opentui(v6): ink budget — earned gold, blue machinery, neutral muted (design pass)

This commit is contained in:
alt-glitch 2026-06-10 23:55:04 +05:30
parent a09fa9df42
commit 639a9cb9a7
17 changed files with 484 additions and 91 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ 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()
}
})
})

View file

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

View file

@ -421,11 +421,15 @@ export function Composer(props: {
</Show>
{/* 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). */}
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: theme().color.prompt }}>{theme().brand.prompt}</span>
<span style={{ fg: theme().color.primary }}>
<b>{theme().brand.prompt}</b>
</span>
</text>
</box>
<textarea

View file

@ -4,21 +4,39 @@
* brand · engine · ready/connecting, fully themed (`useTheme()`, NO hardcoded
* styles §7.5). All session chrome (model/context/cost/duration/profile/mcp/
* cwd) lives in the dense bottom status bar (`statusBar.tsx`).
*
* Design pass (Appendix C): persistent chrome must not spend gold the ``
* icon in accent is the ONLY warm pixel up here; the wordmark demotes to muted
* bold. This component also owns painting the ROOT CANVAS: it is always
* mounted, so a reactive effect pushes `theme.color.bg` (true black by
* default; skins may override) into `renderer.setBackgroundColor` the dark
* room the rest of the ink budget assumes.
*/
import { Show } from 'solid-js'
import { useRenderer } from '@opentui/solid'
import { createEffect, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { useTheme } from './theme.tsx'
export function Header(props: { store: SessionStore }) {
const theme = useTheme()
const renderer = useRenderer()
// Root canvas paint — best-effort (a styling miss must never crash chrome).
createEffect(() => {
const bg = theme().color.bg
try {
renderer.setBackgroundColor(bg)
} catch {
/* canvas paint is cosmetic */
}
})
return (
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
{/* brand glyph in accent + name in primary/bold so the header reads as the
top of the hierarchy, not just another text line (item 8). */}
{/* the accent icon is the header's single warm pixel; the wordmark is
muted BOLD structure without spending gold on decoration. */}
<span style={{ fg: theme().color.accent }}>{`${theme().brand.icon} `}</span>
<span style={{ fg: theme().color.primary }}>
<span style={{ fg: theme().color.muted }}>
<b>{theme().brand.name}</b>
</span>
<span style={{ fg: theme().color.muted }}> · opentui · </span>

View file

@ -36,8 +36,11 @@ function buildSyntaxStyle(theme: Theme): SyntaxStyle {
const c = theme.color
return SyntaxStyle.fromStyles({
default: { fg: rgba(c.text) },
'markup.heading': { bold: true, fg: rgba(c.primary) },
'markup.heading.1': { bold: true, fg: rgba(c.primary) },
// headings/links ride ACCENT, inline code LABEL (design pass): gold
// `primary` is reserved for the earned lamp (newest answer glyph, ``) —
// prose structure is warm but never the hero color.
'markup.heading': { bold: true, fg: rgba(c.accent) },
'markup.heading.1': { bold: true, fg: rgba(c.accent) },
'markup.heading.2': { bold: true, fg: rgba(c.accent) },
'markup.heading.3': { bold: true, fg: rgba(c.accent) },
'markup.bold': { bold: true, fg: rgba(c.text) },

View file

@ -5,13 +5,23 @@
* User/system rows (and settled/resumed assistant rows with no parts) render flat
* `text`. Fully themed; rich text via <b>/<span>, never an attributes bitmask (§8 #1).
*
* Visual hierarchy (design pass, Appendix C): the view is a dark room and gold
* is the single lamp it sits on the NEWEST answer's `⚕` and the user's ``,
* nowhere else (older assistant glyphs demote to grey: they merely happened).
* The user's prompt BODY is muted (your words are context; the answer is the
* reward) and the turn is set off by MORE blank space than the parts inside a
* turn (turn boundary > part gap see `turnSpacing`). Once a turn settles,
* interstitial narration text demotes to muted and only the FINAL text block
* keeps the full-bright answer color (see `lastTextId`).
*
* Stable `id` per part as the <For> key so a new tool part below a streaming text
* part doesn't remount it. Native <markdown> for text parts lands in 2b-ii.
* part doesn't remount it.
*/
import { For, Match, Show, Switch } from 'solid-js'
import { collapseHiddenParts, hiddenRunLabel } from '../logic/details.ts'
import type { Message } from '../logic/store.ts'
import type { Message, Part } from '../logic/store.ts'
import type { ThemeColors } from '../logic/theme.ts'
import { useDisplay } from './display.tsx'
import { Markdown } from './markdown.tsx'
import { ReasoningPart } from './reasoningPart.tsx'
@ -20,27 +30,73 @@ import { ToolPart } from './toolPart.tsx'
const GUTTER = 2
export function MessageLine(props: { message: Message }) {
/**
* Per-turn vertical margins (pure table-tested). Turn boundary > part gap:
* a USER turn gets a blank line above AND below (top 2 + bottom 1; with the
* next turn's own top 1 the boundary around a prompt is 2 rows vs the 1-row
* part gap), so prompts read as section breaks from across the room. /compact
* collapses everything to 0.
*/
export function turnSpacing(role: Message['role'], compact: boolean): { top: number; bottom: number } {
if (compact) return { bottom: 0, top: 0 }
if (role === 'user') return { bottom: 1, top: 2 }
return { bottom: 0, top: 1 }
}
/**
* Role-glyph color (pure table-tested). Gold is EARNED: the user's `` and
* the NEWEST answer's `` are primary; an older assistant glyph demotes to
* grey (it merely happened); system notes stay dim.
*/
export function glyphColor(role: Message['role'], latest: boolean, color: ThemeColors): string {
if (role === 'user') return color.primary
if (role === 'assistant') return latest ? color.primary : color.muted
return color.muted
}
/**
* Flat-body color (pure table-tested). The assistant's answer is the ONLY
* full-bright prose; the user's words are context (muted), system notes dim.
*/
export function bodyColor(role: Message['role'], color: ThemeColors): string {
return role === 'assistant' ? color.text : color.muted
}
/**
* The id of a turn's FINAL text part the answer that keeps full-bright text
* once the turn settles; earlier (interstitial) text parts were play-by-play
* scaffolding and demote to muted. Pure exported for tests.
*/
export function lastTextId(parts: readonly Part[] | undefined): string | undefined {
if (!parts) return undefined
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i]
if (p && p.type === 'text') return p.id
}
return undefined
}
export function MessageLine(props: { message: Message; latest?: boolean }) {
const theme = useTheme()
const display = useDisplay()
const m = () => props.message
const glyph = () => (m().role === 'assistant' ? theme().brand.icon : m().role === 'user' ? theme().brand.prompt : '·')
// Role-distinct color IS the hierarchy (Ink model): the human's turn is tinted
// GOLD (label), the agent's answer is BRIGHT (text), system notes are DIM (muted).
const glyphFg = () =>
m().role === 'user' ? theme().color.label : m().role === 'assistant' ? theme().color.accent : theme().color.muted
const bodyFg = () =>
m().role === 'user' ? theme().color.label : m().role === 'system' ? theme().color.muted : theme().color.text
const glyphFg = () => glyphColor(m().role, props.latest ?? false, theme().color)
const bodyFg = () => bodyColor(m().role, theme().color)
const hasParts = () => (m().parts?.length ?? 0) > 0
const spacing = () => turnSpacing(m().role, display().compact)
// /details hidden: fold each run of tool/reasoning parts into ONE muted line
// (the parts stay in the store — flipping the mode back restores them).
const displayParts = () => (display().details === 'hidden' ? collapseHiddenParts(m().parts ?? []) : (m().parts ?? []))
// Settled-turn narration demotion: once the turn stops streaming, every text
// part EXCEPT the final answer drops to muted.
const textFg = (id: string) =>
!m().streaming && id !== lastTextId(m().parts) ? theme().color.muted : theme().color.text
return (
// One blank line above every turn so user / assistant / tool blocks read as
// distinct turns (item: spacing); /compact collapses it so long sessions
// read denser. The gold-vs-bright color split does the rest.
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: display().compact ? 0 : 1 }}>
// Turn-boundary spacing > part gap (see turnSpacing); /compact collapses it
// so long sessions read denser. The earned-gold glyphs do the rest.
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: spacing().top, marginBottom: spacing().bottom }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
{/* the role glyph is decorative exclude it from mouse selection (item 4).
Bold so the user `` / assistant `` turn boundaries pop (item 8). */}
@ -53,7 +109,7 @@ export function MessageLine(props: { message: Message }) {
{/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text /
reasoning / tool regardless of order or stream timing, so blank lines
don't pop in and out as parts are created/merged mid-stream. /compact
drops the gap along with the per-turn margin above. */}
drops the gap along with the per-turn margins above. */}
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: display().compact ? 0 : 1 }}>
<Show
when={m().role === 'assistant' && hasParts()}
@ -99,8 +155,15 @@ export function MessageLine(props: { message: Message }) {
{/* ONE stable native <markdown> fed the growing text in place (no
per-delta remount no scrollbar flicker, #2); it renders GFM
tables natively (#3). Leading/trailing blanks stripped so the
column `gap` is the sole inter-part spacing (item 5). */}
{t => <Markdown text={t().text.replace(/^\n+|\n+$/g, '')} streaming={m().streaming ?? false} />}
column `gap` is the sole inter-part spacing (item 5).
Interstitial narration demotes to muted once settled. */}
{t => (
<Markdown
text={t().text.replace(/^\n+|\n+$/g, '')}
streaming={m().streaming ?? false}
fg={textFg(t().id)}
/>
)}
</Match>
</Switch>
)}

