opentui(v5/item8): design polish — header chrome, status segments, ANSI strip

Visual-hierarchy pass (design-reviewed against free-code/opencode):
- header: brand glyph in accent + name in primary/bold + a bottom rule, so it
  reads as chrome and bookends the transcript with the status bar's top rule
  (fixes 'nothing differentiates the header from the text stream').
- status bar: a dim │ divider segments model·effort from the context meter.
- user/assistant turn glyphs bold + the user ❯ in accent so turns are scannable.
- reasoning 'Thought' label uses label (not warn) so it matches tool headers —
  warn is reserved for warnings; reasoning/tool now read as one aside family.
- home screen: brand in primary/bold, command names in accent vs muted descs,
  wider column.
- FIX (load-bearing): strip ANSI/SGR escape sequences from slash/notice text
  (pushSystem + openPager) — the gateway colors them for Ink, which interprets
  them; the native <text> rendered them as literal  glyphs. +stripAnsi
  + 3 tests. All tokens themed (no hardcoded colors). 88 pass.
This commit is contained in:
alt-glitch 2026-06-09 03:56:44 +00:00
parent c6e72a8454
commit 741a4c23ca
9 changed files with 58 additions and 13 deletions

View file

@ -14,7 +14,7 @@
import { createStore, produce } from 'solid-js/store'
import type { GatewayEvent, GatewaySkinDecoded } from '../boundary/schema/GatewayEvent.ts'
import { stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
/** A tool call inside an assistant turn (matched start↔complete by `id`=tool_id). */
@ -331,9 +331,12 @@ export function createSessionStore() {
/** Push a system line (slash output, errors, notices). */
function pushSystem(text: string) {
// slash/notice text is often ANSI-colored for the Ink TUI; strip codes so
// they don't render as literal `[1;38m…` glyphs in the native engine (item 8).
const clean = stripAnsi(text)
setState(
produce(draft => {
draft.messages.push({ role: 'system', text })
draft.messages.push({ role: 'system', text: clean })
})
)
}
@ -359,7 +362,7 @@ export function createSessionStore() {
/** Open the pager overlay (long slash output: /status, /logs, …). */
function openPager(title: string, text: string) {
setState('pager', { title, text })
setState('pager', { title, text: stripAnsi(text) })
}
/** Close the pager overlay. */

View file

@ -13,6 +13,17 @@ export interface Collapsed {
truncated: boolean
}
// CSI escape sequences (SGR colors, cursor, mouse). The gateway colors some
// slash/notice text with raw ANSI for the Ink TUI, which interprets it; the
// native `<text>` renders byte-for-byte, so those codes would leak as literal
// glyphs. Strip them on display (item 8).
// eslint-disable-next-line no-control-regex
const ANSI_CSI = /[\u001b\u009b]\[[0-9;:?<>=]*[ -/]*[@-~]/g
/** Remove ANSI/SGR/mouse escape sequences so they don't render as literal text. */
export function stripAnsi(s: string): string {
return (s ?? '').replace(ANSI_CSI, '')
}
/** Truncate a single line to `width` columns, adding an ellipsis when cut. */
export function truncate(s: string, width: number): string {
const w = Math.max(1, width)

View file

@ -4,7 +4,21 @@
*/
import { describe, expect, test } from 'bun:test'
import { collapseToolOutput, stripOmittedNote, stripToolEnvelope, truncate } from '../logic/toolOutput.ts'
import { collapseToolOutput, stripAnsi, stripOmittedNote, stripToolEnvelope, truncate } from '../logic/toolOutput.ts'
describe('stripAnsi (item 8 - gateway slash/notice text is ANSI-colored for Ink)', () => {
const ESC = String.fromCharCode(27)
test('removes SGR color codes, keeps the text', () => {
expect(stripAnsi(`${ESC}[1;38;2;255;215;0m\u2713 Reasoning display: ON${ESC}[0m`)).toBe('\u2713 Reasoning display: ON')
})
test('removes italic + mouse sequences', () => {
expect(stripAnsi(`${ESC}[2;3m Model thinking shown.${ESC}[0m`)).toBe(' Model thinking shown.')
expect(stripAnsi(`hi${ESC}[<0;6;8mthere`)).toBe('hithere')
})
test('leaves plain text untouched', () => {
expect(stripAnsi('just text')).toBe('just text')
})
})
describe('stripOmittedNote (item 2 — peel the gateway verbose-tail label)', () => {
test('extracts the lines/chars note and returns the clean body', () => {

View file

@ -65,7 +65,11 @@ export function App(props: AppProps) {
return (
<box style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1, paddingLeft: 1, paddingRight: 1 }}>
<Header store={props.store} />
{/* a bottom rule under the header bookends the transcript with the status
bar's top rule frames the chrome as intentional (item 8). */}
<box border={['bottom']} borderColor={theme().color.border} style={{ flexShrink: 0 }}>
<Header store={props.store} />
</box>
{/* content zone: a full-screen overlay (pager / agents dashboard) OR the transcript + input zone */}
<Switch
fallback={

View file

@ -14,7 +14,12 @@ export function Header(props: { store: SessionStore }) {
return (
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
<b>{theme().brand.name}</b>
{/* 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). */}
<span style={{ fg: theme().color.accent }}>{`${theme().brand.icon} `}</span>
<span style={{ fg: theme().color.primary }}>
<b>{theme().brand.name}</b>
</span>
<span style={{ fg: theme().color.muted }}> · opentui · </span>
<Show when={props.store.state.ready} fallback={<span style={{ fg: theme().color.muted }}>connecting</span>}>
<span style={{ fg: theme().color.ok }}>ready</span>

View file

@ -23,7 +23,9 @@ export function HomeHint() {
<box style={{ flexDirection: 'column', flexShrink: 0, paddingLeft: 1, marginTop: 1 }}>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{theme().brand.icon} </span>
<b>{theme().brand.name}</b>
<span style={{ fg: theme().color.primary }}>
<b>{theme().brand.name}</b>
</span>
</text>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{theme().brand.welcome}</span>
@ -32,7 +34,7 @@ export function HomeHint() {
<For each={COMMANDS}>
{([cmd, desc]) => (
<text selectable={false}>
<span style={{ fg: theme().color.label }}>{cmd.padEnd(11)}</span>
<span style={{ fg: theme().color.accent }}>{cmd.padEnd(12)}</span>
<span style={{ fg: theme().color.muted }}>{desc}</span>
</text>
)}

View file

@ -41,15 +41,18 @@ export function MessageLine(props: { message: Message }) {
const m = () => props.message
const glyph = () => (m().role === 'assistant' ? theme().brand.icon : m().role === 'user' ? theme().brand.prompt : '·')
const glyphFg = () =>
m().role === 'assistant' ? theme().color.accent : m().role === 'user' ? theme().color.prompt : theme().color.muted
m().role === 'assistant' ? theme().color.accent : m().role === 'user' ? theme().color.accent : theme().color.muted
const hasParts = () => (m().parts?.length ?? 0) > 0
return (
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: m().role === 'user' ? 1 : 0 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
{/* the role glyph is decorative — exclude it from mouse selection (item 4) */}
{/* the role glyph is decorative exclude it from mouse selection (item 4).
Bold so the user `` / assistant `` turn boundaries pop (item 8). */}
<text selectable={false}>
<span style={{ fg: glyphFg() }}>{glyph()}</span>
<span style={{ fg: glyphFg() }}>
<b>{glyph()}</b>
</span>
</text>
</box>
{/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text /

View file

@ -48,7 +48,9 @@ export function ReasoningPart(props: { text: string; streaming?: boolean }) {
</text>
</box>
<text>
<span style={{ fg: theme().color.warn }}>{label()}</span>
{/* 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>
<Show when={summary().title}>
<span style={{ fg: theme().color.muted }}>{`: ${summary().title}`}</span>
</Show>

View file

@ -111,7 +111,8 @@ export function StatusBar(props: { store: SessionStore }) {
<span style={{ fg: theme().color.muted }}>{effort()}</span>
</Show>
<Show when={showBar()}>
<span style={{ fg: theme().color.muted }}>{' '}</span>
{/* a dim divider segments the bar into scannable fields (item 8) */}
<span style={{ fg: theme().color.border }}>{' │ '}</span>
<span style={{ fg: ctxColor(pct()!) }}>{ctxBar(pct()!, CTX_BAR_CELLS)}</span>
<span style={{ fg: theme().color.statusFg }}>{` ${pct()}%`}</span>
</Show>