/** * Lightweight line-based terminal renderer for the TUI. Renders the * `AppStateRest` transcript, input line, overlay, and status into an array of * plain-text + ANSI escaped lines. No external ncurses dependency — outputs * raw ANSI sequences that work in any terminal. Conceptually mirrors * `apps/interfaces/tui/src/view/**` but simplified to line output. */ import { Roles } from "@zesdex/domain"; import type { AppStateRest } from "./state.ts"; import { Overlay } from "./state.ts"; const roleLabel: Record = { [Roles.User]: "you", [Roles.Assistant]: "zesdex", [Roles.System]: "system", [Roles.Tool]: "tool", }; /** Render the full TUI frame into an array of lines (with ANSI colours). */ export function renderFrame(state: AppStateRest, height: number, width: number): string[] { const lines: string[] = []; // Header bar. lines.push(renderHeader(state, width)); // Transcript pane. const transcriptLines = renderTranscript(state, width); lines.push(...transcriptLines); // Padding to fill remaining height. const used = lines.length; for (let i = used; i < height - 3; i++) { lines.push(""); } // Overlay content (if any) replaces the input area. if (state.misc.overlay !== Overlay.None) { lines.push(renderOverlay(state, width)); } else { lines.push(renderInput(state, width)); } // Status/footer bar. lines.push(renderStatus(state, width)); return lines.slice(0, height); } function renderHeader(_state: AppStateRest, width: number): string { const title = " zesdex "; const pad = Math.max(0, width - title.length); return `\x1b[1;44m${title}\x1b[0m${" ".repeat(pad)}`; } function renderTranscript(state: AppStateRest, width: number): string[] { const out: string[] = []; const messages = state.transcriptCache.messages; // Honour scroll offset from the bottom like a typical chat TUI. const start = Math.max(0, messages.length - 30 - state.scroll.offset); for (let i = start; i < messages.length; i++) { const m = messages[i]; if (!m) continue; const label = (roleLabel[m.role] ?? m.role).padEnd(8); const color = m.role === Roles.User ? "\x1b[36m" : m.role === Roles.System ? "\x1b[33m" : "\x1b[32m"; out.push(`${color}${label}\x1b[0m ${m.content}`); if (m.reasoning) { out.push(` \x1b[2m(reasoning) ${m.reasoning}\x1b[0m`); } } void width; return out; } function renderInput(state: AppStateRest, width: number): string { const prompt = "> "; const shown = state.input.buffer.length > width - prompt.length ? state.input.buffer.slice(state.input.buffer.length - (width - prompt.length)) : state.input.buffer; let line = `\x1b[1m${prompt}\x1b[0m${shown}`; // Append autocomplete hint if visible. if (state.input.autocompleteVisible && state.input.autocompleteCandidates[state.input.autocompleteIdx]) { const cand = state.input.autocompleteCandidates[state.input.autocompleteIdx]; line += ` \x1b[2m[tab: ${cand}]\x1b[0m`; } // Toasts. for (const t of state.misc.toasts.slice(-2)) { line += ` \x1b[3m(${t.message})\x1b[0m`; } return line.length > width ? line.slice(0, width) : line; } function renderOverlay(state: AppStateRest, width: number): string { const name = state.misc.overlay; if (name === "diff") { const diffLines = state.misc.diffContent.split("\n"); const from = state.misc.diffScroll; return diffLines.slice(from, from + 10).join("\n "); } if (name === "quit_confirm") { return " \x1b[1;31mQuit?\x1b[0m (y=yes) \x1b[2mCtrl+C again / /quit to confirm\x1b[0m"; } if (name === "help") { return state.helpText; } void width; return ` \x1b[1mOverlay: ${name}\x1b[0m \x1b[2m(Esc to close)\x1b[0m`; } function renderStatus(state: AppStateRest, width: number): string { const left = ` model=${(state.settings as { provider?: string }).provider ?? "?"}`; const right = state.turnInFlightFlag.value ? " \x1b[5m● running\x1b[0m" : " \x1b[2m● idle\x1b[0m"; const combined = `${left}${right}`; return combined.length > width ? combined.slice(0, width) : combined; }