feat(themes): element tokens (ui_tool, ui_thinking) + skinnable diffs

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-21 19:41:59 -05:00
parent 727f6704a7
commit 30ee6f749d
6 changed files with 103 additions and 34 deletions

View File

@ -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',

View File

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

View File

@ -41,26 +41,33 @@ editors or ship built-in presets.
3. `write_file` it to `<hermes-home>/skins/<name>.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

View File

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

View File

@ -454,7 +454,7 @@ function SubagentAccordion({
color={t.color.text}
content={
<>
<Text color={t.color.accent}> </Text>
<Text color={t.color.tool}> </Text>
{line}
</>
}
@ -640,22 +640,22 @@ export const Thinking = memo(function Thinking({
{preview ? (
mode === 'full' ? (
lines.map((line, index) => (
<Text color={t.color.muted} key={index} wrap="wrap-trim">
<Text color={t.color.thinking} key={index} wrap="wrap-trim">
{line || ' '}
{index === lines.length - 1 ? (
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
<StreamCursor color={t.color.thinking} streaming={streaming} visible={active} />
) : null}
</Text>
))
) : (
<Text color={t.color.muted} wrap="truncate-end">
<Text color={t.color.thinking} wrap="truncate-end">
{preview}
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
<StreamCursor color={t.color.thinking} streaming={streaming} visible={active} />
</Text>
)
) : (
<Text color={t.color.muted}>
<StreamCursor color={t.color.muted} streaming={streaming} visible={active} />
<Text color={t.color.thinking}>
<StreamCursor color={t.color.thinking} streaming={streaming} visible={active} />
</Text>
)}
</Box>
@ -855,7 +855,7 @@ export const ToolTrail = memo(function ToolTrail({
: [],
content: (
<>
<Spinner color={t.color.accent} variant="tool" /> {label}
<Spinner color={t.color.tool} variant="tool" /> {label}
{tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''}
</>
)
@ -1072,7 +1072,7 @@ export const ToolTrail = memo(function ToolTrail({
color={group.color}
content={
<>
<Text color={t.color.accent}> </Text>
<Text color={t.color.tool}> </Text>
{toolLabel(group)}
{isDelegateGroup ? (
<Text color={t.color.statusFg} dim>

View File

@ -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: {