/** * @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 { approvalCard } from './tui-approvals.js'; import { composerText, composerWindow } from './tui-composer.js'; import type { TuiLayout, TuiRect } from './tui-layout.js'; import type { ApprovalItem } from '../web/approval-inbox.js'; import type { StatusTelemetry } from '../usage-telemetry.js'; import type { TuiDigestState, TuiGlyphTier, TuiGroup, TuiPickerState, TuiPromptState, TuiRenderModel, TuiRow, TuiSearchState, 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; /** * Footer entries, already labelled, joined here with the separator glyph. * The app layer passes the keys that actually do something right now (which * verbs are wired up, whether a server is answering); omitting it falls back * to the full keymap below. */ footerKeys?: readonly string[]; /** * `[key, what it does]` pairs for the help overlay, same reasoning as * `footerKeys`: the app layer knows which verbs are wired up. Omitting it * falls back to the full keymap. */ helpKeys?: ReadonlyArray; } // ───────────────────────────────────────────────────────────────────────────── // 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; /** * One word per state, shared by the preview title and the `--list` output so * both surfaces call a session the same thing. */ export const STATE_WORDS: Record = { 'blocked-permission': 'blocked', 'blocked-question': 'blocked', waiting: 'waiting', working: 'working', idle: 'idle', recent: 'done', }; 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; } /** * ⚠️ Every glyph here must clear TWO bars that are easy to miss, and both were * failed at once by the first version of this table. * * WIDTH: the renderer addresses cells by column, so a glyph the terminal draws * two cells wide shifts everything after it. `east_asian_width` W or F is * therefore disqualifying. `✋` (U+270B) was Wide, and being an emoji is also * why fonts render it at emoji size in the middle of a text row. * * COVERAGE: a plain terminal font carries far less than the unicode TIER * implies. The tier answers "is the locale UTF-8", which says nothing about * whether a given codepoint has a glyph. A beta tester's font drew `·`, `─`, * `│`, `○`, `▶` and `✔` perfectly while drawing `⏎` (U+23CE) as an empty box. * Prefer Latin-1, Arrows (U+2190–21FF), Box Drawing, Block Elements and * Geometric Shapes, which every monospace font ships; treat Dingbats, * Miscellaneous Symbols and anything with emoji presentation as suspect. */ 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`; } /** * The header's plan-usage chip: `5h 32% · wk 61%`, the same two windows the web * chip shows (the statusline telemetry carries no others). Empty when the * account reports neither, so the header shows no placeholder for a fact that * does not exist. The separator is passed in because the header's own comes * from the glyph tier, and an ASCII terminal must not get a stray `·`. */ export function formatPlanUsage(usage: StatusTelemetry | null | undefined, separator = ' · '): string { if (!usage) return ''; const parts: string[] = []; if (typeof usage.fiveHour?.usedPercentage === 'number') { parts.push(`5h ${Math.round(usage.fiveHour.usedPercentage)}%`); } if (typeof usage.sevenDay?.usedPercentage === 'number') { parts.push(`wk ${Math.round(usage.sevenDay.usedPercentage)}%`); } return parts.join(separator); } /** * 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 base = (session.workingDir ?? '').split('/').filter(Boolean).pop(); // ⚠️ A LIVE pane (it has a mux name) is identified by WHERE it runs, never by // a line scraped out of its transcript. A session created before the user has // typed anything has no prompt to be named after, so the fallback took // whatever the CLI happened to print first: a beta tester's new session // appeared in the list called "Login interrupted", which reads like a failure // report and was in fact a healthy session. A history row is the opposite // case, where the prompt IS the identity, so it keeps the old order. if (session.muxName && base) return base; const prompt = (session.firstPrompt ?? '').trim(); if (prompt && prompt !== '(no content)') return prompt; 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 // ───────────────────────────────────────────────────────────────────────────── /** * The pending dialog, drawn above the tail: the question, the parsed options * with their digits, and the keys that answer them. Red for a permission or * question prompt, yellow for an idle one, the same severity vocabulary the web * inbox uses. */ export function renderApprovalCard( item: ApprovalItem, width: number, glyphs: TuiGlyphSet, opts: TuiRenderOptions ): string[] { const paint = painterFor(opts.color); const card = approvalCard(item); const color = card.tone === 'err' ? SGR.red : SGR.yellow; const glyph = card.tone === 'err' ? glyphs.blockedPermission : glyphs.waiting; const lines: string[] = []; const push = (text: string, style: string): void => { lines.push(padDisplay(paint(clipStyledLine(text, width), style), width)); }; push(` ${glyph} ${card.title}`, color); for (const detail of card.detail) push(` ${detail}`, SGR.gray); for (const option of card.options) push(` ${option.n}. ${option.label}`, ''); push(` ${card.hint}`, SGR.gray); return lines; } /** The card may take half the pane at most: the tail is why the pane exists. */ function cardCapacity(height: number): number { return Math.max(0, Math.floor((height - 1) / 2)); } /** * `name · mode · dir · state`, with the DIRECTORY absorbing the squeeze: the * state word is the one fact the pane exists to confirm, so it must survive a * narrow preview that a full path would push off the end. */ function previewTitle(row: TuiRow, width: number, glyphs: TuiGlyphSet): string { const { session } = row; const sep = ` ${glyphs.separator} `; const head = ` ${rowLabel(session)}${sep}${session.mode ?? 'claude'}`; const tail = `${sep}${STATE_WORDS[row.state]}`; const dirBudget = width - visibleWidth(head) - visibleWidth(tail) - visibleWidth(sep); const dir = session.workingDir ? truncatePathLeft(session.workingDir, Math.max(0, dirBudget), glyphs.ellipsis) : ''; return clipStyledLine(dir ? `${head}${sep}${dir}${tail}` : `${head}${tail}`, width); } 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 { lines.push(padDisplay(paint(previewTitle(selected, rect.width, glyphs), SGR.bold), rect.width)); } const budget = cardCapacity(rect.height); if (selected?.approval && budget > 0) { for (const line of renderApprovalCard(selected.approval, rect.width, glyphs, opts).slice(0, budget)) { lines.push(line); } if (lines.length < rect.height) lines.push(' '.repeat(rect.width)); } const body = previewBody(model, selected, rect, opts, rect.height - lines.length); 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, capacity: number ): string[] { const paint = painterFor(opts.color); 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.note) return hint(preview.note); 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 // ───────────────────────────────────────────────────────────────────────────── /** Sessions with a prompt waiting on a human, which is what the badge counts. */ export function pendingApprovalCount(model: TuiRenderModel): number { let count = 0; for (const group of model.groups()) for (const row of group.rows) if (row.approval) count++; return count; } 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 pending = pendingApprovalCount(model); const badge = pending > 0 ? `${paint(`${glyphs.blockedPermission} ${pending}`, SGR.red)} ` : ''; const left = ` ${paint('codeman', SGR.bold)} ${badge}${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.updown} results ${g.separator} ${g.enter} open ${g.separator} esc close`, digest: (g) => `j/k ${g.separator} ${g.updown} scroll ${g.separator} esc close`, 'new-session': (g) => `${g.updown} select ${g.separator} ${g.enter} choose ${g.separator} esc cancel`, }; /** * The composer's prefix. Fixed width on purpose: the terminal cursor is placed * by column arithmetic (`composerCursorCell`), and a prefix that changed with * the session name would move the cursor with it. */ export const COMPOSER_PREFIX = ' > '; function renderComposerLine(prompt: TuiPromptState, layout: TuiLayout, opts: TuiRenderOptions): string { const paint = painterFor(opts.color); const window = composerWindow(prompt.composer, Math.max(1, layout.cols - visibleWidth(COMPOSER_PREFIX))); return padDisplay(`${paint(COMPOSER_PREFIX, SGR.cyan)}${window.text}`, layout.cols); } /** * Where the terminal's own cursor belongs, or null when nothing is being typed * into a single-line editor. The app shows the cursor there and hides it * otherwise, because a blinking cursor parked in a dashboard reads as a bug. */ export function composerCursorCell(model: TuiRenderModel, layout: TuiLayout): { row: number; col: number } | null { if (model.mode !== 'prompt' || !model.prompt || layout.footer.height <= 0) return null; const prefix = visibleWidth(COMPOSER_PREFIX); const window = composerWindow(model.prompt.composer, Math.max(1, layout.cols - prefix)); return { row: layout.footer.row, col: Math.min(layout.cols, prefix + 1 + window.cursorColumn) }; } function renderFooterLine(model: TuiRenderModel, layout: TuiLayout, opts: TuiRenderOptions): string { const paint = painterFor(opts.color); const glyphs = glyphsFor(opts.glyphs); if (model.mode === 'prompt' && model.prompt) return renderComposerLine(model.prompt, layout, opts); const text = opts.footerKeys ? opts.footerKeys.join(` ${glyphs.separator} `) : (FOOTER_KEYS[model.mode] ?? FOOTER_KEYS.list)(glyphs); return padDisplay(paint(clipStyledLine(` ${text}`, layout.cols), SGR.gray), layout.cols); } // ───────────────────────────────────────────────────────────────────────────── // Overlays // ───────────────────────────────────────────────────────────────────────────── interface OverlayContent { title: string; lines: string[]; /** * Floor for the box's inner width. The search and digest panels are lists * people scan, so they keep a stable width instead of snapping around their * longest current line. */ minWidth?: number; } 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, custom?: ReadonlyArray): string[] { const pairs: ReadonlyArray = custom ?? [ [`${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'], ['?', 'this help'], ['q', 'quit'], ]; const keyWidth = Math.max(...pairs.map(([key]) => visibleWidth(key))); return pairs.map(([key, description]) => `${padDisplay(key, keyWidth)} ${description}`); } /** Longest item list a picker overlay shows, however tall the terminal is. */ const PICKER_MAX_ROWS = 10; /** * A picker's lines: hint, a window of items around the cursor, then the filter * echo. Windowed rather than clipped, so the selected item is always visible in * a long case list. */ function pickerLines(picker: TuiPickerState, glyphs: TuiGlyphSet, capacity: number): string[] { const head: string[] = picker.hint ? [picker.hint, ''] : []; const tail: string[] = picker.filter === undefined ? [] : ['', `filter: ${picker.filter}_`]; if (picker.items.length === 0) return [...head, '(nothing to choose)', ...tail]; const budget = Math.max(1, Math.min(PICKER_MAX_ROWS, capacity - head.length - tail.length)); const first = Math.max(0, Math.min(picker.index - Math.floor(budget / 2), picker.items.length - budget)); const rows = picker.items.slice(first, first + budget).map((item, i) => { const marker = first + i === picker.index ? glyphs.cursor : ' '.repeat(visibleWidth(glyphs.cursor)); return `${marker} ${item.label}${item.detail ? ` ${item.detail}` : ''}`; }); return [...head, ...rows, ...tail]; } /** * The `/` overlay: the query with a caret, one status line, then the results. * * The caret is a trailing `_` rather than the terminal's own cursor, and that is * why the search keymap leaves left/right to the result list: a caret that * cannot move is honest, an invisible one that can is not. */ function searchLines(state: TuiSearchState, glyphs: TuiGlyphSet, capacity: number): string[] { const head = [`${composerText(state.composer)}_`]; if (state.note) head.push(state.note); head.push(''); const budget = Math.max(1, capacity - head.length); if (state.entries.length === 0) { return [...head, state.status === 'searching' ? 'searching…' : '(type to search sessions, events and files)']; } const first = Math.max(0, Math.min(state.index - Math.floor(budget / 2), state.entries.length - budget)); const rows = state.entries.slice(first, first + budget).map((entry, i) => { if (entry.kind === 'header') return entry.text; const marker = first + i === state.index ? glyphs.cursor : ' '.repeat(visibleWidth(glyphs.cursor)); return `${marker} ${entry.text}${entry.detail ? ` ${entry.detail}` : ''}`; }); return [...head, ...rows]; } /** Lines an overlay box can show inside its border, given the body's height. */ function overlayCapacity(height: number): number { return Math.max(1, height - 2); } /** * How many digest lines fit. Exported because the app scrolls by pages and must * not scroll the last page into empty space, which needs this exact number. */ export function digestCapacity(layout: TuiLayout): number { return overlayCapacity(layout.body.height); } function digestLines(state: TuiDigestState, capacity: number): string[] { const offset = Math.min(Math.max(0, state.offset), Math.max(0, state.lines.length - 1)); return state.lines.slice(offset, offset + capacity); } function overlayContent( model: TuiRenderModel, opts: TuiRenderOptions, width: number, height: number ): OverlayContent | null { const glyphs = glyphsFor(opts.glyphs); const panelWidth = Math.max(20, Math.min(width - 8, 72)); switch (model.mode) { case 'help': return { title: 'Keys', lines: helpLines(glyphs, opts.helpKeys) }; case 'search': { if (!model.search) return null; return { title: 'Search', lines: searchLines(model.search, glyphs, overlayCapacity(height)), minWidth: panelWidth, }; } case 'digest': { if (!model.digest) return null; return { title: model.digest.title, lines: digestLines(model.digest, overlayCapacity(height)), minWidth: panelWidth, }; } case 'new-session': { if (!model.picker) return null; return { title: model.picker.title, lines: pickerLines(model.picker, glyphs, Math.max(1, height - 2)) }; } 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, body.height); 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(content.minWidth ?? 0, 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; }