opentui(v5b): visual hierarchy — color-code roles + clean turn spacing

The transcript read as one undifferentiated gold blob (user/assistant/tool all
the same color). Adopt the Ink model where color IS the hierarchy, in 3 brightness
tiers:
- USER input  → label (gold)        — the human's turn stands out.
- ASSISTANT answer → text (bright)   — the primary content, brightest.
- TOOL / REASONING → muted (dim) with an ACCENT glyph (/▶/▼ amber) that marks
  the block — clearly the secondary 'working area' below the answer.
Spacing: one blank line above every turn (was cramped: user had a blank, the
reply didn't) + the existing gap:1 between parts. Dropped the transcript's extra
marginTop (turns own their spacing now). 85 pass; verified live — gold ask, dim
tool, white answer read as three distinct things.
This commit is contained in:
alt-glitch 2026-06-09 05:13:37 +00:00
parent e44b43ad16
commit 503c1201ff
4 changed files with 18 additions and 10 deletions

View file

@ -22,12 +22,18 @@ export function MessageLine(props: { message: Message }) {
const theme = useTheme()
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 === 'assistant' ? theme().color.accent : m().role === 'user' ? theme().color.accent : theme().color.muted
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 hasParts = () => (m().parts?.length ?? 0) > 0
return (
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: m().role === 'user' ? 1 : 0 }}>
// One blank line above every turn so user / assistant / tool blocks read as
// distinct turns (item: spacing). The gold-vs-bright color split does the rest.
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: 1 }}>
<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). */}
@ -51,7 +57,7 @@ export function MessageLine(props: { message: Message }) {
when={m().streaming && !hasParts()}
fallback={
<text>
<span style={{ fg: theme().color.text }}>{m().text}</span>
<span style={{ fg: bodyFg() }}>{m().text}</span>
</text>
}
>

View file

@ -47,13 +47,13 @@ export function ReasoningPart(props: { text: string; streaming?: boolean }) {
>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{expanded() ? '▼' : '▶'}</span>
<span style={{ fg: theme().color.accent }}>{expanded() ? '▼' : '▶'}</span>
</text>
</box>
<text>
{/* label color matches tool headers (label, not warn) so reasoning + tool
read as one family of collapsible asides warn is for warnings (item 8). */}
<span style={{ fg: theme().color.label }}>{label()}</span>
{/* accent chevron marks it; muted label keeps reasoning in the dim,
secondary tier alongside tool calls (Ink hierarchy). */}
<span style={{ fg: theme().color.muted }}>{label()}</span>
<Show when={summary().title}>
<span style={{ fg: theme().color.muted }}>{`: ${summary().title}`}</span>
</Show>

View file

@ -86,7 +86,9 @@ export function ToolPart(props: { part: ToolPartState }) {
const body = createMemo(() => collapseToolOutput(result(), EXPANDED_MAX, bodyWidth() - 2))
const headGlyph = () => (collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
const headColor = () => (props.part.error ? theme().color.error : theme().color.muted)
// accent glyph MARKS the tool (draws the eye); the rest is muted so tools read
// as the dim, secondary tier below the bright assistant answer (Ink hierarchy).
const headColor = () => (props.part.error ? theme().color.error : theme().color.accent)
const subWidth = () => Math.max(1, bodyWidth() - props.part.name.length - 2)
return (
@ -102,7 +104,7 @@ export function ToolPart(props: { part: ToolPartState }) {
</box>
<box style={{ flexDirection: 'row', flexGrow: 1, minWidth: 0 }}>
<text>
<span style={{ fg: theme().color.label }}>{props.part.name}</span>
<span style={{ fg: theme().color.muted }}>{props.part.name}</span>
<Show when={running()}>
<span style={{ fg: theme().color.muted }}> </span>
</Show>

View file

@ -25,7 +25,7 @@ import { ScrollAnchorProvider } from './scrollAnchor.tsx'
export function Transcript(props: { store: SessionStore }) {
const [scroll, setScroll] = createSignal<ScrollBoxRenderable | undefined>()
return (
<box style={{ flexGrow: 1, minHeight: 0, marginTop: 1 }}>
<box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
<ScrollAnchorProvider scroll={scroll}>
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}