View file

@ -5,11 +5,13 @@
* turn settles. Click the header to override either way.
*
* Thinking: <title> live (streaming), body shown
* Thought: <title> settled (collapsed), click to reopen
* Thought: <title> settled (collapsed), click to reopen
* <reasoning markdown> dim body in a left-bordered block
*
* Title is the model's leading `**bold**` line when present (opencode's
* reasoningSummary). Dim throughout it's secondary to the answer.
* reasoningSummary). ALL muted (design pass: thinking is the most secondary
* tier glyph, label, and border ride the neutral grey; no accent ink) and
* nested +2 columns with the tools (the machinery tier under the turn).
*/
import { createMemo, createSignal, Show } from 'solid-js'
@ -20,6 +22,8 @@ import { useScrollAnchor } from './scrollAnchor.tsx'
import { useTheme } from './theme.tsx'
const GUTTER = 2
/** Machinery-tier nesting (design pass): thinking indents +2 with the tools. */
const INDENT = 2
/**
* Header label style reasoning is the MOST secondary tier, so the word
@ -56,20 +60,23 @@ export function ReasoningPart(props: { text: string; streaming?: boolean }) {
return (
<Show when={summary().body || summary().title}>
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<box style={{ flexDirection: 'column', flexShrink: 0, marginLeft: INDENT }}>
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={toggle}>
<box style={{ flexShrink: 0, width: GUTTER }}>
{/* ◐ collapsed / expanded MUTED, never accent (design pass:
thinking spends no warm ink; the half-moon also reads as a
different KIND of row than a tool's $// at a glance). */}
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{expanded() ? '▼' : '▶'}</span>
<span style={{ fg: theme().color.muted }}>{expanded() ? '▼' : ''}</span>
</text>
</box>
{/* the header is a collapsible-section LABEL (Thinking/Thought + title)
chrome, not the reasoning body so a free-form drag yields only
the markdown body below, not the section label (item 4). */}
<text selectable={false}>
{/* accent chevron marks it; muted ITALIC label keeps reasoning the
most secondary tier AND visibly a different kind of row than a
tool (bold name) see reasoningLabelStyle. */}
{/* muted ITALIC label keeps reasoning the most secondary tier AND
visibly a different kind of row than a tool (bold name) see
reasoningLabelStyle. */}
<span style={reasoningLabelStyle(theme().color)}>{label()}</span>
<Show when={summary().title}>
<span style={reasoningLabelStyle(theme().color)}>{`: ${summary().title}`}</span>
@ -80,7 +87,7 @@ export function ReasoningPart(props: { text: string; streaming?: boolean }) {
<box
style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, marginLeft: GUTTER, paddingLeft: 1 }}
border={['left']}
borderColor={theme().color.border}
borderColor={theme().color.muted}
>
<Markdown text={summary().body} streaming={props.streaming ?? false} fg={theme().color.muted} />
</box>

