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:
+5
-2
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user