From 6ef3b2ba2ebd18e05753b888dcc14beb75db0b4e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 18:55:01 +0200 Subject: [PATCH] feat: render TUI frames from the model and layout One absolutely-addressed line per row, each closed with an erase-to-end, so nothing scrolls and a repaint cannot leave the previous frame's tail behind. The caller wraps the result in synchronized-output brackets; that is an IO decision and stays out of the renderer. Color is passed in rather than detected. chalk's detection is right for the one-shot CLI but would make a frame non-deterministic, so the palette is raw SGR in the same semantic roles cli-style uses, and `color: false` emits nothing but the cursor addressing, the session's own colors in the preview included. Co-Authored-By: Claude Fable 5 --- src/tui/tui-render.ts | 631 ++++++++++++++++++++++++++++++++++++ test/tui/tui-render.test.ts | 403 +++++++++++++++++++++++ 2 files changed, 1034 insertions(+) create mode 100644 src/tui/tui-render.ts create mode 100644 test/tui/tui-render.test.ts diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts new file mode 100644 index 00000000..61522fa6 --- /dev/null +++ b/src/tui/tui-render.ts @@ -0,0 +1,631 @@ +/** + * @fileoverview Pure frame renderer: model + layout in, one string out. + * + * The frame is absolute-addressed, one `ESC [ ;1 H` per line followed by + * `ESC [ K`, so nothing ever scrolls and a repaint cannot leave debris. The + * caller wraps the result in synchronized-output brackets (DECSET 2026) where + * the terminal supports it; that is an IO decision and stays out of here. + * + * Color is decided by the caller and passed in, never detected here: chalk's + * auto-detection is the right answer for the one-shot CLI (see `cli-style.ts`) + * but it would make a frame non-deterministic, and "same inputs, same string" + * is what makes this module testable. The palette below is the same semantic + * vocabulary chalk gives `cli-style` (ok green, warn yellow, err red, info + * cyan, muted gray, emph bold), written as raw SGR so the mapping is fixed. + * + * With `color: false` the frame contains no escape sequences at all beyond the + * cursor addressing that puts each line in place. + * + * @module tui/tui-render + */ + +import { clipStyledLine, padDisplay, stripStyles, visibleWidth } from './tui-ansi.js'; +import type { TuiLayout, TuiRect } from './tui-layout.js'; +import type { TuiGlyphTier, TuiGroup, TuiRenderModel, TuiRow, TuiSessionRow, TuiSessionState } from './tui-types.js'; + +export interface TuiRenderOptions { + /** Emit SGR color. False is NO_COLOR: cursor addressing and nothing else. */ + color: boolean; + glyphs: TuiGlyphTier; + /** Animation counter. The WORKING glyph cycles with it. */ + tick: number; + /** Wall clock for elapsed times, passed in so a frame is reproducible. */ + now: number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Palette and glyphs +// ───────────────────────────────────────────────────────────────────────────── + +const SGR = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + inverse: '\x1b[7m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + gray: '\x1b[90m', +} as const; + +const STATE_COLOR: Record = { + 'blocked-permission': SGR.red, + 'blocked-question': SGR.red, + waiting: SGR.yellow, + working: SGR.green, + idle: SGR.gray, + recent: SGR.gray, +}; + +export interface TuiGlyphSet { + blockedPermission: string; + blockedQuestion: string; + waiting: string; + /** WORKING animates through Claude's own glyph family, a deliberate nod. */ + working: readonly string[]; + idle: string; + recent: string; + cursor: string; + rule: string; + divider: string; + boxTopLeft: string; + boxTopRight: string; + boxBottomLeft: string; + boxBottomRight: string; + boxHorizontal: string; + boxVertical: string; + enter: string; + updown: string; + separator: string; + ellipsis: string; +} + +const UNICODE_GLYPHS: TuiGlyphSet = { + blockedPermission: '⚠', + blockedQuestion: '⚠', + waiting: '✋', + working: ['·', '✢', '✳', '∗', '✻', '✽'], + idle: '○', + recent: '✔', + cursor: '▶', + rule: '─', + divider: '│', + boxTopLeft: '┌', + boxTopRight: '┐', + boxBottomLeft: '└', + boxBottomRight: '┘', + boxHorizontal: '─', + boxVertical: '│', + enter: '⏎', + updown: '↑↓', + separator: '·', + ellipsis: '…', +}; + +/** + * The lowest tier, for terminals that are not known-capable. Every state token + * is three columns wide so rows still line up, mirroring what `sc` falls back + * to today. + */ +const ASCII_GLYPHS: TuiGlyphSet = { + blockedPermission: '[!]', + blockedQuestion: '[?]', + waiting: '[w]', + working: ['[*]', '[+]', '[x]', '[+]'], + idle: '[-]', + recent: '[v]', + cursor: '>', + rule: '-', + divider: '|', + boxTopLeft: '+', + boxTopRight: '+', + boxBottomLeft: '+', + boxBottomRight: '+', + boxHorizontal: '-', + boxVertical: '|', + enter: 'enter', + updown: 'up/dn', + separator: '-', + ellipsis: '..', +}; + +/** + * Glyphs for a tier. `nerd` currently renders like `unicode`: the tier exists + * so detection has somewhere to land and a nerd-font-only set has a home, + * without shipping glyphs nobody has reviewed on a real font. + */ +export function glyphsFor(tier: TuiGlyphTier): TuiGlyphSet { + return tier === 'ascii' ? ASCII_GLYPHS : UNICODE_GLYPHS; +} + +/** + * Glyph tier from the environment. IO-ish by nature (it reads env), so it takes + * the env as an argument and the app layer calls it once at startup. The + * known-capable list is the same gate `scripts/tmux-chooser.sh` uses, plus a + * UTF-8 locale check and an explicit override. + */ +export function detectGlyphTier(env: Record): TuiGlyphTier { + const override = env.CODEMAN_TUI_GLYPHS; + if (override === 'ascii' || override === 'unicode' || override === 'nerd') return override; + const term = env.TERM ?? ''; + if (term === '' || term === 'dumb') return 'ascii'; + const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || ''; + if (!/utf-?8/i.test(locale)) return 'ascii'; + const termProgram = env.TERM_PROGRAM ?? ''; + if (termProgram.startsWith('iTerm') || term === 'xterm-kitty' || env.WEZTERM_PANE || env.LC_TERMINAL === 'iTerm2') { + return 'nerd'; + } + return 'unicode'; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Formatting helpers (pure, exported for tests and for the app layer) +// ───────────────────────────────────────────────────────────────────────────── + +/** Compact age: `45s`, `11m`, `2h`, `3d`. Empty when the anchor is unknown. */ +export function formatElapsed(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return ''; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +function trimTrailingZero(value: string): string { + return value.endsWith('.0') ? value.slice(0, -2) : value; +} + +/** Compact token count: `842`, `45.2k`, `1.2M`. Empty when there is nothing to show. */ +export function formatTokens(total: number): string { + if (!Number.isFinite(total) || total <= 0) return ''; + if (total < 1000) return String(Math.floor(total)); + if (total < 1_000_000) return `${trimTrailingZero((total / 1000).toFixed(1))}k`; + return `${trimTrailingZero((total / 1_000_000).toFixed(1))}M`; +} + +/** + * What a row is called. Same rule as the web history rows, including the + * "(no content)" placeholder the transcript reader emits, which is not a title. + */ +export function rowLabel(session: TuiSessionRow): string { + if (session.name) return session.name; + const prompt = (session.firstPrompt ?? '').trim(); + if (prompt && prompt !== '(no content)') return prompt; + const base = (session.workingDir ?? '').split('/').filter(Boolean).pop(); + return base || session.sessionId.slice(0, 8); +} + +/** Keep the tail of a path: the last segments identify it, the root never does. */ +function truncatePathLeft(path: string, width: number, ellipsis: string): string { + if (width <= 0) return ''; + if (visibleWidth(path) <= width) return path; + const keep = Math.max(0, width - visibleWidth(ellipsis)); + return ellipsis + path.slice(path.length - keep); +} + +function tokensOf(session: TuiSessionRow): number { + return (session.inputTokens ?? 0) + (session.outputTokens ?? 0); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Painting +// ───────────────────────────────────────────────────────────────────────────── + +type Painter = (text: string, code: string) => string; + +function painterFor(enabled: boolean): Painter { + return enabled ? (text, code) => (text === '' ? text : `${code}${text}${SGR.reset}`) : (text) => text; +} + +function stateGlyph(row: TuiRow, glyphs: TuiGlyphSet, tick: number): string { + switch (row.state) { + case 'blocked-permission': + return glyphs.blockedPermission; + case 'blocked-question': + return glyphs.blockedQuestion; + case 'waiting': + return glyphs.waiting; + case 'working': { + const frames = glyphs.working; + const index = ((Math.trunc(tick) % frames.length) + frames.length) % frames.length; + return frames[index]; + } + case 'idle': + return glyphs.idle; + case 'recent': + return glyphs.recent; + } +} + +function centered(text: string, width: number): string { + const pad = Math.max(0, Math.floor((width - visibleWidth(text)) / 2)); + return padDisplay(`${' '.repeat(pad)}${text}`, width); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Rows and groups +// ───────────────────────────────────────────────────────────────────────────── + +interface RowContext { + width: number; + /** 1-based position in the flattened list; only 1-9 get a jump digit. */ + index: number; + selected: boolean; + twoLine: boolean; + glyphs: TuiGlyphSet; + opts: TuiRenderOptions; +} + +function renderRowLines(row: TuiRow, ctx: RowContext): string[] { + // A selected row is one inverse-video block, so its parts are built unpainted: + // an inner reset would punch a hole in the highlight. + const inverse = ctx.selected && ctx.opts.color; + const paint = painterFor(ctx.opts.color && !inverse); + const { session } = row; + + const marker = ctx.selected ? padDisplay(ctx.glyphs.cursor, 2) : ' '; + const digit = ctx.index >= 1 && ctx.index <= 9 ? `${ctx.index} ` : ' '; + + const glyph = paint(stateGlyph(row, ctx.glyphs, ctx.opts.tick), STATE_COLOR[row.state]); + const elapsed = row.since > 0 ? formatElapsed(ctx.opts.now - row.since) : ''; + const tokens = formatTokens(tokensOf(session)); + const rightParts = [glyph, paint(elapsed, SGR.gray)]; + if (!ctx.twoLine && tokens) rightParts.push(paint(tokens, SGR.gray)); + const right = rightParts.filter((part) => part !== '').join(' '); + + const mode = session.mode && session.mode !== 'claude' ? session.mode : ''; + const nameWidth = Math.max(4, ctx.width - visibleWidth(marker + digit) - visibleWidth(right) - 1); + const label = rowLabel(session); + const name = mode ? `${label} ${paint(mode, SGR.magenta)}` : label; + + const first = padDisplay(`${marker}${digit}${padDisplay(name, nameWidth)} ${right}`, ctx.width); + const lines = [first]; + + if (ctx.twoLine) { + const detail = [truncatePathLeft(session.workingDir ?? '', Math.max(0, ctx.width - 8), ctx.glyphs.ellipsis)]; + if (mode) detail.push(mode); + if (tokens) detail.push(tokens); + const text = detail.filter((part) => part !== '').join(` ${ctx.glyphs.separator} `); + lines.push(padDisplay(` ${paint(text, SGR.gray)}`, ctx.width)); + } + + return inverse ? lines.map((line) => `${SGR.inverse}${line}${SGR.reset}`) : lines; +} + +function renderGroupHeader(group: TuiGroup, width: number, glyphs: TuiGlyphSet, opts: TuiRenderOptions): string { + const paint = painterFor(opts.color); + const label = ` ${group.label} `; + const fill = Math.max(0, width - visibleWidth(label)); + return padDisplay(`${paint(label, SGR.bold)}${paint(glyphs.rule.repeat(fill), SGR.gray)}`, width); +} + +export interface TuiListEntry { + text: string; + /** Set on the lines that belong to a session row, so the window can chase the cursor. */ + sessionId?: string; +} + +function buildListEntries(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): TuiListEntry[] { + const glyphs = glyphsFor(opts.glyphs); + const width = layout.list.width; + const entries: TuiListEntry[] = []; + let index = 0; + for (const group of model.groups()) { + if (group.rows.length === 0) continue; + entries.push({ text: renderGroupHeader(group, width, glyphs, opts) }); + for (const row of group.rows) { + index++; + const ctx: RowContext = { + width, + index, + selected: row.session.sessionId === model.selectedId, + twoLine: layout.rowHeight === 2, + glyphs, + opts, + }; + for (const text of renderRowLines(row, ctx)) entries.push({ text, sessionId: row.session.sessionId }); + } + } + return entries; +} + +/** + * First visible entry, scrolling the minimum needed to keep the selected row on + * screen. Deterministic on purpose: the window is derived, never remembered, so + * two identical models render identically. + */ +export function computeListWindow( + entries: readonly TuiListEntry[], + capacity: number, + selectedId: string | null +): number { + if (capacity <= 0 || entries.length <= capacity) return 0; + const maxStart = entries.length - capacity; + if (!selectedId) return 0; + const first = entries.findIndex((entry) => entry.sessionId === selectedId); + if (first < 0) return 0; + let last = first; + while (last + 1 < entries.length && entries[last + 1].sessionId === selectedId) last++; + let start = 0; + if (last >= capacity) start = Math.min(last - capacity + 1, maxStart); + if (first < start) start = first; + return start; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Preview +// ───────────────────────────────────────────────────────────────────────────── + +function buildPreviewLines(model: TuiRenderModel, rect: TuiRect, opts: TuiRenderOptions): string[] { + const paint = painterFor(opts.color); + const glyphs = glyphsFor(opts.glyphs); + const lines: string[] = []; + const selected = model.selectedId + ? (model + .groups() + .flatMap((group) => group.rows) + .find((row) => row.session.sessionId === model.selectedId) ?? null) + : null; + + if (!selected) { + lines.push(padDisplay(paint(' no session selected', SGR.gray), rect.width)); + } else { + const { session } = selected; + const parts = [session.mode ?? 'claude', session.workingDir ?? ''].filter((part) => part !== ''); + const title = ` ${rowLabel(session)} ${glyphs.separator} ${parts.join(` ${glyphs.separator} `)}`; + lines.push(padDisplay(paint(clipStyledLine(title, rect.width), SGR.bold), rect.width)); + } + + const body = previewBody(model, selected, rect, opts); + for (const line of body) lines.push(line); + while (lines.length < rect.height) lines.push(' '.repeat(rect.width)); + return lines.slice(0, Math.max(0, rect.height)); +} + +function previewBody(model: TuiRenderModel, selected: TuiRow | null, rect: TuiRect, opts: TuiRenderOptions): string[] { + const paint = painterFor(opts.color); + const capacity = Math.max(0, rect.height - 1); + if (capacity === 0) return []; + const hint = (text: string): string[] => [padDisplay(paint(` ${text}`, SGR.gray), rect.width)]; + + if (!selected) return []; + if (model.connection === 'degraded' || model.connection === 'down') { + return hint('preview unavailable while the server is down'); + } + const preview = model.preview; + if (!preview || preview.sessionId !== selected.session.sessionId) return hint('loading preview…'); + if (preview.error) return hint(preview.error); + + const trimmed = [...preview.lines]; + while (trimmed.length > 0 && trimmed[trimmed.length - 1].trim() === '') trimmed.pop(); + if (trimmed.length === 0) return hint('(no output yet)'); + // The tail carries the session's OWN colors, which is the point of the pane, + // but under NO_COLOR they must go too. + return trimmed + .slice(-capacity) + .map((line) => padDisplay(` ${clipStyledLine(opts.color ? line : stripStyles(line), rect.width - 1)}`, rect.width)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Chrome +// ───────────────────────────────────────────────────────────────────────────── + +function renderHeaderLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string { + const paint = painterFor(opts.color); + const glyphs = glyphsFor(opts.glyphs); + const { hostname, instance, version, planUsage } = model.header; + const facts = [ + instance ? `${hostname ?? ''}:${instance}` : (hostname ?? ''), + version ? `v${version}` : '', + `${model.sessionCount} session${model.sessionCount === 1 ? '' : 's'}`, + planUsage ?? '', + ].filter((part) => part !== ''); + + const left = ` ${paint('codeman', SGR.bold)} ${paint(facts.join(` ${glyphs.separator} `), SGR.gray)}`; + const right = paint('? help q quit ', SGR.gray); + const gap = layout.cols - visibleWidth(left) - visibleWidth(right); + if (gap < 1) return padDisplay(left, layout.cols); + return `${left}${' '.repeat(gap)}${right}`; +} + +function renderBannerLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string { + const paint = painterFor(opts.color); + const glyphs = glyphsFor(opts.glyphs); + const [text, color] = + model.connection === 'degraded' + ? ['server not running: attach only', SGR.yellow] + : model.connection === 'reconnecting' + ? ['reconnecting to the server…', SGR.yellow] + : ['server unreachable', SGR.red]; + return padDisplay(paint(` ${glyphs.blockedPermission} ${text}`, color), layout.cols); +} + +const FOOTER_KEYS: Record string> = { + list: (g) => + [ + `${g.updown} select`, + `${g.enter} attach`, + '1-9 jump', + 'y/n answer', + 'p prompt', + 'n new', + 'x kill', + '/ search', + 'g digest', + '? help', + 'q quit', + ].join(` ${g.separator} `), + help: (g) => `esc ${g.separator} ? close`, + 'confirm-kill': (g) => `type the name ${g.separator} ${g.enter} confirm ${g.separator} esc cancel`, + message: () => 'esc dismiss', + prompt: (g) => `${g.enter} send ${g.separator} esc cancel`, + search: (g) => `${g.enter} open ${g.separator} esc cancel`, +}; + +function renderFooterLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string { + const paint = painterFor(opts.color); + const glyphs = glyphsFor(opts.glyphs); + const build = FOOTER_KEYS[model.mode] ?? FOOTER_KEYS.list; + return padDisplay(paint(clipStyledLine(` ${build(glyphs)}`, layout.cols), SGR.gray), layout.cols); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Overlays +// ───────────────────────────────────────────────────────────────────────────── + +interface OverlayContent { + title: string; + lines: string[]; +} + +function wrapText(text: string, width: number): string[] { + if (width <= 0) return []; + const out: string[] = []; + let line = ''; + for (const word of text.split(/\s+/).filter((part) => part !== '')) { + const candidate = line === '' ? word : `${line} ${word}`; + if (visibleWidth(candidate) > width && line !== '') { + out.push(line); + line = word; + } else { + line = candidate; + } + } + if (line !== '') out.push(line); + return out.length > 0 ? out : ['']; +} + +function helpLines(glyphs: TuiGlyphSet): string[] { + const pairs: Array<[string, string]> = [ + [`${glyphs.updown} / j k`, 'select'], + [glyphs.enter, 'attach'], + ['1-9', 'jump'], + ['y / n', 'answer the pending approval'], + ['p', 'send a prompt'], + ['n', 'new session'], + ['x', 'kill (typed confirmation)'], + ['/', 'search'], + ['g', 'away digest'], + ['r', 'resume a recent session'], + ['?', 'this help'], + ['q', 'quit'], + ]; + const keyWidth = Math.max(...pairs.map(([key]) => visibleWidth(key))); + return pairs.map(([key, description]) => `${padDisplay(key, keyWidth)} ${description}`); +} + +function overlayContent(model: TuiRenderModel, opts: TuiRenderOptions, width: number): OverlayContent | null { + const glyphs = glyphsFor(opts.glyphs); + switch (model.mode) { + case 'help': + return { title: 'Keys', lines: helpLines(glyphs) }; + case 'confirm-kill': { + if (!model.confirm) return null; + const { name, typed } = model.confirm; + return { + title: 'Kill session', + lines: [`Kill ${name}?`, '', 'Type the name to confirm:', ` ${typed}_`], + }; + } + case 'message': + if (!model.message) return null; + return { + title: model.message.tone === 'err' ? 'Error' : model.message.tone === 'warn' ? 'Warning' : 'Notice', + lines: wrapText(model.message.text, Math.max(8, width - 8)), + }; + default: + return null; + } +} + +/** Paint an overlay box over the body, centered, replacing whole terminal rows. */ +function applyOverlay(lines: string[], model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): void { + const body = layout.body; + if (body.height < 3 || body.width < 12) return; + const content = overlayContent(model, opts, body.width); + if (!content) return; + + const paint = painterFor(opts.color); + const glyphs = glyphsFor(opts.glyphs); + const maxInner = body.width - 4; + const visible = content.lines.slice(0, Math.max(1, body.height - 2)); + const inner = Math.min( + maxInner, + Math.max(visibleWidth(content.title) + 2, ...visible.map((line) => visibleWidth(line))) + ); + const boxWidth = inner + 4; + const boxHeight = visible.length + 2; + const left = body.col + Math.max(0, Math.floor((body.width - boxWidth) / 2)); + const top = body.row + Math.max(0, Math.floor((body.height - boxHeight) / 2)); + + const titleText = ` ${content.title} `; + const titleFill = Math.max(0, inner + 2 - visibleWidth(titleText)); + const boxLines = [ + `${glyphs.boxTopLeft}${titleText}${glyphs.boxHorizontal.repeat(titleFill)}${glyphs.boxTopRight}`, + ...visible.map((line) => `${glyphs.boxVertical} ${padDisplay(line, inner)} ${glyphs.boxVertical}`), + `${glyphs.boxBottomLeft}${glyphs.boxHorizontal.repeat(inner + 2)}${glyphs.boxBottomRight}`, + ]; + + for (let i = 0; i < boxLines.length; i++) { + const row = top + i - 1; + if (row < 0 || row >= lines.length) continue; + lines[row] = `${' '.repeat(left - 1)}${paint(boxLines[i], SGR.cyan)}`; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Frame +// ───────────────────────────────────────────────────────────────────────────── + +function writeBody(lines: string[], model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): void { + const { list, preview, divider } = layout; + if (list.height <= 0) return; + const paint = painterFor(opts.color); + const glyphs = glyphsFor(opts.glyphs); + + const entries = buildListEntries(model, layout, opts); + if (entries.length === 0) { + const hint = paint('No sessions. n to start one, q to quit.', SGR.gray); + const row = list.row + Math.floor((list.height - 1) / 2); + lines[row - 1] = centered(hint, layout.cols); + return; + } + + const start = computeListWindow(entries, list.height, model.selectedId); + const previewLines = preview ? buildPreviewLines(model, preview, opts) : []; + + for (let i = 0; i < list.height; i++) { + const left = entries[start + i]?.text ?? ' '.repeat(list.width); + if (!preview || !divider) { + lines[list.row - 1 + i] = left; + continue; + } + const right = previewLines[i] ?? ' '.repeat(preview.width); + lines[list.row - 1 + i] = `${left}${paint(glyphs.divider, SGR.gray)}${right}`; + } +} + +/** + * The whole frame as one string: absolute cursor addressing per line, each line + * closed with an erase-to-end so a shorter line cannot leave the previous + * frame's tail behind. + */ +export function renderFrame(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string { + const lines: string[] = new Array(layout.rows).fill(''); + lines[0] = renderHeaderLine(model, layout, opts); + if (layout.banner) lines[layout.banner.row - 1] = renderBannerLine(model, layout, opts); + writeBody(lines, model, layout, opts); + if (layout.footer.height > 0) lines[layout.footer.row - 1] = renderFooterLine(model, layout, opts); + applyOverlay(lines, model, layout, opts); + + let frame = ''; + for (let i = 0; i < lines.length; i++) { + frame += `\x1b[${i + 1};1H${clipStyledLine(lines[i], layout.cols)}\x1b[K`; + } + return frame; +} diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts new file mode 100644 index 00000000..21a936b7 --- /dev/null +++ b/test/tui/tui-render.test.ts @@ -0,0 +1,403 @@ +/** + * @fileoverview Unit tests for the frame renderer. + * + * The structural expectations below are full frames with the escapes stripped, + * which is what makes a layout regression readable in a diff; the escape + * sequences themselves are asserted separately, including the promise that + * NO_COLOR leaves nothing but cursor addressing behind. + */ +import { describe, it, expect } from 'vitest'; +import { stripStyles, toDisplayLines, visibleWidth } from '../../src/tui/tui-ansi.js'; +import { computeLayout, needsBanner } from '../../src/tui/tui-layout.js'; +import { createTuiModel, type TuiModelStore } from '../../src/tui/tui-model.js'; +import { + detectGlyphTier, + formatElapsed, + formatTokens, + renderFrame, + rowLabel, + type TuiRenderOptions, +} from '../../src/tui/tui-render.js'; + +const NOW = 1_700_000_000_000; + +const PLAIN: TuiRenderOptions = { color: false, glyphs: 'unicode', tick: 4, now: NOW }; + +function fixture(): TuiModelStore { + const model = createTuiModel(); + model.setHeader({ hostname: 'tnode', version: '1.19.0', planUsage: '5h 32% wk 61%' }); + model.replaceSessions([ + { + sessionId: 'aaa1', + name: 'w4-api-refactor', + mode: 'claude', + status: 'busy', + isWorking: true, + workingDir: '/home/dev/api', + createdAt: NOW - 9_000_000, + lastActivityAt: NOW - 1_000, + lastSubmitAt: NOW - 134_000, + inputTokens: 9_000, + outputTokens: 3_300, + sources: ['live', 'persisted'], + }, + { + sessionId: 'bbb2', + name: 'w6-docs', + mode: 'claude', + status: 'idle', + workingDir: '/home/dev/docs', + createdAt: NOW - 8_000_000, + lastActivityAt: NOW - 660_000, + sources: ['live'], + }, + { + sessionId: 'ccc3', + name: 'w1-codeman', + mode: 'claude', + status: 'busy', + isWorking: true, + workingDir: '/home/dev/codeman', + createdAt: NOW - 10_000_000, + lastActivityAt: NOW, + lastSubmitAt: NOW - 1_020_000, + inputTokens: 40_000, + outputTokens: 5_200, + sources: ['live'], + }, + { + sessionId: 'ddd4', + name: 'w2-gallery', + mode: 'codex', + status: 'idle', + workingDir: '/home/dev/gallery', + createdAt: NOW - 6_000_000, + lastActivityAt: NOW - 7_200_000, + sources: ['live'], + }, + { + sessionId: 'eee5', + firstPrompt: 'fix the release script', + workingDir: '/home/dev/api', + lastActivityAt: NOW - 3 * 86_400_000, + sources: ['history'], + }, + ]); + model.setApprovals([ + { id: 'bbb2:1', sessionId: 'bbb2', sessionName: 'w6-docs', kind: 'idle', createdAt: NOW - 680_000 }, + { + id: 'aaa1:2', + sessionId: 'aaa1', + sessionName: 'w4-api-refactor', + kind: 'permission', + createdAt: NOW - 120_000, + toolName: 'Bash', + }, + ]); + model.select('aaa1'); + model.setPreview({ + sessionId: 'aaa1', + lines: toDisplayLines('\x1b[32mActualizing...\x1b[0m (2m 14s)\nrunning tests\n\x1b[31mwarning\x1b[0m here\n'), + }); + return model; +} + +function render(model: TuiModelStore, cols: number, rows: number, opts: Partial = {}): string { + const layout = computeLayout(cols, rows, { banner: needsBanner(model.connection) }); + return renderFrame(model, layout, { ...PLAIN, ...opts }); +} + +/** The frame as visible lines: escapes stripped, trailing padding trimmed. */ +function frameLines(frame: string): string[] { + return frame + .split(/\x1b\[\d+;1H/) + .slice(1) + .map((part) => stripStyles(part).trimEnd()); +} + +describe('renderFrame structure', () => { + it('paints the wide layout at 100x30', () => { + expect(frameLines(render(fixture(), 100, 30))).toEqual([ + ' codeman tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit', + ' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api', + ' 1 w6-docs ✋ 11m│ Actualizing... (2m 14s)', + '▶ 2 w4-api-refactor ⚠ 2m 12.3k│ running tests', + ' WORKING ───────────────────────────│ warning here', + ' 3 w1-codeman ✻ 17m 45.2k│', + ' IDLE ──────────────────────────────│', + ' 4 w2-gallery codex ○ 2h│', + ' RECENT ────────────────────────────│', + ' 5 fix the release script ✔ 3d│', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' │', + ' ↑↓ select · ⏎ attach · 1-9 jump · y/n answer · p prompt · n new · x kill · / search · g digest · ?', + ]); + }); + + it('paints the narrow two-line layout at 44x20', () => { + expect(frameLines(render(fixture(), 44, 20))).toEqual([ + ' codeman tnode · v1.19.0 · 4 sessions · 5h', + ' NEEDS YOU ─────────────────────────────────', + ' 1 w6-docs ✋ 11m', + ' /home/dev/docs', + '▶ 2 w4-api-refactor ⚠ 2m', + ' /home/dev/api · 12.3k', + ' WORKING ───────────────────────────────────', + ' 3 w1-codeman ✻ 17m', + ' /home/dev/codeman · 45.2k', + ' IDLE ──────────────────────────────────────', + ' 4 w2-gallery codex ○ 2h', + ' /home/dev/gallery · codex', + ' RECENT ────────────────────────────────────', + ' 5 fix the release script ✔ 3d', + ' /home/dev/api', + '', + '', + '', + '', + ' ↑↓ select · ⏎ attach · 1-9 jump · y/n answe', + ]); + }); + + it('addresses every line absolutely and erases its tail', () => { + const frame = render(fixture(), 100, 30); + const addresses = [...frame.matchAll(/\x1b\[(\d+);1H/g)].map((match) => Number(match[1])); + expect(addresses).toEqual(Array.from({ length: 30 }, (_, i) => i + 1)); + expect(frame.split('\x1b[K')).toHaveLength(31); + expect(frame).not.toContain('\n'); + }); + + it('never lets a line exceed the terminal width', () => { + for (const [cols, rows] of [ + [100, 30], + [44, 20], + [72, 8], + [30, 6], + ] as const) { + for (const line of frameLines(render(fixture(), cols, rows))) { + expect(visibleWidth(line)).toBeLessThanOrEqual(cols); + } + } + }); + + it('is deterministic for identical inputs', () => { + expect(render(fixture(), 100, 30)).toBe(render(fixture(), 100, 30)); + }); + + it('degrades to a header-only frame on a 5x5 terminal without throwing', () => { + expect(() => render(fixture(), 5, 5)).not.toThrow(); + expect(frameLines(render(fixture(), 5, 5))).toHaveLength(5); + }); +}); + +describe('color', () => { + it('paints states and chrome when color is on', () => { + const model = fixture(); + model.select('eee5'); + const frame = render(model, 100, 30, { color: true }); + expect(frame).toContain('\x1b[32m✻'); + expect(frame).toContain('\x1b[31m⚠'); + expect(frame).toContain('\x1b[33m✋'); + expect(frame).toContain('\x1b[1mcodeman'); + }); + + it('paints the selected row as one inverse block with no styling inside it', () => { + // An inner reset would punch a hole in the highlight, so the selected row + // is built unpainted and wrapped instead. + const frame = render(fixture(), 100, 30, { color: true }); + const highlighted = frame.split('\x1b[7m')[1]?.split('\x1b[0m')[0] ?? ''; + expect(highlighted).toContain('w4-api-refactor'); + expect(highlighted).toContain('⚠'); + expect(frame).not.toContain('\x1b[31m⚠'); + }); + + it('emits nothing but cursor addressing when color is off', () => { + const frame = render(fixture(), 100, 30, { color: false }); + const withoutAddressing = frame.replace(/\x1b\[\d+;1H/g, '').replace(/\x1b\[K/g, ''); + expect(withoutAddressing).not.toContain('\x1b'); + }); + + it('strips the session own colors out of the preview under NO_COLOR', () => { + const model = fixture(); + model.setPreview({ sessionId: 'aaa1', lines: toDisplayLines('\x1b[31mred tail\x1b[0m') }); + expect(render(model, 100, 30, { color: false })).not.toContain('\x1b[31m'); + expect(render(model, 100, 30, { color: true })).toContain('\x1b[31m'); + }); +}); + +describe('glyph tiers', () => { + it('falls back to bracketed ASCII tokens', () => { + const lines = frameLines(render(fixture(), 100, 30, { glyphs: 'ascii' })); + const list = lines.map((line) => line.split('|')[0]); + expect(list[2]).toContain('[w]'); + expect(list[3]).toContain('[!]'); + expect(list[5]).toContain('[*]'); + expect(list[7]).toContain('[-]'); + expect(list[9]).toContain('[v]'); + expect(list[3].startsWith('>')).toBe(true); + expect(lines.join('')).not.toContain('✻'); + expect(lines.join('')).not.toContain('─'); + }); + + it('animates the working glyph with the tick', () => { + const model = fixture(); + const frames = [0, 1, 2, 3, 4, 5].map((tick) => frameLines(render(model, 100, 30, { tick }))[5]); + expect(frames[0]).toContain('·'); + expect(frames[1]).toContain('✢'); + expect(frames[2]).toContain('✳'); + expect(frames[3]).toContain('∗'); + expect(frames[4]).toContain('✻'); + expect(frames[5]).toContain('✽'); + expect(new Set(frames).size).toBe(6); + }); + + it('detects a tier from the environment', () => { + expect(detectGlyphTier({ TERM: 'xterm-256color', LANG: 'en_US.UTF-8' })).toBe('unicode'); + expect(detectGlyphTier({ TERM: 'xterm-kitty', LANG: 'en_US.UTF-8' })).toBe('nerd'); + expect(detectGlyphTier({ TERM: 'xterm-256color', TERM_PROGRAM: 'iTerm.app', LANG: 'en_US.UTF-8' })).toBe('nerd'); + expect(detectGlyphTier({ TERM: 'xterm-256color', LANG: 'C' })).toBe('ascii'); + expect(detectGlyphTier({ TERM: 'dumb' })).toBe('ascii'); + expect(detectGlyphTier({})).toBe('ascii'); + expect(detectGlyphTier({ TERM: 'xterm-kitty', LANG: 'en_US.UTF-8', CODEMAN_TUI_GLYPHS: 'ascii' })).toBe('ascii'); + }); +}); + +describe('overlays', () => { + it('draws the help box over the body', () => { + const model = fixture(); + model.setMode('help'); + const lines = frameLines(render(model, 100, 30)); + expect(lines.join('\n')).toContain('┌ Keys '); + expect(lines.some((line) => line.includes('attach'))).toBe(true); + expect(lines[lines.length - 1]).toContain('close'); + }); + + it('draws the typed confirmation for a kill', () => { + const model = fixture(); + model.beginConfirmKill(model.rows()[0]); + model.setConfirmInput('w6-d'); + const text = frameLines(render(model, 100, 30)).join('\n'); + expect(text).toContain('Kill w6-docs?'); + expect(text).toContain('Type the name to confirm:'); + expect(text).toContain('w6-d_'); + expect(text).toContain('esc cancel'); + }); + + it('draws a message box', () => { + const model = fixture(); + model.setMessage({ text: 'session refused to start: tmux is not installed', tone: 'err' }); + const text = frameLines(render(model, 100, 30)).join('\n'); + expect(text).toContain('Error'); + expect(text).toContain('tmux is not installed'); + }); + + it('skips the overlay when the body is too small to hold a box', () => { + const model = fixture(); + model.setMode('help'); + expect(frameLines(render(model, 100, 4)).join('\n')).not.toContain('Keys'); + }); +}); + +describe('connection states', () => { + it('banners a degraded server and says the preview is gone', () => { + const model = fixture(); + model.setConnection('degraded'); + const lines = frameLines(render(model, 100, 30)); + expect(lines[1]).toContain('server not running: attach only'); + expect(lines.join('\n')).toContain('preview unavailable while the server is down'); + }); + + it('banners reconnecting and down differently', () => { + const model = fixture(); + model.setConnection('reconnecting'); + expect(frameLines(render(model, 100, 30))[1]).toContain('reconnecting'); + model.setConnection('down'); + expect(frameLines(render(model, 100, 30))[1]).toContain('server unreachable'); + }); +}); + +describe('empty and partial states', () => { + it('shows the empty hint when there are no sessions', () => { + const model = createTuiModel(); + const lines = frameLines(render(model, 100, 30)); + expect(lines.join('\n')).toContain('No sessions. n to start one, q to quit.'); + expect(lines[1]).not.toContain('NEEDS YOU'); + }); + + it('says the preview is still loading when it belongs to another session', () => { + const model = fixture(); + model.setPreview({ sessionId: 'bbb2', lines: ['other session'] }); + const text = frameLines(render(model, 100, 30)).join('\n'); + expect(text).toContain('loading preview…'); + expect(text).not.toContain('other session'); + }); + + it('surfaces a preview error instead of a stale tail', () => { + const model = fixture(); + model.setPreview({ sessionId: 'aaa1', lines: [], error: 'terminal capture failed' }); + expect(frameLines(render(model, 100, 30)).join('\n')).toContain('terminal capture failed'); + }); + + it('scrolls the list so the selected row stays visible', () => { + const model = createTuiModel(); + model.replaceSessions( + Array.from({ length: 30 }, (_, i) => ({ + sessionId: `s${String(i).padStart(2, '0')}`, + name: `session-${String(i).padStart(2, '0')}`, + sources: ['live'], + status: 'idle', + lastActivityAt: NOW - i * 1000, + })) + ); + model.select('s29'); + const text = frameLines(render(model, 100, 12)).join('\n'); + expect(text).toContain('session-29'); + expect(text).not.toContain('session-00'); + }); +}); + +describe('formatting helpers', () => { + it('formats elapsed time compactly', () => { + expect(formatElapsed(0)).toBe('0s'); + expect(formatElapsed(45_000)).toBe('45s'); + expect(formatElapsed(11 * 60_000)).toBe('11m'); + expect(formatElapsed(2 * 3_600_000)).toBe('2h'); + expect(formatElapsed(3 * 86_400_000)).toBe('3d'); + expect(formatElapsed(-1)).toBe(''); + expect(formatElapsed(Number.NaN)).toBe(''); + }); + + it('formats token counts compactly', () => { + expect(formatTokens(0)).toBe(''); + expect(formatTokens(842)).toBe('842'); + expect(formatTokens(45_200)).toBe('45.2k'); + expect(formatTokens(45_000)).toBe('45k'); + expect(formatTokens(1_200_000)).toBe('1.2M'); + }); + + it('names a row the way the web history list does', () => { + expect(rowLabel({ sessionId: 'abcdef12', name: 'w1', sources: [] })).toBe('w1'); + expect(rowLabel({ sessionId: 'abcdef12', firstPrompt: 'do the thing', sources: [] })).toBe('do the thing'); + expect(rowLabel({ sessionId: 'abcdef12', firstPrompt: '(no content)', workingDir: '/a/b/case', sources: [] })).toBe( + 'case' + ); + expect(rowLabel({ sessionId: 'abcdef1234', sources: [] })).toBe('abcdef12'); + }); +});