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
+8 -131
View File
@@ -1,38 +1,16 @@
#!/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,
* routes through `handleKey` → `applyAction`, and re-renders the frame.
* Streaming agent output is applied to the transcript via
* `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.
* Boots the OpenTUI CLI renderer and mounts `ZesdexApp`, which routes input
* through `handleKey` → `applyAction`. Replaced the earlier raw-mode stdin
* event loop with OpenTUI's native renderer/keyboard handling.
*/
import * as fs from "node:fs";
import { createTuiState, pushTranscript, appendToLastTranscript, makeChatMessage } from "./state.ts";
import { applyAction } from "./action.ts";
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";
import { createTuiState } from "./state.ts";
import { bootTui } from "./ui.tsx";
function readRaw(): () => void {
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. */
/** Run the interactive OpenTUI until the user quits. */
export async function runTui(): Promise<void> {
const cwd = process.cwd();
const dataDir = `${process.env.HOME ?? ""}/.local/share/zesdex`;
@@ -40,106 +18,5 @@ export async function runTui(): Promise<void> {
fs.mkdirSync(sessionDir, { recursive: true });
const state = createTuiState([cwd], sessionDir, `${dataDir}/memory`);
const restore = readRaw();
// 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");
await bootTui(state);
}