From 741a4c23ca963c14fdb33fa72b941dcc504e1346 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 9 Jun 2026 03:56:44 +0000 Subject: [PATCH] =?UTF-8?q?opentui(v5/item8):=20design=20polish=20?= =?UTF-8?q?=E2=80=94=20header=20chrome,=20status=20segments,=20ANSI=20stri?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 rendered them as literal glyphs. +stripAnsi + 3 tests. All tokens themed (no hardcoded colors). 88 pass. --- ui-tui-opentui-v2/src/logic/store.ts | 9 ++++++--- ui-tui-opentui-v2/src/logic/toolOutput.ts | 11 +++++++++++ ui-tui-opentui-v2/src/test/toolOutput.test.ts | 16 +++++++++++++++- ui-tui-opentui-v2/src/view/App.tsx | 6 +++++- ui-tui-opentui-v2/src/view/header.tsx | 7 ++++++- ui-tui-opentui-v2/src/view/homeHint.tsx | 6 ++++-- ui-tui-opentui-v2/src/view/messageLine.tsx | 9 ++++++--- ui-tui-opentui-v2/src/view/reasoningPart.tsx | 4 +++- ui-tui-opentui-v2/src/view/statusBar.tsx | 3 ++- 9 files changed, 58 insertions(+), 13 deletions(-) diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 84048a44f91..9a768d29942 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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. */ diff --git a/ui-tui-opentui-v2/src/logic/toolOutput.ts b/ui-tui-opentui-v2/src/logic/toolOutput.ts index aba172b5c7e..937ad1e8fc5 100644 --- a/ui-tui-opentui-v2/src/logic/toolOutput.ts +++ b/ui-tui-opentui-v2/src/logic/toolOutput.ts @@ -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 `` 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) diff --git a/ui-tui-opentui-v2/src/test/toolOutput.test.ts b/ui-tui-opentui-v2/src/test/toolOutput.test.ts index fd61ab11244..3ec5ef8df2b 100644 --- a/ui-tui-opentui-v2/src/test/toolOutput.test.ts +++ b/ui-tui-opentui-v2/src/test/toolOutput.test.ts @@ -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', () => { diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index c8a7aecf929..48cf8891b05 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -65,7 +65,11 @@ export function App(props: AppProps) { return ( -
+ {/* a bottom rule under the header bookends the transcript with the status + bar's top rule — frames the chrome as intentional (item 8). */} + +
+ {/* content zone: a full-screen overlay (pager / agents dashboard) OR the transcript + input zone */} - {theme().brand.name} + {/* 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). */} + {`${theme().brand.icon} `} + + {theme().brand.name} + · opentui · connecting…}> ready diff --git a/ui-tui-opentui-v2/src/view/homeHint.tsx b/ui-tui-opentui-v2/src/view/homeHint.tsx index cdac3b8bc83..f35b6d88e6b 100644 --- a/ui-tui-opentui-v2/src/view/homeHint.tsx +++ b/ui-tui-opentui-v2/src/view/homeHint.tsx @@ -23,7 +23,9 @@ export function HomeHint() { {theme().brand.icon} - {theme().brand.name} + + {theme().brand.name} + {theme().brand.welcome} @@ -32,7 +34,7 @@ export function HomeHint() { {([cmd, desc]) => ( - {cmd.padEnd(11)} + {cmd.padEnd(12)} {desc} )} diff --git a/ui-tui-opentui-v2/src/view/messageLine.tsx b/ui-tui-opentui-v2/src/view/messageLine.tsx index 199936a5d00..81b8de5ce7a 100644 --- a/ui-tui-opentui-v2/src/view/messageLine.tsx +++ b/ui-tui-opentui-v2/src/view/messageLine.tsx @@ -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 ( - {/* 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). */} - {glyph()} + + {glyph()} + {/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text / diff --git a/ui-tui-opentui-v2/src/view/reasoningPart.tsx b/ui-tui-opentui-v2/src/view/reasoningPart.tsx index 528b69521fb..d70c5e00c49 100644 --- a/ui-tui-opentui-v2/src/view/reasoningPart.tsx +++ b/ui-tui-opentui-v2/src/view/reasoningPart.tsx @@ -48,7 +48,9 @@ export function ReasoningPart(props: { text: string; streaming?: boolean }) { - {label()} + {/* label color matches tool headers (label, not warn) so reasoning + tool + read as one family of collapsible asides — warn is for warnings (item 8). */} + {label()} {`: ${summary().title}`} diff --git a/ui-tui-opentui-v2/src/view/statusBar.tsx b/ui-tui-opentui-v2/src/view/statusBar.tsx index 2f39353485c..0892709def3 100644 --- a/ui-tui-opentui-v2/src/view/statusBar.tsx +++ b/ui-tui-opentui-v2/src/view/statusBar.tsx @@ -111,7 +111,8 @@ export function StatusBar(props: { store: SessionStore }) { {effort()} - {' '} + {/* a dim divider segments the bar into scannable fields (item 8) */} + {' │ '} {ctxBar(pct()!, CTX_BAR_CELLS)} {` ${pct()}%`}