Simplify Appearance settings (#6459)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-28 11:50:29 -07:00 committed by GitHub
parent 6015ad07a0
commit e58a5e9e17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2393 additions and 1002 deletions

View File

@ -214,6 +214,9 @@ function main() {
console.log(` -> ${outputIco} (filled multi-size ICO, safe-area inset trimmed)`)
}
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('trim-windows-icon-source.mjs')) {
if (
import.meta.url === `file://${process.argv[1]}` ||
process.argv[1]?.endsWith('trim-windows-icon-source.mjs')
) {
main()
}

View File

@ -204,8 +204,7 @@ const POWERSHELL_OSC133_ARGS = [
// real absolute executable before handing it to ConPTY (PR #6537 / issue
// #5161) — a bare/alias `pwsh.exe` makes CreateProcessW fail with error code 5.
// These match the deterministic install roots pinned in the win32 beforeEach.
const RESOLVED_WINDOWS_POWERSHELL =
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const RESOLVED_WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const RESOLVED_PWSH7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const TEST_CODEX_HOME =
process.platform === 'win32'

View File

@ -39,8 +39,7 @@ function makeAttempt(
// error code 5 == ERROR_ACCESS_DENIED from CreateProcessW inside ConPTY when a
// bare/alias pwsh.exe is handed to node-pty.
const ACCESS_DENIED_5 =
'Cannot create process, error code: 5'
const ACCESS_DENIED_5 = 'Cannot create process, error code: 5'
describe('spawnShellWithFallback on Windows', () => {
it('repro: recovers when the primary PowerShell spawn fails with error code 5', () => {

View File

@ -14,12 +14,10 @@ const WIN_ENV: NodeJS.ProcessEnv = {
}
const PWSH7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const WINDOWS_POWERSHELL =
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
const WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
// The Microsoft Store App Execution Alias stub for pwsh — a zero-byte reparse
// point under WindowsApps that ConPTY's CreateProcessW rejects with error 5.
const PWSH_STORE_ALIAS =
'C:\\Users\\dev\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe'
const PWSH_STORE_ALIAS = 'C:\\Users\\dev\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe'
describe('resolveWindowsPowerShellExecutablePath', () => {
it('returns null on non-Windows platforms', () => {

View File

@ -29,7 +29,9 @@ describe('packaged CLI entry redirect', () => {
})
it('does not match the entrypoint on non-Windows platforms', () => {
expect(getPackagedCliEntryArgs([execPath, cliEntryPath, 'status'], cliEntryPath, 'linux')).toBeNull()
expect(
getPackagedCliEntryArgs([execPath, cliEntryPath, 'status'], cliEntryPath, 'linux')
).toBeNull()
})
it('spawns the in-package CLI in Electron node mode before the single-instance lock can win', () => {
@ -50,19 +52,15 @@ describe('packaged CLI entry redirect', () => {
})
expect(result).toEqual({ redirected: true, status: 0 })
expect(spawn).toHaveBeenCalledWith(
execPath,
[cliEntryPath, 'status', '--json'],
{
env: expect.objectContaining({
ELECTRON_RUN_AS_NODE: '1',
ORCA_PACKAGED_CLI_ENTRY_REDIRECTED: '1',
ORCA_NODE_OPTIONS: '--inspect',
ORCA_NODE_REPL_EXTERNAL_MODULE: 'external-loader'
}),
stdio: 'inherit'
}
)
expect(spawn).toHaveBeenCalledWith(execPath, [cliEntryPath, 'status', '--json'], {
env: expect.objectContaining({
ELECTRON_RUN_AS_NODE: '1',
ORCA_PACKAGED_CLI_ENTRY_REDIRECTED: '1',
ORCA_NODE_OPTIONS: '--inspect',
ORCA_NODE_REPL_EXTERNAL_MODULE: 'external-loader'
}),
stdio: 'inherit'
})
const spawnOptions = spawn.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv } | undefined
expect(spawnOptions?.env).not.toHaveProperty('NODE_OPTIONS')
expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE')

View File

@ -0,0 +1,56 @@
import type React from 'react'
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAppStore } from '../../store'
import { normalizeSettingsSearchQuery } from './settings-search'
import { translate } from '@/i18n/i18n'
type AppearanceAdvancedDisclosureProps = {
/** Optional override label; defaults to "Advanced". */
label?: string
showTopBorder?: boolean
className?: string
contentClassName?: string
children: React.ReactNode
}
/** Inline "Advanced" disclosure for low-frequency controls. An active settings
* search force-opens it so matching controls stay reachable instead of being
* hidden behind a collapsed trigger. */
export function AppearanceAdvancedDisclosure({
label,
showTopBorder = true,
className,
contentClassName,
children
}: AppearanceAdvancedDisclosureProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const [open, setOpen] = useState(false)
const expanded = open || isSearching
return (
<div className={cn('mt-3 pt-2', showTopBorder && 'border-t border-border/50', className)}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setOpen((prev) => !prev)}
// Why: while searching the disclosure is forced open, so disable the
// toggle's collapse affordance rather than letting it fight the search.
disabled={isSearching}
className="flex w-full items-center gap-2 py-1 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default"
>
<ChevronRight
className={cn(
'size-3.5 text-muted-foreground transition-transform',
expanded && 'rotate-90'
)}
/>
{label ??
translate('auto.components.settings.AppearanceAdvancedDisclosure.advanced', 'Advanced')}
</button>
{expanded ? <div className={cn('pt-1', contentClassName)}>{children}</div> : null}
</div>
)
}

View File

@ -0,0 +1,241 @@
import type React from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { UIZoomControl } from './UIZoomControl'
import { SearchableSetting } from './SearchableSetting'
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
import { useAppStore } from '../../store'
import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { ShortcutHintList } from './AppearanceShortcutHintList'
import {
FontAutocomplete,
SettingsRow,
SettingsSegmentedControl,
SettingsSwitchRow
} from './SettingsFormControls'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
import {
getLanguageEntries,
getSystemTrayEntries,
getThemeEntries,
getTitlebarEntries,
getTypographyEntries,
getZoomEntries
} from './appearance-search'
import {
getUiLanguageChoiceLabel,
SHOW_UI_LANGUAGE_SETTING,
UI_LANGUAGE_CHOICES
} from '@/i18n/supported-languages'
import { translate } from '@/i18n/i18n'
import type { UiLanguage } from '../../../../shared/ui-language'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
type AppearanceInterfaceSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
applyTheme: (theme: 'system' | 'dark' | 'light') => void
fontSuggestions: string[]
isDesktopWindows: boolean
forceVisiblePrimary?: boolean
}
export function AppearanceInterfaceSection({
settings,
updateSettings,
applyTheme,
fontSuggestions,
isDesktopWindows,
forceVisiblePrimary = false
}: AppearanceInterfaceSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
const languageEntry = getLanguageEntries()[0]
const systemTrayEntry = getSystemTrayEntries({ showSystemTray: true })[0]
const themeEntry = getThemeEntries()[0]
const themeLabel = translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme')
const titlebarEntry = getTitlebarEntries()[0]
const typographyEntry = getTypographyEntries()[0]
const zoomEntry = getZoomEntries()[0]
const advancedEntries = [
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTitlebarEntries(),
...getSystemTrayEntries({ showSystemTray: isDesktopWindows })
]
const showAdvanced = !isSearching || matchesSettingsSearch(searchQuery, advancedEntries)
return (
<div className="divide-y divide-border/40">
<SearchableSetting
title={themeLabel}
description={themeEntry?.description}
keywords={themeEntry?.keywords ?? ['dark', 'light', 'system']}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={themeLabel}
control={
<SettingsSegmentedControl
ariaLabel={themeLabel}
value={settings.theme}
onChange={(option) => {
updateSettings({ theme: option })
applyTheme(option)
}}
options={[
{
value: 'system',
label: translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System')
},
{
value: 'dark',
label: translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark')
},
{
value: 'light',
label: translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light')
}
]}
/>
}
/>
</SearchableSetting>
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
description={zoomEntry?.description}
keywords={zoomEntry?.keywords ?? ['zoom', 'scale', 'shortcut']}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
// Why: keep only the shortcut hint — the control itself makes "scale the
// interface" obvious, but the keyboard gesture and its terminal-pane
// exception are not discoverable from the buttons alone.
description={
<>
<ShortcutHintList combos={zoomInKeyCombos} /> /{' '}
<ShortcutHintList combos={zoomOutKeyCombos} />{' '}
{translate(
'auto.components.settings.AppearancePane.ef89200c1f',
'when not in a terminal pane.'
)}
</>
}
control={<UIZoomControl />}
/>
</SearchableSetting>
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
description={typographyEntry?.description}
keywords={typographyEntry?.keywords ?? ['font', 'typeface', 'typography']}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
control={
<FontAutocomplete
value={settings.appFontFamily}
suggestions={fontSuggestions}
placeholder={DEFAULT_APP_FONT_FAMILY}
onChange={(value) =>
updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY })
}
/>
}
/>
</SearchableSetting>
{showAdvanced ? (
<AppearanceAdvancedDisclosure showTopBorder={false}>
<div className="divide-y divide-border/40">
{SHOW_UI_LANGUAGE_SETTING ? (
<SearchableSetting
title={translate('settings.appearance.language.title', 'Language')}
description={languageEntry?.description}
keywords={languageEntry?.keywords ?? []}
>
<SettingsRow
label={translate('settings.appearance.language.title', 'Language')}
control={
<Select
value={settings.uiLanguage}
onValueChange={(value) => updateSettings({ uiLanguage: value as UiLanguage })}
>
<SelectTrigger
size="sm"
className="min-w-[220px]"
aria-label={translate('settings.appearance.language.title', 'Language')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{UI_LANGUAGE_CHOICES.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{getUiLanguageChoiceLabel(choice, translate)}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</SearchableSetting>
) : null}
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.9868f39007',
'Titlebar App Name'
)}
description={titlebarEntry?.description}
keywords={titlebarEntry?.keywords ?? ['titlebar', 'orca', 'app', 'name']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.9868f39007',
'Titlebar App Name'
)}
checked={settings.showTitlebarAppName}
onChange={() =>
updateSettings({ showTitlebarAppName: !settings.showTitlebarAppName })
}
/>
</SearchableSetting>
{isDesktopWindows ? (
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.2edf606c46',
'Minimize to Tray on Close'
)}
description={systemTrayEntry?.description}
keywords={systemTrayEntry?.keywords ?? ['tray', 'minimize', 'close']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.2edf606c46',
'Minimize to Tray on Close'
)}
// Why: platform constraint + "close keeps Orca running" consequence are
// both non-obvious from the label alone.
description={translate(
'auto.components.settings.AppearancePane.b707773a0d',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
)}
checked={settings.minimizeToTrayOnClose === true}
onChange={() =>
updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose })
}
/>
</SearchableSetting>
) : null}
</div>
</AppearanceAdvancedDisclosure>
) : null}
</div>
)
}

View File

@ -10,10 +10,18 @@ import type { GlobalSettings } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
state: {
availableStatusBarToggles: [] as {
description: string
id: 'ports'
keywords: string[]
title: string
toggleDescription: string
}[],
settingsSearchQuery: 'automations',
statusBarItems: [],
toggleStatusBarItem: vi.fn(),
recordFeatureInteraction: vi.fn()
recordFeatureInteraction: vi.fn(),
setWorktreeCardMode: vi.fn()
}
}))
@ -26,7 +34,7 @@ vi.mock('@/hooks/useShortcutLabel', () => ({
}))
vi.mock('../status-bar/use-available-status-bar-toggles', () => ({
useAvailableStatusBarToggles: () => []
useAvailableStatusBarToggles: () => mocks.state.availableStatusBarToggles
}))
vi.mock('./TerminalAppearanceSection', () => ({
@ -85,6 +93,7 @@ vi.mock('../ui/select', async () => {
})
import { AppearancePane } from './AppearancePane'
import { TooltipProvider } from '../ui/tooltip'
const mountedRoots: Root[] = []
@ -130,16 +139,18 @@ async function renderAppearancePane(
await act(async () => {
root.render(
<I18nextProvider i18n={i18n}>
<AppearancePane
settings={settings}
updateSettings={updateSettings}
applyTheme={vi.fn()}
fontSuggestions={[]}
terminalFontSuggestions={[]}
systemPrefersDark={false}
ghostty={createGhosttyStub() as never}
warpThemes={createWarpThemesStub() as never}
/>
<TooltipProvider>
<AppearancePane
settings={settings}
updateSettings={updateSettings}
applyTheme={vi.fn()}
fontSuggestions={[]}
terminalFontSuggestions={[]}
systemPrefersDark={false}
ghostty={createGhosttyStub() as never}
warpThemes={createWarpThemesStub() as never}
/>
</TooltipProvider>
</I18nextProvider>
)
})
@ -159,7 +170,22 @@ describe('AppearancePane', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.state.availableStatusBarToggles = []
mocks.state.settingsSearchQuery = 'automations'
// UIZoomControl reads window.api.ui on mount; the inline-expansion pane can
// render the full Interface section, so provide a minimal renderer bridge
// without clobbering happy-dom's window.location.
;(window as unknown as { api: unknown }).api = {
ui: {
getZoomLevel: () => 0,
onTerminalZoom: () => () => {},
set: vi.fn()
}
}
})
afterEach(() => {
delete (window as unknown as { api?: unknown }).api
})
it('renders the language dropdown with system, english, chinese, korean, japanese, and spanish options', async () => {
@ -236,4 +262,117 @@ describe('AppearancePane', () => {
expect(updateSettings).toHaveBeenCalledWith({ showAutomationsButton: true })
})
it('changes workspace card layout from the Appearance sidebar controls', async () => {
mocks.state.settingsSearchQuery = 'workspace card layout'
const settings = {
...getDefaultSettings('/tmp'),
compactWorktreeCards: false
}
const container = await renderAppearancePane(settings)
const compactButton = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[role="radio"]')
).find((button) => button.textContent === 'Compact')
expect(compactButton).toBeDefined()
await act(async () => {
compactButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.state.setWorktreeCardMode).toHaveBeenCalledWith('Compact')
})
it('renders the three top-level section rows and no Code & Markdown row when not searching', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(container.textContent).toContain('Interface')
expect(container.textContent).toContain('Terminal')
expect(container.textContent).toContain('Window & Sidebar')
// Code & Markdown is intentionally omitted — Orca has no Appearance-level
// code/markdown settings, so the row would be empty.
expect(container.textContent).not.toContain('Code & Markdown')
})
it('keeps the app icon control at the bottom of the pane, after the section rows', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
const buttons = Array.from(container.querySelectorAll('button'))
const interfaceRow = buttons.find((button) => button.textContent?.includes('Interface'))
const appIconImage = container.querySelector<HTMLImageElement>('img[alt="Selected app icon"]')
expect(interfaceRow).toBeDefined()
expect(appIconImage).not.toBeNull()
// The App Icon block sits after the Interface section row in document order.
expect(
interfaceRow &&
appIconImage &&
interfaceRow.compareDocumentPosition(appIconImage) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
})
it('reveals an advanced sidebar control when its search matches, even though it is hidden by default', async () => {
// The Show Tasks Button toggle lives behind the Window & Sidebar Advanced
// disclosure; with no search it stays collapsed, but a matching query must
// force the disclosure open so the control is reachable.
mocks.state.settingsSearchQuery = ''
const collapsedContainer = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(
collapsedContainer.querySelector('button[role="switch"][aria-label="Show Tasks Button"]')
).toBeNull()
mocks.state.settingsSearchQuery = 'tasks'
const searchedContainer = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(
searchedContainer.querySelector('button[role="switch"][aria-label="Show Tasks Button"]')
).not.toBeNull()
})
it('keeps description-only search matches visible after helper text is hidden', async () => {
mocks.state.settingsSearchQuery = 'app window'
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(container.textContent).toContain('Theme')
expect(container.textContent).not.toContain('Advanced')
})
it('shows useful primary rows for a Window & Sidebar section-label search', async () => {
mocks.state.settingsSearchQuery = 'Window & Sidebar'
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(container.textContent).toContain('Left Sidebar Appearance')
expect(container.textContent).toContain('Status Bar')
expect(container.textContent).not.toContain('Advanced')
})
it('expands status bar controls for a section-label search', async () => {
mocks.state.availableStatusBarToggles = [
{
id: 'ports',
title: 'Ports',
description: 'Show live workspace ports in the status bar.',
toggleDescription: 'Show Ports in the status bar.',
keywords: ['status bar', 'ports']
}
]
mocks.state.settingsSearchQuery = 'status bar'
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
expect(container.querySelector('button[role="switch"][aria-label="Ports"]')).not.toBeNull()
})
it('collapses sibling sections so only the Interface section is expanded by default', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
const expanded = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-expanded="true"]')
).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-'))
expect(expanded).toHaveLength(1)
expect(expanded[0]?.textContent).toContain('Interface')
})
})

