refactor: rombak dari monorepo ke project tunggal — flat src/{domain,application,infrastructure,interfaces}, satu package.json tanpa workspaces, tsconfig paths → src/*, update install.sh/Dockerfile/CI
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
#!/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");
|
||||
}
|
||||
Reference in New Issue
Block a user