From 639a9cb9a78dbb5ba2db24b104d7ba6f033478d5 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 23:55:04 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20ink=20budget=20=E2=80=94=20earne?= =?UTF-8?q?d=20gold,=20blue=20machinery,=20neutral=20muted=20(design=20pas?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-opentui/src/boundary/renderer.ts | 5 + ui-opentui/src/logic/theme.ts | 28 +++- ui-opentui/src/logic/toolOutput.ts | 11 ++ ui-opentui/src/test/displayModes.test.tsx | 14 +- ui-opentui/src/test/inkBudget.test.tsx | 189 ++++++++++++++++++++++ ui-opentui/src/test/tools.test.tsx | 8 +- ui-opentui/src/view/composer.tsx | 8 +- ui-opentui/src/view/header.tsx | 26 ++- ui-opentui/src/view/markdown.tsx | 7 +- ui-opentui/src/view/messageLine.tsx | 95 +++++++++-- ui-opentui/src/view/reasoningPart.tsx | 23 ++- ui-opentui/src/view/statusBar.tsx | 4 +- ui-opentui/src/view/toolPart.tsx | 79 +++++---- ui-opentui/src/view/tools/bashTool.tsx | 25 +-- ui-opentui/src/view/tools/defaultTool.tsx | 5 +- ui-opentui/src/view/tools/registry.tsx | 33 ++++ ui-opentui/src/view/transcript.tsx | 15 +- 17 files changed, 484 insertions(+), 91 deletions(-) create mode 100644 ui-opentui/src/test/inkBudget.test.tsx 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} +