mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): ink budget — earned gold, blue machinery, neutral muted (design pass)
This commit is contained in:
parent
a09fa9df42
commit
639a9cb9a7
17 changed files with 484 additions and 91 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
189
ui-opentui/src/test/inkBudget.test.tsx
Normal file
189
ui-opentui/src/test/inkBudget.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue