Files
zesdex/src/interfaces/tui/ui.tsx
T

950 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string> = {
[Roles.User]: "YOU",
[Roles.Assistant]: "AI",
[Roles.System]: "SYS",
[Roles.Tool]: "TOOL",
};
/** Distinct colour per role — used for badges + body tint. */
const roleColor: Record<string, string> = {
[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 `<markdown>` 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 (
<box flexDirection="column" width="100%">
{/* Role badge header line */}
<text fg={color}>
<b>{` ${pad(`[${tag}]`, 6)}`}</b>
</text>
{/* Body — each wrapped line is a <text> whose styled runs are <span>/<b> */}
{rows.map((row, i) => (
<text key={i} fg={C.fg}>
{row.map((r, j) => {
const text = r.text;
if (r.bold) {
return (
<b key={j} fg={r.fg ?? color}>
{text}
</b>
);
}
return (
<span key={j} fg={r.fg ?? color}>
{text}
</span>
);
})}
</text>
))}
{/* Live reasoning (dimmed, kept to one line) */}
{msg.reasoning.length > 0 && (
<text fg={C.dim}>{` ⋯ ${oneLine(msg.reasoning)}`}</text>
)}
{/* Thin separator between messages */}
<text fg={C.border}>{` ${"─".repeat(Math.max(0, Math.min(60, width - 4)))}`}</text>
</box>
);
}
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 ? (
<MenuView state={state} height={height} />
) : overlayActive ? (
<OverlayView state={state} width={width} height={height} />
) : (
<box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
<scrollbox
flexGrow={1}
flexShrink={1}
flexBasis={0}
minHeight={0}
style={{ backgroundColor: C.bg }}
stickyScroll
stickyStart="bottom"
>
<TranscriptView state={state} width={width} />
</scrollbox>
{/* 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 && <AutocompleteView state={state} />}
<ActivityBar state={state} running={running} />
<InputView state={state} width={width} />
</box>
);
return (
<box flexDirection="column" width={width} height={height}>
{/* Header */}
<HeaderView state={state} running={running} width={width} />
{body}
{/* Status bar */}
<StatusBar state={state} running={running} width={width} />
</box>
);
}
/* ── 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 (
<box backgroundColor={bg}>
<text fg={running ? C.green : "white"}>
<b>{header}</b>
</text>
</box>
);
}
/* ── 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 (
<text fg={C.dim}>
{`\n ─ no messages yet. Type a message below (or /help) ─`}
</text>
);
}
return (
<box flexDirection="column">
{visible.map((m, i) => (
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
))}
</box>
);
}
/* ── 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 (
<box flexDirection="column" width="100%" style={{ backgroundColor: C.surface }}>
<text fg={C.magenta}>
{line}
</text>
</box>
);
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 (
<box flexDirection="column" width="100%" flexShrink={0}>
<text fg={C.border}>{`─`.repeat(Math.max(0, width))}</text>
<box flexDirection="row" width="100%">
<text fg={C.accent}>
<b>{` ❯ `}</b>
</text>
{hasContent ? (
<text fg={C.fg}>{shown}</text>
) : (
<text fg={C.dim}>Ask Zesdex to help…</text>
)}
<text fg={C.accent}>{` ▏`}</text>
</box>
</box>
);
}
/* ── 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 <text> — this fixes the OpenTUI
// quirk where sibling <text> 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 (
<box
width="100%"
flexDirection="column"
flexGrow={0}
flexShrink={0}
style={{ border: true, borderColor: C.border, backgroundColor: C.surface, padding: 1 }}
>
<text fg={kind === "command" ? C.accent : C.magenta} height={lines.length}>
{body}
</text>
</box>
);
}
/* ── 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 (
<ConfirmOverlay
title="Quit Zesdex?"
body=" Are you sure you want to quit?\n\n Any in-flight turn will be aborted."
hint="y=yes n=no Esc=cancel"
/>
);
case "clear_confirm":
return (
<ConfirmOverlay
title="Clear transcript?"
body=" This clears the visible conversation from the transcript view."
hint="y=yes n=no Esc=cancel"
/>
);
case "help":
return <HelpOverlay state={state} />;
case "diff":
return <DiffOverlay state={state} />;
case "model_selector":
return <ModelSelectorOverlay state={state} />;
case "editor":
return <EditorOverlay state={state} />;
default:
return <PlaceholderOverlay name={name} state={state} />;
}
}
function ConfirmOverlay(props: { title: string; body: string; hint: string }): ReactNode {
const { title, body, hint } = props;
return (
<OverlayFrame title={title}>
<text fg={C.fg}>{body}</text>
<text fg={C.dim}>{` ${hint}`}</text>
</OverlayFrame>
);
}
function HelpOverlay(props: { state: AppStateRest }): ReactNode {
const { state } = props;
return (
<OverlayFrame title="Keyboard Shortcuts">
<text fg={C.fg}>{state.helpText}</text>
<text fg={C.dim}>{" Esc or /help to close"}</text>
</OverlayFrame>
);
}
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 (
<OverlayFrame title="Git Diff">
<scrollbox flexGrow={1} stickyScroll stickyStart="bottom">
{slice.map((l, i) => {
const fg = l.startsWith("+")
? C.green
: l.startsWith("-")
? C.red
: l.startsWith("@@")
? C.cyan
: C.dim;
return (
<text key={i} fg={fg}>
{l.length ? l : " "}
</text>
);
})}
</scrollbox>
<text fg={C.dim}>
{` ↑/↓ / PgUp/PgDn scroll · Esc close — ${Math.min(start + 1, lines.length)}/${lines.length}`}
</text>
</OverlayFrame>
);
}
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 (
<OverlayFrame title="Select Model / Provider">
<text fg={C.fg}>
{body}
</text>
<text fg={C.dim}>{" ↑/↓ select · Enter apply · Esc close"}</text>
</OverlayFrame>
);
}
function EditorOverlay(props: { state: AppStateRest }): ReactNode {
const { state } = props;
const ed = state.misc.editor;
if (!ed) return <OverlayFrame title="Editor"><text fg={C.fg}>No buffer.</text></OverlayFrame>;
const lines = ed.content.split("\n");
const start = Math.max(0, ed.cursor - 5);
const slice = lines.slice(start, start + 12);
return (
<OverlayFrame title={`Edit ${ed.path}`}>
{slice.map((l, i) => (
<text key={i} fg={C.fg}>
{`${String(start + i + 1).padStart(3)} | ${l}`}
</text>
))}
<text fg={C.dim}>
{" type to edit · Enter newline · Esc save & close"}
</text>
</OverlayFrame>
);
}
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 (
<OverlayFrame title={`${name} — ${summary}`}>
{content ? (
<text fg={C.fg}>{content}</text>
) : (
<text fg={C.dim}>{` ${summary}: nothing yet.`}</text>
)}
<text fg={C.dim}>{" Esc to close"}</text>
</OverlayFrame>
);
}
function OverlayFrame(props: { title: string; children: ReactNode }): ReactNode {
const { title, children } = props;
return (
<box
width="100%"
flexDirection="column"
flexGrow={1}
flexShrink={0}
style={{
border: true,
borderColor: C.accent,
padding: 1,
backgroundColor: C.surface,
}}
title={title}
titleAlignment="center"
>
{children}
</box>
);
}
/* ── 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 (
<OverlayFrame title={crumb}>
{/* Quick filter bar at the top */}
<text fg={C.dim}>{filter ? ` filter: ${frame.filter} ` : " type to filter…"}</text>
{matched.map(({ it, i }) => {
const isSel = i === frame.selected;
const detail = detailOf?.[i] ?? "";
return (
<box key={i} flexDirection="row" width="100%">
<text fg={isSel ? C.accent : C.fg} flexGrow={1}>
{isSel ? <b>{`${"▸ "}${it}`}</b> : ` ${it}`}
</text>
{detail && (
<text fg={C.dim} flexShrink={0}>
{detail}
</text>
)}
</box>
);
})}
{matched.length === 0 && (
<text fg={C.red}> no matches — type less or press Backspace.</text>
)}
<text fg={C.dim}>
{` ${frame.hint ?? ""}`}
</text>
{frame.items.length > 10 && (
<text fg={C.dim}>
{` showing ${matched.length}/${frame.items.length} · PgUp/PgDn scroll`}
</text>
)}
</OverlayFrame>
);
}
/* ── 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 (
<box backgroundColor={C.surface}>
<text fg={statusColor}>{trimTo(cell, width)}</text>
</box>
);
}
/** 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<void> {
pushTranscript(state, makeChatMessage(Roles.System, "Zesdex ready. Type a message or /help."));
const renderer: CliRenderer = await createCliRenderer();
const root = createRoot(renderer);
root.render(<ZesdexApp state={state} onQuit={() => renderer.destroy()} />);
return new Promise<void>((resolve) => {
const check = setInterval(() => {
if (state.quit) {
clearInterval(check);
try {
root.unmount();
} catch {
/* already unmounted */
}
renderer.destroy();
resolve();
}
}, 60);
});
}