145 lines
5.0 KiB
TypeScript
145 lines
5.0 KiB
TypeScript
#!/usr/bin/env bun
|
|||
|
|
/**
|
||
|
|
* Zesdex TUI — interactive event loop.
|
||
|
|
*
|
||
|
|
* 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.
|
||
|
|
*/
|
||
|
|
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";
|
||
|
|
|
||
|
|
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. */
|
||
|
|
export async function runTui(): Promise<void> {
|
||
|
|
const cwd = process.cwd();
|
||
|
|
const dataDir = `${process.env.HOME ?? ""}/.local/share/zesdex`;
|
||
|
|
const sessionDir = `${dataDir}/sessions/${Date.now()}`;
|
||
|
|
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");
|
||
|
|
}
|