2026-09-03 00:10:01 +07:00
|
|
|
/**
|
|
|
|
|
* OpenTUI React bindings for the Zesdex TUI.
|
|
|
|
|
*
|
2026-09-03 10:59:42 +07:00
|
|
|
* 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 — app · model · session (blue bar)
|
|
|
|
|
* 2. Transcript — role-coloured chat scrollbox (flex-grow)
|
|
|
|
|
* 3. Input row — `>` prompt + buffer (same line)
|
|
|
|
|
* 4. Status bar — turn state · token usage · hints
|
2026-09-03 00:10:01 +07:00
|
|
|
*
|
|
|
|
|
* The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`)
|
2026-09-03 10:59:42 +07:00
|
|
|
* stays logic-pure and unit-tested; this file only reads it for display.
|
2026-09-03 00:10:01 +07:00
|
|
|
*/
|
|
|
|
|
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 } 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 "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 };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Role label mapped to a colour for the transcript pane. */
|
|
|
|
|
const roleColor: Record<string, string> = {
|
|
|
|
|
[Roles.User]: "cyan",
|
|
|
|
|
[Roles.Assistant]: "green",
|
|
|
|
|
[Roles.System]: "yellow",
|
|
|
|
|
[Roles.Tool]: "magenta",
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-03 10:59:42 +07:00
|
|
|
/** 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",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const oneLine = (s: string): string =>
|
|
|
|
|
s.replace(/\s+$/g, "").replace(/\n+/g, "\n").trim();
|
|
|
|
|
|
|
|
|
|
/** 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;
|
|
|
|
|
}
|
2026-09-03 00:10:01 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
try {
|
|
|
|
|
const actions: Action[] = handleKey(zk, state);
|
|
|
|
|
for (const act of actions) {
|
|
|
|
|
if (act.tag === "QuitConfirm") {
|
|
|
|
|
state.quit = true;
|
|
|
|
|
onQuit();
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
applyAction(state, act);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
// Force React to re-read the mutated `state` on the next frame.
|
|
|
|
|
setTick((t: number) => t + 1);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const dimensions = useTerminalDimensionsHook();
|
2026-09-03 10:59:42 +07:00
|
|
|
const width = dimensions.width ?? 80;
|
|
|
|
|
const height = dimensions.height ?? 24;
|
2026-09-03 00:10:01 +07:00
|
|
|
|
2026-09-03 01:19:27 +07:00
|
|
|
// Turn driver: when a turn is submitted, run it against the real agent and
|
|
|
|
|
// stream every event into the transcript as it happens.
|
2026-09-03 00:10:01 +07:00
|
|
|
useEffect(() => {
|
2026-09-03 01:19:27 +07:00
|
|
|
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,
|
|
|
|
|
);
|
2026-09-03 00:10:01 +07:00
|
|
|
state.pendingSubmit = null;
|
2026-09-03 01:19:27 +07:00
|
|
|
return;
|
2026-09-03 00:10:01 +07:00
|
|
|
}
|
2026-09-03 01:19:27 +07:00
|
|
|
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 {
|
2026-09-03 10:59:42 +07:00
|
|
|
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);
|
2026-09-03 01:19:27 +07:00
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
state.turnInFlightFlag.value = false;
|
|
|
|
|
state.turnAbort = null;
|
|
|
|
|
state.abortFlag.value = false;
|
|
|
|
|
// Re-prime the next poll so queued turns run.
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
turnRunning = false;
|
|
|
|
|
setTick((x: number) => x + 1);
|
|
|
|
|
}, 0);
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-09-03 10:59:42 +07:00
|
|
|
const timer = setInterval(() => poll(), 100);
|
2026-09-03 00:10:01 +07:00
|
|
|
return () => clearInterval(timer);
|
|
|
|
|
}, [state, setTick]);
|
|
|
|
|
|
2026-09-03 10:59:42 +07:00
|
|
|
const model = String(state.settings.model ?? state.settings.provider ?? "?");
|
|
|
|
|
const sessionId = String(state.settings.sessionId ?? state.sessionId ?? "");
|
|
|
|
|
const headerLeft = ` zesdex · ${model} `;
|
|
|
|
|
const headerRight = sessionId ? ` session:${sessionId} ` : " ";
|
|
|
|
|
const header = spreadCenter(headerLeft, headerRight, width);
|
|
|
|
|
|
2026-09-03 00:10:01 +07:00
|
|
|
const messages = state.transcriptCache.messages;
|
|
|
|
|
const scrollFromEnd = messages.length - state.scroll.offset;
|
|
|
|
|
const visible = messages.slice(Math.max(0, scrollFromEnd - 40), scrollFromEnd);
|
|
|
|
|
|
2026-09-03 10:59:42 +07:00
|
|
|
const running = state.turnInFlightFlag.value;
|
|
|
|
|
|
2026-09-03 00:10:01 +07:00
|
|
|
return (
|
2026-09-03 10:59:42 +07:00
|
|
|
<box flexDirection="column" width={width} height={height}>
|
2026-09-03 00:10:01 +07:00
|
|
|
{/* Header */}
|
2026-09-03 10:59:42 +07:00
|
|
|
<box backgroundColor="blue">
|
2026-09-03 00:10:01 +07:00
|
|
|
<text fg="white">{header}</text>
|
|
|
|
|
</box>
|
|
|
|
|
|
|
|
|
|
{/* Transcript pane */}
|
2026-09-03 10:59:42 +07:00
|
|
|
<scrollbox flexGrow={1} style={{ backgroundColor: "#0b0f14" }}>
|
2026-09-03 00:10:01 +07:00
|
|
|
{visible.map((m, i) => (
|
2026-09-03 10:59:42 +07:00
|
|
|
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
|
2026-09-03 00:10:01 +07:00
|
|
|
))}
|
2026-09-03 10:59:42 +07:00
|
|
|
{messages.length === 0 && (
|
|
|
|
|
<text fg="gray"> — no messages yet. Type a message below or /help. —</text>
|
|
|
|
|
)}
|
2026-09-03 00:10:01 +07:00
|
|
|
</scrollbox>
|
|
|
|
|
|
2026-09-03 10:59:42 +07:00
|
|
|
{/* Autocomplete dropdown (shown above the input row) */}
|
|
|
|
|
{state.input.autocompleteVisible && <AutocompleteView state={state} />}
|
2026-09-03 00:10:01 +07:00
|
|
|
|
2026-09-03 10:59:42 +07:00
|
|
|
{/* Overlay or input row */}
|
|
|
|
|
{state.misc.overlay !== "none" ? (
|
|
|
|
|
<OverlayView state={state} />
|
|
|
|
|
) : (
|
|
|
|
|
<InputView state={state} width={width} />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Status bar */}
|
|
|
|
|
<StatusBar state={state} running={running} />
|
2026-09-03 00:10:01 +07:00
|
|
|
</box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 10:59:42 +07:00
|
|
|
/** Word-wrap a string to a fixed width (used for plain-text messages). */
|
|
|
|
|
function wrapText(text: string, width: number): string {
|
|
|
|
|
const lines: string[] = [];
|
|
|
|
|
for (const raw of text.split("\n")) {
|
|
|
|
|
// Never wrap an empty (or whitespace-only) line.
|
|
|
|
|
if (raw.length === 0) {
|
|
|
|
|
lines.push("");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let line = "";
|
|
|
|
|
for (const word of raw.split(/(\s+)/)) {
|
|
|
|
|
if (word === "\n") {
|
|
|
|
|
if (line) lines.push(line);
|
|
|
|
|
line = "";
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (line.length + word.length > width) {
|
|
|
|
|
if (line) lines.push(line);
|
|
|
|
|
line = word; // long word spills; next iteration handles it
|
|
|
|
|
} else {
|
|
|
|
|
line += word;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (line) lines.push(line);
|
|
|
|
|
}
|
|
|
|
|
return lines.join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One chat message rendered in the transcript. */
|
|
|
|
|
function TranscriptMessage(props: {
|
|
|
|
|
msg: AppStateRest["transcriptCache"]["messages"][number];
|
|
|
|
|
width: number;
|
|
|
|
|
}): ReactNode {
|
|
|
|
|
const { msg, width } = props;
|
|
|
|
|
const color = roleColor[msg.role] ?? "white";
|
|
|
|
|
const tag = roleTag[msg.role] ?? msg.role?.toUpperCase() ?? "?";
|
|
|
|
|
// 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 =
|
|
|
|
|
msg.role === Roles.Assistant && !msg.content.trim()
|
|
|
|
|
? ""
|
|
|
|
|
: msg.content || "(empty)";
|
|
|
|
|
|
|
|
|
|
// Each message is a labelled block: a coloured role tag line followed by the
|
|
|
|
|
// word-wrapped body. Plain `<text>` is used (not `<markdown>`) so live
|
|
|
|
|
// streaming re-renders a single clean string — no overlap artifacts, and the
|
|
|
|
|
// body fills the full transcript width.
|
2026-09-03 00:10:01 +07:00
|
|
|
return (
|
|
|
|
|
<box flexDirection="column" width="100%">
|
2026-09-03 10:59:42 +07:00
|
|
|
<text fg={color}>{` ${tag.padEnd(4)} `}</text>
|
|
|
|
|
{content.length > 0 && (
|
|
|
|
|
<text fg={color}>{wrapText(content, Math.max(20, width - 4))}</text>
|
|
|
|
|
)}
|
|
|
|
|
{msg.reasoning.length > 0 && (
|
|
|
|
|
<text fg="gray">{` ⋯ ${oneLine(msg.reasoning)}`}</text>
|
|
|
|
|
)}
|
2026-09-03 00:10:01 +07:00
|
|
|
</box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 10:59:42 +07:00
|
|
|
/** Bottom input row: `>` prompt + buffer on one line. */
|
|
|
|
|
function InputView(props: { state: AppStateRest; width: number }): ReactNode {
|
|
|
|
|
const { state, width } = props;
|
|
|
|
|
const shown = state.input.buffer.slice(
|
|
|
|
|
Math.max(0, state.input.cursor - Math.max(10, width - 12)),
|
|
|
|
|
Math.max(0, state.input.cursor) + 40,
|
|
|
|
|
);
|
|
|
|
|
return (
|
|
|
|
|
<box flexDirection="row" width="100%">
|
|
|
|
|
<text fg="cyan">{"> "}</text>
|
|
|
|
|
<text fg="white">{shown}</text>
|
|
|
|
|
</box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Autocomplete / file-mention dropdown rendered as a bordered panel. */
|
|
|
|
|
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";
|
|
|
|
|
return (
|
|
|
|
|
<box width="100%" style={{ border: true, padding: 0 }}>
|
|
|
|
|
<text fg="magenta">
|
|
|
|
|
{` ${title} (↑/↓ navigate, Tab select, Esc close)\n`}
|
|
|
|
|
</text>
|
|
|
|
|
{candidates.slice(0, 10).map((c, i) => (
|
|
|
|
|
<text key={c} fg={i === state.input.autocompleteIdx ? "cyan" : "white"}>
|
|
|
|
|
{i === state.input.autocompleteIdx ? "▸ " : " "}
|
|
|
|
|
{c}
|
|
|
|
|
{i < candidates.slice(0, 10).length - 1 ? "\n" : ""}
|
|
|
|
|
</text>
|
|
|
|
|
))}
|
|
|
|
|
</box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Full-screen modal overlays (help, diff, editor, model selector, etc.). */
|
2026-09-03 00:10:01 +07:00
|
|
|
function OverlayView(props: { state: AppStateRest }): ReactNode {
|
|
|
|
|
const { state } = props;
|
|
|
|
|
const name = state.misc.overlay;
|
|
|
|
|
let body = "";
|
|
|
|
|
if (name === "quit_confirm") body = "Quit? (y=yes, n=no) Ctrl+C again / /quit to confirm";
|
|
|
|
|
else if (name === "help") body = state.helpText;
|
|
|
|
|
else if (name === "diff")
|
|
|
|
|
body = state.misc.diffContent
|
|
|
|
|
.split("\n")
|
|
|
|
|
.slice(state.misc.diffScroll, state.misc.diffScroll + 20)
|
|
|
|
|
.join("\n");
|
|
|
|
|
else body = `Overlay: ${name} (Esc to close)`;
|
|
|
|
|
return (
|
|
|
|
|
<box flexDirection="column" width="100%" style={{ border: true, padding: 1 }}>
|
2026-09-03 10:59:42 +07:00
|
|
|
<text fg="yellow">{body}</text>
|
|
|
|
|
<text fg="gray">
|
|
|
|
|
{name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"}
|
|
|
|
|
</text>
|
2026-09-03 00:10:01 +07:00
|
|
|
</box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 10:59:42 +07:00
|
|
|
/** Bottom status bar: turn state · token usage · live toast/hints. */
|
|
|
|
|
function StatusBar(props: { state: AppStateRest; running: boolean }): ReactNode {
|
|
|
|
|
const { state, running } = props;
|
|
|
|
|
const left = running ? "● running" : "● idle";
|
|
|
|
|
const usage = state.lastUsage;
|
|
|
|
|
const usageStr = usage
|
|
|
|
|
? `in:${usage.tokensIn} · out:${usage.tokensOut}`
|
|
|
|
|
: "in:– · out:–";
|
|
|
|
|
// 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 cell = ` ${left} ${usageStr}${ac}${toast} ${hints} `;
|
|
|
|
|
return <text fg={toast ? "yellow" : running ? "green" : "gray"}>{cell}</text>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 00:10:01 +07:00
|
|
|
/** 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);
|
|
|
|
|
});
|
|
|
|
|
}
|