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.
This commit is contained in:
Jinjing
2026-06-28 16:13:16 -07:00
committed by GitHub
parent 5d29b55187
commit b6c0cb4f62
28 changed files with 610 additions and 94 deletions
@@ -81,7 +81,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
terminalScrollbackRows: 5_000,
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
+1 -1
View File
@@ -85,7 +85,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
terminalScrollbackRows: 5_000,
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
+21
View File
@@ -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<unknown>
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: [] })
+18 -1
View File
@@ -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<string>(SETTINGS_CHANGED_WHITELIST)
type LegacyTerminalScrollbackSettingsUpdate = Partial<GlobalSettings> & {
terminalScrollbackBytes?: unknown
}
function sanitizeRendererSettingsUpdate(args: Partial<GlobalSettings>): Partial<GlobalSettings> {
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<GlobalSettings>) => {
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)
}
+96
View File
@@ -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<string, unknown> }
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<string, unknown> }
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<string, unknown> }
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<string, unknown> }
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'),
+56 -2
View File
@@ -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<GlobalSettings> | undefined
): Partial<GlobalSettings> {
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ??
{}) as Partial<GlobalSettings> & { 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<GlobalSettings>,
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:
@@ -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'
)
@@ -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
@@ -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(
<TerminalAdvancedSection
settings={{ terminalScrollbackRows: 5000 } as GlobalSettings}
updateSettings={updateSettings}
scrollbackMode="custom"
setScrollbackMode={vi.fn()}
searchQuery=""
showWindowsPowerShellImplementation={false}
isMac={false}
/>
)
})
}
function getScrollbackRowsInput(): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>('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')
})
})
@@ -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 (
<section key="advanced" className="space-y-3">
@@ -55,19 +84,19 @@ export function TerminalAdvancedSection({
<div className="divide-y divide-border/40">
<SearchableSetting
title={translate('auto.components.settings.TerminalPane.9df53f7c14', 'Scrollback Size')}
title={translate('auto.components.settings.TerminalPane.9df53f7c14', 'Scrollback Rows')}
description={translate(
'auto.components.settings.TerminalPane.c3810b2b42',
'Maximum terminal scrollback buffer size.'
'Retained desktop terminal rows.'
)}
keywords={['terminal', 'scrollback', 'buffer', 'memory']}
keywords={['terminal', 'scrollback', 'rows', 'buffer', 'memory']}
>
<SettingsRow
alignTop={scrollbackMode === 'custom'}
label={translate('auto.components.settings.TerminalPane.9df53f7c14', 'Scrollback Size')}
label={translate('auto.components.settings.TerminalPane.9df53f7c14', 'Scrollback Rows')}
description={translate(
'auto.components.settings.TerminalPane.81d86b2dd2',
'Maximum terminal scrollback buffer size for new terminal panes.'
'Retained desktop terminal rows for new and open panes.'
)}
control={
<div className="flex flex-col items-end gap-2">
@@ -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) => (
<ToggleGroupItem
key={preset}
value={`${preset}`}
className="h-8 px-3 text-xs"
aria-label={translate(
'auto.components.settings.TerminalPane.5336c096af',
'{{value0}} megabytes',
'{{value0}} rows',
{ value0: preset }
)}
>
{preset} {translate('auto.components.settings.TerminalPane.12e06178fa', 'MB')}
{formatScrollbackRowsLabel(preset)}
</ToggleGroupItem>
))}
<ToggleGroupItem
@@ -121,22 +150,21 @@ export function TerminalAdvancedSection({
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={256}
step={1}
value={scrollbackMb}
onChange={(e) => {
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"
/>
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.TerminalPane.12e06178fa', 'MB')}
{translate('auto.components.settings.TerminalPane.12e06178fa', 'rows')}
</span>
</div>
) : null}
@@ -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: ''
@@ -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')
]
@@ -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 })
@@ -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,
@@ -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(
@@ -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<Terminal, 'options'> }[]
}
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<number, IDisposable>())
@@ -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) {
+8 -7
View File
@@ -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.",
+8 -7
View File
@@ -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.",
+8 -7
View File
@@ -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": "ライト モードでペイン間の分割分割線を制御します。",
+8 -7
View File
@@ -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": "라이트 모드에서 창 사이의 분할 구분선을 제어합니다.",
+8 -7
View File
@@ -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": "控制浅色模式下窗格之间的分割分隔线。",
@@ -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()
@@ -1,4 +1,5 @@
import type { ITerminalOptions } from '@xterm/xterm'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../../../shared/terminal-scrollback-policy'
type TerminalCursorStyle = NonNullable<ITerminalOptions['cursorStyle']>
type TerminalCursorInactiveStyle = NonNullable<ITerminalOptions['cursorInactiveStyle']>
@@ -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,
+13 -1
View File
@@ -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<boolean>
}
type LegacyTerminalScrollbackSettingsUpdate = Partial<GlobalSettings> & {
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<AppState, [], [], SettingsSlice>
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<AppState, [], [], SettingsSlice>
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) {
+2 -1
View File
@@ -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,
@@ -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)
})
})
+55
View File
@@ -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
}
+1 -1
View File
@@ -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