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
+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);
});
}