mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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:
parent
c6e72a8454
commit
741a4c23ca
9 changed files with 58 additions and 13 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue