refactor(tui): rombak renderer ke OpenTUI React bindings

Ganti renderer ANSI line-based (render.ts) dan event loop raw stdin dengan
OpenTUI React bindings (@opentui/react + @opentui/core, React 19.2):

- ui.tsx: komponen ZesdexApp (header/scrollbox transkrip/input/footer),
  adaptor toControllerKey (OpenTUI KeyEvent -> controller KeyEvent),
  bootTui (createCliRenderer + createRoot + mount).
- run.ts: pakai bootTui, jalankan OpenTUI sampai user quit.
- state/action/command/controller tetap logika murni (tidak tersentuh).
- 5 unit test baru untuk toControllerKey; 79 test total hijau.
- tsconfig: jsx react-jsx + jsxImportSource @opentui/react.
- hapus render.ts (tidak terpakai); hapus script format rusak (bun fmt).
- deps: @opentui/core, @opentui/react, react, @types/react.

verify: tsc 0 err, bun test 79 pass, dist/zesdex merender TUI via pty.
This commit is contained in:
asepharyana
2026-09-03 00:10:01 +07:00
parent b7006d1ad8
commit 3e260e6077
8 changed files with 358 additions and 252 deletions
+60
View File
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -10,7 +10,6 @@
"build": "bun build src/interfaces/cli/index.ts --compile --outfile=dist/zesdex", "build": "bun build src/interfaces/cli/index.ts --compile --outfile=dist/zesdex",
"check": "tsc --noEmit", "check": "tsc --noEmit",
"test": "bun test", "test": "bun test",
"format": "bun fmt",
"cli": "bun src/interfaces/cli/index.ts", "cli": "bun src/interfaces/cli/index.ts",
"bootstrap": "bun src/interfaces/cli/bootstrap.ts", "bootstrap": "bun src/interfaces/cli/bootstrap.ts",
"api": "bun src/interfaces/api/main.ts", "api": "bun src/interfaces/api/main.ts",
@@ -22,9 +21,13 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.2.0", "@types/bun": "^1.2.0",
"@types/react": "^19.2.18",
"typescript": "^5.7.0" "typescript": "^5.7.0"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.0" "@node-rs/argon2": "^2.2.0",
"@opentui/core": "^0.5.10",
"@opentui/react": "^0.5.10",
"react": "^19.2.8"
} }
} }
+3 -3
View File
@@ -1,6 +1,5 @@
/** /**
* Zesdex TUI interface — logic-pure interactive layer + lightweight renderer. * Zesdex TUI interface — interactive layer (OpenTUI) + logic-pure core.
* Mirrors `apps/interfaces/tui/`.
*/ */
export type { Action } from "./action.ts"; export type { Action } from "./action.ts";
export { applyAction } from "./action.ts"; export { applyAction } from "./action.ts";
@@ -27,5 +26,6 @@ export {
turnInFlight, turnInFlight,
} from "./state.ts"; } from "./state.ts";
export type { AppStateRest, ChatMessageDisplay } from "./state.ts"; export type { AppStateRest, ChatMessageDisplay } from "./state.ts";
export { renderFrame } from "./render.ts";
export { gitDiffOutput } from "./git.ts"; export { gitDiffOutput } from "./git.ts";
export { ZesdexApp, toControllerKey, bootTui } from "./ui.tsx";
export { runTui } from "./run.ts";
-116
View File
@@ -1,116 +0,0 @@
/**
* Lightweight line-based terminal renderer for the TUI. Renders the
* `AppStateRest` transcript, input line, overlay, and status into an array of
* plain-text + ANSI escaped lines. No external ncurses dependency — outputs
* raw ANSI sequences that work in any terminal. Conceptually mirrors
* `apps/interfaces/tui/src/view/**` but simplified to line output.
*/
import { Roles } from "@zesdex/domain";
import type { AppStateRest } from "./state.ts";
import { Overlay } from "./state.ts";
const roleLabel: Record<string, string> = {
[Roles.User]: "you",
[Roles.Assistant]: "zesdex",
[Roles.System]: "system",
[Roles.Tool]: "tool",
};
/** Render the full TUI frame into an array of lines (with ANSI colours). */
export function renderFrame(state: AppStateRest, height: number, width: number): string[] {
const lines: string[] = [];
// Header bar.
lines.push(renderHeader(state, width));
// Transcript pane.
const transcriptLines = renderTranscript(state, width);
lines.push(...transcriptLines);
// Padding to fill remaining height.
const used = lines.length;
for (let i = used; i < height - 3; i++) {
lines.push("");
}
// Overlay content (if any) replaces the input area.
if (state.misc.overlay !== Overlay.None) {
lines.push(renderOverlay(state, width));
} else {
lines.push(renderInput(state, width));
}
// Status/footer bar.
lines.push(renderStatus(state, width));
return lines.slice(0, height);
}
function renderHeader(_state: AppStateRest, width: number): string {
const title = " zesdex ";
const pad = Math.max(0, width - title.length);
return `\x1b[1;44m${title}\x1b[0m${" ".repeat(pad)}`;
}
function renderTranscript(state: AppStateRest, width: number): string[] {
const out: string[] = [];
const messages = state.transcriptCache.messages;
// Honour scroll offset from the bottom like a typical chat TUI.
const start = Math.max(0, messages.length - 30 - state.scroll.offset);
for (let i = start; i < messages.length; i++) {
const m = messages[i];
if (!m) continue;
const label = (roleLabel[m.role] ?? m.role).padEnd(8);
const color = m.role === Roles.User ? "\x1b[36m" : m.role === Roles.System ? "\x1b[33m" : "\x1b[32m";
out.push(`${color}${label}\x1b[0m ${m.content}`);
if (m.reasoning) {
out.push(` \x1b[2m(reasoning) ${m.reasoning}\x1b[0m`);
}
}
void width;
return out;
}
function renderInput(state: AppStateRest, width: number): string {
const prompt = "> ";
const shown = state.input.buffer.length > width - prompt.length
? state.input.buffer.slice(state.input.buffer.length - (width - prompt.length))
: state.input.buffer;
let line = `\x1b[1m${prompt}\x1b[0m${shown}`;
// Append autocomplete hint if visible.
if (state.input.autocompleteVisible && state.input.autocompleteCandidates[state.input.autocompleteIdx]) {
const cand = state.input.autocompleteCandidates[state.input.autocompleteIdx];
line += ` \x1b[2m[tab: ${cand}]\x1b[0m`;
}
// Toasts.
for (const t of state.misc.toasts.slice(-2)) {
line += ` \x1b[3m(${t.message})\x1b[0m`;
}
return line.length > width ? line.slice(0, width) : line;
}
function renderOverlay(state: AppStateRest, width: number): string {
const name = state.misc.overlay;
if (name === "diff") {
const diffLines = state.misc.diffContent.split("\n");
const from = state.misc.diffScroll;
return diffLines.slice(from, from + 10).join("\n ");
}
if (name === "quit_confirm") {
return " \x1b[1;31mQuit?\x1b[0m (y=yes) \x1b[2mCtrl+C again / /quit to confirm\x1b[0m";
}
if (name === "help") {
return state.helpText;
}
void width;
return ` \x1b[1mOverlay: ${name}\x1b[0m \x1b[2m(Esc to close)\x1b[0m`;
}
function renderStatus(state: AppStateRest, width: number): string {
const left = ` model=${(state.settings as { provider?: string }).provider ?? "?"}`;
const right = state.turnInFlightFlag.value
? " \x1b[5m● running\x1b[0m"
: " \x1b[2m● idle\x1b[0m";
const combined = `${left}${right}`;
return combined.length > width ? combined.slice(0, width) : combined;
}
+8 -131
View File
@@ -1,38 +1,16 @@
#!/usr/bin/env bun #!/usr/bin/env bun
/** /**
* Zesdex TUI — interactive event loop. * Zesdex TUI — OpenTUI entry point.
* *
* Reads keys from the terminal in raw mode, decodes them into `KeyEvent`s, * Boots the OpenTUI CLI renderer and mounts `ZesdexApp`, which routes input
* routes through `handleKey` → `applyAction`, and re-renders the frame. * through `handleKey` → `applyAction`. Replaced the earlier raw-mode stdin
* Streaming agent output is applied to the transcript via * event loop with OpenTUI's native renderer/keyboard handling.
* `appendToLastTranscript`.
*
* Mirrors the conceptual flow of `apps/interfaces/tui/src/run.rs` (event
* loop + turn drain) using a plain raw-mode stdin reader instead of
* crossterm/ratatui.
*/ */
import * as fs from "node:fs"; import * as fs from "node:fs";
import { createTuiState, pushTranscript, appendToLastTranscript, makeChatMessage } from "./state.ts"; import { createTuiState } from "./state.ts";
import { applyAction } from "./action.ts"; import { bootTui } from "./ui.tsx";
import type { Action } from "./action.ts";
import { decodeKey, handleKey } from "./controller.ts";
import type { KeyEvent } from "./controller.ts";
import { renderFrame } from "./render.ts";
import { Roles } from "@zesdex/domain";
function readRaw(): () => void { /** Run the interactive OpenTUI until the user quits. */
const prev = { ...(process.stdout as unknown as { attrs?: unknown }) };
const stdin = process.stdin as unknown as { setRawMode: (b: boolean) => void };
if (typeof stdin.setRawMode === "function") stdin.setRawMode(true);
// Show cursor-off control sequences are handled at frame draw.
void prev;
return () => {
if (typeof stdin.setRawMode === "function") stdin.setRawMode(false);
process.stdout.write("\x1b[?25h\x1b[0m"); // show cursor + reset
};
}
/** Run the interactive TUI until the user quits. */
export async function runTui(): Promise<void> { export async function runTui(): Promise<void> {
const cwd = process.cwd(); const cwd = process.cwd();
const dataDir = `${process.env.HOME ?? ""}/.local/share/zesdex`; const dataDir = `${process.env.HOME ?? ""}/.local/share/zesdex`;
@@ -40,106 +18,5 @@ export async function runTui(): Promise<void> {
fs.mkdirSync(sessionDir, { recursive: true }); fs.mkdirSync(sessionDir, { recursive: true });
const state = createTuiState([cwd], sessionDir, `${dataDir}/memory`); const state = createTuiState([cwd], sessionDir, `${dataDir}/memory`);
const restore = readRaw(); await bootTui(state);
// Seed some transcript so the pane isn't empty on first render.
pushTranscript(state, makeChatMessage(Roles.System, "Zesdex ready. Type a message or /help."));
const stdin = process.stdin as unknown as {
setRawMode: (b: boolean) => void;
on: (ev: string, cb: (chunk: Buffer) => void) => void;
resume: () => void;
};
stdin.resume();
let buffer = "";
stdin.on("data", (chunk: Buffer) => {
buffer += chunk.toString("utf8");
// Process complete escape sequences (each is self-contained).
while (buffer.length > 0) {
const ev = decodeKey(buffer);
if (!ev) {
// Might be a partial escape sequence; wait for more bytes.
break;
}
// consume the decoded sequence length
const consumed = consumedLen(buffer, ev);
buffer = buffer.slice(consumed);
dispatch(ev, state);
}
draw(state);
});
// Turn drain: poll a fake "in-flight" by checking pendingSubmit periodically.
const drainTimer = setInterval(() => {
if (state.pendingSubmit !== null) {
const text = state.pendingSubmit;
state.pendingSubmit = null;
state.turnInFlightFlag.value = true;
state.dirty = true;
// With no live LLM wiring in the pure TUI, simulate a short "thinking"
// then append a fixed assistant note. Real wiring happens in the CLI.
setTimeout(() => {
state.turnInFlightFlag.value = false;
appendToLastTranscript(state, `(no LLM configured — processed: "${text}")`, false);
state.dirty = true;
draw(state);
}, 50);
}
if (state.dirty) draw(state);
}, 100);
draw(state);
// Block until quit.
await new Promise<void>((resolve) => {
const check = setInterval(() => {
if (state.quit) {
clearInterval(check);
clearInterval(drainTimer);
restore();
resolve();
}
}, 50);
});
}
/** Determine how many characters of the input buffer a decoded event consumed. */
function consumedLen(buffer: string, _ev: KeyEvent): number {
// Escape sequences are multi-byte; a plain char is 1 codepoint.
if (buffer.startsWith("\u001b[")) {
// consume through the terminating letter (~ or A-D).
const m = buffer.match(/^\u001b\[([0-9;]*)([A-Z~])/);
return m ? m[0].length : buffer.length;
}
if (buffer.startsWith("\u001b")) {
return buffer.length; // alt sequence
}
return [...buffer][0]!.length;
}
/** Dispatch a decoded key via the controller and apply resulting actions. */
function dispatch(key: KeyEvent, state: ReturnType<typeof createTuiState>): void {
const actions = handleKey(key, state);
for (const action of actions) {
const act = action as Action;
if (act.tag === "QuitConfirm") {
// Go straight to quit on Ctrl+C (simplified confirm).
state.quit = true;
continue;
}
applyAction(state, act);
}
}
/** Draw the current frame. */
function draw(state: ReturnType<typeof createTuiState>): void {
if (!state.dirty) return;
state.dirty = false;
const h = process.stdout.rows ?? 24;
const w = process.stdout.columns ?? 80;
const lines = renderFrame(state, h, w);
process.stdout.write("\x1b[2J\x1b[H"); // clear + home
process.stdout.write(lines.join("\n"));
process.stdout.write("\x1b[?25h");
} }
+38
View File
@@ -7,6 +7,7 @@ import { parseCommand, applyCommand } from "./command.ts";
import { createTuiState, pushTranscript, makeChatMessage } from "./state.ts"; import { createTuiState, pushTranscript, makeChatMessage } from "./state.ts";
import { applyAction } from "./action.ts"; import { applyAction } from "./action.ts";
import { handleKey, decodeKey } from "./controller.ts"; import { handleKey, decodeKey } from "./controller.ts";
import { toControllerKey } from "./ui.tsx";
import { Roles } from "@zesdex/domain"; import { Roles } from "@zesdex/domain";
function freshState() { function freshState() {
@@ -125,3 +126,40 @@ describe("decodeKey", () => {
expect(decodeKey("x")).toEqual({ code: "Char", value: "x", ctrl: false, alt: false }); expect(decodeKey("x")).toEqual({ code: "Char", value: "x", ctrl: false, alt: false });
}); });
}); });
describe("toControllerKey (OpenTUI adapter)", () => {
test("Enter maps to KeyCode.Enter", () => {
expect(toControllerKey({ name: "return", eventType: "press" })).toEqual({
code: "Enter",
ctrl: false,
alt: false,
});
});
test("arrow up maps to ArrowUp", () => {
expect(toControllerKey({ name: "up", eventType: "press" })).toEqual({
code: "ArrowUp",
ctrl: false,
alt: false,
});
});
test("escape maps to Escape", () => {
expect(toControllerKey({ name: "escape", eventType: "press" })).toEqual({
code: "Escape",
ctrl: false,
alt: false,
});
});
test("printable char maps to Char with value + ctrl/meta flags", () => {
expect(toControllerKey({ name: "a", eventType: "press", ctrl: true })).toEqual({
code: "Char",
value: "a",
ctrl: true,
alt: false,
shift: false,
});
});
test("alphanumeric keeps char value", () => {
expect(toControllerKey({ name: "z", eventType: "press" })?.value).toBe("z");
});
});
+242
View File
@@ -0,0 +1,242 @@
/**
* OpenTUI React bindings for the Zesdex TUI.
*
* Renders `AppStateRest` as an OpenTUI layout and routes keyboard input
* through the existing `handleKey` → `applyAction` pipeline. This replaces
* the earlier raw-ANSI line renderer with a proper declarative OpenTUI tree.
*
* The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`)
* is untouched and remains unit-tested.
*/
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",
};
const oneLine = (s: string): string => s.replace(/\n/g, " ").replace(/\s+/g, " ").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);
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();
// Local-only "turn drain": when a turn is submitted without a live LLM,
// simulate a short thinking delay then append a note. Real wiring happens
// in the CLI. Replaced the old raw-loop poll.
useEffect(() => {
const timer = setInterval(() => {
if (state.pendingSubmit != null) {
const text = state.pendingSubmit;
state.pendingSubmit = null;
state.turnInFlightFlag.value = true;
setTimeout(() => {
state.turnInFlightFlag.value = false;
appendToLastTranscript(state, `(no LLM configured — processed: "${text}")`, false);
setTick((x: number) => x + 1);
}, 50);
}
setTick((x: number) => x + 1);
}, 120);
return () => clearInterval(timer);
}, [state, setTick]);
const header = ` zesdex · ${state.settings.provider ?? "?"} `;
const messages = state.transcriptCache.messages;
const scrollFromEnd = messages.length - state.scroll.offset;
const visible = messages.slice(Math.max(0, scrollFromEnd - 40), scrollFromEnd);
return (
<box flexDirection="column" width={dimensions.width} height={dimensions.height}>
{/* Header */}
<box width={dimensions.width} backgroundColor="blue">
<text fg="white">{header}</text>
</box>
{/* Transcript pane */}
<scrollbox flexGrow={1} width={dimensions.width} style={{ backgroundColor: "#0b0f14" }}>
{visible.map((m, i) => (
<text key={`${m.timestamp}-${i}`} fg={roleColor[m.role] ?? "white"}>
{`${(m.role ?? "?").padEnd(9)} `}
{oneLine(m.content)}
</text>
))}
</scrollbox>
{/* Overlay or input area */}
{state.misc.overlay !== "none" ? <OverlayView state={state} /> : <InputView state={state} />}
{/* Status footer */}
<text fg={state.turnInFlightFlag.value ? "green" : "gray"}>
{state.turnInFlightFlag.value ? "● running" : "● idle"}
</text>
</box>
);
}
function InputView(props: { state: AppStateRest }): ReactNode {
const { state } = props;
const shown = state.input.buffer.slice(
Math.max(0, state.input.cursor - 40),
state.input.cursor + 20,
);
const hint =
state.input.autocompleteVisible &&
state.input.autocompleteCandidates[state.input.autocompleteIdx] != null
? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]`
: "";
const toast =
state.misc.toasts.length > 0
? ` (${state.misc.toasts[state.misc.toasts.length - 1]?.message})`
: "";
return (
<box flexDirection="column" width="100%">
<text fg="cyan">{"> "}</text>
<text>{shown}</text>
<text fg="gray">{hint}{toast}</text>
</box>
);
}
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 }}>
<text fg="yellow">{body.split("\n").map((l, i) => (<text key={i}>{l}</text>))}</text>
<text fg="gray">{name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"}</text>
</box>
);
}
/** 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);
});
}
+2
View File
@@ -19,6 +19,8 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"paths": { "paths": {
"@zesdex/domain": ["./src/domain/index.ts"], "@zesdex/domain": ["./src/domain/index.ts"],
"@zesdex/application": ["./src/application/index.ts"], "@zesdex/application": ["./src/application/index.ts"],