View file

@ -315,7 +315,9 @@ export function StatusBar(props: { store: SessionStore }) {
</Show>
<Show when={profileText()}>
<span style={{ fg: theme().color.border }}>{SEP}</span>
<span style={{ fg: theme().color.accent }}>{profileText()}</span>
{/* statusFg, not accent persistent chrome spends no warm ink
(design pass); the navy fill is the bar's one blue surface. */}
<span style={{ fg: theme().color.statusFg }}>{profileText()}</span>
</Show>
{/* `N bg` would slot here (segs().bg) — no store data feeds it yet (see header). */}
<Show when={mcpText()}>

View file

@ -6,14 +6,19 @@
* registry (`view/tools/registry.tsx`, Epic 2.2):
*
* terminal sleep 8 · 12s running (elapsed ticks live)
* terminal ls -la src · 0.3s (12 lines) collapsed (default)
* $ terminal ls -la src · 0.3s (12 lines) collapsed (default; per-tool glyph)
* terminal ls -la src · 0.3s expanded header
* <renderer body> labeled fields / output /
* terminal exit 1 · 0.1s (3 lines) failed (error-colored glyph)
*
* Lifecycle is legible from the HEAD GLYPH alone (Epic 2.5): `` running (with
* a live `· Ns` elapsed off the shared 1s tick in `elapsed.ts` never a timer
* per part), ``/`` settled-expandable, `` failed (theme error color).
* Lifecycle + identity are legible from the HEAD GLYPH alone (Epic 2.5 + the
* visual-hierarchy design pass): `` running in accent heat (with a live `· Ns`
* elapsed off the shared 1s tick in `elapsed.ts` never a timer per part), the
* PER-TOOL glyph (`glyphFor` `$`/``/``/``/) in machinery BLUE
* (`shellDollar`) when settled, `` (blue) only while expanded, `` failed
* (theme error color). Tools are the machinery tier: the whole part nests +2
* columns under the turn, the name is muted-bright (statusFg) bold never the
* full-bright answer color and args/durations/counts stay muted grey.
* Clicking an expandable header toggles it (wrapped in useScrollAnchor so
* expanding never yanks the viewport); running parts have no expand
* affordance. The header row is chrome (selectable=false) a free-form drag
@ -25,15 +30,17 @@ import { useDimensions } from './dimensions.tsx'
import { useDisplay } from './display.tsx'
import { createSignal, Show } from 'solid-js'
import { truncate } from '../logic/toolOutput.ts'
import { argsCapColumns, truncate } from '../logic/toolOutput.ts'
import { elapsedSeconds, useElapsedTick } from './elapsed.ts'
import { useScrollAnchor } from './scrollAnchor.tsx'
import { useSessionInfo } from './sessionInfo.tsx'
import { useTheme } from './theme.tsx'
import { resultLines } from './tools/defaultTool.tsx'
import { rendererFor } from './tools/registry.tsx'
import { glyphFor, rendererFor } from './tools/registry.tsx'
const GUTTER = 2
/** Machinery-tier nesting (design pass): tools indent +2 under the turn. */
const INDENT = 2
function fmtDuration(s: number): string {
if (s < 10) return `${s.toFixed(1)}s`
@ -52,13 +59,15 @@ function fmtElapsed(s: number): string {
}
/**
* Header tool-NAME style the name is the PRIMARY cue for what a settled tool
* IS, so it renders in the primary text color + BOLD (the transcript otherwise
* reads as undifferentiated muted rows). The failed state's error coloring
* wins (still bold failures should be unmistakable alongside the glyph);
* a running part keeps its current muted treatment (the glyph + live
* elapsed already carry the running signal). Exported so tests can pin the
* selection logic (char frames carry no color/attribute info).
* Header tool-NAME style the name says what a settled tool IS, but a tool is
* MACHINERY, not the answer: it renders muted-BRIGHT (the statusFg silver one
* step up from muted grey) + BOLD, demoted from the full-bright answer color so
* the eye lands on prose, not tool names (visual-hierarchy design pass). The
* failed state's error coloring wins (still bold failures should be
* unmistakable alongside the glyph); a running part keeps its muted
* treatment (the glyph + live elapsed already carry the running signal).
* Exported so tests can pin the selection logic (char frames carry no
* color/attribute info).
*/
export function toolNameStyle(
state: { failed: boolean; running: boolean },
@ -66,7 +75,7 @@ export function toolNameStyle(
): { fg: string; bold: boolean } {
if (state.failed) return { bold: true, fg: color.error }
if (state.running) return { bold: false, fg: color.muted }
return { bold: true, fg: color.text }
return { bold: true, fg: color.statusFg }
}
/**
@ -100,7 +109,7 @@ export function ToolPart(props: { part: ToolPartState }) {
// Per-tool renderer (re-dispatches if the name settles on tool.complete).
const renderer = () => rendererFor(props.part.name)
const bodyWidth = () => Math.max(20, dims().width - GUTTER - 4)
const bodyWidth = () => Math.max(20, dims().width - GUTTER - INDENT - 4)
// "(N lines)" counts what the renderer's body will actually show (per-tool
// `lines`, e.g. read_file's content), not the raw resultText.
const lines = () => renderer().lines?.(props.part) ?? resultLines(props.part)
@ -113,21 +122,31 @@ export function ToolPart(props: { part: ToolPartState }) {
// Optional `+N M` change summary (file tools) — themed, settled parts only.
const stats = () => (running() || props.part.error ? undefined : renderer().stats?.(props.part))
// Failed parts are legible from the glyph alone: `✗` in the head position
// (error-colored), regardless of expandability — `(N lines)` still marks an
// expandable body. `error` only lands on tool.complete, so running stays ⚡.
// Head glyph = lifecycle + identity (design pass): `✗` failed (error), `⚡`
// running (accent heat), `▼` only while expanded, else the PER-TOOL glyph
// (`$`/`◇`/`◆`/`○`/… — the vocabulary survives the default collapsed view).
// `error` only lands on tool.complete, so running stays ⚡.
const failed = () => !running() && Boolean(props.part.error)
const headGlyph = () => (failed() ? '✗' : collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
// accent glyph MARKS the tool (draws the eye); the NAME is primary (bold text
// via toolNameStyle) so WHAT the tool is reads at a glance; subtitle/metadata
// stay muted — the secondary tier below the bright assistant answer.
const headColor = () => (failed() ? theme().color.error : theme().color.accent)
const headGlyph = () =>
failed() ? '✗' : running() ? '⚡' : collapsible() && expanded() ? '▼' : glyphFor(props.part.name)
// Settled machinery is BLUE (`shellDollar` — "blue is the hum of machinery");
// running is accent heat; failed is error. The NAME is muted-bright bold (see
// toolNameStyle); subtitle/metadata stay muted grey — the machinery tier
// below the bright assistant answer.
const headColor = () =>
failed() ? theme().color.error : running() ? theme().color.accent : theme().color.shellDollar
const subWidth = () => Math.max(1, bodyWidth() - props.part.name.length - 2)
// Args are context, not content: cap the collapsed args preview to ~half the
// pane (argsCapColumns) so a long command/path can never become the loudest
// mass on screen. Error subtitles keep the full row — failures must stay
// readable.
const subCap = () => (props.part.error ? subWidth() : Math.max(1, Math.min(subWidth(), argsCapColumns(dims().width))))
return (
// Spacing between parts is owned by the parts column (gap), not per-part
// margins — so a tool appearing mid-stream doesn't shift the layout.
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
// marginLeft nests the machinery tier +2 under the turn (answers at base).
<box style={{ flexDirection: 'column', flexShrink: 0, marginLeft: INDENT }}>
{/* header — clickable to toggle when there's an expandable body */}
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && toggle()}>
<box style={{ flexShrink: 0, width: GUTTER }}>
@ -141,8 +160,9 @@ export function ToolPart(props: { part: ToolPartState }) {
free-form drag over a tool yields only the expanded body content,
never the header label. */}
<text selectable={false}>
{/* the NAME is the primary cue (text + bold; error when failed;
muted while running) see toolNameStyle. Subtitle stays muted. */}
{/* the NAME identifies the machinery (muted-bright + bold; error
when failed; muted while running) see toolNameStyle. Subtitle
stays muted. */}
<span style={toolNameStyle({ failed: failed(), running: running() }, theme().color)}>
{props.part.name}
</span>
@ -151,7 +171,7 @@ export function ToolPart(props: { part: ToolPartState }) {
sleep 8 · 12s`, Ink parity. */}
<Show when={subtitle()}>
<span style={{ fg: props.part.error ? theme().color.error : theme().color.muted }}>
{` ${truncate(subtitle(), subWidth())}`}
{` ${truncate(subtitle(), subCap())}`}
</span>
</Show>
<Show when={stats()}>
@ -186,12 +206,13 @@ export function ToolPart(props: { part: ToolPartState }) {
{/* expanded body the per-tool renderer's Body, inside a single
left-bordered column (a `` rule, not a bg fill opencode's BlockTool
style; also renders faithfully and reads cleaner). */}
style; also renders faithfully and reads cleaner). The rule is
machinery blue (error wins) same family as the head glyph. */}
<Show when={collapsible() && expanded()}>
<box
style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, marginLeft: GUTTER, paddingLeft: 1 }}
border={['left']}
borderColor={props.part.error ? theme().color.error : theme().color.border}
borderColor={props.part.error ? theme().color.error : theme().color.shellDollar}
>
{(() => {
const Body = renderer().Body

View file

@ -23,7 +23,8 @@
import { createMemo, For, Show } from 'solid-js'
import type { ToolPartState } from '../../logic/store.ts'
import { truncate } from '../../logic/toolOutput.ts'
import { argsCapColumns, truncate } from '../../logic/toolOutput.ts'
import { useDimensions } from '../dimensions.tsx'
import { useTheme } from '../theme.tsx'
import { CodeBlock } from './codeBlock.tsx'
import { defaultSubtitle, resultLines, structuredArgs, ToolOutputBlock } from './defaultTool.tsx'
@ -52,24 +53,29 @@ export function commandOf(part: ToolPartState): string {
* True when the collapsed header already shows the WHOLE command (item 3)
* single line AND untruncated. Mirrors the header math in `view/toolPart.tsx`:
* the subtitle gets `bodyWidth - name - 2` columns while the Body gets
* `bodyWidth - 2`, so the header's subtitle width is `width - name.length`.
* A failed part's header shows the ERROR instead of the command, so the body
* must echo it again.
* `bodyWidth - 2`, so the header's subtitle width is `width - name.length`
* ALSO capped to ~half the pane (`argsCapColumns`, the design pass's args
* width cap) when `headerCap` is given, keeping this check mirrored with the
* header's actual truncation. A failed part's header shows the ERROR instead
* of the command, so the body must echo it again.
*/
export function commandFitsHeader(part: ToolPartState, width: number): boolean {
export function commandFitsHeader(part: ToolPartState, width: number, headerCap?: number): boolean {
if (part.error) return false
const cmd = commandOf(part)
if (cmd.includes('\n')) return false
const flat = cmd.replace(/\s+/g, ' ').trim()
return flat.length <= Math.max(1, width - part.name.length)
return flat.length <= Math.min(Math.max(1, width - part.name.length), headerCap ?? Number.POSITIVE_INFINITY)
}
/** Expanded body: the command echo (only when the header truncated it), then
* the full (capped) output. */
export function BashToolBody(props: ToolBodyProps) {
const theme = useTheme()
const dims = useDimensions()
const command = createMemo(() => commandOf(props.part).replace(/\s+$/, ''))
const echo = createMemo(() => Boolean(command()) && !commandFitsHeader(props.part, props.width))
const echo = createMemo(
() => Boolean(command()) && !commandFitsHeader(props.part, props.width, argsCapColumns(dims().width))
)
return (
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<Show when={echo()}>
@ -79,9 +85,10 @@ export function BashToolBody(props: ToolBodyProps) {
<For each={command().split('\n')}>
{(line, i) => (
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
{/* `$ ` prompt glyph (continuation lines indent under it) — chrome */}
{/* `$ ` prompt glyph (continuation lines indent under it) chrome;
machinery BLUE (design pass: all tool glyphs ride shellDollar) */}
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{i() === 0 ? '$ ' : ' '}</span>
<span style={{ fg: theme().color.shellDollar }}>{i() === 0 ? '$ ' : ' '}</span>
</text>
{/* the command itself is copyable content */}
<text selectionBg={theme().color.selectionBg}>

View file

@ -163,7 +163,8 @@ export function ToolOutputBlock(props: { part: ToolPartState; width: number; lab
</Show>
<Show when={body().hiddenLines > 0 && !props.part.omittedNote}>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`… +${body().hiddenLines} more lines`}</span>
{/* counts are muted (design pass) — chrome never spends warm ink */}
<span style={{ fg: theme().color.muted }}>{`… +${body().hiddenLines} more lines`}</span>
</text>
</Show>
</Show>
@ -191,7 +192,7 @@ export function DefaultToolBody(props: ToolBodyProps & { omitFields?: readonly s
<Show when={fields().length > FIELDS_MAX}>
{/* overflow annotation — chrome, not content */}
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`… +${fields().length - FIELDS_MAX} more`}</span>
<span style={{ fg: theme().color.muted }}>{`… +${fields().length - FIELDS_MAX} more`}</span>
</text>
</Show>
</Show>

View file

@ -51,6 +51,39 @@ export interface ToolRenderer {
Body: Component<ToolBodyProps>
}
/**
* Per-tool head-glyph vocabulary (visual-hierarchy design pass, Appendix C).
* The glyph IS the tool's identity in the default (collapsed) view settled
* rows show it in machinery blue (`shellDollar`); only the expanded header
* swaps to ``. Running stays `` (accent heat) and failure `` (error)
* lifecycle owns those two; this map owns WHAT the tool is:
* terminal `$` · read `` · write/patch `` · search `` · web `` ·
* clarify `?` · skill `` · delegate `` (the one whimsy) · MCP/unknown ``
*/
export const TOOL_GLYPHS: Record<string, string> = {
clarify: '?',
delegate_task: '⚕',
execute_code: '$',
patch: '◆',
process: '$',
read_file: '◇',
search_files: '○',
skill_manage: '▲',
skill_view: '▲',
terminal: '$',
web_extract: '●',
web_search: '●',
write_file: '◆'
}
/** Fallback glyph for MCP/unmapped tools. */
export const DEFAULT_TOOL_GLYPH = '◦'
/** The settled head glyph for a tool name (vocabulary survives the default view). */
export function glyphFor(name: string): string {
return TOOL_GLYPHS[name] ?? DEFAULT_TOOL_GLYPH
}
const TOOLS: Record<string, ToolRenderer> = {
// clarify (item 4): collapsed = `question: answer`; expanded = `User
// answered:` + `· q: a` rows — NEVER the raw JSON result.

View file

@ -15,7 +15,7 @@
* to hold the viewport in place so expanding doesn't yank to the bottom (#4).
*/
import type { ScrollBoxRenderable } from '@opentui/core'
import { createSignal, For, Show } from 'solid-js'
import { createMemo, createSignal, For, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { DisplayProvider } from './display.tsx'
@ -29,6 +29,15 @@ export function Transcript(props: { store: SessionStore }) {
const theme = useTheme()
const dropped = () => props.store.state.dropped
const sid = () => props.store.state.sessionId
// The NEWEST assistant answer's index — gold is earned (design pass): only
// that turn's `⚕` glyph stays primary; older answers demote to grey.
const latestAssistant = createMemo(() => {
const messages = props.store.state.messages
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.role === 'assistant') return i
}
return -1
})
return (
<box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
@ -48,7 +57,9 @@ export function Transcript(props: { store: SessionStore }) {
{`${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`}
</text>
</Show>
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
<For each={props.store.state.messages}>
{(message, i) => <MessageLine message={message} latest={i() === latestAssistant()} />}
</For>
</DisplayProvider>
</ScrollAnchorProvider>
</scrollbox>