/** * Pure frame renderer for the TUI. Takes a complete view-model and returns * the full ANSI frame plus the clickable hit regions — interactive rows are * rendered as bracketed buttons (`[ zai ]`, `[S] Start`) and every button * registers a row/column span the app hit-tests against mouse clicks (SGR * mouse tracking, opencode-style). No I/O, no clock — fully deterministic so * tests can snapshot both the text and the regions. * * Layout mirrors the Android app: three stacked cards (Settings & Login / * Proxy Server / Logs) plus a toast line and a key-hint footer (the hints * are themselves clickable). The top two cards are fixed-height; the log * card absorbs the remaining terminal rows. */ import { displayWidth, truncateToWidth } from "./width.js"; export type Seg = { t: string; c?: string }; /** What a mouse click on a button should do. `key` reuses the key bindings. */ export type ClickAction = | { kind: "key"; key: string } | { kind: "provider"; value: "zai" | "bigmodel" } | { kind: "plan"; value: "coding-plan" | "start-plan" } | { kind: "follow" }; /** 0-based terminal-cell span carrying an action. */ export interface ClickRegion { row: number; col: number; width: number; action: ClickAction; } export interface Frame { text: string; regions: ClickRegion[]; } /** Hit-test: the action whose button contains (col,row), if any. */ export function findRegion(regions: readonly ClickRegion[], row: number, col: number): ClickAction | null { for (const r of regions) { if (r.row === row && col >= r.col && col < r.col + r.width) return r.action; } return null; } export interface FrameState { version: string; configPath: string; provider: string; plan: string; loggedIn: boolean; apiKeyPreview: string; loginInFlight: boolean; loginHint: string; serverStatus: "stopped" | "starting" | "running" | "error"; serverUrl: string; serverError: string; modelCount: number; responsesEnabled: boolean; claimAuto: boolean; logTotal: number; logView: ReadonlyArray<{ level: string; text: string }>; logFollowing: boolean; logFromBottom: number; toast: { text: string; kind: "ok" | "err" | "info" } | null; width: number; height: number; } const DIM = "90"; const RED = "31"; const GREEN = "32"; const AMBER = "33"; const CYAN = "36"; const BOLD = "1"; const LABEL_W = 12; // fixed label column inside cards // Solid filled buttons — GitHub dark-theme button palette via 24-bit // truecolor SGR (Windows Terminal / mintty / modern Unix terminals render it // natively; a legacy terminal falls back to its defaults with text intact). // green #238636 success red #da3633 danger // blue #1f6feb primary gray #30363d secondary (text #c9d1d9) const BTN_GREEN = "38;2;255;255;255;48;2;35;134;54"; const BTN_RED = "38;2;255;255;255;48;2;218;54;51"; const BTN_BLUE = "38;2;255;255;255;48;2;31;111;235"; const BTN_GRAY = "38;2;201;209;217;48;2;48;54;61"; const BTN_SELECTED = BTN_BLUE; /** Black on bright yellow — login-hint attention chip, 16-color safe. */ const HINT_CHIP = "30;103"; function paint(code: string, t: string): string { return `\x1b[${code}m${t}\x1b[0m`; } function segWidth(segs: readonly Seg[]): number { let w = 0; for (const s of segs) w += displayWidth(s.t); return w; } function paintSegs(segs: readonly Seg[]): string { return segs.map((s) => (s.c ? paint(s.c, s.t) : s.t)).join(""); } function segPlain(segs: readonly Seg[]): string { return segs.map((s) => s.t).join(""); } /** `╭─ title ────…──── pill ─╮` */ function renderTopBorder(w: number, left: Seg[], right: Seg[] = []): string { let l = left; let r = right; if (r.length === 0) { // ╭(1) ─(1) sp(1) left sp(1) fill ╮(1) = w if (5 + segWidth(l) > w - 1) l = [{ t: truncateToWidth(segPlain(l), Math.max(0, w - 7)) }]; const fill = Math.max(1, w - 5 - segWidth(l)); return paint(DIM, "╭─ ") + paintSegs(l) + paint(DIM, ` ${"─".repeat(fill)}╮`); } // ╭(1) ─(1) sp(1) left sp(1) fill sp(1) right sp(1) ─(1) ╮(1) = w const lw = segWidth(l); const rw = segWidth(r); let fill = w - 8 - lw - rw; if (fill < 1) { // Degenerate: shrink the right side, then the left. const budget = Math.max(0, w - 9 - lw); r = [{ t: truncateToWidth(segPlain(r), budget) }]; fill = Math.max(1, w - 8 - lw - segWidth(r)); if (fill < 1) { l = [{ t: truncateToWidth(segPlain(l), Math.max(0, w - 8 - segWidth(r))) }]; fill = Math.max(1, w - 8 - segWidth(l) - segWidth(r)); } } return ( paint(DIM, "╭─ ") + paintSegs(l) + paint(DIM, ` ${"─".repeat(fill)} `) + paintSegs(r) + paint(DIM, " ─╮") ); } function renderBottomBorder(w: number): string { return paint(DIM, `╰${"─".repeat(Math.max(0, w - 2))}╯`); } interface Part { t: string; c?: string; action?: ClickAction; } /** * `│ Label [button] [button] … │` for an interactive row: parts are laid * out left-to-right after the (optional) label column; parts carrying an * action register a click region spanning exactly their cells. Overflow is * dropped on the part that crosses the budget (and its region never added). * With `chrome: false` the card borders/padding are omitted (footer row). */ function composeRow( w: number, row: number, label: string | null, parts: readonly Part[], opts: { chrome?: boolean } = {}, ): { line: string; regions: ClickRegion[] } { const chrome = opts.chrome !== false; const contentW = Math.max(0, w - (chrome ? 4 : 2)); const prefixPlain = label !== null ? " ".repeat(2) + label.padEnd(LABEL_W - 2) : " ".repeat(2); const prefixW = displayWidth(prefixPlain); const prefixPainted = label !== null ? paint(DIM, prefixPlain) : prefixPlain; const regions: ClickRegion[] = []; const painted: string[] = []; let col = prefixW; for (const p of parts) { const pw = displayWidth(p.t); if (col + pw > contentW) break; // With chrome the content starts after `│ ` (2 cells); without it the // prefix itself starts at column 0. if (p.action) regions.push({ row, col: (chrome ? 2 : 0) + col, width: pw, action: p.action }); painted.push(p.c ? paint(p.c, p.t) : p.t); col += pw; } if (!chrome) { return { line: prefixPainted + painted.join(""), regions }; } const pad = " ".repeat(Math.max(0, contentW - col)); return { line: `${paint(DIM, "│")} ${prefixPainted}${painted.join("")}${pad} ${paint(DIM, "│")}`, regions, }; } /** Radio-style option buttons rendered in FIXED slot order — selection only * changes colors, never positions, so clicking the same spot twice always * hits the same button. */ function optionParts( slots: readonly [string, string], selected: string, makeAction: (v: string) => ClickAction, disabled: boolean, ): Part[] { const parts: Part[] = []; slots.forEach((v, i) => { if (i > 0) parts.push({ t: " " }); parts.push({ t: ` ${v} `, c: disabled ? DIM : v === selected ? BTN_SELECTED : BTN_GRAY, action: disabled ? undefined : makeAction(v), }); }); if (disabled) parts.push({ t: " " }, { t: "(stop to switch)", c: AMBER }); return parts; } function logLineCode(level: string): string | undefined { if (level === "error") return RED; if (level === "warn") return AMBER; return undefined; } export function buildFrame(s: FrameState): Frame { const w = s.width; const h = s.height; const regions: ClickRegion[] = []; const lines: string[] = []; const emit = (line: string): number => { lines.push(line + "\x1b[0m\x1b[K"); return lines.length - 1; }; if (w < 40 || h < 14) { // Untruncated on purpose: shrinking the diagnostic would defeat it. emit("zcode-proxy: terminal too small for the TUI."); emit(` need >= 40x14, got ${w}x${h}`); return { text: lines.join("\n") + "\x1b[0m\x1b[J", regions }; } const pill: Seg[] = s.loginInFlight ? [{ t: "● logging in…", c: AMBER }] : s.loggedIn ? [{ t: "● ready", c: GREEN }] : [{ t: "● logged out", c: AMBER }]; const busy = s.serverStatus === "running" || s.serverStatus === "starting"; // --- Settings & Login card --------------------------------------------- emit(renderTopBorder(w, [{ t: "Settings & Login", c: BOLD }], pill)); const providerRow = composeRow( w, lines.length, "Provider", optionParts( ["zai", "bigmodel"], s.provider, (v) => ({ kind: "provider", value: v as "zai" | "bigmodel" }), busy, ), ); emit(providerRow.line); regions.push(...providerRow.regions); const planRow = composeRow( w, lines.length, "Plan", optionParts( ["coding-plan", "start-plan"], s.plan, (v) => ({ kind: "plan", value: v as "coding-plan" | "start-plan" }), busy, ), ); emit(planRow.line); regions.push(...planRow.regions); const authSegs: Seg[] = s.loggedIn ? [{ t: "● ", c: GREEN }, { t: `logged in · ${s.apiKeyPreview}` }] : [{ t: "○ not logged in", c: AMBER }]; emit((composeRow(w, lines.length, "Auth", authSegs.map((g) => ({ t: g.t, c: g.c })))).line); const loginRow = composeRow(w, lines.length, "", s.loginInFlight ? [{ t: " Logging in… ", c: DIM }] : s.loggedIn ? [ { t: " Logged In ", c: DIM }, { t: " " }, // Logout is stop-first while the proxy is running — the server keeps // serving with its in-memory credential until restarted. busy ? { t: " Logout ", c: DIM } : { t: " Logout ", c: BTN_GRAY, action: { kind: "key", key: "o" } }, ] : [ { t: " OAuth Login ", c: BTN_GREEN, action: { kind: "key", key: "l" } }, { t: " " }, { t: " Logout ", c: DIM }, ]); emit(loginRow.line); regions.push(...loginRow.regions); if (s.loginInFlight && s.loginHint) { // High-contrast attention chip (black on bright yellow): the headless // paste fallback hint must not be missed — plain amber text reads as // ordinary on many terminal palettes. emit((composeRow(w, lines.length, "Login", [{ t: ` ▸ ${s.loginHint} `, c: HINT_CHIP }])).line); } emit(renderBottomBorder(w)); emit(""); // --- Proxy Server card -------------------------------------------------- emit(renderTopBorder(w, [{ t: "Proxy Server", c: BOLD }])); const statusSegs: Seg[] = s.serverStatus === "running" ? [{ t: "● running", c: GREEN }, { t: " " + s.serverUrl, c: CYAN }] : s.serverStatus === "starting" ? [{ t: "● starting…", c: AMBER }] : s.serverStatus === "error" ? [{ t: "✗ failed", c: RED }] : [{ t: "○ stopped", c: DIM }]; emit((composeRow(w, lines.length, "Status", statusSegs.map((g) => ({ t: g.t, c: g.c })))).line); const configText = [ `${s.provider} · ${s.plan} · ${s.modelCount} models`, s.responsesEnabled ? "/v1/responses" : "", s.claimAuto ? "claim:auto" : "", `v${s.version}`, ].filter(Boolean).join(" · "); emit((composeRow(w, lines.length, "Config", [{ t: configText, c: DIM }])).line); const startEnabled = s.serverStatus === "stopped" || s.serverStatus === "error"; const stopEnabled = s.serverStatus === "running"; const serverButtonsRow = composeRow(w, lines.length, "", [ { t: " Start ", c: startEnabled ? BTN_GREEN : DIM, action: startEnabled ? { kind: "key", key: "s" } : undefined }, { t: " " }, { t: " Stop ", c: stopEnabled ? BTN_RED : DIM, action: stopEnabled ? { kind: "key", key: "s" } : undefined }, ]); emit(serverButtonsRow.line); regions.push(...serverButtonsRow.regions); if (s.serverStatus === "error" && s.serverError) { emit((composeRow(w, lines.length, "Error", [{ t: s.serverError, c: RED }])).line); } emit(renderBottomBorder(w)); emit(""); // --- Logs card (absorbs remaining height) ------------------------------- const logRight: Seg[] = s.logFollowing ? [{ t: "following", c: GREEN }] : [{ t: `▼ ${s.logFromBottom} more · g follow`, c: AMBER }]; const fixed = lines.length + 2 /* log card borders */ + 1 /* footer */ + (s.toast ? 1 : 0); const logRows = Math.max(1, h - fixed); const logBorderRow = emit(renderTopBorder(w, [{ t: "Logs", c: BOLD }, { t: ` (${s.logTotal})`, c: DIM }], logRight)); if (!s.logFollowing) { // Clicking the log title bar jumps back to the tail, like the g key. regions.push({ row: logBorderRow, col: 0, width: w, action: { kind: "follow" } }); } if (s.logView.length === 0) { emit((composeRow(w, lines.length, null, [{ t: "(no logs yet — start the server and send requests)", c: DIM }])).line); } else { for (const line of s.logView.slice(-logRows)) { emit((composeRow(w, lines.length, null, [{ t: truncateToWidth(line.text, w - 6), c: logLineCode(line.level) }])).line); } } emit(renderBottomBorder(w)); // --- Toast + footer ------------------------------------------------------ if (s.toast) { const color = s.toast.kind === "ok" ? GREEN : s.toast.kind === "err" ? RED : CYAN; emit(" " + paint(color, truncateToWidth(`▸ ${s.toast.text}`, w - 2))); } const footerRow = lines.length; const footerParts: Part[] = []; // Priority order — the footer composes greedily and drops its tail on // narrow terminals, so the primary actions must come first. const footerItems: Array<[string, string]> = [ ["s", "start/stop"], ["l", "login"], ["o", "logout"], ["g", "follow"], ["q", "quit"], ["p", "provider"], ["t", "plan"], ["c", "clear"], ]; footerParts.push({ t: "↑↓ scroll", c: DIM }); for (const [key, label] of footerItems) { footerParts.push({ t: " · ", c: DIM }); footerParts.push({ t: `[${key}] ${label}`, c: DIM, action: { kind: "key", key } }); } // The footer lives outside the card borders — no chrome, plain indent. const footer = composeRow(w, footerRow, null, footerParts, { chrome: false }); emit(footer.line); regions.push(...footer.regions); return { text: lines.join("\n") + "\x1b[0m\x1b[J", regions }; }