From 30ee6f749d551ed1d11045d8f053c7f85f493d5e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 19:41:59 -0500 Subject: [PATCH] feat(themes): element tokens (ui_tool, ui_thinking) + skinnable diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theming was semantic-only: the gold tool `●` was `accent`, shared with headings/links/chevrons, so "recolor tool calls" was impossible and the agent had no key to point at. Add `ui_tool` (● + tool spinner) and `ui_thinking` (reasoning body) tokens that fall back to accent/muted — defaults unchanged, but now independently settable. Make diffs skinnable too (`diff_*`), which fromSkin previously hardcoded. Document the full element→key map in the skill so Hermes knows which knob turns what. --- apps/shared/src/skin.ts | 7 +++++ hermes_cli/skin_engine.py | 6 +++++ skills/hermes-themes/SKILL.md | 41 +++++++++++++++++------------- ui-tui/src/__tests__/theme.test.ts | 29 +++++++++++++++++++++ ui-tui/src/components/thinking.tsx | 18 ++++++------- ui-tui/src/theme.ts | 36 ++++++++++++++++++++------ 6 files changed, 103 insertions(+), 34 deletions(-) diff --git a/apps/shared/src/skin.ts b/apps/shared/src/skin.ts index b130d190e67..cc518817f0b 100644 --- a/apps/shared/src/skin.ts +++ b/apps/shared/src/skin.ts @@ -37,6 +37,13 @@ export const SKIN_COLOR_TOKENS = [ 'ui_warn', 'ui_error', 'ui_label', + // Element-specific (fall back to accent/muted when unset). + 'ui_tool', + 'ui_thinking', + 'diff_added', + 'diff_removed', + 'diff_added_word', + 'diff_removed_word', // CLI / TUI chrome. 'prompt', 'input_rule', diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 8f018249cf1..843f76cc620 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -36,6 +36,12 @@ All fields are optional. Missing values inherit from the ``default`` skin. ui_ok: "#4caf50" # Success indicators ui_error: "#ef5350" # Error indicators ui_warn: "#ffa726" # Warning indicators + ui_tool: "#FFBF00" # Tool-call markers (● / spinner); falls back to ui_accent + ui_thinking: "#CC9B1F" # Reasoning/thinking text; falls back to banner_dim + diff_added: "#dcffdc" # Diff added-line background (TUI) + diff_removed: "#ffdcdc" # Diff removed-line background + diff_added_word: "#248a3d" # Diff added word-level foreground + diff_removed_word: "#cf222e" # Diff removed word-level foreground prompt: "#FFF8DC" # Prompt text color input_rule: "#CD7F32" # Input area horizontal rule response_border: "#FFD700" # Response box border (ANSI) diff --git a/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md index 83ad4f0e70c..91c24b4dbd1 100644 --- a/skills/hermes-themes/SKILL.md +++ b/skills/hermes-themes/SKILL.md @@ -41,26 +41,33 @@ editors or ship built-in presets. 3. `write_file` it to `/skins/.yaml`. 4. Activate it (see Procedure). Confirm the change landed. -## Quick Reference +## Quick Reference — element → key -Load-bearing color keys (hex, `#rrggbb`). The desktop GUI derives its whole -palette from these; the TUI and CLI read the terminal-oriented ones directly. +Hex (`#rrggbb`). Theming is **semantic**: one key colors every element that plays +that role, so match the element to its key. To recolor a specific element, set the +key in its row (element-specific keys fall back to the shared one when unset). -| Key | Drives | -|---|---| -| `background` | Base surface. Paints the whole TUI (OSC 11) + seeds the GUI. Set it. | -| `ui_accent` / `banner_accent` | Brand accent: buttons, rings, primary. | -| `banner_title` | Headings / primary text. | -| `banner_text` / `ui_text` | Body foreground. | -| `banner_border` / `ui_border` | Borders. | -| `banner_dim` | Muted / secondary text. | -| `ui_ok` / `ui_warn` / `ui_error` | Semantic status colors. | -| `status_bar_bg` / `status_bar_text` | TUI status bar. | -| `response_border` | CLI response box. | +| Visible element | Key to set | Falls back to | +|---|---|---| +| App background (whole TUI + GUI) | `background` | terminal default | +| **Tool-call marker** (`●`, tool spinner) | `ui_tool` | `ui_accent` | +| **Thinking / reasoning text** | `ui_thinking` | `banner_dim` | +| Accent — headings, links, chevrons, `Σ` | `ui_accent` / `banner_accent` | — | +| Heading / primary text | `banner_title` / `ui_primary` | — | +| Body / label text, user messages | `ui_text` / `banner_text`, `ui_label` | — | +| Muted / secondary, tree connectors | `banner_dim` | — | +| Borders, rules, gutters | `ui_border` / `banner_border` | — | +| Prompt symbol color | `prompt` | `banner_text` | +| Success / warn / error | `ui_ok` / `ui_warn` / `ui_error` | — | +| Status bar text + usage | `status_bar_text`, `status_bar_good/warn/bad/critical` | — | +| Diff add/remove (line + word) | `diff_added` / `diff_removed` / `diff_added_word` / `diff_removed_word` | built-in | +| Completion menu | `completion_menu_bg` / `completion_menu_current_bg` / `…_meta_bg` | — | -`branding` (`agent_name`, `welcome`, `goodbye`, `prompt_symbol`, `help_header`), -`spinner` (faces/verbs/wings), and `tool_prefix` are optional flavor. See the -full schema in `hermes_cli/skin_engine.py`. +Note the sharing: `ui_accent` colors tool markers **and** headings/links/chevrons, +so to recolor *only* tool calls (the classic "change the gold `●`") set `ui_tool`. +`branding` (`agent_name`, `prompt_symbol`, `welcome`, `goodbye`, `help_header`), +`spinner`, and `tool_prefix` are optional flavor; full schema in +`hermes_cli/skin_engine.py`. ## Procedure diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 586fae528c3..57d03d2eff6 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -533,6 +533,35 @@ describe('background-aware adaptation (OSC-11 light terminals)', () => { expect(defaultThemeForCurrentBackground({ HERMES_TUI_BACKGROUND: '#ffffff' }).color).toEqual(LIGHT_THEME.color) }) + it('gives tool + thinking their own keys, defaulting to accent + muted', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + + // Independent override: recolor tool markers without touching accent. + const themed = fromSkin({ ui_accent: '#111111', ui_tool: '#ff0000', ui_thinking: '#00ff00' }, {}) + expect(themed.color.tool).toBe('#ff0000') + expect(themed.color.thinking).toBe('#00ff00') + expect(themed.color.accent).toBe('#111111') + + // Default: tool follows accent, thinking follows muted. + const fallback = fromSkin({ ui_accent: '#abcdef', banner_dim: '#123456' }, {}) + expect(fallback.color.tool).toBe('#abcdef') + expect(fallback.color.thinking).toBe('#123456') + }) + + it('lets skins override diff colors', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + + const { color } = fromSkin( + { diff_added: '#0a0', diff_removed: '#a00', diff_added_word: '#0f0', diff_removed_word: '#f00' }, + {} + ) + + expect(color.diffAdded).toBe('#0a0') + expect(color.diffRemoved).toBe('#a00') + expect(color.diffAddedWord).toBe('#0f0') + expect(color.diffRemovedWord).toBe('#f00') + }) + it('maps the status bar from skin status_bar_* keys', async () => { const { fromSkin } = await importThemeWithCleanEnv() diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 016c99138af..84c85fb4d54 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -454,7 +454,7 @@ function SubagentAccordion({ color={t.color.text} content={ <> - + {line} } @@ -640,22 +640,22 @@ export const Thinking = memo(function Thinking({ {preview ? ( mode === 'full' ? ( lines.map((line, index) => ( - + {line || ' '} {index === lines.length - 1 ? ( - + ) : null} )) ) : ( - + {preview} - + ) ) : ( - - + + )} @@ -855,7 +855,7 @@ export const ToolTrail = memo(function ToolTrail({ : [], content: ( <> - {label} + {label} {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} ) @@ -1072,7 +1072,7 @@ export const ToolTrail = memo(function ToolTrail({ color={group.color} content={ <> - + {toolLabel(group)} {isDelegateGroup ? ( diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 8d496d92432..4901a3124fa 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -18,6 +18,11 @@ export interface ThemeColors { error: string warn: string + /** Tool-call markers (● bullet, tool spinner). Defaults to `accent`. */ + tool: string + /** Reasoning/thinking body text. Defaults to `muted`. */ + thinking: string + prompt: string sessionLabel: string sessionBorder: string @@ -82,10 +87,11 @@ const ANSI_NORMALIZED_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ 'statusWarn', 'statusBad', 'statusCritical', - 'shellDollar' + 'shellDollar', + 'tool' ] -const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder'] +const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder', 'thinking'] function xtermEightBitRgb(colorNumber: number): [number, number, number] { if (colorNumber >= 232) { @@ -299,6 +305,11 @@ export function buildPalette(seeds: ThemeSeeds, isLight: boolean): ThemeColors { error: seeds.error, warn: seeds.warn, + // Element tokens: independently settable, but default to their semantic + // parents (tool marker → accent, reasoning body → muted). + tool: seeds.accent, + thinking: muted, + prompt: seeds.prompt ?? seeds.text, // sessionLabel/sessionBorder track the muted tone — "same role, same // colour" by design (#11300). @@ -836,7 +847,15 @@ export function fromSkin( sessionBorder: c('session_border') ?? c('banner_dim') ?? derived.sessionBorder, statusBg: c('status_bar_bg') ?? surface, statusFg: c('status_bar_text') ?? derived.statusFg, - selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? derived.selectionBg + selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? derived.selectionBg, + // Element tokens + skinnable diffs (theme-sdk): overridable, else the + // derived defaults (tool→accent, thinking→muted, diff_* → DIFF_* ladder). + tool: c('ui_tool') ?? derived.tool, + thinking: c('ui_thinking') ?? derived.thinking, + diffAdded: c('diff_added') ?? derived.diffAdded, + diffRemoved: c('diff_removed') ?? derived.diffRemoved, + diffAddedWord: c('diff_added_word') ?? derived.diffAddedWord, + diffRemovedWord: c('diff_removed_word') ?? derived.diffRemovedWord } // 4. Guard: contrast floors against the real background + fill polarity. @@ -851,11 +870,12 @@ export function fromSkin( return normalizeThemeForAnsiLightTerminal( { // The element tokens theme-sdk introduced (ui_primary, ui_text, - // ui_border, ui_ok/warn/error, shell_dollar, status_bar_*) are read - // above into `seeds` and flow through buildPalette → adaptColorsToBackground, - // so `adapted` already honors them AND applies #20379's contrast/polarity - // machinery. Emitting a hand-mapped color block here would bypass that - // adaptation and regress theme quality. + // ui_border, ui_ok/warn/error, ui_tool, ui_thinking, shell_dollar, + // status_bar_*, diff_*) are read into `seeds`/`assembled` above and + // flow through buildPalette → adaptColorsToBackground, so `adapted` + // already honors them AND applies #20379's contrast/polarity machinery. + // Emitting a hand-mapped color block here would bypass that adaptation + // and regress theme quality. color: adapted, brand: {