diff --git a/mobile/src/terminal/terminal-webview-payload-hash.test.ts b/mobile/src/terminal/terminal-webview-payload-hash.test.ts index f8bfa4bd134..66cd2735ea2 100644 --- a/mobile/src/terminal/terminal-webview-payload-hash.test.ts +++ b/mobile/src/terminal/terminal-webview-payload-hash.test.ts @@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html' // uncovered region ships silently. A diff here means the emitted WebView source changed — // update these values only when that change is deliberate, and only after checking the // document still runs. Refactors that merely move slice boundaries must leave them alone. -const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608' -const EXPECTED_LENGTH = 729776 +const EXPECTED_SHA256 = '5c69dce3236662c381abbfb5d2d6b7163e0f4dd6841d72753733f9470326fee3' +const EXPECTED_LENGTH = 730428 describe('terminal WebView payload', () => { it('composes the expected document', () => { diff --git a/mobile/src/terminal/terminal-webview-theme-injected.test.ts b/mobile/src/terminal/terminal-webview-theme-injected.test.ts index 92e4127b2fc..d0947ef3e92 100644 --- a/mobile/src/terminal/terminal-webview-theme-injected.test.ts +++ b/mobile/src/terminal/terminal-webview-theme-injected.test.ts @@ -76,4 +76,43 @@ describe('mobile terminal-webview contrast floor gate', () => { context.applyTerminalTheme({ theme: { background: '#1e242a' } }) expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR) }) + + // #10754: the desktop user can lower or disable the floor. Mobile mirrors the desktop gate, so the + // published value has to win here or the same session renders differently on the phone. + describe('published desktop override', () => { + function applyOn(term: { options: { minimumContrastRatio: number } }, input: unknown): void { + const context = loadThemeInjected({ + term, + document: { + documentElement: { style: { background: '' } }, + body: { style: { background: '' } } + } + }) as Record & { applyTerminalTheme: (input: unknown) => void } + context.applyTerminalTheme(input) + } + + it('uses the published floor instead of the luminance gate', () => { + const term = { options: { minimumContrastRatio: 0 } } + applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 1 }) + expect(term.options.minimumContrastRatio).toBe(1) + }) + + it("clamps a published floor to xterm's 1-21 window", () => { + const term = { options: { minimumContrastRatio: 0 } } + applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 99 }) + expect(term.options.minimumContrastRatio).toBe(21) + applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: 0 }) + expect(term.options.minimumContrastRatio).toBe(1) + }) + + it('falls back to the luminance gate for an older host that omits the field', () => { + const term = { options: { minimumContrastRatio: 0 } } + for (const published of [undefined, null, 'off', Number.NaN]) { + applyOn(term, { theme: { background: '#1e242a' }, minimumContrastRatio: published }) + expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR) + applyOn(term, { theme: { background: '#ffffff' }, minimumContrastRatio: published }) + expect(term.options.minimumContrastRatio).toBe(LIGHT_FLOOR) + } + }) + }) }) diff --git a/mobile/src/terminal/terminal-webview-theme-injected.ts b/mobile/src/terminal/terminal-webview-theme-injected.ts index c2d9beb4786..98489b219c7 100644 --- a/mobile/src/terminal/terminal-webview-theme-injected.ts +++ b/mobile/src/terminal/terminal-webview-theme-injected.ts @@ -5,7 +5,8 @@ import { colors } from '../theme/mobile-theme' // #7934/#10104): a dark composed background gets a mild floor of 3 to rescue near-background body text // (e.g. Antigravity's #262b30 on #1e242a) without over-brightening vibrant ANSI colors; a light // background keeps the WCAG-AA 4.5 floor. Gate on the composed background luminance, not app mode, -// because either theme slot can hold either kind of theme. +// because either theme slot can hold either kind of theme. An explicit desktop override published on +// the theme payload (#10754) wins over the luminance gate; older hosts simply omit it. export const TERMINAL_WEBVIEW_THEME_JS = ` var DARK_BG_MIN_CONTRAST = 3; var LIGHT_BG_MIN_CONTRAST = 4.5; @@ -63,6 +64,12 @@ export const TERMINAL_WEBVIEW_THEME_JS = ` return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); } + // Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override". + function normalizeTerminalContrastOverride(value) { + if (typeof value !== 'number' || !isFinite(value)) return null; + return Math.min(21, Math.max(1, value)); + } + // Pick the xterm minimumContrastRatio floor from the composed terminal background. // Unparseable input defaults to the dark floor so agent output never stays invisible. function resolveTerminalContrastFloor(background) { @@ -100,7 +107,13 @@ export const TERMINAL_WEBVIEW_THEME_JS = ` var background = terminalTheme.background || '${colors.terminalBg}'; document.documentElement.style.background = background; document.body.style.background = background; - terminalMinimumContrastRatio = resolveTerminalContrastFloor(background); + // Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754); + // an older host omits the field and the luminance gate stays authoritative. + var publishedFloor = normalizeTerminalContrastOverride( + input && typeof input === 'object' ? input.minimumContrastRatio : undefined + ); + terminalMinimumContrastRatio = + publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor; if (term) { term.options.theme = terminalTheme; term.options.minimumContrastRatio = terminalMinimumContrastRatio; diff --git a/src/main/persistence/applying-settings/settings-update-terminal-contrast.test.ts b/src/main/persistence/applying-settings/settings-update-terminal-contrast.test.ts new file mode 100644 index 00000000000..7de32d7b36d --- /dev/null +++ b/src/main/persistence/applying-settings/settings-update-terminal-contrast.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { updateSettings, type SettingsMutationOperations } from './settings-update' + +function makeOperations(): SettingsMutationOperations { + return { + // Only the fields updateSettings reads; the rest of GlobalSettings is irrelevant to the clamp. + state: { settings: { terminalFontSize: 14 }, repos: [] } as unknown as PersistedState, + bumpLocalWorktreeScanGeneration: vi.fn(), + removeRetainedBlob: vi.fn(), + scheduleSave: vi.fn(), + notifySettingsChanged: vi.fn() + } +} + +// #10754: desktop IPC, the web RPC and the CLI all reach the store through this boundary, and xterm +// throws on a non-finite minimumContrastRatio, so the clamp cannot live in the settings UI alone. +describe('updateSettings terminalMinimumContrastRatio', () => { + it('persists an in-range floor unchanged', () => { + const operations = makeOperations() + + expect( + updateSettings(operations, { terminalMinimumContrastRatio: 1 }).terminalMinimumContrastRatio + ).toBe(1) + expect( + updateSettings(operations, { terminalMinimumContrastRatio: 4.5 }).terminalMinimumContrastRatio + ).toBe(4.5) + }) + + it('clamps a hand-edited value into xterm range', () => { + const operations = makeOperations() + + expect( + updateSettings(operations, { terminalMinimumContrastRatio: 0 }).terminalMinimumContrastRatio + ).toBe(1) + expect( + updateSettings(operations, { terminalMinimumContrastRatio: 500 }).terminalMinimumContrastRatio + ).toBe(21) + }) + + it('drops an unusable value back to automatic rather than storing it', () => { + const operations = makeOperations() + + expect( + updateSettings(operations, { + terminalMinimumContrastRatio: Number.NaN + }).terminalMinimumContrastRatio + ).toBeUndefined() + expect( + updateSettings(operations, { + terminalMinimumContrastRatio: 'off' as unknown as number + }).terminalMinimumContrastRatio + ).toBeUndefined() + }) + + it('clears the override so the automatic floor comes back', () => { + const operations = makeOperations() + + updateSettings(operations, { terminalMinimumContrastRatio: 1 }) + expect( + updateSettings(operations, { terminalMinimumContrastRatio: undefined }) + .terminalMinimumContrastRatio + ).toBeUndefined() + }) + + it('leaves a stored floor alone when an unrelated setting is written', () => { + const operations = makeOperations() + + updateSettings(operations, { terminalMinimumContrastRatio: 1 }) + expect(updateSettings(operations, { terminalFontSize: 15 }).terminalMinimumContrastRatio).toBe( + 1 + ) + }) +}) diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 20614061bdd..e8a080763da 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -9,6 +9,7 @@ import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-c import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' import { normalizeDesktopTerminalScrollbackRows } from '../../../shared/terminal-scrollback-policy' +import { normalizeTerminalMinimumContrastRatio } from '../../../shared/terminal-minimum-contrast-settings' import { normalizeTaskProviderSettings } from '../../../shared/task-providers' import { normalizeOpenInApplications } from '../../../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' @@ -123,6 +124,13 @@ export function updateSettings( updates.terminalScrollbackRows ) } + // Why here: every writer (desktop IPC, web RPC, CLI) crosses this boundary, so xterm can never be + // handed an out-of-range floor, and undefined stays undefined to mean "automatic" (#10754). + if ('terminalMinimumContrastRatio' in updates) { + sanitizedUpdates.terminalMinimumContrastRatio = normalizeTerminalMinimumContrastRatio( + updates.terminalMinimumContrastRatio + ) + } if ( 'terminalTuiScrollSensitivity' in updates || 'terminalTuiScrollSensitivityDefaultedToOne' in updates diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts index 319ec241293..2ad4147d235 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts @@ -58,6 +58,43 @@ describe('buildPreviewTerminalOptions', () => { scrollback: 1000 } + // #10754: the dashboard preview renders the agent's live buffer, so it has to reproduce the same + // contrast floor the pane used or a Powerline statusline looks different in the popout. + it('mirrors the automatic contrast floor when no override is set', () => { + expect( + buildPreviewTerminalOptions({ + ...base, + terminalInput: null, + theme: { background: '#1e242a' } + }).minimumContrastRatio + ).toBe(3) + expect( + buildPreviewTerminalOptions({ + ...base, + terminalInput: null, + theme: { background: '#ffffff' }, + themeMode: 'light' + }).minimumContrastRatio + ).toBe(4.5) + }) + + it('honors the user contrast override, clamped to xterm range', () => { + expect( + buildPreviewTerminalOptions({ + ...base, + terminalInput: null, + settings: { ...SETTINGS, terminalMinimumContrastRatio: 1 } + }).minimumContrastRatio + ).toBe(1) + expect( + buildPreviewTerminalOptions({ + ...base, + terminalInput: null, + settings: { ...SETTINGS, terminalMinimumContrastRatio: 0 } + }).minimumContrastRatio + ).toBe(1) + }) + it('keeps the kitty advertisement and skips ConPTY options off Windows', () => { const options = buildPreviewTerminalOptions({ ...base, diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts index 284f6510558..14fe851a657 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts @@ -80,7 +80,8 @@ export function buildPreviewTerminalOptions(args: { theme: args.theme ?? undefined, minimumContrastRatio: resolveTerminalMinimumContrastRatio( args.theme?.background, - args.themeMode + args.themeMode, + args.settings?.terminalMinimumContrastRatio ) } } diff --git a/src/renderer/src/components/settings/SettingsFormControls.number-field.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.number-field.test.tsx new file mode 100644 index 00000000000..4b8b2ff1bae --- /dev/null +++ b/src/renderer/src/components/settings/SettingsFormControls.number-field.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NumberField } from './SettingsFormControls' + +afterEach(cleanup) + +// #10754: an optional setting needs a way back to "unset". Without a clear path the field can pin a +// value but never restore Orca's automatic behavior, which is the state most users should be in. +describe('NumberField clearable fields', () => { + it('renders the placeholder and commits nothing while the value is unset', () => { + render( + + ) + + const input = screen.getByLabelText('Minimum Contrast Ratio') as HTMLInputElement + expect(input.value).toBe('') + expect(input.getAttribute('placeholder')).toBe('Auto') + }) + + it('clears the setting when the field is emptied', () => { + const onChange = vi.fn() + const onClear = vi.fn() + render( + + ) + + const input = screen.getByLabelText('Minimum Contrast Ratio') + fireEvent.change(input, { target: { value: '' } }) + fireEvent.blur(input) + + expect(onClear).toHaveBeenCalledTimes(1) + expect(onChange).not.toHaveBeenCalled() + }) + + it('still snaps back to the current value when the field is not clearable', () => { + const onChange = vi.fn() + render() + + const input = screen.getByLabelText('Font Size') as HTMLInputElement + fireEvent.change(input, { target: { value: '' } }) + fireEvent.blur(input) + + expect(onChange).not.toHaveBeenCalled() + expect(input.value).toBe('14') + }) + + it('clamps a committed value into the min/max window', () => { + const onChange = vi.fn() + render( + + ) + + const input = screen.getByLabelText('Minimum Contrast Ratio') + fireEvent.change(input, { target: { value: '99' } }) + fireEvent.blur(input) + + expect(onChange).toHaveBeenCalledWith(21) + }) +}) diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 9a0993849f6..ef374619344 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -262,13 +262,17 @@ type ColorFieldProps = { type NumberFieldProps = { label: string description: string - value: number + /** undefined renders the field empty — pair it with `placeholder` and `onClear` for an unset state. */ + value: number | undefined defaultValue?: number min: number max?: number step?: number integer?: boolean onChange: (value: number) => void + /** When set, emptying the field clears the setting instead of snapping back to the current value. */ + onClear?: () => void + placeholder?: string suffix?: string className?: string } @@ -316,6 +320,8 @@ export function NumberField({ step = 1, integer = false, onChange, + onClear, + placeholder, suffix, className }: NumberFieldProps): React.JSX.Element { @@ -331,6 +337,11 @@ export function NumberField({ const commit = (): void => { const trimmed = draft.trim() if (trimmed === '') { + if (onClear) { + // Clearable fields treat empty as "unset" so the caller can fall back to its automatic value. + onClear() + return + } // Empty input — reset to current value rather than committing 0 setDraft(Number.isFinite(value) ? String(value) : '') return @@ -369,6 +380,7 @@ export function NumberField({ max={max} step={step} aria-label={label} + placeholder={placeholder} value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={commit} diff --git a/src/renderer/src/components/settings/TerminalContrastSetting.test.tsx b/src/renderer/src/components/settings/TerminalContrastSetting.test.tsx new file mode 100644 index 00000000000..41739d8bb2d --- /dev/null +++ b/src/renderer/src/components/settings/TerminalContrastSetting.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom +import { useState } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { TerminalContrastSetting } from './TerminalContrastSetting' + +vi.mock('./SearchableSetting', () => ({ SearchableSetting: ({ children }) => children })) +afterEach(cleanup) + +function mount(initial: number | undefined = undefined): ReturnType { + const persist = vi.fn() + function Harness(): React.JSX.Element { + const [settings, setSettings] = useState({ + terminalMinimumContrastRatio: initial + } as GlobalSettings) + return ( + { + persist(patch) + setSettings((previous) => ({ ...previous, ...patch })) + }} + /> + ) + } + render() + return persist +} + +describe('terminal contrast modes', () => { + it('lets users turn correction off and restore automatic without editing a number', () => { + const persist = mount() + expect(screen.getByRole('radio', { name: 'Automatic' }).getAttribute('aria-checked')).toBe( + 'true' + ) + expect(screen.queryByRole('spinbutton')).toBeNull() + fireEvent.click(screen.getByRole('radio', { name: 'Off' })) + expect(persist).toHaveBeenLastCalledWith({ terminalMinimumContrastRatio: 1 }) + expect(screen.queryByRole('spinbutton')).toBeNull() + fireEvent.click(screen.getByRole('radio', { name: 'Automatic' })) + expect(persist).toHaveBeenLastCalledWith({ terminalMinimumContrastRatio: undefined }) + }) + + it('restores the custom target when toggling through off and automatic', () => { + const persist = mount(7) + fireEvent.click(screen.getByRole('radio', { name: 'Off' })) + fireEvent.click(screen.getByRole('radio', { name: 'Automatic' })) + fireEvent.click(screen.getByRole('radio', { name: 'Custom' })) + expect(persist).toHaveBeenLastCalledWith({ terminalMinimumContrastRatio: 7 }) + expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('7') + }) + + it('starts custom at a usable target and bounds precise input', () => { + const persist = mount() + fireEvent.click(screen.getByRole('radio', { name: 'Custom' })) + expect(persist).toHaveBeenLastCalledWith({ terminalMinimumContrastRatio: 4.5 }) + const input = screen.getByRole('spinbutton') + fireEvent.change(input, { target: { value: '99' } }) + fireEvent.blur(input) + expect(persist).toHaveBeenLastCalledWith({ terminalMinimumContrastRatio: 21 }) + fireEvent.change(input, { target: { value: '1' } }) + fireEvent.blur(input) + expect(screen.getByRole('radio', { name: 'Off' }).getAttribute('aria-checked')).toBe('true') + }) +}) diff --git a/src/renderer/src/components/settings/TerminalContrastSetting.tsx b/src/renderer/src/components/settings/TerminalContrastSetting.tsx new file mode 100644 index 00000000000..174979602f2 --- /dev/null +++ b/src/renderer/src/components/settings/TerminalContrastSetting.tsx @@ -0,0 +1,137 @@ +import { useRef, useState } from 'react' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { Slider } from '../ui/slider' +import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' +import { SearchableSetting } from './SearchableSetting' +import { + LIGHT_BG_MIN_CONTRAST, + MIN_TERMINAL_CONTRAST_RATIO, + MAX_TERMINAL_CONTRAST_RATIO, + normalizeTerminalMinimumContrastRatio +} from '@/lib/terminal-contrast-correction' +import { translate } from '@/i18n/i18n' + +type ContrastMode = 'auto' | 'off' | 'custom' + +type Props = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function TerminalContrastSetting({ settings, updateSettings }: Props): React.JSX.Element { + const value = normalizeTerminalMinimumContrastRatio(settings.terminalMinimumContrastRatio) + const mode: ContrastMode = value === undefined ? 'auto' : value === 1 ? 'off' : 'custom' + const lastCustomValue = useRef(LIGHT_BG_MIN_CONTRAST) + const [draft, setDraft] = useState(value ?? LIGHT_BG_MIN_CONTRAST) + const [previousValue, setPreviousValue] = useState(value) + if (value !== previousValue) { + setPreviousValue(value) + setDraft(value ?? LIGHT_BG_MIN_CONTRAST) + } + const title = translate('auto.components.settings.contrast.title', 'Color Contrast') + const description = translate( + 'auto.components.settings.contrast.description', + 'Improve text readability or preserve the colors chosen by terminal programs.' + ) + const ratioLabel = translate('auto.components.settings.contrast.ratio', 'Contrast target') + const selectMode = (next: ContrastMode): void => { + if (mode === 'custom' && value !== undefined) { + lastCustomValue.current = value + } + updateSettings({ + terminalMinimumContrastRatio: + next === 'auto' ? undefined : next === 'off' ? 1 : lastCustomValue.current + }) + } + + return ( + + + ariaLabel={title} + value={mode} + onChange={selectMode} + options={[ + { + value: 'auto', + label: translate('auto.components.settings.contrast.auto', 'Automatic') + }, + { value: 'off', label: translate('auto.components.settings.contrast.off', 'Off') }, + { + value: 'custom', + label: translate('auto.components.settings.contrast.custom', 'Custom') + } + ]} + /> + } + /> + {mode === 'custom' && ( +
+ updateSettings({ terminalMinimumContrastRatio: ratio })} + /> + setDraft(ratio)} + onValueCommit={([ratio]) => updateSettings({ terminalMinimumContrastRatio: ratio })} + /> +
+ {translate('auto.components.settings.contrast.subtle', 'Subtle')} + {translate('auto.components.settings.contrast.strong', 'Strong')} +
+
+ )} +
+ ) +} diff --git a/src/renderer/src/components/settings/TerminalRenderingSection.tsx b/src/renderer/src/components/settings/TerminalRenderingSection.tsx index 47e3017d353..b4031e8f7a4 100644 --- a/src/renderer/src/components/settings/TerminalRenderingSection.tsx +++ b/src/renderer/src/components/settings/TerminalRenderingSection.tsx @@ -5,6 +5,7 @@ import { SettingsSubsectionHeader } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' +import { TerminalContrastSetting } from './TerminalContrastSetting' import { translate } from '@/i18n/i18n' type TerminalRenderingSectionProps = { @@ -91,6 +92,8 @@ export function TerminalRenderingSection({ } /> + + ) diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx index b3a881f9cd0..e597a47173c 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx @@ -206,7 +206,8 @@ export function TerminalSettingsPreview({ // Why: share applyTerminalAppearance's gating helper (#7934) so the preview can't drift from live panes. terminal.options.minimumContrastRatio = resolveTerminalMinimumContrastRatio( composedTheme.background, - effectiveMode + effectiveMode, + settings.terminalMinimumContrastRatio ) // Why: xterm renders an alpha-channel background opaque unless allowTransparency is set (matches applyTerminalAppearance). terminal.options.allowTransparency = @@ -218,7 +219,12 @@ export function TerminalSettingsPreview({ // Why reset() not clear(): buffer ends mid-line on the prompt, so clear()+write would duplicate the trailing fragment. terminal.reset() terminal.write(PREVIEW_BUFFER) - }, [composedTheme, effectiveMode, settings.terminalBackgroundOpacity]) + }, [ + composedTheme, + effectiveMode, + settings.terminalBackgroundOpacity, + settings.terminalMinimumContrastRatio + ]) useEffect(() => { const terminal = terminalRef.current diff --git a/src/renderer/src/components/settings/setting-labels.ts b/src/renderer/src/components/settings/setting-labels.ts index c2cec96322f..e46e35bf4e4 100644 --- a/src/renderer/src/components/settings/setting-labels.ts +++ b/src/renderer/src/components/settings/setting-labels.ts @@ -10,6 +10,7 @@ export const SETTING_LABELS: Partial> = { terminalFastScrollSensitivity: 'Fast Scroll Speed', terminalTuiScrollSensitivity: 'TUI Scroll Speed', terminalBackgroundOpacity: 'Background Opacity', + terminalMinimumContrastRatio: 'Color Contrast', terminalCursorStyle: 'Cursor Style', terminalCursorBlink: 'Cursor Blink', terminalCursorOpacity: 'Cursor Opacity', diff --git a/src/renderer/src/components/settings/terminal-typography-search.ts b/src/renderer/src/components/settings/terminal-typography-search.ts index 02fe124d25f..6a4c378fb83 100644 --- a/src/renderer/src/components/settings/terminal-typography-search.ts +++ b/src/renderer/src/components/settings/terminal-typography-search.ts @@ -128,6 +128,55 @@ export const getTerminalRenderingSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.terminal.search.7d924d870d', 'graphics'), ...translateSearchKeyword('auto.components.settings.terminal.search.1abcf4d7de', 'linux') ] + }, + { + title: translate( + 'auto.components.settings.terminal.search.minimumContrast.title', + 'Color Contrast' + ), + description: translate( + 'auto.components.settings.terminal.search.minimumContrast.description', + 'Improve text readability or preserve the colors chosen by terminal programs.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.minimumContrast.contrast', + 'contrast' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.minimumContrast.minimum', + 'minimum' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.minimumContrast.ratio', + 'ratio' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.minimumContrast.readability', + 'readability' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.minimumContrast.wcag', + 'wcag' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.minimumContrast.powerline', + 'powerline' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.minimumContrast.statusline', + 'statusline' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.minimumContrast.dim', + 'dim' + ), + ...translateSearchKeyword( + 'auto.components.settings.terminal.search.minimumContrast.colors', + 'colors' + ) + ] } ]) diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 6f7bec8592b..ce259c5d04d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -268,6 +268,46 @@ describe('applyTerminalAppearance theme assignment', () => { expect(pane.terminal.options.minimumContrastRatio).toBe(4.5) }) + // #10754: a Powerline statusline draws its segment separators in the neighbouring segment's + // background color, so the automatic floor turns every invisible seam into a bright line. + it('lets the user setting disable contrast correction on a dark theme', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, theme: 'dark', terminalMinimumContrastRatio: 1 }) + + expect(pane.terminal.options.minimumContrastRatio).toBe(1) + }) + + it('lets the user setting override the light-background floor as well', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, theme: 'light', terminalMinimumContrastRatio: 1 }) + + expect(pane.terminal.options.minimumContrastRatio).toBe(1) + }) + + it('clamps an out-of-range user setting before it reaches xterm', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, theme: 'dark', terminalMinimumContrastRatio: 99 }) + + expect(pane.terminal.options.minimumContrastRatio).toBe(21) + }) + + it('returns to the automatic floor when the user setting is cleared live', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, theme: 'dark', terminalMinimumContrastRatio: 1 }) + expect(pane.terminal.options.minimumContrastRatio).toBe(1) + + apply(pane, { ...settings, theme: 'dark', terminalMinimumContrastRatio: undefined }) + expect(pane.terminal.options.minimumContrastRatio).toBe(3) + }) + it('skips the minimumContrastRatio write on a no-op re-apply (preserves xterm contrast cache)', () => { const pane = makePane(1) let writes = 0 diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index a5aa36568ca..6b589e7a6dd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -170,7 +170,8 @@ export function applyTerminalAppearance( // Why value-gated: writing minimumContrastRatio clears xterm's contrast cache, so skip on no-op re-applies. const minimumContrastRatio = resolveTerminalMinimumContrastRatio( theme?.background, - appearance.mode + appearance.mode, + settings.terminalMinimumContrastRatio ) if (pane.terminal.options.minimumContrastRatio !== minimumContrastRatio) { pane.terminal.options.minimumContrastRatio = minimumContrastRatio diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 352b086f9d5..4fa36b54a9f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8627,6 +8627,15 @@ "fastDescription": "Extra multiplier while scrolling with a modifier key.", "tui": "TUI", "tuiDescription": "Discrete wheel reports for full-screen terminal apps." + }, + "minimumContrast": { + "title": "Minimum Contrast Ratio", + "description": "Lifts terminal foreground colors that sit too close to the background. Leave blank for automatic, or set 1 to render program colors exactly as sent.", + "automatic": "Automatic: {{light}} on light backgrounds, {{dark}} on dark.", + "disabled": "Correction off. Programs that rely on low contrast, like Powerline separators, render as sent.", + "pinned": "Targets {{ratio}}:1 contrast for foreground colors, where possible.", + "placeholder": "Auto", + "suffix": "blank = automatic, 1 = off" } }, "TerminalSettingsPreview": { @@ -10488,7 +10497,20 @@ "agent": "agent", "process": "process", "prompt": "prompt", - "stop": "stop" + "stop": "stop", + "minimumContrast": { + "title": "Color Contrast", + "description": "Improve text readability or preserve the colors chosen by terminal programs.", + "contrast": "contrast", + "minimum": "minimum", + "ratio": "ratio", + "readability": "readability", + "wcag": "wcag", + "powerline": "powerline", + "statusline": "statusline", + "dim": "dim", + "colors": "colors" + } }, "windows": { "search": { @@ -11813,6 +11835,20 @@ }, "NativeChatSupportedAgents": { "label": "Supported agents:" + }, + "contrast": { + "title": "Color Contrast", + "description": "Improve text readability or preserve the colors chosen by terminal programs.", + "ratio": "Contrast target", + "autoDescription": "Balances readability with your terminal theme. Recommended.", + "offDescription": "Keeps program colors unchanged, including dim text and Powerline separators.", + "customDescription": "Choose how much to increase contrast between text and its background.", + "auto": "Automatic", + "off": "Off", + "custom": "Custom", + "targetDescription": "Higher values increase contrast where possible. Background colors stay unchanged.", + "subtle": "Subtle", + "strong": "Strong" } }, "right": { diff --git a/src/renderer/src/lib/terminal-contrast-correction.test.ts b/src/renderer/src/lib/terminal-contrast-correction.test.ts index 2197bdf903c..cf34328b879 100644 --- a/src/renderer/src/lib/terminal-contrast-correction.test.ts +++ b/src/renderer/src/lib/terminal-contrast-correction.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest' import { DARK_BG_MIN_CONTRAST, LIGHT_BG_MIN_CONTRAST, + MAX_TERMINAL_CONTRAST_RATIO, + MIN_TERMINAL_CONTRAST_RATIO, + normalizeTerminalMinimumContrastRatio, resolveTerminalMinimumContrastRatio } from './terminal-contrast-correction' import { TERMINAL_THEME_CATALOG } from './terminal-themes' @@ -42,6 +45,63 @@ describe('resolveTerminalMinimumContrastRatio', () => { }) }) +// #10754: the automatic floor rewrites deliberately low-contrast TUI output (Powerline seams, dimmed +// secondary text), so the user setting has to win over the luminance gate on both backgrounds. +describe('resolveTerminalMinimumContrastRatio with a user override', () => { + it('lets 1 disable contrast correction on a dark background', () => { + expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', 1)).toBe(1) + }) + + it('lets 1 disable contrast correction on a light background too', () => { + expect(resolveTerminalMinimumContrastRatio('#ffffff', 'light', 1)).toBe(1) + }) + + it('honors an intermediate override instead of the automatic floor', () => { + expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', 1.5)).toBe(1.5) + expect(resolveTerminalMinimumContrastRatio('#ffffff', 'light', 7)).toBe(7) + }) + + it("clamps an out-of-range override to xterm's 1-21 window", () => { + expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', 0)).toBe( + MIN_TERMINAL_CONTRAST_RATIO + ) + expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', -5)).toBe( + MIN_TERMINAL_CONTRAST_RATIO + ) + expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', 99)).toBe( + MAX_TERMINAL_CONTRAST_RATIO + ) + }) + + it('falls back to the automatic floor when the override is unset or unusable', () => { + // A hand-edited settings file can carry any of these; xterm throws on a non-finite option. + for (const value of [undefined, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark', value)).toBe( + DARK_BG_MIN_CONTRAST + ) + expect(resolveTerminalMinimumContrastRatio('#ffffff', 'light', value)).toBe( + LIGHT_BG_MIN_CONTRAST + ) + } + }) +}) + +describe('normalizeTerminalMinimumContrastRatio', () => { + it('returns undefined for anything that is not a usable number', () => { + for (const value of [undefined, null, '3', Number.NaN, Number.POSITIVE_INFINITY, {}]) { + expect(normalizeTerminalMinimumContrastRatio(value)).toBeUndefined() + } + }) + + it('passes in-range values through and clamps the rest', () => { + expect(normalizeTerminalMinimumContrastRatio(1)).toBe(1) + expect(normalizeTerminalMinimumContrastRatio(4.5)).toBe(4.5) + expect(normalizeTerminalMinimumContrastRatio(21)).toBe(21) + expect(normalizeTerminalMinimumContrastRatio(0.5)).toBe(1) + expect(normalizeTerminalMinimumContrastRatio(1000)).toBe(21) + }) +}) + // #10104: the dark-background floor must sit in the window that rescues near-background body text // without over-brightening vibrant ANSI colors (the #7934 regression). Guarding both edges keeps a // future tweak from silently sliding out of that window. diff --git a/src/renderer/src/lib/terminal-contrast-correction.ts b/src/renderer/src/lib/terminal-contrast-correction.ts index 4a4e9ac3cb6..7293ce2735e 100644 --- a/src/renderer/src/lib/terminal-contrast-correction.ts +++ b/src/renderer/src/lib/terminal-contrast-correction.ts @@ -1,4 +1,11 @@ import { isTerminalBackgroundLight } from '@/lib/terminal-title-contrast' +import { normalizeTerminalMinimumContrastRatio } from '../../../shared/terminal-minimum-contrast-settings' + +export { + MAX_TERMINAL_CONTRAST_RATIO, + MIN_TERMINAL_CONTRAST_RATIO, + normalizeTerminalMinimumContrastRatio +} from '../../../shared/terminal-minimum-contrast-settings' // xterm minimumContrastRatio tuning (#7934, #9599, #10104). Light backgrounds keep WCAG-AA correction so // invisible white/bright-white ANSI body text stays readable. Dark backgrounds use a mild floor of 3 @@ -13,10 +20,17 @@ export const DARK_BG_MIN_CONTRAST = 3 // Why gate by background luminance, not app mode (#7934): either theme slot can hold either kind of // theme (match-dark-mode, or a light theme in the dark slot), so follow the composed background. +// `override` is the user's terminalMinimumContrastRatio; clamped here too so a hand-edited settings +// file can't hand xterm an out-of-range or non-finite floor. export function resolveTerminalMinimumContrastRatio( background: string | undefined, - appSurface: 'dark' | 'light' + appSurface: 'dark' | 'light', + override?: number ): number { + const configured = normalizeTerminalMinimumContrastRatio(override) + if (configured !== undefined) { + return configured + } return isTerminalBackgroundLight(background, { appSurface }) ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.test.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.test.ts new file mode 100644 index 00000000000..ce5f4aea704 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { resolveMobileTerminalTheme } from './mobile-terminal-theme' + +function stateWith(settings: Record | null): AppState { + return { settings } as unknown as AppState +} + +const BASE = { + terminalThemeDark: 'Ghostty Default Style Dark', + terminalThemeLight: 'Builtin Tango Light', + terminalUseSeparateLightTheme: true, + theme: 'dark' +} + +// #10754: mobile mirrors the desktop contrast gate, so an explicit floor has to travel with the +// theme payload — otherwise the same session renders differently on the phone. +describe('resolveMobileTerminalTheme contrast floor', () => { + it('omits the floor when the user has not set one', () => { + const theme = resolveMobileTerminalTheme(stateWith(BASE), true) + expect(theme?.minimumContrastRatio).toBeUndefined() + }) + + it('publishes the user floor so the phone stops lifting low-contrast output', () => { + const theme = resolveMobileTerminalTheme( + stateWith({ ...BASE, terminalMinimumContrastRatio: 1 }), + true + ) + expect(theme?.minimumContrastRatio).toBe(1) + }) + + it('clamps before publishing so an old client can trust the value', () => { + expect( + resolveMobileTerminalTheme(stateWith({ ...BASE, terminalMinimumContrastRatio: 99 }), true) + ?.minimumContrastRatio + ).toBe(21) + expect( + resolveMobileTerminalTheme( + stateWith({ ...BASE, terminalMinimumContrastRatio: Number.NaN }), + true + )?.minimumContrastRatio + ).toBeUndefined() + }) + + it('returns nothing without settings', () => { + expect(resolveMobileTerminalTheme(stateWith(null), true)).toBeUndefined() + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts index ab9e2c05042..c4bae609a39 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts @@ -2,6 +2,7 @@ import { getSystemPrefersDark, resolveEffectiveTerminalAppearance } from '@/lib/ import type { AppState } from '@/store/types' import type { RuntimeMobileTerminalTheme } from '../../../../shared/runtime-types' import { graphState } from './graph-state' +import { normalizeTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction' function hexToRgba(hex: string, alpha: number): string { let clean = hex.replace('#', '') @@ -52,7 +53,15 @@ export function resolveMobileTerminalTheme( theme[key] = value } } - return { mode: appearance.mode, theme: theme as RuntimeMobileTerminalTheme['theme'] } + return { + mode: appearance.mode, + theme: theme as RuntimeMobileTerminalTheme['theme'], + // Why publish: mobile mirrors the desktop contrast gate, so an explicit floor has to travel with + // the theme or the same session would render differently on the phone (#10754). + minimumContrastRatio: normalizeTerminalMinimumContrastRatio( + settings.terminalMinimumContrastRatio + ) + } } export function getMobileTerminalTheme( diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index bef153ba8c9..5aac39c52ca 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -144,6 +144,10 @@ export type GlobalSettings = { terminalPaneOpacityTransitionMs: number terminalDividerThicknessPx: number terminalBackgroundOpacity?: number + /** xterm minimumContrastRatio floor for terminal panes (#10754). Undefined keeps the automatic, + * background-luminance-gated floor (3 dark / 4.5 light); 1 disables contrast correction so TUIs + * that rely on deliberately low contrast (Powerline seams, dimmed secondary text) render as sent. */ + terminalMinimumContrastRatio?: number terminalColorOverrides?: TerminalColorOverrides terminalPaddingX?: number terminalPaddingY?: number diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index 07e3a1b5524..563637c562f 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -33,6 +33,9 @@ export type RuntimeMobileSessionTerminalTab = { export type RuntimeMobileTerminalTheme = { mode: 'dark' | 'light' theme: TerminalColorOverrides + /** Optional desktop terminalMinimumContrastRatio override (#10754). Absent means the client picks + * its own background-luminance floor, which is what pre-#10754 clients always do. */ + minimumContrastRatio?: number } export type RuntimeMobileSessionMarkdownTab = { diff --git a/src/shared/terminal-minimum-contrast-settings.ts b/src/shared/terminal-minimum-contrast-settings.ts new file mode 100644 index 00000000000..c2df1c7ebbb --- /dev/null +++ b/src/shared/terminal-minimum-contrast-settings.ts @@ -0,0 +1,16 @@ +// xterm's minimumContrastRatio range: 1 disables contrast correction entirely, 21 is the maximum +// WCAG ratio (black on white). Shared so main's persistence boundary and the renderer clamp alike. +export const MIN_TERMINAL_CONTRAST_RATIO = 1 +export const MAX_TERMINAL_CONTRAST_RATIO = 21 + +/** + * Clamps a user-supplied contrast floor (#10754). `undefined` means "unset", so callers fall back to + * Orca's automatic background-luminance floor; anything unusable is treated the same way rather than + * handed to xterm, which throws on a non-finite option. + */ +export function normalizeTerminalMinimumContrastRatio(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.min(MAX_TERMINAL_CONTRAST_RATIO, Math.max(MIN_TERMINAL_CONTRAST_RATIO, value)) +}