fix(terminal): scope terminal color overrides to their light/dark theme slot (#36581)

This commit is contained in:
Neil
2026-10-31 13:12:56 -07:00
committed by GitHub
parent 119aafe0ca
commit ee44140fb9
43 changed files with 454 additions and 222 deletions
@@ -278,6 +278,40 @@ it('migrates a shipped split chat/terminal profile using terminal choices while
})
})
describe('pre-#10561 shared terminal color overrides', () => {
const overrides = { background: '#101010' }
const legacy = { terminalColorOverridesSplit: undefined, terminalColorOverrides: overrides }
it.each([
{ theme: 'system', terminalUseSeparateLightTheme: true },
{ theme: 'light', terminalUseSeparateLightTheme: true },
{ theme: 'system', terminalUseSeparateLightTheme: undefined }
])('keep painting both modes when the light slot could show them (%o)', (appearance) => {
const normalized = normalizeLegacyProfile({ ...legacy, ...appearance })
expect(normalized.terminalColorOverrides).toEqual(overrides)
expect(normalized.terminalColorOverridesLight).toEqual(overrides)
expect(normalized.terminalColorOverridesSplit).toBe(true)
})
it.each([
{ theme: 'dark', terminalUseSeparateLightTheme: true },
{ theme: 'light', terminalUseSeparateLightTheme: false }
])('stay on the dark slot otherwise (%o)', (appearance) => {
const normalized = normalizeLegacyProfile({ ...legacy, ...appearance })
expect(normalized.terminalColorOverrides).toEqual(overrides)
expect(normalized.terminalColorOverridesLight).toBeUndefined()
})
it('never re-split once migrated, so a cleared light slot stays clear', () => {
const normalized = normalizeLegacyProfile({
terminalColorOverrides: overrides,
theme: 'light',
terminalUseSeparateLightTheme: true
})
expect(normalized.terminalColorOverridesLight).toBeUndefined()
})
})
describe('terminal default shell', () => {
it('trims a hand-edited value and treats whitespace-only as unset', () => {
expect(
@@ -36,6 +36,7 @@ export function normalizeLoadedGlobalSettings(
migratedOptionAsAlt,
migratedFloatingTerminalEnabled,
migratedOsc52Clipboard,
migratedTerminalColorOverrides,
migratedFloatingTerminalCwd,
migratedFloatingTerminalTrustedCwds
} = terminal
@@ -111,6 +112,7 @@ export function normalizeLoadedGlobalSettings(
localAccountRuntime: migratedLocalAccountRuntime,
localAccountRuntimeDefaultedToAutoForAllUsers: true,
...migratedOsc52Clipboard,
...migratedTerminalColorOverrides,
floatingTerminalEnabled: migratedFloatingTerminalEnabled,
floatingTerminalDefaultedForAllUsers: true,
floatingTerminalCwd: migratedFloatingTerminalCwd,
@@ -16,6 +16,7 @@ import {
normalizeOsc52ClipboardDefaultOn,
osc52ClipboardDefaultOnOverridesPersistedOff
} from '../../../shared/osc52-clipboard-settings'
import { splitLegacyTerminalColorOverrides } from '../../../shared/terminal-color-overrides'
import {
migrateTerminalScrollbackRows,
migrateTerminalTuiScrollSensitivityDefault
@@ -47,6 +48,7 @@ export type PreparedLoadedTerminalSettings = {
GlobalSettings,
'terminalAllowOsc52Clipboard' | 'terminalAllowOsc52ClipboardDefaultedOnForAllUsers'
>
migratedTerminalColorOverrides: ReturnType<typeof splitLegacyTerminalColorOverrides>
migratedFloatingTerminalCwd: GlobalSettings['floatingTerminalCwd']
migratedFloatingTerminalTrustedCwds: GlobalSettings['floatingTerminalTrustedCwds']
osc52ClipboardNoticePending: boolean
@@ -135,6 +137,10 @@ export function prepareLoadedTerminalSettings(
if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) {
markNeedsSave()
}
const migratedTerminalColorOverrides = splitLegacyTerminalColorOverrides(parsed.settings)
if (parsed.settings?.terminalColorOverridesSplit !== true) {
markNeedsSave()
}
const floatingTerminalCwdMigrated =
parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true
// Repair the old empty notes-directory migration; terminal cwd and notes now resolve separately.
@@ -182,6 +188,7 @@ export function prepareLoadedTerminalSettings(
migratedOptionAsAlt,
migratedFloatingTerminalEnabled,
migratedOsc52Clipboard,
migratedTerminalColorOverrides,
migratedFloatingTerminalCwd,
migratedFloatingTerminalTrustedCwds,
osc52ClipboardNoticePending
@@ -14,7 +14,7 @@ import {
import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures'
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
import { terminalPreviewUnavailableMessage } from './terminal-preview-unavailable-message'
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { createPreviewGridClaim } from './preview-grid-claim'
@@ -65,10 +65,7 @@ export function AgentTerminalPreview({
return { terminalTheme: null, terminalMode: 'dark' as const }
}
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const theme = composeActiveTerminalTheme(
appearance.theme ?? getBuiltinTheme(appearance.themeName),
settings
)
const theme = composeActiveTerminalTheme(appearance.theme, settings)
return { terminalTheme: theme, terminalMode: appearance.mode }
}, [settings, systemPrefersDark])
// Settings arrive as cloned snapshots; compare theme values before reconnecting.
@@ -4,6 +4,7 @@ import { resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection'
import { TERMINAL_COLOR_OVERRIDES_SETTING } from '../../../../shared/terminal-color-overrides'
import { nativeChatAppearanceStyle } from './native-chat-appearance-style'
const css = readFileSync(resolve('src/renderer/src/assets/main.css'), 'utf8')
@@ -31,7 +32,7 @@ function createChat(appScheme: 'light' | 'dark', terminalScheme: 'light' | 'dark
const style = nativeChatAppearanceStyle(
createGlobalSettingsFixture({
theme: appScheme,
terminalColorOverrides:
[TERMINAL_COLOR_OVERRIDES_SETTING[appScheme]]:
terminalScheme === 'light'
? { background: '#ffffff', foreground: '#000000' }
: { background: '#000000', foreground: '#ffffff' },
@@ -46,6 +46,7 @@ export function selectNativeChatAppearanceSettings(
terminalUseSeparateLightTheme: settings?.terminalUseSeparateLightTheme,
terminalCustomThemes: settings?.terminalCustomThemes,
terminalColorOverrides: settings?.terminalColorOverrides,
terminalColorOverridesLight: settings?.terminalColorOverridesLight,
terminalFontFamily: settings?.terminalFontFamily,
terminalFontSize: settings?.nativeChatAppearance?.matchTerminalInterface
? settings.terminalFontSize
@@ -107,7 +108,8 @@ export function nativeChatAppearanceStyle(
terminalThemeLight: settings?.terminalThemeLight ?? '',
terminalUseSeparateLightTheme: settings?.terminalUseSeparateLightTheme ?? false,
terminalCustomThemes: settings?.terminalCustomThemes,
terminalColorOverrides: settings?.terminalColorOverrides
terminalColorOverrides: settings?.terminalColorOverrides,
terminalColorOverridesLight: settings?.terminalColorOverridesLight
},
systemPrefersDark
)
@@ -10,6 +10,8 @@ import type { GhosttyImportPreview, GlobalSettings } from '../../../../shared/gl
import type { DiscoveryStatusEmitted } from '../../../../shared/onboarding-state-types'
import { i18n, translate } from '@/i18n/i18n'
import { ChromePreview } from './theme-chrome-preview'
import { withImportedTerminalColorOverrides } from '../../../../shared/terminal-theme-selection'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
type ThemeStepProps = {
theme: GlobalSettings['theme']
@@ -128,17 +130,9 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
track('onboarding_ghostty_import_failed', { reason: 'empty_diff' })
return
}
await updateSettings({
...resolved.diff,
...(resolved.diff.terminalColorOverrides
? {
terminalColorOverrides: {
...settings.terminalColorOverrides,
...resolved.diff.terminalColorOverrides
}
}
: {})
})
await updateSettings(
withImportedTerminalColorOverrides(settings, resolved.diff, getSystemPrefersDark())
)
// Sync the controller’s local theme so Continue cannot overwrite the imported theme.
if (resolved.diff.theme && mountedRef.current) {
onThemeChange(resolved.diff.theme)
@@ -11,7 +11,7 @@ import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { resolveTerminalMinimumContrastRatio } from '../../../../shared/terminal-contrast-correction'
import { syncPreviewTerminalLigatures } from '@/components/dashboard-popout/preview-terminal-ligatures'
@@ -71,10 +71,7 @@ function resolveThemeColors(
}
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
return {
theme: composeActiveTerminalTheme(
appearance.theme ?? getBuiltinTheme(appearance.themeName),
settings
),
theme: composeActiveTerminalTheme(appearance.theme, settings),
mode: appearance.mode
}
}
@@ -172,7 +172,8 @@ it.each([6, 60])('prepares only the clicked terminal palette among %i choices',
terminalUseSeparateLightTheme: true,
terminalPluginPalettes: [{ id: candidate.id, colors: candidate.terminal }]
})
expect(update).toHaveProperty('terminalColorOverrides', undefined)
// The light slot now has its own theme, so the dark slot's overrides stay for dark mode.
expect(update).not.toHaveProperty('terminalColorOverrides')
expect(update).not.toHaveProperty('theme')
expect(settings().terminalThemeLight).toBe(before.terminalThemeLight)
expect(
@@ -5,6 +5,10 @@ import type { PluginTerminalThemeRegistration } from '../../../../../shared/plug
import { translate } from '@/i18n/i18n'
import { resolveDocumentTheme } from '@/lib/document-theme'
import { pluginAppThemeSlotUpdate } from '../../../../../shared/plugin-theme-settings'
import {
hasTerminalColorOverrides,
TERMINAL_COLOR_OVERRIDES_SETTING
} from '../../../../../shared/terminal-color-overrides'
import { withTerminalPluginPalettes } from '@/lib/terminal-plugin-palettes'
import { useAppStore } from '@/store'
import { PLUGIN_ACTION_TOAST_DURATION_MS } from './plugin-action-toast'
@@ -62,13 +66,14 @@ export function terminalThemeUpdate(
dark: boolean,
terminalThemes: readonly PluginTerminalThemeRegistration[]
): Partial<GlobalSettings> {
const slot = terminalSlot(theme.mode, dark) === 'terminalThemeLight' ? 'light' : 'dark'
return withTerminalPluginPalettes(
settings,
{
...terminalThemeSlotUpdate(theme, dark),
// A selected theme must replace overrides or they mask its palette.
...(Object.keys(settings.terminalColorOverrides ?? {}).length > 0
? { terminalColorOverrides: undefined }
// A selected theme must replace its slot's overrides or they mask its palette.
...(hasTerminalColorOverrides(settings, slot)
? { [TERMINAL_COLOR_OVERRIDES_SETTING[slot]]: undefined }
: {})
},
terminalThemes
@@ -125,6 +130,7 @@ export async function applyPluginThemesNow(pluginKey: string): Promise<boolean>
terminalThemeLight: settings.terminalThemeLight,
terminalUseSeparateLightTheme: settings.terminalUseSeparateLightTheme,
terminalColorOverrides: settings.terminalColorOverrides,
terminalColorOverridesLight: settings.terminalColorOverridesLight,
terminalPluginPalettes: settings.terminalPluginPalettes ?? []
}
let next: Partial<GlobalSettings> = {}
@@ -158,7 +158,10 @@ export function TerminalSurfacePreview({ settings }: { settings: GlobalSettings
const systemPrefersDark = useSystemPrefersDark()
const customThemes = JSON.stringify(settings.terminalCustomThemes)
const pluginPalettes = JSON.stringify(settings.terminalPluginPalettes)
const colorOverrides = JSON.stringify(settings.terminalColorOverrides)
const colorOverrides = JSON.stringify([
settings.terminalColorOverrides,
settings.terminalColorOverridesLight
])
const theme = useMemo(
() =>
composeActiveTerminalTheme(
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { TerminalThemeSlot } from '../../../../shared/terminal-color-overrides'
import {
matchesSettingsSearch,
normalizeSettingsSearchQuery,
@@ -49,8 +50,6 @@ type TerminalAppearanceSectionProps = {
forceVisiblePrimary?: boolean
}
type TerminalThemeTarget = 'dark' | 'light'
function scoreThemeTargetIntent(searchQuery: string, entries: SettingsSearchEntry[]): number {
// Infer dark/light target from labels/aliases, not incidental description mentions.
return scoreSettingsSearch(
@@ -62,7 +61,7 @@ function scoreThemeTargetIntent(searchQuery: string, entries: SettingsSearchEntr
function getPreferredThemeTarget(
darkThemeSearchScore: number,
lightThemeSearchScore: number
): TerminalThemeTarget | undefined {
): TerminalThemeSlot | undefined {
if (darkThemeSearchScore === lightThemeSearchScore) {
return undefined
}
@@ -127,47 +126,58 @@ export function TerminalAppearanceSection({
showDesktopThemeImports && (!isSearching || forceVisiblePrimary || ghosttyImportMatches)
const showTypographyAdvancedDisclosure = !isSearching || typographyMatches
const advancedGroups = [
cursorMatches
? {
key: 'cursor',
node: (
<TerminalCursorAppearanceSection settings={settings} updateSettings={updateSettings} />
)
}
: null,
paneMatches
? {
key: 'pane',
node: (
<TerminalPaneAppearanceSection settings={settings} updateSettings={updateSettings} />
)
}
: null,
windowMatches
? {
key: 'window',
node: <TerminalWindowSection settings={settings} updateSettings={updateSettings} />
}
: null
].filter((group): group is { key: string; node: React.JSX.Element } => group !== null)
const showAdvancedDisclosure = !isSearching || advancedGroups.length > 0
const previewAdvancedContent = showAdvancedDisclosure ? (
<AppearanceAdvancedDisclosure
showTopBorder={false}
className="mt-0 pt-2"
contentClassName="ml-4 pt-4"
>
{advancedGroups.map((group, index) => (
<div
key={group.key}
className={index > 0 ? 'mt-2 border-t border-border/60 pt-4' : undefined}
>
{group.node}
</div>
))}
</AppearanceAdvancedDisclosure>
) : null
const advancedGroups = (colorOverrideSlot: TerminalThemeSlot) =>
[
cursorMatches
? {
key: 'cursor',
node: (
<TerminalCursorAppearanceSection
settings={settings}
updateSettings={updateSettings}
/>
)
}
: null,
paneMatches
? {
key: 'pane',
node: (
<TerminalPaneAppearanceSection settings={settings} updateSettings={updateSettings} />
)
}
: null,
windowMatches
? {
key: 'window',
node: (
<TerminalWindowSection
settings={settings}
updateSettings={updateSettings}
colorOverrideSlot={colorOverrideSlot}
/>
)
}
: null
].filter((group): group is { key: string; node: React.JSX.Element } => group !== null)
const showAdvancedDisclosure = !isSearching || cursorMatches || paneMatches || windowMatches
const renderPreviewAdvancedContent = (colorOverrideSlot: TerminalThemeSlot): ReactNode =>
showAdvancedDisclosure ? (
<AppearanceAdvancedDisclosure
showTopBorder={false}
className="mt-0 pt-2"
contentClassName="ml-4 pt-4"
>
{advancedGroups(colorOverrideSlot).map((group, index) => (
<div
key={group.key}
className={index > 0 ? 'mt-2 border-t border-border/60 pt-4' : undefined}
>
{group.node}
</div>
))}
</AppearanceAdvancedDisclosure>
) : null
return (
<div className="space-y-5">
@@ -260,7 +270,7 @@ export function TerminalAppearanceSection({
warpThemes={warpThemes}
showThemeImport={showDesktopThemeImports}
preferredTarget={preferredThemeTarget}
advancedContent={previewAdvancedContent}
renderAdvancedContent={renderPreviewAdvancedContent}
/>
) : null}
@@ -90,6 +90,8 @@ export function TerminalSettingsPreview({
settings.terminalUseSeparateLightTheme,
settings.terminalDividerColorDark,
settings.terminalDividerColorLight,
settings.terminalColorOverrides,
settings.terminalColorOverridesLight,
systemPrefersDark
]
)
@@ -98,12 +100,7 @@ export function TerminalSettingsPreview({
const composedTheme = useMemo(
() => composeActiveTerminalTheme(appearance.theme, settings),
// oxlint-disable-next-line react-hooks/exhaustive-deps
[
appearance,
settings.terminalColorOverrides,
settings.terminalBackgroundOpacity,
settings.terminalCursorOpacity
]
[appearance, settings.terminalBackgroundOpacity, settings.terminalCursorOpacity]
)
const dividerThicknessPx = clampNumber(settings.terminalDividerThicknessPx, 1, 32)
@@ -234,12 +234,13 @@ describe('TerminalThemeCatalogSection', () => {
})
})
it('clears imported color overrides when selecting a light theme', () => {
it('clears only the light slot overrides when selecting a light theme', () => {
const updateSettings = vi.fn()
const element = renderCatalog(
makeSettings({
terminalUseSeparateLightTheme: true,
terminalColorOverrides: { background: '#111111' }
terminalColorOverrides: { background: '#111111' },
terminalColorOverridesLight: { background: '#eeeeee' }
}),
updateSettings,
'light'
@@ -251,7 +252,7 @@ describe('TerminalThemeCatalogSection', () => {
expect(updateSettings.mock.calls[0]?.[0]).toStrictEqual({
terminalThemeLight: 'GitHub Light',
terminalColorOverrides: undefined
terminalColorOverridesLight: undefined
})
})
@@ -2,6 +2,11 @@ import { usePluginTerminalThemes } from '@/store/plugin-themes'
import { withTerminalPluginPalettes } from '@/lib/terminal-plugin-palettes'
import { useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import {
hasTerminalColorOverrides,
TERMINAL_COLOR_OVERRIDES_SETTING,
type TerminalThemeSlot
} from '../../../../shared/terminal-color-overrides'
import {
ColorField,
SettingsSegmentedControl,
@@ -23,11 +28,9 @@ import {
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
type TerminalThemeTarget = 'dark' | 'light'
let lastEditedTerminalThemeTarget: TerminalThemeSlot | null = null
let lastEditedTerminalThemeTarget: TerminalThemeTarget | null = null
function rememberTerminalThemeTarget(target: TerminalThemeTarget): void {
function rememberTerminalThemeTarget(target: TerminalThemeSlot): void {
lastEditedTerminalThemeTarget = target
}
@@ -43,8 +46,8 @@ function isCustomizedTheme(themeName: string, defaultThemeName: string): boolean
function getInitialTerminalThemeTarget(
settings: GlobalSettings,
systemPrefersDark: boolean,
preferredTarget?: TerminalThemeTarget
): TerminalThemeTarget {
preferredTarget?: TerminalThemeSlot
): TerminalThemeSlot {
if (preferredTarget) {
return preferredTarget
}
@@ -73,8 +76,8 @@ type TerminalThemeCatalogSectionProps = {
importedHighlightSignal: number
warpThemes: UseWarpThemeImportReturn
showThemeImport: boolean
preferredTarget?: TerminalThemeTarget
advancedContent?: ReactNode
preferredTarget?: TerminalThemeSlot
renderAdvancedContent?: (colorOverrideSlot: TerminalThemeSlot) => ReactNode
}
export function TerminalThemeCatalogSection({
@@ -88,12 +91,12 @@ export function TerminalThemeCatalogSection({
warpThemes,
showThemeImport,
preferredTarget,
advancedContent
renderAdvancedContent
}: TerminalThemeCatalogSectionProps): React.JSX.Element {
const [target, setTargetState] = useState<TerminalThemeTarget>(() =>
const [target, setTargetState] = useState<TerminalThemeSlot>(() =>
getInitialTerminalThemeTarget(settings, systemPrefersDark, preferredTarget)
)
const setTarget = (nextTarget: TerminalThemeTarget): void => {
const setTarget = (nextTarget: TerminalThemeSlot): void => {
rememberTerminalThemeTarget(nextTarget)
setTargetState(nextTarget)
}
@@ -103,6 +106,8 @@ export function TerminalThemeCatalogSection({
const matchDarkMode = !settings.terminalUseSeparateLightTheme
const lightModeMatchesDark = isLightTarget && matchDarkMode
const showCustomControls = !lightModeMatchesDark
// Light mode that matches dark paints the dark slot, so its overrides are the ones the preview shows.
const advancedContent = renderAdvancedContent?.(lightModeMatchesDark ? 'dark' : target)
const selectedTheme = isLightTarget ? settings.terminalThemeLight : settings.terminalThemeDark
const pickerTitle = isLightTarget
? translate('auto.components.settings.TerminalThemeSections.8273bc75d7', 'Light Theme')
@@ -254,15 +259,15 @@ export function TerminalThemeCatalogSection({
onQueryChange={setThemeSearch}
onSelectTheme={(theme) => {
rememberTerminalThemeTarget(target)
// A selected theme must replace overrides or they mask the picker.
const hasColorOverrides =
Object.keys(settings.terminalColorOverrides ?? {}).length > 0
// A selected theme must replace its slot's overrides or they mask the picker.
updateSettings(
withTerminalPluginPalettes(
settings,
{
[isLightTarget ? 'terminalThemeLight' : 'terminalThemeDark']: theme,
...(hasColorOverrides ? { terminalColorOverrides: undefined } : {})
...(hasTerminalColorOverrides(settings, target)
? { [TERMINAL_COLOR_OVERRIDES_SETTING[target]]: undefined }
: {})
},
pluginTerminalThemes
)
@@ -1,6 +1,11 @@
import { useRef, useState } from 'react'
import { RotateCw } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import {
TERMINAL_COLOR_OVERRIDES_SETTING,
terminalColorOverridesForSlot,
type TerminalThemeSlot
} from '../../../../shared/terminal-color-overrides'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'
@@ -10,18 +15,23 @@ import { clampNumber } from '@/lib/terminal-theme'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { COLOR_OVERRIDE_GROUPS } from './terminal-window-color-groups'
type TerminalWindowSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
/** Theme slot the Color Overrides editor writes, following the theme catalog's Dark/Light target. */
colorOverrideSlot: TerminalThemeSlot
}
import { COLOR_OVERRIDE_GROUPS } from './terminal-window-color-groups'
export function TerminalWindowSection({
settings,
updateSettings
updateSettings,
colorOverrideSlot
}: TerminalWindowSectionProps): React.JSX.Element {
const [colorOverridesExpanded, setColorOverridesExpanded] = useState(false)
const colorOverridesSetting = TERMINAL_COLOR_OVERRIDES_SETTING[colorOverrideSlot]
const colorOverrides = terminalColorOverridesForSlot(settings, colorOverrideSlot)
// Blur is a startup window option on macOS/Windows; compare against mount state to request restart only after changes.
const blurAtMountRef = useRef<boolean>(settings.windowBackgroundBlur ?? false)
const blurPendingRestart = (settings.windowBackgroundBlur ?? false) !== blurAtMountRef.current
@@ -283,6 +293,17 @@ export function TerminalWindowSection({
}`}
>
<div className="min-h-0 space-y-4">
<p className="text-xs text-muted-foreground">
{colorOverrideSlot === 'light'
? translate(
'auto.components.settings.TerminalWindowSection.color_overrides_light_description',
'Override individual colors of the light terminal theme.'
)
: translate(
'auto.components.settings.TerminalWindowSection.color_overrides_dark_description',
'Override individual colors of the dark terminal theme.'
)}
</p>
{COLOR_OVERRIDE_GROUPS.map((group) => (
<div key={group.label} className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground">{group.label}</p>
@@ -292,12 +313,12 @@ export function TerminalWindowSection({
key={item.key}
label={item.label}
description={item.description}
value={settings.terminalColorOverrides?.[item.key] ?? ''}
value={colorOverrides?.[item.key] ?? ''}
fallback=""
onChange={(value) =>
updateSettings({
terminalColorOverrides: {
...settings.terminalColorOverrides,
[colorOverridesSetting]: {
...colorOverrides,
[item.key]: value || undefined
}
})
@@ -310,12 +331,17 @@ export function TerminalWindowSection({
<Button
variant="outline"
size="sm"
onClick={() => updateSettings({ terminalColorOverrides: undefined })}
onClick={() => updateSettings({ [colorOverridesSetting]: undefined })}
>
{translate(
'auto.components.settings.TerminalWindowSection.03c855d15f',
'Reset all color overrides'
)}
{colorOverrideSlot === 'light'
? translate(
'auto.components.settings.TerminalWindowSection.reset_light_color_overrides',
'Reset light color overrides'
)
: translate(
'auto.components.settings.TerminalWindowSection.reset_dark_color_overrides',
'Reset dark color overrides'
)}
</Button>
</div>
</div>
@@ -1,6 +1,8 @@
import { useState } from 'react'
import type { GhosttyImportPreview, GlobalSettings } from '../../../../shared/global-settings-types'
import { useMountedRef } from '../../hooks/useMountedRef'
import { withImportedTerminalColorOverrides } from '../../../../shared/terminal-theme-selection'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
export type UseGhosttyImportReturn = {
open: boolean
@@ -49,17 +51,11 @@ export function useGhosttyImport(
if (applied || !preview?.found || Object.keys(preview.diff).length === 0 || !settings) {
return
}
const merged = {
...preview.diff,
...(preview.diff.terminalColorOverrides
? {
terminalColorOverrides: {
...settings.terminalColorOverrides,
...preview.diff.terminalColorOverrides
}
}
: {})
}
const merged = withImportedTerminalColorOverrides(
settings,
preview.diff,
getSystemPrefersDark()
)
setApplyError(null)
try {
// Await persistence before marking applied so rejected IPC cannot report success.
@@ -5,7 +5,6 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
describe('composeActiveTerminalTheme', () => {
function settingsWith(partial: Partial<GlobalSettings>): GlobalSettings {
return {
terminalColorOverrides: undefined,
terminalCursorOpacity: undefined,
terminalBackgroundOpacity: undefined,
...partial
@@ -38,16 +37,6 @@ describe('composeActiveTerminalTheme', () => {
expect(result!.scrollbarSliderBackground).toBe('rgba(1, 2, 3, 0.4)')
})
it('layers terminalColorOverrides on top of the base theme', () => {
const base = { background: '#101010', foreground: '#fafafa' }
const result = composeActiveTerminalTheme(
base,
settingsWith({ terminalColorOverrides: { foreground: '#00ff00' } })
)
expect(result!.foreground).toBe('#00ff00')
expect(result!.background).toBe('#101010')
})
it('applies background opacity by converting the hex background to rgba', () => {
const base = { background: '#112233' }
const result = composeActiveTerminalTheme(
@@ -3,11 +3,7 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures'
import {
getBuiltinTheme,
resolvePaneStyleOptions,
resolveEffectiveTerminalAppearance
} from '@/lib/terminal-theme'
import { resolvePaneStyleOptions, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { buildFontFamily } from '@/lib/monospace-font-family'
import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops'
import { canApplyPaneMetricOptions } from '@/lib/pane-manager/pane-fit'
@@ -50,13 +46,11 @@ export function isHexColor(value: string): boolean {
return HEX_COLOR_RE.test(value)
}
// Keep pure so settings preview can compose the same theme without PaneManager.
// Keep pure so settings preview can compose the same theme without PaneManager. baseTheme is the resolved palette
// (resolveEffectiveTerminalAppearance), which already carries the slot's color overrides.
export function composeActiveTerminalTheme(
baseTheme: ITheme | null,
settings: Pick<
GlobalSettings,
'terminalColorOverrides' | 'terminalBackgroundOpacity' | 'terminalCursorOpacity'
>
settings: Pick<GlobalSettings, 'terminalBackgroundOpacity' | 'terminalCursorOpacity'>
): ITheme | null {
if (!baseTheme) {
return null
@@ -70,9 +64,6 @@ export function composeActiveTerminalTheme(
scrollbarSliderActiveBackground: 'rgba(180, 180, 185, 0.8)',
...baseTheme
}
if (settings.terminalColorOverrides) {
theme = { ...theme, ...settings.terminalColorOverrides }
}
// Why: convert the hex background to rgba so xterm honors the opacity when allowTransparency is set.
if (settings.terminalBackgroundOpacity !== undefined && theme.background) {
theme = {
@@ -100,8 +91,7 @@ export function publishTerminalViewAttributesAtAppStart(
return false
}
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
const theme = composeActiveTerminalTheme(baseTheme, settings)
const theme = composeActiveTerminalTheme(appearance.theme, settings)
return send !== undefined
? publishTerminalViewAttributes(theme, appearance.mode, settings, send)
: publishTerminalViewAttributes(theme, appearance.mode, settings)
@@ -144,8 +134,7 @@ export function applyTerminalAppearance(
): void {
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const paneStyles = resolvePaneStyleOptions(settings)
const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
const theme = composeActiveTerminalTheme(baseTheme, settings)
const theme = composeActiveTerminalTheme(appearance.theme, settings)
publishTerminalViewAttributes(theme, appearance.mode, settings)
const paneBackground = theme?.background ?? '#000000'
@@ -59,8 +59,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll
const effectiveAppearance = settings
? resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
: null
const terminalBackground =
settings?.terminalColorOverrides?.background ?? effectiveAppearance?.theme?.background
const terminalBackground = effectiveAppearance?.theme?.background
const titleUsesLightSurface = isTerminalBackgroundLight(terminalBackground, {
appSurface: effectiveAppearance?.mode,
backgroundOpacity: settings?.terminalBackgroundOpacity
+5 -2
View File
@@ -9324,7 +9324,6 @@
},
"TerminalWindowSection": {
"1705318506": "ANSI-Farbe Magenta",
"03c855d15f": "Alle Farbanpassungen zurücksetzen",
"63f8d9336e": "Farbanpassungen",
"e86e09b5c7": "Einzelne Terminalfarben anpassen.",
"1d1920dc8a": "Den Mauszeiger beim Tippen im Terminal ausblenden.",
@@ -9391,7 +9390,11 @@
"cf37ff69f6": "Basis",
"8abdab9f7c": "Jetzt neu starten",
"907131d741": "Wird neu gestartet…",
"605a35d600": "Wird vom Terminal-Renderer noch nicht angewendet — xterm.js hat keine Farboption für fetten Text. Ein gespeicherter Wert bleibt erhalten."
"605a35d600": "Wird vom Terminal-Renderer noch nicht angewendet — xterm.js hat keine Farboption für fetten Text. Ein gespeicherter Wert bleibt erhalten.",
"color_overrides_dark_description": "Einzelne Farben des dunklen Terminal-Themes anpassen.",
"color_overrides_light_description": "Einzelne Farben des hellen Terminal-Themes anpassen.",
"reset_dark_color_overrides": "Farbanpassungen des dunklen Themes zurücksetzen",
"reset_light_color_overrides": "Farbanpassungen des hellen Themes zurücksetzen"
},
"UIZoomControl": {
"c2c64b24d0": "Zurücksetzen"
+5 -2
View File
@@ -9404,7 +9404,6 @@
},
"TerminalWindowSection": {
"1705318506": "ANSI magenta color",
"03c855d15f": "Reset all color overrides",
"63f8d9336e": "Color Overrides",
"e86e09b5c7": "Override individual terminal colors.",
"1d1920dc8a": "Hide the mouse cursor when typing in the terminal.",
@@ -9471,7 +9470,11 @@
"cf37ff69f6": "Base",
"8abdab9f7c": "Restart now",
"907131d741": "Restarting…",
"605a35d600": "Not applied by the terminal renderer yet — xterm.js has no bold color slot. A saved value is preserved."
"605a35d600": "Not applied by the terminal renderer yet — xterm.js has no bold color slot. A saved value is preserved.",
"color_overrides_dark_description": "Override individual colors of the dark terminal theme.",
"color_overrides_light_description": "Override individual colors of the light terminal theme.",
"reset_dark_color_overrides": "Reset dark color overrides",
"reset_light_color_overrides": "Reset light color overrides"
},
"UIZoomControl": {
"c2c64b24d0": "Reset"
+5 -2
View File
@@ -8930,7 +8930,6 @@
},
"TerminalWindowSection": {
"1705318506": "Color magenta ANSI",
"03c855d15f": "Restablecer todas las anulaciones de color",
"63f8d9336e": "Anulaciones de color",
"e86e09b5c7": "Anula colores individuales de la terminal.",
"1d1920dc8a": "Oculta el cursor del mouse al escribir en la terminal.",
@@ -8997,7 +8996,11 @@
"cf37ff69f6": "Base",
"8abdab9f7c": "Reiniciar ahora",
"907131d741": "Reiniciando…",
"605a35d600": "El renderizador de la terminal aún no aplica este valor: xterm.js no tiene un ajuste de color para el texto en negrita. Se conserva el valor guardado."
"605a35d600": "El renderizador de la terminal aún no aplica este valor: xterm.js no tiene un ajuste de color para el texto en negrita. Se conserva el valor guardado.",
"color_overrides_dark_description": "Anula colores individuales del tema oscuro de la terminal.",
"color_overrides_light_description": "Anula colores individuales del tema claro de la terminal.",
"reset_dark_color_overrides": "Restablecer las anulaciones de color del tema oscuro",
"reset_light_color_overrides": "Restablecer las anulaciones de color del tema claro"
},
"UIZoomControl": {
"c2c64b24d0": "Restablecer"
+5 -2
View File
@@ -9200,7 +9200,6 @@
},
"TerminalWindowSection": {
"1705318506": "Couleur ANSI magenta",
"03c855d15f": "Réinitialiser toutes les surcharges de couleurs",
"63f8d9336e": "Surcharges de couleurs",
"e86e09b5c7": "Remplacer individuellement les couleurs du terminal.",
"1d1920dc8a": "Masquer le curseur de la souris pendant la saisie dans le terminal.",
@@ -9267,7 +9266,11 @@
"cf37ff69f6": "Base",
"8abdab9f7c": "Redémarrer maintenant",
"907131d741": "Redémarrage…",
"605a35d600": "Non encore appliqué par le moteur de rendu du terminal — xterm.js n'a pas d'emplacement dédié aux couleurs en gras. Une valeur enregistrée est conservée."
"605a35d600": "Non encore appliqué par le moteur de rendu du terminal — xterm.js n'a pas d'emplacement dédié aux couleurs en gras. Une valeur enregistrée est conservée.",
"color_overrides_dark_description": "Remplacer individuellement les couleurs du thème de terminal sombre.",
"color_overrides_light_description": "Remplacer individuellement les couleurs du thème de terminal clair.",
"reset_dark_color_overrides": "Réinitialiser les surcharges de couleurs du thème sombre",
"reset_light_color_overrides": "Réinitialiser les surcharges de couleurs du thème clair"
},
"UIZoomControl": {
"c2c64b24d0": "Réinitialiser"
+5 -2
View File
@@ -8672,7 +8672,6 @@
},
"TerminalWindowSection": {
"1705318506": "ANSIマゼンタ色",
"03c855d15f": "すべての色のオーバーライドをリセット",
"63f8d9336e": "色のオーバーライド",
"e86e09b5c7": "個々のターミナルの色をオーバーライドします。",
"1d1920dc8a": "ターミナルに入力するときにマウスカーソルを非表示にします。",
@@ -8739,7 +8738,11 @@
"cf37ff69f6": "ベース",
"8abdab9f7c": "今すぐ再起動",
"907131d741": "再起動中…",
"605a35d600": "ターミナルのレンダラーではまだ適用されません。xterm.js に太字用の色スロットがないためです。保存した値は保持されます。"
"605a35d600": "ターミナルのレンダラーではまだ適用されません。xterm.js に太字用の色スロットがないためです。保存した値は保持されます。",
"color_overrides_dark_description": "ダークターミナルテーマの個々の色をオーバーライドします。",
"color_overrides_light_description": "ライトターミナルテーマの個々の色をオーバーライドします。",
"reset_dark_color_overrides": "ダークテーマの色のオーバーライドをリセット",
"reset_light_color_overrides": "ライトテーマの色のオーバーライドをリセット"
},
"UIZoomControl": {
"c2c64b24d0": "リセット"
+5 -2
View File
@@ -8635,7 +8635,6 @@
},
"TerminalWindowSection": {
"1705318506": "ANSI 마젠타색",
"03c855d15f": "모든 색상 재정의 재설정",
"63f8d9336e": "색상 재정의",
"e86e09b5c7": "개별 터미널 색상을 재정의합니다.",
"1d1920dc8a": "terminal에 입력할 때 마우스 커서를 숨깁니다.",
@@ -8702,7 +8701,11 @@
"cf37ff69f6": "기본",
"8abdab9f7c": "지금 재시작",
"907131d741": "다시 시작하는 중…",
"605a35d600": "아직 터미널 렌더러에는 적용되지 않습니다. xterm.js에 굵은 글씨 색상 슬롯이 없기 때문입니다. 저장된 값은 유지됩니다."
"605a35d600": "아직 터미널 렌더러에는 적용되지 않습니다. xterm.js에 굵은 글씨 색상 슬롯이 없기 때문입니다. 저장된 값은 유지됩니다.",
"color_overrides_dark_description": "다크 터미널 테마의 개별 색상을 재정의합니다.",
"color_overrides_light_description": "라이트 터미널 테마의 개별 색상을 재정의합니다.",
"reset_dark_color_overrides": "다크 테마 색상 재정의 재설정",
"reset_light_color_overrides": "라이트 테마 색상 재정의 재설정"
},
"UIZoomControl": {
"c2c64b24d0": "재설정"
+5 -2
View File
@@ -5177,7 +5177,6 @@
},
"TerminalWindowSection": {
"1705318506": "Cor magenta ANSI",
"03c855d15f": "Redefinir todas as substituições de cor",
"63f8d9336e": "Substituições de Cor",
"e86e09b5c7": "Substitui cores individuais do terminal.",
"1d1920dc8a": "Oculta o cursor do mouse ao digitar no terminal.",
@@ -5244,7 +5243,11 @@
"cf37ff69f6": "Base",
"8abdab9f7c": "Reiniciar agora",
"907131d741": "Reiniciando…",
"605a35d600": "Ainda não aplicado pelo renderizador do terminal — o xterm.js não tem um slot de cor para negrito. Um valor salvo é preservado."
"605a35d600": "Ainda não aplicado pelo renderizador do terminal — o xterm.js não tem um slot de cor para negrito. Um valor salvo é preservado.",
"color_overrides_dark_description": "Substitui cores individuais do tema escuro do terminal.",
"color_overrides_light_description": "Substitui cores individuais do tema claro do terminal.",
"reset_dark_color_overrides": "Redefinir as substituições de cor do tema escuro",
"reset_light_color_overrides": "Redefinir as substituições de cor do tema claro"
},
"agents": {
"search": {
+5 -2
View File
@@ -9682,7 +9682,6 @@
},
"TerminalWindowSection": {
"1705318506": "Пурпурный цвет ANSI",
"03c855d15f": "Сбросить все переопределения цветов",
"63f8d9336e": "Переопределения цветов",
"e86e09b5c7": "Измените отдельные цвета терминала.",
"1d1920dc8a": "Скрывать указатель мыши при вводе в терминале.",
@@ -9749,7 +9748,11 @@
"cf37ff69f6": "Основные",
"8abdab9f7c": "Перезапустить сейчас",
"907131d741": "Перезапуск…",
"605a35d600": "Пока не применяется при отрисовке терминала: в xterm.js нет отдельного цвета жирного текста. Сохранённое значение сохраняется."
"605a35d600": "Пока не применяется при отрисовке терминала: в xterm.js нет отдельного цвета жирного текста. Сохранённое значение сохраняется.",
"color_overrides_dark_description": "Измените отдельные цвета тёмной темы терминала.",
"color_overrides_light_description": "Измените отдельные цвета светлой темы терминала.",
"reset_dark_color_overrides": "Сбросить переопределения цветов тёмной темы",
"reset_light_color_overrides": "Сбросить переопределения цветов светлой темы"
},
"UIZoomControl": {
"c2c64b24d0": "Сбросить"
+5 -2
View File
@@ -9310,7 +9310,6 @@
},
"TerminalWindowSection": {
"1705318506": "ANSI eflatun rengi",
"03c855d15f": "Tüm renk geçersiz kılmalarını sıfırla",
"63f8d9336e": "Renk Geçersiz Kılmaları",
"e86e09b5c7": "Tek tek terminal renklerini geçersiz kılın.",
"1d1920dc8a": "Terminalde yazarken fare imlecini gizleyin.",
@@ -9377,7 +9376,11 @@
"cf37ff69f6": "Temel",
"8abdab9f7c": "Şimdi yeniden başlat",
"907131d741": "Yeniden başlatılıyor…",
"605a35d600": "Terminal render motoru tarafından henüz uygulanmıyor; xterm.js'de kalın renk yuvası yok. Kaydedilen değer korunur."
"605a35d600": "Terminal render motoru tarafından henüz uygulanmıyor; xterm.js'de kalın renk yuvası yok. Kaydedilen değer korunur.",
"color_overrides_dark_description": "Koyu terminal temasının renklerini tek tek geçersiz kılın.",
"color_overrides_light_description": "Açık terminal temasının renklerini tek tek geçersiz kılın.",
"reset_dark_color_overrides": "Koyu tema renk geçersiz kılmalarını sıfırla",
"reset_light_color_overrides": "Açık tema renk geçersiz kılmalarını sıfırla"
},
"UIZoomControl": {
"c2c64b24d0": "Sıfırla"
+5 -2
View File
@@ -9147,7 +9147,6 @@
},
"TerminalWindowSection": {
"1705318506": "ANSI 洋紅色",
"03c855d15f": "重設所有顏色覆寫設定",
"63f8d9336e": "顏色覆寫設定",
"e86e09b5c7": "覆寫個別終端機顏色。",
"1d1920dc8a": "在終端機中輸入時隱藏滑鼠游標。",
@@ -9214,7 +9213,11 @@
"cf37ff69f6": "基本",
"8abdab9f7c": "立即重新啟動",
"907131d741": "正在重新啟動...",
"605a35d600": "終端機算繪器尚未套用此設定,因為 xterm.js 沒有粗體顏色欄位。已儲存的值會保留。"
"605a35d600": "終端機算繪器尚未套用此設定,因為 xterm.js 沒有粗體顏色欄位。已儲存的值會保留。",
"color_overrides_dark_description": "覆寫深色終端機主題的個別顏色。",
"color_overrides_light_description": "覆寫淺色終端機主題的個別顏色。",
"reset_dark_color_overrides": "重設深色主題顏色覆寫設定",
"reset_light_color_overrides": "重設淺色主題顏色覆寫設定"
},
"UIZoomControl": {
"c2c64b24d0": "重設"
+5 -2
View File
@@ -8635,7 +8635,6 @@
},
"TerminalWindowSection": {
"1705318506": "ANSI 洋红色",
"03c855d15f": "重置所有颜色覆盖",
"63f8d9336e": "颜色覆盖",
"e86e09b5c7": "覆盖各个终端颜色。",
"1d1920dc8a": "在终端中输入时隐藏鼠标光标。",
@@ -8702,7 +8701,11 @@
"cf37ff69f6": "根据",
"8abdab9f7c": "立即重启",
"907131d741": "正在重新启动...",
"605a35d600": "终端渲染器尚未应用此项 — xterm.js 没有粗体颜色槽位。已保存的值会被保留。"
"605a35d600": "终端渲染器尚未应用此项 — xterm.js 没有粗体颜色槽位。已保存的值会被保留。",
"color_overrides_dark_description": "覆盖深色终端主题的各个颜色。",
"color_overrides_light_description": "覆盖浅色终端主题的各个颜色。",
"reset_dark_color_overrides": "重置深色主题颜色覆盖",
"reset_light_color_overrides": "重置浅色主题颜色覆盖"
},
"UIZoomControl": {
"c2c64b24d0": "重置"
@@ -19,6 +19,7 @@ type LeftSidebarAppearanceSettings = Pick<
| 'terminalCustomThemes'
| 'terminalDividerColorLight'
| 'terminalColorOverrides'
| 'terminalColorOverridesLight'
| 'terminalBackgroundOpacity'
>
@@ -93,11 +94,10 @@ function resolveTerminalSurfaceVariables(
): LeftSidebarStyleVariables {
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const background = applyAlpha(
settings.terminalColorOverrides?.background ?? appearance.theme?.background ?? '#000000',
appearance.theme?.background ?? '#000000',
settings.terminalBackgroundOpacity
)
const foreground =
settings.terminalColorOverrides?.foreground ?? appearance.theme?.foreground ?? '#fafafa'
const foreground = appearance.theme?.foreground ?? '#fafafa'
return buildSurfaceVariables({ background, foreground, overrideTextTokens: true })
}
+10 -24
View File
@@ -12,8 +12,9 @@ import {
DEFAULT_TERMINAL_THEME_DARK,
DEFAULT_TERMINAL_THEME_LIGHT,
lookupTerminalTheme,
type TerminalThemeLookupSettings,
selectTerminalTheme
resolveTerminalPalette,
type TerminalPaletteSettings,
type TerminalThemeLookupSettings
} from '../../../shared/terminal-theme-selection'
export const BUILTIN_TERMINAL_THEME_NAMES = getThemeNames()
@@ -58,20 +59,6 @@ export function getTerminalTheme(
return lookupTerminalTheme(settings, selection)
}
function getTerminalThemePreview(
name: string,
settings?: TerminalThemeLookupSettings,
fallbackMode: 'dark' | 'light' = 'dark'
): ITheme | null {
const theme = getTerminalTheme(settings, name)
if (theme) {
return theme
}
return getTheme(
fallbackMode === 'light' ? DEFAULT_TERMINAL_THEME_LIGHT : DEFAULT_TERMINAL_THEME_DARK
)
}
export function getAvailableTerminalThemeOptions(
settings: Pick<GlobalSettings, 'terminalCustomThemes' | 'terminalPluginPalettes'>,
pluginThemes: readonly PluginTerminalThemeRegistration[] = []
@@ -116,24 +103,23 @@ export function resolveEffectiveTerminalAppearance(
| 'terminalPluginPalettes'
| 'terminalDividerColorLight'
> &
TerminalThemeLookupSettings,
TerminalPaletteSettings,
systemPrefersDark = getSystemPrefersDark()
): EffectiveTerminalAppearance {
const {
mode: sourceTheme,
useLightVariant,
themeName
} = selectTerminalTheme(settings, systemPrefersDark)
const { mode, useLightVariant, themeName, theme } = resolveTerminalPalette(
settings,
systemPrefersDark
)
const dividerColor = useLightVariant
? normalizeColor(settings.terminalDividerColorLight, DEFAULT_TERMINAL_DIVIDER_LIGHT)
: normalizeColor(settings.terminalDividerColorDark, DEFAULT_TERMINAL_DIVIDER_DARK)
return {
mode: sourceTheme,
mode,
sourceTheme: settings.theme,
themeName,
dividerColor,
theme: getTerminalThemePreview(themeName, settings, useLightVariant ? 'light' : 'dark'),
theme,
systemPrefersDark
}
}
@@ -536,7 +536,7 @@ describe('buildMobileSessionTabSnapshots', () => {
...getDefaultSettings('/tmp'),
theme: 'light',
terminalUseSeparateLightTheme: true,
terminalColorOverrides: {
terminalColorOverridesLight: {
background: '#f8f8f8',
foreground: '#101010',
cursor: '#202020'
@@ -30,9 +30,8 @@ export function resolveMobileTerminalTheme(
return undefined
}
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const resolvedTheme = appearance.theme
? { ...appearance.theme, ...settings.terminalColorOverrides }
: undefined
// Copy: the opacity writes below must not mutate the shared theme catalog entry.
const resolvedTheme = appearance.theme ? { ...appearance.theme } : undefined
if (!resolvedTheme) {
return undefined
}
@@ -23,6 +23,7 @@ import type { PairedUiState, PairingLocalUiField } from '../../../../shared/pair
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
import { normalizeStatusBarUsageMode } from '../../../../shared/status-bar-usage-mode'
import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes'
import { splitLegacyTerminalColorOverrides } from '../../../../shared/terminal-color-overrides'
import {
DEFAULT_TUI_AGENT_ARGS,
DEFAULT_TUI_AGENT_ENV,
@@ -165,6 +166,7 @@ export function migrateStoredWebSettings(
...normalizeTerminalCursorStyleDefault(stored),
...normalizeOsc52ClipboardDefaultOn(stored),
terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes),
...splitLegacyTerminalColorOverrides(stored),
...migratePortugueseUiLanguageOnLoad(stored)
}
}
@@ -48,6 +48,7 @@ export function getStoredSettings(): GlobalSettings {
stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !==
migratedStored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers ||
stored.terminalCustomThemes !== migratedStored.terminalCustomThemes ||
stored.terminalColorOverridesSplit !== migratedStored.terminalColorOverridesSplit ||
stored.uiLanguage !== migratedStored.uiLanguage ||
needsChatUiChoiceCarry(stored) ||
stored.uiLanguagePortugueseMigrationApplied !== true)
+1
View File
@@ -85,6 +85,7 @@ export function buildDefaultSettings(args: {
terminalThemeDark: 'Ghostty Default Style Dark',
terminalDividerColorDark: '#3f3f46',
terminalUseSeparateLightTheme: true,
terminalColorOverridesSplit: true,
terminalThemeLight: 'Builtin Tango Light',
terminalCustomThemes: [],
terminalDividerColorLight: '#d4d4d8',
+3 -1
View File
@@ -10,6 +10,7 @@ import type { PluginUpdatePolicySettings } from './plugins/catalog/plugin-update
import type { PluginSystemSettings } from './plugins/plugin-system-settings'
import type { WorktreeDeletionPreferences } from './worktree-deletion-preferences'
import type { AgentPermissionGlobalSettings } from './tui-agent-permission-choice'
import type { TerminalColorOverrideGlobalSettings } from './terminal-color-overrides'
/** Settings groups owned by their feature modules and composed into GlobalSettings. */
export type GlobalSettingsBase = ClaudeAgentViewSettings &
@@ -23,4 +24,5 @@ export type GlobalSettingsBase = ClaudeAgentViewSettings &
PluginAppearanceSettings &
PluginFileTypeSuggestionSettings &
PluginUpdatePolicySettings &
PluginSystemSettings
PluginSystemSettings &
TerminalColorOverrideGlobalSettings
-2
View File
@@ -29,7 +29,6 @@ import type {
} from './managed-account-types'
import type { NotificationSettings } from './notification-settings-types'
import type { CtrlTabOrderMode } from './tab-types'
import type { TerminalColorOverrides } from './terminal-color-overrides'
import type { TerminalQuickCommand } from './terminal-quick-command-types'
import type { TuiAgent } from './tui-agent'
import type { ZcodePlanSite } from './zcode-plan-sites'
@@ -136,7 +135,6 @@ export type GlobalSettings = GlobalSettingsBase & {
* background-luminance-gated floor (3 dark / 4.5 light); 1 disables contrast correction so TUIs
* that rely on deliberately low contrast (Powerline seams, dimmed secondary text) render as sent. */
terminalMinimumContrastRatio?: number
terminalColorOverrides?: TerminalColorOverrides
terminalPaddingX?: number
terminalPaddingY?: number
terminalMouseHideWhileTyping?: boolean
+74
View File
@@ -1,3 +1,6 @@
import type { ITheme } from '@xterm/xterm'
import type { GlobalSettings } from './global-settings-types'
export type TerminalColorOverrides = {
foreground?: string
background?: string
@@ -30,3 +33,74 @@ export type TerminalPluginPalette = {
id: `plugin:${string}`
colors: TerminalColorOverrides
}
/** Terminal theme slot (the Dark/Light picker) an override bag tweaks; light mode paints the dark slot unless it has its own theme. */
export type TerminalThemeSlot = 'dark' | 'light'
export type TerminalColorOverrideGlobalSettings = {
/** Dark slot's overrides; light mode uses them too unless terminalUseSeparateLightTheme. */
terminalColorOverrides?: TerminalColorOverrides
/** Light slot's overrides (#10561); older clients ignore them. */
terminalColorOverridesLight?: TerminalColorOverrides
/** One-shot migration guard for splitting the legacy shared bag per slot. */
terminalColorOverridesSplit?: boolean
}
// Why the dark slot keeps the original key: older clients keep applying it in both modes, the pre-split behavior.
export const TERMINAL_COLOR_OVERRIDES_SETTING = {
dark: 'terminalColorOverrides',
light: 'terminalColorOverridesLight'
} as const satisfies Record<TerminalThemeSlot, keyof TerminalColorOverrideGlobalSettings>
export type TerminalColorOverrideSettings = Pick<
TerminalColorOverrideGlobalSettings,
'terminalColorOverrides' | 'terminalColorOverridesLight'
>
export function terminalColorOverridesForSlot(
settings: TerminalColorOverrideSettings,
slot: TerminalThemeSlot
): TerminalColorOverrides | undefined {
return settings[TERMINAL_COLOR_OVERRIDES_SETTING[slot]]
}
export function hasTerminalColorOverrides(
settings: TerminalColorOverrideSettings,
slot: TerminalThemeSlot
): boolean {
return Object.values(terminalColorOverridesForSlot(settings, slot) ?? {}).some(Boolean)
}
/** Cleared fields stay undefined in memory until reload; they must not blank the theme's color. */
export function withTerminalColorOverrides(
theme: ITheme,
overrides: TerminalColorOverrides | undefined
): ITheme {
const painted = Object.entries(overrides ?? {}).filter(
([, value]) => typeof value === 'string' && value.length > 0
)
return painted.length === 0 ? theme : { ...theme, ...Object.fromEntries(painted) }
}
type LegacyTerminalColorOverrideSettings = TerminalColorOverrideGlobalSettings &
Partial<Pick<GlobalSettings, 'theme' | 'terminalUseSeparateLightTheme'>>
/** One-shot split of the pre-#10561 shared bag. It stays on the dark slot; when the light slot could have shown it
* (own light theme, app not pinned dark) it is copied there too, so nothing changes on upgrade and older clients,
* which only read the dark key, keep their behavior. A missing separate-light flag means its default, on. */
export function splitLegacyTerminalColorOverrides(
settings: LegacyTerminalColorOverrideSettings | undefined
): TerminalColorOverrideGlobalSettings {
const shownInLight =
settings?.terminalColorOverridesSplit !== true &&
settings?.theme !== 'dark' &&
settings?.terminalUseSeparateLightTheme !== false &&
settings?.terminalColorOverridesLight === undefined
return {
terminalColorOverrides: settings?.terminalColorOverrides,
terminalColorOverridesLight: shownInLight
? settings?.terminalColorOverrides
: settings?.terminalColorOverridesLight,
terminalColorOverridesSplit: true
}
}
+49 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { resolveConfiguredTerminalColors } from './terminal-theme-selection'
import {
resolveConfiguredTerminalColors,
resolveTerminalPalette,
withImportedTerminalColorOverrides
} from './terminal-theme-selection'
import { TERMINAL_THEME_CATALOG } from './terminal-themes'
const base = {
@@ -45,6 +49,50 @@ describe('the terminal colours a host configures', () => {
})
})
describe('terminal colour overrides per theme slot (#10561)', () => {
const settings = {
...base,
terminalColorOverrides: { background: '#101010' },
terminalColorOverridesLight: { foreground: '#202020' }
}
it('paints each mode with only its own slot overrides', () => {
expect(resolveTerminalPalette(settings, true).theme).toMatchObject({
foreground: '#ffffff',
background: '#101010'
})
expect(resolveTerminalPalette(settings, false).theme).toMatchObject({
foreground: '#202020',
background: '#ffffff'
})
})
it('lets light mode that matches dark share the dark slot overrides', () => {
expect(
resolveTerminalPalette({ ...settings, terminalUseSeparateLightTheme: false }, false).theme
).toMatchObject({ foreground: '#ffffff', background: '#101010' })
})
it('merges an imported palette into the slot on screen once the import applies', () => {
const imported = { terminalColorOverrides: { red: '#ff0000' } }
expect(withImportedTerminalColorOverrides(settings, imported, false)).toEqual({
terminalColorOverridesLight: { foreground: '#202020', red: '#ff0000' }
})
expect(
withImportedTerminalColorOverrides(settings, { ...imported, theme: 'dark' }, false)
).toEqual({ theme: 'dark', terminalColorOverrides: { background: '#101010', red: '#ff0000' } })
})
it('keeps the theme colour for a field cleared in memory', () => {
expect(
resolveConfiguredTerminalColors(
{ ...base, terminalColorOverrides: { background: undefined } },
true
)
).toEqual({ foreground: '#ffffff', background: '#282c34' })
})
})
describe('plugin terminal palettes', () => {
const id = 'plugin:samples.dusk/dusk' as const
const colors = { background: '#1b1430', foreground: '#e8e0ff', red: '#ff6b8b' }
+47 -15
View File
@@ -1,5 +1,12 @@
import type { ITheme } from '@xterm/xterm'
import type { GlobalSettings } from './global-settings-types'
import { isPluginContentSwitchedOff } from './plugins/plugin-system-settings'
import {
TERMINAL_COLOR_OVERRIDES_SETTING,
terminalColorOverridesForSlot,
withTerminalColorOverrides,
type TerminalColorOverrideSettings
} from './terminal-color-overrides'
import {
hasUsableTerminalThemeColors,
normalizeTerminalColorOverrides,
@@ -86,26 +93,51 @@ function pluginPalette(palettes: unknown, selection: string): TerminalThemeMap[s
return hasUsableTerminalThemeColors(colors) ? colors : null
}
function themeColors(
selection: string,
settings: TerminalThemeLookupSettings
): TerminalOscColorQueryReplyColors | null {
const theme = lookupTerminalTheme(settings, selection)
return theme ? { foreground: theme.foreground, background: theme.background } : null
export type TerminalPaletteSettings = TerminalThemeSelectionSettings &
TerminalThemeLookupSettings &
TerminalColorOverrideSettings
export type TerminalPalette = TerminalThemeSelection & { theme: ITheme | null }
/** The one place a mode's terminal palette is composed: its slot's theme (or that slot's default) plus the slot's overrides. */
export function resolveTerminalPalette(
settings: TerminalPaletteSettings,
systemPrefersDark: boolean
): TerminalPalette {
const selection = selectTerminalTheme(settings, systemPrefersDark)
const slot = selection.useLightVariant ? 'light' : 'dark'
const base =
lookupTerminalTheme(settings, selection.themeName) ??
lookupTerminalTheme(
settings,
selection.useLightVariant ? DEFAULT_TERMINAL_THEME_LIGHT : DEFAULT_TERMINAL_THEME_DARK
)
return {
...selection,
theme: base && withTerminalColorOverrides(base, terminalColorOverridesForSlot(settings, slot))
}
}
/** An imported palette (Ghostty) merges into the slot painting the terminal once the import applies, so it shows. */
export function withImportedTerminalColorOverrides(
settings: TerminalThemeSelectionSettings & TerminalColorOverrideSettings,
diff: Partial<GlobalSettings>,
systemPrefersDark: boolean
): Partial<GlobalSettings> {
const { terminalColorOverrides: imported, ...rest } = diff
if (!imported) {
return diff
}
const { useLightVariant } = selectTerminalTheme({ ...settings, ...rest }, systemPrefersDark)
const setting = TERMINAL_COLOR_OVERRIDES_SETTING[useLightVariant ? 'light' : 'dark']
return { ...rest, [setting]: { ...settings[setting], ...imported } }
}
/** Answer OSC 10/11 from saved settings without a renderer; queries report opaque theme colors, ignoring opacity. */
export function resolveConfiguredTerminalColors(
settings: TerminalThemeSelectionSettings &
TerminalThemeLookupSettings &
Pick<GlobalSettings, 'terminalColorOverrides'>,
settings: TerminalPaletteSettings,
systemPrefersDark: boolean
): TerminalOscColorQueryReplyColors {
const { themeName, useLightVariant } = selectTerminalTheme(settings, systemPrefersDark)
const fallback = useLightVariant ? DEFAULT_TERMINAL_THEME_LIGHT : DEFAULT_TERMINAL_THEME_DARK
const base: TerminalOscColorQueryReplyColors =
themeColors(themeName, settings) ?? themeColors(fallback, settings) ?? {}
const foreground = settings.terminalColorOverrides?.foreground ?? base.foreground
const background = settings.terminalColorOverrides?.background ?? base.background
const { foreground, background } = resolveTerminalPalette(settings, systemPrefersDark).theme ?? {}
return { ...(foreground ? { foreground } : {}), ...(background ? { background } : {}) }
}