View File

@ -1,36 +1,22 @@
/* eslint-disable max-lines -- Why: AppearancePane keeps theme, typography, zoom, and status-bar
visibility settings together so the searchable settings rows share one filtered surface. */
import type React from 'react'
import { useState } from 'react'
import { AppWindow, PanelLeft, TerminalSquare } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/types'
import { Separator } from '../ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { UIZoomControl } from './UIZoomControl'
import { AppearanceSection } from './AppearanceSection'
import { AppearanceInterfaceSection } from './AppearanceInterfaceSection'
import { AppearanceWindowSidebarSection } from './AppearanceWindowSidebarSection'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
import { useAppStore } from '../../store'
import { useShortcutKeyComboDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import {
FontAutocomplete,
SettingsRow,
SettingsSegmentedControl,
SettingsSubsectionHeader,
SettingsSwitchRow
} from './SettingsFormControls'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
import { normalizeAppIconId } from '../../../../shared/app-icon'
import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles'
import {
getAppIconEntries,
getAppearancePaneSearchEntries,
getLanguageEntries,
getLayoutEntries,
getLeftSidebarAppearanceEntry,
getSidebarEntries,
getStatusBarEntries,
getStatusBarToggles,
getSystemTrayEntries,
getThemeEntries,
getTitlebarEntries,
@ -42,17 +28,15 @@ import { TerminalAppearanceSection } from './TerminalAppearanceSection'
import type { UseGhosttyImportReturn } from './useGhosttyImport'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { AppIconSelector } from './AppIconSelector'
import { normalizeAppIconId } from '../../../../shared/app-icon'
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { isWebClientLocation } from '@/lib/web-client-location'
import {
getUiLanguageChoiceLabel,
SHOW_UI_LANGUAGE_SETTING,
UI_LANGUAGE_CHOICES
} from '@/i18n/supported-languages'
import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages'
import { translate } from '@/i18n/i18n'
import type { UiLanguage } from '../../../../shared/ui-language'
import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting'
import { getWorkspaceCardLayoutEntry } from './appearance-sidebar-search'
import {
getLeftSidebarAppearanceEntry,
getWorkspaceCardLayoutEntry
} from './appearance-sidebar-search'
export { getAppearancePaneSearchEntries }
type AppearancePaneProps = {
@ -66,28 +50,16 @@ type AppearancePaneProps = {
warpThemes: UseWarpThemeImportReturn
}
function ShortcutHintList({ combos }: { combos: ShortcutKeyComboDetails[] }): React.JSX.Element {
if (combos.length === 0) {
return (
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.AppearancePane.3057983501', 'Unassigned')}
</span>
)
}
type AppearanceSectionKey = 'interface' | 'terminal' | 'window'
return (
<span className="inline-flex flex-wrap items-center gap-1 align-middle">
{combos.map((combo) => (
<ShortcutKeyCombo
key={combo.keys.join('-')}
keys={combo.keys}
doubleTap={combo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
))}
</span>
)
function resolveThemeSummary(theme: GlobalSettings['theme']): string {
if (theme === 'system') {
return translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System')
}
if (theme === 'light') {
return translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light')
}
return translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark')
}
export function AppearancePane({
@ -101,479 +73,165 @@ export function AppearancePane({
warpThemes
}: AppearancePaneProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const isWebClient = isWebClientLocation()
// Why: the system tray behavior is desktop-Electron Windows-only; a Windows
// browser web client has no local tray to control.
const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
const statusBarItems = useAppStore((state) => state.statusBarItems)
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
const terminalAppearanceSearchEntries = getTerminalAppearanceSearchEntries({
showWarpImport: !isWebClient
const [manuallyOpenSection, setManuallyOpenSection] = useState<AppearanceSectionKey | null>(
'interface'
)
const interfaceTitle = translate(
'auto.components.settings.AppearancePane.interfaceTitle',
'Interface'
)
const terminalTitle = translate(
'auto.components.settings.AppearancePane.terminalTitle',
'Terminal'
)
const windowSidebarTitle = translate(
'auto.components.settings.AppearancePane.windowSidebarTitle',
'Window & Sidebar'
)
const windowSidebarSummary = translate(
'auto.components.settings.AppearancePane.windowSidebarSummary',
'Sidebar, status bar, and file explorer'
)
// Search-entry buckets per section so a query can force-open the matching one.
const interfaceSearchEntries = [
{ title: interfaceTitle },
...getThemeEntries(),
...getZoomEntries(),
...getTypographyEntries(),
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTitlebarEntries(),
...getSystemTrayEntries({ showSystemTray: isDesktopWindows })
]
const terminalSearchEntries = [
{ title: terminalTitle },
...getTerminalAppearanceSearchEntries({ showWarpImport: !isWebClient })
]
const windowSearchEntries = [
{
title: windowSidebarTitle,
description: windowSidebarSummary
},
...getStatusBarEntries(),
...getSidebarEntries(),
...getLayoutEntries(),
getLeftSidebarAppearanceEntry(),
getWorkspaceCardLayoutEntry()
]
const interfaceMatches = matchesSettingsSearch(searchQuery, interfaceSearchEntries)
const terminalMatches = matchesSettingsSearch(searchQuery, terminalSearchEntries)
const windowMatches = matchesSettingsSearch(searchQuery, windowSearchEntries)
const interfaceLabelMatches = matchesSettingsSearch(searchQuery, { title: interfaceTitle })
const terminalLabelMatches = matchesSettingsSearch(searchQuery, { title: terminalTitle })
const windowLabelMatches = matchesSettingsSearch(searchQuery, {
title: windowSidebarTitle,
description: windowSidebarSummary
})
const systemTrayEntries = getSystemTrayEntries({ showSystemTray: isDesktopWindows })
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
const visibleSections = [
matchesSettingsSearch(searchQuery, getThemeEntries()) ||
(SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries())) ||
matchesSettingsSearch(searchQuery, getZoomEntries()) ||
matchesSettingsSearch(searchQuery, getTypographyEntries()) ? (
<section key="interface" className="divide-y divide-border/40">
{matchesSettingsSearch(searchQuery, getThemeEntries()) ? (
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme')}
description={translate(
'auto.components.settings.AppearancePane.0f28e7b30c',
'Choose how Orca looks in the app window.'
)}
keywords={getThemeEntries()[0]?.keywords ?? ['dark', 'light', 'system']}
>
<SettingsRow
label={translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme')}
description={translate(
'auto.components.settings.AppearancePane.0f28e7b30c',
'Choose how Orca looks in the app window.'
)}
control={
<SettingsSegmentedControl
ariaLabel={translate(
'auto.components.settings.AppearancePane.932ff1fbff',
'Theme'
)}
value={settings.theme}
onChange={(option) => {
updateSettings({ theme: option })
applyTheme(option)
}}
options={[
{
value: 'system',
label: translate(
'auto.components.settings.AppearancePane.fb0e0b4453',
'System'
)
},
{
value: 'dark',
label: translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark')
},
{
value: 'light',
label: translate(
'auto.components.settings.AppearancePane.fd89b5487c',
'Light'
)
}
]}
/>
}
/>
</SearchableSetting>
) : null}
const appIconMatches = matchesSettingsSearch(searchQuery, getAppIconEntries())
{SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries()) ? (
<SearchableSetting
title={translate('settings.appearance.language.title', 'Language')}
description={translate(
'settings.appearance.language.description',
'Choose the language used by the Orca interface.'
)}
keywords={getLanguageEntries()[0]?.keywords ?? []}
>
<SettingsRow
label={translate('settings.appearance.language.title', 'Language')}
description={translate(
'settings.appearance.language.description',
'Choose the language used by the Orca interface.'
)}
control={
<Select
value={settings.uiLanguage}
onValueChange={(value) => updateSettings({ uiLanguage: value as UiLanguage })}
>
<SelectTrigger
size="sm"
className="min-w-[220px]"
aria-label={translate('settings.appearance.language.title', 'Language')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{UI_LANGUAGE_CHOICES.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{getUiLanguageChoiceLabel(choice, translate)}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</SearchableSetting>
) : null}
// While searching, force-open every section that contains a match so its
// controls (including advanced ones) are revealed; otherwise the accordion
// shows exactly one manually-chosen section.
function isSectionOpen(key: AppearanceSectionKey): boolean {
if (isSearching) {
return key === 'interface'
? interfaceMatches
: key === 'terminal'
? terminalMatches
: windowMatches
}
return manuallyOpenSection === key
}
{matchesSettingsSearch(searchQuery, getZoomEntries()) ? (
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
description={translate(
'auto.components.settings.AppearancePane.622e1c3465',
'Scale the entire application interface.'
)}
keywords={getZoomEntries()[0]?.keywords ?? ['zoom', 'scale', 'shortcut']}
>
<SettingsRow
label={translate('auto.components.settings.AppearancePane.5e6d7aba8d', 'UI Zoom')}
description={
<>
{translate(
'auto.components.settings.AppearancePane.f687711a9b',
'Scale the entire application interface. Use'
)}{' '}
<ShortcutHintList combos={zoomInKeyCombos} /> /{' '}
<ShortcutHintList combos={zoomOutKeyCombos} />{' '}
{translate(
'auto.components.settings.AppearancePane.ef89200c1f',
'when not in a terminal pane.'
)}
</>
}
control={<UIZoomControl />}
/>
</SearchableSetting>
) : null}
function toggleSection(key: AppearanceSectionKey): void {
setManuallyOpenSection((current) => (current === key ? null : key))
}
{matchesSettingsSearch(searchQuery, getTypographyEntries()) ? (
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
description={translate(
'auto.components.settings.AppearancePane.42554f615f',
'Choose the font used by the Orca interface.'
)}
keywords={getTypographyEntries()[0]?.keywords ?? ['font', 'typeface', 'typography']}
>
<SettingsRow
alignTop
label={translate('auto.components.settings.AppearancePane.102d6b5f9b', 'IDE Font')}
description={translate(
'auto.components.settings.AppearancePane.42554f615f',
'Choose the font used by the Orca interface.'
)}
control={
<FontAutocomplete
value={settings.appFontFamily}
suggestions={fontSuggestions}
placeholder={DEFAULT_APP_FONT_FAMILY}
onChange={(value) =>
updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY })
}
/>
}
/>
</SearchableSetting>
) : null}
</section>
) : null,
matchesSettingsSearch(searchQuery, terminalAppearanceSearchEntries) ? (
<TerminalAppearanceSection
key="terminal-appearance"
settings={settings}
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
ghostty={ghostty}
warpThemes={warpThemes}
/>
) : null,
matchesSettingsSearch(searchQuery, getLayoutEntries()) ? (
<section key="layout" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.d496901cd0', 'File Explorer')}
/>
const interfaceSummary = `${resolveThemeSummary(settings.theme)} · ${
settings.appFontFamily ||
translate('auto.components.settings.AppearancePane.interfaceDefaultFont', 'Default font')
}`
const terminalSummary = `${
settings.terminalFontFamily ||
translate('auto.components.settings.AppearancePane.terminalDefaultFont', 'Default font')
} · ${settings.terminalFontSize}px`
<div className="divide-y divide-border/40">
<SearchableSetting
title={
getLayoutEntries()[0]?.title ??
translate(
'auto.components.settings.AppearancePane.0fafabcf35',
'Show Git-Ignored Files'
)
}
description={
getLayoutEntries()[0]?.description ??
translate(
'auto.components.settings.AppearancePane.75f07ab60c',
'Show files matched by .gitignore in the file explorer.'
)
}
keywords={getLayoutEntries()[0]?.keywords ?? ['git', 'gitignore', 'ignored']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.0fafabcf35',
'Show Git-Ignored Files'
)}
description={translate(
'auto.components.settings.AppearancePane.e9f2ca5582',
'Turn off to hide files matched by .gitignore from the file explorer.'
)}
checked={settings.showGitIgnoredFiles ?? true}
onChange={() =>
updateSettings({ showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true) })
}
/>
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, getTitlebarEntries()) ? (
<section key="titlebar" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.6a272ca553', 'Titlebar')}
description={translate(
'auto.components.settings.AppearancePane.4de76f6902',
'Control what appears in the application titlebar.'
)}
/>
return (
<div className="space-y-2.5">
{interfaceMatches ? (
<AppearanceSection
id="interface"
icon={<AppWindow aria-hidden="true" />}
title={interfaceTitle}
summary={interfaceSummary}
open={isSectionOpen('interface')}
onToggle={() => toggleSection('interface')}
>
<AppearanceInterfaceSection
settings={settings}
updateSettings={updateSettings}
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
isDesktopWindows={isDesktopWindows}
forceVisiblePrimary={interfaceLabelMatches}
/>
</AppearanceSection>
) : null}
<div className="divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.9868f39007',
'Titlebar App Name'
)}
description={translate(
'auto.components.settings.AppearancePane.2df8f79aa5',
'Show Orca in the titlebar.'
)}
keywords={getTitlebarEntries()[0]?.keywords ?? ['titlebar', 'orca', 'app', 'name']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.9868f39007',
'Titlebar App Name'
)}
description={translate(
'auto.components.settings.AppearancePane.2df8f79aa5',
'Show Orca in the titlebar.'
)}
checked={settings.showTitlebarAppName}
onChange={() =>
updateSettings({ showTitlebarAppName: !settings.showTitlebarAppName })
}
/>
</SearchableSetting>
</div>
</section>
) : null,
isDesktopWindows && matchesSettingsSearch(searchQuery, systemTrayEntries) ? (
<section key="system-tray" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.872af9556e', 'System Tray')}
/>
{/* Why: Code & Markdown is intentionally omitted. Orca has no Appearance-level
code/markdown settings the Monaco editor reuses the terminal font and
there is no markdown-style or line-number setting so a fourth row would
be empty. We surface only the three sections that hold real controls
rather than fabricate settings. */}
<div className="divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.2edf606c46',
'Minimize to Tray on Close'
)}
description={translate(
'auto.components.settings.AppearancePane.b707773a0d',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
)}
keywords={systemTrayEntries[0]?.keywords ?? ['tray', 'minimize', 'close']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.2edf606c46',
'Minimize to Tray on Close'
)}
description={translate(
'auto.components.settings.AppearancePane.b707773a0d',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
)}
checked={settings.minimizeToTrayOnClose === true}
onChange={() =>
updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose })
}
/>
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, getStatusBarEntries()) ? (
<section key="status-bar" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.3e4175e5c6', 'Status Bar')}
description={translate(
'auto.components.settings.AppearancePane.ea943d0db0',
'Choose which indicators appear at the bottom of the window. You can also right-click the status bar for the same toggles.'
)}
/>
{terminalMatches ? (
<AppearanceSection
id="terminal"
icon={<TerminalSquare aria-hidden="true" />}
title={terminalTitle}
summary={terminalSummary}
open={isSectionOpen('terminal')}
onToggle={() => toggleSection('terminal')}
>
<TerminalAppearanceSection
settings={settings}
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
ghostty={ghostty}
warpThemes={warpThemes}
forceVisiblePrimary={terminalLabelMatches}
/>
</AppearanceSection>
) : null}
<div className="divide-y divide-border/40">
{visibleStatusBarToggles.map((toggle) => {
const enabled = statusBarItems.includes(toggle.id)
return (
<SearchableSetting
key={toggle.id}
title={toggle.title}
description={toggle.description}
keywords={toggle.keywords}
>
<SettingsSwitchRow
label={toggle.title}
description={toggle.toggleDescription}
checked={enabled}
onChange={() => {
if (toggle.id === 'resource-usage') {
recordFeatureInteraction('resource-manager')
} else if (toggle.id === 'ports') {
recordFeatureInteraction('ports')
} else if (toggle.id === 'ssh') {
recordFeatureInteraction('ssh')
} else if (
toggle.id === 'claude' ||
toggle.id === 'codex' ||
toggle.id === 'gemini' ||
toggle.id === 'opencode-go'
) {
recordFeatureInteraction('usage-tracking')
}
toggleStatusBarItem(toggle.id)
}}
ariaLabel={toggle.title}
/>
</SearchableSetting>
)
})}
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, getSidebarEntries()) ? (
<section key="sidebar" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.dc29f3cc0d', 'Sidebar')}
/>
{windowMatches ? (
<AppearanceSection
id="window"
icon={<PanelLeft aria-hidden="true" />}
title={windowSidebarTitle}
summary={windowSidebarSummary}
open={isSectionOpen('window')}
onToggle={() => toggleSection('window')}
>
<AppearanceWindowSidebarSection
settings={settings}
updateSettings={updateSettings}
forceVisiblePrimary={windowLabelMatches}
/>
</AppearanceSection>
) : null}
<div className="divide-y divide-border/40">
<SearchableSetting
title={leftSidebarAppearanceEntry.title}
description={leftSidebarAppearanceEntry.description}
keywords={leftSidebarAppearanceEntry.keywords}
className="space-y-2"
>
<LeftSidebarAppearanceSetting settings={settings} updateSettings={updateSettings} />
</SearchableSetting>
{/* Why: this setting lives with the sidebar layout controls; Settings only
points people to it so we do not create a second stateful control. */}
<SearchableSetting
title={workspaceCardLayoutEntry.title}
description={workspaceCardLayoutEntry.description}
keywords={workspaceCardLayoutEntry.keywords}
>
<SettingsRow
label={workspaceCardLayoutEntry.title}
description={translate(
'auto.components.settings.AppearancePane.workspaceCardLayoutGuidance',
'Use the workspace sidebar options menu > Card layout > Compact.'
)}
control={null}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.cf81907069',
'Show Tasks Button'
)}
description={translate(
'auto.components.settings.AppearancePane.661942ab7f',
'Show the Tasks button at the top of the left sidebar.'
)}
keywords={getSidebarEntries()[0]?.keywords ?? ['tasks', 'sidebar', 'button']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.cf81907069',
'Show Tasks Button'
)}
description={translate(
'auto.components.settings.AppearancePane.661942ab7f',
'Show the Tasks button at the top of the left sidebar.'
)}
checked={settings.showTasksButton !== false}
onChange={() =>
updateSettings({ showTasksButton: !(settings.showTasksButton !== false) })
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.511f270ebb',
'Show Automations Button'
)}
description={translate(
'auto.components.settings.AppearancePane.fa882a3e6b',
'Show the Automations button at the top of the left sidebar.'
)}
keywords={getSidebarEntries()[1]?.keywords ?? ['automations', 'automation', 'schedule']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.511f270ebb',
'Show Automations Button'
)}
description={translate(
'auto.components.settings.AppearancePane.fa882a3e6b',
'Show the Automations button at the top of the left sidebar.'
)}
checked={settings.showAutomationsButton !== false}
onChange={() =>
updateSettings({
showAutomationsButton: !(settings.showAutomationsButton !== false)
})
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.9da1020447',
'Show Orca Mobile Button'
)}
description={translate(
'auto.components.settings.AppearancePane.5db6ba961f',
'Show the Orca Mobile button at the top of the left sidebar.'
)}
keywords={getSidebarEntries()[2]?.keywords ?? ['mobile', 'phone', 'sidebar']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.9da1020447',
'Show Orca Mobile Button'
)}
description={translate(
'auto.components.settings.AppearancePane.61d842eca0',
'Show the Orca Mobile shortcut in the sidebar. It remains available from Toolbox.'
)}
checked={settings.showMobileButton !== false}
onChange={() =>
updateSettings({ showMobileButton: !(settings.showMobileButton !== false) })
}
/>
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, getAppIconEntries()) ? (
<section key="app-icon" className="space-y-3">
{/* App icon stays at the bottom of Appearance as a small easter egg,
matching production not buried inside Interface advanced. */}
{appIconMatches ? (
<SearchableSetting
title={translate('auto.components.settings.AppearancePane.ca1590d42f', 'App Icon')}
description={translate(
@ -585,25 +243,14 @@ export function AppearancePane({
entry.description ?? '',
...(entry.keywords ?? [])
])}
className="max-w-none py-2"
className="max-w-none px-1 pt-2"
>
<AppIconSelector
value={normalizeAppIconId(settings.appIcon)}
onChange={(appIcon) => updateSettings({ appIcon })}
/>
</SearchableSetting>
</section>
) : null
].filter(Boolean)
return (
<div className="space-y-6">
{visibleSections.map((section, index) => (
<div key={index} className="space-y-6">
{index > 0 ? <Separator /> : null}
{section}
</div>
))}
) : null}
</div>
)
}

