/** * OpenTUI React bindings for the Zesdex TUI. * * Renders `AppStateRest` as a polished OpenTUI layout and routes keyboard * input through the existing `handleKey` → `applyAction` pipeline. * * Layout (column flex over the full terminal): * 1. Header bar — logo · model · session · live turn spinner * 2. Transcript — role-coloured chat scrollbox (markdown-lite, flex-grow) * 3. Activity bar — collapsed breadcrumb of the running turn's tools/agents * 4. Input row — `❯` prompt + buffer + cursor * 5. Status bar — turn state · token usage · uptime · toast · hints * * The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`) * stays logic-pure and unit-tested; this file only reads it for display. */ import { useEffect, useState } from "react"; import { createCliRenderer } from "@opentui/core"; import { createRoot, useKeyboard, useTerminalDimensions as useTerminalDimensionsHook, } from "@opentui/react"; import type { KeyEvent as OpenTuiKey, CliRenderer } from "@opentui/core"; import type { ReactNode } from "react"; import type { AppStateRest, ChatMessageDisplay } from "./state.ts"; import { pushTranscript, makeChatMessage, appendToLastTranscript } from "./state.ts"; import { Roles } from "@zesdex/domain"; import { handleKey, KeyCode } from "./controller.ts"; import type { KeyEvent as ZelKeyEvent } from "./controller.ts"; import { applyAction } from "./action.ts"; import type { Action } from "./action.ts"; /** Minimal key-shape accepted by toControllerKey (avoids depending on OpenTUI internals). */ export interface MinimalKey { name?: string; ctrl?: boolean; meta?: boolean; option?: boolean; shift?: boolean; eventType?: string; } /** Map an OpenTUI KeyEvent to the controller's KeyEvent shape. */ export function toControllerKey(key: MinimalKey): ZelKeyEvent { const name = (key.name ?? "").toLowerCase(); const ctrl = key.ctrl ?? false; const alt = key.meta ?? key.option ?? false; const shift = key.shift ?? false; switch (name) { case "return": case "enter": return { code: KeyCode.Enter, ctrl, alt }; case "backspace": return { code: KeyCode.Backspace, ctrl, alt }; case "delete": case "del": return { code: KeyCode.Delete, ctrl, alt }; case "up": return { code: KeyCode.Up, ctrl, alt }; case "down": return { code: KeyCode.Down, ctrl, alt }; case "left": return { code: KeyCode.Left, ctrl, alt }; case "right": return { code: KeyCode.Right, ctrl, alt }; case "pageup": case "pgup": return { code: KeyCode.PageUp, ctrl, alt }; case "pagedown": case "pgdn": return { code: KeyCode.PageDown, ctrl, alt }; case "home": return { code: KeyCode.Home, ctrl, alt }; case "end": return { code: KeyCode.End, ctrl, alt }; case "escape": case "esc": return { code: KeyCode.Esc, ctrl, alt }; case "tab": return { code: KeyCode.Tab, ctrl, alt }; case "space": return { code: KeyCode.Char, value: " ", ctrl, alt, shift }; default: // Printable character (name is the char itself). return { code: KeyCode.Char, value: name, ctrl, alt, shift }; } } /* ── Palette ──────────────────────────────────────────────────────── */ /** Zesdex brand colours — a calm, readable "deep ocean" scheme. */ const C = { bg: "#0d1117", // deep off-navy transcript ground surface: "#161b22", // panel / secondary background border: "#2a3340", // dim hairline separators fg: "#c9d1d9", // primary foreground dim: "#6e7681", // muted text cyan: "#58a6ff", // user / accents green: "#3fb950", // assistant yellow: "#d29922", // system magenta: "#bc8cff", // agent / tools red: "#f85149", // errors headerA: "#0d47a1", // header gradient start (deep blue) headerB: "#1f3a5f", // header gradient end accent: "#58a6ff", // prompt / highlight } as const; /** Short uppercase role tag shown before each message line. */ const roleTag: Record = { [Roles.User]: "YOU", [Roles.Assistant]: "AI", [Roles.System]: "SYS", [Roles.Tool]: "TOOL", }; /** Distinct colour per role — used for badges + body tint. */ const roleColor: Record = { [Roles.User]: C.cyan, [Roles.Assistant]: C.green, [Roles.System]: C.yellow, [Roles.Tool]: C.magenta, }; /** Column-align a pair of header cells across the full width. */ function spreadCenter(left: string, right: string, width: number): string { const gap = Math.max(1, width - left.length - right.length); return left + " ".repeat(gap) + right; } /** Pad a string to at least `n` chars, clipping if longer. */ function pad(s: string, n: number): string { return s.length >= n ? s : s + " ".repeat(n - s.length); } /* ── Spinner ──────────────────────────────────────────────────────── */ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /** Pick a spinner glyph that animates with the tick counter. */ function spinner(state: AppStateRest): string { const frame = Math.floor(state.misc.tickCount / 2) % SPINNER_FRAMES.length; return SPINNER_FRAMES[frame] ?? "⠋"; } /* ── Markdown-lite rendering ──────────────────────────────────────── */ /** One styled run of text within a wrapped line. */ interface Run { text: string; fg?: string; bold?: boolean; } /** * Parse a modest Markdown subset into styled runs, then word-wrap them to a * fixed width with a left indent. Keeps layout fully under our control (vs the * self-drawing `` node) so streaming + indent stay stable. * * Supported: **bold**, `inline code`, #headers, - bullets, > quotes, tables * are untouched (rare in agent output). Code fences are rendered as * single-colour blocks. */ function renderMarkdown(text: string, indent: number, width: number): Run[][] { const rows: Run[][] = []; const bodyWidth = Math.max(10, width - indent); const wrap = new Wrap(bodyWidth); for (const raw of stripFences(text).split("\n")) { const line = raw.replace(/\s+$/, ""); if (line.trim().length === 0) { wrap.break(); continue; } if (/^#{1,6}\s+/.test(line)) { const seg: Run = { text: line.replace(/^#{1,6}\s+/, "").trim(), bold: true, fg: C.cyan }; wrap.add([seg]); continue; } if (/^[-*]\s+/.test(line)) { // Bullet: prefix glyph, then inline-parse the body so **bold**/`code` // inside list items render too. wrap.add([{ text: "• ", fg: C.fg }, ...inlineRuns(line.replace(/^[-*]\s+/, ""))]); continue; } if (/^>\s?/.test(line)) { wrap.add([{ text: "▍ ", fg: C.dim }, ...inlineRuns(line.replace(/^>\s?/, ""))]); continue; } const runs = inlineRuns(line); wrap.add(runs); } // Flatten wrapped rows into the output. for (const row of wrap.rows()) { rows.push(row); } return rows; } /** Render plain text (user / system / tool bodies) with wrapping + indent. */ function renderPlain(text: string, indent: number, width: number): Run[][] { const bodyWidth = Math.max(10, width - indent); const wrap = new Wrap(bodyWidth); for (const raw of text.split("\n")) { wrap.add([{ text: raw.replace(/\s+$/, "") }]); } return wrap.rows(); } /** A line-oriented wrapper that accumulates runs and breaks on width. */ class Wrap { private out: Run[][] = []; private cur: Run[] = []; private curLen = 0; constructor(private width: number) {} add(runs: Run[]): void { for (const r of runs) this.pushRun(r); this.break(); } private pushRun(r: Run): void { if (this.curLen + r.text.length > this.width && this.cur.length > 0) { this.break(); } if (r.text.length === 0) return; this.cur.push(r); this.curLen += r.text.length; } break(): void { if (this.cur.length > 0) { this.out.push(this.cur); this.cur = []; this.curLen = 0; } } rows(): Run[][] { this.break(); return this.out; } } /** Tokenise a line into bold / inline-code / plain runs. */ function inlineRuns(line: string): Run[] { const runs: Run[] = []; const re = /(\*\*[^*]+\*\*|`[^`]+`)/g; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(line)) !== null) { if (m.index > last) runs.push({ text: line.slice(last, m.index) }); const tok = m[0]; if (tok.startsWith("**")) { runs.push({ text: tok.slice(2, -2), bold: true, fg: C.fg }); } else { runs.push({ text: tok.slice(1, -1), fg: C.magenta }); } last = m.index + tok.length; } if (last < line.length) runs.push({ text: line.slice(last) }); return runs.length ? runs : [{ text: line }]; } /** Neutralise fenced code blocks so inline parsing stays clean (kept as plain). */ function stripFences(text: string): string { return text.replace(/```[a-zA-Z0-9]*\n?/g, ""); } /* ── Transcript rendering ─────────────────────────────────────────── */ /** One chat message rendered in the transcript. */ function TranscriptMessage(props: { msg: ChatMessageDisplay; width: number; }): ReactNode { const { msg, width } = props; const color = roleColor[msg.role] ?? C.fg; const tag = roleTag[msg.role] ?? msg.role?.toUpperCase() ?? "?"; const isAssistant = msg.role === Roles.Assistant; // Assistant messages legitimately have no body while a tool call is being // made (or mid-stream) — don't fill that with a misleading placeholder. const content = isAssistant && !msg.content.trim() ? "" : msg.content || "(empty)"; const rows: Run[][] = msg.role === Roles.Assistant && content.length > 0 ? renderMarkdown(content, 5, width) : renderPlain(content, 5, width); return ( {/* Role badge header line */} {` ${pad(`[${tag}]`, 6)}`} {/* Body — each wrapped line is a whose styled runs are / */} {rows.map((row, i) => ( {row.map((r, j) => { const text = r.text; if (r.bold) { return ( {text} ); } return ( {text} ); })} ))} {/* Live reasoning (dimmed, kept to one line) */} {msg.reasoning.length > 0 && ( {` ⋯ ${oneLine(msg.reasoning)}`} )} {/* Thin separator between messages */} {` ${"─".repeat(Math.max(0, Math.min(60, width - 4)))}`} ); } const oneLine = (s: string): string => s.replace(/\s+$/g, "").replace(/\n+/g, "\n").trim(); /** * Root OpenTUI component for the Zesdex TUI. * @param state The mutable AppStateRest (mutated by applyAction). * @param onQuit Called when the app should exit (Ctrl+C confirmed). */ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): ReactNode { const { state, onQuit } = props; const [, setTick] = useState(0); useKeyboard((key: OpenTuiKey) => { if (key.eventType === "release") return; const zk = toControllerKey(key); let consumedByModal = false; try { const actions: Action[] = handleKey(zk, state); for (const act of actions) { if (act.tag === "QuitConfirm") { state.quit = true; onQuit(); consumedByModal = true; continue; } // A slash-command that opens a modal (overlay / menu) "consumes" the // input line — clear the buffer so `/model` doesn't linger. Autocomplete // selection and normal submit must NOT be cleared (they own the buffer). const opensModal = act.tag === "OpenOverlay" || act.tag === "OpenMenuById" || act.tag === "OpenEditor" || act.tag === "ShowDiff" || act.tag === "ModelList"; if (opensModal && !state.input.autocompleteVisible) consumedByModal = true; applyAction(state, act); } } finally { // Clear the input line only when a modal command consumed it this press. if (consumedByModal) { state.input.clear(); } // Force React to re-read the mutated `state` on the next frame. setTick((t: number) => t + 1); } }); const dimensions = useTerminalDimensionsHook(); const width = dimensions.width ?? 80; const height = dimensions.height ?? 24; const running = state.turnInFlightFlag.value; // Turn driver: when a turn is submitted, run it against the real agent and // stream every event into the transcript as it happens. useEffect(() => { let turnRunning = false; const poll = async () => { if (turnRunning) return; const text = state.pendingSubmit; if (text == null) return; if (state.turnRunner == null) { appendToLastTranscript( state, "\n(no agent runtime — run via `zesdex` on the CLI / ensure settings)", false, ); state.pendingSubmit = null; return; } turnRunning = true; const captured = state.pendingSubmit; state.pendingSubmit = null; state.turnInFlightFlag.value = true; const abort = new AbortController(); state.turnAbort = abort; // Open an assistant message we stream into. pushTranscript(state, makeChatMessage(Roles.Assistant, "")); try { await state.turnRunner(captured ?? text, () => { // The turn runner already streams into state via handleEvent; // re-render as new tokens arrive. setTick((t: number) => t + 1); }); } finally { state.turnInFlightFlag.value = false; state.turnAbort = null; state.abortFlag.value = false; // Keep the last activity breadcrumb visible briefly, then clear it on // the next turn start (see the start of poll()). setTimeout(() => { state.misc.activity = []; turnRunning = false; setTick((x: number) => x + 1); }, 0); } }; const timer = setInterval(() => poll(), 100); return () => clearInterval(timer); }, [state, setTick]); // Tick the spinner + uptime while idle too (so ● running animates and the // uptime clock in the status bar advances even between key presses). useEffect(() => { if (!running) return; const id = setInterval(() => { state.misc.tickCount += 1; state.dirty = true; setTick((t: number) => t + 1); }, 120); return () => clearInterval(id); }, [running, state, setTick]); const overlayActive = state.misc.overlay !== "none"; const menuActive = state.misc.menuStack.length > 0; // Modal overlays and interactive menus take the full body (below the header, // above the status bar), replacing the transcript + input. const body = menuActive ? ( ) : overlayActive ? ( ) : ( {/* Dropdown (compact, sized to content) + activity + input, in order. The input row is flexShrink:0 so the transcript scrollbox absorbs overflow and the text area is never hidden. */} {state.input.autocompleteVisible && } ); return ( {/* Header */} {body} {/* Status bar */} ); } /* ── Header ───────────────────────────────────────────────────────── */ function HeaderView(props: { state: AppStateRest; running: boolean; width: number; }): ReactNode { const { state, running, width } = props; const model = String(state.settings.model ?? state.settings.provider ?? "?"); const sessionId = String(state.settings.sessionId ?? state.sessionId ?? ""); const shortId = sessionId.length > 12 ? sessionId.slice(0, 11) : sessionId; const glyph = running ? spinner(state) : "◆"; const left = ` ${glyph} zesdex · ${model} `; const right = sessionId ? ` session:${shortId} ` : " "; const header = spreadCenter(left, right, width); const bg = running ? C.headerA : C.headerB; return ( {header} ); } /* ── Transcript ───────────────────────────────────────────────────── */ function TranscriptView(props: { state: AppStateRest; width: number }): ReactNode { const { state, width } = props; const messages = state.transcriptCache.messages; const scrollFromEnd = messages.length - state.scroll.offset; const visible = messages.slice( Math.max(0, scrollFromEnd - 40), scrollFromEnd, ); if (messages.length === 0) { return ( {`\n ─ no messages yet. Type a message below (or /help) ─`} ); } return ( {visible.map((m, i) => ( ))} ); } /* ── Activity bar (running turn) ──────────────────────────────────── */ function ActivityBar(props: { state: AppStateRest; running: boolean }): ReactNode { const { state, running } = props; const activity = state.misc.activity; if (!running || activity.length === 0) return null; const line = ` ${spinner(state)} ${activity.map((a) => label(a)).join(" · ")} `; return ( {line} ); function label(a: { label: string; kind: string; detail?: string; count?: number }): string { const sym = a.kind === "error" ? "✕" : a.kind === "agent" ? "⧉" : "⚙"; const n = a.count && a.count > 1 ? `×${a.count}` : ""; return `${sym} ${a.label}${n}`; } } /* ── Input row ────────────────────────────────────────────────────── */ function InputView(props: { state: AppStateRest; width: number }): ReactNode { const { state, width } = props; const buffer = state.input.buffer; const cursor = state.input.cursor; const shown = buffer.slice( Math.max(0, cursor - Math.max(10, width - 14)), cursor + 40, ); const hasContent = buffer.length > 0; return ( {`─`.repeat(Math.max(0, width))} {` ❯ `} {hasContent ? ( {shown} ) : ( Ask Zesdex to help… )} {` ▏`} ); } /* ── Autocomplete ─────────────────────────────────────────────────── */ function AutocompleteView(props: { state: AppStateRest }): ReactNode { const { state } = props; const candidates = state.input.autocompleteCandidates; const kind = state.input.autocompleteKind; const title = kind === "command" ? "commands" : "files"; const selected = Math.min(state.input.autocompleteIdx, candidates.length - 1); const visible = candidates.slice(0, 10); // Render the whole panel as ONE multiline — this fixes the OpenTUI // quirk where sibling children overlap. The box is sized to its // content (flexShrink 0, explicit height) so it never squashes the input row. const lines = [ ` ${title}`, ...visible.map((c, i) => (i === selected ? `▸ ${c}` : ` ${c}`)), ` ↑/↓ navigate · Tab accept · Esc close`, ]; const body = lines.join("\n"); return ( {body} ); } /* ── Overlays ─────────────────────────────────────────────────────── */ function OverlayView(props: { state: AppStateRest; width: number; height: number; }): ReactNode { const { state } = props; const name = state.misc.overlay; switch (name) { case "quit_confirm": return ( ); case "clear_confirm": return ( ); case "help": return ; case "diff": return ; case "model_selector": return ; case "editor": return ; default: return ; } } function ConfirmOverlay(props: { title: string; body: string; hint: string }): ReactNode { const { title, body, hint } = props; return ( {body} {` ${hint}`} ); } function HelpOverlay(props: { state: AppStateRest }): ReactNode { const { state } = props; return ( {state.helpText} {" Esc or /help to close"} ); } function DiffOverlay(props: { state: AppStateRest }): ReactNode { const { state } = props; const lines = state.misc.diffContent.split("\n"); const start = state.misc.diffScroll; const slice = lines.slice(start, start + 18); return ( {slice.map((l, i) => { const fg = l.startsWith("+") ? C.green : l.startsWith("-") ? C.red : l.startsWith("@@") ? C.cyan : C.dim; return ( {l.length ? l : " "} ); })} {` ↑/↓ / PgUp/PgDn scroll · Esc close — ${Math.min(start + 1, lines.length)}/${lines.length}`} ); } function ModelSelectorOverlay(props: { state: AppStateRest }): ReactNode { const { state } = props; const names = Object.keys(state.appConfig.providers); const list = names.length ? names : ["claude", "zen", "router"]; const idx = Math.min(Math.max(0, state.misc.selectedIndex), list.length - 1); const current = String(state.settings.provider ?? ""); const selected = idx; const body = list .map((p, i) => { const isSel = i === selected; const active = p === current; const suffix = active ? " (active)" : ""; return `${isSel ? "▸ " : " "}${pad(p, 15)}${suffix}`; }) .join("\n"); return ( {body} {" ↑/↓ select · Enter apply · Esc close"} ); } function EditorOverlay(props: { state: AppStateRest }): ReactNode { const { state } = props; const ed = state.misc.editor; if (!ed) return No buffer.; const lines = ed.content.split("\n"); const start = Math.max(0, ed.cursor - 5); const slice = lines.slice(start, start + 12); return ( {slice.map((l, i) => ( {`${String(start + i + 1).padStart(3)} | ${l}`} ))} {" type to edit · Enter newline · Esc save & close"} ); } function PlaceholderOverlay(props: { name: string; state: AppStateRest }): ReactNode { const { name, state } = props; const content = name === "todo" ? state.misc.todoContent : name === "plan" ? state.misc.planContent : ""; const summary = name === "todo" ? "todo list" : name === "plan" ? "plan outline" : name === "usage" ? "usage statistics" : name === "mcp" ? "MCP server management" : name === "settings" ? "settings" : name === "bash" ? "bash jobs" : name === "rewind" ? "rewind history" : name === "learning" ? "learned patterns" : "overlay"; return ( {content ? ( {content} ) : ( {` ${summary}: nothing yet.`} )} {" Esc to close"} ); } function OverlayFrame(props: { title: string; children: ReactNode }): ReactNode { const { title, children } = props; return ( {children} ); } /* ── Interactive menu ─────────────────────────────────────────────── */ /** Modal picker for the menu stack — like Claude Code / opencode selectors. */ function MenuView(props: { state: AppStateRest; height: number }): ReactNode { const { state, height } = props; const stack = state.misc.menuStack; const frame = stack[stack.length - 1]; if (!frame) return null; const filter = (frame.filter ?? "").toLowerCase(); // Rows that match the live filter (empty filter = all rows). const rawMatched = frame.items .map((it, i) => ({ it, i })) .filter((r) => !filter || r.it.toLowerCase().includes(filter)); // Fit the visible rows to the modal height (top rows take priority so the // selected row stays anchored near the top). const maxRows = Math.max(4, height - 8); const visibleStart = Math.max(0, Math.min(rawMatched.length - maxRows, frame.selected)); const matched = rawMatched.slice(visibleStart, visibleStart + maxRows); const detailOf = frame.details; // Breadcrumb: titles from the stack, e.g. "Model › pick a model (zen)". const crumb = stack.map((f) => f.title).join(" › "); return ( {/* Quick filter bar at the top */} {filter ? ` filter: ${frame.filter} ` : " type to filter…"} {matched.map(({ it, i }) => { const isSel = i === frame.selected; const detail = detailOf?.[i] ?? ""; return ( {isSel ? {`${"▸ "}${it}`} : ` ${it}`} {detail && ( {detail} )} ); })} {matched.length === 0 && ( no matches — type less or press Backspace. )} {` ${frame.hint ?? ""}`} {frame.items.length > 10 && ( {` showing ${matched.length}/${frame.items.length} · PgUp/PgDn scroll`} )} ); } /* ── Status bar ───────────────────────────────────────────────────── */ function StatusBar(props: { state: AppStateRest; running: boolean; width: number; }): ReactNode { const { state, running, width } = props; const left = running ? "● running" : "● idle"; const usage = state.lastUsage; const usageStr = usage ? `in:${usage.tokensIn} · out:${usage.tokensOut}` : "in:– · out:–"; // Uptime since boot. const uptime = fmtUptime((Date.now() - state.bootedAt) / 1000); // Autocomplete hint for the active input. const ac = state.input.autocompleteVisible && state.input.autocompleteCandidates[state.input.autocompleteIdx] != null ? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]` : ""; // Most recent live toast (expires via MiscState). const toasts = state.misc.toasts; const toast = toasts.length > 0 ? ` (${toasts[toasts.length - 1]?.message})` : ""; const hints = running ? "Esc abort" : "/help · Ctrl+C quit · ↑ history"; const statusColor = toast ? C.yellow : running ? C.green : C.dim; const activity = running ? ` ${spinner(state)}` : ""; const cell = ` ${left}${activity} ${usageStr} ${uptime}${ac}${toast} ${hints} `; return ( {trimTo(cell, width)} ); } /** Clip a status line to the available width (avoid overflow). */ function trimTo(s: string, width: number): string { if (s.length <= width) return s; return s.slice(0, Math.max(0, width - 1)) + "…"; } /** Format seconds as a compact human uptime (e.g. 1m23s). */ function fmtUptime(sec: number): string { const s = Math.floor(sec); const m = Math.floor(s / 60); const h = Math.floor(m / 60); if (h > 0) return `${h}h${String(m % 60).padStart(2, "0")}m`; if (m > 0) return `${m}m${String(s % 60).padStart(2, "0")}s`; return `${s}s`; } /** Boot the OpenTUI renderer and mount the Zesdex app. */ export async function bootTui(state: AppStateRest): Promise { pushTranscript(state, makeChatMessage(Roles.System, "Zesdex ready. Type a message or /help.")); const renderer: CliRenderer = await createCliRenderer(); const root = createRoot(renderer); root.render( renderer.destroy()} />); return new Promise((resolve) => { const check = setInterval(() => { if (state.quit) { clearInterval(check); try { root.unmount(); } catch { /* already unmounted */ } renderer.destroy(); resolve(); } }, 60); }); }