diff --git a/bun.lock b/bun.lock index c2a5ae2..99db680 100644 --- a/bun.lock +++ b/bun.lock @@ -6,9 +6,13 @@ "name": "zesdex", "dependencies": { "@node-rs/argon2": "^2.2.0", + "@opentui/core": "^0.5.10", + "@opentui/react": "^0.5.10", + "react": "^19.2.8", }, "devDependencies": { "@types/bun": "^1.2.0", + "@types/react": "^19.2.18", "typescript": "^5.7.0", }, }, @@ -42,14 +46,70 @@ "@node-rs/argon2-win32-x64-msvc": ["@node-rs/argon2-win32-x64-msvc@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-cfcNjVqLpgOU/9l2mLOFduuXTxvGH5qoV38AhcOz9oQaC7GDmsFbAgcuizVNuOtpSyQr5HoQez3f2X2jYgGMsg=="], + "@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="], + + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="], + + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="], + + "@opentui/react": ["@opentui/react@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-76UFqe/ML121Skjche2+mGX56GKTlwG3ftrOi3b7WGLvjGucPqgEDJ0+CmaC/Yk85O8ui/ZTZJ2tay/8foR4FQ=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + + "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], + + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], } } diff --git a/package.json b/package.json index 7a09bd6..7f413d9 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "build": "bun build src/interfaces/cli/index.ts --compile --outfile=dist/zesdex", "check": "tsc --noEmit", "test": "bun test", - "format": "bun fmt", "cli": "bun src/interfaces/cli/index.ts", "bootstrap": "bun src/interfaces/cli/bootstrap.ts", "api": "bun src/interfaces/api/main.ts", @@ -22,9 +21,13 @@ }, "devDependencies": { "@types/bun": "^1.2.0", + "@types/react": "^19.2.18", "typescript": "^5.7.0" }, "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" } } diff --git a/src/interfaces/tui/index.ts b/src/interfaces/tui/index.ts index 1fc1cb1..cc44c65 100644 --- a/src/interfaces/tui/index.ts +++ b/src/interfaces/tui/index.ts @@ -1,6 +1,5 @@ /** - * Zesdex TUI interface — logic-pure interactive layer + lightweight renderer. - * Mirrors `apps/interfaces/tui/`. + * Zesdex TUI interface — interactive layer (OpenTUI) + logic-pure core. */ export type { Action } from "./action.ts"; export { applyAction } from "./action.ts"; @@ -27,5 +26,6 @@ export { turnInFlight, } from "./state.ts"; export type { AppStateRest, ChatMessageDisplay } from "./state.ts"; -export { renderFrame } from "./render.ts"; export { gitDiffOutput } from "./git.ts"; +export { ZesdexApp, toControllerKey, bootTui } from "./ui.tsx"; +export { runTui } from "./run.ts"; diff --git a/src/interfaces/tui/render.ts b/src/interfaces/tui/render.ts deleted file mode 100644 index 2f5748e..0000000 --- a/src/interfaces/tui/render.ts +++ /dev/null @@ -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 = { - [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; -} diff --git a/src/interfaces/tui/run.ts b/src/interfaces/tui/run.ts index 4404144..2352d8f 100644 --- a/src/interfaces/tui/run.ts +++ b/src/interfaces/tui/run.ts @@ -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 { const cwd = process.cwd(); const dataDir = `${process.env.HOME ?? ""}/.local/share/zesdex`; @@ -40,106 +18,5 @@ export async function runTui(): Promise { 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((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): 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): 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); } diff --git a/src/interfaces/tui/tui.test.ts b/src/interfaces/tui/tui.test.ts index 6bcd94e..7100028 100644 --- a/src/interfaces/tui/tui.test.ts +++ b/src/interfaces/tui/tui.test.ts @@ -7,6 +7,7 @@ import { parseCommand, applyCommand } from "./command.ts"; import { createTuiState, pushTranscript, makeChatMessage } from "./state.ts"; import { applyAction } from "./action.ts"; import { handleKey, decodeKey } from "./controller.ts"; +import { toControllerKey } from "./ui.tsx"; import { Roles } from "@zesdex/domain"; function freshState() { @@ -125,3 +126,40 @@ describe("decodeKey", () => { 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"); + }); +}); + diff --git a/src/interfaces/tui/ui.tsx b/src/interfaces/tui/ui.tsx new file mode 100644 index 0000000..623540e --- /dev/null +++ b/src/interfaces/tui/ui.tsx @@ -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 = { + [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 ( + + {/* Header */} + + {header} + + + {/* Transcript pane */} + + {visible.map((m, i) => ( + + {`${(m.role ?? "?").padEnd(9)} `} + {oneLine(m.content)} + + ))} + + + {/* Overlay or input area */} + {state.misc.overlay !== "none" ? : } + + {/* Status footer */} + + {state.turnInFlightFlag.value ? "● running" : "● idle"} + + + ); +} + +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 ( + + {"> "} + {shown} + {hint}{toast} + + ); +} + +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 ( + + {body.split("\n").map((l, i) => ({l}))} + {name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"} + + ); +} + +/** Boot the OpenTUI renderer and mount the Zesdex app. */ +export async function bootTui(state: AppStateRest): Promise { + pushTranscript(state, makeChatMessage(Roles.System, "Zesdex ready. Type a message or /help.")); + + const renderer: CliRenderer = await createCliRenderer(); + const root = createRoot(renderer); + + root.render( renderer.destroy()} />); + + return new Promise((resolve) => { + const check = setInterval(() => { + if (state.quit) { + clearInterval(check); + try { + root.unmount(); + } catch { + /* already unmounted */ + } + renderer.destroy(); + resolve(); + } + }, 60); + }); +} diff --git a/tsconfig.json b/tsconfig.json index 2ca4d8d..bde085e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,8 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, + "jsx": "react-jsx", + "jsxImportSource": "@opentui/react", "paths": { "@zesdex/domain": ["./src/domain/index.ts"], "@zesdex/application": ["./src/application/index.ts"],