From 912c2495fa4ddd9fecc650b0640d59feba6c99bd Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 25 Jul 2026 17:42:34 -0700
Subject: [PATCH] Show Terminal and Window settings by default in Appearance
pane (#10628)
Terminal and Window & Sidebar sections now expand alongside Interface by
default so users don't miss advanced settings. Sections remain independently
collapsible and each can be force-open on deep-link navigation without
collapsing siblings. Search disables toggles to prevent unexpected collapse
when query clears. Remove unused "ghostty" translation key; product name
stays untranslated for search consistency.
---
.../onboarding/GhosttyDiscoveryRow.tsx | 2 +-
.../settings/AppearancePane.test.tsx | 116 +++++++++++++++++-
.../components/settings/AppearancePane.tsx | 46 +++++--
.../components/settings/AppearanceSection.tsx | 15 ++-
.../src/components/settings/Settings.tsx | 4 +-
.../appearance-usage-percentage-search.ts | 6 +-
.../terminal-advanced-platform-search.ts | 8 +-
.../terminal-pane-appearance-search.ts | 4 +-
src/renderer/src/i18n/locales/en.json | 1 -
src/renderer/src/i18n/locales/es.json | 1 -
src/renderer/src/i18n/locales/ja.json | 1 -
src/renderer/src/i18n/locales/ko.json | 1 -
src/renderer/src/i18n/locales/zh.json | 5 +-
13 files changed, 176 insertions(+), 34 deletions(-)
diff --git a/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx b/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx
index 9a24547251e..a9e44f218b6 100644
--- a/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx
+++ b/src/renderer/src/components/onboarding/GhosttyDiscoveryRow.tsx
@@ -28,7 +28,7 @@ export function GhosttyDiscoveryRow({
{translate(
'auto.components.onboarding.ThemeStep.2c3aa538f8',
- 'Looking for a Ghostty config...'
+ 'Looking for a Ghostty config…'
)}
)
diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx
index 0550e9704a6..fb1395a8bce 100644
--- a/src/renderer/src/components/settings/AppearancePane.test.tsx
+++ b/src/renderer/src/components/settings/AppearancePane.test.tsx
@@ -167,6 +167,42 @@ async function renderAppearancePane(
return container
}
+async function rerenderAppearancePane(
+ settings: GlobalSettings = getDefaultSettings('/tmp')
+): Promise {
+ const root = mountedRoots.at(-1)
+ if (!root) {
+ throw new Error('expected a mounted AppearancePane root')
+ }
+ await act(async () => {
+ root.render(
+
+
+
+
+
+ )
+ })
+}
+
+function appearanceSectionToggle(
+ container: HTMLElement,
+ sectionId: 'interface' | 'terminal' | 'window'
+): HTMLButtonElement | undefined {
+ return Array.from(container.querySelectorAll('button[aria-expanded]')).find(
+ (button) => button.getAttribute('aria-controls') === `appearance-section-${sectionId}`
+ )
+}
+
describe('AppearancePane', () => {
afterEach(async () => {
await act(async () => {
@@ -182,6 +218,7 @@ describe('AppearancePane', () => {
mocks.state.availableStatusBarToggles = []
mocks.state.appPlatform = 'linux'
mocks.state.settingsSearchQuery = 'automations'
+ mocks.state.appearanceAccordionDeepLink = null
mocks.state.usagePercentageDisplay = 'used'
// UIZoomControl reads window.api.ui on mount; the inline-expansion pane can
// render the full Interface section, so provide a minimal renderer bridge
@@ -481,7 +518,7 @@ describe('AppearancePane', () => {
expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('antigravity')
})
- it('collapses sibling sections so only the Interface section is expanded by default', async () => {
+ it('expands Interface, Terminal, and Window & Sidebar by default', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
@@ -489,7 +526,80 @@ describe('AppearancePane', () => {
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')
+ expect(expanded).toHaveLength(3)
+ expect(expanded.map((button) => button.textContent).join(' ')).toContain('Interface')
+ expect(expanded.map((button) => button.textContent).join(' ')).toContain('Terminal')
+ expect(expanded.map((button) => button.textContent).join(' ')).toContain('Window & Sidebar')
+ })
+
+ it('lets each appearance section collapse independently', async () => {
+ mocks.state.settingsSearchQuery = ''
+ const container = await renderAppearancePane(getDefaultSettings('/tmp'))
+
+ const terminalToggle = appearanceSectionToggle(container, 'terminal')
+
+ expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true')
+
+ await act(async () => {
+ terminalToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ expect(terminalToggle?.getAttribute('aria-expanded')).toBe('false')
+
+ const stillExpanded = Array.from(
+ container.querySelectorAll('button[aria-expanded="true"]')
+ ).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-'))
+
+ expect(stillExpanded).toHaveLength(2)
+ expect(stillExpanded.map((button) => button.textContent).join(' ')).toContain('Interface')
+ expect(stillExpanded.map((button) => button.textContent).join(' ')).toContain(
+ 'Window & Sidebar'
+ )
+ })
+
+ it('re-opens a collapsed section for appearance deep links without collapsing siblings', async () => {
+ mocks.state.settingsSearchQuery = ''
+ mocks.state.appearanceAccordionDeepLink = null
+ const container = await renderAppearancePane(getDefaultSettings('/tmp'))
+
+ const windowToggle = appearanceSectionToggle(container, 'window')
+ const terminalToggle = appearanceSectionToggle(container, 'terminal')
+ expect(windowToggle?.getAttribute('aria-expanded')).toBe('true')
+
+ await act(async () => {
+ windowToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ expect(windowToggle?.getAttribute('aria-expanded')).toBe('false')
+ expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true')
+
+ mocks.state.appearanceAccordionDeepLink = 'window'
+ await rerenderAppearancePane()
+
+ expect(appearanceSectionToggle(container, 'window')?.getAttribute('aria-expanded')).toBe('true')
+ expect(appearanceSectionToggle(container, 'terminal')?.getAttribute('aria-expanded')).toBe(
+ 'true'
+ )
+ expect(mocks.state.clearAppearanceAccordionDeepLink).toHaveBeenCalled()
+ })
+
+ it('disables section toggles while searching so clearing search does not surprise-collapse', async () => {
+ mocks.state.settingsSearchQuery = 'terminal'
+ const container = await renderAppearancePane(getDefaultSettings('/tmp'))
+
+ const terminalToggle = appearanceSectionToggle(container, 'terminal')
+ expect(terminalToggle?.disabled).toBe(true)
+ expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true')
+
+ await act(async () => {
+ terminalToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true')
+
+ mocks.state.settingsSearchQuery = ''
+ await rerenderAppearancePane()
+
+ const afterClear = appearanceSectionToggle(container, 'terminal')
+ expect(afterClear?.disabled).toBe(false)
+ expect(afterClear?.getAttribute('aria-expanded')).toBe('true')
})
})
diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx
index 58954d32565..6e73d5aafdf 100644
--- a/src/renderer/src/components/settings/AppearancePane.tsx
+++ b/src/renderer/src/components/settings/AppearancePane.tsx
@@ -55,6 +55,12 @@ type AppearancePaneProps = {
type AppearanceSectionKey = 'interface' | 'terminal' | 'window'
+const ALL_APPEARANCE_SECTIONS = [
+ 'interface',
+ 'terminal',
+ 'window'
+] as const satisfies readonly AppearanceSectionKey[]
+
function resolveThemeSummary(theme: GlobalSettings['theme']): string {
if (theme === 'system') {
return translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System')
@@ -88,20 +94,29 @@ export function AppearancePane({
const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient
const isDesktopMac = getRendererAppPlatform() === 'darwin' && !isWebClient
- const [manuallyOpenSection, setManuallyOpenSection] = useState(
- 'interface'
+ // Why: Terminal / Window settings were too easy to miss when only Interface
+ // started open; keep sections independently collapsible but expanded by default.
+ const [openSections, setOpenSections] = useState>(
+ () => new Set(ALL_APPEARANCE_SECTIONS)
)
// Why: nested deep links (e.g. Usage percentages) land under Window & Sidebar;
- // expand that accordion before Settings scrolls so the row is actually visible.
+ // expand that section before Settings scrolls so the row is actually visible.
useLayoutEffect(() => {
if (!appearanceAccordionDeepLink) {
return
}
- setManuallyOpenSection(appearanceAccordionDeepLink)
+ setOpenSections((current) => {
+ if (current.has(appearanceAccordionDeepLink)) {
+ return current
+ }
+ const next = new Set(current)
+ next.add(appearanceAccordionDeepLink)
+ return next
+ })
clearAppearanceAccordionDeepLink()
- // Why: accordion expand is layout-synchronous; scroll on the next frame so
- // the target has non-zero height when Settings (or this fallback) scrolls.
+ // Why: expand is layout-synchronous; scroll on the next frame so the target
+ // has non-zero height when Settings (or this fallback) scrolls.
const frameId = requestAnimationFrame(() => {
document
.getElementById(USAGE_PERCENTAGE_DISPLAY_SETTING_ID)
@@ -167,8 +182,8 @@ export function AppearancePane({
const appIconMatches = matchesSettingsSearch(searchQuery, getAppIconEntries())
// 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.
+ // controls (including advanced ones) are revealed; otherwise use the user's
+ // independent open/closed state (all expanded by default).
function isSectionOpen(key: AppearanceSectionKey): boolean {
if (isSearching) {
return key === 'interface'
@@ -177,11 +192,19 @@ export function AppearancePane({
? terminalMatches
: windowMatches
}
- return manuallyOpenSection === key
+ return openSections.has(key)
}
function toggleSection(key: AppearanceSectionKey): void {
- setManuallyOpenSection((current) => (current === key ? null : key))
+ setOpenSections((current) => {
+ const next = new Set(current)
+ if (next.has(key)) {
+ next.delete(key)
+ } else {
+ next.add(key)
+ }
+ return next
+ })
}
const interfaceSummary = `${resolveThemeSummary(settings.theme)} · ${
@@ -203,6 +226,7 @@ export function AppearancePane({
summary={interfaceSummary}
open={isSectionOpen('interface')}
onToggle={() => toggleSection('interface')}
+ toggleDisabled={isSearching}
>
toggleSection('terminal')}
+ toggleDisabled={isSearching}
>
toggleSection('window')}
+ toggleDisabled={isSearching}
>
void
+ /** Why: search force-opens matching sections; disable collapse so toggles
+ * do not silently rewrite open-state that only applies after search clears. */
+ toggleDisabled?: boolean
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. */
+/** Compact summary row that expands its section inline. The parent owns open
+ * state so sections can stay independently collapsible and search can force
+ * a section open. */
export function AppearanceSection({
id,
icon,
@@ -24,6 +27,7 @@ export function AppearanceSection({
summary,
open,
onToggle,
+ toggleDisabled = false,
children
}: AppearanceSectionProps): React.JSX.Element {
const contentId = `appearance-section-${id}`
@@ -39,7 +43,8 @@ export function AppearanceSection({
aria-expanded={open}
aria-controls={contentId}
onClick={onToggle}
- className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
+ disabled={toggleDisabled}
+ className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default disabled:hover:bg-transparent"
>
{icon}
diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx
index ac0578fa38b..65b98097cca 100644
--- a/src/renderer/src/components/settings/Settings.tsx
+++ b/src/renderer/src/components/settings/Settings.tsx
@@ -645,7 +645,7 @@ function Settings(): React.JSX.Element {
}
pendingNavSectionRef.current = paneSectionId
pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId
- // Why: force Appearance's collapsed status-bar accordion open before scrolling so the row is visible.
+ // Why: ensure Appearance's nested status-bar section is open before scrolling so the row is visible.
if (settingsNavigationTarget.pane === 'appearance') {
const accordion = resolveAppearanceAccordionDeepLink(settingsNavigationTarget.sectionId)
if (accordion) {
@@ -974,7 +974,7 @@ function Settings(): React.JSX.Element {
const scrollTargetId = pendingScrollTargetRef.current
const pendingNavSectionId = pendingNavSectionRef.current
- // Why: subsection deep links clear a stale filter that could hide the target row; pane-level links keep it to force-open the matching accordion.
+ // Why: subsection deep links clear a stale filter that could hide the target row; pane-level links keep it to force-open the matching section.
if (
scrollTargetId &&
pendingNavSectionId &&
diff --git a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts
index b7bbc49218b..5745bfa325b 100644
--- a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts
+++ b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts
@@ -5,12 +5,12 @@ import { translateSearchKeyword } from './settings-search-keywords'
/** Stable Settings deep-link / scroll target for the Used/Remaining control. */
export const USAGE_PERCENTAGE_DISPLAY_SETTING_ID = 'usage-percentage-display'
-/** Appearance accordion keys that can be force-opened for nested deep links. */
+/** Appearance section keys that can be force-opened for nested deep links. */
export type AppearanceAccordionSection = 'interface' | 'terminal' | 'window'
/**
- * Map a Settings subsection id to the Appearance accordion that must be open
- * before the row is visible (collapsed accordion content is not user-visible).
+ * Map a Settings subsection id to the Appearance section that must be open
+ * before the row is visible (collapsed section content is not user-visible).
*/
export function resolveAppearanceAccordionDeepLink(
sectionId: string | undefined
diff --git a/src/renderer/src/components/settings/terminal-advanced-platform-search.ts b/src/renderer/src/components/settings/terminal-advanced-platform-search.ts
index e686b51d37c..a944ae12f65 100644
--- a/src/renderer/src/components/settings/terminal-advanced-platform-search.ts
+++ b/src/renderer/src/components/settings/terminal-advanced-platform-search.ts
@@ -62,7 +62,9 @@ export const getTerminalMacOptionSearchEntries = createLocalizedCatalog(() => [
'international'
),
...translateSearchKeyword('auto.components.settings.terminal.search.fae142a354', 'readline'),
- ...translateSearchKeyword('auto.components.settings.terminal.search.82b63d07fe', 'ghostty')
+ // Why: product name stays untranslated so search matches "Ghostty".
+ 'Ghostty',
+ 'ghostty'
]
}
])
@@ -99,7 +101,9 @@ export const getTerminalGhosttyImportSearchEntries = createLocalizedCatalog(() =
'One-time import of supported Ghostty terminal settings.'
),
keywords: [
- ...translateSearchKeyword('auto.components.settings.terminal.search.82b63d07fe', 'ghostty'),
+ // Why: product name stays untranslated so search matches "Ghostty".
+ 'Ghostty',
+ 'ghostty',
...translateSearchKeyword('auto.components.settings.terminal.search.fd752b3cac', 'import'),
...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
...translateSearchKeyword('auto.components.settings.terminal.search.2ade3ea490', 'config'),
diff --git a/src/renderer/src/components/settings/terminal-pane-appearance-search.ts b/src/renderer/src/components/settings/terminal-pane-appearance-search.ts
index d77c5e68432..becfb796bee 100644
--- a/src/renderer/src/components/settings/terminal-pane-appearance-search.ts
+++ b/src/renderer/src/components/settings/terminal-pane-appearance-search.ts
@@ -85,7 +85,9 @@ export const getTerminalPaneInteractionSearchEntries = createLocalizedCatalog(()
...translateSearchKeyword('auto.components.settings.terminal.search.ea364ce6e4', 'mouse'),
...translateSearchKeyword('auto.components.settings.terminal.search.d1fa00a9cb', 'hover'),
...translateSearchKeyword('auto.components.settings.terminal.search.846a7a1204', 'pane'),
- ...translateSearchKeyword('auto.components.settings.terminal.search.82b63d07fe', 'ghostty'),
+ // Why: product name stays untranslated so search matches "Ghostty".
+ 'Ghostty',
+ 'ghostty',
...translateSearchKeyword('auto.components.settings.terminal.search.f036794286', 'active')
]
},
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 71aef99a990..f73ec351319 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -8642,7 +8642,6 @@
"10f9fb6fea": "settings",
"2ade3ea490": "config",
"fd752b3cac": "import",
- "82b63d07fe": "ghostty",
"73e9422f19": "One-time import of supported Ghostty terminal settings.",
"a979df0083": "Import from Ghostty",
"warp_import": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 38bd42096b2..7bd23476609 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -8582,7 +8582,6 @@
"10f9fb6fea": "ajustes",
"2ade3ea490": "configuración",
"fd752b3cac": "importar",
- "82b63d07fe": "ghostty",
"73e9422f19": "Importación única de ajustes de terminal compatibles de Ghostty.",
"a979df0083": "Importar desde Ghostty",
"4cec42dbf7": "internacional",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 7d4a14ba7a2..e45b172ace4 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -8604,7 +8604,6 @@
"10f9fb6fea": "設定",
"2ade3ea490": "構成",
"fd752b3cac": "インポート",
- "82b63d07fe": "ghostty",
"73e9422f19": "サポートされている Ghostty terminal 設定の 1 回限りのインポート。",
"a979df0083": "Ghostty からインポート",
"warp_import": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index f4a063ce1a4..d032b5bdb5d 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -8567,7 +8567,6 @@
"10f9fb6fea": "설정",
"2ade3ea490": "구성",
"fd752b3cac": "가져오기",
- "82b63d07fe": "ghostty",
"73e9422f19": "지원되는 Ghostty terminal 설정을 한 번만 가져옵니다.",
"a979df0083": "Ghostty에서 가져오기",
"4cec42dbf7": "국제",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index f8d65c5be1e..cd83fea80a0 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -6870,7 +6870,7 @@
"4aae5db258": "字体粗细",
"f04b17a50e": "新窗格和实时更新的默认终端字体系列。",
"a408266e67": "字体家族",
- "855a76343a": "从幽灵导入",
+ "855a76343a": "从 Ghostty 导入",
"711e589f18": "新窗格和实时更新的默认终端排版。",
"048aac8a64": "终端排版",
"4415beb958": "已禁用",
@@ -8567,9 +8567,8 @@
"10f9fb6fea": "设置",
"2ade3ea490": "配置",
"fd752b3cac": "导入",
- "82b63d07fe": "Ghostty",
"73e9422f19": "一次性导入受支持的 Ghostty 终端设置。",
- "a979df0083": "从幽灵导入",
+ "a979df0083": "从 Ghostty 导入",
"4cec42dbf7": "国际",
"b495dc6a9f": "吉斯",
"d8d6f7a3c5": "麦科斯",