950 lines
32 KiB
TypeScript
950 lines
32 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
}
|
||
|