View File

@ -0,0 +1,78 @@
import type React from 'react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
type AppearanceSectionProps = {
/** Stable id used for the accordion toggle + aria wiring. */
id: string
icon: React.ReactNode
title: React.ReactNode
/** Plain-language current value shown in the collapsed summary row. */
summary: React.ReactNode
open: boolean
onToggle: () => void
children: React.ReactNode
}
/** Compact summary row that expands its section inline. The parent owns the
* open state so opening one row can collapse the previously open one
* (accordion behavior) and search can force a section open. */
export function AppearanceSection({
id,
icon,
title,
summary,
open,
onToggle,
children
}: AppearanceSectionProps): React.JSX.Element {
const contentId = `appearance-section-${id}`
return (
<div
className={cn(
'overflow-hidden rounded-xl border border-border/50 bg-card transition-colors',
open && 'border-ring/40'
)}
>
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-secondary text-foreground [&_svg]:size-4">
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold">{title}</span>
{!open ? (
<span className="block truncate text-xs text-muted-foreground">{summary}</span>
) : null}
</span>
<ChevronRight
className={cn(
'size-[18px] shrink-0 text-muted-foreground transition-transform',
open && 'rotate-90 text-foreground'
)}
/>
</button>
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,opacity,border-color] duration-200 ease-out motion-reduce:transition-none',
open
? 'grid-rows-[1fr] border-t border-border/50 opacity-100'
: 'grid-rows-[0fr] border-t border-transparent opacity-0'
)}
aria-hidden={!open}
inert={!open}
>
<div className="min-h-0 overflow-hidden">
<div id={contentId} role="region" className="px-4 pt-1 pb-4">
{children}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,34 @@
import type React from 'react'
import { type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import { translate } from '@/i18n/i18n'
/** Renders the primary keyboard shortcut combo inline, or an "Unassigned"
* hint when the action has no binding. Platform-aware glyphs come from
* ShortcutKeyCombo. */
export function ShortcutHintList({
combos
}: {
combos: ShortcutKeyComboDetails[]
}): React.JSX.Element {
if (combos.length === 0) {
return (
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.AppearancePane.3057983501', 'Unassigned')}
</span>
)
}
const primaryCombo = combos[0]
return (
<span className="inline-flex items-center align-middle">
<ShortcutKeyCombo
keys={primaryCombo.keys}
doubleTap={primaryCombo.doubleTap}
className="inline-flex gap-0.5"
separatorClassName="text-[10px] text-muted-foreground"
/>
</span>
)
}

View File

@ -0,0 +1,307 @@
import type React from 'react'
import type { GlobalSettings, StatusBarItem } from '../../../../shared/types'
import type { FeatureInteractionId } from '../../../../shared/feature-interaction-catalog'
import { SearchableSetting } from './SearchableSetting'
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
import { useAppStore } from '../../store'
import {
SettingsRow,
SettingsSegmentedControl,
SettingsSubsectionHeader,
SettingsSwitchRow
} from './SettingsFormControls'
import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles'
import { getLayoutEntries, getSidebarEntries, getStatusBarToggles } from './appearance-search'
import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting'
import {
getLeftSidebarAppearanceEntry,
getWorkspaceCardLayoutEntry
} from './appearance-sidebar-search'
import { translate } from '@/i18n/i18n'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
type AppearanceWindowSidebarSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
forceVisiblePrimary?: boolean
}
function recordStatusBarToggleInteraction(
id: StatusBarItem,
recordFeatureInteraction: (feature: FeatureInteractionId) => void
): void {
if (id === 'resource-usage') {
recordFeatureInteraction('resource-manager')
} else if (id === 'ports') {
recordFeatureInteraction('ports')
} else if (id === 'ssh') {
recordFeatureInteraction('ssh')
} else if (id === 'claude' || id === 'codex' || id === 'gemini' || id === 'opencode-go') {
recordFeatureInteraction('usage-tracking')
}
}
export function AppearanceWindowSidebarSection({
settings,
updateSettings,
forceVisiblePrimary = false
}: AppearanceWindowSidebarSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const statusBarItems = useAppStore((state) => state.statusBarItems)
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
const setWorktreeCardMode = useAppStore((state) => state.setWorktreeCardMode)
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
const sidebarEntries = getSidebarEntries()
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
const layoutEntries = getLayoutEntries()
const statusBarTitle = translate(
'auto.components.settings.AppearancePane.3e4175e5c6',
'Status Bar'
)
const statusBarDescription = translate(
'auto.components.settings.AppearancePane.statusBarDescription',
'Choose which indicators appear in the status bar.'
)
const statusBarKeywords = ['status bar', 'indicators']
const statusBarSectionMatches = matchesSettingsSearch(searchQuery, {
title: statusBarTitle,
description: statusBarDescription,
keywords: statusBarKeywords
})
const statusBarControlMatches = visibleStatusBarToggles.some((toggle) =>
matchesSettingsSearch(searchQuery, {
title: toggle.title,
description: toggle.description,
keywords: toggle.keywords
})
)
const sidebarAdvancedMatches = matchesSettingsSearch(searchQuery, [
workspaceCardLayoutEntry,
...sidebarEntries
])
const fileExplorerAdvancedMatches = matchesSettingsSearch(searchQuery, layoutEntries)
const showStatusBarControls = !isSearching || statusBarSectionMatches || statusBarControlMatches
const showSidebarAdvanced = !isSearching || sidebarAdvancedMatches
const showFileExplorerAdvanced = !isSearching || fileExplorerAdvancedMatches
const showAdvanced = showSidebarAdvanced || showFileExplorerAdvanced
return (
<div className="space-y-2">
<div className="divide-y divide-border/40">
<SearchableSetting
title={leftSidebarAppearanceEntry.title}
description={leftSidebarAppearanceEntry.description}
keywords={leftSidebarAppearanceEntry.keywords}
className="space-y-2"
forceVisible={forceVisiblePrimary}
>
<LeftSidebarAppearanceSetting settings={settings} updateSettings={updateSettings} />
</SearchableSetting>
<SearchableSetting
title={statusBarTitle}
keywords={statusBarKeywords}
forceVisible={forceVisiblePrimary || statusBarSectionMatches || statusBarControlMatches}
>
<SettingsRow label={statusBarTitle} description={statusBarDescription} control={null} />
{showStatusBarControls ? (
<div className="ml-4 divide-y divide-border/40 border-t border-border/40">
{visibleStatusBarToggles.map((toggle) => {
const enabled = statusBarItems.includes(toggle.id)
return (
<SearchableSetting
key={toggle.id}
title={toggle.title}
description={toggle.description}
keywords={toggle.keywords}
>
<SettingsSwitchRow
label={toggle.title}
description={toggle.toggleDescription}
checked={enabled}
onChange={() => {
recordStatusBarToggleInteraction(toggle.id, recordFeatureInteraction)
toggleStatusBarItem(toggle.id)
}}
ariaLabel={toggle.title}
/>
</SearchableSetting>
)
})}
</div>
) : null}
</SearchableSetting>
</div>
{showAdvanced ? (
<AppearanceAdvancedDisclosure contentClassName="ml-4 pt-4">
<div className="space-y-4">
{showSidebarAdvanced ? (
<div className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.dc29f3cc0d', 'Sidebar')}
/>
<div className="ml-4 divide-y divide-border/40">
{/* Why: this setting lives with the sidebar layout controls; Settings only
names that ownership so we do not create a second stateful control. */}
<SearchableSetting
title={workspaceCardLayoutEntry.title}
description={workspaceCardLayoutEntry.description}
keywords={workspaceCardLayoutEntry.keywords}
>
<SettingsRow
label={workspaceCardLayoutEntry.title}
description={workspaceCardLayoutEntry.description}
control={
<SettingsSegmentedControl
value={settings.compactWorktreeCards ? 'compact' : 'detailed'}
onChange={(value) =>
setWorktreeCardMode(value === 'compact' ? 'Compact' : 'Default')
}
ariaLabel={workspaceCardLayoutEntry.title}
options={[
{
value: 'detailed',
label: translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b',
'Detailed'
)
},
{
value: 'compact',
label: translate(
'auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb',
'Compact'
)
}
]}
/>
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.cf81907069',
'Show Tasks Button'
)}
description={sidebarEntries[0]?.description}
keywords={sidebarEntries[0]?.keywords ?? ['tasks', 'sidebar', 'button']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.cf81907069',
'Show Tasks Button'
)}
checked={settings.showTasksButton !== false}
onChange={() =>
updateSettings({ showTasksButton: !(settings.showTasksButton !== false) })
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.511f270ebb',
'Show Automations Button'
)}
description={sidebarEntries[1]?.description}
keywords={
sidebarEntries[1]?.keywords ?? ['automations', 'automation', 'schedule']
}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.511f270ebb',
'Show Automations Button'
)}
checked={settings.showAutomationsButton !== false}
onChange={() =>
updateSettings({
showAutomationsButton: !(settings.showAutomationsButton !== false)
})
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.9da1020447',
'Show Orca Mobile Button'
)}
description={sidebarEntries[2]?.description}
keywords={sidebarEntries[2]?.keywords ?? ['mobile', 'phone', 'sidebar']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.9da1020447',
'Show Orca Mobile Button'
)}
// Why: clarify where the shortcut still lives after hiding it, so users
// don't think the feature is gone.
description={translate(
'auto.components.settings.AppearancePane.61d842eca0',
'Show the Orca Mobile shortcut in the sidebar. It remains available from Toolbox.'
)}
checked={settings.showMobileButton !== false}
onChange={() =>
updateSettings({ showMobileButton: !(settings.showMobileButton !== false) })
}
/>
</SearchableSetting>
</div>
</div>
) : null}
{showFileExplorerAdvanced ? (
<div className="space-y-3">
<SettingsSubsectionHeader
title={translate(
'auto.components.settings.AppearancePane.d496901cd0',
'File Explorer'
)}
/>
<div className="ml-4 divide-y divide-border/40">
<SearchableSetting
title={
layoutEntries[0]?.title ??
translate(
'auto.components.settings.AppearancePane.0fafabcf35',
'Show Git-Ignored Files'
)
}
description={layoutEntries[0]?.description}
keywords={layoutEntries[0]?.keywords ?? ['git', 'gitignore', 'ignored']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.0fafabcf35',
'Show Git-Ignored Files'
)}
// Why: define what "git-ignored" matches; the location (file explorer)
// is obvious from the section header.
description={translate(
'auto.components.settings.AppearancePane.gitIgnoredGlossary',
'Files matched by .gitignore.'
)}
checked={settings.showGitIgnoredFiles ?? true}
onChange={() =>
updateSettings({
showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true)
})
}
/>
</SearchableSetting>
</div>
</div>
) : null}
</div>
</AppearanceAdvancedDisclosure>
) : null}
</div>
)
}

