From b6c0cb4f628a7d213e864eba775e06363128472b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:13:16 -0700 Subject: [PATCH] Migrate terminal scrollback setting from bytes to rows (#6593) * Migrate terminal scrollback setting from bytes to rows Transition terminal scrollback configuration from a byte-based (MB) limit to a row-based count to align with standard terminal emulator behavior. - Introduce a shared scrollback policy to handle normalization and safe migration of legacy byte presets to row equivalents. - Update persistence logic to automatically convert and clean up legacy settings on startup and write-back. - Refactor the advanced settings interface and localization strings to let users configure scrollback in terms of rows (up to 50k). - Live-apply row limit changes directly to mounted xterm instances without recreating panes or restarting PTY sessions. * Migrate terminal scrollback setting from bytes to rows Transition terminal scrollback configuration from a byte-based (MB) limit to a row-based count to align with standard terminal emulator behavior. - Introduce a shared scrollback policy to handle normalization and safe migration of legacy byte presets to row equivalents. - Update persistence logic to automatically convert and clean up legacy settings on startup and write-back. - Refactor the advanced settings interface and localization strings to let users configure scrollback in terms of rows (up to 50k). - Live-apply row limit changes directly to mounted xterm instances without recreating panes or restarting PTY sessions. * defer updating terminal scrollback rows until blur or Enter Avoid committing terminal scrollback row setting changes on every keystroke, which can trigger rapid updates with incomplete or invalid numbers. Instead, manage a local draft state and commit to settings only when the input is blurred or the user presses Enter. --- .../runtime-home-service.test.ts | 2 +- src/main/codex-accounts/service.test.ts | 2 +- src/main/ipc/settings.test.ts | 21 ++++ src/main/ipc/settings.ts | 19 +++- src/main/persistence.test.ts | 96 ++++++++++++++++ src/main/persistence.ts | 58 +++++++++- .../src/components/settings/Settings.tsx | 15 +-- .../components/settings/SettingsConstants.ts | 3 +- .../settings/TerminalAdvancedSection.test.tsx | 106 ++++++++++++++++++ .../settings/TerminalAdvancedSection.tsx | 80 ++++++++----- .../settings/TerminalPane.pwsh.test.ts | 14 +-- .../terminal-advanced-platform-search.ts | 5 +- .../settings/terminal-search.test.ts | 9 ++ .../settings/useGhosttyImport.test.ts | 2 +- .../use-terminal-pane-lifecycle.test.ts | 31 +++++ .../use-terminal-pane-lifecycle.ts | 37 +++++- src/renderer/src/i18n/locales/en.json | 15 +-- src/renderer/src/i18n/locales/es.json | 15 +-- src/renderer/src/i18n/locales/ja.json | 15 +-- src/renderer/src/i18n/locales/ko.json | 15 +-- src/renderer/src/i18n/locales/zh.json | 15 +-- .../lib/pane-manager/pane-lifecycle.test.ts | 4 + .../lib/pane-manager/pane-terminal-options.ts | 3 +- src/renderer/src/store/slices/settings.ts | 14 ++- src/shared/constants.ts | 3 +- src/shared/terminal-scrollback-policy.test.ts | 48 ++++++++ src/shared/terminal-scrollback-policy.ts | 55 +++++++++ src/shared/types.ts | 2 +- 28 files changed, 610 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/components/settings/TerminalAdvancedSection.test.tsx create mode 100644 src/shared/terminal-scrollback-policy.test.ts create mode 100644 src/shared/terminal-scrollback-policy.ts 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