diff --git a/ui-opentui/src/boundary/renderer.ts b/ui-opentui/src/boundary/renderer.ts
index aa5577075b9..1131fd12990 100644
--- a/ui-opentui/src/boundary/renderer.ts
+++ b/ui-opentui/src/boundary/renderer.ts
@@ -12,6 +12,7 @@
import { createCliRenderer, type CliRenderer, type KeyEvent, type Selection } from '@opentui/core'
import { Deferred, Effect } from 'effect'
+import { DEFAULT_THEME } from '../logic/theme.ts'
import { RendererError } from './errors.ts'
import { installFfiCoordSafety } from './ffiSafe.ts'
import { getLog } from './log.ts'
@@ -66,6 +67,10 @@ export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options:
Effect.tryPromise({
try: () =>
createCliRenderer({
+ // Root canvas (design pass): paint the dark room from the first frame
+ // — true black by default. Skin overrides land reactively via the
+ // header's theme effect (view/header.tsx setBackgroundColor).
+ backgroundColor: DEFAULT_THEME.color.bg,
// scrollbox clips growing output → no terminal-scrollback corruption (gotcha §8 #2).
externalOutputMode: 'passthrough',
targetFps: 60,
diff --git a/ui-opentui/src/logic/theme.ts b/ui-opentui/src/logic/theme.ts
index 8941aa9f0f8..8cd63a3c2dd 100644
--- a/ui-opentui/src/logic/theme.ts
+++ b/ui-opentui/src/logic/theme.ts
@@ -22,6 +22,10 @@ export interface ThemeColors {
border: string
text: string
muted: string
+ /** Root canvas background (design pass: the view is a dark room — true black
+ * by default; skins may override via `ui_bg`). CSS names are valid OpenTUI
+ * ColorInput, so the defaults use `black`/`white`, not invented hexes. */
+ bg: string
completionBg: string
completionCurrentBg: string
completionMetaBg: string
@@ -252,7 +256,13 @@ export const DARK_THEME: Theme = {
accent: '#FFBF00',
border: '#CD7F32',
text: '#FFF8DC',
- muted: '#CC9B1F',
+ // TRUE NEUTRAL (design pass precondition): muted was `#CC9B1F` — itself
+ // gold — so "dim" read as "darker gold" and the hero color was the
+ // wallpaper. Re-pointed to the statusFg `#C0C0C0` (silver) family's darker
+ // step, CSS `gray` — no invented hexes. Grey = everything that merely
+ // happened; gold stays earned.
+ muted: '#808080',
+ bg: 'black',
completionBg: '#1a1a2e',
completionCurrentBg: '#333355',
completionMetaBg: '#1a1a2e',
@@ -264,8 +274,9 @@ export const DARK_THEME: Theme = {
warn: '#ffa726',
prompt: '#FFF8DC',
- sessionLabel: '#CC9B1F',
- sessionBorder: '#CC9B1F',
+ // session chrome rides the same neutral muted family (was gold #CC9B1F).
+ sessionLabel: '#808080',
+ sessionBorder: '#808080',
statusBg: '#1a1a2e',
statusFg: '#C0C0C0',
@@ -294,7 +305,10 @@ export const LIGHT_THEME: Theme = {
accent: '#A0651C',
border: '#7A4F1F',
text: '#3D2F13',
- muted: '#7A5A0F',
+ // same disease as dark: muted was `#7A5A0F` (gold-brown). True neutral —
+ // the statusFg `#333333` family's lighter step, CSS `dimgray`.
+ muted: '#696969',
+ bg: 'white',
completionBg: '#F5F5F5',
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),
completionMetaBg: '#F5F5F5',
@@ -306,8 +320,8 @@ export const LIGHT_THEME: Theme = {
warn: '#E65100',
prompt: '#2B2014',
- sessionLabel: '#7A5A0F',
- sessionBorder: '#7A5A0F',
+ sessionLabel: '#696969',
+ sessionBorder: '#696969',
statusBg: '#F5F5F5',
statusFg: '#333333',
@@ -449,6 +463,8 @@ export function fromSkin(
border: c('ui_border') ?? c('banner_border') ?? d.color.border,
text: c('ui_text') ?? c('banner_text') ?? d.color.text,
muted,
+ // root canvas — skins may override (`ui_bg`); default true black/white.
+ bg: c('ui_bg') ?? d.color.bg,
completionBg,
completionCurrentBg,
completionMetaBg,
diff --git a/ui-opentui/src/logic/toolOutput.ts b/ui-opentui/src/logic/toolOutput.ts
index 888f59dd326..d2e7ecd5142 100644
--- a/ui-opentui/src/logic/toolOutput.ts
+++ b/ui-opentui/src/logic/toolOutput.ts
@@ -115,6 +115,17 @@ export function stripOmittedNote(text: string): { body: string; omittedNote?: st
return { body: s.slice(match[0].length), omittedNote: match[1] ?? '' }
}
+/**
+ * Width cap for a collapsed tool header's ARGS preview (design pass): args are
+ * context, not content — they get at most ~half the pane, so a long command or
+ * path can never become the loudest mass on screen. Shared by the header
+ * truncation (toolPart) and the bash body's "did the header already show the
+ * whole command" echo check (bashTool) so the two stay mirrored.
+ */
+export function argsCapColumns(totalWidth: number): number {
+ return Math.max(8, Math.floor(totalWidth / 2))
+}
+
/**
* Collapse text to at most `maxLines` lines, each capped to `width` columns. The
* view renders an overflow marker from `hiddenLines`; this stays pure (no marker).
diff --git a/ui-opentui/src/test/displayModes.test.tsx b/ui-opentui/src/test/displayModes.test.tsx
index f4fbf051092..32b8731792b 100644
--- a/ui-opentui/src/test/displayModes.test.tsx
+++ b/ui-opentui/src/test/displayModes.test.tsx
@@ -55,9 +55,9 @@ describe('/details — global detail mode drives default expansion (frame)', ()
// default: collapsed — tool body lines stay hidden, Thought folded.
// (Markdown BODY text never paints in the headless char frame — a known
// harness limitation, see render.test.tsx — so assertions stick to the
- // plain-text renderables: tool output lines + the ▶/▼ headers.)
+ // plain-text renderables: tool output lines + the ◐/▼ headers.)
const collapsed = await probe.waitForFrame(f => f.includes('terminal'))
- expect(collapsed).toContain('▶ Thought: Plan')
+ expect(collapsed).toContain('◐ Thought: Plan')
expect(collapsed).not.toContain('beta.txt')
// /details expanded → tool body + reasoning preview default-open (no clicks)
@@ -70,7 +70,7 @@ describe('/details — global detail mode drives default expansion (frame)', ()
store.setDetails('collapsed')
const back = await probe.waitForFrame(f => !f.includes('beta.txt'))
expect(back).toContain('terminal')
- expect(back).toContain('▶ Thought: Plan')
+ expect(back).toContain('◐ Thought: Plan')
} finally {
probe.destroy()
}
@@ -94,7 +94,7 @@ describe('/details — global detail mode drives default expansion (frame)', ()
// restore — flipping the mode back brings the rows straight back
store.setDetails('collapsed')
const restored = await probe.waitForFrame(f => f.includes('terminal'))
- expect(restored).toContain('▶ Thought: Plan')
+ expect(restored).toContain('◐ Thought: Plan')
expect(restored).not.toContain('hidden — /details')
} finally {
probe.destroy()
@@ -115,7 +115,9 @@ describe('/compact — transcript spacing (frame line-count)', () => {
const a = rows.findIndex(r => r.includes('alpha-line'))
const b = rows.findIndex(r => r.includes('beta-line'))
expect(a).toBeGreaterThanOrEqual(0)
- expect(b - a).toBe(2) // one blank line between turns
+ // user turns are set off by MORE space than the part gap (design pass:
+ // turn boundary > part gap): top 2 + bottom 1 around each prompt.
+ expect(b - a).toBe(4)
store.setCompact(true)
await probe.settle()
@@ -129,7 +131,7 @@ describe('/compact — transcript spacing (frame line-count)', () => {
const again = probe.frame().split('\n')
const a3 = again.findIndex(r => r.includes('alpha-line'))
const b3 = again.findIndex(r => r.includes('beta-line'))
- expect(b3 - a3).toBe(2)
+ expect(b3 - a3).toBe(4)
} finally {
probe.destroy()
}
diff --git a/ui-opentui/src/test/inkBudget.test.tsx b/ui-opentui/src/test/inkBudget.test.tsx
new file mode 100644
index 00000000000..2e24d4c3810
--- /dev/null
+++ b/ui-opentui/src/test/inkBudget.test.tsx
@@ -0,0 +1,189 @@
+/**
+ * Visual-hierarchy design pass (Appendix C) — the ink budget. "Gold is the
+ * single lamp: it sits on the newest answer and on the ❯ waiting for the next
+ * command, nowhere else. Blue is the hum of machinery. Grey is everything that
+ * merely happened."
+ *
+ * Layers:
+ * 1. theme: `muted` is a TRUE NEUTRAL (no longer the gold hue family) in
+ * both themes; the new `bg` token paints the root canvas (skin override).
+ * 2. glyph vocabulary: the per-tool glyph map (registry) — identity survives
+ * the default collapsed view.
+ * 3. messageLine roles: earned gold (glyphColor), muted user body
+ * (bodyColor), turn boundary > part gap (turnSpacing), settled-turn
+ * narration demotion (lastTextId).
+ * 4. frames: machinery indent (+2 under the turn) for tool + thinking rows.
+ */
+import { describe, expect, test } from 'vitest'
+
+import { createSessionStore } from '../logic/store.ts'
+import { DARK_THEME, fromSkin, LIGHT_THEME } from '../logic/theme.ts'
+import { App } from '../view/App.tsx'
+import { bodyColor, glyphColor, lastTextId, turnSpacing } from '../view/messageLine.tsx'
+import { ThemeProvider } from '../view/theme.tsx'
+import { DEFAULT_TOOL_GLYPH, glyphFor, TOOL_GLYPHS } from '../view/tools/registry.tsx'
+import { renderProbe } from './lib/render.ts'
+
+// ── 1. theme: neutral muted + bg token ───────────────────────────────────
+
+/** True when a hex color is achromatic (r=g=b — a pure grey, no hue). */
+function isAchromatic(hex: string): boolean {
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex)
+ if (!m) return false
+ return m[1] === m[2] && m[2] === m[3]
+}
+
+describe('theme — muted is a true neutral, not darker gold (design-pass precondition)', () => {
+ test('DARK muted left the gold hue family: pure grey in the statusFg silver family', () => {
+ expect(DARK_THEME.color.muted).toBe('#808080') // CSS gray — silver family, darker step
+ expect(isAchromatic(DARK_THEME.color.muted)).toBe(true)
+ expect(DARK_THEME.color.muted).not.toBe('#CC9B1F') // the old gold "dim"
+ })
+
+ test('LIGHT muted had the same disease — now neutral too', () => {
+ expect(LIGHT_THEME.color.muted).toBe('#696969') // CSS dimgray
+ expect(isAchromatic(LIGHT_THEME.color.muted)).toBe(true)
+ })
+
+ test('session label/border rode the gold muted — now the same neutral', () => {
+ expect(isAchromatic(DARK_THEME.color.sessionLabel)).toBe(true)
+ expect(isAchromatic(DARK_THEME.color.sessionBorder)).toBe(true)
+ })
+
+ test('bg token: true black (dark) / white (light) by CSS name', () => {
+ expect(DARK_THEME.color.bg).toBe('black')
+ expect(LIGHT_THEME.color.bg).toBe('white')
+ })
+
+ test('fromSkin: skins may override bg via ui_bg; default stays the theme bg', () => {
+ expect(fromSkin({ ui_bg: '#101010' }, {}).color.bg).toBe('#101010')
+ expect(fromSkin({}, {}).color.bg).toBe(DARK_THEME.color.bg)
+ })
+
+ test('skin mapping intact: banner_dim still re-points muted (skins keep their dim)', () => {
+ expect(fromSkin({ banner_dim: '#445566' }, {}).color.muted).toBe('#445566')
+ })
+})
+
+// ── 2. glyph vocabulary ──────────────────────────────────────────────────
+
+describe('tool glyph vocabulary (registry) — identity survives the collapsed view', () => {
+ test('the settled per-tool glyph map, pinned', () => {
+ expect(TOOL_GLYPHS).toEqual({
+ clarify: '?',
+ delegate_task: '⚕',
+ execute_code: '$',
+ patch: '◆',
+ process: '$',
+ read_file: '◇',
+ search_files: '○',
+ skill_manage: '▲',
+ skill_view: '▲',
+ terminal: '$',
+ web_extract: '●',
+ web_search: '●',
+ write_file: '◆'
+ })
+ })
+
+ test('MCP/unknown tools fall back to ◦', () => {
+ expect(glyphFor('mcp_railway_deploy')).toBe(DEFAULT_TOOL_GLYPH)
+ expect(glyphFor('totally_new_tool')).toBe('◦')
+ expect(glyphFor('terminal')).toBe('$')
+ })
+})
+
+// ── 3. messageLine roles (pure) ──────────────────────────────────────────
+
+const color = DARK_THEME.color
+
+describe('glyphColor — gold is earned', () => {
+ test('the user ❯ and the NEWEST answer ⚕ are primary; older answers grey', () => {
+ expect(glyphColor('user', false, color)).toBe(color.primary)
+ expect(glyphColor('assistant', true, color)).toBe(color.primary)
+ expect(glyphColor('assistant', false, color)).toBe(color.muted)
+ expect(glyphColor('system', false, color)).toBe(color.muted)
+ })
+})
+
+describe('bodyColor — the answer is the only full-bright prose', () => {
+ test('user body is muted (your words are context); assistant bright; system dim', () => {
+ expect(bodyColor('user', color)).toBe(color.muted)
+ expect(bodyColor('assistant', color)).toBe(color.text)
+ expect(bodyColor('system', color)).toBe(color.muted)
+ })
+})
+
+describe('turnSpacing — turn boundary > part gap', () => {
+ test('a user turn gets blank space above AND below, MORE than the 1-row part gap', () => {
+ const user = turnSpacing('user', false)
+ expect(user.top).toBeGreaterThan(1) // above the prompt > part gap
+ expect(user.bottom).toBeGreaterThanOrEqual(1) // blank line below too
+ expect(turnSpacing('assistant', false)).toEqual({ bottom: 0, top: 1 })
+ expect(turnSpacing('system', false)).toEqual({ bottom: 0, top: 1 })
+ })
+
+ test('/compact collapses all turn margins', () => {
+ expect(turnSpacing('user', true)).toEqual({ bottom: 0, top: 0 })
+ expect(turnSpacing('assistant', true)).toEqual({ bottom: 0, top: 0 })
+ })
+})
+
+describe('lastTextId — settled-turn narration demotion', () => {
+ test('finds the FINAL text part (the answer that keeps full-bright text)', () => {
+ expect(
+ lastTextId([
+ { id: 'p1', text: 'Let me look…', type: 'text' },
+ { id: 'p2', name: 'terminal', state: 'complete', type: 'tool' },
+ { id: 'p3', text: 'The answer.', type: 'text' }
+ ])
+ ).toBe('p3')
+ })
+
+ test('no text parts → undefined (nothing demotes)', () => {
+ expect(lastTextId([{ id: 'p1', name: 'terminal', state: 'complete', type: 'tool' }])).toBeUndefined()
+ expect(lastTextId(undefined)).toBeUndefined()
+ expect(lastTextId([])).toBeUndefined()
+ })
+})
+
+// ── 4. frames: machinery indent ──────────────────────────────────────────
+
+describe('machinery tier indent — tools + thinking nest +2 under the turn', () => {
+ test('tool and Thought rows sit 2 columns right of where flat content starts', async () => {
+ const store = createSessionStore()
+ store.apply({ type: 'gateway.ready' })
+ store.pushUser('inspect the files')
+ store.apply({ type: 'message.start' })
+ store.apply({ payload: { text: '**Plan**\n\nthink' }, type: 'reasoning.delta' })
+ store.apply({ payload: { context: 'ls', name: 'terminal', tool_id: 't1' }, type: 'tool.start' })
+ store.apply({
+ payload: { args: { command: 'ls' }, duration_s: 0.1, name: 'terminal', result_text: 'a\nb', tool_id: 't1' },
+ type: 'tool.complete'
+ })
+ store.apply({ type: 'message.complete' })
+
+ const probe = await renderProbe(
+ () => (
+ store.state.theme}>
+
+
+ ),
+ { height: 30, width: 80 }
+ )
+ try {
+ const frame = await probe.waitForFrame(f => f.includes('terminal') && f.includes('inspect the files'))
+ const rows = frame.split('\n')
+ const userCol = (rows.find(r => r.includes('inspect the files')) ?? '').indexOf('inspect the files')
+ const toolCol = (rows.find(r => r.includes('$ terminal')) ?? '').indexOf('$ terminal')
+ const thoughtCol = (rows.find(r => r.includes('◐ Thought')) ?? '').indexOf('◐ Thought')
+ expect(userCol).toBeGreaterThanOrEqual(0)
+ // machinery rows start +2 columns right of base content (the user body
+ // shares the parts column's base x — both sit after the 2-col gutter).
+ expect(toolCol).toBe(userCol + 2)
+ expect(thoughtCol).toBe(userCol + 2)
+ } finally {
+ probe.destroy()
+ }
+ })
+})
diff --git a/ui-opentui/src/test/tools.test.tsx b/ui-opentui/src/test/tools.test.tsx
index 330b81e4d34..565e88c814d 100644
--- a/ui-opentui/src/test/tools.test.tsx
+++ b/ui-opentui/src/test/tools.test.tsx
@@ -603,7 +603,7 @@ describe('tool lifecycle states — running / done / failed (Epic 2.5)', () => {
}
})
- test('settled success keeps the ▶/▼ + duration contract (glyph never error-colored ✗)', async () => {
+ test('settled success shows the PER-TOOL glyph ($) collapsed, ▼ expanded + duration (never ✗)', async () => {
const store = createSessionStore()
seedTool(
store,
@@ -615,7 +615,7 @@ describe('tool lifecycle states — running / done / failed (Epic 2.5)', () => {
try {
const frame = await probe.waitForFrame(f => f.includes('terminal'))
const row = frame.split('\n').find(line => line.includes('terminal')) ?? ''
- expect(row).toContain('▶ terminal') // head-glyph position, after the ⚕ gutter
+ expect(row).toContain('$ terminal') // per-tool glyph in the head position (design pass)
expect(row).toContain('· 0.3s')
expect(row).not.toContain('✗')
expect(row).not.toContain('⚡')
@@ -800,8 +800,8 @@ describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only en
describe('tool-name emphasis + thought styling (feedback: undifferentiated muted rows)', () => {
const color = DEFAULT_THEME.color
- test('toolNameStyle: settled name is PRIMARY (text color + bold); subtitle stays muted by the shell', () => {
- expect(toolNameStyle({ failed: false, running: false }, color)).toEqual({ bold: true, fg: color.text })
+ test('toolNameStyle: settled name is muted-BRIGHT (statusFg + bold) — machinery, never the answer color', () => {
+ expect(toolNameStyle({ failed: false, running: false }, color)).toEqual({ bold: true, fg: color.statusFg })
expect(color.text).not.toBe(color.muted) // the emphasis is real, not a no-op
})
diff --git a/ui-opentui/src/view/composer.tsx b/ui-opentui/src/view/composer.tsx
index 32d7f889e8b..595fc3b70de 100644
--- a/ui-opentui/src/view/composer.tsx
+++ b/ui-opentui/src/view/composer.tsx
@@ -421,11 +421,15 @@ export function Composer(props: {
{/* prompt glyph + textarea — the glyph (item 3) marks the input line so the
composer is distinguished by structure (glyph + the status-bar rule above),
- not a background tint. */}
+ not a background tint. PRIMARY BOLD: the idle view's one bright action
+ (design pass — gold sits on the newest answer and on the ❯ waiting for
+ the next command, nowhere else). */}
- {theme().brand.prompt}
+
+ {theme().brand.prompt}
+