View File

@ -76,11 +76,19 @@ export function SettingsRow({
}: SettingsRowProps): React.JSX.Element {
return (
<div
className={cn('flex gap-4 py-2', alignTop ? 'items-start' : 'items-center justify-between')}
className={cn(
'flex gap-4',
description ? 'py-3' : 'py-2',
alignTop ? 'items-start' : 'items-center justify-between'
)}
>
<div className="min-w-0 flex-1 space-y-0.5">
<Label id={labelId}>{label}</Label>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
<div className={cn('min-w-0 flex-1', description ? 'space-y-1' : 'space-y-0.5')}>
<Label id={labelId} className="select-text">
{label}
</Label>
{description ? (
<p className="select-text text-xs text-muted-foreground">{description}</p>
) : null}
</div>
<div className="shrink-0">{control}</div>
</div>
@ -218,16 +226,18 @@ type SettingsSubsectionHeaderProps = {
title: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
className?: string
}
/** Consistent subsection header: h3 text-sm font-semibold + optional muted description. */
export function SettingsSubsectionHeader({
title,
description,
action
action,
className
}: SettingsSubsectionHeaderProps): React.JSX.Element {
return (
<div className="flex items-start justify-between gap-3">
<div className={cn('flex items-start justify-between gap-3', className)}>
<div className="space-y-1">
<h3 className="text-sm font-semibold">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}

View File

@ -0,0 +1,188 @@
import type { GlobalSettings } from '../../../../shared/types'
import {
DEFAULT_TERMINAL_FONT_WEIGHT,
TERMINAL_FONT_WEIGHT_MAX,
TERMINAL_FONT_WEIGHT_MIN,
TERMINAL_FONT_WEIGHT_STEP,
normalizeTerminalFontWeight
} from '../../../../shared/terminal-fonts'
import {
fontFamilyHasKnownLigatures,
resolveTerminalLigaturesEnabled
} from '../../../../shared/terminal-ligatures'
import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import { clampNumber } from '@/lib/terminal-theme'
import { translate } from '@/i18n/i18n'
import { getTerminalAdvancedTypographySearchEntries } from './terminal-typography-search'
type TerminalAdvancedTypographyControlsProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
/** Low-frequency terminal typography knobs (weight, line height, ligatures).
* Split out of the primary font controls so the default Terminal scan stays
* compact while these stay searchable inside the Advanced disclosure. */
export function TerminalAdvancedTypographyControls({
settings,
updateSettings
}: TerminalAdvancedTypographyControlsProps): React.JSX.Element {
const searchEntries = getTerminalAdvancedTypographySearchEntries()
return (
<div className="divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.4aae5db258',
'Font Weight'
)}
description={searchEntries[0]?.description}
keywords={searchEntries[0]?.keywords ?? ['terminal', 'typography', 'weight']}
>
<NumberField
label={translate(
'auto.components.settings.TerminalAppearanceSection.4aae5db258',
'Font Weight'
)}
description=""
value={normalizeTerminalFontWeight(settings.terminalFontWeight)}
defaultValue={DEFAULT_TERMINAL_FONT_WEIGHT}
min={TERMINAL_FONT_WEIGHT_MIN}
max={TERMINAL_FONT_WEIGHT_MAX}
step={TERMINAL_FONT_WEIGHT_STEP}
suffix="100-900"
onChange={(value) =>
updateSettings({ terminalFontWeight: normalizeTerminalFontWeight(value) })
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.c084eb7d4c',
'Line Height'
)}
description={searchEntries[1]?.description}
keywords={
searchEntries[1]?.keywords ?? ['terminal', 'typography', 'line height', 'spacing']
}
>
<NumberField
label={translate(
'auto.components.settings.TerminalAppearanceSection.c084eb7d4c',
'Line Height'
)}
description=""
value={settings.terminalLineHeight}
defaultValue={1}
min={1}
max={3}
step={0.1}
suffix="1-3"
onChange={(value) => updateSettings({ terminalLineHeight: clampNumber(value, 1, 3) })}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.be8da35e7f',
'Font Ligatures'
)}
description={searchEntries[2]?.description}
keywords={
searchEntries[2]?.keywords ?? [
'terminal',
'typography',
'ligatures',
'ligature',
'fira code',
'jetbrains mono',
'cascadia code',
'iosevka',
'calt',
'font features'
]
}
>
<SettingsRow
label={translate(
'auto.components.settings.TerminalAppearanceSection.be8da35e7f',
'Font Ligatures'
)}
// Why: "ligatures" is jargon; the per-state gloss tells the user whether
// Auto resolves on/off for their current font.
description={
settings.terminalLigatures === 'on'
? translate(
'auto.components.settings.TerminalAppearanceSection.7234abcd08',
'Always on. Fonts without ligatures simply render as-is.'
)
: settings.terminalLigatures === 'off'
? translate(
'auto.components.settings.TerminalAppearanceSection.04569feb07',
'Always off, even for fonts that ship them.'
)
: fontFamilyHasKnownLigatures(settings.terminalFontFamily)
? translate(
'auto.components.settings.TerminalAppearanceSection.400e950ca5',
'Auto - enabled for "{{value0}}".',
{ value0: settings.terminalFontFamily }
)
: translate(
'auto.components.settings.TerminalAppearanceSection.4b1f29598e',
'Auto - disabled for "{{value0}}".',
{ value0: settings.terminalFontFamily || 'the current font' }
)
}
control={
<SettingsSegmentedControl
ariaLabel={translate(
'auto.components.settings.TerminalAppearanceSection.be8da35e7f',
'Font Ligatures'
)}
value={settings.terminalLigatures ?? 'auto'}
onChange={(option) => updateSettings({ terminalLigatures: option })}
options={[
{
value: 'auto',
label: translate(
'auto.components.settings.TerminalAppearanceSection.bc9ff84d61',
'Auto'
)
},
{
value: 'on',
label: translate(
'auto.components.settings.TerminalAppearanceSection.84bd22f2cd',
'On'
)
},
{
value: 'off',
label: translate(
'auto.components.settings.TerminalAppearanceSection.870377082f',
'Off'
)
}
]}
/>
}
/>
<p className="sr-only" aria-live="polite">
{translate(
'auto.components.settings.TerminalAppearanceSection.31f6e61085',
'Ligatures are currently'
)}{' '}
{resolveTerminalLigaturesEnabled(settings.terminalLigatures, settings.terminalFontFamily)
? translate('auto.components.settings.TerminalAppearanceSection.4e7d41a9f0', 'enabled')
: translate(
'auto.components.settings.TerminalAppearanceSection.4415beb958',
'disabled'
)}
.
</p>
</SearchableSetting>
</div>
)
}

View File

