Download src/tui/frame.ts from bolikoto/zcode-api: direct link, hf CLI and curl.
- Browser
- Download file 14.2 kB
-
https://huggingface.co/spaces/bolikoto/zcode-api/resolve/main/src/tui/frame.ts
- Command line
-
hf download hf://spaces/bolikoto/zcode-api/src/tui/frame.ts
-
curl -L -o frame.ts https://huggingface.co/spaces/bolikoto/zcode-api/resolve/main/src/tui/frame.ts
14.2 kB
| /** | |
| * 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 }; | |
| } | |