From e2657b916cafa79d382658a78a01abca3131173c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:52:03 -0700 Subject: [PATCH 01/16] Add chat contrast and terminal interface matching --- src/renderer/src/assets/main.css | 122 +++++++++++++---- .../native-chat/NativeChatResolvedView.tsx | 4 +- .../NativeChatStructuredSession.test.tsx | 26 +++- .../NativeChatStructuredSession.tsx | 4 +- .../native-chat-appearance-style.test.ts | 125 +++++++++++++++++- .../native-chat-appearance-style.ts | 82 ++++++++++-- .../native-chat/native-chat-appearance.css | 12 ++ .../AppearanceChatContrastControls.test.tsx | 43 ++++++ .../AppearanceChatContrastControls.tsx | 70 ++++++++++ .../settings/AppearanceChatSection.test.tsx | 3 + .../settings/AppearanceChatSection.tsx | 6 + .../settings/chat-appearance-search.ts | 23 +++- src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 10 +- src/renderer/src/i18n/locales/fr.json | 10 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 10 +- .../native-chat-appearance-settings.test.ts | 51 ++++++- src/shared/native-chat-appearance-settings.ts | 18 ++- 20 files changed, 592 insertions(+), 57 deletions(-) create mode 100644 src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx create mode 100644 src/renderer/src/components/settings/AppearanceChatContrastControls.tsx diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index aa4c597f108b..b77ab8136ba5 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -43,6 +43,9 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-chat-source-background: var(--chat-source-background); + --color-chat-source-foreground: var(--chat-source-foreground); + --color-chat-source-muted-foreground: var(--chat-source-muted-foreground); --color-chat-canvas: var(--chat-canvas); --color-chat-foreground: var(--chat-foreground); --color-chat-foreground-strong: var(--chat-foreground-strong); @@ -173,19 +176,20 @@ --background: #fff; --editor-surface: #ffffff; --foreground: #0a0a0a; - --chat-canvas: var(--background); - --chat-foreground: color-mix(in srgb, var(--foreground) 82%, var(--chat-canvas)); - --chat-foreground-strong: color-mix(in srgb, var(--foreground) 92%, var(--chat-canvas)); - --chat-foreground-faint: var(--muted-foreground); - --chat-user-surface: color-mix(in srgb, var(--foreground) 4%, var(--chat-canvas)); - --chat-user-border: color-mix(in srgb, var(--foreground) 7%, transparent); - --chat-code-foreground: color-mix(in srgb, var(--foreground) 85%, var(--chat-canvas)); - --chat-code-surface: color-mix(in srgb, var(--foreground) 2%, var(--chat-canvas)); - --chat-code-border: color-mix(in srgb, var(--foreground) 7%, transparent); - --chat-inline-code-surface: color-mix(in srgb, var(--foreground) 4%, var(--chat-canvas)); - --chat-inline-code-border: color-mix(in srgb, var(--foreground) 9%, transparent); - --chat-composer-surface: var(--chat-canvas); - --chat-composer-border: color-mix(in srgb, var(--foreground) 11%, transparent); + --chat-canvas-mix: 0%; + --chat-foreground-mix: 82%; + --chat-strong-mix: 92%; + --chat-faint-mix: 100%; + --chat-user-mix: 4%; + --chat-user-border-mix: 7%; + --chat-code-mix: 2%; + --chat-code-base: var(--chat-canvas); + --chat-inline-code-mix: 4%; + --chat-inline-code-base: var(--chat-canvas); + --chat-inline-code-border-mix: 9%; + --chat-composer-mix: 0%; + --chat-composer-base: var(--chat-canvas); + --chat-composer-border-mix: 11%; --chat-content-max-width: 46rem; --card: #fff; --card-foreground: #0a0a0a; @@ -310,19 +314,20 @@ --background: #0a0a0a; --editor-surface: #1e1e1e; --foreground: #fafafa; - --chat-canvas: color-mix(in srgb, var(--foreground) 5%, var(--background)); - --chat-foreground: color-mix(in srgb, var(--foreground) 78%, var(--chat-canvas)); - --chat-foreground-strong: color-mix(in srgb, var(--foreground) 90%, var(--chat-canvas)); - --chat-foreground-faint: color-mix(in srgb, var(--muted-foreground) 83%, var(--chat-canvas)); - --chat-user-surface: color-mix(in srgb, var(--foreground) 7%, var(--chat-canvas)); - --chat-user-border: color-mix(in srgb, var(--foreground) 6%, transparent); - --chat-code-foreground: color-mix(in srgb, var(--foreground) 85%, var(--chat-canvas)); - --chat-code-surface: color-mix(in srgb, var(--foreground) 3.5%, transparent); - --chat-code-border: color-mix(in srgb, var(--foreground) 7%, transparent); - --chat-inline-code-surface: color-mix(in srgb, var(--foreground) 6%, transparent); - --chat-inline-code-border: color-mix(in srgb, var(--foreground) 8%, transparent); - --chat-composer-surface: color-mix(in srgb, var(--foreground) 4%, transparent); - --chat-composer-border: color-mix(in srgb, var(--foreground) 9%, transparent); + --chat-canvas-mix: 5%; + --chat-foreground-mix: 78%; + --chat-strong-mix: 90%; + --chat-faint-mix: 83%; + --chat-user-mix: 7%; + --chat-user-border-mix: 6%; + --chat-code-mix: 3.5%; + --chat-code-base: transparent; + --chat-inline-code-mix: 6%; + --chat-inline-code-base: transparent; + --chat-inline-code-border-mix: 8%; + --chat-composer-mix: 4%; + --chat-composer-base: transparent; + --chat-composer-border-mix: 9%; --chat-content-max-width: 46rem; --card: #171717; --card-foreground: #fafafa; @@ -529,6 +534,71 @@ body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) { /* ── Base Layer ──────────────────────────────────────── */ +/* Re-evaluate chat colors where the appearance producer supplies their source colors. */ +:root, +.dark, +.native-chat-appearance { + --chat-source-background: var(--background); + --chat-source-foreground: var(--foreground); + --chat-source-muted-foreground: var(--muted-foreground); + --chat-canvas: color-mix( + in srgb, + var(--chat-source-foreground) var(--chat-canvas-mix), + var(--chat-source-background) + ); + --chat-foreground: color-mix( + in srgb, + var(--chat-source-foreground) var(--chat-foreground-mix), + var(--chat-canvas) + ); + --chat-foreground-strong: color-mix( + in srgb, + var(--chat-source-foreground) var(--chat-strong-mix), + var(--chat-canvas) + ); + --chat-foreground-faint: color-mix( + in srgb, + var(--chat-source-muted-foreground) var(--chat-faint-mix), + var(--chat-canvas) + ); + --chat-user-surface: color-mix( + in srgb, + var(--chat-source-foreground) var(--chat-user-mix), + var(--chat-canvas) + ); + --chat-user-border: color-mix( + in srgb, + var(--chat-source-foreground) var(--chat-user-border-mix), + transparent + ); + --chat-code-surface: color-mix( + in srgb, + var(--chat-source-foreground) var(--chat-code-mix), + var(--chat-code-base) + ); + --chat-code-border: color-mix(in srgb, var(--chat-source-foreground) 7%, transparent); + --chat-inline-code-surface: color-mix( + in srgb, + var(--chat-source-foreground) var(--chat-inline-code-mix), + var(--chat-inline-code-base) + ); + --chat-inline-code-border: color-mix( + in srgb, + var(--chat-source-foreground) var(--chat-inline-code-border-mix), + transparent + ); + --chat-composer-surface: color-mix( + in srgb, + var(--chat-source-foreground) var(--chat-composer-mix), + var(--chat-composer-base) + ); + --chat-composer-border: color-mix( + in srgb, + var(--chat-source-foreground) var(--chat-composer-border-mix), + transparent + ); +} + @layer base { *, *::before, diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 9d48e563a80a..a3094ddf36ee 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -315,8 +315,8 @@ export function NativeChatResolvedView({ // Only the focused conversation accepts chat text-size shortcuts. useNativeChatFontSize(isConversation && isVisible && isFocusedGroup, rootRef) - const appearanceSettings = useAppStore((state) => state.settings?.nativeChatAppearance) - const appearanceStyle = useNativeChatAppearanceStyle({ nativeChatAppearance: appearanceSettings }) + const appearanceSettings = useAppStore((state) => state.settings) + const appearanceStyle = useNativeChatAppearanceStyle(appearanceSettings) return (
{ useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), - nativeChatAppearance: { fontSize: 18, codeFontSize: 11, width: 'wide' } + theme: 'dark', + nativeChatAppearance: { + fontSize: 18, + codeFontSize: 11, + width: 'wide', + matchTerminalInterface: true, + contrast: 120 + }, + terminalFontFamily: 'Consolas', + terminalColorOverrides: { background: '#112233', foreground: '#ddeeff' } } }) const { container } = render( @@ -51,14 +60,27 @@ describe('NativeChatStructuredSession', () => { /> ) const root = container.querySelector('[data-native-chat-root]') + expect(root?.style.getPropertyValue('--chat-source-background')).toBe('#112233') + expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('#ddeeff') + expect(root?.style.getPropertyValue('--chat-code-font-family')).toContain('Consolas') + expect(root?.style.getPropertyValue('--chat-font-family')).toContain('Consolas') + expect(root?.style.getPropertyValue('--chat-foreground-mix')).toBe('86.8%') expect(root?.style.getPropertyValue('--chat-font-size')).toBe('18px') expect(root?.style.getPropertyValue('--chat-code-font-size')).toBe('11px') expect(root?.style.getPropertyValue('--chat-content-max-width')).toBe('60rem') act(() => useAppStore.setState({ - settings: { ...getDefaultSettings('/tmp'), nativeChatAppearance: { width: 'full' } } + settings: { + ...getDefaultSettings('/tmp'), + theme: 'dark', + nativeChatAppearance: { width: 'full' } + } }) ) + expect(root?.style.getPropertyValue('--chat-source-background')).toBe('') + expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('') + expect(root?.style.getPropertyValue('--chat-font-family')).toBe('') + expect(root?.style.getPropertyValue('--chat-foreground-mix')).toBe('78%') expect(root?.style.getPropertyValue('--chat-font-size')).toBe('14px') expect(root?.style.getPropertyValue('--chat-content-max-width')).toBe('none') act(() => useAppStore.setState({ settings: original })) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 8f43b043c81a..400108943787 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -147,8 +147,8 @@ export function NativeChatStructuredSession( viewState.kind === 'ready' && props.isVisible && props.isFocusedGroup, rootRef ) - const appearanceSettings = useAppStore((state) => state.settings?.nativeChatAppearance) - const appearanceStyle = useNativeChatAppearanceStyle({ nativeChatAppearance: appearanceSettings }) + const appearanceSettings = useAppStore((state) => state.settings) + const appearanceStyle = useNativeChatAppearanceStyle(appearanceSettings) const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( fileLinkContext, diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts index 051a62b087cf..c2cc8dfbe05f 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts @@ -1,5 +1,128 @@ import { describe, expect, it } from 'vitest' -import { nativeChatAppearanceStyle } from './native-chat-appearance-style' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { buildFontFamily } from '@/lib/monospace-font-family' +import { resolveConfiguredTerminalColors } from '../../../../shared/terminal-theme-selection' +import { nativeChatAppearanceStyle, nativeChatContrastMix } from './native-chat-appearance-style' + +const makeSettings = createGlobalSettingsFixture + +describe('nativeChatContrastMix', () => { + it.each([ + [50, false, 56], + [100, false, 78], + [150, false, 100], + [50, true, 64], + [100, true, 82], + [150, true, 100], + [-100, false, 56], + [200, true, 100], + [Number.NaN, false, 78] + ])('maps %s in light mode %s to %s', (contrast, light, expected) => { + expect(nativeChatContrastMix(contrast, light)).toBe(expected) + }) +}) + +describe('nativeChatAppearanceStyle terminal interface', () => { + it('follows the terminal code font while keeping default body font and colors', () => { + const style = nativeChatAppearanceStyle(makeSettings({ terminalFontFamily: 'Menlo' })) + expect(style['--chat-code-font-family']).toBe(buildFontFamily('Menlo')) + expect(style['--chat-font-family']).toBeUndefined() + expect(style['--chat-source-foreground']).toBeUndefined() + expect(style['--chat-source-background']).toBeUndefined() + expect(style['--chat-foreground-mix']).toBe('78%') + }) + + it('updates the code font and matching body font from the current terminal setting', () => { + for (const terminalFontFamily of ['', 'Menlo', 'Consolas']) { + const settings = makeSettings({ + terminalFontFamily, + nativeChatAppearance: { matchTerminalInterface: true } + }) + const style = nativeChatAppearanceStyle(settings) + expect(style['--chat-font-family']).toBe(buildFontFamily(terminalFontFamily)) + expect(style['--chat-code-font-family']).toBe(buildFontFamily(terminalFontFamily)) + expect(style['--chat-code-font-size']).toBe('12px') + } + }) + + it('uses the selected theme and live overrides as the two palette sources', () => { + const settings = makeSettings({ + nativeChatAppearance: { matchTerminalInterface: true, contrast: 150 }, + terminalColorOverrides: { background: '#122033', foreground: '#ddeeff' } + }) + const style = nativeChatAppearanceStyle(settings) + expect(style['--chat-source-background']).toBe('#122033') + expect(style['--chat-source-foreground']).toBe('#ddeeff') + expect(style['--chat-canvas-mix']).toBe('0%') + expect(style['--chat-foreground-mix']).toBe('100%') + expect(style['--chat-code-base']).toBe('transparent') + }) + + it('resolves custom themes and falls back when the selected theme was removed', () => { + const settings = makeSettings({ + terminalThemeDark: 'custom:manual:sample', + terminalCustomThemes: [ + { + id: 'manual:sample', + name: 'Sample', + source: 'manual', + mode: 'dark', + terminal: { background: '#122033', foreground: '#ddeeff', black: '#000001' }, + importedAt: '2026-10-05T00:00:00.000Z' + } + ], + nativeChatAppearance: { matchTerminalInterface: true } + }) + expect(nativeChatAppearanceStyle(settings)['--chat-source-background']).toBe('#122033') + const missing = { ...settings, terminalCustomThemes: [] } + expect(nativeChatAppearanceStyle(missing)['--chat-source-background']).toBe( + resolveConfiguredTerminalColors(missing, true).background + ) + }) + + it('uses light formulas for a light terminal inside a dark app', () => { + const settings = makeSettings({ + theme: 'dark', + terminalThemeDark: 'Builtin Tango Light', + nativeChatAppearance: { matchTerminalInterface: true } + }) + const style = nativeChatAppearanceStyle(settings) + const colors = resolveConfiguredTerminalColors(settings, true) + expect(style['--chat-source-background']).toBe(colors.background) + expect(style['--chat-source-foreground']).toBe(colors.foreground) + expect(style['--chat-foreground-mix']).toBe('82%') + expect(style['--chat-code-base']).toBe('var(--chat-canvas)') + expect(style['--chat-strong-mix']).toBe('92%') + }) + + it('follows the separate light theme and system theme changes', () => { + const settings = makeSettings({ + theme: 'system', + terminalUseSeparateLightTheme: true, + terminalThemeLight: 'Builtin Tango Light', + nativeChatAppearance: { matchTerminalInterface: true } + }) + const dark = nativeChatAppearanceStyle(settings, undefined, true) + const light = nativeChatAppearanceStyle(settings, undefined, false) + expect(light['--chat-source-background']).toBe( + resolveConfiguredTerminalColors(settings, false).background + ) + expect(light['--chat-source-background']).not.toBe(dark['--chat-source-background']) + expect(light['--chat-foreground-mix']).toBe('82%') + }) + + it('uses app light contrast without matching and removes matching overrides when off', () => { + const style = nativeChatAppearanceStyle( + makeSettings({ + theme: 'light', + nativeChatAppearance: { matchTerminalInterface: false, contrast: 50 } + }) + ) + expect(style['--chat-foreground-mix']).toBe('64%') + expect(style['--chat-font-family']).toBeUndefined() + expect(style['--chat-canvas-mix']).toBeUndefined() + }) +}) describe('chat root appearance style', () => { it('keeps shared typography tokens independent of chat size and provides a relative code ratio', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts index 5bab3f292d73..df065ee70077 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts @@ -2,13 +2,17 @@ import './native-chat-appearance.css' import type { CSSProperties } from 'react' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { resolveNativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' +import { resolveConfiguredTerminalColors } from '../../../../shared/terminal-theme-selection' +import { buildFontFamily } from '@/lib/monospace-font-family' +import { getSystemPrefersDark, isTerminalBackgroundLight } from '@/lib/terminal-theme' +import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark' export const NATIVE_CHAT_APPEARANCE_ROOT_CLASS = 'native-chat-appearance bg-chat-canvas' export const NATIVE_CHAT_TRANSCRIPT_OUTER_CLASS = 'px-3 pt-10 pb-4 sm:px-4' export const NATIVE_CHAT_TRANSCRIPT_COLUMN_CLASS = 'mx-auto flex w-full max-w-(--chat-content-max-width) flex-col gap-5 px-[5px]' -export type NativeChatAppearanceStyle = CSSProperties & { +export type NativeChatAppearanceStyle = CSSProperties & Record<`--${string}`, string | number> & { '--chat-font-size': string '--chat-code-font-size': string '--chat-content-max-width': string @@ -24,20 +28,28 @@ export function nativeChatColumnWidthBucket(width: number | null | undefined): n : null } +export function nativeChatContrastMix(contrast: number, light: boolean): number { + const value = Number.isFinite(contrast) ? Math.min(150, Math.max(50, contrast)) : 100 + return light + ? Math.min(100, Math.max(55, 82 + (value - 100) * 0.36)) + : Math.min(100, Math.max(50, 78 + (value - 100) * 0.44)) +} + export function nativeChatAppearanceStyle( - settings: Pick | null | undefined, - measuredColumnWidthPx?: number | null + settings: Partial | null | undefined, + measuredColumnWidthPx?: number | null, + systemPrefersDark = getSystemPrefersDark() ): NativeChatAppearanceStyle { - const { fontSize, codeFontSize, width } = resolveNativeChatAppearanceSettings( - settings?.nativeChatAppearance - ) + const appearance = resolveNativeChatAppearanceSettings(settings?.nativeChatAppearance) + const { fontSize, codeFontSize, width } = appearance const maxWidthPx = width === 'wide' ? 960 : width === 'full' ? Number.POSITIVE_INFINITY : 736 const measuredWidth = nativeChatColumnWidthBucket(measuredColumnWidthPx) const columnWidthPx = Math.min( measuredWidth ? measuredWidth : width === 'wide' ? 960 : 736, maxWidthPx ) - return { + const font = buildFontFamily(settings?.terminalFontFamily ?? '') + const style: NativeChatAppearanceStyle = { '--chat-font-size': `${fontSize}px`, '--chat-code-font-size': `${codeFontSize}px`, '--chat-content-max-width': width === 'full' ? 'none' : width === 'wide' ? '60rem' : '46rem', @@ -46,12 +58,64 @@ export function nativeChatAppearanceStyle( '--chat-estimated-chars-per-line': Math.max( 1, Math.floor((((96 * columnWidthPx) / 736) * 14) / fontSize) + ), + '--chat-code-font-family': font + } + let light = settings?.theme === 'light' || (settings?.theme === 'system' && !systemPrefersDark) + if (appearance?.matchTerminalInterface === true) { + const colors = resolveConfiguredTerminalColors( + { + theme: settings?.theme ?? 'dark', + terminalThemeDark: settings?.terminalThemeDark ?? '', + terminalThemeLight: settings?.terminalThemeLight ?? '', + terminalUseSeparateLightTheme: settings?.terminalUseSeparateLightTheme ?? false, + terminalCustomThemes: settings?.terminalCustomThemes, + terminalColorOverrides: settings?.terminalColorOverrides + }, + systemPrefersDark ) + light = isTerminalBackgroundLight(colors.background) + Object.assign(style, { + '--chat-font-family': font, + '--background': 'var(--chat-canvas)', + '--foreground': 'var(--chat-foreground-strong)', + '--muted-foreground': 'var(--chat-foreground-faint)', + '--accent': 'var(--chat-code-surface)', + '--accent-foreground': 'var(--chat-foreground-strong)', + '--border': 'var(--chat-code-border)', + '--input': 'var(--chat-composer-border)', + '--card': 'var(--chat-user-surface)', + '--card-foreground': 'var(--chat-foreground-strong)', + '--primary': 'var(--chat-foreground-strong)', + '--primary-foreground': 'var(--chat-canvas)', + '--secondary': 'var(--chat-user-surface)', + '--secondary-foreground': 'var(--chat-foreground-strong)', + '--ring': 'var(--chat-foreground-faint)', + '--chat-source-background': colors.background, + '--chat-source-foreground': colors.foreground, + '--chat-source-muted-foreground': + 'color-mix(in srgb, var(--chat-source-foreground) 62%, var(--chat-canvas))', + '--chat-canvas-mix': '0%', + '--chat-strong-mix': light ? '92%' : '90%', + '--chat-faint-mix': light ? '100%' : '83%', + '--chat-user-mix': light ? '4%' : '7%', + '--chat-user-border-mix': light ? '7%' : '6%', + '--chat-code-mix': light ? '2%' : '3.5%', + '--chat-code-base': light ? 'var(--chat-canvas)' : 'transparent', + '--chat-inline-code-mix': light ? '4%' : '6%', + '--chat-inline-code-base': light ? 'var(--chat-canvas)' : 'transparent', + '--chat-inline-code-border-mix': light ? '9%' : '8%', + '--chat-composer-mix': light ? '0%' : '4%', + '--chat-composer-base': light ? 'var(--chat-canvas)' : 'transparent', + '--chat-composer-border-mix': light ? '11%' : '9%' + }) } + style['--chat-foreground-mix'] = `${nativeChatContrastMix(appearance?.contrast ?? 100, light)}%` + return style } export function useNativeChatAppearanceStyle( - settings: Pick | null | undefined + settings: Partial | null | undefined ): NativeChatAppearanceStyle { - return nativeChatAppearanceStyle(settings) + return nativeChatAppearanceStyle(settings, undefined, useSystemPrefersDark()) } diff --git a/src/renderer/src/components/native-chat/native-chat-appearance.css b/src/renderer/src/components/native-chat/native-chat-appearance.css index 3405ff6f001a..76b7479f188d 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance.css +++ b/src/renderer/src/components/native-chat/native-chat-appearance.css @@ -7,3 +7,15 @@ .native-chat-message-text { font-size: var(--chat-font-size, 14px); } + +.native-chat-appearance { + font-family: var(--chat-font-family, var(--font-sans)); +} + +.native-chat-appearance :is(.font-sans, textarea, [contenteditable='true']) { + font-family: inherit; +} + +.native-chat-appearance :is(.font-mono, code, pre) { + font-family: var(--chat-code-font-family, var(--font-mono)); +} diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx new file mode 100644 index 000000000000..0c23a22e555c --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { AppearanceChatSection } from './AppearanceChatSection' + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) => + selector({ settingsSearchQuery: '' }) +})) +afterEach(cleanup) + +describe('chat contrast controls', () => { + it('renders a switch and saves the opt-in choice', () => { + const updateSettings = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('switch', { name: 'Match terminal interface' })) + expect(updateSettings).toHaveBeenCalledWith({ + nativeChatAppearance: { matchTerminalInterface: true } + }) + }) + + it('shows the clamped contrast and supports slider keyboard input', () => { + const updateSettings = vi.fn() + render( + + ) + const slider = screen.getByRole('slider', { name: 'Contrast' }) + expect(slider.getAttribute('aria-valuenow')).toBe('150') + fireEvent.keyDown(slider, { key: 'ArrowLeft' }) + expect(updateSettings).toHaveBeenCalledWith({ nativeChatAppearance: { contrast: 149 } }) + expect(screen.getByText('Softer')).toBeTruthy() + expect(screen.getByText('Sharper')).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx new file mode 100644 index 000000000000..d79ae085d553 --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx @@ -0,0 +1,70 @@ +import type { NativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' +import { getChatContrastSearchEntries } from './chat-appearance-search' +import { translate } from '@/i18n/i18n' +import { Slider } from '../ui/slider' +import { SearchableSetting } from './SearchableSetting' +import { SettingsRow, SettingsSwitchRow } from './SettingsFormControls' + +type ChatContrastControlsProps = { + appearance: Required + onChange: (updates: NativeChatAppearanceSettings) => void + forceVisiblePrimary?: boolean +} + +export function AppearanceChatContrastControls({ + appearance, + onChange, + forceVisiblePrimary +}: ChatContrastControlsProps): React.JSX.Element { + const entries = getChatContrastSearchEntries() + const { contrast, matchTerminalInterface: matching } = appearance + return ( + <> + + onChange({ matchTerminalInterface: !matching })} + /> + + + + + {translate('settings.appearance.chat.softer', 'Softer')} + +
+ onChange({ contrast: value })} + /> +
+ + {translate('settings.appearance.chat.sharper', 'Sharper')} + + + {contrast} + +
+ } + /> + + + ) +} diff --git a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx index ba53111824ca..740a3a93b442 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx @@ -148,6 +148,9 @@ describe('chat appearance settings controls', () => { const entries = getChatAppearanceSearchEntries() for (const query of [ 'Chat', + 'Match terminal interface', + 'Contrast', + 'brighter look', 'Code text size', 'tool output', 'Comfortable', diff --git a/src/renderer/src/components/settings/AppearanceChatSection.tsx b/src/renderer/src/components/settings/AppearanceChatSection.tsx index 067b0768cb72..af1b8ae850a2 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.tsx @@ -7,6 +7,7 @@ import { import { useAppStore } from '../../store' import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' +import { AppearanceChatContrastControls } from './AppearanceChatContrastControls' import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' @@ -38,6 +39,11 @@ export function AppearanceChatSection({ } return (
+ [ + { + title: translate('settings.appearance.chat.matchTerminalInterface', 'Match terminal interface'), + description: translate( + 'settings.appearance.chat.matchTerminalInterfaceDescription', + "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later." + ), + targetSectionId: 'chat-match-terminal-interface' + }, + { + title: translate('settings.appearance.chat.contrast', 'Contrast'), + description: translate( + 'settings.appearance.chat.contrastDescription', + 'How bright chat text is against the background. The right end matches the older, brighter look.' + ), + targetSectionId: 'chat-contrast' + } +]) + + const getChatAppearanceCatalog = createLocalizedCatalog( () => ({ @@ -66,7 +86,8 @@ export function getChatAppearanceSearchEntries(): SettingsSearchEntry[] { { title: translate('auto.components.settings.Settings.2b4474780a', 'Appearance') }, - ...Object.values(getChatAppearanceEntriesByKey()) + ...Object.values(getChatAppearanceEntriesByKey()), + ...getChatContrastSearchEntries() ] } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f9071f056100..a7a37b1a0ed4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19184,5 +19184,13 @@ "projectField": { "updateFailed": "Failed to update project field" }, - "settings.appearance.chat.title": "Chat" + "settings.appearance.chat.title": "Chat", + "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", + "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", + "settings.appearance.chat.contrast": "Contrast", + "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", + "settings.appearance.chat.softer": "Softer", + "settings.appearance.chat.sharper": "Sharper", + "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", + "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0da94aca4732..437da43f3ecd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -19119,5 +19119,13 @@ "confirm": "Eliminar todos los worktrees indicados", "preservedBranches": "Se conservaron algunas ramas de worktrees anidados" } - } + }, + "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", + "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", + "settings.appearance.chat.contrast": "Contrast", + "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", + "settings.appearance.chat.softer": "Softer", + "settings.appearance.chat.sharper": "Sharper", + "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", + "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 1213c89d4458..7d06a0f8e0c7 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19119,5 +19119,13 @@ "confirm": "Supprimer tous les worktrees listés", "preservedBranches": "Certaines branches de worktrees imbriqués ont été conservées" } - } + }, + "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", + "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", + "settings.appearance.chat.contrast": "Contrast", + "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", + "settings.appearance.chat.softer": "Softer", + "settings.appearance.chat.sharper": "Sharper", + "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", + "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 066c752ceb8f..87566127f1a8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -19119,5 +19119,13 @@ "confirm": "一覧表示されたすべてのワークツリーを削除", "preservedBranches": "一部のネストされたワークツリーブランチは保持されました" } - } + }, + "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", + "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", + "settings.appearance.chat.contrast": "Contrast", + "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", + "settings.appearance.chat.softer": "Softer", + "settings.appearance.chat.sharper": "Sharper", + "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", + "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a043ec36ea99..8a4656ff9f8e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -19119,5 +19119,13 @@ "confirm": "나열된 모든 워크트리 삭제", "preservedBranches": "일부 중첩 워크트리 브랜치가 유지되었습니다" } - } + }, + "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", + "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", + "settings.appearance.chat.contrast": "Contrast", + "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", + "settings.appearance.chat.softer": "Softer", + "settings.appearance.chat.sharper": "Sharper", + "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", + "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3af1cb962ed7..32368875e284 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -19119,5 +19119,13 @@ "confirm": "删除所有列出的工作树", "preservedBranches": "部分嵌套工作树分支已保留" } - } + }, + "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", + "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", + "settings.appearance.chat.contrast": "Contrast", + "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", + "settings.appearance.chat.softer": "Softer", + "settings.appearance.chat.sharper": "Sharper", + "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", + "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" } diff --git a/src/shared/native-chat-appearance-settings.test.ts b/src/shared/native-chat-appearance-settings.test.ts index 4a52b28fda57..c1fdf725793a 100644 --- a/src/shared/native-chat-appearance-settings.test.ts +++ b/src/shared/native-chat-appearance-settings.test.ts @@ -8,18 +8,18 @@ import { describe('native chat appearance normalization', () => { it('preserves unknown future fields while normalizing only known fields', () => { const future = { - contrast: 151, - matchTerminalInterface: true, futureSetting: { nested: 'keep' } } expect( normalizeNativeChatAppearanceSettings({ ...future, + contrast: 151, + matchTerminalInterface: true, fontSize: 99, codeFontSize: 12, width: 'comfortable' }) - ).toEqual({ ...future, fontSize: 20 }) + ).toEqual({ ...future, fontSize: 20, contrast: 150, matchTerminalInterface: true }) expect(normalizeNativeChatAppearanceSettings(future)).toEqual(future) }) @@ -34,7 +34,9 @@ describe('native chat appearance normalization', () => { expect(resolveNativeChatAppearanceSettings(undefined)).toEqual({ fontSize: 14, codeFontSize: 12, - width: 'comfortable' + width: 'comfortable', + contrast: 100, + matchTerminalInterface: false }) }) it('resets only known controls while keeping settings from a newer version', () => { @@ -54,7 +56,13 @@ describe('native chat appearance normalization', () => { it('clamps and rounds values on read', () => { expect( resolveNativeChatAppearanceSettings({ fontSize: 99, codeFontSize: 0, width: 'full' }) - ).toEqual({ fontSize: 20, codeFontSize: 10, width: 'full' }) + ).toEqual({ + fontSize: 20, + codeFontSize: 10, + width: 'full', + contrast: 100, + matchTerminalInterface: false + }) expect( normalizeNativeChatAppearanceSettings({ fontSize: 15.6, codeFontSize: 13.2, width: 'wide' }) ).toEqual({ fontSize: 16, codeFontSize: 13, width: 'wide' }) @@ -72,3 +80,36 @@ describe('native chat appearance normalization', () => { } }) }) + +describe('chat contrast and terminal interface normalization', () => { + it('removes default fields and the empty object', () => { + expect( + normalizeNativeChatAppearanceSettings({ contrast: 100, matchTerminalInterface: false }) + ).toBeUndefined() + expect( + normalizeNativeChatAppearanceSettings({ + fontSize: 16, + contrast: 100, + matchTerminalInterface: false + }) + ).toEqual({ fontSize: 16 }) + expect( + normalizeNativeChatAppearanceSettings({ + codeFontSize: 13, + contrast: 120, + matchTerminalInterface: true + }) + ).toEqual({ codeFontSize: 13, contrast: 120, matchTerminalInterface: true }) + }) + + it('clamps and rounds persisted contrast, accepting only an explicit true toggle', () => { + expect( + resolveNativeChatAppearanceSettings({ contrast: 999, matchTerminalInterface: 'true' }) + ).toMatchObject({ contrast: 150, matchTerminalInterface: false }) + expect(normalizeNativeChatAppearanceSettings({ contrast: -5 })).toEqual({ contrast: 50 }) + expect(normalizeNativeChatAppearanceSettings({ contrast: 120.6 })).toEqual({ contrast: 121 }) + for (const contrast of [Number.NaN, Number.POSITIVE_INFINITY, '150', null]) { + expect(normalizeNativeChatAppearanceSettings({ contrast })).toBeUndefined() + } + }) +}) diff --git a/src/shared/native-chat-appearance-settings.ts b/src/shared/native-chat-appearance-settings.ts index 2dd1924a3685..b01d4137bb76 100644 --- a/src/shared/native-chat-appearance-settings.ts +++ b/src/shared/native-chat-appearance-settings.ts @@ -5,6 +5,10 @@ export type NativeChatAppearanceSettings = { codeFontSize?: number /** Transcript and composer width; absent = comfortable (46rem). */ width?: 'comfortable' | 'wide' | 'full' + /** Chat contrast, 50–150; absent = 100. */ + contrast?: number + /** Follow the terminal interface font and theme; absent = false. */ + matchTerminalInterface?: boolean } export type NativeChatGlobalSettings = { @@ -13,6 +17,7 @@ export type NativeChatGlobalSettings = { export const DEFAULT_NATIVE_CHAT_FONT_SIZE = 14 export const DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE = 12 +export const DEFAULT_NATIVE_CHAT_CONTRAST = 100 function normalizeSize(value: unknown, min: number, max: number, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) @@ -33,15 +38,20 @@ export function normalizeNativeChatAppearanceSettings( 18, 12 ) + const contrast = normalizeSize('contrast' in value ? value.contrast : undefined, 50, 150, 100) + const matchTerminalInterface = + 'matchTerminalInterface' in value && value.matchTerminalInterface === true const width = 'width' in value && (value.width === 'wide' || value.width === 'full') ? value.width : undefined const normalized: NativeChatAppearanceSettings = { ...Object.fromEntries( - Object.entries(value).filter(([key]) => !['fontSize', 'codeFontSize', 'width'].includes(key)) + Object.entries(value).filter(([key]) => !['fontSize', 'codeFontSize', 'width', 'contrast', 'matchTerminalInterface'].includes(key)) ), ...(fontSize !== DEFAULT_NATIVE_CHAT_FONT_SIZE ? { fontSize } : {}), ...(codeFontSize !== DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE ? { codeFontSize } : {}), - ...(width ? { width } : {}) + ...(width ? { width } : {}), + ...(contrast !== DEFAULT_NATIVE_CHAT_CONTRAST ? { contrast } : {}), + ...(matchTerminalInterface ? { matchTerminalInterface } : {}) } return Object.keys(normalized).length ? normalized : undefined } @@ -53,7 +63,9 @@ export function resolveNativeChatAppearanceSettings( return { fontSize: normalized?.fontSize ?? DEFAULT_NATIVE_CHAT_FONT_SIZE, codeFontSize: normalized?.codeFontSize ?? DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE, - width: normalized?.width ?? 'comfortable' + width: normalized?.width ?? 'comfortable', + contrast: normalized?.contrast ?? DEFAULT_NATIVE_CHAT_CONTRAST, + matchTerminalInterface: normalized?.matchTerminalInterface ?? false } } From 644dabfb4abcc9de202d57a4f12cc561569f195b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:19:38 -0700 Subject: [PATCH 02/16] Fix matching chat palettes and contrast controls --- src/renderer/src/assets/main.css | 94 +++++++------ .../native-chat/NativeChatResolvedView.tsx | 1 + .../NativeChatStructuredSession.tsx | 1 + .../native-chat-appearance-palette.test.ts | 127 ++++++++++++++++++ .../native-chat-appearance-style.test.ts | 50 ++++++- .../native-chat-appearance-style.ts | 32 +++-- ...earanceChatContrastControls.draft.test.tsx | 66 +++++++++ .../AppearanceChatContrastControls.tsx | 54 ++++---- src/renderer/src/i18n/locales/en.json | 18 ++- src/renderer/src/i18n/locales/es.json | 18 ++- src/renderer/src/i18n/locales/fr.json | 18 ++- src/renderer/src/i18n/locales/ja.json | 18 ++- src/renderer/src/i18n/locales/ko.json | 18 ++- src/renderer/src/i18n/locales/zh.json | 18 ++- 14 files changed, 393 insertions(+), 140 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts create mode 100644 src/renderer/src/components/settings/AppearanceChatContrastControls.draft.test.tsx diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index b77ab8136ba5..63bb92dff61b 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -24,7 +24,8 @@ unicode-range: U+E000-F8FF, U+F0000-FFFFD, U+100000-10FFFD; } -@custom-variant dark (&:is(.dark *)); +/* A matching chat can use a different scheme from the document; portaled menus stay outside it. */ +@custom-variant dark (&:is(.dark *:not(:where([data-native-chat-scheme='light'], [data-native-chat-scheme='light'] *)), [data-native-chat-scheme='dark'], [data-native-chat-scheme='dark'] *)); /* Why: hover-reveal controls (sidebar/row action buttons) hide with `opacity-0` and fade in via `group-hover`. Touch devices have no hover, so those controls @@ -43,9 +44,6 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --color-chat-source-background: var(--chat-source-background); - --color-chat-source-foreground: var(--chat-source-foreground); - --color-chat-source-muted-foreground: var(--chat-source-muted-foreground); --color-chat-canvas: var(--chat-canvas); --color-chat-foreground: var(--chat-foreground); --color-chat-foreground-strong: var(--chat-foreground-strong); @@ -203,8 +201,6 @@ --muted-foreground: #737373; --accent: #f5f5f5; --accent-foreground: #171717; - --destructive: #e40014; - --destructive-foreground: #fcf3f3; --border: #e5e5e5; --input: #e5e5e5; --ring: #a1a1a1; @@ -216,14 +212,6 @@ --agent-question: var(--color-orange-600); /* Legible weight for text/glyphs on a tinted --agent-question surface. */ --agent-question-text: var(--color-orange-700); - --status-success: #15803d; - --status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent); - --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); - /* Needs-attention state that is not an error: a resume that did not carry on, an update that - failed. Yellow, so it stays apart from destructive red and from the orange working state. */ - --status-warning: #ca8a04; - --status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent); - --status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent); /* Workspace PR-state tones: same hue in both themes so a status keeps its identity when the theme flips. */ --workspace-status-done: #c7a594; @@ -262,17 +250,6 @@ --terminal-pane-title-on-light-button-hover-fg: rgb(24 24 27); --terminal-pane-title-on-light-input-bg: rgb(24 24 27 / 0.05); --terminal-pane-title-on-light-separator: rgb(24 24 27 / 0.1); - --git-decoration-added: #587c0c; - --git-decoration-modified: #895503; - --git-decoration-deleted: #ad0707; - --git-decoration-renamed: #007acc; - --git-decoration-untracked: #007100; - --git-decoration-copied: #007acc; - --git-decoration-ignored: #8c8c8c; - --diff-added-ground: color-mix(in srgb, var(--git-decoration-added) 13%, transparent); - --diff-added-gutter: color-mix(in srgb, var(--git-decoration-added) 26%, transparent); - --diff-removed-ground: color-mix(in srgb, var(--git-decoration-deleted) 11%, transparent); - --diff-removed-gutter: color-mix(in srgb, var(--git-decoration-deleted) 22%, transparent); --git-graph-ref: #007acc; --git-graph-remote-ref: #b66dff; --git-graph-base-ref: #ea5c00; @@ -341,8 +318,6 @@ --muted-foreground: #a1a1a1; --accent: #404040; --accent-foreground: #fafafa; - --destructive: #ff6568; - --destructive-foreground: #df2225; --border: rgb(255 255 255 / 0.07); --input: rgb(255 255 255 / 0.15); --ring: #737373; @@ -350,12 +325,6 @@ --ai-action-accent: var(--color-violet-400); --agent-question: var(--color-orange-500); --agent-question-text: var(--color-orange-300); - --status-success: #86efac; - --status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent); - --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); - --status-warning: #eab308; - --status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent); - --status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent); --workspace-status-done: #c7a594; --workspace-status-review: #16a34a; --workspace-status-progress: #d4a300; @@ -396,17 +365,6 @@ --terminal-pane-title-on-light-button-hover-fg: rgb(24 24 27); --terminal-pane-title-on-light-input-bg: rgb(24 24 27 / 0.05); --terminal-pane-title-on-light-separator: rgb(24 24 27 / 0.1); - --git-decoration-added: #81b88b; - --git-decoration-modified: #e2c08d; - --git-decoration-deleted: #c74e39; - --git-decoration-renamed: #73c991; - --git-decoration-untracked: #73c991; - --git-decoration-copied: #73c991; - --git-decoration-ignored: #6e6e6e; - --diff-added-ground: color-mix(in srgb, var(--git-decoration-added) 16%, transparent); - --diff-added-gutter: color-mix(in srgb, var(--git-decoration-added) 30%, transparent); - --diff-removed-ground: color-mix(in srgb, var(--git-decoration-deleted) 18%, transparent); - --diff-removed-gutter: color-mix(in srgb, var(--git-decoration-deleted) 32%, transparent); --git-graph-ref: #3794ff; --git-graph-remote-ref: #b66dff; --git-graph-base-ref: #ea5c00; @@ -423,6 +381,54 @@ --tab-group-split-divider-strong: #a1a1aa; } +/* Status and diff colors follow the chat scheme without copying the app palette. */ +:root, +.native-chat-appearance[data-native-chat-scheme='light'] { + --destructive: #e40014; + --destructive-foreground: #fcf3f3; + --status-success: #15803d; + --status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent); + --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); + /* Needs-attention states use yellow, separate from errors and working states. */ + --status-warning: #ca8a04; + --status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent); + --status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent); + --git-decoration-added: #587c0c; + --git-decoration-modified: #895503; + --git-decoration-deleted: #ad0707; + --git-decoration-renamed: #007acc; + --git-decoration-untracked: #007100; + --git-decoration-copied: #007acc; + --git-decoration-ignored: #8c8c8c; + --diff-added-ground: color-mix(in srgb, var(--git-decoration-added) 13%, transparent); + --diff-added-gutter: color-mix(in srgb, var(--git-decoration-added) 26%, transparent); + --diff-removed-ground: color-mix(in srgb, var(--git-decoration-deleted) 11%, transparent); + --diff-removed-gutter: color-mix(in srgb, var(--git-decoration-deleted) 22%, transparent); +} + +.dark, +.native-chat-appearance[data-native-chat-scheme='dark'] { + --destructive: #ff6568; + --destructive-foreground: #df2225; + --status-success: #86efac; + --status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent); + --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); + --status-warning: #eab308; + --status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent); + --status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent); + --git-decoration-added: #81b88b; + --git-decoration-modified: #e2c08d; + --git-decoration-deleted: #c74e39; + --git-decoration-renamed: #73c991; + --git-decoration-untracked: #73c991; + --git-decoration-copied: #73c991; + --git-decoration-ignored: #6e6e6e; + --diff-added-ground: color-mix(in srgb, var(--git-decoration-added) 16%, transparent); + --diff-added-gutter: color-mix(in srgb, var(--git-decoration-added) 30%, transparent); + --diff-removed-ground: color-mix(in srgb, var(--git-decoration-deleted) 18%, transparent); + --diff-removed-gutter: color-mix(in srgb, var(--git-decoration-deleted) 32%, transparent); +} + .plugin-security-chrome { /* Why: plugin themes may style the app, but provenance and consent must retain host-owned contrast so a pack cannot disguise a trust decision. */ diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index a3094ddf36ee..d6143dadd6d9 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -359,6 +359,7 @@ export function NativeChatResolvedView({ 'flex h-full min-h-0 w-full flex-col focus:outline-none' )} style={appearanceStyle} + data-native-chat-scheme={appearanceStyle.colorScheme} >
{viewState.kind === 'loading' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 400108943787..28d0ff903be5 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -267,6 +267,7 @@ export function NativeChatStructuredSession( 'flex h-full min-h-0 w-full flex-col focus:outline-none' )} style={appearanceStyle} + data-native-chat-scheme={appearanceStyle.colorScheme} >
{viewState.kind === 'loading' ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts new file mode 100644 index 000000000000..0f7fe00e1b76 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment happy-dom +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { compile } from 'tailwindcss' +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 { nativeChatAppearanceStyle } from './native-chat-appearance-style' + +const css = readFileSync(resolve('src/renderer/src/assets/main.css'), 'utf8') +// Happy DOM only needs the ordinary rules; Tailwind directives are compiled separately below. +const paletteCss = [ + ...css.matchAll(/(?:^|\n)(:root[^{}]*|\.dark[^{}]*|\.native-chat-appearance[^{}]*)\{([^{}]*)\}/g) +] + .map((match) => match[0]) + .join('\n') + +afterEach(() => { + document.head.innerHTML = '' + document.body.innerHTML = '' + document.documentElement.className = '' + vi.restoreAllMocks() +}) + +function createChat(appScheme: 'light' | 'dark', terminalScheme: 'light' | 'dark'): HTMLElement { + document.documentElement.classList.toggle('dark', appScheme === 'dark') + const sheet = document.createElement('style') + sheet.textContent = paletteCss + document.head.append(sheet) + const chat = document.createElement('div') + chat.className = 'native-chat-appearance' + const style = nativeChatAppearanceStyle( + createGlobalSettingsFixture({ + theme: appScheme, + terminalColorOverrides: + terminalScheme === 'light' + ? { background: '#ffffff', foreground: '#000000' } + : { background: '#000000', foreground: '#ffffff' }, + nativeChatAppearance: { matchTerminalInterface: true } + }) + ) + chat.dataset.nativeChatScheme = style.colorScheme + for (const [key, value] of Object.entries(style)) { + if (key.startsWith('--')) { + chat.style.setProperty(key, String(value)) + } + } + document.body.append(chat) + return chat +} + +const statusRoles = [ + '--destructive', + '--status-success', + '--status-warning', + '--git-decoration-added', + '--git-decoration-deleted', + '--diff-added-ground', + '--diff-added-gutter', + '--diff-removed-ground', + '--diff-removed-gutter' +] + +describe('matching chat surfaces with opposite app schemes', () => { + it.each([ + ['dark', 'light'], + ['light', 'dark'] + ] as const)('uses the %s app with a %s terminal palette', (appScheme, terminalScheme) => { + const chat = createChat(appScheme, terminalScheme) + // These roles cover transcript payloads, question badges, inline completion menus and controls. + const matched = getComputedStyle(chat) + expect(matched.getPropertyValue('--muted')).toContain('7%') + expect(matched.getPropertyValue('--muted-foreground')).toContain('62%') + expect(matched.getPropertyValue('--popover')).toContain('4%') + expect(matched.getPropertyValue('--popover-foreground')).toContain( + terminalScheme === 'light' ? '92%' : '90%' + ) + expect(matched.getPropertyValue('--accent')).toContain('9%') + expect(matched.getPropertyValue('--input')).toContain('7%') + expect(matched.getPropertyValue('--secondary')).toContain('7%') + expect(matched.getPropertyValue('--ring')).toContain('44%') + expect(matched.getPropertyValue('--chat-source-background')).toBe( + terminalScheme === 'light' ? '#ffffff' : '#000000' + ) + expect(matched.getPropertyValue('--chat-source-foreground')).toBe( + terminalScheme === 'light' ? '#000000' : '#ffffff' + ) + const outside = getComputedStyle(document.documentElement) + const appStatus = statusRoles.map((role) => outside.getPropertyValue(role)) + const chatStatus = statusRoles.map((role) => matched.getPropertyValue(role)) + expect(chatStatus.every(Boolean)).toBe(true) + expect(chatStatus).not.toEqual(appStatus) + document.documentElement.classList.toggle('dark', terminalScheme === 'dark') + expect( + statusRoles.map((role) => getComputedStyle(document.documentElement).getPropertyValue(role)) + ).toEqual(chatStatus) + }) + + it('resolves an incomplete terminal palette without a local background variable cycle', () => { + vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors').mockReturnValue({}) + const chat = createChat('dark', 'light') + expect(chat.dataset.nativeChatScheme).toBe('dark') + const styles = getComputedStyle(chat) + expect(styles.getPropertyValue('--chat-canvas')).toContain('#000000') + expect(styles.getPropertyValue('--chat-source-foreground')).toBe('#fafafa') + }) +}) + +describe('chat scheme and inherited dark utilities', () => { + it('compiles the custom variant and excludes light chats and enables dark chats', async () => { + const declaration = css.match(/^@custom-variant dark \((.+)\);$/m) + expect(declaration).not.toBeNull() + if (!declaration) { + throw new Error('Missing dark variant') + } + const compiler = await compile( + `${declaration[0]}\n@theme { --color-input: #123456; }\n@tailwind utilities;` + ) + expect(compiler.build(['dark:bg-input/30'])).toContain('data-native-chat-scheme') + expect(declaration[1]).toContain( + ".dark *:not(:where([data-native-chat-scheme='light'], [data-native-chat-scheme='light'] *))" + ) + expect(declaration[1]).toContain( + ", [data-native-chat-scheme='dark'], [data-native-chat-scheme='dark'] *" + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts index c2cc8dfbe05f..af3d5907ea48 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts @@ -1,10 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' import { buildFontFamily } from '@/lib/monospace-font-family' +import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection' import { resolveConfiguredTerminalColors } from '../../../../shared/terminal-theme-selection' import { nativeChatAppearanceStyle, nativeChatContrastMix } from './native-chat-appearance-style' const makeSettings = createGlobalSettingsFixture +afterEach(() => vi.restoreAllMocks()) describe('nativeChatContrastMix', () => { it.each([ @@ -170,3 +172,49 @@ describe('chat root appearance style', () => { ).toBe('none') }) }) + +describe('contrast hierarchy and incomplete terminal palettes', () => { + it.each(['light', 'dark'] as const)( + 'keeps strong text above body in %s mode up to the cap', + (theme) => { + for (const matching of [false, true]) { + for (let contrast = 50; contrast <= 150; contrast++) { + const style = nativeChatAppearanceStyle( + makeSettings({ + theme, + terminalColorOverrides: + theme === 'light' + ? { background: '#ffffff', foreground: '#000000' } + : { background: '#000000', foreground: '#ffffff' }, + nativeChatAppearance: { contrast, matchTerminalInterface: matching } + }) + ) + const body = Number.parseFloat(style['--chat-foreground-mix']) + const strong = Number.parseFloat(style['--chat-strong-mix']) + expect(strong).toBe(Math.min(100, body + (theme === 'light' ? 10 : 12))) + if (body < 100) { + expect(strong).toBeGreaterThan(body) + } + } + } + } + ) + + it.each([{}, { background: '#ffffff' }, { foreground: '#000000' }])( + 'uses the fallback source pair for an incomplete palette %j', + (colors) => { + vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors').mockReturnValue(colors) + for (const theme of ['light', 'dark'] as const) { + const style = nativeChatAppearanceStyle( + makeSettings({ + theme, + nativeChatAppearance: { matchTerminalInterface: true } + }) + ) + expect(style['--chat-source-background']).toBe('#000000') + expect(style['--chat-source-foreground']).toBe('#fafafa') + expect(style.colorScheme).toBe('dark') + } + } + ) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts index df065ee70077..50aa249a4bda 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts @@ -74,29 +74,34 @@ export function nativeChatAppearanceStyle( }, systemPrefersDark ) - light = isTerminalBackgroundLight(colors.background) + const hasThemeColors = Boolean(colors.background && colors.foreground) + const background = hasThemeColors ? colors.background : '#000000' + const foreground = hasThemeColors ? colors.foreground : '#fafafa' + light = isTerminalBackgroundLight(background) Object.assign(style, { '--chat-font-family': font, '--background': 'var(--chat-canvas)', '--foreground': 'var(--chat-foreground-strong)', - '--muted-foreground': 'var(--chat-foreground-faint)', - '--accent': 'var(--chat-code-surface)', + '--muted-foreground': 'var(--chat-source-muted-foreground)', + '--accent': 'color-mix(in srgb, var(--chat-source-foreground) 9%, var(--chat-canvas))', + '--muted': 'color-mix(in srgb, var(--chat-source-foreground) 7%, var(--chat-canvas))', + '--popover': 'color-mix(in srgb, var(--chat-source-foreground) 4%, var(--chat-canvas))', + '--popover-foreground': 'var(--chat-foreground-strong)', '--accent-foreground': 'var(--chat-foreground-strong)', - '--border': 'var(--chat-code-border)', - '--input': 'var(--chat-composer-border)', - '--card': 'var(--chat-user-surface)', + '--border': 'color-mix(in srgb, var(--chat-source-foreground) 7%, var(--chat-canvas))', + '--input': 'color-mix(in srgb, var(--chat-source-foreground) 7%, var(--chat-canvas))', + '--card': 'color-mix(in srgb, var(--chat-source-foreground) 4%, var(--chat-canvas))', '--card-foreground': 'var(--chat-foreground-strong)', '--primary': 'var(--chat-foreground-strong)', '--primary-foreground': 'var(--chat-canvas)', - '--secondary': 'var(--chat-user-surface)', + '--secondary': 'var(--muted)', '--secondary-foreground': 'var(--chat-foreground-strong)', - '--ring': 'var(--chat-foreground-faint)', - '--chat-source-background': colors.background, - '--chat-source-foreground': colors.foreground, + '--ring': 'color-mix(in srgb, var(--chat-source-foreground) 44%, var(--chat-canvas))', + '--chat-source-background': background, + '--chat-source-foreground': foreground, '--chat-source-muted-foreground': 'color-mix(in srgb, var(--chat-source-foreground) 62%, var(--chat-canvas))', '--chat-canvas-mix': '0%', - '--chat-strong-mix': light ? '92%' : '90%', '--chat-faint-mix': light ? '100%' : '83%', '--chat-user-mix': light ? '4%' : '7%', '--chat-user-border-mix': light ? '7%' : '6%', @@ -110,7 +115,10 @@ export function nativeChatAppearanceStyle( '--chat-composer-border-mix': light ? '11%' : '9%' }) } - style['--chat-foreground-mix'] = `${nativeChatContrastMix(appearance?.contrast ?? 100, light)}%` + const bodyMix = nativeChatContrastMix(appearance.contrast, light) + style['--chat-foreground-mix'] = `${bodyMix}%` + style['--chat-strong-mix'] = `${Math.min(100, bodyMix + (light ? 10 : 12))}%` + style.colorScheme = light ? 'light' : 'dark' return style } diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.draft.test.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.draft.test.tsx new file mode 100644 index 000000000000..7aea10207286 --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.draft.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom +import type { ComponentProps } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Slider } from '../ui/slider' +import { resolveNativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' +import { AppearanceChatContrastControls } from './AppearanceChatContrastControls' + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) => + selector({ settingsSearchQuery: '' }) +})) +vi.mock('../ui/slider', () => ({ + Slider: ({ value, onValueChange, onValueCommit }: ComponentProps) => ( + onValueChange?.([Number(event.target.value)])} + onPointerUp={(event) => onValueCommit?.([Number(event.currentTarget.value)])} + /> + ) +})) +afterEach(cleanup) + +describe('contrast drag persistence', () => { + it('updates the displayed draft during dragging and saves once on commit', () => { + const onChange = vi.fn() + render( + + ) + const slider = screen.getByRole('slider', { name: 'Contrast' }) + for (const value of [110, 120, 130]) { + fireEvent.change(slider, { target: { value } }) + expect(screen.getByText(String(value))).toBeTruthy() + expect(onChange).not.toHaveBeenCalled() + } + fireEvent.pointerUp(slider) + expect(onChange).toHaveBeenCalledExactlyOnceWith({ contrast: 130 }) + }) + + it('resets the draft when settings are changed externally', () => { + const onChange = vi.fn() + const { rerender } = render( + + ) + fireEvent.change(screen.getByRole('slider'), { target: { value: 130 } }) + rerender( + + ) + expect(screen.getByRole('slider').getAttribute('value')).toBe('80') + expect(screen.getByText('80')).toBeTruthy() + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx index d79ae085d553..c621ab663752 100644 --- a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import type { NativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' import { getChatContrastSearchEntries } from './chat-appearance-search' import { translate } from '@/i18n/i18n' @@ -40,31 +41,38 @@ export function AppearanceChatContrastControls({ - - {translate('settings.appearance.chat.softer', 'Softer')} - -
- onChange({ contrast: value })} - /> -
- - {translate('settings.appearance.chat.sharper', 'Sharper')} - - - {contrast} - -
- } + control={} /> ) } + +function ChatContrastSlider({ + contrast, + onChange +}: Pick & { contrast: number }): React.JSX.Element { + const [draft, setDraft] = useState(contrast) + return ( +
+ + {translate('settings.appearance.chat.softer', 'Softer')} + +
+ setDraft(value)} + onValueCommit={([value]) => onChange({ contrast: value })} + /> +
+ + {translate('settings.appearance.chat.sharper', 'Sharper')} + + {draft} +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a7a37b1a0ed4..9799868ab1f4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -233,7 +233,13 @@ "wide": "Wide", "full": "Full", "pixels": "px", - "reset": "Reset" + "reset": "Reset", + "matchTerminalInterface": "Match terminal interface", + "matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", + "contrast": "Contrast", + "contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", + "softer": "Softer", + "sharper": "Sharper" } }, "browser": { @@ -19184,13 +19190,5 @@ "projectField": { "updateFailed": "Failed to update project field" }, - "settings.appearance.chat.title": "Chat", - "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", - "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", - "settings.appearance.chat.contrast": "Contrast", - "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", - "settings.appearance.chat.softer": "Softer", - "settings.appearance.chat.sharper": "Sharper", - "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", - "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" + "settings.appearance.chat.title": "Chat" } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 437da43f3ecd..e75c793ce29c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -70,7 +70,13 @@ "wide": "Amplio", "full": "Completo", "pixels": "px", - "reset": "Restablecer" + "reset": "Restablecer", + "matchTerminalInterface": "Usar la interfaz del terminal", + "matchTerminalInterfaceDescription": "Usa la fuente y los colores de la interfaz del terminal para todo el chat. Se mantiene sincronizado si cambias el tema del terminal.", + "contrast": "Contraste", + "contrastDescription": "Ajusta el brillo del texto del chat sobre el fondo. El extremo derecho recupera el aspecto anterior, más brillante.", + "softer": "Más suave", + "sharper": "Más nítido" } }, "browser": { @@ -19119,13 +19125,5 @@ "confirm": "Eliminar todos los worktrees indicados", "preservedBranches": "Se conservaron algunas ramas de worktrees anidados" } - }, - "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", - "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", - "settings.appearance.chat.contrast": "Contrast", - "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", - "settings.appearance.chat.softer": "Softer", - "settings.appearance.chat.sharper": "Sharper", - "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", - "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" + } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 7d06a0f8e0c7..fa1ee55338ae 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -169,7 +169,13 @@ "wide": "Large", "full": "Pleine", "pixels": "px", - "reset": "Réinitialiser" + "reset": "Réinitialiser", + "matchTerminalInterface": "Utiliser l’interface du terminal", + "matchTerminalInterfaceDescription": "Utilise la police et les couleurs de l’interface du terminal pour tout le chat. Les changements de thème du terminal sont appliqués automatiquement.", + "contrast": "Contraste", + "contrastDescription": "Ajuste la luminosité du texte du chat sur le fond. L’extrémité droite retrouve l’ancien aspect, plus lumineux.", + "softer": "Plus doux", + "sharper": "Plus net" } }, "browser": { @@ -19119,13 +19125,5 @@ "confirm": "Supprimer tous les worktrees listés", "preservedBranches": "Certaines branches de worktrees imbriqués ont été conservées" } - }, - "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", - "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", - "settings.appearance.chat.contrast": "Contrast", - "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", - "settings.appearance.chat.softer": "Softer", - "settings.appearance.chat.sharper": "Sharper", - "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", - "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" + } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 87566127f1a8..562d369f5a7b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -70,7 +70,13 @@ "wide": "広い", "full": "全幅", "pixels": "px", - "reset": "リセット" + "reset": "リセット", + "matchTerminalInterface": "ターミナルの外観に合わせる", + "matchTerminalInterfaceDescription": "チャット全体にターミナルと同じフォントと配色を使用します。ターミナルのテーマを変更すると、チャットにも反映されます。", + "contrast": "コントラスト", + "contrastDescription": "背景に対するチャットの文字の明るさを調整します。右端にすると、以前の明るい表示になります。", + "softer": "柔らかく", + "sharper": "くっきり" } }, "browser": { @@ -19119,13 +19125,5 @@ "confirm": "一覧表示されたすべてのワークツリーを削除", "preservedBranches": "一部のネストされたワークツリーブランチは保持されました" } - }, - "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", - "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", - "settings.appearance.chat.contrast": "Contrast", - "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", - "settings.appearance.chat.softer": "Softer", - "settings.appearance.chat.sharper": "Sharper", - "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", - "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" + } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8a4656ff9f8e..ab20cd4bcb84 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -70,7 +70,13 @@ "wide": "넓게", "full": "전체", "pixels": "px", - "reset": "초기화" + "reset": "초기화", + "matchTerminalInterface": "터미널 모양에 맞추기", + "matchTerminalInterfaceDescription": "전체 채팅에 터미널과 같은 글꼴과 색상을 사용합니다. 터미널 테마를 변경하면 채팅에도 반영됩니다.", + "contrast": "대비", + "contrastDescription": "배경에 대한 채팅 텍스트의 밝기를 조절합니다. 오른쪽 끝으로 이동하면 이전의 더 밝은 모습으로 표시됩니다.", + "softer": "부드럽게", + "sharper": "선명하게" } }, "browser": { @@ -19119,13 +19125,5 @@ "confirm": "나열된 모든 워크트리 삭제", "preservedBranches": "일부 중첩 워크트리 브랜치가 유지되었습니다" } - }, - "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", - "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", - "settings.appearance.chat.contrast": "Contrast", - "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", - "settings.appearance.chat.softer": "Softer", - "settings.appearance.chat.sharper": "Sharper", - "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", - "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" + } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 32368875e284..4a2a3a552c5f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -70,7 +70,13 @@ "wide": "宽", "full": "全宽", "pixels": "px", - "reset": "重置" + "reset": "重置", + "matchTerminalInterface": "匹配终端外观", + "matchTerminalInterfaceDescription": "整个聊天使用与终端相同的字体和配色。更改终端主题后,聊天会自动同步。", + "contrast": "对比度", + "contrastDescription": "调整聊天文字相对于背景的亮度。滑到最右端可恢复以前更明亮的外观。", + "softer": "更柔和", + "sharper": "更清晰" } }, "browser": { @@ -19119,13 +19125,5 @@ "confirm": "删除所有列出的工作树", "preservedBranches": "部分嵌套工作树分支已保留" } - }, - "settings.appearance.chat.matchTerminalInterface": "Match terminal interface", - "settings.appearance.chat.matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.", - "settings.appearance.chat.contrast": "Contrast", - "settings.appearance.chat.contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", - "settings.appearance.chat.softer": "Softer", - "settings.appearance.chat.sharper": "Sharper", - "settings.appearance.chat.terminalInterfaceSummary": "Terminal interface", - "settings.appearance.chat.contrastSummary": "Contrast {{value0}}" + } } From ecda8bd548d12088ae9839ccb8fe5132ef5edaa9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:32:01 -0700 Subject: [PATCH 03/16] Verify rebased appearance settings and preserve slider focus --- .../native-chat-appearance-style.test.ts | 4 +- .../use-native-chat-appearance-style.test.tsx | 46 +++++++++++++++++++ .../AppearanceChatContrastControls.test.tsx | 20 ++++++++ .../AppearanceChatContrastControls.tsx | 16 +++++-- .../native-chat-appearance-settings.test.ts | 15 ++++++ src/shared/native-chat-appearance-settings.ts | 5 +- 6 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts index af3d5907ea48..7f51fde84b13 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts @@ -189,8 +189,8 @@ describe('contrast hierarchy and incomplete terminal palettes', () => { nativeChatAppearance: { contrast, matchTerminalInterface: matching } }) ) - const body = Number.parseFloat(style['--chat-foreground-mix']) - const strong = Number.parseFloat(style['--chat-strong-mix']) + const body = Number.parseFloat(String(style['--chat-foreground-mix'])) + const strong = Number.parseFloat(String(style['--chat-strong-mix'])) expect(strong).toBe(Math.min(100, body + (theme === 'light' ? 10 : 12))) if (body < 100) { expect(strong).toBeGreaterThan(body) diff --git a/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx new file mode 100644 index 000000000000..6a85e4e1b566 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { resetSystemPrefersDarkSubscriptionForTests } from '../terminal-pane/use-system-prefers-dark' +import { useNativeChatAppearanceStyle } from './native-chat-appearance-style' + +afterEach(() => { + cleanup() + resetSystemPrefersDarkSubscriptionForTests() + vi.restoreAllMocks() +}) + +describe('shared chat appearance hook', () => { + it('updates every consumer from one system-scheme subscription', () => { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const matches = vi.spyOn(media, 'matches', 'get').mockReturnValue(true) + vi.spyOn(window, 'matchMedia').mockReturnValue(media) + const addListener = vi.spyOn(media, 'addEventListener') + const removeListener = vi.spyOn(media, 'removeEventListener') + const settings = createGlobalSettingsFixture({ + theme: 'system', + terminalUseSeparateLightTheme: true, + terminalThemeDark: 'Builtin Tango Dark', + terminalThemeLight: 'Builtin Tango Light', + nativeChatAppearance: { matchTerminalInterface: true } + }) + const chat = renderHook(() => useNativeChatAppearanceStyle(settings)) + const preview = renderHook(() => useNativeChatAppearanceStyle(settings)) + expect(addListener).toHaveBeenCalledTimes(1) + expect(chat.result.current.colorScheme).toBe('dark') + const darkBackground = chat.result.current['--chat-source-background'] + act(() => { + matches.mockReturnValue(false) + media.dispatchEvent(new MediaQueryListEvent('change', { matches: false })) + }) + expect(chat.result.current.colorScheme).toBe('light') + expect(chat.result.current['--chat-source-background']).not.toBe(darkBackground) + expect(chat.result.current['--chat-foreground-mix']).toBe('82%') + expect(preview.result.current).toEqual(chat.result.current) + chat.unmount() + expect(removeListener).not.toHaveBeenCalled() + preview.unmount() + expect(removeListener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx index 0c23a22e555c..0e28f80eead8 100644 --- a/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx @@ -40,4 +40,24 @@ describe('chat contrast controls', () => { expect(screen.getByText('Softer')).toBeTruthy() expect(screen.getByText('Sharper')).toBeTruthy() }) + it('keeps keyboard focus when a committed value reaches settings', () => { + const updateSettings = vi.fn() + const settings = createGlobalSettingsFixture({ nativeChatAppearance: { contrast: 120 } }) + const { rerender } = render( + + ) + const slider = screen.getByRole('slider', { name: 'Contrast' }) + slider.focus() + fireEvent.keyDown(slider, { key: 'ArrowLeft' }) + expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { contrast: 119 } }) + rerender( + + ) + expect(document.activeElement).toBe(slider) + fireEvent.keyDown(slider, { key: 'ArrowLeft' }) + expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { contrast: 118 } }) + }) }) diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx index c621ab663752..e8ace9d6b27e 100644 --- a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx @@ -41,7 +41,7 @@ export function AppearanceChatContrastControls({ } + control={} /> @@ -52,7 +52,11 @@ function ChatContrastSlider({ contrast, onChange }: Pick & { contrast: number }): React.JSX.Element { - const [draft, setDraft] = useState(contrast) + const [draft, setDraft] = useState({ savedContrast: contrast, value: contrast }) + // Keep the thumb mounted so committed keyboard changes retain focus. + if (draft.savedContrast !== contrast) { + setDraft({ savedContrast: contrast, value: contrast }) + } return (
@@ -63,16 +67,18 @@ function ChatContrastSlider({ min={50} max={150} step={1} - value={[draft]} + value={[draft.value]} thumbLabels={[translate('settings.appearance.chat.contrast', 'Contrast')]} - onValueChange={([value]) => setDraft(value)} + onValueChange={([value]) => setDraft({ savedContrast: contrast, value })} onValueCommit={([value]) => onChange({ contrast: value })} />
{translate('settings.appearance.chat.sharper', 'Sharper')} - {draft} + + {draft.value} +
) } diff --git a/src/shared/native-chat-appearance-settings.test.ts b/src/shared/native-chat-appearance-settings.test.ts index c1fdf725793a..be29769e149f 100644 --- a/src/shared/native-chat-appearance-settings.test.ts +++ b/src/shared/native-chat-appearance-settings.test.ts @@ -23,6 +23,21 @@ describe('native chat appearance normalization', () => { expect(normalizeNativeChatAppearanceSettings(future)).toEqual(future) }) + it('preserves future fields without letting known defaults or malformed values survive', () => { + const future = { futureSetting: { nested: 'keep' } } + for (const contrast of [100, Number.NaN, Number.POSITIVE_INFINITY, '150', null]) { + for (const matchTerminalInterface of [false, 'true', null]) { + expect( + normalizeNativeChatAppearanceSettings({ + ...future, + contrast, + matchTerminalInterface + }) + ).toEqual(future) + } + } + }) + it('derives defaults without storing them', () => { expect( normalizeNativeChatAppearanceSettings({ diff --git a/src/shared/native-chat-appearance-settings.ts b/src/shared/native-chat-appearance-settings.ts index b01d4137bb76..585a96bb4024 100644 --- a/src/shared/native-chat-appearance-settings.ts +++ b/src/shared/native-chat-appearance-settings.ts @@ -45,7 +45,10 @@ export function normalizeNativeChatAppearanceSettings( 'width' in value && (value.width === 'wide' || value.width === 'full') ? value.width : undefined const normalized: NativeChatAppearanceSettings = { ...Object.fromEntries( - Object.entries(value).filter(([key]) => !['fontSize', 'codeFontSize', 'width', 'contrast', 'matchTerminalInterface'].includes(key)) + Object.entries(value).filter( + ([key]) => + !['fontSize', 'codeFontSize', 'width', 'contrast', 'matchTerminalInterface'].includes(key) + ) ), ...(fontSize !== DEFAULT_NATIVE_CHAT_FONT_SIZE ? { fontSize } : {}), ...(codeFontSize !== DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE ? { codeFontSize } : {}), From c96ae459d651e8b86b54753f959069d327727d64 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:21:55 -0700 Subject: [PATCH 04/16] Memoize chat appearance and narrow settings subscriptions --- .../native-chat/NativeChatResolvedView.tsx | 6 +- .../NativeChatStructuredSession.test.tsx | 43 +++++++ .../NativeChatStructuredSession.tsx | 6 +- .../native-chat-appearance-style.test.ts | 14 +++ .../native-chat-appearance-style.ts | 49 ++++++-- .../use-native-chat-appearance-style.test.tsx | 68 ++++++++++- .../use-native-chat-row-typography.test.tsx | 109 ++++++++++++++---- .../use-native-chat-row-typography.ts | 12 +- .../AppearanceChatContrastControls.tsx | 20 ++-- .../settings/chat-appearance-search.ts | 49 ++++---- 10 files changed, 300 insertions(+), 76 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index d6143dadd6d9..ceccc83231f8 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -1,6 +1,8 @@ +import { useShallow } from 'zustand/react/shallow' import { cn } from '@/lib/utils' import { NATIVE_CHAT_APPEARANCE_ROOT_CLASS, + selectNativeChatAppearanceSettings, useNativeChatAppearanceStyle } from './native-chat-appearance-style' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -315,7 +317,9 @@ export function NativeChatResolvedView({ // Only the focused conversation accepts chat text-size shortcuts. useNativeChatFontSize(isConversation && isVisible && isFocusedGroup, rootRef) - const appearanceSettings = useAppStore((state) => state.settings) + const appearanceSettings = useAppStore( + useShallow((state) => selectNativeChatAppearanceSettings(state.settings)) + ) const appearanceStyle = useNativeChatAppearanceStyle(appearanceSettings) return ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 1a6a4f141ee1..6eab1f7a74c6 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -1,3 +1,4 @@ +import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection' import { getDefaultSettings } from '../../../../shared/constants' // @vitest-environment happy-dom @@ -60,6 +61,8 @@ describe('NativeChatStructuredSession', () => { /> ) const root = container.querySelector('[data-native-chat-root]') + expect(root?.style.colorScheme).toBe('dark') + expect(root?.dataset.nativeChatScheme).toBe('dark') expect(root?.style.getPropertyValue('--chat-source-background')).toBe('#112233') expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('#ddeeff') expect(root?.style.getPropertyValue('--chat-code-font-family')).toContain('Consolas') @@ -77,6 +80,8 @@ describe('NativeChatStructuredSession', () => { } }) ) + expect(root?.style.colorScheme).toBe('') + expect(root?.dataset.nativeChatScheme).toBeUndefined() expect(root?.style.getPropertyValue('--chat-source-background')).toBe('') expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('') expect(root?.style.getPropertyValue('--chat-font-family')).toBe('') @@ -86,9 +91,47 @@ describe('NativeChatStructuredSession', () => { act(() => useAppStore.setState({ settings: original })) }) + it('skips appearance work on unrelated settings writes and updates the root for appearance changes', () => { + const original = useAppStore.getState().settings + const settings = { + ...getDefaultSettings('/tmp'), + theme: 'dark' as const, + nativeChatAppearance: { matchTerminalInterface: true } + } + useAppStore.setState({ settings }) + const resolveColors = vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors') + const view = render( + + ) + const initialResolutions = resolveColors.mock.calls.length + act(() => + useAppStore.setState({ + settings: { ...settings, terminalFontSize: settings.terminalFontSize + 1 } + }) + ) + expect(resolveColors).toHaveBeenCalledTimes(initialResolutions) + act(() => useAppStore.setState({ settings: { ...settings, terminalFontFamily: 'Menlo' } })) + expect(resolveColors).toHaveBeenCalledTimes(initialResolutions + 1) + expect( + view.container + .querySelector('[data-native-chat-root]') + ?.style.getPropertyValue('--chat-code-font-family') + ).toContain('Menlo') + view.unmount() + useAppStore.setState({ settings: original }) + }) + afterEach(() => { cleanup() resetStructuredSessionMocks() + vi.restoreAllMocks() }) it("gives the composer this pane shows the conversation's own draft, and Stop returns text there", () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 28d0ff903be5..dc05e058b943 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,6 +1,8 @@ +import { useShallow } from 'zustand/react/shallow' import { cn } from '@/lib/utils' import { NATIVE_CHAT_APPEARANCE_ROOT_CLASS, + selectNativeChatAppearanceSettings, useNativeChatAppearanceStyle } from './native-chat-appearance-style' import { useMemo, useRef, useState } from 'react' @@ -147,7 +149,9 @@ export function NativeChatStructuredSession( viewState.kind === 'ready' && props.isVisible && props.isFocusedGroup, rootRef ) - const appearanceSettings = useAppStore((state) => state.settings) + const appearanceSettings = useAppStore( + useShallow((state) => selectNativeChatAppearanceSettings(state.settings)) + ) const appearanceStyle = useNativeChatAppearanceStyle(appearanceSettings) const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts index 7f51fde84b13..fd4c2ab9f4f0 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts @@ -155,6 +155,20 @@ describe('chat root appearance style', () => { '--chat-estimated-chars-per-line': 96 }) }) + it.each(['light', 'dark', 'system'] as const)( + 'leaves native controls app-owned in %s mode when matching is off', + (theme) => { + for (const nativeChatAppearance of [ + undefined, + { matchTerminalInterface: false }, + { contrast: 150 } + ]) { + const style = nativeChatAppearanceStyle(makeSettings({ theme, nativeChatAppearance })) + expect(style.colorScheme).toBeUndefined() + } + } + ) + it('clamps sizes on read and derives the column width', () => { expect( nativeChatAppearanceStyle({ diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts index 50aa249a4bda..30c310454d1b 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts @@ -1,5 +1,6 @@ import './native-chat-appearance.css' -import type { CSSProperties } from 'react' +import { useMemo, type CSSProperties } from 'react' +import { useShallow } from 'zustand/react/shallow' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { resolveNativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' import { resolveConfiguredTerminalColors } from '../../../../shared/terminal-theme-selection' @@ -12,14 +13,15 @@ export const NATIVE_CHAT_TRANSCRIPT_OUTER_CLASS = 'px-3 pt-10 pb-4 sm:px-4' export const NATIVE_CHAT_TRANSCRIPT_COLUMN_CLASS = 'mx-auto flex w-full max-w-(--chat-content-max-width) flex-col gap-5 px-[5px]' -export type NativeChatAppearanceStyle = CSSProperties & Record<`--${string}`, string | number> & { - '--chat-font-size': string - '--chat-code-font-size': string - '--chat-content-max-width': string - '--chat-inline-code-ratio': string - '--chat-estimated-line-height': number - '--chat-estimated-chars-per-line': number -} +export type NativeChatAppearanceStyle = CSSProperties & + Record<`--${string}`, string | number> & { + '--chat-font-size': string + '--chat-code-font-size': string + '--chat-content-max-width': string + '--chat-inline-code-ratio': string + '--chat-estimated-line-height': number + '--chat-estimated-chars-per-line': number + } // Width buckets keep a pixel-by-pixel resize from re-deriving the entire transcript. export function nativeChatColumnWidthBucket(width: number | null | undefined): number | null { @@ -28,6 +30,21 @@ export function nativeChatColumnWidthBucket(width: number | null | undefined): n : null } +export function selectNativeChatAppearanceSettings( + settings: Partial | null | undefined +) { + return { + theme: settings?.theme, + terminalThemeDark: settings?.terminalThemeDark, + terminalThemeLight: settings?.terminalThemeLight, + terminalUseSeparateLightTheme: settings?.terminalUseSeparateLightTheme, + terminalCustomThemes: settings?.terminalCustomThemes, + terminalColorOverrides: settings?.terminalColorOverrides, + terminalFontFamily: settings?.terminalFontFamily, + nativeChatAppearance: settings?.nativeChatAppearance + } +} + export function nativeChatContrastMix(contrast: number, light: boolean): number { const value = Number.isFinite(contrast) ? Math.min(150, Math.max(50, contrast)) : 100 return light @@ -78,6 +95,7 @@ export function nativeChatAppearanceStyle( const background = hasThemeColors ? colors.background : '#000000' const foreground = hasThemeColors ? colors.foreground : '#fafafa' light = isTerminalBackgroundLight(background) + style.colorScheme = light ? 'light' : 'dark' Object.assign(style, { '--chat-font-family': font, '--background': 'var(--chat-canvas)', @@ -118,12 +136,19 @@ export function nativeChatAppearanceStyle( const bodyMix = nativeChatContrastMix(appearance.contrast, light) style['--chat-foreground-mix'] = `${bodyMix}%` style['--chat-strong-mix'] = `${Math.min(100, bodyMix + (light ? 10 : 12))}%` - style.colorScheme = light ? 'light' : 'dark' return style } export function useNativeChatAppearanceStyle( - settings: Partial | null | undefined + settings: Partial | null | undefined, + measuredColumnWidthPx?: number | null ): NativeChatAppearanceStyle { - return nativeChatAppearanceStyle(settings, undefined, useSystemPrefersDark()) + const selectInputs = useShallow(selectNativeChatAppearanceSettings) + const inputs = selectInputs(settings) + const systemPrefersDark = useSystemPrefersDark() + const measuredWidth = nativeChatColumnWidthBucket(measuredColumnWidthPx) + return useMemo( + () => nativeChatAppearanceStyle(inputs, measuredWidth, systemPrefersDark), + [inputs, measuredWidth, systemPrefersDark] + ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx index 6a85e4e1b566..de39f7749a84 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment happy-dom import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection' import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' import { resetSystemPrefersDarkSubscriptionForTests } from '../terminal-pane/use-system-prefers-dark' import { useNativeChatAppearanceStyle } from './native-chat-appearance-style' @@ -26,7 +28,7 @@ describe('shared chat appearance hook', () => { nativeChatAppearance: { matchTerminalInterface: true } }) const chat = renderHook(() => useNativeChatAppearanceStyle(settings)) - const preview = renderHook(() => useNativeChatAppearanceStyle(settings)) + const anotherChat = renderHook(() => useNativeChatAppearanceStyle(settings)) expect(addListener).toHaveBeenCalledTimes(1) expect(chat.result.current.colorScheme).toBe('dark') const darkBackground = chat.result.current['--chat-source-background'] @@ -37,10 +39,70 @@ describe('shared chat appearance hook', () => { expect(chat.result.current.colorScheme).toBe('light') expect(chat.result.current['--chat-source-background']).not.toBe(darkBackground) expect(chat.result.current['--chat-foreground-mix']).toBe('82%') - expect(preview.result.current).toEqual(chat.result.current) + expect(anotherChat.result.current).toEqual(chat.result.current) chat.unmount() expect(removeListener).not.toHaveBeenCalled() - preview.unmount() + anotherChat.unmount() expect(removeListener).toHaveBeenCalledTimes(1) }) + it('keeps the style and palette resolution stable across unrelated settings and render changes', () => { + const resolveColors = vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors') + const settings = createGlobalSettingsFixture({ + theme: 'dark', + nativeChatAppearance: { matchTerminalInterface: true }, + terminalFontFamily: 'Consolas' + }) + const { result, rerender } = renderHook( + ({ settings, width }: { settings: GlobalSettings; width?: number }) => + useNativeChatAppearanceStyle(settings, width), + { initialProps: { settings, width: 736 } } + ) + const style = result.current + expect(resolveColors).toHaveBeenCalledTimes(1) + rerender({ + settings: { ...settings, terminalFontSize: settings.terminalFontSize + 1 }, + width: 736 + }) + expect(result.current).toBe(style) + expect(resolveColors).toHaveBeenCalledTimes(1) + rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 736 }) + expect(result.current['--chat-code-font-family']).toContain('Menlo') + expect(resolveColors).toHaveBeenCalledTimes(2) + rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 384 }) + expect(result.current['--chat-estimated-chars-per-line']).toBe(50) + expect(resolveColors).toHaveBeenCalledTimes(3) + const narrower = result.current + rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 399 }) + expect(result.current).toBe(narrower) + expect(resolveColors).toHaveBeenCalledTimes(3) + }) + + it('invalidates the style for each appearance input', () => { + const settings = createGlobalSettingsFixture({ + theme: 'dark', + terminalThemeDark: 'Builtin Tango Dark', + terminalThemeLight: '', + nativeChatAppearance: { matchTerminalInterface: true } + }) + const updates: Partial[] = [ + { theme: 'light' }, + { terminalThemeDark: 'Builtin Tango Light' }, + { terminalThemeLight: 'Builtin Tango Light' }, + { terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme }, + { terminalCustomThemes: [] }, + { terminalColorOverrides: { background: '#122033', foreground: '#ddeeff' } }, + { terminalFontFamily: 'Menlo' }, + { nativeChatAppearance: { matchTerminalInterface: true, contrast: 120 } } + ] + const { result, rerender } = renderHook( + (settings: GlobalSettings) => useNativeChatAppearanceStyle(settings), + { initialProps: settings } + ) + for (const update of updates) { + rerender(settings) + const style = result.current + rerender({ ...settings, ...update }) + expect(result.current, Object.keys(update).join(',')).not.toBe(style) + } + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx index 91f61a11c5df..ca031caaa55e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx @@ -2,45 +2,106 @@ import { useMemo, useRef } from 'react' import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { useAppStore } from '../../store' +import { resetSystemPrefersDarkSubscriptionForTests } from '../terminal-pane/use-system-prefers-dark' import { useNativeChatRowTypography } from './use-native-chat-row-typography' -const measurements = vi.hoisted((): { onWidth?: (width: number | null) => void } => ({})) -vi.mock('../right-sidebar/right-sidebar-measured-width', () => ({ - useMeasuredWidth: (onWidth: (width: number | null) => void) => { - measurements.onWidth = onWidth - return () => undefined - } -})) -vi.mock('../../store', () => ({ - useAppStore: (selector: (state: { settings: object }) => unknown) => selector({ settings: {} }) -})) -afterEach(cleanup) +const originalSettings = useAppStore.getState().settings + +afterEach(() => { + cleanup() + useAppStore.setState({ settings: originalSettings }) + resetSystemPrefersDarkSubscriptionForTests() + vi.restoreAllMocks() +}) + +describe('memoized transcript row typography', () => { + it('does no theme work on message renders or unrelated settings writes and follows text size and measured width', () => { + const settings = createGlobalSettingsFixture({ + theme: 'dark', + nativeChatAppearance: { matchTerminalInterface: true } + }) + useAppStore.setState({ settings }) + const resolveColors = vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors') + const matchMedia = vi.spyOn(window, 'matchMedia') + const rendered = vi.fn() + const { result, rerender } = renderHook(() => { + rendered() + return useNativeChatRowTypography(useRef(null)) + }) + const initial = result.current.typography + expect(resolveColors).toHaveBeenCalledTimes(1) + const initialMediaReads = matchMedia.mock.calls.length + rerender() + rerender() + expect(result.current.typography).toBe(initial) + expect(resolveColors).toHaveBeenCalledTimes(1) + expect(matchMedia).toHaveBeenCalledTimes(initialMediaReads) + const renders = rendered.mock.calls.length + act(() => + useAppStore.setState({ + settings: { ...settings, terminalFontSize: settings.terminalFontSize + 1 } + }) + ) + expect(rendered).toHaveBeenCalledTimes(renders) + expect(resolveColors).toHaveBeenCalledTimes(1) + act(() => + useAppStore.setState({ + settings: { + ...settings, + nativeChatAppearance: { matchTerminalInterface: true, fontSize: 20 } + } + }) + ) + expect(result.current.typography.lineHeightPx).toBe((22 * 20) / 14) + expect(resolveColors).toHaveBeenCalledTimes(2) + const node = document.createElement('div') + vi.spyOn(node, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ width: 384 })) + act(() => result.current.measureContent(node)) + expect(result.current.typography.charsPerLine).toBe(35) + expect(resolveColors).toHaveBeenCalledTimes(3) + const measured = result.current.typography + vi.mocked(node.getBoundingClientRect).mockReturnValue(DOMRect.fromRect({ width: 399 })) + act(() => result.current.measureContent(node)) + expect(result.current.typography).toBe(measured) + expect(resolveColors).toHaveBeenCalledTimes(3) + act(() => result.current.measureContent(null)) + }) -describe('chat row typography during resizing', () => { it('preserves the slot memo on default-width mount and pixel resizes, then refreshes for a new bucket', () => { + useAppStore.setState({ settings: createGlobalSettingsFixture() }) let slotBuilds = 0 const { result } = renderHook(() => { const ref = useRef(null) - const { typography } = useNativeChatRowTypography(ref) + const { typography, measureContent } = useNativeChatRowTypography(ref) const slots = useMemo(() => { slotBuilds += 1 return { typography } }, [typography]) - return slots + return { slots, measureContent } }) - const initial = result.current - act(() => measurements.onWidth?.(736)) - expect(result.current).toBe(initial) + const node = document.createElement('div') + const bounds = vi.spyOn(node, 'getBoundingClientRect') + const measure = (width: number) => { + bounds.mockReturnValue(DOMRect.fromRect({ width })) + act(() => result.current.measureContent(node)) + } + const initial = result.current.slots + measure(736) + expect(result.current.slots).toBe(initial) expect(slotBuilds).toBe(1) - act(() => measurements.onWidth?.(735)) - const narrower = result.current - act(() => measurements.onWidth?.(734)) - act(() => measurements.onWidth?.(733)) - expect(result.current).toBe(narrower) + measure(735) + const narrower = result.current.slots + measure(734) + measure(733) + expect(result.current.slots).toBe(narrower) expect(slotBuilds).toBe(2) expect(narrower).not.toBe(initial) - act(() => measurements.onWidth?.(672)) + measure(672) expect(slotBuilds).toBe(3) - expect(result.current).not.toBe(narrower) + expect(result.current.slots).not.toBe(narrower) + act(() => result.current.measureContent(null)) }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-row-typography.ts b/src/renderer/src/components/native-chat/use-native-chat-row-typography.ts index cff700d8740f..cd7e62244fe6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-row-typography.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-row-typography.ts @@ -1,13 +1,17 @@ import { useCallback, useMemo, useState, type RefObject } from 'react' import { useMeasuredWidth } from '../right-sidebar/right-sidebar-measured-width' +import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import { - nativeChatAppearanceStyle, - nativeChatColumnWidthBucket + nativeChatColumnWidthBucket, + selectNativeChatAppearanceSettings, + useNativeChatAppearanceStyle } from './native-chat-appearance-style' export function useNativeChatRowTypography(contentRef: RefObject) { - const appearance = useAppStore((state) => state.settings?.nativeChatAppearance) + const appearanceSettings = useAppStore( + useShallow((state) => selectNativeChatAppearanceSettings(state.settings)) + ) const [columnWidthPx, setColumnWidthPx] = useState(null) const commitWidth = useCallback((width: number | null) => { setColumnWidthPx(nativeChatColumnWidthBucket(width)) @@ -20,7 +24,7 @@ export function useNativeChatRowTypography(contentRef: RefObject ({ lineHeightPx, charsPerLine }), [lineHeightPx, charsPerLine]) diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx index e8ace9d6b27e..f2338f35ea57 100644 --- a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import type { NativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' -import { getChatContrastSearchEntries } from './chat-appearance-search' +import { getChatContrastEntriesByKey } from './chat-appearance-search' import { translate } from '@/i18n/i18n' import { Slider } from '../ui/slider' import { SearchableSetting } from './SearchableSetting' @@ -17,30 +17,30 @@ export function AppearanceChatContrastControls({ onChange, forceVisiblePrimary }: ChatContrastControlsProps): React.JSX.Element { - const entries = getChatContrastSearchEntries() + const entries = getChatContrastEntriesByKey() const { contrast, matchTerminalInterface: matching } = appearance return ( <> onChange({ matchTerminalInterface: !matching })} /> } /> diff --git a/src/renderer/src/components/settings/chat-appearance-search.ts b/src/renderer/src/components/settings/chat-appearance-search.ts index dba42d60e3d8..99a54855a69d 100644 --- a/src/renderer/src/components/settings/chat-appearance-search.ts +++ b/src/renderer/src/components/settings/chat-appearance-search.ts @@ -3,25 +3,34 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog' import type { SettingsSearchEntry } from './settings-search' import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' -export const getChatContrastSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ - { - title: translate('settings.appearance.chat.matchTerminalInterface', 'Match terminal interface'), - description: translate( - 'settings.appearance.chat.matchTerminalInterfaceDescription', - "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later." - ), - targetSectionId: 'chat-match-terminal-interface' - }, - { - title: translate('settings.appearance.chat.contrast', 'Contrast'), - description: translate( - 'settings.appearance.chat.contrastDescription', - 'How bright chat text is against the background. The right end matches the older, brighter look.' - ), - targetSectionId: 'chat-contrast' - } -]) +export const getChatContrastEntriesByKey = createLocalizedCatalog( + () => + ({ + matchTerminalInterface: { + title: translate( + 'settings.appearance.chat.matchTerminalInterface', + 'Match terminal interface' + ), + description: translate( + 'settings.appearance.chat.matchTerminalInterfaceDescription', + "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later." + ), + targetSectionId: 'chat-match-terminal-interface' + }, + contrast: { + title: translate('settings.appearance.chat.contrast', 'Contrast'), + description: translate( + 'settings.appearance.chat.contrastDescription', + 'How bright chat text is against the background. The right end matches the older, brighter look.' + ), + targetSectionId: 'chat-contrast' + } + }) satisfies Record +) +export function getChatContrastSearchEntries(): SettingsSearchEntry[] { + return Object.values(getChatContrastEntriesByKey()) +} const getChatAppearanceCatalog = createLocalizedCatalog( () => @@ -83,9 +92,7 @@ export function getChatAppearanceEntriesByKey(shortcuts?: { increase: string; de export function getChatAppearanceSearchEntries(): SettingsSearchEntry[] { return [ - { - title: translate('auto.components.settings.Settings.2b4474780a', 'Appearance') - }, + { title: translate('auto.components.settings.Settings.2b4474780a', 'Appearance') }, ...Object.values(getChatAppearanceEntriesByKey()), ...getChatContrastSearchEntries() ] From ad1f3a6b4b8a42610828417e8aaa13f9bec90d58 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:30:41 -0700 Subject: [PATCH 05/16] Wrap native chat tool-run summaries in the terminal font --- .../NativeChatToolRun.appearance.test.tsx | 43 +++++++++++++++++++ .../native-chat/NativeChatToolRun.test.tsx | 2 +- .../native-chat/NativeChatToolRun.tsx | 10 ++--- 3 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx new file mode 100644 index 000000000000..09ae546527b4 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatBlock } from '../../../../shared/native-chat-types' +import { NativeChatToolRun } from './NativeChatToolRun' +import { nativeChatAppearanceStyle } from './native-chat-appearance-style' + +afterEach(cleanup) + +const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: 'Bash', input: { command: 'test' }, state: 'failed' }, + { type: 'tool-result', output: 'exit 1', isError: true }, + { type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' }, + { type: 'tool-call', name: 'Write', input: { file_path: 'a.ts' }, state: 'completed' }, + { type: 'tool-call', name: 'Grep', input: { pattern: 'todo' }, state: 'completed' }, + { type: 'tool-call', name: 'Task', input: { description: 'Review' }, state: 'completed' } +] + +describe('tool-run summary in a matching chat', () => { + it.each([false, true])('allows the full summary to wrap, live=%s', (live) => { + const style = nativeChatAppearanceStyle({ + terminalFontFamily: 'Menlo', + nativeChatAppearance: { matchTerminalInterface: true } + }) + const { container } = render( +
+
+ +
+
+ ) + expect(style['--chat-content-max-width']).toBe('46rem') + expect(style['--chat-font-family']).toContain('Menlo') + const summary = container.querySelector('span.native-chat-message-text') + expect(summary).toHaveTextContent(live ? 'running 1 agent' : 'ran 1 agent') + expect(summary).toHaveClass('min-w-0', 'whitespace-normal', 'break-words') + expect(summary).not.toHaveClass('truncate', 'whitespace-nowrap', 'overflow-hidden', 'font-mono') + const failure = container.querySelector('[aria-label="Failed tool calls: 1"]') + expect(failure).toHaveTextContent('1 failed') + expect(failure).toHaveClass('shrink-0') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index 1a614ed2337e..03d6da18f641 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -320,7 +320,7 @@ describe('NativeChatToolRun', () => { it('keeps the run in the transcript type, not a monospace dump', () => { const { container } = render() - const label = runHeader(container).querySelector('span.truncate') + const label = runHeader(container).querySelector('span.native-chat-message-text') expect(label).toHaveClass('text-sm') expect(label).not.toHaveClass('font-mono') }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 62a591d094ad..bdbf5c33f210 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -51,8 +51,7 @@ import { NativeChatToolRunCallCounts } from './NativeChatToolRunCallCounts' const NO_SUBAGENT_GROUPS: NativeChatSubagentGroupBlock[] = [] const NO_BACKGROUND_TASKS: NativeChatBackgroundTaskBlock[] = [] -/** A run of a message's tool calls/results, collapsed to a one-line summary that - * expands to the individual inline tool lines. */ +/** A run of tool calls/results with a summary that expands to the individual tool lines. */ export function NativeChatToolRun({ blocks, previousTodoWrite, @@ -268,13 +267,10 @@ export function NativeChatToolRun({ className="text-chat-foreground-faint" /> ) : null} - {/* The run in words, in the transcript's own type. Present tense while - live, past once settled; the text changes in place and nothing - around it moves. While live it keeps its width and the preview - beside it is what gives way. */} + {/* Wrap the summary in its inherited font; only the latest-call preview may truncate. */} Date: Mon, 5 Oct 2026 15:31:52 -0700 Subject: [PATCH 06/16] Use the matched chat foreground for inherited controls --- .../native-chat/NativeChatStructuredSession.test.tsx | 2 ++ .../native-chat/native-chat-appearance-palette.test.ts | 5 +++++ .../native-chat/native-chat-appearance-style.test.ts | 1 + .../components/native-chat/native-chat-appearance-style.ts | 2 ++ 4 files changed, 10 insertions(+) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 6eab1f7a74c6..0570a6828144 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -62,6 +62,7 @@ describe('NativeChatStructuredSession', () => { ) const root = container.querySelector('[data-native-chat-root]') expect(root?.style.colorScheme).toBe('dark') + expect(root?.style.color).toBe('var(--foreground)') expect(root?.dataset.nativeChatScheme).toBe('dark') expect(root?.style.getPropertyValue('--chat-source-background')).toBe('#112233') expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('#ddeeff') @@ -81,6 +82,7 @@ describe('NativeChatStructuredSession', () => { }) ) expect(root?.style.colorScheme).toBe('') + expect(root?.style.color).toBe('') expect(root?.dataset.nativeChatScheme).toBeUndefined() expect(root?.style.getPropertyValue('--chat-source-background')).toBe('') expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('') diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts index 0f7fe00e1b76..f4a725819d83 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts @@ -40,6 +40,7 @@ function createChat(appScheme: 'light' | 'dark', terminalScheme: 'light' | 'dark }) ) chat.dataset.nativeChatScheme = style.colorScheme + chat.style.color = style.color ?? '' for (const [key, value] of Object.entries(style)) { if (key.startsWith('--')) { chat.style.setProperty(key, String(value)) @@ -69,6 +70,10 @@ describe('matching chat surfaces with opposite app schemes', () => { const chat = createChat(appScheme, terminalScheme) // These roles cover transcript payloads, question badges, inline completion menus and controls. const matched = getComputedStyle(chat) + expect(chat.style.color).toBe('var(--foreground)') + expect(matched.getPropertyValue('--foreground')).toContain( + terminalScheme === 'light' ? '92%' : '90%' + ) expect(matched.getPropertyValue('--muted')).toContain('7%') expect(matched.getPropertyValue('--muted-foreground')).toContain('62%') expect(matched.getPropertyValue('--popover')).toContain('4%') diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts index fd4c2ab9f4f0..5bbc530ba609 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts @@ -165,6 +165,7 @@ describe('chat root appearance style', () => { ]) { const style = nativeChatAppearanceStyle(makeSettings({ theme, nativeChatAppearance })) expect(style.colorScheme).toBeUndefined() + expect(style.color).toBeUndefined() } } ) diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts index 30c310454d1b..63202694e7c0 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts @@ -96,6 +96,8 @@ export function nativeChatAppearanceStyle( const foreground = hasThemeColors ? colors.foreground : '#fafafa' light = isTerminalBackgroundLight(background) style.colorScheme = light ? 'light' : 'dark' + // Ghost controls inherit a color, so remapping the token alone leaves the app's color in place. + style.color = 'var(--foreground)' Object.assign(style, { '--chat-font-family': font, '--background': 'var(--chat-canvas)', From 7948d26212c331b21ccc642cecc44cc08de73a3c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:57:29 -0700 Subject: [PATCH 07/16] fix(native-chat): bound collapsed tool summaries --- .../native-chat/NativeChatToolLine.tsx | 5 ++- .../NativeChatToolRun.appearance.test.tsx | 34 ++++++++++++++++--- .../native-chat/NativeChatToolRun.tsx | 4 +-- .../native-chat-row-height-estimate.test.ts | 31 +++++++++++++++++ .../native-chat-row-height-estimate.ts | 5 +-- 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx index ae8273e7bc77..015e20aebedf 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx @@ -18,7 +18,10 @@ import { NativeChatDiffView } from './NativeChatDiffView' import { nativeChatToolLineLabel } from './native-chat-tool-line-label' import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff' import { useNativeChatDisclosure } from './native-chat-disclosure-store' -import { createToolInputDisplay, truncateToolDetail } from './native-chat-tool-summary' +import { + createToolInputDisplay, + truncateToolDetail +} from './native-chat-tool-summary' /** A tool sentence with input and output behind its own remembered disclosure. */ export function NativeChatToolLine({ diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx index 09ae546527b4..1190f62c1fdf 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { cleanup, render } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import type { NativeChatBlock } from '../../../../shared/native-chat-types' import { NativeChatToolRun } from './NativeChatToolRun' @@ -18,7 +18,7 @@ const blocks: NativeChatBlock[] = [ ] describe('tool-run summary in a matching chat', () => { - it.each([false, true])('allows the full summary to wrap, live=%s', (live) => { + it.each([false, true])('caps the summary at two wrapped lines, live=%s', (live) => { const style = nativeChatAppearanceStyle({ terminalFontFamily: 'Menlo', nativeChatAppearance: { matchTerminalInterface: true } @@ -34,10 +34,36 @@ describe('tool-run summary in a matching chat', () => { expect(style['--chat-font-family']).toContain('Menlo') const summary = container.querySelector('span.native-chat-message-text') expect(summary).toHaveTextContent(live ? 'running 1 agent' : 'ran 1 agent') - expect(summary).toHaveClass('min-w-0', 'whitespace-normal', 'break-words') - expect(summary).not.toHaveClass('truncate', 'whitespace-nowrap', 'overflow-hidden', 'font-mono') + expect(summary).toHaveClass('min-w-0', 'line-clamp-2', 'whitespace-normal', 'break-words') + expect(summary).not.toHaveClass('truncate', 'whitespace-nowrap', 'font-mono') const failure = container.querySelector('[aria-label="Failed tool calls: 1"]') expect(failure).toHaveTextContent('1 failed') expect(failure).toHaveClass('shrink-0') }) + + it('keeps a long command available in the expanded detail', () => { + const command = `printf ${'x'.repeat(5000)}` + const style = nativeChatAppearanceStyle({ + terminalFontFamily: 'Menlo', + nativeChatAppearance: { matchTerminalInterface: true } + }) + const { container } = render( +
+ +
+ ) + + const summary = container.querySelector('span.native-chat-message-text') + expect(summary).toHaveClass('line-clamp-2', 'break-words') + expect(summary?.textContent).toContain('printf') + expect(summary?.textContent?.length).toBeLessThan(command.length) + expect(container.querySelector('pre')).toBeNull() + + fireEvent.click(screen.getByRole('button')) + + expect(container.querySelector('pre')).toHaveTextContent(command) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index bdbf5c33f210..4fe24b5102f3 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -267,10 +267,10 @@ export function NativeChatToolRun({ className="text-chat-foreground-faint" /> ) : null} - {/* Wrap the summary in its inherited font; only the latest-call preview may truncate. */} + {/* Keep the collapsed header bounded; the tool detail carries the full command. */} { estimateNativeChatRowHeight(prose, NO_CHROME) ) }) + + it('reserves a bounded two-line tool header across supported text sizes', () => { + const estimates = [12, 14, 20].map((fontSize) => { + const style = nativeChatAppearanceStyle({ nativeChatAppearance: { fontSize } }) + const typography = { + lineHeightPx: style['--chat-estimated-line-height'], + charsPerLine: style['--chat-estimated-chars-per-line'] + } + const heightFor = (command: string): number => + estimateNativeChatRowHeight( + nativeChatRowContentMetrics({ + id: command, + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'shell', input: { command }, state: 'completed' }], + timestamp: 1, + source: 'transcript' + }), + NO_CHROME, + typography + ) + const estimate = heightFor('x'.repeat(5000)) + + expect(estimate).toBe(heightFor('ls')) + expect(estimate).toBeGreaterThan(2 * typography.lineHeightPx) + expect(estimate).toBeLessThan(2 * typography.lineHeightPx + 24) + return estimate + }) + + expect(estimates[0]).toBeLessThan(estimates[1]) + expect(estimates[1]).toBeLessThan(estimates[2]) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts index ca60ed38e0e1..ec296c021e91 100644 --- a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts +++ b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts @@ -41,7 +41,8 @@ const DEFAULT_ROW_TYPOGRAPHY: NativeChatRowTypography = { lineHeightPx: 22, char const PROSE_MIN_LINES = 1 const USER_BUBBLE_CHROME_PX = 32 const IMAGE_STRIP_PX = 88 -const TOOL_RUN_PX = 40 +/** Header margin and vertical padding; its summary can occupy two text lines. */ +const TOOL_RUN_CHROME_PX = 16 const SUBAGENT_ROW_PX = 32 /** The one-line head that names a subagent above its own rows. */ export const NATIVE_CHAT_SUBAGENT_SECTION_HEAD_PX = SUBAGENT_ROW_PX @@ -138,7 +139,7 @@ export function estimateNativeChatRowHeight( } if (content.toolCount > 0) { // A run is one collapsed header by default; its members only exist while open. - height += TOOL_RUN_PX + height += TOOL_RUN_CHROME_PX + 2 * typography.lineHeightPx } height += content.subagentGroupCount * SUBAGENT_ROW_PX partCount = height > 0 ? 1 : 0 From 7261be0931cf9460b50076da08cb402241ae34ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:14:45 -0700 Subject: [PATCH 08/16] Restore code foreground token for matched chat themes --- src/renderer/src/assets/main.css | 1 + .../native-chat/native-chat-appearance-palette.test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 63bb92dff61b..629d711e93cc 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -577,6 +577,7 @@ body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) { var(--chat-source-foreground) var(--chat-user-border-mix), transparent ); + --chat-code-foreground: color-mix(in srgb, var(--chat-source-foreground) 85%, var(--chat-canvas)); --chat-code-surface: color-mix( in srgb, var(--chat-source-foreground) var(--chat-code-mix), diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts index f4a725819d83..a7aaf2d109e2 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts @@ -90,6 +90,10 @@ describe('matching chat surfaces with opposite app schemes', () => { expect(matched.getPropertyValue('--chat-source-foreground')).toBe( terminalScheme === 'light' ? '#000000' : '#ffffff' ) + const codeForeground = matched.getPropertyValue('--chat-code-foreground') + expect(codeForeground).toContain(`${terminalScheme === 'light' ? '#000000' : '#ffffff'} 85%`) + expect(codeForeground).toContain(terminalScheme === 'light' ? '#ffffff' : '#000000') + expect(codeForeground).not.toContain(appScheme === 'dark' ? '#fafafa' : '#0a0a0a') const outside = getComputedStyle(document.documentElement) const appStatus = statusRoles.map((role) => outside.getPropertyValue(role)) const chatStatus = statusRoles.map((role) => matched.getPropertyValue(role)) From 17cc3046317b74af66668accca18d27897750e5b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:20:44 -0700 Subject: [PATCH 09/16] Reset all chat appearance controls through serialized writer --- .../native-chat-appearance-write.test.ts | 5 +- .../AppearanceChatContrastControls.test.tsx | 75 ++++++++++++------- .../settings/AppearanceChatSection.test.tsx | 6 +- .../native-chat-appearance-settings.test.ts | 8 +- src/shared/native-chat-appearance-settings.ts | 3 +- 5 files changed, 60 insertions(+), 37 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts index ba3271c7df2b..3b26fdb42274 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts @@ -35,7 +35,7 @@ describe('chat appearance write queue', () => { mock.state.settings = { ...mock.state.settings!, ...updates } }) mock.state.updateSettings = updateSettings - const fromNewerVersion = { fontSize: 14, contrast: 151 } + const fromNewerVersion = { fontSize: 14, contrast: 151, futureSetting: 'keep' } mock.state.settings = { ...getDefaultSettings('/tmp'), nativeChatAppearance: fromNewerVersion @@ -49,7 +49,8 @@ describe('chat appearance write queue', () => { expect(updateSettings).toHaveBeenCalledTimes(2) expect(mock.state.settings?.nativeChatAppearance).toEqual({ - contrast: 151, + contrast: 150, + futureSetting: 'keep', codeFontSize: 16, fontSize: 15 }) diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx index 0e28f80eead8..94beb4dcbd2b 100644 --- a/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx @@ -1,55 +1,72 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' import { AppearanceChatSection } from './AppearanceChatSection' +const mock = vi.hoisted( + (): { state: { settingsSearchQuery: string; settings: GlobalSettings | null } } => ({ + state: { settingsSearchQuery: '', settings: null } + }) +) + vi.mock('../../store', () => ({ - useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) => - selector({ settingsSearchQuery: '' }) + useAppStore: Object.assign( + (selector: (state: typeof mock.state) => unknown) => selector(mock.state), + { getState: () => mock.state } + ) })) +beforeEach(() => { + mock.state.settings = null +}) afterEach(cleanup) +function persistInMock(settings: GlobalSettings) { + mock.state.settings = settings + return vi.fn(async (updates: Partial) => { + mock.state.settings = { ...mock.state.settings!, ...updates } + }) +} + describe('chat contrast controls', () => { - it('renders a switch and saves the opt-in choice', () => { - const updateSettings = vi.fn() - render( - - ) + it('renders a switch and saves the opt-in choice', async () => { + const settings = createGlobalSettingsFixture() + const updateSettings = persistInMock(settings) + render() fireEvent.click(screen.getByRole('switch', { name: 'Match terminal interface' })) - expect(updateSettings).toHaveBeenCalledWith({ - nativeChatAppearance: { matchTerminalInterface: true } - }) + await waitFor(() => + expect(updateSettings).toHaveBeenCalledWith({ + nativeChatAppearance: { matchTerminalInterface: true } + }) + ) }) - it('shows the clamped contrast and supports slider keyboard input', () => { - const updateSettings = vi.fn() - render( - - ) + it('shows the clamped contrast and supports slider keyboard input', async () => { + const settings = createGlobalSettingsFixture({ nativeChatAppearance: { contrast: 999 } }) + const updateSettings = persistInMock(settings) + render() const slider = screen.getByRole('slider', { name: 'Contrast' }) expect(slider.getAttribute('aria-valuenow')).toBe('150') fireEvent.keyDown(slider, { key: 'ArrowLeft' }) - expect(updateSettings).toHaveBeenCalledWith({ nativeChatAppearance: { contrast: 149 } }) + await waitFor(() => + expect(updateSettings).toHaveBeenCalledWith({ nativeChatAppearance: { contrast: 149 } }) + ) expect(screen.getByText('Softer')).toBeTruthy() expect(screen.getByText('Sharper')).toBeTruthy() }) - it('keeps keyboard focus when a committed value reaches settings', () => { - const updateSettings = vi.fn() + it('keeps keyboard focus when a committed value reaches settings', async () => { const settings = createGlobalSettingsFixture({ nativeChatAppearance: { contrast: 120 } }) + const updateSettings = persistInMock(settings) const { rerender } = render( ) const slider = screen.getByRole('slider', { name: 'Contrast' }) slider.focus() fireEvent.keyDown(slider, { key: 'ArrowLeft' }) - expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { contrast: 119 } }) + await waitFor(() => + expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { contrast: 119 } }) + ) rerender( { ) expect(document.activeElement).toBe(slider) fireEvent.keyDown(slider, { key: 'ArrowLeft' }) - expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { contrast: 118 } }) + await waitFor(() => + expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { contrast: 118 } }) + ) }) }) diff --git a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx index 740a3a93b442..db32dd04b63d 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx @@ -132,7 +132,9 @@ describe('chat appearance settings controls', () => { fontSize: 18, codeFontSize: 16, width: 'wide' as const, - contrast: 151 + contrast: 130, + matchTerminalInterface: true, + futureSetting: 'keep' } } const updateSettings = persistInMock(settings) @@ -142,7 +144,7 @@ describe('chat appearance settings controls', () => { fireEvent.blur(text) fireEvent.click(screen.getByRole('button', { name: 'Reset' })) await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(2)) - expect(mocks.state.settings?.nativeChatAppearance).toEqual({ contrast: 151 }) + expect(mocks.state.settings?.nativeChatAppearance).toEqual({ futureSetting: 'keep' }) }) it('indexes each row and width choice in Chat settings search', () => { const entries = getChatAppearanceSearchEntries() diff --git a/src/shared/native-chat-appearance-settings.test.ts b/src/shared/native-chat-appearance-settings.test.ts index be29769e149f..5e26ccc6e5b9 100644 --- a/src/shared/native-chat-appearance-settings.test.ts +++ b/src/shared/native-chat-appearance-settings.test.ts @@ -54,17 +54,17 @@ describe('native chat appearance normalization', () => { matchTerminalInterface: false }) }) - it('resets only known controls while keeping settings from a newer version', () => { + it('resets all five known controls while keeping settings from a newer version', () => { const fromNewerVersion = { fontSize: 18, codeFontSize: 16, width: 'wide' as const, contrast: 151, - matchTerminalInterface: true + matchTerminalInterface: true, + futureSetting: { nested: 'keep' } } expect(resetNativeChatAppearanceSettings(fromNewerVersion)).toEqual({ - contrast: 151, - matchTerminalInterface: true + futureSetting: { nested: 'keep' } }) expect(resetNativeChatAppearanceSettings({ fontSize: 18 })).toBeUndefined() }) diff --git a/src/shared/native-chat-appearance-settings.ts b/src/shared/native-chat-appearance-settings.ts index 585a96bb4024..44dec0b64ba1 100644 --- a/src/shared/native-chat-appearance-settings.ts +++ b/src/shared/native-chat-appearance-settings.ts @@ -78,7 +78,8 @@ export function resetNativeChatAppearanceSettings( return normalizeNativeChatAppearanceSettings( Object.fromEntries( Object.entries(appearance ?? {}).filter( - ([key]) => key !== 'fontSize' && key !== 'codeFontSize' && key !== 'width' + ([key]) => + !['fontSize', 'codeFontSize', 'width', 'contrast', 'matchTerminalInterface'].includes(key) ) ) ) From 2864697970654eb37f54805713c778d45ec5332f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:55:27 -0700 Subject: [PATCH 10/16] Make terminal matching control chat size and contrast --- .../NativeChatStructuredSession.test.tsx | 16 ++++-- .../native-chat-appearance-style.test.ts | 40 +++++++++++-- .../native-chat-appearance-style.ts | 13 ++++- .../native-chat-appearance-write.test.ts | 31 ++++++++++ .../native-chat/native-chat-font-size.ts | 3 + .../use-native-chat-appearance-style.test.tsx | 25 ++++++-- .../use-native-chat-font-size.test.tsx | 39 +++++++++---- .../use-native-chat-row-typography.test.tsx | 20 ++++--- .../AppearanceChatContrastControls.tsx | 17 ++++-- .../settings/AppearanceChatSection.test.tsx | 57 ++++++++++++++++++- .../settings/AppearanceChatSection.tsx | 17 ++++-- .../settings/SettingsFormControls.tsx | 8 ++- .../settings/chat-appearance-search.ts | 4 ++ src/renderer/src/hooks/zoom-routing.test.ts | 15 +++++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 20 files changed, 266 insertions(+), 57 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 0570a6828144..9fbf94602b32 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -47,6 +47,7 @@ describe('NativeChatStructuredSession', () => { contrast: 120 }, terminalFontFamily: 'Consolas', + terminalFontSize: 16, terminalColorOverrides: { background: '#112233', foreground: '#ddeeff' } } }) @@ -68,9 +69,9 @@ describe('NativeChatStructuredSession', () => { expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('#ddeeff') expect(root?.style.getPropertyValue('--chat-code-font-family')).toContain('Consolas') expect(root?.style.getPropertyValue('--chat-font-family')).toContain('Consolas') - expect(root?.style.getPropertyValue('--chat-foreground-mix')).toBe('86.8%') - expect(root?.style.getPropertyValue('--chat-font-size')).toBe('18px') - expect(root?.style.getPropertyValue('--chat-code-font-size')).toBe('11px') + expect(root?.style.getPropertyValue('--chat-foreground-mix')).toBe('100%') + expect(root?.style.getPropertyValue('--chat-font-size')).toBe('16px') + expect(root?.style.getPropertyValue('--chat-code-font-size')).toBe('16px') expect(root?.style.getPropertyValue('--chat-content-max-width')).toBe('60rem') act(() => useAppStore.setState({ @@ -118,9 +119,14 @@ describe('NativeChatStructuredSession', () => { settings: { ...settings, terminalFontSize: settings.terminalFontSize + 1 } }) ) - expect(resolveColors).toHaveBeenCalledTimes(initialResolutions) - act(() => useAppStore.setState({ settings: { ...settings, terminalFontFamily: 'Menlo' } })) expect(resolveColors).toHaveBeenCalledTimes(initialResolutions + 1) + expect( + view.container + .querySelector('[data-native-chat-root]') + ?.style.getPropertyValue('--chat-font-size') + ).toBe(`${settings.terminalFontSize + 1}px`) + act(() => useAppStore.setState({ settings: { ...settings, terminalFontFamily: 'Menlo' } })) + expect(resolveColors).toHaveBeenCalledTimes(initialResolutions + 2) expect( view.container .querySelector('[data-native-chat-root]') diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts index 5bbc530ba609..bc87947021ad 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.test.ts @@ -43,7 +43,7 @@ describe('nativeChatAppearanceStyle terminal interface', () => { const style = nativeChatAppearanceStyle(settings) expect(style['--chat-font-family']).toBe(buildFontFamily(terminalFontFamily)) expect(style['--chat-code-font-family']).toBe(buildFontFamily(terminalFontFamily)) - expect(style['--chat-code-font-size']).toBe('12px') + expect(style['--chat-code-font-size']).toBe(`${settings.terminalFontSize}px`) } }) @@ -92,9 +92,9 @@ describe('nativeChatAppearanceStyle terminal interface', () => { const colors = resolveConfiguredTerminalColors(settings, true) expect(style['--chat-source-background']).toBe(colors.background) expect(style['--chat-source-foreground']).toBe(colors.foreground) - expect(style['--chat-foreground-mix']).toBe('82%') + expect(style['--chat-foreground-mix']).toBe('100%') expect(style['--chat-code-base']).toBe('var(--chat-canvas)') - expect(style['--chat-strong-mix']).toBe('92%') + expect(style['--chat-strong-mix']).toBe('100%') }) it('follows the separate light theme and system theme changes', () => { @@ -110,7 +110,7 @@ describe('nativeChatAppearanceStyle terminal interface', () => { resolveConfiguredTerminalColors(settings, false).background ) expect(light['--chat-source-background']).not.toBe(dark['--chat-source-background']) - expect(light['--chat-foreground-mix']).toBe('82%') + expect(light['--chat-foreground-mix']).toBe('100%') }) it('uses app light contrast without matching and removes matching overrides when off', () => { @@ -124,6 +124,38 @@ describe('nativeChatAppearanceStyle terminal interface', () => { expect(style['--chat-font-family']).toBeUndefined() expect(style['--chat-canvas-mix']).toBeUndefined() }) + + it('uses live terminal size and full foreground while matching, then restores saved chat sizes and contrast', () => { + const settings = makeSettings({ + terminalFontSize: 17, + nativeChatAppearance: { + fontSize: 20, + codeFontSize: 10, + contrast: 50, + matchTerminalInterface: true + } + }) + const matched = nativeChatAppearanceStyle(settings, 384) + expect(matched).toMatchObject({ + '--chat-font-size': '17px', + '--chat-code-font-size': '17px', + '--chat-inline-code-ratio': '1em', + '--chat-foreground-mix': '100%', + '--chat-estimated-line-height': (22 * 17) / 14 + }) + expect( + nativeChatAppearanceStyle({ ...settings, terminalFontSize: 18 })['--chat-font-size'] + ).toBe('18px') + const restored = nativeChatAppearanceStyle({ + ...settings, + nativeChatAppearance: { ...settings.nativeChatAppearance, matchTerminalInterface: false } + }) + expect(restored).toMatchObject({ + '--chat-font-size': '20px', + '--chat-code-font-size': '10px', + '--chat-foreground-mix': '56%' + }) + }) }) describe('chat root appearance style', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts index 63202694e7c0..76e7ee624d3a 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-style.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-style.ts @@ -41,6 +41,9 @@ export function selectNativeChatAppearanceSettings( terminalCustomThemes: settings?.terminalCustomThemes, terminalColorOverrides: settings?.terminalColorOverrides, terminalFontFamily: settings?.terminalFontFamily, + terminalFontSize: settings?.nativeChatAppearance?.matchTerminalInterface + ? settings.terminalFontSize + : undefined, nativeChatAppearance: settings?.nativeChatAppearance } } @@ -58,7 +61,11 @@ export function nativeChatAppearanceStyle( systemPrefersDark = getSystemPrefersDark() ): NativeChatAppearanceStyle { const appearance = resolveNativeChatAppearanceSettings(settings?.nativeChatAppearance) - const { fontSize, codeFontSize, width } = appearance + const { width } = appearance + const matching = appearance.matchTerminalInterface + const terminalFontSize = settings?.terminalFontSize ?? 14 + const fontSize = matching ? terminalFontSize : appearance.fontSize + const codeFontSize = matching ? terminalFontSize : appearance.codeFontSize const maxWidthPx = width === 'wide' ? 960 : width === 'full' ? Number.POSITIVE_INFINITY : 736 const measuredWidth = nativeChatColumnWidthBucket(measuredColumnWidthPx) const columnWidthPx = Math.min( @@ -79,7 +86,7 @@ export function nativeChatAppearanceStyle( '--chat-code-font-family': font } let light = settings?.theme === 'light' || (settings?.theme === 'system' && !systemPrefersDark) - if (appearance?.matchTerminalInterface === true) { + if (matching) { const colors = resolveConfiguredTerminalColors( { theme: settings?.theme ?? 'dark', @@ -135,7 +142,7 @@ export function nativeChatAppearanceStyle( '--chat-composer-border-mix': light ? '11%' : '9%' }) } - const bodyMix = nativeChatContrastMix(appearance.contrast, light) + const bodyMix = matching ? 100 : nativeChatContrastMix(appearance.contrast, light) style['--chat-foreground-mix'] = `${bodyMix}%` style['--chat-strong-mix'] = `${Math.min(100, bodyMix + (light ? 10 : 12))}%` return style diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts index 3b26fdb42274..2031ebf49cc1 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts @@ -74,4 +74,35 @@ describe('chat appearance write queue', () => { expect(updateSettings).toHaveBeenCalledTimes(2) expect(mock.state.settings?.nativeChatAppearance).toEqual({ fontSize: 20 }) }) + + it('consumes chat size actions without changing saved size while terminal matching is active', async () => { + const updateSettings = vi.fn(async (updates: Partial) => { + mock.state.settings = { ...mock.state.settings!, ...updates } + }) + mock.state.updateSettings = updateSettings + mock.state.settings = { + ...getDefaultSettings('/tmp'), + nativeChatAppearance: { + fontSize: 18, + codeFontSize: 16, + contrast: 125, + matchTerminalInterface: true + } + } + await writeNativeChatFontSize('increase') + await writeNativeChatFontSize('decrease') + await writeNativeChatFontSize('reset') + expect(updateSettings).not.toHaveBeenCalled() + expect(mock.state.settings.nativeChatAppearance?.fontSize).toBe(18) + mock.state.settings = { + ...mock.state.settings, + nativeChatAppearance: { + ...mock.state.settings.nativeChatAppearance, + matchTerminalInterface: false + } + } + await writeNativeChatFontSize('increase') + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(mock.state.settings.nativeChatAppearance?.fontSize).toBe(19) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-font-size.ts b/src/renderer/src/components/native-chat/native-chat-font-size.ts index b69ffd5f954c..62af3d441fb4 100644 --- a/src/renderer/src/components/native-chat/native-chat-font-size.ts +++ b/src/renderer/src/components/native-chat/native-chat-font-size.ts @@ -13,6 +13,9 @@ export function chatFontSizeForAction( appearance: NativeChatAppearanceSettings | undefined, action: Exclude ): NativeChatAppearanceSettings | undefined { + if (appearance?.matchTerminalInterface) { + return appearance + } const { fontSize } = resolveNativeChatAppearanceSettings(appearance) return normalizeNativeChatAppearanceSettings({ ...appearance, diff --git a/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx index de39f7749a84..18a706383eb2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx @@ -38,7 +38,7 @@ describe('shared chat appearance hook', () => { }) expect(chat.result.current.colorScheme).toBe('light') expect(chat.result.current['--chat-source-background']).not.toBe(darkBackground) - expect(chat.result.current['--chat-foreground-mix']).toBe('82%') + expect(chat.result.current['--chat-foreground-mix']).toBe('100%') expect(anotherChat.result.current).toEqual(chat.result.current) chat.unmount() expect(removeListener).not.toHaveBeenCalled() @@ -63,18 +63,19 @@ describe('shared chat appearance hook', () => { settings: { ...settings, terminalFontSize: settings.terminalFontSize + 1 }, width: 736 }) - expect(result.current).toBe(style) - expect(resolveColors).toHaveBeenCalledTimes(1) + expect(result.current).not.toBe(style) + expect(result.current['--chat-font-size']).toBe(`${settings.terminalFontSize + 1}px`) + expect(resolveColors).toHaveBeenCalledTimes(2) rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 736 }) expect(result.current['--chat-code-font-family']).toContain('Menlo') - expect(resolveColors).toHaveBeenCalledTimes(2) + expect(resolveColors).toHaveBeenCalledTimes(3) rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 384 }) expect(result.current['--chat-estimated-chars-per-line']).toBe(50) - expect(resolveColors).toHaveBeenCalledTimes(3) + expect(resolveColors).toHaveBeenCalledTimes(4) const narrower = result.current rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 399 }) expect(result.current).toBe(narrower) - expect(resolveColors).toHaveBeenCalledTimes(3) + expect(resolveColors).toHaveBeenCalledTimes(4) }) it('invalidates the style for each appearance input', () => { @@ -92,6 +93,7 @@ describe('shared chat appearance hook', () => { { terminalCustomThemes: [] }, { terminalColorOverrides: { background: '#122033', foreground: '#ddeeff' } }, { terminalFontFamily: 'Menlo' }, + { terminalFontSize: settings.terminalFontSize + 1 }, { nativeChatAppearance: { matchTerminalInterface: true, contrast: 120 } } ] const { result, rerender } = renderHook( @@ -105,4 +107,15 @@ describe('shared chat appearance hook', () => { expect(result.current, Object.keys(update).join(',')).not.toBe(style) } }) + + it('ignores terminal size changes while matching is off', () => { + const settings = createGlobalSettingsFixture({ nativeChatAppearance: { fontSize: 18 } }) + const { result, rerender } = renderHook( + (current: GlobalSettings) => useNativeChatAppearanceStyle(current), + { initialProps: settings } + ) + const style = result.current + rerender({ ...settings, terminalFontSize: settings.terminalFontSize + 1 }) + expect(result.current).toBe(style) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-font-size.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-font-size.test.tsx index 54190efb5147..cb09164504e8 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-font-size.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-font-size.test.tsx @@ -19,7 +19,7 @@ import { useNativeChatFontSize } from './use-native-chat-font-size' vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web })) import { isMacPlatform } from './native-chat-shortcut' -function key(key: string, target: EventTarget = window): void { +function key(key: string, target: EventTarget = window): KeyboardEvent { const code = key === '+' ? 'Equal' @@ -28,17 +28,17 @@ function key(key: string, target: EventTarget = window): void { : key === '0' ? 'Digit0' : `Key${key.toUpperCase()}` - target.dispatchEvent( - new KeyboardEvent('keydown', { - key, - code, - bubbles: true, - cancelable: true, - metaKey: isMacPlatform(), - ctrlKey: !isMacPlatform(), - shiftKey: key === '+' || key === '_' - }) - ) + const event = new KeyboardEvent('keydown', { + key, + code, + bubbles: true, + cancelable: true, + metaKey: isMacPlatform(), + ctrlKey: !isMacPlatform(), + shiftKey: key === '+' || key === '_' + }) + target.dispatchEvent(event) + return event } afterEach(() => { cleanup() @@ -49,6 +49,21 @@ afterEach(() => { }) describe('persisted chat font-size shortcuts', () => { + it('consumes matching chat size keys without writing the saved font size', async () => { + mocks.settings.nativeChatAppearance = { fontSize: 18, matchTerminalInterface: true } + renderHook(() => useNativeChatFontSize(true)) + const events: KeyboardEvent[] = [] + act(() => { + events.push(key('+'), key('-'), key('0')) + }) + await Promise.resolve() + expect(events.every((event) => event.defaultPrevented)).toBe(true) + expect(mocks.updateSettings).not.toHaveBeenCalled() + expect(mocks.settings.nativeChatAppearance).toEqual({ + fontSize: 18, + matchTerminalInterface: true + }) + }) it('serializes quick repeats against the latest stored size, clamps, and resets only text size', async () => { mocks.settings.nativeChatAppearance = { fontSize: 19, codeFontSize: 16, width: 'wide' } renderHook(() => useNativeChatFontSize(true)) diff --git a/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx index ca031caaa55e..28800ca8c0f4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-row-typography.test.tsx @@ -40,33 +40,35 @@ describe('memoized transcript row typography', () => { expect(resolveColors).toHaveBeenCalledTimes(1) expect(matchMedia).toHaveBeenCalledTimes(initialMediaReads) const renders = rendered.mock.calls.length + const terminalSizeSettings = { ...settings, terminalFontSize: settings.terminalFontSize + 1 } act(() => useAppStore.setState({ - settings: { ...settings, terminalFontSize: settings.terminalFontSize + 1 } + settings: terminalSizeSettings }) ) - expect(rendered).toHaveBeenCalledTimes(renders) - expect(resolveColors).toHaveBeenCalledTimes(1) + expect(rendered).toHaveBeenCalledTimes(renders + 1) + expect(result.current.typography.lineHeightPx).toBe((22 * 15) / 14) + expect(resolveColors).toHaveBeenCalledTimes(2) act(() => useAppStore.setState({ settings: { - ...settings, + ...terminalSizeSettings, nativeChatAppearance: { matchTerminalInterface: true, fontSize: 20 } } }) ) - expect(result.current.typography.lineHeightPx).toBe((22 * 20) / 14) - expect(resolveColors).toHaveBeenCalledTimes(2) + expect(result.current.typography.lineHeightPx).toBe((22 * 15) / 14) + expect(resolveColors).toHaveBeenCalledTimes(3) const node = document.createElement('div') vi.spyOn(node, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ width: 384 })) act(() => result.current.measureContent(node)) - expect(result.current.typography.charsPerLine).toBe(35) - expect(resolveColors).toHaveBeenCalledTimes(3) + expect(result.current.typography.charsPerLine).toBe(46) + expect(resolveColors).toHaveBeenCalledTimes(4) const measured = result.current.typography vi.mocked(node.getBoundingClientRect).mockReturnValue(DOMRect.fromRect({ width: 399 })) act(() => result.current.measureContent(node)) expect(result.current.typography).toBe(measured) - expect(resolveColors).toHaveBeenCalledTimes(3) + expect(resolveColors).toHaveBeenCalledTimes(4) act(() => result.current.measureContent(null)) }) diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx index f2338f35ea57..c3c819d8bd08 100644 --- a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import type { NativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' -import { getChatContrastEntriesByKey } from './chat-appearance-search' +import { chatTerminalControlledHint, getChatContrastEntriesByKey } from './chat-appearance-search' import { translate } from '@/i18n/i18n' import { Slider } from '../ui/slider' import { SearchableSetting } from './SearchableSetting' @@ -40,8 +40,10 @@ export function AppearanceChatContrastControls({ > } + description={matching ? chatTerminalControlledHint() : entries.contrast.description} + control={ + + } />
@@ -50,8 +52,12 @@ export function AppearanceChatContrastControls({ function ChatContrastSlider({ contrast, - onChange -}: Pick & { contrast: number }): React.JSX.Element { + onChange, + disabled +}: Pick & { + contrast: number + disabled: boolean +}): React.JSX.Element { const [draft, setDraft] = useState({ savedContrast: contrast, value: contrast }) // Keep the thumb mounted so committed keyboard changes retain focus. if (draft.savedContrast !== contrast) { @@ -64,6 +70,7 @@ function ChatContrastSlider({
{ codeFontSize: 16, width: 'wide' as const, contrast: 130, - matchTerminalInterface: true, + matchTerminalInterface: false, futureSetting: 'keep' } } @@ -163,4 +163,59 @@ describe('chat appearance settings controls', () => { expect(matchesSettingsSearch(query, entries), query).toBe(true) } }) + + it('disables only terminal-controlled rows while matching and restores saved values off', async () => { + const settings = { + ...getDefaultSettings('/tmp'), + nativeChatAppearance: { + fontSize: 18, + codeFontSize: 16, + contrast: 125, + matchTerminalInterface: true + } + } + const updateSettings = persistInMock(settings) + const { rerender } = render( + + ) + expect(screen.getAllByText('Set by terminal interface.')).toHaveLength(3) + expect(screen.getByRole('spinbutton', { name: 'Text size' }).hasAttribute('disabled')).toBe( + true + ) + expect( + screen.getByRole('spinbutton', { name: 'Code text size' }).hasAttribute('disabled') + ).toBe(true) + expect(screen.getByRole('slider', { name: 'Contrast' }).hasAttribute('data-disabled')).toBe( + true + ) + expect( + screen.getByRole('switch', { name: 'Match terminal interface' }).hasAttribute('disabled') + ).toBe(false) + fireEvent.click(screen.getByRole('radio', { name: 'Wide' })) + await waitFor(() => expect(mocks.state.settings?.nativeChatAppearance?.width).toBe('wide')) + expect(mocks.state.settings?.nativeChatAppearance).toMatchObject({ + fontSize: 18, + codeFontSize: 16, + contrast: 125 + }) + rerender( + + ) + fireEvent.click(screen.getByRole('switch', { name: 'Match terminal interface' })) + await waitFor(() => + expect(mocks.state.settings?.nativeChatAppearance?.matchTerminalInterface).toBeUndefined() + ) + rerender( + + ) + expect(screen.queryByText('Set by terminal interface.')).toBeNull() + expect(screen.getByRole('spinbutton', { name: 'Text size' }).getAttribute('value')).toBe('18') + expect(screen.getByRole('spinbutton', { name: 'Code text size' }).getAttribute('value')).toBe( + '16' + ) + expect(screen.getByRole('slider', { name: 'Contrast' }).getAttribute('aria-valuenow')).toBe( + '125' + ) + expect(screen.getByRole('radio', { name: 'Wide' }).getAttribute('aria-checked')).toBe('true') + }) }) diff --git a/src/renderer/src/components/settings/AppearanceChatSection.tsx b/src/renderer/src/components/settings/AppearanceChatSection.tsx index af1b8ae850a2..245e33858bc8 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.tsx @@ -11,7 +11,11 @@ import { AppearanceChatContrastControls } from './AppearanceChatContrastControls import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' -import { getChatAppearanceEntriesByKey, getChatWidthOptions } from './chat-appearance-search' +import { + chatTerminalControlledHint, + getChatAppearanceEntriesByKey, + getChatWidthOptions +} from './chat-appearance-search' import { writeNativeChatAppearance } from '../native-chat/native-chat-appearance-write' import type { GlobalSettings } from '../../../../shared/global-settings-types' @@ -27,6 +31,7 @@ export function AppearanceChatSection({ forceVisiblePrimary = false }: AppearanceChatSectionProps): React.JSX.Element { const appearance = resolveNativeChatAppearanceSettings(settings.nativeChatAppearance) + const matching = appearance.matchTerminalInterface const keybindings = useAppStore((state) => state.keybindings) const increase = formatPrimaryShortcutLabel('zoom.in', keybindings) const decrease = formatPrimaryShortcutLabel('zoom.out', keybindings) @@ -51,9 +56,10 @@ export function AppearanceChatSection({ > { + if (disabled) { + return + } const trimmed = draft.trim() if (trimmed === '') { if (onClear) { @@ -376,6 +381,7 @@ export function NumberField({
({ diff --git a/src/renderer/src/hooks/zoom-routing.test.ts b/src/renderer/src/hooks/zoom-routing.test.ts index 6aa3b2d1d6cc..370e229fb01c 100644 --- a/src/renderer/src/hooks/zoom-routing.test.ts +++ b/src/renderer/src/hooks/zoom-routing.test.ts @@ -209,6 +209,21 @@ describe('registerZoomIpcBridge', () => { expect(zoom.setEditorFontZoomLevel).not.toHaveBeenCalled() }) + it('consumes menu chat zoom while matching without changing saved size or app zoom', async () => { + const zoom = await mountZoomBridge({ + activeTabType: 'terminal', + activeElement: makeTarget({ chatClosest: true }), + nativeChatAppearance: { fontSize: 18, matchTerminalInterface: true } + }) + zoom.fire('in') + zoom.fire('out') + zoom.fire('reset') + await vi.waitFor(() => expect(zoom.settings.nativeChatAppearance?.fontSize).toBe(18)) + expect(zoom.updateSettings).not.toHaveBeenCalled() + expect(zoom.applyUIZoom).not.toHaveBeenCalled() + expect(zoom.setEditorFontZoomLevel).not.toHaveBeenCalled() + }) + it('applies app zoom for an active browser tab', async () => { const zoom = await mountZoomBridge({ activeTabType: 'browser' }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9799868ab1f4..025930d80357 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -239,7 +239,8 @@ "contrast": "Contrast", "contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", "softer": "Softer", - "sharper": "Sharper" + "sharper": "Sharper", + "terminalControlledHint": "Set by terminal interface." } }, "browser": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e75c793ce29c..6a0f48988712 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -76,7 +76,8 @@ "contrast": "Contraste", "contrastDescription": "Ajusta el brillo del texto del chat sobre el fondo. El extremo derecho recupera el aspecto anterior, más brillante.", "softer": "Más suave", - "sharper": "Más nítido" + "sharper": "Más nítido", + "terminalControlledHint": "Lo controla la interfaz del terminal." } }, "browser": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index fa1ee55338ae..31348a1415a0 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -175,7 +175,8 @@ "contrast": "Contraste", "contrastDescription": "Ajuste la luminosité du texte du chat sur le fond. L’extrémité droite retrouve l’ancien aspect, plus lumineux.", "softer": "Plus doux", - "sharper": "Plus net" + "sharper": "Plus net", + "terminalControlledHint": "Défini par l’interface du terminal." } }, "browser": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 562d369f5a7b..2aef0b26b436 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -76,7 +76,8 @@ "contrast": "コントラスト", "contrastDescription": "背景に対するチャットの文字の明るさを調整します。右端にすると、以前の明るい表示になります。", "softer": "柔らかく", - "sharper": "くっきり" + "sharper": "くっきり", + "terminalControlledHint": "ターミナルの外観設定に従います。" } }, "browser": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ab20cd4bcb84..7d7606537458 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -76,7 +76,8 @@ "contrast": "대비", "contrastDescription": "배경에 대한 채팅 텍스트의 밝기를 조절합니다. 오른쪽 끝으로 이동하면 이전의 더 밝은 모습으로 표시됩니다.", "softer": "부드럽게", - "sharper": "선명하게" + "sharper": "선명하게", + "terminalControlledHint": "터미널 인터페이스 설정을 따릅니다." } }, "browser": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4a2a3a552c5f..d57d9cc52ef5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -76,7 +76,8 @@ "contrast": "对比度", "contrastDescription": "调整聊天文字相对于背景的亮度。滑到最右端可恢复以前更明亮的外观。", "softer": "更柔和", - "sharper": "更清晰" + "sharper": "更清晰", + "terminalControlledHint": "由终端界面设置。" } }, "browser": { From 1b6a7a64fd38fcfa4184e3f3916f4b2fa4c2a3fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:39:26 -0700 Subject: [PATCH 11/16] Verify terminal match controls on Chat settings page --- .../settings/ChatSettingsSection.test.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx index 9299cfca1d35..579cd20cfd34 100644 --- a/src/renderer/src/components/settings/ChatSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/ChatSettingsSection.test.tsx @@ -144,4 +144,25 @@ describe('Chat settings page', () => { expect(getSettingsScrollTarget(entry.targetSectionId ?? '', container)).toBeTruthy() } }) + + it.each([ + ['Match terminal interface', 'chat-match-terminal-interface', 'switch'], + ['Contrast', 'chat-contrast', 'slider'] + ])('searches %s and resolves its Chat deep link', (title, targetSectionId, role) => { + state.settingsSearchQuery = title + const { container } = renderChat(true) + expect(screen.getByRole(role, { name: title })).toBeTruthy() + const sections = buildSettingsNavigationMetadata({ + isMac: true, + isWindows: false, + isWebClient: false, + experimentalStructuredNativeChat: true, + repos: [] + }) + const result = buildCmdJSettingsResults(sections).find( + (entry) => entry.sectionId === 'chat' && entry.title === title + ) + expect(result?.targetSectionId).toBe(targetSectionId) + expect(getSettingsScrollTarget(targetSectionId, container)).toBeTruthy() + }) }) From 8dd4bca81623c417421432b399d7e8d472735090 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:08:45 -0700 Subject: [PATCH 12/16] Share native chat appearance store selection --- .../native-chat/NativeChatResolvedView.tsx | 13 +++---------- .../native-chat/NativeChatStructuredSession.tsx | 13 +++---------- .../native-chat/use-native-chat-row-typography.ts | 14 +++----------- .../use-native-chat-store-appearance-style.ts | 13 +++++++++++++ 4 files changed, 22 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-store-appearance-style.ts diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index ceccc83231f8..dca8420460ad 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -1,10 +1,6 @@ -import { useShallow } from 'zustand/react/shallow' import { cn } from '@/lib/utils' -import { - NATIVE_CHAT_APPEARANCE_ROOT_CLASS, - selectNativeChatAppearanceSettings, - useNativeChatAppearanceStyle -} from './native-chat-appearance-style' +import { NATIVE_CHAT_APPEARANCE_ROOT_CLASS } from './native-chat-appearance-style' +import { useNativeChatStoreAppearanceStyle } from './use-native-chat-store-appearance-style' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus' import { useAppStore } from '../../store' @@ -317,10 +313,7 @@ export function NativeChatResolvedView({ // Only the focused conversation accepts chat text-size shortcuts. useNativeChatFontSize(isConversation && isVisible && isFocusedGroup, rootRef) - const appearanceSettings = useAppStore( - useShallow((state) => selectNativeChatAppearanceSettings(state.settings)) - ) - const appearanceStyle = useNativeChatAppearanceStyle(appearanceSettings) + const appearanceStyle = useNativeChatStoreAppearanceStyle() return (
selectNativeChatAppearanceSettings(state.settings)) - ) - const appearanceStyle = useNativeChatAppearanceStyle(appearanceSettings) + const appearanceStyle = useNativeChatStoreAppearanceStyle() const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( fileLinkContext, diff --git a/src/renderer/src/components/native-chat/use-native-chat-row-typography.ts b/src/renderer/src/components/native-chat/use-native-chat-row-typography.ts index cd7e62244fe6..c640d5d58b8d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-row-typography.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-row-typography.ts @@ -1,17 +1,9 @@ import { useCallback, useMemo, useState, type RefObject } from 'react' import { useMeasuredWidth } from '../right-sidebar/right-sidebar-measured-width' -import { useShallow } from 'zustand/react/shallow' -import { useAppStore } from '../../store' -import { - nativeChatColumnWidthBucket, - selectNativeChatAppearanceSettings, - useNativeChatAppearanceStyle -} from './native-chat-appearance-style' +import { nativeChatColumnWidthBucket } from './native-chat-appearance-style' +import { useNativeChatStoreAppearanceStyle } from './use-native-chat-store-appearance-style' export function useNativeChatRowTypography(contentRef: RefObject) { - const appearanceSettings = useAppStore( - useShallow((state) => selectNativeChatAppearanceSettings(state.settings)) - ) const [columnWidthPx, setColumnWidthPx] = useState(null) const commitWidth = useCallback((width: number | null) => { setColumnWidthPx(nativeChatColumnWidthBucket(width)) @@ -24,7 +16,7 @@ export function useNativeChatRowTypography(contentRef: RefObject ({ lineHeightPx, charsPerLine }), [lineHeightPx, charsPerLine]) diff --git a/src/renderer/src/components/native-chat/use-native-chat-store-appearance-style.ts b/src/renderer/src/components/native-chat/use-native-chat-store-appearance-style.ts new file mode 100644 index 000000000000..42f904dee321 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-store-appearance-style.ts @@ -0,0 +1,13 @@ +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '../../store' +import { + selectNativeChatAppearanceSettings, + useNativeChatAppearanceStyle +} from './native-chat-appearance-style' + +export function useNativeChatStoreAppearanceStyle(measuredColumnWidthPx?: number | null) { + const settings = useAppStore( + useShallow((state) => selectNativeChatAppearanceSettings(state.settings)) + ) + return useNativeChatAppearanceStyle(settings, measuredColumnWidthPx) +} From 9fcd7748715077b48372b4cfa3fb9150335584b4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:29:55 -0700 Subject: [PATCH 13/16] Align matched chat palette test with full-strength foreground --- .../native-chat-appearance-palette.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts index a7aaf2d109e2..b893277b3f26 100644 --- a/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts @@ -70,15 +70,20 @@ describe('matching chat surfaces with opposite app schemes', () => { const chat = createChat(appScheme, terminalScheme) // These roles cover transcript payloads, question badges, inline completion menus and controls. const matched = getComputedStyle(chat) + const terminalForegroundAtFullStrength = `${terminalScheme === 'light' ? '#000000' : '#ffffff'} 100%` expect(chat.style.color).toBe('var(--foreground)') - expect(matched.getPropertyValue('--foreground')).toContain( - terminalScheme === 'light' ? '92%' : '90%' + expect(matched.getPropertyValue('--chat-foreground')).toContain( + terminalForegroundAtFullStrength ) + expect(matched.getPropertyValue('--chat-foreground-strong')).toContain( + terminalForegroundAtFullStrength + ) + expect(matched.getPropertyValue('--foreground')).toContain(terminalForegroundAtFullStrength) expect(matched.getPropertyValue('--muted')).toContain('7%') expect(matched.getPropertyValue('--muted-foreground')).toContain('62%') expect(matched.getPropertyValue('--popover')).toContain('4%') expect(matched.getPropertyValue('--popover-foreground')).toContain( - terminalScheme === 'light' ? '92%' : '90%' + terminalForegroundAtFullStrength ) expect(matched.getPropertyValue('--accent')).toContain('9%') expect(matched.getPropertyValue('--input')).toContain('7%') From b8dd211c60a1b024821c77541d21871b9e84d915 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:07:33 -0700 Subject: [PATCH 14/16] Keep tool-run header marks on the summary's first line A wrapped two-line summary centered the leading icon, failure count, check, and chevron between its lines. The header now top-aligns its items and gives each mark a one-line-tall slot, so marks sit on line 1 and line 2 wraps under the text. --- .../NativeChatToolRun.appearance.test.tsx | 37 +++++++++++++ .../native-chat/NativeChatToolRun.tsx | 52 +++++++++++-------- .../NativeChatToolRunCallCounts.tsx | 17 +++--- 3 files changed, 78 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx index 1190f62c1fdf..3cd69b6c1b8c 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx @@ -39,6 +39,7 @@ describe('tool-run summary in a matching chat', () => { const failure = container.querySelector('[aria-label="Failed tool calls: 1"]') expect(failure).toHaveTextContent('1 failed') expect(failure).toHaveClass('shrink-0') + expect(failure?.parentElement).toHaveClass('flex', 'h-[1lh]', 'items-center') }) it('keeps a long command available in the expanded detail', () => { @@ -66,4 +67,40 @@ describe('tool-run summary in a matching chat', () => { expect(container.querySelector('pre')).toHaveTextContent(command) }) + + it.each([ + ['wrapped', `QA inert command for display only: ${'x'.repeat(400)}`], + ['single-line', 'pnpm test'] + ])('pins the marks to the first line of a %s summary', (_shape, command) => { + const { container } = render( + + ) + + const header = screen.getByRole('button') + // Top-aligned in the summary's own type, so `1lh` is one summary line. + expect(header).toHaveClass('items-start', 'text-sm', 'leading-relaxed') + expect(header).toHaveClass('native-chat-message-text') + expect(header).not.toHaveClass('items-center') + const summary = container.querySelector('span.native-chat-message-text') + expect(summary?.parentElement).toBe(header) + expect(summary).toHaveClass('min-w-0', 'line-clamp-2') + + const slots = Array.from(header.children).filter((child) => child !== summary) + expect(slots.length).toBeGreaterThanOrEqual(3) + for (const slot of slots) { + expect(slot).toHaveClass('flex', 'h-[1lh]', 'items-center') + } + // The icon leads, so a wrapped second line starts under the text, not under it. + expect(header.firstElementChild?.querySelector('svg')).toBeInTheDocument() + expect(header.children[1]).toBe(summary) + expect(header.querySelector('.lucide-check')?.parentElement).toHaveClass('h-[1lh]') + expect(header.querySelector('.lucide-chevron-right')?.parentElement).toHaveClass('h-[1lh]') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 4fe24b5102f3..cc5bbbe42bb7 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -253,19 +253,23 @@ export function NativeChatToolRun({ // One element for the run's whole life. Live and settled are states of // this button, not two buttons: a header that remounted as a call started // and again as it ended lost its hover, its mark, and its count each time. + // It carries the summary's type so each `h-[1lh]` slot is one summary line + // tall: marks sit on line 1 and a wrapped line 2 starts under the text. )} {open && showsHeader ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.tsx b/src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.tsx index ae2d0db4dfd3..a1cf8e7be953 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRunCallCounts.tsx @@ -47,13 +47,16 @@ export function NativeChatToolRunCallCounts({ /* Outside the truncating member list, so the one thing the reader cannot afford to miss survives a pane too narrow to print it. Quiet text in the header's own type, not a destructive tint or a swapped glyph: a tool error - is routine work, and the line's own detail is one click away. */ - mark.label).join(', ')} - className="shrink-0 font-sans text-xs tabular-nums text-chat-foreground-faint transition-colors group-hover/tool-run:text-chat-foreground" - > - · - {marks.map((mark) => mark.text).join(', ')} + is routine work, and the line's own detail is one click away. One header + line tall so it stays on a wrapped summary's first line. */ + + mark.label).join(', ')} + className="shrink-0 font-sans text-xs tabular-nums text-chat-foreground-faint transition-colors group-hover/tool-run:text-chat-foreground" + > + · + {marks.map((mark) => mark.text).join(', ')} + ) } From 04518c16290484f16e007fa6a5f0cdd6ec5f068c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:15:27 -0700 Subject: [PATCH 15/16] Explain terminal-matched chat controls on hover While Match terminal interface is on, Contrast, Text size and Code text size are disabled. Hovering any of them now shows a tooltip saying the chat is matching the terminal interface and how to change it. Disabled controls drop hover events, so an enabled wrapper carries the tooltip; with matching off the controls render exactly as before. --- .../AppearanceChatContrastControls.tsx | 35 +++++++++----- .../settings/AppearanceChatSection.test.tsx | 47 ++++++++++++++++++- .../settings/AppearanceChatSection.tsx | 3 ++ .../SettingsDisabledControlTooltip.tsx | 26 ++++++++++ .../settings/SettingsFormControls.tsx | 44 +++++++++-------- .../settings/chat-appearance-search.ts | 7 +++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 12 files changed, 141 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/components/settings/SettingsDisabledControlTooltip.tsx diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx index c3c819d8bd08..64b3573bfd31 100644 --- a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx @@ -1,9 +1,14 @@ import { useState } from 'react' import type { NativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' -import { chatTerminalControlledHint, getChatContrastEntriesByKey } from './chat-appearance-search' +import { + chatTerminalControlledHint, + chatTerminalControlledTooltip, + getChatContrastEntriesByKey +} from './chat-appearance-search' import { translate } from '@/i18n/i18n' import { Slider } from '../ui/slider' import { SearchableSetting } from './SearchableSetting' +import { SettingsDisabledControlTooltip } from './SettingsDisabledControlTooltip' import { SettingsRow, SettingsSwitchRow } from './SettingsFormControls' type ChatContrastControlsProps = { @@ -68,18 +73,22 @@ function ChatContrastSlider({ {translate('settings.appearance.chat.softer', 'Softer')} -
- setDraft({ savedContrast: contrast, value })} - onValueCommit={([value]) => onChange({ contrast: value })} - /> -
+ +
+ setDraft({ savedContrast: contrast, value })} + onValueCommit={([value]) => onChange({ contrast: value })} + /> +
+
{translate('settings.appearance.chat.sharper', 'Sharper')} diff --git a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx index 927a7f35e6b9..25c592167973 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx @@ -4,6 +4,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' +import { TooltipProvider } from '../ui/tooltip' import { AppearanceChatSection } from './AppearanceChatSection' import { getChatAppearanceEntriesByKey, @@ -176,7 +177,8 @@ describe('chat appearance settings controls', () => { } const updateSettings = persistInMock(settings) const { rerender } = render( - + , + { wrapper: TooltipProvider } ) expect(screen.getAllByText('Set by terminal interface.')).toHaveLength(3) expect(screen.getByRole('spinbutton', { name: 'Text size' }).hasAttribute('disabled')).toBe( @@ -218,4 +220,47 @@ describe('chat appearance settings controls', () => { ) expect(screen.getByRole('radio', { name: 'Wide' }).getAttribute('aria-checked')).toBe('true') }) + + const terminalTooltip = + 'Matching your terminal interface. Turn off Match terminal interface to change this.' + const controlledControls = [ + ['spinbutton', 'Text size'], + ['spinbutton', 'Code text size'], + ['slider', 'Contrast'] + ] as const + + it.each(controlledControls)( + 'explains a matched %s "%s" on hover of its wrapper', + async (role, name) => { + const settings = { + ...getDefaultSettings('/tmp'), + nativeChatAppearance: { matchTerminalInterface: true } + } + render( + , + { wrapper: TooltipProvider } + ) + const trigger = screen.getByRole(role, { name }).closest('[data-slot="tooltip-trigger"]') + expect(trigger).not.toBeNull() + fireEvent.pointerMove(trigger!, { pointerType: 'mouse' }) + expect((await screen.findByRole('tooltip')).textContent).toBe(terminalTooltip) + // Width stays editable and unexplained. + expect( + screen.getByRole('radio', { name: 'Wide' }).closest('[data-slot="tooltip-trigger"]') + ).toBeNull() + } + ) + + it('gives the controls no tooltip while matching is off', () => { + const settings = getDefaultSettings('/tmp') + render(, { + wrapper: TooltipProvider + }) + for (const [role, name] of controlledControls) { + const control = screen.getByRole(role, { name }) + expect(control.closest('[data-slot="tooltip-trigger"]')).toBeNull() + fireEvent.pointerMove(control, { pointerType: 'mouse' }) + } + expect(screen.queryByRole('tooltip')).toBeNull() + }) }) diff --git a/src/renderer/src/components/settings/AppearanceChatSection.tsx b/src/renderer/src/components/settings/AppearanceChatSection.tsx index 245e33858bc8..94e72ad91fd2 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.tsx @@ -13,6 +13,7 @@ import { SearchableSetting } from './SearchableSetting' import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' import { chatTerminalControlledHint, + chatTerminalControlledTooltip, getChatAppearanceEntriesByKey, getChatWidthOptions } from './chat-appearance-search' @@ -60,6 +61,7 @@ export function AppearanceChatSection({ value={appearance.fontSize} defaultValue={matching ? undefined : 14} disabled={matching} + disabledReason={chatTerminalControlledTooltip()} min={12} max={20} integer @@ -78,6 +80,7 @@ export function AppearanceChatSection({ value={appearance.codeFontSize} defaultValue={matching ? undefined : 12} disabled={matching} + disabledReason={chatTerminalControlledTooltip()} min={10} max={18} integer diff --git a/src/renderer/src/components/settings/SettingsDisabledControlTooltip.tsx b/src/renderer/src/components/settings/SettingsDisabledControlTooltip.tsx new file mode 100644 index 000000000000..2228b6cd4c0d --- /dev/null +++ b/src/renderer/src/components/settings/SettingsDisabledControlTooltip.tsx @@ -0,0 +1,26 @@ +import type React from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' + +/** Explains why a settings control is disabled; renders the control untouched without a reason. */ +export function SettingsDisabledControlTooltip({ + reason, + children +}: { + reason?: string + children: React.ReactNode +}): React.JSX.Element { + if (!reason) { + return <>{children} + } + return ( + + + {/* Disabled controls drop hover events, so an enabled wrapper carries the tooltip. */} + {children} + + + {reason} + + + ) +} diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 300bdb992283..d7e276803de4 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -4,6 +4,7 @@ import { Input } from '../ui/input' import { Label } from '../ui/label' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { Switch } from '../ui/switch' +import { SettingsDisabledControlTooltip } from './SettingsDisabledControlTooltip' import { normalizeColor } from '@/lib/terminal-theme' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -276,6 +277,8 @@ type NumberFieldProps = { suffix?: string className?: string disabled?: boolean + /** Hover explanation shown only while the field is disabled. */ + disabledReason?: string } export function ColorField({ @@ -325,7 +328,8 @@ export function NumberField({ placeholder, suffix, className, - disabled = false + disabled = false, + disabledReason }: NumberFieldProps): React.JSX.Element { const [draft, setDraft] = useState(Number.isFinite(value) ? String(value) : '') const [prevValue, setPrevValue] = useState(value) @@ -379,24 +383,26 @@ export function NumberField({ } control={
- setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commit() - } - }} - className="number-input-clean w-24 tabular-nums" - /> + + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commit() + } + }} + className="number-input-clean w-24 tabular-nums" + /> + {suffix ? {suffix} : null}
} diff --git a/src/renderer/src/components/settings/chat-appearance-search.ts b/src/renderer/src/components/settings/chat-appearance-search.ts index 3220872aceb7..5ae313e2133a 100644 --- a/src/renderer/src/components/settings/chat-appearance-search.ts +++ b/src/renderer/src/components/settings/chat-appearance-search.ts @@ -7,6 +7,13 @@ export function chatTerminalControlledHint(): string { return translate('settings.appearance.chat.terminalControlledHint', 'Set by terminal interface.') } +export function chatTerminalControlledTooltip(): string { + return translate( + 'settings.appearance.chat.terminalControlledTooltip', + 'Matching your terminal interface. Turn off Match terminal interface to change this.' + ) +} + export const getChatContrastEntriesByKey = createLocalizedCatalog( () => ({ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7e36e7b4fe67..950ad592d1bb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -240,7 +240,8 @@ "contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.", "softer": "Softer", "sharper": "Sharper", - "terminalControlledHint": "Set by terminal interface." + "terminalControlledHint": "Set by terminal interface.", + "terminalControlledTooltip": "Matching your terminal interface. Turn off Match terminal interface to change this." } }, "browser": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8019094d0c13..efab16978f40 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -77,7 +77,8 @@ "contrastDescription": "Ajusta el brillo del texto del chat sobre el fondo. El extremo derecho recupera el aspecto anterior, más brillante.", "softer": "Más suave", "sharper": "Más nítido", - "terminalControlledHint": "Lo controla la interfaz del terminal." + "terminalControlledHint": "Lo controla la interfaz del terminal.", + "terminalControlledTooltip": "Coincide con la interfaz del terminal. Desactiva Usar la interfaz del terminal para cambiarlo." } }, "browser": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index b0024f7e1206..43cd0aaf8441 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -176,7 +176,8 @@ "contrastDescription": "Ajuste la luminosité du texte du chat sur le fond. L’extrémité droite retrouve l’ancien aspect, plus lumineux.", "softer": "Plus doux", "sharper": "Plus net", - "terminalControlledHint": "Défini par l’interface du terminal." + "terminalControlledHint": "Défini par l’interface du terminal.", + "terminalControlledTooltip": "Suit l’interface du terminal. Désactivez Utiliser l’interface du terminal pour le modifier." } }, "browser": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 49f7c2647e1e..47d8a1598a55 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -77,7 +77,8 @@ "contrastDescription": "背景に対するチャットの文字の明るさを調整します。右端にすると、以前の明るい表示になります。", "softer": "柔らかく", "sharper": "くっきり", - "terminalControlledHint": "ターミナルの外観設定に従います。" + "terminalControlledHint": "ターミナルの外観設定に従います。", + "terminalControlledTooltip": "ターミナルの外観に合わせています。変更するには「ターミナルの外観に合わせる」をオフにしてください。" } }, "browser": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a4cdfe2ded07..fad55f0a0096 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -77,7 +77,8 @@ "contrastDescription": "배경에 대한 채팅 텍스트의 밝기를 조절합니다. 오른쪽 끝으로 이동하면 이전의 더 밝은 모습으로 표시됩니다.", "softer": "부드럽게", "sharper": "선명하게", - "terminalControlledHint": "터미널 인터페이스 설정을 따릅니다." + "terminalControlledHint": "터미널 인터페이스 설정을 따릅니다.", + "terminalControlledTooltip": "터미널 모양에 맞추는 중입니다. 변경하려면 터미널 모양에 맞추기를 끄세요." } }, "browser": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ba34c32366dc..866e46581d41 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -77,7 +77,8 @@ "contrastDescription": "调整聊天文字相对于背景的亮度。滑到最右端可恢复以前更明亮的外观。", "softer": "更柔和", "sharper": "更清晰", - "terminalControlledHint": "由终端界面设置。" + "terminalControlledHint": "由终端界面设置。", + "terminalControlledTooltip": "正在匹配终端外观。要更改此项,请关闭“匹配终端外观”。" } }, "browser": { From 1367e14066b839532e47c7917a2907d7e77afe63 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:57:59 -0700 Subject: [PATCH 16/16] Name the tool-run alignment test case by summary length --- .../native-chat/NativeChatToolRun.appearance.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx index 3cd69b6c1b8c..b707c7cd38ca 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx @@ -71,7 +71,7 @@ describe('tool-run summary in a matching chat', () => { it.each([ ['wrapped', `QA inert command for display only: ${'x'.repeat(400)}`], ['single-line', 'pnpm test'] - ])('pins the marks to the first line of a %s summary', (_shape, command) => { + ])('pins the marks to the first line of a %s summary', (_summaryLength, command) => { const { container } = render(