diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index cfee7b8ad10..555e19175e7 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -81,7 +81,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalClipboardOnSelect: false, terminalAllowOsc52Clipboard: false, setupScriptLaunchMode: 'split-vertical', - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: 5_000, localAccountRuntime: 'host', localAccountWslDistro: null, openLinksInApp: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 1f52a6470a0..ad5b5976a58 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -85,7 +85,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalClipboardOnSelect: false, terminalAllowOsc52Clipboard: false, setupScriptLaunchMode: 'split-vertical', - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: 5_000, localAccountRuntime: 'host', localAccountWslDistro: null, openLinksInApp: false, diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 2a166a4c5a1..540079a510e 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -285,6 +285,27 @@ describe('registerSettingsHandlers', () => { ) }) + it('normalizes terminal scrollback row updates and drops legacy byte updates', async () => { + store.getSettings.mockReturnValue({ terminalScrollbackRows: 5_000 }) + store.updateSettings.mockReturnValue({ terminalScrollbackRows: 50_000 }) + registerSettingsHandlers(store as never) + + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { + terminalScrollbackRows: 75_000, + terminalScrollbackBytes: 250_000_000 + }) + + expect(store.updateSettings).toHaveBeenCalledWith( + { terminalScrollbackRows: 50_000 }, + { notifyListeners: true, originWebContentsId: 1 } + ) + }) + it('normalizes custom terminal themes from renderer settings IPC', async () => { store.getSettings.mockReturnValue({ terminalCustomThemes: [] }) store.updateSettings.mockReturnValue({ terminalCustomThemes: [] }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 585a3a6ee3c..712f49f6d43 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -17,6 +17,7 @@ import { normalizeAppIconId } from '../../shared/app-icon' import { normalizeUiLanguage } from '../../shared/ui-language' import { applyAppIcon } from '../app-icon' import { normalizeTerminalCustomThemes } from '../../shared/terminal-custom-themes' +import { normalizeDesktopTerminalScrollbackRows } from '../../shared/terminal-scrollback-policy' import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation' import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' @@ -25,6 +26,17 @@ import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base // check stay O(1) without re-coercing the readonly tuple on every call. const SETTINGS_CHANGED_WHITELIST_SET = new Set(SETTINGS_CHANGED_WHITELIST) +type LegacyTerminalScrollbackSettingsUpdate = Partial & { + terminalScrollbackBytes?: unknown +} + +function sanitizeRendererSettingsUpdate(args: Partial): Partial { + const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedArgs } = + args as LegacyTerminalScrollbackSettingsUpdate + void _legacyScrollbackBytes + return sanitizedArgs +} + // Why: fields that appear in the View > Appearance submenu need the menu // rebuilt after any update so the checkbox `checked` state stays in sync // with the persisted value. Electron doesn't reactively re-render menu @@ -55,7 +67,7 @@ export function registerSettingsHandlers( }) ipcMain.handle('settings:set', async (event, args: Partial) => { - const sanitizedArgs = { ...args } + const sanitizedArgs = sanitizeRendererSettingsUpdate(args) // Why: Floating Workspace grants are trusted only when written by the // main-process directory picker, never by renderer-provided settings IPC. delete sanitizedArgs.floatingTerminalTrustedCwds @@ -78,6 +90,11 @@ export function registerSettingsHandlers( if ('terminalCustomThemes' in args) { sanitizedArgs.terminalCustomThemes = normalizeTerminalCustomThemes(args.terminalCustomThemes) } + if ('terminalScrollbackRows' in args) { + sanitizedArgs.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( + args.terminalScrollbackRows + ) + } if ('uiLanguage' in args) { sanitizedArgs.uiLanguage = normalizeUiLanguage(args.uiLanguage) } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index c48cf0c47d7..b016e679b61 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3858,6 +3858,102 @@ describe('Store', () => { expect(listener).not.toHaveBeenCalled() }) + it('migrates missing terminal scrollback rows to the row default and writes back rows only', async () => { + writeDataFile({ settings: {} }) + + const store = await createStore() + + expect(store.getSettings().terminalScrollbackRows).toBe(5_000) + + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings?.terminalScrollbackRows).toBe(5_000) + expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') + }) + + it('migrates legacy terminal scrollback byte presets by intent', async () => { + writeDataFile({ + settings: { + terminalScrollbackBytes: 25_000_000 + } + }) + + const store = await createStore() + + expect(store.getSettings().terminalScrollbackRows).toBe(10_000) + + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings?.terminalScrollbackRows).toBe(10_000) + expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') + }) + + it('lets persisted terminal scrollback rows win over legacy bytes', async () => { + writeDataFile({ + settings: { + terminalScrollbackRows: 25_000, + terminalScrollbackBytes: 100_000_000 + } + }) + + const store = await createStore() + + expect(store.getSettings().terminalScrollbackRows).toBe(25_000) + + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings?.terminalScrollbackRows).toBe(25_000) + expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') + }) + + it('normalizes invalid and clamped terminal scrollback rows on load', async () => { + writeDataFile({ + settings: { + terminalScrollbackRows: '50000' + } + }) + + const invalidStore = await createStore() + expect(invalidStore.getSettings().terminalScrollbackRows).toBe(5_000) + invalidStore.flush() + + writeDataFile({ + settings: { + terminalScrollbackRows: 75_000 + } + }) + + const clampedStore = await createStore() + expect(clampedStore.getSettings().terminalScrollbackRows).toBe(50_000) + }) + + it('normalizes terminal scrollback row updates and ignores stale byte updates', async () => { + const store = await createStore() + const listener = vi.fn() + store.onSettingsChanged(listener) + + const updated = store.updateSettings( + { + terminalScrollbackRows: 75_000, + terminalScrollbackBytes: 250_000_000 + } as never, + { notifyListeners: true } + ) + + expect(updated.terminalScrollbackRows).toBe(50_000) + expect(listener).toHaveBeenCalledWith( + { terminalScrollbackRows: 50_000 }, + expect.objectContaining({ terminalScrollbackRows: 50_000 }), + undefined + ) + + store.updateSettings({ terminalScrollbackBytes: 10_000_000 } as never) + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings?.terminalScrollbackRows).toBe(50_000) + expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes') + }) + it('normalizes disabled TUI agents on load and update', async () => { writeFileSync( join(testState.dir, 'orca-data.json'), diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e6bca9b3119..99ebeb1f792 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -132,6 +132,10 @@ import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order' import { normalizeAppIconId } from '../shared/app-icon' import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes' +import { + legacyTerminalScrollbackBytesToRows, + normalizeDesktopTerminalScrollbackRows +} from '../shared/terminal-scrollback-policy' import { compareFeatureInteractionUsageBuckets, getFeatureInteractionCategory, @@ -392,6 +396,46 @@ function buildWorkspaceDirHistoryForUpdate( return next } +type LegacyTerminalScrollbackSettings = { + terminalScrollbackRows?: unknown + terminalScrollbackBytes?: unknown +} + +function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminalScrollbackSettings { + return settings && typeof settings === 'object' + ? (settings as LegacyTerminalScrollbackSettings) + : {} +} + +function stripLegacyTerminalScrollbackBytes( + settings: Partial | undefined +): Partial { + const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ?? + {}) as Partial & { terminalScrollbackBytes?: unknown } + void _legacyScrollbackBytes + return rest +} + +function migrateTerminalScrollbackRows(settings: unknown): { + rows: number + needsSave: boolean +} { + const legacySettings = readLegacyTerminalScrollbackSettings(settings) + const hasRows = Object.prototype.hasOwnProperty.call(legacySettings, 'terminalScrollbackRows') + const hasLegacyBytes = Object.prototype.hasOwnProperty.call( + legacySettings, + 'terminalScrollbackBytes' + ) + const rows = hasRows + ? normalizeDesktopTerminalScrollbackRows(legacySettings.terminalScrollbackRows) + : legacyTerminalScrollbackBytesToRows(legacySettings.terminalScrollbackBytes) + + return { + rows, + needsSave: !hasRows || hasLegacyBytes || legacySettings.terminalScrollbackRows !== rows + } +} + function getWorkspaceLayoutHistoryKey(layout: OrcaWorkspaceLayout): string { return `${normalizeRuntimePathForComparison(layout.path)}:${layout.nestWorkspaces}` } @@ -2536,6 +2580,10 @@ export class Store { // Merge with defaults in case new fields were added const homeDir = homedir() const defaults = getDefaultPersistedState(homeDir) + const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) + if (migratedTerminalScrollback.needsSave) { + this.loadNeedsSave = true + } const rawSourceControlAi = parsed.settings?.sourceControlAi const rawSourceControlAiMissing = rawSourceControlAi === undefined const rawSourceControlAiActionsMissing = @@ -2758,7 +2806,7 @@ export class Store { ), settings: { ...defaults.settings, - ...parsed.settings, + ...stripLegacyTerminalScrollbackBytes(parsed.settings), // Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry // the old persisted flag forward once so enabled users don't lose it. experimentalPet: @@ -2794,6 +2842,7 @@ export class Store { floatingTerminalCwd: migratedFloatingTerminalCwd, floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, floatingTerminalCwdMigratedToAppWorkspace: true, + terminalScrollbackRows: migratedTerminalScrollback.rows, terminalQuickCommands: normalizeTerminalQuickCommands( parsed.settings?.terminalQuickCommands ), @@ -4636,7 +4685,7 @@ export class Store { updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { - const sanitizedUpdates = { ...updates } + const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) // Why: coerce strictly to boolean here (not at the IPC edge) so every write // path is covered and a non-bool renderer payload can never persist a // truthy non-bool that later reads as "tray-minimize on". @@ -4664,6 +4713,11 @@ export class Store { updates.terminalCustomThemes ) } + if ('terminalScrollbackRows' in updates) { + sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( + updates.terminalScrollbackRows + ) + } if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { const taskProviderSettings = normalizeTaskProviderSettings({ visibleTaskProviders: diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 3aa6e1d8fca..16af369a9e4 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -14,7 +14,7 @@ import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-pref import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import { applyDocumentTheme } from '@/lib/document-theme' import { useConfirmationDialog } from '@/components/confirmation-dialog' -import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants' +import { SCROLLBACK_PRESETS_ROWS, getFallbackTerminalFonts } from './SettingsConstants' import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { GeneralPane } from './GeneralPane' @@ -294,7 +294,7 @@ function Settings(): React.JSX.Element { // sidebar. We trim platform-only entries on other platforms so search never // reveals controls that the renderer will intentionally hide. const [scrollbackMode, setScrollbackMode] = useState<'preset' | 'custom'>('preset') - const [prevScrollbackBytes, setPrevScrollbackBytes] = useState(settings?.terminalScrollbackBytes) + const [prevScrollbackRows, setPrevScrollbackRows] = useState(settings?.terminalScrollbackRows) // Why: Appearance owns terminal visual controls, but the Ghostty import flow // still needs Settings-level state so the modal survives section remounts. const ghostty = useGhosttyImport(updateSettings, settings) @@ -578,14 +578,15 @@ function Settings(): React.JSX.Element { clearSettingsTarget() }, [clearSettingsTarget, settings, settingsNavigationTarget]) - // Why: only recompute scrollback mode when the byte value actually changes, + // Why: only recompute scrollback mode when the row value actually changes, // not on every unrelated settings mutation. - if (settings?.terminalScrollbackBytes !== prevScrollbackBytes) { - setPrevScrollbackBytes(settings?.terminalScrollbackBytes) + if (settings?.terminalScrollbackRows !== prevScrollbackRows) { + setPrevScrollbackRows(settings?.terminalScrollbackRows) if (settings) { - const scrollbackMb = Math.max(1, Math.round(settings.terminalScrollbackBytes / 1_000_000)) setScrollbackMode( - SCROLLBACK_PRESETS_MB.includes(scrollbackMb as (typeof SCROLLBACK_PRESETS_MB)[number]) + SCROLLBACK_PRESETS_ROWS.includes( + settings.terminalScrollbackRows as (typeof SCROLLBACK_PRESETS_ROWS)[number] + ) ? 'preset' : 'custom' ) diff --git a/src/renderer/src/components/settings/SettingsConstants.ts b/src/renderer/src/components/settings/SettingsConstants.ts index 17254f8b039..47fb0ba281a 100644 --- a/src/renderer/src/components/settings/SettingsConstants.ts +++ b/src/renderer/src/components/settings/SettingsConstants.ts @@ -1,10 +1,11 @@ import type { OrcaHooks } from '../../../../shared/types' import { getDefaultRepoHookSettings } from '../../../../shared/constants' +import { DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS } from '../../../../shared/terminal-scrollback-policy' export type HookName = keyof OrcaHooks['scripts'] export const DEFAULT_REPO_HOOK_SETTINGS = getDefaultRepoHookSettings() export const MAX_THEME_RESULTS = 80 -export const SCROLLBACK_PRESETS_MB = [10, 25, 50, 100, 250] as const +export const SCROLLBACK_PRESETS_ROWS = DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS export const ZOOM_STEP = 0.5 export const ZOOM_MIN = -3 export const ZOOM_MAX = 5 diff --git a/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx b/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx new file mode 100644 index 00000000000..a9f361d7ce3 --- /dev/null +++ b/src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/types' +import { TerminalAdvancedSection } from './TerminalAdvancedSection' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, defaultValue: string) => defaultValue +})) + +describe('TerminalAdvancedSection scrollback rows', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() + }) + + function renderSection(updateSettings = vi.fn()): void { + act(() => { + root.render( + + ) + }) + } + + function getScrollbackRowsInput(): HTMLInputElement { + const input = container.querySelector('input[type="number"]') + if (!input) { + throw new Error('scrollback rows input not found') + } + return input + } + + function setNativeValue(input: HTMLInputElement, text: string): void { + // Why: React reads controlled-input changes through the native value setter. + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setValue?.call(input, text) + } + + function typeText(input: HTMLInputElement, text: string): void { + act(() => { + setNativeValue(input, text) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + } + + function blurInput(input: HTMLInputElement): void { + act(() => { + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + }) + } + + function pressEnter(input: HTMLInputElement): void { + act(() => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + } + + it('keeps custom row edits local until blur', () => { + const updateSettings = vi.fn() + renderSection(updateSettings) + + const input = getScrollbackRowsInput() + typeText(input, '2') + expect(input.value).toBe('2') + typeText(input, '25') + expect(input.value).toBe('25') + expect(updateSettings).not.toHaveBeenCalled() + + blurInput(input) + + expect(updateSettings).toHaveBeenCalledWith({ terminalScrollbackRows: 1000 }) + expect(input.value).toBe('1000') + }) + + it('commits the normalized custom rows on Enter', () => { + const updateSettings = vi.fn() + renderSection(updateSettings) + + const input = getScrollbackRowsInput() + typeText(input, '12345.9') + pressEnter(input) + + expect(updateSettings).toHaveBeenCalledWith({ terminalScrollbackRows: 12345 }) + expect(input.value).toBe('12345') + }) +}) diff --git a/src/renderer/src/components/settings/TerminalAdvancedSection.tsx b/src/renderer/src/components/settings/TerminalAdvancedSection.tsx index 2f9b5687b0c..27d29ed4241 100644 --- a/src/renderer/src/components/settings/TerminalAdvancedSection.tsx +++ b/src/renderer/src/components/settings/TerminalAdvancedSection.tsx @@ -1,13 +1,18 @@ +import { useState } from 'react' import type { GlobalSettings } from '../../../../shared/types' +import { + DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX, + DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN, + normalizeDesktopTerminalScrollbackRows +} from '../../../../shared/terminal-scrollback-policy' import { Input } from '../ui/input' import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group' -import { clampNumber } from '@/lib/terminal-theme' import { SettingsRow, SettingsSegmentedControl, SettingsSubsectionHeader } from './SettingsFormControls' -import { SCROLLBACK_PRESETS_MB } from './SettingsConstants' +import { SCROLLBACK_PRESETS_ROWS } from './SettingsConstants' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' import { getTerminalWindowsPowershellImplementationSearchEntry } from './terminal-windows-search' @@ -25,6 +30,10 @@ type TerminalAdvancedSectionProps = { isMac: boolean } +function formatScrollbackRowsLabel(rows: number): string { + return rows % 1_000 === 0 ? `${rows / 1_000}k` : String(rows) +} + export function TerminalAdvancedSection({ settings, updateSettings, @@ -35,13 +44,33 @@ export function TerminalAdvancedSection({ pwshAvailable, isMac }: TerminalAdvancedSectionProps): React.JSX.Element { - const scrollbackMb = Math.max(1, Math.round(settings.terminalScrollbackBytes / 1_000_000)) - const isPreset = SCROLLBACK_PRESETS_MB.includes( - scrollbackMb as (typeof SCROLLBACK_PRESETS_MB)[number] + const scrollbackRows = normalizeDesktopTerminalScrollbackRows(settings.terminalScrollbackRows) + const [scrollbackRowsDraft, setScrollbackRowsDraft] = useState(String(scrollbackRows)) + const [prevScrollbackRows, setPrevScrollbackRows] = useState(scrollbackRows) + if (scrollbackRows !== prevScrollbackRows) { + // Why: settings can update outside this pane, so the draft follows the + // persisted source once it changes instead of clobbering active edits. + setPrevScrollbackRows(scrollbackRows) + setScrollbackRowsDraft(String(scrollbackRows)) + } + const isPreset = SCROLLBACK_PRESETS_ROWS.includes( + scrollbackRows as (typeof SCROLLBACK_PRESETS_ROWS)[number] ) const scrollbackToggleValue = - scrollbackMode === 'custom' ? 'custom' : isPreset ? `${scrollbackMb}` : 'custom' + scrollbackMode === 'custom' ? 'custom' : isPreset ? `${scrollbackRows}` : 'custom' const powerShellImplementation = settings.terminalWindowsPowerShellImplementation ?? 'auto' + const commitScrollbackRowsDraft = (): void => { + const trimmed = scrollbackRowsDraft.trim() + const value = Number(trimmed) + if (trimmed === '' || !Number.isFinite(value)) { + setScrollbackRowsDraft(String(scrollbackRows)) + return + } + + const next = normalizeDesktopTerminalScrollbackRows(value) + updateSettings({ terminalScrollbackRows: next }) + setScrollbackRowsDraft(String(next)) + } return (
@@ -55,19 +84,19 @@ export function TerminalAdvancedSection({
@@ -85,25 +114,25 @@ export function TerminalAdvancedSection({ setScrollbackMode('preset') updateSettings({ - terminalScrollbackBytes: Number(value) * 1_000_000 + terminalScrollbackRows: normalizeDesktopTerminalScrollbackRows(Number(value)) }) }} variant="outline" size="sm" className="h-8 flex-wrap justify-end" > - {SCROLLBACK_PRESETS_MB.map((preset) => ( + {SCROLLBACK_PRESETS_ROWS.map((preset) => ( - {preset} {translate('auto.components.settings.TerminalPane.12e06178fa', 'MB')} + {formatScrollbackRowsLabel(preset)} ))} { - const value = Number(e.target.value) - if (Number.isFinite(value)) { - updateSettings({ - terminalScrollbackBytes: clampNumber(value, 1, 256) * 1_000_000 - }) + min={DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN} + max={DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX} + step={100} + value={scrollbackRowsDraft} + onChange={(e) => setScrollbackRowsDraft(e.target.value)} + onBlur={commitScrollbackRowsDraft} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commitScrollbackRowsDraft() } }} className="number-input-clean w-24 tabular-nums" /> - {translate('auto.components.settings.TerminalPane.12e06178fa', 'MB')} + {translate('auto.components.settings.TerminalPane.12e06178fa', 'rows')}
) : null} diff --git a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts index b796c0c9d32..07050938229 100644 --- a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts +++ b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts @@ -280,7 +280,7 @@ describe('TerminalPane PowerShell version setting', () => { it('shows the PowerShell 7+ download link when pwsh is unavailable', () => { const element = TerminalPane({ settings: { - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: 5_000, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', terminalWordSeparator: '' @@ -302,7 +302,7 @@ describe('TerminalPane PowerShell version setting', () => { it('does not show WSL as a Windows default shell option when available', () => { const element = TerminalPane({ settings: { - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: 5_000, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto', terminalWordSeparator: '' @@ -325,7 +325,7 @@ describe('TerminalPane PowerShell version setting', () => { it('shows Windows shell controls for a remote Windows host on a non-Windows client', () => { const element = TerminalPane({ settings: { - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: 5_000, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto', terminalWordSeparator: '' @@ -349,7 +349,7 @@ describe('TerminalPane PowerShell version setting', () => { it('hides WSL as a Windows default shell option when unavailable', () => { const element = TerminalPane({ settings: { - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: 5_000, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto', terminalWordSeparator: '' @@ -368,7 +368,7 @@ describe('TerminalPane PowerShell version setting', () => { it('does not show WSL distro choices for a persisted legacy WSL shell', () => { const element = TerminalPane({ settings: { - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: 5_000, terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian', terminalWindowsPowerShellImplementation: 'auto', @@ -395,7 +395,7 @@ describe('TerminalPane PowerShell version setting', () => { it('shows Git Bash as a Windows default shell option when bash.exe is detected', () => { const element = TerminalPane({ settings: { - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: 5_000, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto', terminalWordSeparator: '' @@ -415,7 +415,7 @@ describe('TerminalPane PowerShell version setting', () => { it('hides Git Bash as a Windows default shell option when not detected', () => { const element = TerminalPane({ settings: { - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: 5_000, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto', terminalWordSeparator: '' diff --git a/src/renderer/src/components/settings/terminal-advanced-platform-search.ts b/src/renderer/src/components/settings/terminal-advanced-platform-search.ts index ae99099c91b..e686b51d37c 100644 --- a/src/renderer/src/components/settings/terminal-advanced-platform-search.ts +++ b/src/renderer/src/components/settings/terminal-advanced-platform-search.ts @@ -4,10 +4,10 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog' export const getTerminalAdvancedSearchEntries = createLocalizedCatalog(() => [ { - title: translate('auto.components.settings.terminal.search.7674e758e1', 'Scrollback Size'), + title: translate('auto.components.settings.terminal.search.7674e758e1', 'Scrollback Rows'), description: translate( 'auto.components.settings.terminal.search.f7d56b6281', - 'Maximum terminal scrollback buffer size.' + 'Retained desktop terminal rows.' ), keywords: [ ...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'), @@ -15,6 +15,7 @@ export const getTerminalAdvancedSearchEntries = createLocalizedCatalog(() => [ 'auto.components.settings.terminal.search.cde233f5da', 'scrollback' ), + ...translateSearchKeyword('auto.components.settings.terminal.search.rows', 'rows'), ...translateSearchKeyword('auto.components.settings.terminal.search.fffdff40a7', 'buffer'), ...translateSearchKeyword('auto.components.settings.terminal.search.56fff3d113', 'memory') ] diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 60ff53fef0b..4d4d9e05ec8 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -72,6 +72,15 @@ describe('getTerminalPaneSearchEntries', () => { expect(entriesLinux.some((entry) => entry.title === 'Manage Sessions')).toBe(true) }) + it('indexes terminal scrollback as rows rather than MB size', () => { + const entries = getTerminalPaneSearchEntries({ isWindows: false, isMac: false }) + const scrollbackEntry = entries.find((entry) => entry.title === 'Scrollback Rows') + + expect(scrollbackEntry).toBeDefined() + expect(matchesSettingsSearch('rows', [scrollbackEntry!])).toBe(true) + expect(entries.some((entry) => entry.title === 'Scrollback Size')).toBe(false) + }) + it('includes the OSC 52 clipboard setting on all platforms', () => { const entriesWindows = getTerminalPaneSearchEntries({ isWindows: true, isMac: false }) const entriesMac = getTerminalPaneSearchEntries({ isWindows: false, isMac: true }) diff --git a/src/renderer/src/components/settings/useGhosttyImport.test.ts b/src/renderer/src/components/settings/useGhosttyImport.test.ts index cb4c21d5426..26326b99286 100644 --- a/src/renderer/src/components/settings/useGhosttyImport.test.ts +++ b/src/renderer/src/components/settings/useGhosttyImport.test.ts @@ -13,7 +13,7 @@ const baseSettings: GlobalSettings = { terminalGpuAcceleration: 'auto', terminalCursorStyle: 'bar', terminalCursorBlink: true, - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: 5_000, terminalBackgroundOpacity: 1, terminalInactivePaneOpacity: 1, terminalPaddingX: 0, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index 049312db69e..c4dccba82c2 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + applyTerminalScrollbackRowsToMountedPanes, mapRestoredPaneTitlesByPaneId, scheduleVisibilityReconcilePass, shouldDetachPaneTransportOnUnmount, @@ -80,6 +81,36 @@ describe('splitPaneWithOneShotStartup', () => { }) }) +describe('applyTerminalScrollbackRowsToMountedPanes', () => { + it('updates mounted pane xterm scrollback options only when needed', () => { + const firstOptions = { scrollback: 1_000 } + const secondOptions = { scrollback: 5_000 } + const firstTerminal = { options: firstOptions } + let secondWrites = 0 + const secondTerminal = { + options: { + get scrollback() { + return secondOptions.scrollback + }, + set scrollback(value: number | undefined) { + secondWrites += 1 + secondOptions.scrollback = value ?? 0 + } + } + } + const manager = { + getPanes: vi.fn(() => [{ terminal: firstTerminal }, { terminal: secondTerminal }]) + } + + applyTerminalScrollbackRowsToMountedPanes(manager, 5_000) + + expect(firstTerminal.options.scrollback).toBe(5_000) + expect(secondOptions.scrollback).toBe(5_000) + expect(secondWrites).toBe(0) + expect(manager.getPanes).toHaveBeenCalledTimes(1) + }) +}) + describe('shouldDetachPaneTransportOnUnmount', () => { it('detaches when the tab still owns the transport PTY', () => { expect( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index b1f19e1dc3c..3973eea820c 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -9,6 +9,7 @@ import { normalizeTerminalScrollSensitivity, resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' +import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' import { useAppStore } from '@/store' @@ -110,6 +111,21 @@ export function recordRuntimeCreatedTerminalPaneSplit( return recordCreatedTerminalPaneSplit(createdPane, args) } +type TerminalScrollbackPaneManager = { + getPanes(): { terminal: Pick }[] +} + +export function applyTerminalScrollbackRowsToMountedPanes( + manager: TerminalScrollbackPaneManager, + rows: number +): void { + for (const pane of manager.getPanes()) { + if (pane.terminal.options.scrollback !== rows) { + pane.terminal.options.scrollback = rows + } + } +} + function extractUncHost(value: string | undefined): string | null { const match = /^(?:\\\\|\/\/)([^\\/]+)/.exec(value ?? '') return match?.[1] || null @@ -440,6 +456,9 @@ export function useTerminalPaneLifecycle({ setPaneCount, setPaneLayoutRevision }: UseTerminalPaneLifecycleDeps): void { + const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( + settings?.terminalScrollbackRows + ) const systemPrefersDarkRef = useRef(systemPrefersDark) systemPrefersDarkRef.current = systemPrefersDark const linkProviderDisposablesRef = useRef(new Map()) @@ -1174,12 +1193,8 @@ export function useTerminalPaneLifecycle({ fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? ''), fontWeight: terminalFontWeights.fontWeight, fontWeightBold: terminalFontWeights.fontWeightBold, - scrollback: Math.min( - 50_000, - Math.max( - 1000, - Math.round((currentSettings?.terminalScrollbackBytes ?? 10_000_000) / 200) - ) + scrollback: normalizeDesktopTerminalScrollbackRows( + currentSettings?.terminalScrollbackRows ), cursorStyle, cursorInactiveStyle: resolveTerminalCursorInactiveStyle(cursorStyle), @@ -1568,6 +1583,16 @@ export function useTerminalPaneLifecycle({ managerRef.current?.setTerminalGpuAcceleration(settings?.terminalGpuAcceleration ?? 'auto') }, [settings?.terminalGpuAcceleration, managerRef]) + useEffect(() => { + const manager = managerRef.current + if (!manager) { + return + } + // Why: live row retention changes are xterm option updates only; they must + // not recreate panes, replay snapshots, refit, resize, or signal the PTY. + applyTerminalScrollbackRowsToMountedPanes(manager, terminalScrollbackRows) + }, [managerRef, terminalScrollbackRows]) + useEffect(() => { const manager = managerRef.current if (!manager) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4d5a98689b4..0c03e01db57 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6357,12 +6357,12 @@ "3d88af864d": "Choose whether the PowerShell shell option launches Windows PowerShell or PowerShell 7+ for new terminal panes.", "8a956cc91e": "Characters treated as word boundaries for double-click selection.", "4bebcc2b2c": "Word Separators", - "12e06178fa": "MB", + "12e06178fa": "rows", "907b0b9d3e": "Custom", - "5336c096af": "{{value0}} megabytes", - "81d86b2dd2": "Maximum terminal scrollback buffer size for new terminal panes.", - "9df53f7c14": "Scrollback Size", - "c3810b2b42": "Maximum terminal scrollback buffer size.", + "5336c096af": "{{value0}} rows", + "81d86b2dd2": "Retained desktop terminal rows for new and open panes.", + "9df53f7c14": "Scrollback Rows", + "c3810b2b42": "Retained desktop terminal rows.", "267d020745": "Scrollback, word boundaries, and platform-specific terminal behaviors.", "5e5f06c82c": "Advanced", "003df129fe": "Split Horizontally", @@ -8007,8 +8007,9 @@ "957a0203fc": "Word Separators", "56fff3d113": "memory", "fffdff40a7": "buffer", - "f7d56b6281": "Maximum terminal scrollback buffer size.", - "7674e758e1": "Scrollback Size", + "rows": "rows", + "f7d56b6281": "Retained desktop terminal rows.", + "7674e758e1": "Scrollback Rows", "411229c636": "light", "781f49d942": "divider", "77d9f9cd55": "Controls the split divider line between panes in light mode.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 62782b7c191..0c85b5bde32 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -6320,12 +6320,12 @@ "3d88af864d": "Elija si la opción de shell de PowerShell inicia Windows PowerShell o PowerShell 7+ para nuevos paneles de terminal.", "8a956cc91e": "Caracteres tratados como límites de palabras para la selección con doble clic.", "4bebcc2b2c": "Separadores de palabras", - "12e06178fa": "MEGABYTE", + "12e06178fa": "filas", "907b0b9d3e": "Costumbre", - "5336c096af": "{{value0}} megabytes", - "81d86b2dd2": "Tamaño máximo del búfer de desplazamiento hacia atrás del terminal para nuevos paneles de terminal.", - "9df53f7c14": "Tamaño de desplazamiento hacia atrás", - "c3810b2b42": "Tamaño máximo del búfer de desplazamiento hacia atrás del terminal.", + "5336c096af": "{{value0}} filas", + "81d86b2dd2": "Filas de terminal de escritorio retenidas para paneles nuevos y abiertos.", + "9df53f7c14": "Filas de desplazamiento hacia atrás", + "c3810b2b42": "Filas de terminal de escritorio retenidas.", "267d020745": "Desplazamiento hacia atrás, límites de palabras y comportamientos de terminal específicos de la plataforma.", "5e5f06c82c": "Avanzado", "003df129fe": "Dividir horizontalmente", @@ -7956,8 +7956,9 @@ "957a0203fc": "Separadores de palabras", "56fff3d113": "memoria", "fffdff40a7": "buffer", - "f7d56b6281": "Tamaño máximo del búfer de desplazamiento hacia atrás del terminal.", - "7674e758e1": "Tamaño de desplazamiento hacia atrás", + "rows": "filas", + "f7d56b6281": "Filas de terminal de escritorio retenidas.", + "7674e758e1": "Filas de desplazamiento hacia atrás", "411229c636": "luz", "781f49d942": "divisor", "77d9f9cd55": "Controla la línea divisoria dividida entre paneles en modo claro.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c23ac80c5ae..a801f65bd99 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6342,12 +6342,12 @@ "3d88af864d": "PowerShell シェル オプションで、新規 terminal ペインに対して Windows PowerShell を起動するか PowerShell 7+ を起動するかを選択します。", "8a956cc91e": "文字はダブルクリック選択の単語境界として扱われます。", "4bebcc2b2c": "単語の区切り文字", - "12e06178fa": "MB", + "12e06178fa": "行", "907b0b9d3e": "カスタム", - "5336c096af": "{{value0}}メガバイト", - "81d86b2dd2": "新規 terminal ペインの最大 terminal スクロールバック バッファ サイズ。", - "9df53f7c14": "スクロールバックのサイズ", - "c3810b2b42": "terminal スクロールバック バッファの最大サイズ。", + "5336c096af": "{{value0}} 行", + "81d86b2dd2": "新規および開いているペインで保持するデスクトップ terminal 行数。", + "9df53f7c14": "スクロールバック行数", + "c3810b2b42": "保持するデスクトップ terminal 行数。", "267d020745": "スクロールバック、単語境界、およびプラットフォーム固有の terminal 動作。", "5e5f06c82c": "詳細設定", "003df129fe": "水平方向に分割", @@ -7992,8 +7992,9 @@ "957a0203fc": "単語の区切り文字", "56fff3d113": "メモリ", "fffdff40a7": "バッファ", - "f7d56b6281": "terminal スクロールバック バッファの最大サイズ。", - "7674e758e1": "スクロールバックのサイズ", + "rows": "行", + "f7d56b6281": "保持するデスクトップ terminal 行数。", + "7674e758e1": "スクロールバック行数", "411229c636": "ライト", "781f49d942": "ディバイダー", "77d9f9cd55": "ライト モードでペイン間の分割分割線を制御します。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4d9a3fc7f5c..615cb7c3f0b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6305,12 +6305,12 @@ "3d88af864d": "PowerShell 셸 옵션이 새 terminal 패널에 대해 Windows PowerShell을 시작할지 아니면 PowerShell 7+를 시작할지 선택합니다.", "8a956cc91e": "두 번 클릭 선택 시 단어 경계로 처리되는 문자입니다.", "4bebcc2b2c": "단어 구분 기호", - "12e06178fa": "MB", + "12e06178fa": "행", "907b0b9d3e": "사용자 지정", - "5336c096af": "{{value0}} 메가바이트", - "81d86b2dd2": "새 terminal 패널의 최대 terminal 스크롤백 버퍼 크기입니다.", - "9df53f7c14": "스크롤백 크기", - "c3810b2b42": "최대 terminal 스크롤백 버퍼 크기.", + "5336c096af": "{{value0}} 행", + "81d86b2dd2": "새 패널과 열린 패널에 보관할 데스크톱 terminal 행입니다.", + "9df53f7c14": "스크롤백 행", + "c3810b2b42": "보관할 데스크톱 terminal 행입니다.", "267d020745": "스크롤백, 단어 경계 및 플랫폼별 terminal 동작.", "5e5f06c82c": "고급", "003df129fe": "수평으로 분할", @@ -7941,8 +7941,9 @@ "957a0203fc": "단어 구분 기호", "56fff3d113": "메모리", "fffdff40a7": "버퍼", - "f7d56b6281": "최대 terminal 스크롤백 버퍼 크기입니다.", - "7674e758e1": "스크롤백 크기", + "rows": "행", + "f7d56b6281": "보관할 데스크톱 terminal 행입니다.", + "7674e758e1": "스크롤백 행", "411229c636": "라이트", "781f49d942": "분할기", "77d9f9cd55": "라이트 모드에서 창 사이의 분할 구분선을 제어합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4cdc49483d0..d292efcc174 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6305,12 +6305,12 @@ "3d88af864d": "选择 PowerShell shell 选项是为新终端窗格启动 Windows PowerShell 还是 PowerShell 7+。", "8a956cc91e": "双击选择时将字符视为单词边界。", "4bebcc2b2c": "单词分隔符", - "12e06178fa": "MB", + "12e06178fa": "行", "907b0b9d3e": "自定义", - "5336c096af": "{{value0}} 兆字节", - "81d86b2dd2": "新终端窗格的最大终端回滚缓冲区大小。", - "9df53f7c14": "回滚大小", - "c3810b2b42": "最大终端回滚缓冲区大小。", + "5336c096af": "{{value0}} 行", + "81d86b2dd2": "为新窗格和已打开窗格保留的桌面终端行数。", + "9df53f7c14": "回滚行数", + "c3810b2b42": "保留的桌面终端行数。", "267d020745": "回滚、字边界和特定于平台的终端行为。", "5e5f06c82c": "高级", "003df129fe": "水平分割", @@ -7941,8 +7941,9 @@ "957a0203fc": "单词分隔符", "56fff3d113": "记忆", "fffdff40a7": "缓冲", - "f7d56b6281": "最大终端回滚缓冲区大小。", - "7674e758e1": "回滚大小", + "rows": "行", + "f7d56b6281": "保留的桌面终端行数。", + "7674e758e1": "回滚行数", "411229c636": "浅色", "781f49d942": "分隔线", "77d9f9cd55": "控制浅色模式下窗格之间的分割分隔线。", diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index f6a2e367de0..5899f3f9236 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -88,6 +88,10 @@ describe('buildDefaultTerminalOptions', () => { expect(buildDefaultTerminalOptions().scrollbar?.width).toBe(7) }) + it('uses the shared desktop scrollback row default', () => { + expect(buildDefaultTerminalOptions().scrollback).toBe(5_000) + }) + it('slightly increases default terminal wheel scrolling while preserving fast scroll', () => { const options = buildDefaultTerminalOptions() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 8c154468331..89bbaf4b118 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -1,4 +1,5 @@ import type { ITerminalOptions } from '@xterm/xterm' +import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../../../shared/terminal-scrollback-policy' type TerminalCursorStyle = NonNullable type TerminalCursorInactiveStyle = NonNullable @@ -40,7 +41,7 @@ export function buildDefaultTerminalOptions(): ITerminalOptions { '"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", monospace', fontWeight: '300', fontWeightBold: '500', - scrollback: 10000, + scrollback: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, // Why: Orca's default terminal cells are taller than many users' baseline // terminal, so a small multiplier keeps row-per-wheel movement familiar. scrollSensitivity: DEFAULT_TERMINAL_SCROLL_SENSITIVITY, diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index f7b05e003f3..f9d4d4f3699 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -21,6 +21,7 @@ import { } from '../../../../shared/tui-agent-launch-defaults' import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context' import { normalizeUiLanguage } from '../../../../shared/ui-language' +import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' import { translate } from '@/i18n/i18n' export type SettingsSlice = SettingsSearchState & { @@ -30,6 +31,10 @@ export type SettingsSlice = SettingsSearchState & { switchRuntimeEnvironment: (environmentId: string | null) => Promise } +type LegacyTerminalScrollbackSettingsUpdate = Partial & { + terminalScrollbackBytes?: unknown +} + function normalizeRuntimeEnvironmentId(value: string | null | undefined): string | null { const trimmed = value?.trim() return trimmed ? trimmed : null @@ -76,7 +81,9 @@ export const createSettingsSlice: StateCreator updateSettings: async (updates) => { try { - const sanitizedUpdates = { ...updates } + const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedUpdates } = + updates as LegacyTerminalScrollbackSettingsUpdate + void _legacyScrollbackBytes if ('terminalQuickCommands' in updates) { sanitizedUpdates.terminalQuickCommands = normalizeTerminalQuickCommands( updates.terminalQuickCommands @@ -123,6 +130,11 @@ export const createSettingsSlice: StateCreator if ('uiLanguage' in updates) { sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage) } + if ('terminalScrollbackRows' in updates) { + sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( + updates.terminalScrollbackRows + ) + } const nextSettings = await window.api.settings.set(sanitizedUpdates) set((s) => ({ settings: (nextSettings as GlobalSettings | undefined) ?? s.settings })) } catch (err) { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index dfb1f660ade..4b03a57271c 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -30,6 +30,7 @@ import { } from './left-sidebar-appearance' import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' +import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' export { @@ -260,7 +261,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalAllowOsc52Clipboard: false, claudeAgentTeamsMode: 'off', setupScriptLaunchMode: 'new-tab', - terminalScrollbackBytes: 10_000_000, + terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, httpProxyUrl: '', httpProxyBypassRules: '', electronHttp1CompatibilityMode: false, diff --git a/src/shared/terminal-scrollback-policy.test.ts b/src/shared/terminal-scrollback-policy.test.ts new file mode 100644 index 00000000000..77bbec61c94 --- /dev/null +++ b/src/shared/terminal-scrollback-policy.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS, + DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, + DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX, + DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN, + legacyTerminalScrollbackBytesToRows, + normalizeDesktopTerminalScrollbackRows, + normalizeDesktopTerminalSnapshotRows +} from './terminal-scrollback-policy' + +describe('terminal scrollback policy', () => { + it('exports the desktop row defaults and presets', () => { + expect(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT).toBe(5_000) + expect(DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN).toBe(1_000) + expect(DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX).toBe(50_000) + expect(DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS).toEqual([5_000, 10_000, 25_000, 50_000]) + }) + + it('normalizes persisted desktop rows without string coercion', () => { + expect(normalizeDesktopTerminalScrollbackRows(undefined)).toBe(5_000) + expect(normalizeDesktopTerminalScrollbackRows('25000')).toBe(5_000) + expect(normalizeDesktopTerminalScrollbackRows(Number.NaN)).toBe(5_000) + expect(normalizeDesktopTerminalScrollbackRows(500.9)).toBe(1_000) + expect(normalizeDesktopTerminalScrollbackRows(25_000.9)).toBe(25_000) + expect(normalizeDesktopTerminalScrollbackRows(100_000)).toBe(50_000) + }) + + it('normalizes snapshot rows while preserving visible-screen-only zero', () => { + expect(normalizeDesktopTerminalSnapshotRows(undefined)).toBeUndefined() + expect(normalizeDesktopTerminalSnapshotRows('0')).toBeUndefined() + expect(normalizeDesktopTerminalSnapshotRows(0)).toBe(0) + expect(normalizeDesktopTerminalSnapshotRows(-1)).toBe(0) + expect(normalizeDesktopTerminalSnapshotRows(25_000.9)).toBe(25_000) + expect(normalizeDesktopTerminalSnapshotRows(100_000)).toBe(50_000) + }) + + it('migrates legacy decimal MB buckets by intent, not byte-to-row math', () => { + expect(legacyTerminalScrollbackBytesToRows(undefined)).toBe(5_000) + expect(legacyTerminalScrollbackBytesToRows(0)).toBe(5_000) + expect(legacyTerminalScrollbackBytesToRows(1_000_000)).toBe(1_000) + expect(legacyTerminalScrollbackBytesToRows(10_000_000)).toBe(5_000) + expect(legacyTerminalScrollbackBytesToRows(25_000_000)).toBe(10_000) + expect(legacyTerminalScrollbackBytesToRows(50_000_000)).toBe(25_000) + expect(legacyTerminalScrollbackBytesToRows(100_000_000)).toBe(50_000) + expect(legacyTerminalScrollbackBytesToRows(250_000_000)).toBe(50_000) + }) +}) diff --git a/src/shared/terminal-scrollback-policy.ts b/src/shared/terminal-scrollback-policy.ts new file mode 100644 index 00000000000..22296036cc8 --- /dev/null +++ b/src/shared/terminal-scrollback-policy.ts @@ -0,0 +1,55 @@ +export const DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT = 5_000 +export const DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN = 1_000 +export const DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX = 50_000 +export const DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS = [5_000, 10_000, 25_000, 50_000] as const + +export const LEGACY_TERMINAL_SCROLLBACK_BYTES_1_MB = 1_000_000 +export const LEGACY_TERMINAL_SCROLLBACK_BYTES_10_MB = 10_000_000 +export const LEGACY_TERMINAL_SCROLLBACK_BYTES_25_MB = 25_000_000 +export const LEGACY_TERMINAL_SCROLLBACK_BYTES_50_MB = 50_000_000 +export const LEGACY_TERMINAL_SCROLLBACK_BYTES_100_MB = 100_000_000 + +export const LEGACY_TERMINAL_SCROLLBACK_BUCKET_5K_MAX_BYTES = 17_500_000 +export const LEGACY_TERMINAL_SCROLLBACK_BUCKET_10K_MAX_BYTES = 37_500_000 +export const LEGACY_TERMINAL_SCROLLBACK_BUCKET_25K_MAX_BYTES = 75_000_000 + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function clampRows(value: number, min: number): number { + return Math.min(DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX, Math.max(min, Math.floor(value))) +} + +export function normalizeDesktopTerminalScrollbackRows(value: unknown): number { + if (!isFiniteNumber(value)) { + return DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT + } + return clampRows(value, DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN) +} + +export function normalizeDesktopTerminalSnapshotRows(value: unknown): number | undefined { + if (!isFiniteNumber(value)) { + return undefined + } + return clampRows(value, 0) +} + +export function legacyTerminalScrollbackBytesToRows(bytes: unknown): number { + if (!isFiniteNumber(bytes) || bytes <= 0) { + return DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT + } + if (bytes <= LEGACY_TERMINAL_SCROLLBACK_BYTES_1_MB) { + return DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN + } + if (bytes < LEGACY_TERMINAL_SCROLLBACK_BUCKET_5K_MAX_BYTES) { + return DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT + } + if (bytes < LEGACY_TERMINAL_SCROLLBACK_BUCKET_10K_MAX_BYTES) { + return 10_000 + } + if (bytes < LEGACY_TERMINAL_SCROLLBACK_BUCKET_25K_MAX_BYTES) { + return 25_000 + } + return DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX +} diff --git a/src/shared/types.ts b/src/shared/types.ts index bbb66e35fa6..b927e002890 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2545,7 +2545,7 @@ export type GlobalSettings = { * background "Setup" tab so the user's main terminal stays immediately * usable without the setup output crowding the initial pane. */ setupScriptLaunchMode: SetupScriptLaunchMode - terminalScrollbackBytes: number + terminalScrollbackRows: number /** Optional app-level proxy for Electron networking and locally spawned PTYs. * Empty preserves system proxy settings plus inherited proxy env behavior. */ httpProxyUrl?: string