@ -97,8 +97,8 @@ vi.mock('./SettingsFormControls', () => ({
}) {
return options?.map((option) => option.label) ?? null
},
SettingsSubsectionHeader: function SettingsSubsectionHeader() {
return null
SettingsSubsectionHeader: function SettingsSubsectionHeader({ action }: { action?: unknown }) {
return action ?? null
},
SettingsSwitchRow: function SettingsSwitchRow() {
return null
@ -322,6 +322,30 @@ function findWarpThemeImportModal(node: unknown): ReactElementLike | null {
return null
}
function findComponentByTypeName(node: unknown, targetTypeName: string): ReactElementLike | null {
if (node == null) {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findComponentByTypeName(child, targetTypeName)
if (found) {
return found
}
}
return null
}
const el = node as ReactElementLike
const typeName = typeof el.type === 'function' ? el.type.name : String(el.type)
if (typeName === targetTypeName) {
return el
}
if (el.props?.children) {
return findComponentByTypeName(el.props.children, targetTypeName)
}
return null
}
describe('TerminalAppearanceSection ghostty import wiring', () => {
beforeEach(() => {
mockStateValues.length = 0
@ -421,6 +445,68 @@ describe('TerminalAppearanceSection ghostty import wiring', () => {
expect(findTerminalThemeCatalogSection(darkPhraseElement)?.props.preferredTarget).toBe('dark')
})
it('does not open advanced typography for primary terminal font searches', () => {
mockSettingsSearchQuery = 'font size'
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findComponentByTypeName(element, 'TerminalAdvancedTypographyControls')).toBeNull()
})
it('does not show primary typography chrome for unrelated terminal searches', () => {
mockSettingsSearchQuery = 'cursor opacity'
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findComponentByTypeName(element, 'TerminalFontSizeSetting')).toBeNull()
expect(findButtons(element).some((button) => button.text === 'Import from Ghostty')).toBe(false)
})
it('shows the Ghostty import button for Ghostty-only searches', () => {
mockSettingsSearchQuery = 'ghostty'
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findButtons(element).some((button) => button.text === 'Import from Ghostty')).toBe(true)
})
it('opens typography advanced inside the typography section for advanced searches', () => {
mockSettingsSearchQuery = 'line height'
const element = TerminalAppearanceSection({
settings: {} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
ghostty: ghosttyMock,
warpThemes: warpThemesMock
})
expect(findComponentByTypeName(element, 'TerminalAdvancedTypographyControls')).not.toBeNull()
expect(findComponentByTypeName(element, 'TerminalFontSizeSetting')).not.toBeNull()
})
it('hides the theme import affordance on paired web clients', () => {
vi.stubGlobal('window', {
__ORCA_WEB_CLIENT__: true,

View File

@ -2,32 +2,42 @@ import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import {
matchesSettingsSearch,
normalizeSettingsSearchQuery,
scoreSettingsSearch,
type SettingsSearchEntry
} from './settings-search'
import { useAppStore } from '../../store'
import {
getTerminalAdvancedTypographySearchEntries,
getTerminalCursorSearchEntries,
getTerminalDarkThemeSearchEntries,
getTerminalGhosttyImportSearchEntries,
getTerminalLightThemeSearchEntries,
getTerminalPaneAppearanceSearchEntries,
getTerminalThemeTargetSearchEntries,
getTerminalTypographySearchEntries,
getTerminalWarpImportSearchEntries,
getTerminalWindowSearchEntries,
getTerminalYamlImportSearchEntries
getTerminalYamlImportSearchEntries,
getTerminalTypographySearchEntries,
getTerminalWindowSearchEntries
} from './terminal-search'
import { Button } from '../ui/button'
import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls'
import { SearchableSetting } from './SearchableSetting'
import { FontAutocomplete } from './SettingsFormControls'
import { TerminalFontSizeSetting } from './TerminalFontSizeSetting'
import { TerminalAdvancedTypographyControls } from './TerminalAdvancedTypographyControls'
import { TerminalThemeCatalogSection } from './TerminalThemeSections'
import { TerminalWindowSection } from './TerminalWindowSection'
import { TerminalTypographyAppearanceSection } from './TerminalTypographyAppearanceSection'
import { TerminalCursorAppearanceSection } from './TerminalCursorAppearanceSection'
import { TerminalPaneAppearanceSection } from './TerminalPaneAppearanceSection'
import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure'
import { GhosttyImportModal } from './GhosttyImportModal'
import type { UseGhosttyImportReturn } from './useGhosttyImport'
import { WarpThemeImportModal } from './WarpThemeImportModal'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { isWebClientLocation } from '@/hooks/useSettingsNavigationMetadata'
import ghosttyIcon from '../../../../../resources/ghostty.svg'
import { translate } from '@/i18n/i18n'
type TerminalAppearanceSectionProps = {
settings: GlobalSettings
@ -36,6 +46,7 @@ type TerminalAppearanceSectionProps = {
terminalFontSuggestions: string[]
ghostty: UseGhosttyImportReturn
warpThemes: UseWarpThemeImportReturn
forceVisiblePrimary?: boolean
}
type TerminalThemeTarget = 'dark' | 'light'
@ -64,88 +75,194 @@ export function TerminalAppearanceSection({
systemPrefersDark,
terminalFontSuggestions,
ghostty,
warpThemes
warpThemes,
forceVisiblePrimary = false
}: TerminalAppearanceSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
const [themeSearch, setThemeSearch] = useState('')
const [previewFontFamily, setPreviewFontFamily] = useState<string | null>(null)
const showWarpThemeImport = !isWebClientLocation()
const darkThemeSearchEntries = getTerminalDarkThemeSearchEntries()
const lightThemeSearchEntries = getTerminalLightThemeSearchEntries()
const darkThemeSearchScore = scoreSettingsSearch(searchQuery, darkThemeSearchEntries)
const lightThemeSearchScore = scoreSettingsSearch(searchQuery, lightThemeSearchEntries)
const terminalTypographyEntries = getTerminalTypographySearchEntries()
const ghosttyImportEntries = getTerminalGhosttyImportSearchEntries()
const themeCatalogSearchEntries = [
...getTerminalThemeTargetSearchEntries(),
...darkThemeSearchEntries,
...lightThemeSearchEntries,
...(showWarpThemeImport
? [...getTerminalWarpImportSearchEntries(), ...getTerminalYamlImportSearchEntries()]
: [])
]
const darkThemeTargetScore = scoreThemeTargetIntent(searchQuery, darkThemeSearchEntries)
const lightThemeTargetScore = scoreThemeTargetIntent(searchQuery, lightThemeSearchEntries)
const darkThemeMatches = darkThemeSearchScore > 0
const lightThemeMatches = lightThemeSearchScore > 0
const themeTargetMatches = matchesSettingsSearch(
searchQuery,
getTerminalThemeTargetSearchEntries()
)
const themeImportMatches =
showWarpThemeImport &&
(matchesSettingsSearch(searchQuery, getTerminalWarpImportSearchEntries()) ||
matchesSettingsSearch(searchQuery, getTerminalYamlImportSearchEntries()))
const showTerminalThemeCatalog =
darkThemeMatches || lightThemeMatches || themeTargetMatches || themeImportMatches
const preferredThemeTarget = getPreferredThemeTarget(darkThemeTargetScore, lightThemeTargetScore)
const visibleSections = [
matchesSettingsSearch(searchQuery, getTerminalGhosttyImportSearchEntries()) ||
matchesSettingsSearch(searchQuery, getTerminalTypographySearchEntries()) ? (
<TerminalTypographyAppearanceSection
key="typography"
settings={settings}
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
ghostty={ghostty}
previewFontFamily={previewFontFamily}
setPreviewFontFamily={setPreviewFontFamily}
/>
) : null,
matchesSettingsSearch(searchQuery, getTerminalCursorSearchEntries()) ? (
<TerminalCursorAppearanceSection
key="cursor"
settings={settings}
updateSettings={updateSettings}
/>
) : null,
matchesSettingsSearch(searchQuery, getTerminalPaneAppearanceSearchEntries()) ? (
<TerminalPaneAppearanceSection
key="pane-appearance"
settings={settings}
updateSettings={updateSettings}
/>
) : null,
matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries()) ? (
<TerminalWindowSection key="window" settings={settings} updateSettings={updateSettings} />
) : null,
showTerminalThemeCatalog ? (
<TerminalThemeCatalogSection
key={`theme-catalog-${preferredThemeTarget ?? 'manual'}`}
settings={settings}
systemPrefersDark={systemPrefersDark}
themeSearch={themeSearch}
setThemeSearch={setThemeSearch}
updateSettings={updateSettings}
previewFontFamily={previewFontFamily}
importedHighlightSignal={warpThemes.importSignal}
warpThemes={warpThemes}
showThemeImport={showWarpThemeImport}
preferredTarget={preferredThemeTarget}
/>
) : null
].filter(Boolean)
// Why: low-frequency knobs are force-opened during search; render each group
// only when its own search matches so an active query never leaves a dangling header.
const typographyMatches = matchesSettingsSearch(
searchQuery,
getTerminalAdvancedTypographySearchEntries()
)
const cursorMatches = matchesSettingsSearch(searchQuery, getTerminalCursorSearchEntries())
const paneMatches = matchesSettingsSearch(searchQuery, getTerminalPaneAppearanceSearchEntries())
const windowMatches = matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries())
const themeCatalogMatches = matchesSettingsSearch(searchQuery, themeCatalogSearchEntries)
const previewAdvancedMatches = cursorMatches || paneMatches || windowMatches
const showThemeCatalog = !isSearching || themeCatalogMatches || previewAdvancedMatches
const primaryTypographyMatches = matchesSettingsSearch(
searchQuery,
terminalTypographyEntries.slice(0, 2)
)
const ghosttyImportMatches = matchesSettingsSearch(searchQuery, ghosttyImportEntries)
const showPrimaryTypography =
!isSearching ||
forceVisiblePrimary ||
primaryTypographyMatches ||
typographyMatches ||
ghosttyImportMatches
const showGhosttyImport = !isSearching || forceVisiblePrimary || ghosttyImportMatches
const showTypographyAdvancedDisclosure = !isSearching || typographyMatches
return (
<div className="space-y-6">
{visibleSections.map((section, index) => (
<div key={index} className="space-y-6">
{index > 0 ? <div className="h-px bg-border/60" /> : null}
{section}
const advancedGroups = [
cursorMatches
? {
key: 'cursor',
node: (
<TerminalCursorAppearanceSection settings={settings} updateSettings={updateSettings} />
)
}
: null,
paneMatches
? {
key: 'pane',
node: (
<TerminalPaneAppearanceSection settings={settings} updateSettings={updateSettings} />
)
}
: null,
windowMatches
? {
key: 'window',
node: <TerminalWindowSection settings={settings} updateSettings={updateSettings} />
}
: null
].filter((group): group is { key: string; node: React.JSX.Element } => group !== null)
const showAdvancedDisclosure = !isSearching || advancedGroups.length > 0
const previewAdvancedContent = showAdvancedDisclosure ? (
<AppearanceAdvancedDisclosure
showTopBorder={false}
className="mt-0 pt-2"
contentClassName="ml-4 pt-4"
>
{advancedGroups.map((group, index) => (
<div
key={group.key}
className={index > 0 ? 'mt-2 border-t border-border/60 pt-4' : undefined}
>
{group.node}
</div>
))}
</AppearanceAdvancedDisclosure>
) : null
return (
<div className="space-y-5">
{/* Primary: font + theme + previews. The expanded section column is far
narrower than the xl breakpoint, so the preview grids inside the
theme catalog already stack full-width below their controls. */}
{showPrimaryTypography ? (
<section className="space-y-3 pt-2">
<SettingsSubsectionHeader
className="items-center"
title={translate(
'auto.components.settings.TerminalAppearanceSection.048aac8a64',
'Terminal Typography'
)}
action={
showGhosttyImport ? (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void ghostty.handleClick()}
>
<img src={ghosttyIcon} alt="" aria-hidden="true" className="size-4" />
{translate(
'auto.components.settings.TerminalAppearanceSection.855a76343a',
'Import from Ghostty'
)}
</Button>
) : null
}
/>
<div className="ml-4 divide-y divide-border/40 border-y border-border/40">
<TerminalFontSizeSetting
settings={settings}
updateSettings={updateSettings}
forceVisible={forceVisiblePrimary}
/>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.a408266e67',
'Font Family'
)}
description={terminalTypographyEntries[1]?.description}
keywords={
terminalTypographyEntries[1]?.keywords ?? ['terminal', 'typography', 'font']
}
forceVisible={forceVisiblePrimary}
>
<SettingsRow
label={translate(
'auto.components.settings.TerminalAppearanceSection.a408266e67',
'Font Family'
)}
control={
<FontAutocomplete
value={settings.terminalFontFamily}
suggestions={terminalFontSuggestions}
onChange={(value) => updateSettings({ terminalFontFamily: value })}
onPreviewFontFamily={setPreviewFontFamily}
/>
}
/>
</SearchableSetting>
</div>
{showTypographyAdvancedDisclosure ? (
<div className="ml-4">
<AppearanceAdvancedDisclosure showTopBorder={false} contentClassName="ml-4">
<TerminalAdvancedTypographyControls
settings={settings}
updateSettings={updateSettings}
/>
</AppearanceAdvancedDisclosure>
</div>
) : null}
</section>
) : null}
{showThemeCatalog ? (
<TerminalThemeCatalogSection
key={`theme-catalog-${preferredThemeTarget ?? 'manual'}`}
settings={settings}
systemPrefersDark={systemPrefersDark}
themeSearch={themeSearch}
setThemeSearch={setThemeSearch}
updateSettings={updateSettings}
previewFontFamily={previewFontFamily}
importedHighlightSignal={warpThemes.importSignal}
warpThemes={warpThemes}
showThemeImport={showWarpThemeImport}
preferredTarget={preferredThemeTarget}
advancedContent={previewAdvancedContent}
/>
) : null}
<GhosttyImportModal
open={ghostty.open}
onOpenChange={ghostty.handleOpenChange}

View File

@ -26,13 +26,9 @@ export function TerminalCursorAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
'Terminal Cursor'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.d455f2ef4f',
'Default cursor appearance for Orca terminal panes.'
)}
/>
<div className="divide-y divide-border/40">
<div className="ml-4 divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.db270cc9a9',
@ -44,15 +40,12 @@ export function TerminalCursorAppearanceSection({
)}
keywords={['terminal', 'cursor', 'bar', 'block', 'underline']}
>
{/* Why: Bar/Block/Underline options convey the meaning; helper text pruned. */}
<SettingsRow
label={translate(
'auto.components.settings.TerminalAppearanceSection.db270cc9a9',
'Cursor Shape'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.d455f2ef4f',
'Default cursor appearance for Orca terminal panes.'
)}
control={
<SettingsSegmentedControl
ariaLabel={translate(
@ -105,10 +98,6 @@ export function TerminalCursorAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.74736cc9b1',
'Blinking Cursor'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.2de6b5a699',
'Uses the blinking variant of the selected cursor shape.'
)}
checked={settings.terminalCursorBlink}
onChange={() => updateSettings({ terminalCursorBlink: !settings.terminalCursorBlink })}
/>
@ -130,10 +119,7 @@ export function TerminalCursorAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.b9f1804422',
'Cursor Opacity'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.04cdf85dec',
'Opacity of the terminal cursor.'
)}
description=""
value={settings.terminalCursorOpacity ?? 1}
defaultValue={1}
min={0}

View File

@ -8,10 +8,12 @@ import { translate } from '@/i18n/i18n'
export function TerminalFontSizeSetting({
settings,
updateSettings
updateSettings,
forceVisible = false
}: {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
forceVisible?: boolean
}): React.JSX.Element {
return (
<SearchableSetting
@ -21,16 +23,15 @@ export function TerminalFontSizeSetting({
'Default terminal font size for new panes and live updates.'
)}
keywords={['terminal', 'typography', 'text size']}
forceVisible={forceVisible}
>
{/* Why: helper text dropped per the copy audit "Font Size" + px control
is self-evident; the search index keeps the longer description. */}
<SettingsRow
label={translate(
'auto.components.settings.TerminalFontSizeSetting.a4a352b1e9',
'Font Size'
)}
description={translate(
'auto.components.settings.TerminalFontSizeSetting.0f4c92e595',
'Default terminal font size for new panes and live updates.'
)}
control={
<div className="flex items-center gap-2">
<Button

View File

@ -22,13 +22,9 @@ export function TerminalPaneAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.e1a5c25555',
'Terminal Panes'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.1b79379d4f',
'Control inactive pane dimming and split divider thickness.'
)}
/>
<div className="divide-y divide-border/40">
<div className="ml-4 divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalAppearanceSection.a6fdd6a3b1',
@ -45,9 +41,10 @@ export function TerminalPaneAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.a6fdd6a3b1',
'Inactive Pane Opacity'
)}
// Why: clarify which panes get dimmed; tightened per the copy audit.
description={translate(
'auto.components.settings.TerminalAppearanceSection.db632cb50e',
'Opacity applied to panes that are not currently active.'
'auto.components.settings.TerminalAppearanceSection.dimUnfocusedPanes',
'Dim unfocused panes.'
)}
value={paneStyleOptions.inactivePaneOpacity}
defaultValue={0.8}
@ -78,10 +75,7 @@ export function TerminalPaneAppearanceSection({
'auto.components.settings.TerminalAppearanceSection.f27a99978d',
'Divider Thickness'
)}
description={translate(
'auto.components.settings.TerminalAppearanceSection.a14a427ae4',
'Thickness of the pane divider line.'
)}
description=""
value={paneStyleOptions.dividerThicknessPx}
defaultValue={1}
min={1}

View File

@ -1,4 +1,4 @@
import { useState, type Dispatch, type SetStateAction } from 'react'
import { useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import {
ColorField,
@ -29,6 +29,7 @@ type TerminalThemeCatalogSectionProps = {
warpThemes: UseWarpThemeImportReturn
showThemeImport: boolean
preferredTarget?: TerminalThemeTarget
advancedContent?: ReactNode
}
export function TerminalThemeCatalogSection({
@ -41,7 +42,8 @@ export function TerminalThemeCatalogSection({
importedHighlightSignal,
warpThemes,
showThemeImport,
preferredTarget
preferredTarget,
advancedContent
}: TerminalThemeCatalogSectionProps): React.JSX.Element {
const [target, setTarget] = useState<TerminalThemeTarget>(preferredTarget ?? 'dark')
const themeOptions = getAvailableTerminalThemeOptions(settings)
@ -78,25 +80,23 @@ export function TerminalThemeCatalogSection({
return (
<section className="space-y-5">
<SettingsSubsectionHeader
className="items-center"
title={translate(
'auto.components.settings.TerminalThemeSections.catalog_title',
'Terminal Themes'
)}
description={translate(
'auto.components.settings.TerminalThemeSections.catalog_description',
'Choose terminal themes and divider colors for dark and light mode.'
)}
action={
showThemeImport ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<WarpThemeImportButton warpThemes={warpThemes} />
<YamlThemeImportButton warpThemes={warpThemes} />
</div>
) : null
}
/>
{showThemeImport ? (
<div className="flex flex-wrap items-center gap-2">
<WarpThemeImportButton warpThemes={warpThemes} />
<YamlThemeImportButton warpThemes={warpThemes} />
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div>
<div className="ml-4 grid gap-4">
<div className={advancedContent ? 'border-b border-border/40' : undefined}>
<div className="space-y-3">
<SearchableSetting
title={translate(
@ -237,6 +237,8 @@ export function TerminalThemeCatalogSection({
</div>
</div>
{advancedContent ? <div className="-mt-4">{advancedContent}</div> : null}
<TerminalSettingsPreview
title={
isLightTarget

View File

@ -59,132 +59,126 @@ export function TerminalWindowSection({
</p>
</div>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
'Background Opacity'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.03acb60aa0',
'Controls the transparency of the terminal background.'
)}
keywords={['opacity', 'transparency', 'background', 'alpha']}
>
<NumberField
label={translate(
<div className="ml-4 space-y-4">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
'Background Opacity'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.809f37738d',
'Controls the transparency of the terminal background. 1 is fully opaque, 0 is fully transparent.'
'auto.components.settings.TerminalWindowSection.03acb60aa0',
'Controls the transparency of the terminal background.'
)}
value={settings.terminalBackgroundOpacity ?? 1}
defaultValue={1}
min={0}
max={1}
step={0.05}
suffix="0 to 1"
onChange={(value) =>
updateSettings({ terminalBackgroundOpacity: clampNumber(value, 0, 1) })
}
/>
</SearchableSetting>
keywords={['opacity', 'transparency', 'background', 'alpha']}
>
<NumberField
label={translate(
'auto.components.settings.TerminalWindowSection.ea7b1a158e',
'Background Opacity'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.809f37738d',
'Controls the transparency of the terminal background. 1 is fully opaque, 0 is fully transparent.'
)}
value={settings.terminalBackgroundOpacity ?? 1}
defaultValue={1}
min={0}
max={1}
step={0.05}
suffix="0 to 1"
onChange={(value) =>
updateSettings({ terminalBackgroundOpacity: clampNumber(value, 0, 1) })
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
keywords={['window', 'blur', 'background', 'transparency', 'vibrancy']}
className="space-y-3 py-2"
>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
</p>
</div>
<button
role="switch"
aria-checked={settings.windowBackgroundBlur ?? false}
onClick={() => updateSettings({ windowBackgroundBlur: !settings.windowBackgroundBlur })}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
(settings.windowBackgroundBlur ?? false) ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.windowBackgroundBlur ?? false) ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{blurPendingRestart ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2.5">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-300">
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
keywords={['window', 'blur', 'background', 'transparency', 'vibrancy']}
className="space-y-3 py-2"
>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.c65bb9ce63',
'Restart required'
'auto.components.settings.TerminalWindowSection.2b82242f43',
'Window Blur'
)}
</p>
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalWindowSection.53ce336e15',
'Restart Orca to apply the window blur change.'
'auto.components.settings.TerminalWindowSection.97950bb087',
'Apply background blur to the terminal window. Requires restart.'
)}
</p>
</div>
<Button
size="sm"
variant="default"
className="shrink-0 gap-1.5"
disabled={relaunchingBlur}
onClick={() => void handleRelaunch()}
<button
role="switch"
aria-checked={settings.windowBackgroundBlur ?? false}
onClick={() =>
updateSettings({ windowBackgroundBlur: !settings.windowBackgroundBlur })
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
(settings.windowBackgroundBlur ?? false)
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
>
<RotateCw className={`size-3 ${relaunchingBlur ? 'animate-spin' : ''}`} />
{relaunchingBlur
? translate(
'auto.components.settings.TerminalWindowSection.907131d741',
'Restarting…'
)
: translate(
'auto.components.settings.TerminalWindowSection.8abdab9f7c',
'Restart now'
)}
</Button>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.windowBackgroundBlur ?? false) ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
) : null}
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.36b8402015',
'Horizontal Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.25e2f8e8e1',
'Horizontal padding around the terminal grid in pixels.'
)}
keywords={['padding', 'horizontal', 'spacing', 'margin']}
>
<NumberField
label={translate(
{blurPendingRestart ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2.5">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-yellow-700 dark:text-yellow-300">
{translate(
'auto.components.settings.TerminalWindowSection.c65bb9ce63',
'Restart required'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalWindowSection.53ce336e15',
'Restart Orca to apply the window blur change.'
)}
</p>
</div>
<Button
size="sm"
variant="default"
className="shrink-0 gap-1.5"
disabled={relaunchingBlur}
onClick={() => void handleRelaunch()}
>
<RotateCw className={`size-3 ${relaunchingBlur ? 'animate-spin' : ''}`} />
{relaunchingBlur
? translate(
'auto.components.settings.TerminalWindowSection.907131d741',
'Restarting…'
)
: translate(
'auto.components.settings.TerminalWindowSection.8abdab9f7c',
'Restart now'
)}
</Button>
</div>
) : null}
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.36b8402015',
'Horizontal Padding'
)}
@ -192,29 +186,26 @@ export function TerminalWindowSection({
'auto.components.settings.TerminalWindowSection.25e2f8e8e1',
'Horizontal padding around the terminal grid in pixels.'
)}
value={settings.terminalPaddingX ?? 4}
defaultValue={4}
min={0}
max={512}
step={1}
suffix="px"
onChange={(value) => updateSettings({ terminalPaddingX: Math.max(0, value) })}
/>
</SearchableSetting>
keywords={['padding', 'horizontal', 'spacing', 'margin']}
>
<NumberField
label={translate(
'auto.components.settings.TerminalWindowSection.36b8402015',
'Horizontal Padding'
)}
description=""
value={settings.terminalPaddingX ?? 4}
defaultValue={4}
min={0}
max={512}
step={1}
suffix="px"
onChange={(value) => updateSettings({ terminalPaddingX: Math.max(0, value) })}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.1afcc1d973',
'Vertical Padding'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1846f6ee6a',
'Vertical padding around the terminal grid in pixels.'
)}
keywords={['padding', 'vertical', 'spacing', 'margin']}
>
<NumberField
label={translate(
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.1afcc1d973',
'Vertical Padding'
)}
@ -222,133 +213,140 @@ export function TerminalWindowSection({
'auto.components.settings.TerminalWindowSection.1846f6ee6a',
'Vertical padding around the terminal grid in pixels.'
)}
value={settings.terminalPaddingY ?? 4}
defaultValue={4}
min={0}
max={512}
step={1}
suffix="px"
onChange={(value) => updateSettings({ terminalPaddingY: Math.max(0, value) })}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
'Hide the mouse cursor when typing in the terminal.'
)}
keywords={['mouse', 'hide', 'typing', 'cursor']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
'Hide the mouse cursor when typing in the terminal.'
)}
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalMouseHideWhileTyping ?? false}
onClick={() =>
updateSettings({
terminalMouseHideWhileTyping: !settings.terminalMouseHideWhileTyping
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
(settings.terminalMouseHideWhileTyping ?? false)
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
keywords={['padding', 'vertical', 'spacing', 'margin']}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.terminalMouseHideWhileTyping ?? false) ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.e86e09b5c7',
'Override individual terminal colors.'
)}
keywords={['color', 'override', 'ansi', 'palette', 'theme']}
className="space-y-3"
>
<div className="space-y-2">
<button
onClick={() => setColorOverridesExpanded((prev) => !prev)}
className="flex items-center gap-2 text-sm font-medium"
>
<span className={`transition-transform ${colorOverridesExpanded ? 'rotate-90' : ''}`}>
</span>
{translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
<NumberField
label={translate(
'auto.components.settings.TerminalWindowSection.1afcc1d973',
'Vertical Padding'
)}
</button>
<div
className={`grid overflow-hidden transition-all duration-300 ease-out ${
colorOverridesExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
description=""
value={settings.terminalPaddingY ?? 4}
defaultValue={4}
min={0}
max={512}
step={1}
suffix="px"
onChange={(value) => updateSettings({ terminalPaddingY: Math.max(0, value) })}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.1d1920dc8a',
'Hide the mouse cursor when typing in the terminal.'
)}
keywords={['mouse', 'hide', 'typing', 'cursor']}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
{/* Why: helper text dropped per copy audit near-verbatim restatement
of the label; the search index keeps the longer phrasing. */}
<Label>
{translate(
'auto.components.settings.TerminalWindowSection.3530908ef9',
'Hide Mouse While Typing'
)}
</Label>
</div>
<button
role="switch"
aria-checked={settings.terminalMouseHideWhileTyping ?? false}
onClick={() =>
updateSettings({
terminalMouseHideWhileTyping: !settings.terminalMouseHideWhileTyping
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
(settings.terminalMouseHideWhileTyping ?? false)
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
>
<div className="min-h-0 space-y-4">
{COLOR_OVERRIDE_GROUPS.map((group) => (
<div key={group.label} className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground">{group.label}</p>
<div className="grid gap-2 sm:grid-cols-2">
{group.keys.map((item) => (
<ColorField
key={item.key}
label={item.label}
description={item.description}
value={settings.terminalColorOverrides?.[item.key] ?? ''}
fallback=""
onChange={(value) =>
updateSettings({
terminalColorOverrides: {
...settings.terminalColorOverrides,
[item.key]: value || undefined
}
})
}
/>
))}
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
(settings.terminalMouseHideWhileTyping ?? false)
? 'translate-x-4'
: 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
)}
description={translate(
'auto.components.settings.TerminalWindowSection.e86e09b5c7',
'Override individual terminal colors.'
)}
keywords={['color', 'override', 'ansi', 'palette', 'theme']}
className="space-y-3"
>
<div className="space-y-2">
<button
onClick={() => setColorOverridesExpanded((prev) => !prev)}
className="flex items-center gap-2 text-sm font-medium"
>
<span className={`transition-transform ${colorOverridesExpanded ? 'rotate-90' : ''}`}>
</span>
{translate(
'auto.components.settings.TerminalWindowSection.63f8d9336e',
'Color Overrides'
)}
</button>
<div
className={`grid overflow-hidden transition-all duration-300 ease-out ${
colorOverridesExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="min-h-0 space-y-4">
{COLOR_OVERRIDE_GROUPS.map((group) => (
<div key={group.label} className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground">{group.label}</p>
<div className="grid gap-2 sm:grid-cols-2">
{group.keys.map((item) => (
<ColorField
key={item.key}
label={item.label}
description={item.description}
value={settings.terminalColorOverrides?.[item.key] ?? ''}
fallback=""
onChange={(value) =>
updateSettings({
terminalColorOverrides: {
...settings.terminalColorOverrides,
[item.key]: value || undefined
}
})
}
/>
))}
</div>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => updateSettings({ terminalColorOverrides: undefined })}
>
{translate(
'auto.components.settings.TerminalWindowSection.03c855d15f',
'Reset all color overrides'
)}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => updateSettings({ terminalColorOverrides: undefined })}
>
{translate(
'auto.components.settings.TerminalWindowSection.03c855d15f',
'Reset all color overrides'
)}
</Button>
</div>
</div>
</div>
</div>
</SearchableSetting>
</SearchableSetting>
</div>
</section>
)
}

View File

@ -245,6 +245,25 @@ export function getSystemTrayEntries(options: SystemTraySearchOptions = {}): Set
return shouldShowSystemTrayEntries(options) ? getSystemTrayEntryCatalog() : []
}
const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate('auto.components.settings.AppearancePane.interfaceTitle', 'Interface')
},
{
title: translate('auto.components.settings.AppearancePane.terminalTitle', 'Terminal')
},
{
title: translate(
'auto.components.settings.AppearancePane.windowSidebarTitle',
'Window & Sidebar'
),
description: translate(
'auto.components.settings.AppearancePane.windowSidebarSummary',
'Sidebar, status bar, and file explorer'
)
}
])
type AppearancePaneSearchOptions = {
showWarpImport?: boolean
showSystemTray?: boolean
@ -254,6 +273,7 @@ function buildAppearancePaneSearchEntries(
options: AppearancePaneSearchOptions
): SettingsSearchEntry[] {
return [
...getAppearanceSectionEntries(),
...getThemeEntries(),
...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []),
...getTypographyEntries(),

View File

@ -43,7 +43,7 @@ export const getWorkspaceCardLayoutEntry = createLocalizedCatalog(
),
description: translate(
'auto.components.settings.appearance.search.workspaceCardLayout.description',
'Switch between compact and detailed workspace cards from the workspace sidebar options menu.'
'Workspace cards can use compact or detailed layouts.'
),
keywords: [
...translateSearchKeyword(

View File

@ -180,6 +180,8 @@ describe('getTerminalPaneSearchEntries', () => {
expect(getSidebarEntries()).toContainEqual(entry)
expect(getAppearancePaneSearchEntries()).toContainEqual(entry)
expect(entry.description).toBe('Workspace cards can use compact or detailed layouts.')
expect(entry.description).not.toContain('options menu')
})
it.each(['compact', 'compact display', 'workspace cards', 'sidebar', 'card layout'])(

View File

@ -34,6 +34,7 @@ import {
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export {
getTerminalAdvancedTypographySearchEntries,
getTerminalTypographySearchEntries,
getTerminalRenderingSearchEntries,
getTerminalCursorSearchEntries

View File

@ -2,7 +2,7 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
export const getTerminalTypographySearchEntries = createLocalizedCatalog(() => [
const getTerminalTypographySearchEntryCatalog = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.terminal.search.5930244899', 'Font Size'),
description: translate(
@ -100,6 +100,14 @@ export const getTerminalTypographySearchEntries = createLocalizedCatalog(() => [
}
])
export const getTerminalTypographySearchEntries = createLocalizedCatalog(() => [
...getTerminalTypographySearchEntryCatalog()
])
export const getTerminalAdvancedTypographySearchEntries = createLocalizedCatalog(() =>
getTerminalTypographySearchEntryCatalog().slice(2)
)
export const getTerminalRenderingSearchEntries = createLocalizedCatalog(() => [
{
title: translate('auto.components.settings.terminal.search.13a2502dfc', 'GPU Acceleration'),

View File

@ -6215,6 +6215,249 @@ describe('connectPanePty', () => {
disposable.dispose()
})
it('abandons a stalled hidden restore and drains pending foreground chunks warning-first', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>()
getMainBufferSnapshot.mockReturnValue(snapshot.promise)
const hidden = 'hidden-codex-output\r\n'
const firstLive = 'first-live-output\r\n'
const secondLive = 'second-live-output\r\n'
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(firstLive, {
seq: hidden.length + firstLive.length,
rawLength: firstLive.length
})
await flushAsyncTicks(4)
capturedDataCallback.current?.(secondLive, {
seq: hidden.length + firstLive.length + secondLive.length,
rawLength: secondLive.length
})
expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 })
expect(pane.terminal.write).not.toHaveBeenCalledWith(firstLive, expect.any(Function))
expect(pane.terminal.write).not.toHaveBeenCalledWith(secondLive, expect.any(Function))
vi.advanceTimersByTime(749)
await flushAsyncTicks(4)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
expect.stringContaining('main recovery was unavailable'),
expect.any(Function)
)
vi.advanceTimersByTime(1)
vi.advanceTimersByTime(0)
await flushAsyncTicks(10)
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
const combinedLiveIndex = written.indexOf(firstLive + secondLive)
expect(warningIndex).toBeGreaterThanOrEqual(0)
expect(combinedLiveIndex).toBeGreaterThan(warningIndex)
snapshot.resolve({
data: 'late-snapshot-state\r\n',
cols: 100,
rows: 30,
seq: hidden.length + firstLive.length + secondLive.length
})
await flushAsyncTicks(20)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
'late-snapshot-state\r\n',
expect.any(Function)
)
disposable.dispose()
})
it('falls back after repeated null hidden restore retries and drains blocked foreground', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
getMainBufferSnapshot.mockResolvedValue(null)
const hidden = 'hidden-codex-output\r\n'
const live = 'visible-after-null-retries\r\n'
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(live, {
seq: hidden.length + live.length,
rawLength: live.length
})
await flushAsyncTicks(10)
for (let attempt = 0; attempt < 3; attempt++) {
expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function))
vi.advanceTimersByTime(50)
vi.advanceTimersByTime(0)
await flushAsyncTicks(10)
}
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
const liveIndex = written.indexOf(live)
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(4)
expect(warningIndex).toBeGreaterThanOrEqual(0)
expect(liveIndex).toBeGreaterThan(warningIndex)
disposable.dispose()
})
it('drops pending foreground overflow when a stalled hidden restore falls back', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
getMainBufferSnapshot.mockReturnValue(
createDeferred<{ data: string; cols: number; rows: number; seq: number }>().promise
)
const hidden = 'hidden-codex-output\r\n'
const liveOverflow = 'v'.repeat(512 * 1024 + 1)
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(liveOverflow, {
seq: hidden.length + liveOverflow.length,
rawLength: liveOverflow.length
})
await flushAsyncTicks(4)
vi.advanceTimersByTime(750)
await vi.runAllTimersAsync()
await flushAsyncTicks(20)
vi.advanceTimersByTime(0)
await flushAsyncTicks(10)
expect(pane.terminal.write).toHaveBeenCalledWith(
expect.stringContaining('main recovery was unavailable'),
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith(liveOverflow, expect.any(Function))
disposable.dispose()
})
it('coalesces tiny pending foreground chunks when stalled hidden restore falls back', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
getMainBufferSnapshot.mockReturnValue(
createDeferred<{ data: string; cols: number; rows: number; seq: number }>().promise
)
const hidden = 'hidden-codex-output\r\n'
const chunkCount = 2_000
const liveChunk = 'x'
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
for (let index = 0; index < chunkCount; index += 1) {
capturedDataCallback.current?.(liveChunk, {
seq: hidden.length + index + 1,
rawLength: liveChunk.length
})
}
await flushAsyncTicks(4)
vi.advanceTimersByTime(750)
vi.advanceTimersByTime(0)
await flushAsyncTicks(10)
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
const combinedLive = liveChunk.repeat(chunkCount)
const liveWrites = written.filter((data) => data === combinedLive)
expect(warningIndex).toBeGreaterThanOrEqual(0)
expect(liveWrites).toHaveLength(1)
expect(written.indexOf(combinedLive)).toBeGreaterThan(warningIndex)
disposable.dispose()
expect(vi.getTimerCount()).toBe(0)
})
it('keeps foreground output when hidden-backlog snapshot recovery is unavailable', async () => {
const pendingTimeouts: {
canceled: boolean
@ -6324,6 +6567,106 @@ describe('connectPanePty', () => {
}
})
it('keeps a newer same-PTY hidden restore after a timed-out snapshot resolves late', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
const firstSnapshot = createDeferred<{
data: string
cols: number
rows: number
seq: number
}>()
const secondSnapshot = createDeferred<{
data: string
cols: number
rows: number
seq: number
}>()
getMainBufferSnapshot
.mockReturnValueOnce(firstSnapshot.promise)
.mockReturnValueOnce(secondSnapshot.promise)
const firstHidden = 'first-hidden-output\r\n'
const firstLive = 'first-live-output\r\n'
const secondHidden = 'second-hidden-output\r\n'
const secondLive = 'second-live-output\r\n'
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(firstHidden, {
seq: firstHidden.length,
rawLength: firstHidden.length
})
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(firstLive, {
seq: firstHidden.length + firstLive.length,
rawLength: firstLive.length
})
await flushAsyncTicks(4)
vi.advanceTimersByTime(750)
vi.advanceTimersByTime(0)
await flushAsyncTicks(10)
pane.terminal.write.mockClear()
;(deps.isVisibleRef as { current: boolean }).current = false
capturedDataCallback.current?.(secondHidden, {
seq: firstHidden.length + firstLive.length + secondHidden.length,
rawLength: secondHidden.length
})
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(secondLive, {
seq: firstHidden.length + firstLive.length + secondHidden.length + secondLive.length,
rawLength: secondLive.length
})
await flushAsyncTicks(4)
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2)
firstSnapshot.resolve({
data: 'stale-first-snapshot\r\n',
cols: 100,
rows: 30,
seq: firstHidden.length + firstLive.length
})
await flushAsyncTicks(10)
secondSnapshot.resolve({
data: 'fresh-second-snapshot\r\n',
cols: 100,
rows: 30,
seq: firstHidden.length + firstLive.length + secondHidden.length + secondLive.length
})
await flushAsyncTicks(20)
expect(pane.terminal.write).not.toHaveBeenCalledWith(
'stale-first-snapshot\r\n',
expect.any(Function)
)
expect(pane.terminal.write).toHaveBeenCalledWith(
'fresh-second-snapshot\r\n',
expect.any(Function)
)
expect(pane.terminal.write).not.toHaveBeenCalledWith(secondLive, expect.any(Function))
disposable.dispose()
})
it('ignores an async hidden-backlog snapshot if the pane changes PTYs first', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('old-pty-id')

View File

@ -141,6 +141,7 @@ const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50
const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3
const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750
const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256
const CURSOR_SHOW_SEQUENCE = '\x1b[?25h'
const CURSOR_HIDE_SEQUENCE = '\x1b[?25l'
@ -811,6 +812,7 @@ export function connectPanePty(
let unregisterBacklogRecovery: (() => void) | null = null
let unregisterDocumentVisibilityRecovery: (() => void) | null = null
let cleanupHiddenOutputRestoreDeferredRetry = (): void => {}
let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {}
let unregisterE2ePtyDataInjection = (): void => {}
let startupInjectTimer: ReturnType<typeof setTimeout> | null = null
let sshShellReadyFallbackTimer: ReturnType<typeof setTimeout> | null = null
@ -2619,6 +2621,7 @@ export function connectPanePty(
let hiddenOutputRestoreRetryDeferred = false
let hiddenOutputRestoreScheduled = false
let hiddenOutputRestoreDeferredRetryTimer: ReturnType<typeof setTimeout> | null = null
let hiddenOutputRestoreForegroundDeadlineTimer: ReturnType<typeof setTimeout> | null = null
let hiddenOutputRestoreDeferredRetryAttempts = 0
// Why: hidden recovery state belongs to one PTY stream. Reattach/restart
// can reuse the pane object for a different session before visibility.
@ -2856,7 +2859,8 @@ export function connectPanePty(
(synchronizedForegroundOutput ||
nativeWindowsCursorRestore ||
foregroundRenderRefreshNeeded),
followupForegroundRefresh: nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup,
followupForegroundRefresh:
nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup,
stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground,
coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded,
holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive
@ -3030,6 +3034,7 @@ export function connectPanePty(
hiddenOutputRestorePendingChunks = []
hiddenOutputRestorePendingChars = 0
hiddenOutputRestorePendingOverflow = true
armHiddenOutputRestoreForegroundDeadline()
return
}
const pending: PendingHiddenOutputRestoreChunk = { data }
@ -3041,6 +3046,7 @@ export function connectPanePty(
}
hiddenOutputRestorePendingChunks.push(pending)
hiddenOutputRestorePendingChars += data.length
armHiddenOutputRestoreForegroundDeadline()
}
function getChunkDataAfterSnapshot(
@ -3109,34 +3115,10 @@ export function connectPanePty(
hiddenOutputRestoreScheduled = false
cancelScheduledHiddenOutputRestore(pane.terminal)
clearHiddenOutputRestoreDeferredRetryTimer()
clearHiddenOutputRestoreForegroundDeadlineTimer()
hiddenOutputRestoreDeferredRetryAttempts = 0
}
function drainPendingLiveChunksWithoutSnapshot(): void {
if (hiddenOutputRestorePendingOverflow) {
hiddenOutputRestorePendingChunks = []
hiddenOutputRestorePendingChars = 0
hiddenOutputRestorePendingOverflow = false
return
}
// Why: once snapshot retries are exhausted, these bounded chunks are the
// only known visible-era PTY bytes; replay them without overlap trimming.
while (hiddenOutputRestorePendingChunks.length > 0) {
const chunks = hiddenOutputRestorePendingChunks
hiddenOutputRestorePendingChunks = []
hiddenOutputRestorePendingChars = 0
for (const chunk of chunks) {
writePtyOutputToXterm(chunk.data, true)
}
if (hiddenOutputRestorePendingOverflow) {
hiddenOutputRestorePendingChunks = []
hiddenOutputRestorePendingChars = 0
hiddenOutputRestorePendingOverflow = false
return
}
}
}
function clearHiddenOutputRestoreDeferredRetryTimer(): void {
if (hiddenOutputRestoreDeferredRetryTimer === null) {
return
@ -3146,6 +3128,81 @@ export function connectPanePty(
}
cleanupHiddenOutputRestoreDeferredRetry = clearHiddenOutputRestoreDeferredRetryTimer
function clearHiddenOutputRestoreForegroundDeadlineTimer(): void {
if (hiddenOutputRestoreForegroundDeadlineTimer === null) {
return
}
clearTimeout(hiddenOutputRestoreForegroundDeadlineTimer)
hiddenOutputRestoreForegroundDeadlineTimer = null
}
cleanupHiddenOutputRestoreForegroundDeadline = clearHiddenOutputRestoreForegroundDeadlineTimer
function armHiddenOutputRestoreForegroundDeadline(): void {
if (
disposed ||
hiddenOutputRestoreForegroundDeadlineTimer !== null ||
!shouldWritePtyOutputForeground(deps.isVisibleRef.current) ||
(hiddenOutputRestorePendingChunks.length === 0 && !hiddenOutputRestorePendingOverflow)
) {
return
}
const ptyId = hiddenOutputRestorePtyId
if (ptyId === null || transport.getPtyId() !== ptyId) {
return
}
const deadlineGeneration = hiddenOutputRestoreGeneration
// Why: only foreground-visible output blocked behind recovery gets a
// deadline; hidden-time restore work can continue without user impact.
hiddenOutputRestoreForegroundDeadlineTimer = setTimeout(() => {
hiddenOutputRestoreForegroundDeadlineTimer = null
if (
disposed ||
hiddenOutputRestoreGeneration !== deadlineGeneration ||
hiddenOutputRestorePtyId !== ptyId ||
!shouldWritePtyOutputForeground(deps.isVisibleRef.current)
) {
return
}
abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId)
}, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS)
}
function abandonHiddenOutputRestoreAndDrainPendingForeground(expectedPtyId: string): void {
if (transport.getPtyId() !== expectedPtyId || hiddenOutputRestorePtyId !== expectedPtyId) {
resetHiddenOutputRestoreIfPtyChanged()
return
}
const pendingChunks = hiddenOutputRestorePendingOverflow
? []
: hiddenOutputRestorePendingChunks.slice()
const hadPendingOverflow = hiddenOutputRestorePendingOverflow
hiddenOutputRestoreGeneration += 1
hiddenOutputRestoreInFlight = null
hiddenOutputRestoreNeeded = false
hiddenOutputRestorePtyId = null
hiddenOutputRestorePendingChunks = []
hiddenOutputRestorePendingChars = 0
hiddenOutputRestorePendingOverflow = false
hiddenOutputRestoreFreshSnapshotNeeded = false
hiddenOutputRestoreRetryDeferred = false
hiddenOutputRestoreScheduled = false
hiddenStartupRendererQueryPending = ''
hiddenRendererStateDirty = false
cancelScheduledHiddenOutputRestore(pane.terminal)
clearHiddenOutputRestoreDeferredRetryTimer()
clearHiddenOutputRestoreForegroundDeadlineTimer()
hiddenOutputRestoreDeferredRetryAttempts = 0
writeRestoreUnavailableWarning()
if (hadPendingOverflow) {
return
}
const pendingData = pendingChunks.map((chunk) => chunk.data).join('')
if (pendingData) {
writePtyOutputToXterm(pendingData, true)
}
}
function scheduleHiddenOutputRestoreDeferredRetry(): void {
if (
disposed ||
@ -3155,9 +3212,13 @@ export function connectPanePty(
return
}
if (hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX) {
writeRestoreUnavailableWarning()
drainPendingLiveChunksWithoutSnapshot()
clearHiddenOutputRestoreState()
const ptyId = hiddenOutputRestorePtyId
if (ptyId !== null) {
abandonHiddenOutputRestoreAndDrainPendingForeground(ptyId)
} else {
clearHiddenOutputRestoreState()
writeRestoreUnavailableWarning()
}
return
}
hiddenOutputRestoreDeferredRetryAttempts += 1
@ -3283,6 +3344,7 @@ export function connectPanePty(
}
hiddenOutputRestorePtyId = ptyId
if (hiddenOutputRestoreInFlight) {
armHiddenOutputRestoreForegroundDeadline()
return true
}
if (!opts?.bypassScheduler) {
@ -3356,14 +3418,15 @@ export function connectPanePty(
if (disposed) {
return
}
if (
hiddenOutputRestoreGeneration !== restoreGeneration ||
transport.getPtyId() !== currentPtyId ||
hiddenOutputRestorePtyId !== currentPtyId
) {
const restoreGenerationChanged = hiddenOutputRestoreGeneration !== restoreGeneration
const restorePtyChanged =
transport.getPtyId() !== currentPtyId || hiddenOutputRestorePtyId !== currentPtyId
if (restoreGenerationChanged || restorePtyChanged) {
// Why: the snapshot belongs to the requested PTY; after reattach,
// replaying it would show stale/cleared output in the new terminal.
if (hiddenOutputRestorePtyId === currentPtyId) {
// A stale generation may be an abandoned timeout while a newer
// restore for the same PTY owns the current hidden-recovery state.
if (restorePtyChanged && hiddenOutputRestorePtyId === currentPtyId) {
clearHiddenOutputRestoreState()
}
return
@ -3382,6 +3445,7 @@ export function connectPanePty(
if (drainPendingLiveChunksAfterSnapshot(snapshot.seq) && !needsFreshSnapshot) {
hiddenOutputRestoreNeeded = false
hiddenOutputRestorePtyId = null
clearHiddenOutputRestoreForegroundDeadlineTimer()
return
}
if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) {
@ -3393,10 +3457,16 @@ export function connectPanePty(
}
hiddenOutputRestoreNeeded = true
}
})().finally(() => {
hiddenOutputRestoreInFlight = null
})()
const hiddenOutputRestoreTask = hiddenOutputRestoreInFlight
let trackedHiddenOutputRestore: Promise<void>
trackedHiddenOutputRestore = hiddenOutputRestoreTask.finally(() => {
if (hiddenOutputRestoreInFlight === trackedHiddenOutputRestore) {
hiddenOutputRestoreInFlight = null
}
if (hiddenOutputRestorePendingChunks.length > 0 || hiddenOutputRestorePendingOverflow) {
hiddenOutputRestoreNeeded = true
armHiddenOutputRestoreForegroundDeadline()
}
if (
!hiddenOutputRestoreRetryDeferred &&
@ -3406,6 +3476,7 @@ export function connectPanePty(
requestHiddenOutputRestoreIfNeeded()
}
})
hiddenOutputRestoreInFlight = trackedHiddenOutputRestore
return true
}
@ -4300,6 +4371,7 @@ export function connectPanePty(
clearTerminalBellNotificationTimer()
clearReattachIdleAgentCursorResetTimer()
cleanupHiddenOutputRestoreDeferredRetry()
cleanupHiddenOutputRestoreForegroundDeadline()
unregisterBacklogRecovery?.()
unregisterBacklogRecovery = null
unregisterDocumentVisibilityRecovery?.()

View File

@ -4597,7 +4597,16 @@
"tintOpacity": "Tint Strength",
"tintOpacityDescription": "Controls how strongly the tint is mixed into the sidebar."
},
"workspaceCardLayoutGuidance": "Use the workspace sidebar options menu > Card layout > Compact."
"workspaceCardLayoutGuidance": "Managed from the workspace sidebar.",
"interfaceDefaultFont": "Default font",
"terminalDefaultFont": "Default font",
"interfaceTitle": "Interface",
"terminalTitle": "Terminal",
"windowSidebarTitle": "Window & Sidebar",
"windowSidebarSummary": "Sidebar, status bar, and file explorer",
"statusBarCount": "{{value0}} indicators visible.",
"gitIgnoredGlossary": "Files matched by .gitignore.",
"statusBarDescription": "Choose which indicators appear in the status bar."
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@ -6248,7 +6257,9 @@
"4415beb958": "disabled",
"4e7d41a9f0": "enabled",
"e90afcc44f": "off",
"16c471ee03": "on"
"16c471ee03": "on",
"typographyAdvanced": "Typography",
"dimUnfocusedPanes": "Dim unfocused panes."
},
"TerminalFontSizeSetting": {
"9b5252c85a": "px",
@ -6778,7 +6789,7 @@
},
"workspaceCardLayout": {
"title": "Workspace Card Layout",
"description": "Switch between compact and detailed workspace cards from the workspace sidebar options menu.",
"description": "Workspace cards can use compact or detailed layouts.",
"compact": "compact",
"compactDisplay": "compact display",
"workspaceCards": "workspace cards",
@ -8360,6 +8371,9 @@
"wslUnavailable": "WSL is not available on this machine.",
"distroRequired": "Choose a WSL distro before projects can inherit WSL.",
"wslDescription": "Detect and launch agents in {{value0}} via WSL for projects that do not override their runtime."
},
"AppearanceAdvancedDisclosure": {
"advanced": "Advanced"
}
},
"right": {

View File

@ -4594,10 +4594,19 @@
"tintOpacity": "Intensidad del tinte",
"tintOpacityDescription": "Controla con qué fuerza se mezcla el tinte en la barra lateral."
},
"workspaceCardLayoutGuidance": "Usa el menú de opciones de la barra lateral de espacios de trabajo > Diseño de tarjeta > Compacto.",
"workspaceCardLayoutGuidance": "Gestionado desde la barra lateral del espacio de trabajo.",
"872af9556e": "Bandeja del sistema",
"2edf606c46": "Minimizar a la bandeja al cerrar",
"b707773a0d": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir."
"b707773a0d": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.",
"interfaceDefaultFont": "Fuente predeterminada",
"terminalDefaultFont": "Fuente predeterminada",
"interfaceTitle": "Interfaz",
"terminalTitle": "Terminal",
"windowSidebarTitle": "Ventana y barra lateral",
"windowSidebarSummary": "Barra lateral, barra de estado y explorador de archivos",
"statusBarCount": "Indicadores visibles: {{value0}}.",
"gitIgnoredGlossary": "Archivos coincidentes con .gitignore.",
"statusBarDescription": "Choose which indicators appear in the status bar."
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@ -6211,7 +6220,9 @@
"4415beb958": "desactivado",
"4e7d41a9f0": "activado",
"e90afcc44f": "apagado",
"16c471ee03": "en"
"16c471ee03": "activado",
"typographyAdvanced": "Tipografía",
"dimUnfocusedPanes": "Atenuar paneles sin foco."
},
"TerminalFontSizeSetting": {
"9b5252c85a": "píxeles",
@ -6739,7 +6750,7 @@
},
"workspaceCardLayout": {
"title": "Diseño de tarjetas de espacios de trabajo",
"description": "Cambia entre tarjetas de espacios de trabajo compactas y detalladas desde el menú de opciones de la barra lateral de espacios de trabajo.",
"description": "Las tarjetas de espacios de trabajo pueden usar diseños compactos o detallados.",
"compact": "compacto",
"compactDisplay": "vista compacta",
"workspaceCards": "tarjetas de espacios de trabajo",
@ -8360,6 +8371,9 @@
"wslUnavailable": "WSL is not available on this machine.",
"distroRequired": "Choose a WSL distro before projects can inherit WSL.",
"wslDescription": "Detect and launch agents in {{value0}} via WSL for projects that do not override their runtime."
},
"AppearanceAdvancedDisclosure": {
"advanced": "Avanzado"
}
},
"right": {

View File

@ -4579,10 +4579,19 @@
"tintOpacity": "色合いの強さ",
"tintOpacityDescription": "サイドバーに色合いをどの程度強く混ぜるかを調整します。"
},
"workspaceCardLayoutGuidance": "ワークスペースサイドバーのオプションメニュー > カードレイアウト > コンパクト を使用します。",
"workspaceCardLayoutGuidance": "ワークスペースサイドバーで管理されます。",
"872af9556e": "システムトレイ",
"2edf606c46": "閉じるときにトレイへ最小化",
"b707773a0d": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。"
"b707773a0d": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。",
"interfaceDefaultFont": "デフォルトフォント",
"terminalDefaultFont": "デフォルトフォント",
"interfaceTitle": "インターフェイス",
"terminalTitle": "ターミナル",
"windowSidebarTitle": "ウィンドウとサイドバー",
"windowSidebarSummary": "サイドバー、ステータスバー、ファイルエクスプローラー",
"statusBarCount": "{{value0}} 個のインジケーターが表示中。",
"gitIgnoredGlossary": ".gitignore に一致するファイル。",
"statusBarDescription": "Choose which indicators appear in the status bar."
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@ -6233,7 +6242,9 @@
"4415beb958": "無効",
"4e7d41a9f0": "有効",
"e90afcc44f": "オフ",
"16c471ee03": "の上"
"16c471ee03": "オン",
"typographyAdvanced": "タイポグラフィ",
"dimUnfocusedPanes": "フォーカスされていないペインを暗くします。"
},
"TerminalFontSizeSetting": {
"9b5252c85a": "ピクセル",
@ -6761,7 +6772,7 @@
},
"workspaceCardLayout": {
"title": "ワークスペースカードのレイアウト",
"description": "ワークスペースサイドバーのオプションメニューから、コンパクト表示と詳細表示のワークスペースカードを切り替えます。",
"description": "ワークスペースカードはコンパクトまたは詳細レイアウトを使用できます。",
"compact": "コンパクト",
"compactDisplay": "コンパクト表示",
"workspaceCards": "ワークスペースカード",
@ -8360,6 +8371,9 @@
"wslUnavailable": "WSL is not available on this machine.",
"distroRequired": "Choose a WSL distro before projects can inherit WSL.",
"wslDescription": "Detect and launch agents in {{value0}} via WSL for projects that do not override their runtime."
},
"AppearanceAdvancedDisclosure": {
"advanced": "詳細設定"
}
},
"right": {

View File

@ -4582,7 +4582,16 @@
"tintOpacity": "색조 강도",
"tintOpacityDescription": "사이드바에 색조를 얼마나 강하게 섞을지 조절합니다."
},
"workspaceCardLayoutGuidance": "워크스페이스 사이드바 옵션 메뉴 > 카드 레이아웃 > 컴팩트를 사용하세요."
"workspaceCardLayoutGuidance": "워크스페이스 사이드바에서 관리됩니다.",
"interfaceDefaultFont": "기본 글꼴",
"terminalDefaultFont": "기본 글꼴",
"interfaceTitle": "인터페이스",
"terminalTitle": "터미널",
"windowSidebarTitle": "창 및 사이드바",
"windowSidebarSummary": "사이드바, 상태 표시줄 및 파일 탐색기",
"statusBarCount": "{{value0}}개 표시기가 보입니다.",
"gitIgnoredGlossary": ".gitignore와 일치하는 파일.",
"statusBarDescription": "Choose which indicators appear in the status bar."
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@ -6196,7 +6205,9 @@
"4415beb958": "비활성",
"4e7d41a9f0": "활성화됨",
"e90afcc44f": "끔",
"16c471ee03": "켜짐"
"16c471ee03": "켜짐",
"typographyAdvanced": "타이포그래피",
"dimUnfocusedPanes": "포커스되지 않은 창을 흐리게 표시합니다."
},
"TerminalFontSizeSetting": {
"9b5252c85a": "px",
@ -6726,7 +6737,7 @@
"4d5b9427b5": "활성화하면 창을 닫아도 Orca가 종료되지 않고 시스템 트레이에서 계속 실행됩니다.",
"workspaceCardLayout": {
"title": "워크스페이스 카드 레이아웃",
"description": "워크스페이스 사이드바 옵션 메뉴에서 워크스페이스 카드를 컴팩트 또는 상세 보기로 전환합니다.",
"description": "워크스페이스 카드는 컴팩트 또는 상세 레이아웃을 사용할 수 있습니다.",
"compact": "컴팩트",
"compactDisplay": "컴팩트 보기",
"workspaceCards": "워크스페이스 카드",
@ -8360,6 +8371,9 @@
"wslUnavailable": "WSL is not available on this machine.",
"distroRequired": "Choose a WSL distro before projects can inherit WSL.",
"wslDescription": "Detect and launch agents in {{value0}} via WSL for projects that do not override their runtime."
},
"AppearanceAdvancedDisclosure": {
"advanced": "고급"
}
},
"right": {

View File

@ -4579,10 +4579,19 @@
"tintOpacity": "色调强度",
"tintOpacityDescription": "控制色调混入边栏的强度。"
},
"workspaceCardLayoutGuidance": "使用工作区侧边栏选项菜单 > 卡片布局 > 紧凑。",
"workspaceCardLayoutGuidance": "从工作区侧边栏管理。",
"872af9556e": "系统托盘",
"2edf606c46": "关闭时最小化到托盘",
"b707773a0d": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。"
"b707773a0d": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。",
"interfaceDefaultFont": "默认字体",
"terminalDefaultFont": "默认字体",
"interfaceTitle": "界面",
"terminalTitle": "终端",
"windowSidebarTitle": "窗口和侧边栏",
"windowSidebarSummary": "侧边栏、状态栏和文件浏览器",
"statusBarCount": "显示 {{value0}} 个指示器。",
"gitIgnoredGlossary": "与 .gitignore 匹配的文件。",
"statusBarDescription": "Choose which indicators appear in the status bar."
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@ -6196,7 +6205,9 @@
"4415beb958": "已禁用",
"4e7d41a9f0": "已启用",
"e90afcc44f": "关",
"16c471ee03": "开"
"16c471ee03": "开",
"typographyAdvanced": "排版",
"dimUnfocusedPanes": "调暗未聚焦的窗格。"
},
"TerminalFontSizeSetting": {
"9b5252c85a": "像素",
@ -6724,7 +6735,7 @@
},
"workspaceCardLayout": {
"title": "工作区卡片布局",
"description": "从工作区侧边栏选项菜单在紧凑和详细工作区卡片之间切换。",
"description": "工作区卡片可以使用紧凑或详细布局。",
"compact": "紧凑",
"compactDisplay": "紧凑显示",
"workspaceCards": "工作区卡片",
@ -8360,6 +8371,9 @@
"wslUnavailable": "WSL is not available on this machine.",
"distroRequired": "Choose a WSL distro before projects can inherit WSL.",
"wslDescription": "Detect and launch agents in {{value0}} via WSL for projects that do not override their runtime."
},
"AppearanceAdvancedDisclosure": {
"advanced": "高级"
}
},
"right": {

View File

@ -140,9 +140,7 @@ describe('agent process recognition', () => {
agent: 'qwen-code',
processName: 'qwen'
})
expect(
recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\qwen.cmd`)
).toEqual({
expect(recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\qwen.cmd`)).toEqual({
agent: 'qwen-code',
processName: 'qwen'
})

View File

@ -850,11 +850,7 @@ describe('keybindings', () => {
// Ctrl+Shift+C on the same layout (terminal copy) must match too.
expect(
keybindingMatchesAction(
'terminal.copySelection',
{ ...cyrillicCtrlC, shift: true },
'win32'
)
keybindingMatchesAction('terminal.copySelection', { ...cyrillicCtrlC, shift: true }, 'win32')
).toBe(true)
// Greek layout: physical P produces 'π' (U+03C0); Ctrl+P must still match.