From 3268760084bcf256cd8dbb97ac3694975cbf7c00 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:05:46 -0700 Subject: [PATCH] Add chat contrast and "Match terminal interface" (#25659) * Add chat contrast and terminal interface matching * Fix matching chat palettes and contrast controls * Verify rebased appearance settings and preserve slider focus * Memoize chat appearance and narrow settings subscriptions * Wrap native chat tool-run summaries in the terminal font * Use the matched chat foreground for inherited controls * fix(native-chat): bound collapsed tool summaries * Restore code foreground token for matched chat themes * Reset all chat appearance controls through serialized writer * Make terminal matching control chat size and contrast * Verify terminal match controls on Chat settings page * Share native chat appearance store selection * Align matched chat palette test with full-strength foreground * 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. * 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. * Name the tool-run alignment test case by summary length --- src/renderer/src/assets/main.css | 211 +++++++++++------ .../native-chat/NativeChatResolvedView.tsx | 10 +- .../NativeChatStructuredSession.test.tsx | 81 ++++++- .../NativeChatStructuredSession.tsx | 10 +- .../native-chat/NativeChatToolLine.tsx | 5 +- .../NativeChatToolRun.appearance.test.tsx | 106 +++++++++ .../native-chat/NativeChatToolRun.test.tsx | 2 +- .../native-chat/NativeChatToolRun.tsx | 62 ++--- .../NativeChatToolRunCallCounts.tsx | 17 +- .../native-chat-appearance-palette.test.ts | 141 +++++++++++ .../native-chat-appearance-style.test.ts | 222 +++++++++++++++++- .../native-chat-appearance-style.ts | 142 +++++++++-- .../native-chat-appearance-write.test.ts | 36 ++- .../native-chat/native-chat-appearance.css | 12 + .../native-chat/native-chat-font-size.ts | 3 + .../native-chat-row-height-estimate.test.ts | 46 ++++ .../native-chat-row-height-estimate.ts | 17 +- .../use-native-chat-appearance-style.test.tsx | 121 ++++++++++ .../use-native-chat-font-size.test.tsx | 39 ++- .../use-native-chat-row-typography.test.tsx | 111 +++++++-- .../use-native-chat-row-typography.ts | 10 +- .../use-native-chat-store-appearance-style.ts | 13 + ...earanceChatContrastControls.draft.test.tsx | 66 ++++++ .../AppearanceChatContrastControls.test.tsx | 82 +++++++ .../AppearanceChatContrastControls.tsx | 100 ++++++++ .../settings/AppearanceChatSection.test.tsx | 109 ++++++++- .../settings/AppearanceChatSection.tsx | 26 +- .../settings/ChatSettingsSection.test.tsx | 21 ++ .../SettingsDisabledControlTooltip.tsx | 26 ++ .../settings/SettingsFormControls.tsx | 48 ++-- .../settings/chat-appearance-search.ts | 47 +++- src/renderer/src/hooks/zoom-routing.test.ts | 15 ++ 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 | 74 +++++- src/shared/native-chat-appearance-settings.ts | 24 +- 40 files changed, 1878 insertions(+), 237 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-store-appearance-style.ts create mode 100644 src/renderer/src/components/settings/AppearanceChatContrastControls.draft.test.tsx create mode 100644 src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx create mode 100644 src/renderer/src/components/settings/AppearanceChatContrastControls.tsx create mode 100644 src/renderer/src/components/settings/SettingsDisabledControlTooltip.tsx diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index aa4c597f108..629d711e93c 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 @@ -173,19 +174,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; @@ -199,8 +201,6 @@ --muted-foreground: #737373; --accent: #f5f5f5; --accent-foreground: #171717; - --destructive: #e40014; - --destructive-foreground: #fcf3f3; --border: #e5e5e5; --input: #e5e5e5; --ring: #a1a1a1; @@ -212,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; @@ -258,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; @@ -310,19 +291,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; @@ -336,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; @@ -345,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; @@ -391,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; @@ -418,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. */ @@ -529,6 +540,72 @@ 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-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), + 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 51646d32bc5..f6eb4a55516 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -1,8 +1,6 @@ import { cn } from '@/lib/utils' -import { - NATIVE_CHAT_APPEARANCE_ROOT_CLASS, - 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' @@ -327,8 +325,7 @@ 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 appearanceStyle = useNativeChatStoreAppearanceStyle() return (
{viewState.kind === 'loading' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index b4d8ee5e87c..a8761e9e973 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 @@ -38,7 +39,17 @@ describe('NativeChatStructuredSession', () => { 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', + terminalFontSize: 16, + terminalColorOverrides: { background: '#112233', foreground: '#ddeeff' } } }) const { container } = render( @@ -52,22 +63,84 @@ describe('NativeChatStructuredSession', () => { /> ) const root = container.querySelector('[data-native-chat-root]') - expect(root?.style.getPropertyValue('--chat-font-size')).toBe('18px') - expect(root?.style.getPropertyValue('--chat-code-font-size')).toBe('11px') + 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') + 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('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({ - settings: { ...getDefaultSettings('/tmp'), nativeChatAppearance: { width: 'full' } } + settings: { + ...getDefaultSettings('/tmp'), + theme: 'dark', + nativeChatAppearance: { width: 'full' } + } }) ) + 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('') + 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 })) }) + 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 + 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]') + ?.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 1c4108f4e61..6990ebad88d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,8 +1,6 @@ import { cn } from '@/lib/utils' -import { - NATIVE_CHAT_APPEARANCE_ROOT_CLASS, - 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 { useMemo, useRef, useState } from 'react' import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' @@ -145,8 +143,7 @@ export function NativeChatStructuredSession( viewState.kind === 'ready' && props.isVisible && props.isFocusedGroup, rootRef ) - const appearanceSettings = useAppStore((state) => state.settings?.nativeChatAppearance) - const appearanceStyle = useNativeChatAppearanceStyle({ nativeChatAppearance: appearanceSettings }) + const appearanceStyle = useNativeChatStoreAppearanceStyle() const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( fileLinkContext, @@ -265,6 +262,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/NativeChatToolLine.tsx b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx index ae8273e7bc7..015e20aebed 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 new file mode 100644 index 00000000000..b707c7cd38c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.appearance.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +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' +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])('caps the summary at two wrapped lines, 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', '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') + expect(failure?.parentElement).toHaveClass('flex', 'h-[1lh]', 'items-center') + }) + + 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) + }) + + 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', (_summaryLength, 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.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index 1a614ed2337..03d6da18f64 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 62a591d094a..cc5bbbe42bb 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, @@ -254,27 +253,28 @@ 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 ae2d0db4dfd..a1cf8e7be95 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(', ')} + ) } 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 00000000000..b893277b3f2 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-appearance-palette.test.ts @@ -0,0 +1,141 @@ +// @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 + chat.style.color = style.color ?? '' + 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) + const terminalForegroundAtFullStrength = `${terminalScheme === 'light' ? '#000000' : '#ffffff'} 100%` + expect(chat.style.color).toBe('var(--foreground)') + 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( + terminalForegroundAtFullStrength + ) + 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 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)) + 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 051a62b087c..bc87947021a 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,162 @@ -import { describe, expect, it } from 'vitest' -import { nativeChatAppearanceStyle } from './native-chat-appearance-style' +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([ + [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(`${settings.terminalFontSize}px`) + } + }) + + 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('100%') + expect(style['--chat-code-base']).toBe('var(--chat-canvas)') + expect(style['--chat-strong-mix']).toBe('100%') + }) + + 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('100%') + }) + + 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() + }) + + 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', () => { it('keeps shared typography tokens independent of chat size and provides a relative code ratio', () => { @@ -30,6 +187,21 @@ 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() + expect(style.color).toBeUndefined() + } + } + ) + it('clamps sizes on read and derives the column width', () => { expect( nativeChatAppearanceStyle({ @@ -47,3 +219,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(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) + } + } + } + } + ) + + 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 5bab3f292d7..76e7ee624d3 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,21 +1,27 @@ 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' +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 & { - '--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 { @@ -24,20 +30,50 @@ 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, + terminalFontSize: settings?.nativeChatAppearance?.matchTerminalInterface + ? settings.terminalFontSize + : undefined, + 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 + ? 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 { 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( 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 +82,82 @@ 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 (matching) { + const colors = resolveConfiguredTerminalColors( + { + theme: settings?.theme ?? 'dark', + terminalThemeDark: settings?.terminalThemeDark ?? '', + terminalThemeLight: settings?.terminalThemeLight ?? '', + terminalUseSeparateLightTheme: settings?.terminalUseSeparateLightTheme ?? false, + terminalCustomThemes: settings?.terminalCustomThemes, + terminalColorOverrides: settings?.terminalColorOverrides + }, + systemPrefersDark + ) + const hasThemeColors = Boolean(colors.background && colors.foreground) + const background = hasThemeColors ? colors.background : '#000000' + 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)', + '--foreground': 'var(--chat-foreground-strong)', + '--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': '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(--muted)', + '--secondary-foreground': 'var(--chat-foreground-strong)', + '--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-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%' + }) + } + 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 } export function useNativeChatAppearanceStyle( - settings: Pick | null | undefined + settings: Partial | null | undefined, + measuredColumnWidthPx?: number | null ): NativeChatAppearanceStyle { - return nativeChatAppearanceStyle(settings) + 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/native-chat-appearance-write.test.ts b/src/renderer/src/components/native-chat/native-chat-appearance-write.test.ts index ba3271c7df2..2031ebf49cc 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 }) @@ -73,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-appearance.css b/src/renderer/src/components/native-chat/native-chat-appearance.css index 3405ff6f001..76b7479f188 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/native-chat/native-chat-font-size.ts b/src/renderer/src/components/native-chat/native-chat-font-size.ts index b69ffd5f954..62af3d441fb 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/native-chat-row-height-estimate.test.ts b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts index 67767958732..e9977790eee 100644 --- a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts @@ -216,4 +216,50 @@ describe('transcript row height estimate', () => { 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]) + }) + + it('grows the collapsed reasoning trigger with chat text size above its floor', () => { + const reasoning = nativeChatRowContentMetrics(message('thinking', 'reasoning')) + const [small, standard, large] = [12, 14, 20].map((fontSize) => { + const style = nativeChatAppearanceStyle({ nativeChatAppearance: { fontSize } }) + return estimateNativeChatRowHeight(reasoning, NO_CHROME, { + lineHeightPx: style['--chat-estimated-line-height'], + charsPerLine: style['--chat-estimated-chars-per-line'] + }) + }) + + expect(small).toBe(24) + expect(standard).toBe(24) + expect(large).toBeGreaterThan(standard) + }) }) 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 9a5f052dff1..54ebc92dec2 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,8 +41,9 @@ 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 -/** A reasoning row draws only its `xs` trigger button until opened; opening remeasures it. */ +/** Header margin and vertical padding; its summary can occupy two text lines. */ +const TOOL_RUN_CHROME_PX = 16 +/** A reasoning row draws only its one-line trigger (`min-h-6`) until opened; opening remeasures it. */ const COLLAPSED_REASONING_PX = 24 const SUBAGENT_ROW_PX = 32 /** The one-line head that names a subagent above its own rows. */ @@ -110,6 +111,14 @@ export function nativeChatRowContentMetrics( return metrics } +/** The trigger's headline is chat text, so it grows with the chat size above its `min-h-6` floor. */ +function collapsedReasoningHeight(typography: NativeChatRowTypography): number { + return Math.max( + COLLAPSED_REASONING_PX, + (COLLAPSED_REASONING_PX * typography.lineHeightPx) / DEFAULT_ROW_TYPOGRAPHY.lineHeightPx + ) +} + export function estimateNativeChatRowHeight( content: NativeChatRowContentMetrics, chrome: NativeChatRowChromeMetrics, @@ -127,7 +136,7 @@ export function estimateNativeChatRowHeight( height = content.role === 'reasoning' ? content.textLines > 0 - ? COLLAPSED_REASONING_PX + ? collapsedReasoningHeight(typography) : 0 : content.textLines * typography.lineHeightPx if (content.role === 'user' && content.textLines > 0) { @@ -145,7 +154,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 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 00000000000..18a706383eb --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-appearance-style.test.tsx @@ -0,0 +1,121 @@ +// @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' + +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 anotherChat = 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('100%') + expect(anotherChat.result.current).toEqual(chat.result.current) + chat.unmount() + expect(removeListener).not.toHaveBeenCalled() + 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).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(3) + rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 384 }) + expect(result.current['--chat-estimated-chars-per-line']).toBe(50) + expect(resolveColors).toHaveBeenCalledTimes(4) + const narrower = result.current + rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 399 }) + expect(result.current).toBe(narrower) + expect(resolveColors).toHaveBeenCalledTimes(4) + }) + + 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' }, + { terminalFontSize: settings.terminalFontSize + 1 }, + { 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) + } + }) + + 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 54190efb514..cb09164504e 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 91f61a11c5d..28800ca8c0f 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,108 @@ 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 + const terminalSizeSettings = { ...settings, terminalFontSize: settings.terminalFontSize + 1 } + act(() => + useAppStore.setState({ + settings: terminalSizeSettings + }) + ) + expect(rendered).toHaveBeenCalledTimes(renders + 1) + expect(result.current.typography.lineHeightPx).toBe((22 * 15) / 14) + expect(resolveColors).toHaveBeenCalledTimes(2) + act(() => + useAppStore.setState({ + settings: { + ...terminalSizeSettings, + nativeChatAppearance: { matchTerminalInterface: true, fontSize: 20 } + } + }) + ) + 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(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(4) + 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 cff700d8740..c640d5d58b8 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,9 @@ import { useCallback, useMemo, useState, type RefObject } from 'react' import { useMeasuredWidth } from '../right-sidebar/right-sidebar-measured-width' -import { useAppStore } from '../../store' -import { - nativeChatAppearanceStyle, - nativeChatColumnWidthBucket -} 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 appearance = useAppStore((state) => state.settings?.nativeChatAppearance) const [columnWidthPx, setColumnWidthPx] = useState(null) const commitWidth = useCallback((width: number | null) => { setColumnWidthPx(nativeChatColumnWidthBucket(width)) @@ -20,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 00000000000..42f904dee32 --- /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) +} 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 00000000000..7aea1020728 --- /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.test.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx new file mode 100644 index 00000000000..94beb4dcbd2 --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom +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: 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', async () => { + const settings = createGlobalSettingsFixture() + const updateSettings = persistInMock(settings) + render() + fireEvent.click(screen.getByRole('switch', { name: 'Match terminal interface' })) + await waitFor(() => + expect(updateSettings).toHaveBeenCalledWith({ + nativeChatAppearance: { matchTerminalInterface: true } + }) + ) + }) + + 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' }) + 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', 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' }) + await waitFor(() => + expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { contrast: 119 } }) + ) + rerender( + + ) + expect(document.activeElement).toBe(slider) + fireEvent.keyDown(slider, { key: 'ArrowLeft' }) + await waitFor(() => + expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { contrast: 118 } }) + ) + }) +}) diff --git a/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx new file mode 100644 index 00000000000..64b3573bfd3 --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceChatContrastControls.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import type { NativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings' +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 = { + appearance: Required + onChange: (updates: NativeChatAppearanceSettings) => void + forceVisiblePrimary?: boolean +} + +export function AppearanceChatContrastControls({ + appearance, + onChange, + forceVisiblePrimary +}: ChatContrastControlsProps): React.JSX.Element { + const entries = getChatContrastEntriesByKey() + const { contrast, matchTerminalInterface: matching } = appearance + return ( + <> + + onChange({ matchTerminalInterface: !matching })} + /> + + + + } + /> + + + ) +} + +function ChatContrastSlider({ + contrast, + 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) { + setDraft({ savedContrast: contrast, value: contrast }) + } + return ( +
+ + {translate('settings.appearance.chat.softer', 'Softer')} + + +
+ setDraft({ savedContrast: contrast, value })} + onValueCommit={([value]) => onChange({ contrast: value })} + /> +
+
+ + {translate('settings.appearance.chat.sharper', 'Sharper')} + + + {draft.value} + +
+ ) +} diff --git a/src/renderer/src/components/settings/AppearanceChatSection.test.tsx b/src/renderer/src/components/settings/AppearanceChatSection.test.tsx index ba53111824c..25c59216797 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, @@ -132,7 +133,9 @@ describe('chat appearance settings controls', () => { fontSize: 18, codeFontSize: 16, width: 'wide' as const, - contrast: 151 + contrast: 130, + matchTerminalInterface: false, + futureSetting: 'keep' } } const updateSettings = persistInMock(settings) @@ -142,12 +145,15 @@ 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() for (const query of [ 'Chat', + 'Match terminal interface', + 'Contrast', + 'brighter look', 'Code text size', 'tool output', 'Comfortable', @@ -158,4 +164,103 @@ 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( + , + { wrapper: TooltipProvider } + ) + 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') + }) + + 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 067b0768cb7..94e72ad91fd 100644 --- a/src/renderer/src/components/settings/AppearanceChatSection.tsx +++ b/src/renderer/src/components/settings/AppearanceChatSection.tsx @@ -7,10 +7,16 @@ 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' -import { getChatAppearanceEntriesByKey, getChatWidthOptions } from './chat-appearance-search' +import { + chatTerminalControlledHint, + chatTerminalControlledTooltip, + getChatAppearanceEntriesByKey, + getChatWidthOptions +} from './chat-appearance-search' import { writeNativeChatAppearance } from '../native-chat/native-chat-appearance-write' import type { GlobalSettings } from '../../../../shared/global-settings-types' @@ -26,6 +32,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) @@ -38,6 +45,11 @@ export function AppearanceChatSection({ } return (
+ { 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() + }) }) diff --git a/src/renderer/src/components/settings/SettingsDisabledControlTooltip.tsx b/src/renderer/src/components/settings/SettingsDisabledControlTooltip.tsx new file mode 100644 index 00000000000..2228b6cd4c0 --- /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 ef374619344..d7e276803de 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' @@ -275,6 +276,9 @@ type NumberFieldProps = { placeholder?: string suffix?: string className?: string + disabled?: boolean + /** Hover explanation shown only while the field is disabled. */ + disabledReason?: string } export function ColorField({ @@ -323,7 +327,9 @@ export function NumberField({ onClear, placeholder, suffix, - className + className, + disabled = false, + disabledReason }: NumberFieldProps): React.JSX.Element { const [draft, setDraft] = useState(Number.isFinite(value) ? String(value) : '') const [prevValue, setPrevValue] = useState(value) @@ -335,6 +341,9 @@ export function NumberField({ } const commit = (): void => { + if (disabled) { + return + } const trimmed = draft.trim() if (trimmed === '') { if (onClear) { @@ -374,23 +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 a35a08aa912..5ae313e2133 100644 --- a/src/renderer/src/components/settings/chat-appearance-search.ts +++ b/src/renderer/src/components/settings/chat-appearance-search.ts @@ -3,6 +3,46 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog' import type { SettingsSearchEntry } from './settings-search' import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' +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( + () => + ({ + 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( () => ({ @@ -63,10 +103,9 @@ export function getChatAppearanceEntriesByKey(shortcuts?: { increase: string; de export function getChatAppearanceSearchEntries(): SettingsSearchEntry[] { return [ - { - title: translate('auto.components.settings.Settings.2b4474780a', 'Appearance') - }, - ...Object.values(getChatAppearanceEntriesByKey()) + { title: translate('auto.components.settings.Settings.2b4474780a', 'Appearance') }, + ...Object.values(getChatAppearanceEntriesByKey()), + ...getChatContrastSearchEntries() ] } diff --git a/src/renderer/src/hooks/zoom-routing.test.ts b/src/renderer/src/hooks/zoom-routing.test.ts index 6aa3b2d1d6c..370e229fb01 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 e8d79b4e4fa..d3a19ec6cda 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -233,7 +233,15 @@ "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", + "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 d769e6f3dcd..81fbdbe4c99 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -70,7 +70,15 @@ "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", + "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 fec8d16043d..22ecb1c2052 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -169,7 +169,15 @@ "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", + "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 88379cfe2ab..887ddd9b93a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -70,7 +70,15 @@ "wide": "広い", "full": "全幅", "pixels": "px", - "reset": "リセット" + "reset": "リセット", + "matchTerminalInterface": "ターミナルの外観に合わせる", + "matchTerminalInterfaceDescription": "チャット全体にターミナルと同じフォントと配色を使用します。ターミナルのテーマを変更すると、チャットにも反映されます。", + "contrast": "コントラスト", + "contrastDescription": "背景に対するチャットの文字の明るさを調整します。右端にすると、以前の明るい表示になります。", + "softer": "柔らかく", + "sharper": "くっきり", + "terminalControlledHint": "ターミナルの外観設定に従います。", + "terminalControlledTooltip": "ターミナルの外観に合わせています。変更するには「ターミナルの外観に合わせる」をオフにしてください。" } }, "browser": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5adf75f1fff..41eb6d72242 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -70,7 +70,15 @@ "wide": "넓게", "full": "전체", "pixels": "px", - "reset": "초기화" + "reset": "초기화", + "matchTerminalInterface": "터미널 모양에 맞추기", + "matchTerminalInterfaceDescription": "전체 채팅에 터미널과 같은 글꼴과 색상을 사용합니다. 터미널 테마를 변경하면 채팅에도 반영됩니다.", + "contrast": "대비", + "contrastDescription": "배경에 대한 채팅 텍스트의 밝기를 조절합니다. 오른쪽 끝으로 이동하면 이전의 더 밝은 모습으로 표시됩니다.", + "softer": "부드럽게", + "sharper": "선명하게", + "terminalControlledHint": "터미널 인터페이스 설정을 따릅니다.", + "terminalControlledTooltip": "터미널 모양에 맞추는 중입니다. 변경하려면 터미널 모양에 맞추기를 끄세요." } }, "browser": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5a0fbe1c6d6..e355a865a2c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -70,7 +70,15 @@ "wide": "宽", "full": "全宽", "pixels": "px", - "reset": "重置" + "reset": "重置", + "matchTerminalInterface": "匹配终端外观", + "matchTerminalInterfaceDescription": "整个聊天使用与终端相同的字体和配色。更改终端主题后,聊天会自动同步。", + "contrast": "对比度", + "contrastDescription": "调整聊天文字相对于背景的亮度。滑到最右端可恢复以前更明亮的外观。", + "softer": "更柔和", + "sharper": "更清晰", + "terminalControlledHint": "由终端界面设置。", + "terminalControlledTooltip": "正在匹配终端外观。要更改此项,请关闭“匹配终端外观”。" } }, "browser": { diff --git a/src/shared/native-chat-appearance-settings.test.ts b/src/shared/native-chat-appearance-settings.test.ts index 4a52b28fda5..5e26ccc6e5b 100644 --- a/src/shared/native-chat-appearance-settings.test.ts +++ b/src/shared/native-chat-appearance-settings.test.ts @@ -8,21 +8,36 @@ 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) }) + 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({ @@ -34,27 +49,35 @@ 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', () => { + 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() }) 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 +95,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 2dd1924a368..44dec0b64ba 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,23 @@ 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 +66,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 } } @@ -63,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) ) ) )