diff --git a/config/scripts/trim-windows-icon-source.mjs b/config/scripts/trim-windows-icon-source.mjs index 90a1914e7..a62089657 100644 --- a/config/scripts/trim-windows-icon-source.mjs +++ b/config/scripts/trim-windows-icon-source.mjs @@ -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() } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d20e5905e..4b9ce9508 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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' diff --git a/src/main/providers/local-pty-utils-windows-fallback.test.ts b/src/main/providers/local-pty-utils-windows-fallback.test.ts index 97fd3b6ea..48b35cd27 100644 --- a/src/main/providers/local-pty-utils-windows-fallback.test.ts +++ b/src/main/providers/local-pty-utils-windows-fallback.test.ts @@ -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', () => { diff --git a/src/main/providers/windows-powershell-executable.test.ts b/src/main/providers/windows-powershell-executable.test.ts index 28400332c..dbdb652ae 100644 --- a/src/main/providers/windows-powershell-executable.test.ts +++ b/src/main/providers/windows-powershell-executable.test.ts @@ -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', () => { diff --git a/src/main/startup/packaged-cli-entry-redirect.test.ts b/src/main/startup/packaged-cli-entry-redirect.test.ts index f892ae6c4..4f91657ec 100644 --- a/src/main/startup/packaged-cli-entry-redirect.test.ts +++ b/src/main/startup/packaged-cli-entry-redirect.test.ts @@ -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') diff --git a/src/renderer/src/components/settings/AppearanceAdvancedDisclosure.tsx b/src/renderer/src/components/settings/AppearanceAdvancedDisclosure.tsx new file mode 100644 index 000000000..73f5c6682 --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceAdvancedDisclosure.tsx @@ -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 ( +
+ + {expanded ?
{children}
: null} +
+ ) +} diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx new file mode 100644 index 000000000..0f691a072 --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -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) => 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 ( +
+ + { + 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') + } + ]} + /> + } + /> + + + + + /{' '} + {' '} + {translate( + 'auto.components.settings.AppearancePane.ef89200c1f', + 'when not in a terminal pane.' + )} + + } + control={} + /> + + + + + updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY }) + } + /> + } + /> + + + {showAdvanced ? ( + +
+ {SHOW_UI_LANGUAGE_SETTING ? ( + + updateSettings({ uiLanguage: value as UiLanguage })} + > + + + + + {UI_LANGUAGE_CHOICES.map((choice) => ( + + {getUiLanguageChoiceLabel(choice, translate)} + + ))} + + + } + /> + + ) : null} + + + + updateSettings({ showTitlebarAppName: !settings.showTitlebarAppName }) + } + /> + + + {isDesktopWindows ? ( + + + updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose }) + } + /> + + ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index a5d0c0cde..e26cffccc 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -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( - + + + ) }) @@ -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('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('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('button[aria-expanded="true"]') + ).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-')) + + expect(expanded).toHaveLength(1) + expect(expanded[0]?.textContent).toContain('Interface') + }) }) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 8a392e305..f7a2dd52c 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -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 ( - - {translate('auto.components.settings.AppearancePane.3057983501', 'Unassigned')} - - ) - } +type AppearanceSectionKey = 'interface' | 'terminal' | 'window' - return ( - - {combos.map((combo) => ( - - ))} - - ) +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( + '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()) ? ( -
- {matchesSettingsSearch(searchQuery, getThemeEntries()) ? ( - - { - 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' - ) - } - ]} - /> - } - /> - - ) : null} + const appIconMatches = matchesSettingsSearch(searchQuery, getAppIconEntries()) - {SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries()) ? ( - - updateSettings({ uiLanguage: value as UiLanguage })} - > - - - - - {UI_LANGUAGE_CHOICES.map((choice) => ( - - {getUiLanguageChoiceLabel(choice, translate)} - - ))} - - - } - /> - - ) : 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()) ? ( - - - {translate( - 'auto.components.settings.AppearancePane.f687711a9b', - 'Scale the entire application interface. Use' - )}{' '} - /{' '} - {' '} - {translate( - 'auto.components.settings.AppearancePane.ef89200c1f', - 'when not in a terminal pane.' - )} - - } - control={} - /> - - ) : null} + function toggleSection(key: AppearanceSectionKey): void { + setManuallyOpenSection((current) => (current === key ? null : key)) + } - {matchesSettingsSearch(searchQuery, getTypographyEntries()) ? ( - - - updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY }) - } - /> - } - /> - - ) : null} -
- ) : null, - matchesSettingsSearch(searchQuery, terminalAppearanceSearchEntries) ? ( - - ) : null, - matchesSettingsSearch(searchQuery, getLayoutEntries()) ? ( -
- + 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` -
- - - updateSettings({ showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true) }) - } - /> - -
-
- ) : null, - matchesSettingsSearch(searchQuery, getTitlebarEntries()) ? ( -
- + return ( +
+ {interfaceMatches ? ( + + ) : null} -
- - - updateSettings({ showTitlebarAppName: !settings.showTitlebarAppName }) - } - /> - -
-
- ) : null, - isDesktopWindows && matchesSettingsSearch(searchQuery, systemTrayEntries) ? ( -
- + {/* 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. */} -
- - - updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose }) - } - /> - -
-
- ) : null, - matchesSettingsSearch(searchQuery, getStatusBarEntries()) ? ( -
- + {terminalMatches ? ( + + ) : null} -
- {visibleStatusBarToggles.map((toggle) => { - const enabled = statusBarItems.includes(toggle.id) - return ( - - { - 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} - /> - - ) - })} -
-
- ) : null, - matchesSettingsSearch(searchQuery, getSidebarEntries()) ? ( -
- + {windowMatches ? ( + + ) : null} -
- - - - - {/* Why: this setting lives with the sidebar layout controls; Settings only - points people to it so we do not create a second stateful control. */} - - Card layout > Compact.' - )} - control={null} - /> - - - - - updateSettings({ showTasksButton: !(settings.showTasksButton !== false) }) - } - /> - - - - - updateSettings({ - showAutomationsButton: !(settings.showAutomationsButton !== false) - }) - } - /> - - - - - updateSettings({ showMobileButton: !(settings.showMobileButton !== false) }) - } - /> - -
-
- ) : null, - matchesSettingsSearch(searchQuery, getAppIconEntries()) ? ( -
+ {/* App icon stays at the bottom of Appearance as a small easter egg, + matching production — not buried inside Interface advanced. */} + {appIconMatches ? ( updateSettings({ appIcon })} /> -
- ) : null - ].filter(Boolean) - - return ( -
- {visibleSections.map((section, index) => ( -
- {index > 0 ? : null} - {section} -
- ))} + ) : null}
) } diff --git a/src/renderer/src/components/settings/AppearanceSection.tsx b/src/renderer/src/components/settings/AppearanceSection.tsx new file mode 100644 index 000000000..faa7dde2f --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceSection.tsx @@ -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 ( +
+ +
+
+
+ {children} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/AppearanceShortcutHintList.tsx b/src/renderer/src/components/settings/AppearanceShortcutHintList.tsx new file mode 100644 index 000000000..8f9b33cb2 --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceShortcutHintList.tsx @@ -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 ( + + {translate('auto.components.settings.AppearancePane.3057983501', 'Unassigned')} + + ) + } + const primaryCombo = combos[0] + + return ( + + + + ) +} diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx new file mode 100644 index 000000000..065056fbe --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -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) => 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 ( +
+
+ + + + + + + {showStatusBarControls ? ( +
+ {visibleStatusBarToggles.map((toggle) => { + const enabled = statusBarItems.includes(toggle.id) + return ( + + { + recordStatusBarToggleInteraction(toggle.id, recordFeatureInteraction) + toggleStatusBarItem(toggle.id) + }} + ariaLabel={toggle.title} + /> + + ) + })} +
+ ) : null} +
+
+ + {showAdvanced ? ( + +
+ {showSidebarAdvanced ? ( +
+ +
+ {/* Why: this setting lives with the sidebar layout controls; Settings only + names that ownership so we do not create a second stateful control. */} + + + 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' + ) + } + ]} + /> + } + /> + + + + + updateSettings({ showTasksButton: !(settings.showTasksButton !== false) }) + } + /> + + + + + updateSettings({ + showAutomationsButton: !(settings.showAutomationsButton !== false) + }) + } + /> + + + + + updateSettings({ showMobileButton: !(settings.showMobileButton !== false) }) + } + /> + +
+
+ ) : null} + + {showFileExplorerAdvanced ? ( +
+ +
+ + + updateSettings({ + showGitIgnoredFiles: !(settings.showGitIgnoredFiles ?? true) + }) + } + /> + +
+
+ ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index d60a72c69..bc51edc5b 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -76,11 +76,19 @@ export function SettingsRow({ }: SettingsRowProps): React.JSX.Element { return (
-
- - {description ?

{description}

: null} +
+ + {description ? ( +

{description}

+ ) : null}
{control}
@@ -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 ( -
+

{title}

{description ?

{description}

: null} diff --git a/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx b/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx new file mode 100644 index 000000000..5acfee0d1 --- /dev/null +++ b/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx @@ -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) => 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 ( +
+ + + updateSettings({ terminalFontWeight: normalizeTerminalFontWeight(value) }) + } + /> + + + + updateSettings({ terminalLineHeight: clampNumber(value, 1, 3) })} + /> + + + + 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' + ) + } + ]} + /> + } + /> +

+ {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' + )} + . +

+
+
+ ) +} diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts b/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts index deb307211..d5f2c28f7 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.ghostty.test.ts @@ -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, diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx index e79067383..4503eb545 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx @@ -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(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()) ? ( - - ) : null, - matchesSettingsSearch(searchQuery, getTerminalCursorSearchEntries()) ? ( - - ) : null, - matchesSettingsSearch(searchQuery, getTerminalPaneAppearanceSearchEntries()) ? ( - - ) : null, - matchesSettingsSearch(searchQuery, getTerminalWindowSearchEntries()) ? ( - - ) : null, - showTerminalThemeCatalog ? ( - - ) : 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 ( -
- {visibleSections.map((section, index) => ( -
- {index > 0 ?
: null} - {section} + const advancedGroups = [ + cursorMatches + ? { + key: 'cursor', + node: ( + + ) + } + : null, + paneMatches + ? { + key: 'pane', + node: ( + + ) + } + : null, + windowMatches + ? { + key: 'window', + node: + } + : null + ].filter((group): group is { key: string; node: React.JSX.Element } => group !== null) + const showAdvancedDisclosure = !isSearching || advancedGroups.length > 0 + const previewAdvancedContent = showAdvancedDisclosure ? ( + + {advancedGroups.map((group, index) => ( +
0 ? 'mt-2 border-t border-border/60 pt-4' : undefined} + > + {group.node}
))} +
+ ) : null + + return ( +
+ {/* 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 ? ( +
+ void ghostty.handleClick()} + > + + {translate( + 'auto.components.settings.TerminalAppearanceSection.855a76343a', + 'Import from Ghostty' + )} + + ) : null + } + /> + +
+ + + + updateSettings({ terminalFontFamily: value })} + onPreviewFontFamily={setPreviewFontFamily} + /> + } + /> + +
+ + {showTypographyAdvancedDisclosure ? ( +
+ + + +
+ ) : null} +
+ ) : null} + + {showThemeCatalog ? ( + + ) : null} + -
+
+ {/* Why: Bar/Block/Underline options convey the meaning; helper text pruned. */} 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} diff --git a/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx b/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx index e3964394a..cc601aa99 100644 --- a/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx +++ b/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx @@ -8,10 +8,12 @@ import { translate } from '@/i18n/i18n' export function TerminalFontSizeSetting({ settings, - updateSettings + updateSettings, + forceVisible = false }: { settings: GlobalSettings updateSettings: (updates: Partial) => void + forceVisible?: boolean }): React.JSX.Element { return ( + {/* Why: helper text dropped per the copy audit — "Font Size" + px control + is self-evident; the search index keeps the longer description. */} -
- - {blurPendingRestart ? ( -
-
-

+ +

+
+

{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.' )}

- + +
- ) : null} - - - +
+

+ {translate( + 'auto.components.settings.TerminalWindowSection.c65bb9ce63', + 'Restart required' + )} +

+

+ {translate( + 'auto.components.settings.TerminalWindowSection.53ce336e15', + 'Restart Orca to apply the window blur change.' + )} +

+
+ +
+ ) : null} + + + updateSettings({ terminalPaddingX: Math.max(0, value) })} - /> - + keywords={['padding', 'horizontal', 'spacing', 'margin']} + > + updateSettings({ terminalPaddingX: Math.max(0, value) })} + /> + - - updateSettings({ terminalPaddingY: Math.max(0, value) })} - /> - - - -
- -

- {translate( - 'auto.components.settings.TerminalWindowSection.1d1920dc8a', - 'Hide the mouse cursor when typing in the terminal.' - )} -

-
- -
- - -
- + + + +
+ +
+
+ {COLOR_OVERRIDE_GROUPS.map((group) => ( +
+

{group.label}

+
+ {group.keys.map((item) => ( + + updateSettings({ + terminalColorOverrides: { + ...settings.terminalColorOverrides, + [item.key]: value || undefined + } + }) + } + /> + ))} +
-
- ))} - + ))} + +
-
- + +
) } diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index b0d3d8eb8..7284b5db3 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -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(), diff --git a/src/renderer/src/components/settings/appearance-sidebar-search.ts b/src/renderer/src/components/settings/appearance-sidebar-search.ts index d723e3c4a..fd66f0f00 100644 --- a/src/renderer/src/components/settings/appearance-sidebar-search.ts +++ b/src/renderer/src/components/settings/appearance-sidebar-search.ts @@ -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( diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 88041fdf8..60ff53fef 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -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'])( diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts index 3b06d4a3b..783f7085d 100644 --- a/src/renderer/src/components/settings/terminal-search.ts +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -34,6 +34,7 @@ import { import { createLocalizedCatalog } from '@/i18n/localized-catalog' export { + getTerminalAdvancedTypographySearchEntries, getTerminalTypographySearchEntries, getTerminalRenderingSearchEntries, getTerminalCursorSearchEntries diff --git a/src/renderer/src/components/settings/terminal-typography-search.ts b/src/renderer/src/components/settings/terminal-typography-search.ts index ed04dcc07..02fe124d2 100644 --- a/src/renderer/src/components/settings/terminal-typography-search.ts +++ b/src/renderer/src/components/settings/terminal-typography-search.ts @@ -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'), diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 5d7fec5c1..ef0430160 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 64bbae244..7475a1d26 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 | null = null let sshShellReadyFallbackTimer: ReturnType | null = null @@ -2619,6 +2621,7 @@ export function connectPanePty( let hiddenOutputRestoreRetryDeferred = false let hiddenOutputRestoreScheduled = false let hiddenOutputRestoreDeferredRetryTimer: ReturnType | null = null + let hiddenOutputRestoreForegroundDeadlineTimer: ReturnType | 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 + 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?.() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0e4ae0698..632f976a5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8a79c6e3a..281be816a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 09040f48d..4e14b21d7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0dc37894a..64591eea9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 089b4e141..aea5fd20e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts index 865d5105c..297e8e405 100644 --- a/src/shared/agent-process-recognition.test.ts +++ b/src/shared/agent-process-recognition.test.ts @@ -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' }) diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 6dcfcbba4..4d724b630 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -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.