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)
)
)
)