diff --git a/apps/interfaces/tui/package.json b/apps/interfaces/tui/package.json new file mode 100644 index 0000000..82cc819 --- /dev/null +++ b/apps/interfaces/tui/package.json @@ -0,0 +1,16 @@ +{ + "name": "@zesdex/tui", + "version": "1.21.2", + "private": true, + "type": "module", + "scripts": { + "tui": "bun src/main.ts", + "build": "true" + }, + "dependencies": { + "@zesdex/domain": "workspace:*" + }, + "devDependencies": { + "@types/bun": "^1.2.0" + } +} diff --git a/apps/interfaces/tui/src/action.ts b/apps/interfaces/tui/src/action.ts new file mode 100644 index 0000000..4dfe0ae --- /dev/null +++ b/apps/interfaces/tui/src/action.ts @@ -0,0 +1,200 @@ +/** + * The `Action` — a single well-typed event in the TUI, produced by key input + * and applied to `AppStateRest` by the event loop. Mirrors + * `apps/interfaces/tui/src/action/mod.rs`. + * + * Flow: `handleKey` returns `Action[]` → the event loop calls `applyAction` + * for each → state is mutated in place. Every state mutation funnels through + * this single chokepoint. + */ +import * as fs from "node:fs"; +import { Roles } from "@zesdex/domain"; +import type { AppStateRest, Overlay } from "./state.ts"; +import { + makeChatMessage, + pushTranscript, + toastInfo, + markDirty, + EditorState, +} from "./state.ts"; +import { gitDiffOutput } from "./git.ts"; + +/** A single well-typed event in the TUI that mutates `AppStateRest`. */ +export type Action = + | { tag: "ForceQuit" } + | { tag: "SubmitInput"; text: string } + | { tag: "DeleteChar" } + | { tag: "DeleteCharRight" } + | { tag: "CursorLeft" } + | { tag: "CursorRight" } + | { tag: "HistoryUp" } + | { tag: "HistoryDown" } + | { tag: "ScrollUp" } + | { tag: "ScrollDown" } + | { tag: "OpenOverlay"; overlay: Overlay } + | { tag: "CloseOverlay" } + | { tag: "SystemNote"; kind: string; message: string } + | { tag: "QuitConfirm" } + | { tag: "Resize"; width: number; height: number } + | { tag: "Tick" } + | { tag: "LessonAccept"; name: string } + | { tag: "LessonReject"; name: string } + | { tag: "LessonDelete"; name: string } + | { tag: "StartOAuth"; provider: string } + | { tag: "OpenEditor"; path: string } + | { tag: "McpAdd"; name: string; command: string } + | { tag: "ModelList" } + | { tag: "AbortTurn" } + | { tag: "Compact" } + | { tag: "ShowDiff" } + | { tag: "DiffScroll"; amount: number }; + +/** Apply an `Action` to `AppStateRest`. `state` is mutated in place. */ +export function applyAction(state: AppStateRest, action: Action): void { + switch (action.tag) { + case "ForceQuit": + state.quit = true; + break; + + case "QuitConfirm": + state.misc.overlay = "quit_confirm"; + state.dirty = true; + break; + + case "Resize": + if (state.lastRenderWidth !== action.width) { + state.transcriptCache.dirty = true; + state.lastRenderWidth = action.width; + } + state.dirty = true; + break; + + case "Tick": + handleTick(state); + break; + + case "SubmitInput": + pushTranscript(state, makeChatMessage(Roles.User, action.text)); + state.input.submit(); + state.pendingSubmit = action.text; + state.tokenCountDirty = true; + state.dirty = true; + break; + + case "DeleteChar": + state.input.deleteLeft(); + state.dirty = true; + break; + case "DeleteCharRight": + state.input.deleteRight(); + state.dirty = true; + break; + case "CursorLeft": + state.input.cursor = Math.max(0, state.input.cursor - 1); + state.dirty = true; + break; + case "CursorRight": + if (state.input.cursor < state.input.buffer.length) state.input.cursor += 1; + state.dirty = true; + break; + case "HistoryUp": + state.input.historyUp(); + state.dirty = true; + break; + case "HistoryDown": + state.input.historyDown(); + state.dirty = true; + break; + + case "ScrollUp": + state.scroll.scrollUp(3); + state.dirty = true; + break; + case "ScrollDown": + state.scroll.scrollDown(3); + state.dirty = true; + break; + + case "OpenOverlay": + state.misc.overlay = action.overlay; + state.dirty = true; + break; + case "CloseOverlay": + state.misc.overlay = "none"; + state.dirty = true; + break; + + case "SystemNote": + pushTranscript(state, makeChatMessage(Roles.System, action.message)); + break; + + case "ModelList": + state.misc.overlay = "model_selector"; + state.dirty = true; + break; + + case "OpenEditor": + { + const content = fs.existsSync(action.path) + ? fs.readFileSync(action.path, "utf8") + : ""; + state.misc.editor = new EditorState(action.path, content); + state.misc.overlay = "editor"; + state.dirty = true; + } + break; + + case "LessonAccept": + toastInfo(state, `Lesson accepted: ${action.name}`); + break; + case "LessonReject": + toastInfo(state, `Lesson rejected: ${action.name}`); + break; + case "LessonDelete": + toastInfo(state, `Lesson deleted: ${action.name}`); + break; + + case "StartOAuth": + toastInfo(state, `OAuth login started for ${action.provider}`); + break; + + case "McpAdd": + toastInfo(state, `MCP server added: ${action.name} (${action.command})`); + break; + + case "AbortTurn": + state.abortFlag.value = true; + toastInfo(state, "Aborting current turn..."); + break; + + case "Compact": + toastInfo(state, "Compacting conversation..."); + break; + + case "ShowDiff": + { + state.misc.diffContent = gitDiffOutput(state.workspaceRoots); + state.misc.diffScroll = 0; + state.misc.overlay = "diff"; + state.dirty = true; + } + break; + + case "DiffScroll": + { + const maxScroll = Math.max(0, state.misc.diffContent.split("\n").length - 1); + const newScroll = Math.max(0, state.misc.diffScroll + action.amount); + state.misc.diffScroll = Math.min(newScroll, maxScroll); + state.dirty = true; + } + break; + } +} + +/** Periodic timer tick — drains queued events and applies streaming text. */ +function handleTick(state: AppStateRest): void { + // Streaming updates are applied via appendToLastTranscript by the turn + // driver; here we only reconcile cache validity and progress the spinner. + state.misc.tickCount += 1; + markDirty(state); +} diff --git a/apps/interfaces/tui/src/command.ts b/apps/interfaces/tui/src/command.ts new file mode 100644 index 0000000..3c28a69 --- /dev/null +++ b/apps/interfaces/tui/src/command.ts @@ -0,0 +1,113 @@ +/** + * Slash-command parser that maps TUI `/foo` input lines into `Command` + * variants for the action dispatch system. Mirrors + * `apps/interfaces/tui/src/controller/command.rs`. + */ + +import type { Action } from "./action.ts"; + +/** A parsed slash command from the TUI input buffer. */ +export type Command = + | { tag: "Help" } + | { tag: "Quit" } + | { tag: "McpOpen" } + | { tag: "Clear" } + | { tag: "ClearConfirm" } + | { tag: "Login"; provider: string } + | { tag: "Edit"; path: string } + | { tag: "McpAdd"; name: string; command: string } + | { tag: "ModelList" } + | { tag: "Compact" } + | { tag: "TodoOpen" } + | { tag: "PlanOpen" } + | { tag: "UsageOpen" } + | { tag: "Diff" } + | { tag: "Unknown"; text: string }; + +/** Parse a slash-prefixed input line into a `Command` value. */ +export function parseCommand(text: string): Command { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) return { tag: "Unknown", text: trimmed }; + + const parts = trimmed.split(" ", 3); + const cmd = parts[0] ?? ""; + const arg1 = parts[1] ?? ""; + const arg2 = parts[2] ?? ""; + + switch (cmd) { + case "/help": + return { tag: "Help" }; + case "/quit": + return { tag: "Quit" }; + case "/clear": + return arg1 === "" ? { tag: "ClearConfirm" } : { tag: "Clear" }; + case "/login": + return arg1 === "" ? { tag: "Login", provider: "" } : { tag: "Login", provider: arg1 }; + case "/edit": + return { tag: "Edit", path: arg1 === "" ? "." : arg1 }; + case "/mcp": + if (arg1 === "") return { tag: "McpOpen" }; + if (arg1 === "add") { + const rest = arg2.trim(); + const space = rest.indexOf(" "); + if (space !== -1) { + return { tag: "McpAdd", name: rest.slice(0, space), command: rest.slice(space + 1).trim() }; + } + return { tag: "McpAdd", name: rest, command: "" }; + } + return { tag: "Unknown", text: cmd }; + case "/model": + return { tag: "ModelList" }; + case "/compact": + return { tag: "Compact" }; + case "/todo": + return { tag: "TodoOpen" }; + case "/plan": + return { tag: "PlanOpen" }; + case "/usage": + return { tag: "UsageOpen" }; + case "/diff": + return { tag: "Diff" }; + default: + return { tag: "Unknown", text: cmd }; + } +} + +/** Map a parsed `Command` into `Action` values for the event loop. */ +export function applyCommand(cmd: Command): Action[] { + switch (cmd.tag) { + case "Help": + return [{ tag: "OpenOverlay", overlay: "help" }]; + case "Quit": + return [{ tag: "QuitConfirm" }]; + case "McpOpen": + return [{ tag: "OpenOverlay", overlay: "mcp" }]; + case "Clear": + return [{ tag: "SystemNote", kind: "clear", message: "Transcript cleared." }]; + case "ClearConfirm": + return [{ tag: "OpenOverlay", overlay: "clear_confirm" }]; + case "Login": + return [{ tag: "StartOAuth", provider: cmd.provider }]; + case "Edit": + return [{ tag: "OpenEditor", path: cmd.path }]; + case "McpAdd": + return [{ tag: "McpAdd", name: cmd.name, command: cmd.command }]; + case "ModelList": + return [{ tag: "ModelList" }]; + case "Compact": + return [{ tag: "Compact" }]; + case "TodoOpen": + return [{ tag: "OpenOverlay", overlay: "todo" }]; + case "PlanOpen": + return [{ tag: "OpenOverlay", overlay: "plan" }]; + case "UsageOpen": + return [{ tag: "OpenOverlay", overlay: "usage" }]; + case "Diff": + return [{ tag: "ShowDiff" }]; + case "Unknown": + if (cmd.text.startsWith("/")) { + return [{ tag: "SystemNote", kind: "error", message: `Unknown command: ${cmd.text}` }]; + } + return [{ tag: "SubmitInput", text: cmd.text }]; + } +} diff --git a/apps/interfaces/tui/src/controller.ts b/apps/interfaces/tui/src/controller.ts new file mode 100644 index 0000000..936a2f6 --- /dev/null +++ b/apps/interfaces/tui/src/controller.ts @@ -0,0 +1,330 @@ +/** + * Key event dispatcher: maps raw key events into `Action` variants, with + * special handling for overlays, autocomplete, and the inline editor. + * Mirrors `apps/interfaces/tui/src/controller/input.rs`. + * + * Flow: `handleKey` is called on each key press → returns `Action[]`. + */ +import { Roles } from "@zesdex/domain"; +import type { AppStateRest } from "./state.ts"; +import { AutocompleteKind, markDirty, toastInfo, overlayIsActive } from "./state.ts"; +import type { Action } from "./action.ts"; +import { applyCommand, parseCommand } from "./command.ts"; + +/** A decoded terminal key event (normalised from raw input). */ +export interface KeyEvent { + code: string; + ctrl: boolean; + alt: boolean; + shift?: boolean; + /** For plain char events, the decoded character(s). */ + value?: string; +} + +/** Common key codes we normalise from raw input. */ +export const KeyCode = { + Enter: "Enter", + Backspace: "Backspace", + Delete: "Delete", + Left: "ArrowLeft", + Right: "ArrowRight", + Up: "ArrowUp", + Down: "ArrowDown", + PageUp: "PageUp", + PageDown: "PageDown", + Esc: "Escape", + Tab: "Tab", + Char: "Char", +} as const; + +/** Normalise a raw string key (from raw-mode stdin) into a `KeyEvent`. */ +export function decodeKey(raw: string): KeyEvent | null { + if (raw === "\r" || raw === "\n") return { code: KeyCode.Enter, ctrl: false, alt: false }; + if (raw === "\u001b") return { code: KeyCode.Esc, ctrl: false, alt: false }; + if (raw === "\t") return { code: KeyCode.Tab, ctrl: false, alt: false }; + if (raw === "\u007f" || raw === "\b") return { code: KeyCode.Backspace, ctrl: false, alt: false }; + + // Ctrl+letter (0x01–0x1a) + const ch = raw.charCodeAt(0); + if (ch >= 0x01 && ch <= 0x1a && raw.length === 1) { + const letter = String.fromCharCode(ch + 0x60); + return { code: KeyCode.Char, value: letter, ctrl: true, alt: false }; + } + + // ESC-prefixed sequences: arrows, page keys, alt+char. + if (raw.startsWith("\u001b[")) { + switch (raw) { + case "\u001b[A": + return { code: KeyCode.Up, ctrl: false, alt: false }; + case "\u001b[B": + return { code: KeyCode.Down, ctrl: false, alt: false }; + case "\u001b[C": + return { code: KeyCode.Right, ctrl: false, alt: false }; + case "\u001b[D": + return { code: KeyCode.Left, ctrl: false, alt: false }; + case "\u001b[5~": + return { code: KeyCode.PageUp, ctrl: false, alt: false }; + case "\u001b[6~": + return { code: KeyCode.PageDown, ctrl: false, alt: false }; + case "\u001b[3~": + return { code: KeyCode.Delete, ctrl: false, alt: false }; + case "\u001b[1;5A": + return { code: KeyCode.Up, ctrl: true, alt: false }; + case "\u001b[1;5B": + return { code: KeyCode.Down, ctrl: true, alt: false }; + default: + return null; + } + } + if (raw.startsWith("\u001b") && raw.length > 1) { + // Alt+ + const inner = raw.slice(1); + if (inner.length === 1) { + return { code: KeyCode.Char, value: inner, ctrl: false, alt: true }; + } + return null; + } + + // Plain printable char(s) + return { code: KeyCode.Char, value: raw, ctrl: false, alt: false }; +} + +/** Mark state dirty and return an empty action list. */ +function mark(state: AppStateRest): Action[] { + markDirty(state); + return []; +} + +/** + * Translate a decoded `KeyEvent` into zero or more `Action` values based on + * the current application state. + */ +export function handleKey(key: KeyEvent, state: AppStateRest): Action[] { + const code = key.code; + + // ── Editor overlay ─────────────────────────────────────────────────── + if (state.misc.overlay === "editor") { + if (code === KeyCode.Char && key.ctrl && (key.value === "c" || key.value === "s")) { + return [{ tag: "QuitConfirm" }]; + } + if (code === KeyCode.Esc) { + state.misc.editor = null; + state.misc.overlay = "none"; + return []; + } + if (code === KeyCode.Backspace) { + if (state.misc.editor) { + state.misc.editor.deleteLeft(); + mark(state); + } + return []; + } + if (code === KeyCode.Enter) { + if (state.misc.editor) { + state.misc.editor.content = + state.misc.editor.content.slice(0, state.misc.editor.cursor) + + "\n" + + state.misc.editor.content.slice(state.misc.editor.cursor); + state.misc.editor.cursor += 1; + mark(state); + } + return []; + } + if (code === KeyCode.Char && key.value) { + if (state.misc.editor) { + state.misc.editor.content = + state.misc.editor.content.slice(0, state.misc.editor.cursor) + + key.value + + state.misc.editor.content.slice(state.misc.editor.cursor); + state.misc.editor.cursor += key.value.length; + mark(state); + } + return []; + } + return []; + } + + // ── Diff overlay ───────────────────────────────────────────────────── + if (state.misc.overlay === "diff") { + if (code === KeyCode.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }]; + if (code === KeyCode.Up || (code === KeyCode.Char && key.value === "k")) { + return [{ tag: "DiffScroll", amount: -1 }]; + } + if (code === KeyCode.Down || (code === KeyCode.Char && key.value === "j")) { + return [{ tag: "DiffScroll", amount: 1 }]; + } + if (code === KeyCode.PageUp) return [{ tag: "DiffScroll", amount: -20 }]; + if (code === KeyCode.PageDown) return [{ tag: "DiffScroll", amount: 20 }]; + if (code === KeyCode.Esc) return [{ tag: "CloseOverlay" }]; + return []; + } + + // ── Learning overlay ───────────────────────────────────────────────── + if (state.misc.overlay === "learning") { + if (code === KeyCode.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }]; + if (code === KeyCode.Esc) return [{ tag: "CloseOverlay" }]; + if (code === KeyCode.Up) { + state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, 10, false); + return mark(state); + } + if (code === KeyCode.Down) { + state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, 10, true); + return mark(state); + } + if (code === KeyCode.Enter || (code === KeyCode.Char && key.value === "a")) { + return [{ tag: "LessonAccept", name: `lesson-${state.misc.selectedIndex}` }]; + } + if (code === KeyCode.Char && key.value === "r") { + return [{ tag: "LessonReject", name: `lesson-${state.misc.selectedIndex}` }]; + } + if (code === KeyCode.Char && key.value === "d") { + return [{ tag: "LessonDelete", name: `lesson-${state.misc.selectedIndex}` }]; + } + return []; + } + + // ── Normal (non-overlay) dispatch ──────────────────────────────────── + switch (code) { + case KeyCode.Char: + if (key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }]; + if (key.ctrl && key.value === "d") return [{ tag: "CloseOverlay" }]; + if (key.ctrl && key.value === "y") { + const lastAssistant = [...state.transcriptCache.messages] + .reverse() + .find((m) => m.role === Roles.Assistant); + if (lastAssistant) { + state.misc.pendingClipboardCopy = lastAssistant.content; + } else { + toastInfo(state, "No assistant message to copy yet"); + } + return []; + } + if (key.alt) return [{ tag: "ShowDiff" }]; + // Regular char insertion + if (state.input.autocompleteVisible) { + state.input.closeAutocomplete(); + mark(state); + } + if (key.value) state.input.insert(key.value); + mark(state); + if (state.input.buffer.startsWith("/")) { + state.input.openAutocomplete(); + } else if (state.input.mentionQueryAtCursor()) { + state.input.openMentionAutocomplete(state.mentionFiles); + } + return []; + case KeyCode.Enter: + if (state.input.autocompleteVisible) { + state.input.selectAutocomplete(); + return mark(state); + } + if (overlayIsActive(state.misc.overlay)) { + return []; // handle_overlay_enter (simplified: no-op) + } + { + const text = state.input.buffer; + if (text.startsWith("/")) { + return applyCommand(parseCommand(text)); + } + return [{ tag: "SubmitInput", text }]; + } + case KeyCode.Backspace: + if (state.input.autocompleteVisible) { + state.input.closeAutocomplete(); + return mark(state); + } + return [{ tag: "DeleteChar" }]; + case KeyCode.Delete: + if (state.input.autocompleteVisible) { + state.input.closeAutocomplete(); + return mark(state); + } + return [{ tag: "DeleteCharRight" }]; + case KeyCode.Left: + return [{ tag: "CursorLeft" }]; + case KeyCode.Right: + return [{ tag: "CursorRight" }]; + case KeyCode.Up: + if (state.input.autocompleteVisible) { + state.input.cycleAutocomplete(false); + mark(state); + return []; + } else if (state.misc.overlay === "model_selector") { + state.misc.selectedIndex = cycleSelectedIndex( + state.misc.selectedIndex, + Object.keys(state.appConfig.providers).length, + false, + ); + mark(state); + return []; + } else if (key.ctrl) { + return [{ tag: "ScrollUp" }]; + } else { + return [{ tag: "HistoryUp" }]; + } + case KeyCode.Down: + if (state.input.autocompleteVisible) { + state.input.cycleAutocomplete(true); + mark(state); + return []; + } else if (state.misc.overlay === "model_selector") { + state.misc.selectedIndex = cycleSelectedIndex( + state.misc.selectedIndex, + Object.keys(state.appConfig.providers).length, + true, + ); + mark(state); + return []; + } else if (key.ctrl) { + return [{ tag: "ScrollDown" }]; + } else { + return [{ tag: "HistoryDown" }]; + } + case KeyCode.PageUp: + return [{ tag: "ScrollUp" }]; + case KeyCode.PageDown: + return [{ tag: "ScrollDown" }]; + case KeyCode.Esc: + if (turnInFlightLocal(state)) return [{ tag: "AbortTurn" }]; + else if (state.input.autocompleteVisible) { + state.input.closeAutocomplete(); + mark(state); + return []; + } else if (overlayIsActive(state.misc.overlay)) { + return [{ tag: "CloseOverlay" }]; + } + return []; + case KeyCode.Tab: + if (state.input.buffer.startsWith("/")) { + if (state.input.autocompleteVisible) { + state.input.cycleAutocomplete(true); + } else { + state.input.tabComplete(); + } + mark(state); + } else if ( + state.input.autocompleteKind === AutocompleteKind.FileMention && + state.input.autocompleteVisible + ) { + state.input.cycleAutocomplete(true); + mark(state); + } + return []; + default: + return []; + } +} + +/** Local helper: whether an agent turn is in flight. */ +function turnInFlightLocal(state: AppStateRest): boolean { + return state.turnInFlightFlag.value; +} + +/** Cycle a selected index within `n` items (forward or backward). */ +export function cycleSelectedIndex(idx: number, n: number, forward: boolean): number { + if (n <= 0) return 0; + if (forward) { + return (idx + 1) % n; + } + return idx === 0 ? n - 1 : idx - 1; +} diff --git a/apps/interfaces/tui/src/git.ts b/apps/interfaces/tui/src/git.ts new file mode 100644 index 0000000..9665b69 --- /dev/null +++ b/apps/interfaces/tui/src/git.ts @@ -0,0 +1,26 @@ +/** + * Git diff preview helper — shells out to `git diff` over the workspace + * roots and returns the combined output text. Mirrors + * `apps/interfaces/tui/src/action/git.rs`. + */ +import { execSync } from "node:child_process"; + +/** Run `git diff` across the given workspace roots and return the output. */ +export function gitDiffOutput(workspaceRoots: string[]): string { + for (const root of workspaceRoots) { + try { + const out = execSync("git diff --stat && git diff", { + cwd: root, + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); + if (out.trim().length > 0) { + return out; + } + } catch { + // Not a git repo or git not available — continue to the next root. + } + } + return "No diff available (not a git repository)."; +} diff --git a/apps/interfaces/tui/src/index.ts b/apps/interfaces/tui/src/index.ts new file mode 100644 index 0000000..1fc1cb1 --- /dev/null +++ b/apps/interfaces/tui/src/index.ts @@ -0,0 +1,31 @@ +/** + * Zesdex TUI interface — logic-pure interactive layer + lightweight renderer. + * Mirrors `apps/interfaces/tui/`. + */ +export type { Action } from "./action.ts"; +export { applyAction } from "./action.ts"; +export type { Command } from "./command.ts"; +export { parseCommand, applyCommand } from "./command.ts"; +export type { KeyEvent } from "./controller.ts"; +export { handleKey, decodeKey, cycleSelectedIndex } from "./controller.ts"; +export { + createTuiState, + AutocompleteKind, + Overlay, + COMMANDS, + DEFAULT_HELP_TEXT, + TranscriptCache, + InputState, + EditorState, + ScrollState, + MiscState, + pushTranscript, + appendToLastTranscript, + makeChatMessage, + toastInfo, + toastError, + turnInFlight, +} from "./state.ts"; +export type { AppStateRest, ChatMessageDisplay } from "./state.ts"; +export { renderFrame } from "./render.ts"; +export { gitDiffOutput } from "./git.ts"; diff --git a/apps/interfaces/tui/src/main.ts b/apps/interfaces/tui/src/main.ts new file mode 100644 index 0000000..130a03e --- /dev/null +++ b/apps/interfaces/tui/src/main.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env bun +/** + * Zesdex TUI — standalone entry point. + * Mirrors the default (TUI) mode of the Rust gateway. + */ +import { runTui } from "./run.ts"; + +void runTui(); diff --git a/apps/interfaces/tui/src/render.ts b/apps/interfaces/tui/src/render.ts new file mode 100644 index 0000000..2f5748e --- /dev/null +++ b/apps/interfaces/tui/src/render.ts @@ -0,0 +1,116 @@ +/** + * 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/apps/interfaces/tui/src/run.ts b/apps/interfaces/tui/src/run.ts new file mode 100644 index 0000000..4404144 --- /dev/null +++ b/apps/interfaces/tui/src/run.ts @@ -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 { + 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((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"); +} diff --git a/apps/interfaces/tui/src/state.ts b/apps/interfaces/tui/src/state.ts new file mode 100644 index 0000000..84fdefd --- /dev/null +++ b/apps/interfaces/tui/src/state.ts @@ -0,0 +1,511 @@ +/** + * TUI state — the single source-of-truth struct for the TUI interface plus + * all the types it owns. Mirrors `apps/interfaces/tui/src/state/**`. + * + * Design: `AppStateRest` is mutated by `applyAction` and read by the renderer + * each frame. Everything here is logic-pure (no terminal I/O), so it is + * unit-testable in Bun. + */ + +import { Roles, type Role } from "@zesdex/domain"; + +/* ── Input state ──────────────────────────────────────────────────── */ + +/** Which source populated the autocomplete dropdown. */ +export const AutocompleteKind = { + Command: "command", + FileMention: "file_mention", +} as const; +export type AutocompleteKind = (typeof AutocompleteKind)[keyof typeof AutocompleteKind]; + +/** Builtin slash-commands recognised by the chat input autocomplete. */ +export const COMMANDS: string[] = [ + "/help", + "/quit", + "/clear", + "/login", + "/login zen", + "/login openai", + "/edit", + "/mcp add", + "/model", + "/model ls", + "/model add", + "/todo", + "/usage", + "/compact", +]; + +/** The user's input buffer, cursor, history, and autocomplete state. */ +export class InputState { + buffer = ""; + cursor = 0; + history: string[] = []; + historyIdx: number | null = null; + autocompleteCandidates: string[] = []; + autocompleteIdx = 0; + autocompleteVisible = false; + autocompleteKind: AutocompleteKind = AutocompleteKind.Command; + mentionStart = 0; + historyFile: string | null = null; + + /** Hide the autocomplete dropdown and clear its state. */ + closeAutocomplete(): void { + this.autocompleteVisible = false; + this.autocompleteCandidates = []; + this.autocompleteIdx = 0; + this.autocompleteKind = AutocompleteKind.Command; + this.mentionStart = 0; + } + + /** Open or refresh the autocomplete dropdown by filtering COMMANDS. */ + openAutocomplete(): void { + const trimmed = this.buffer.trim(); + if (trimmed.length === 0 || !trimmed.startsWith("/")) { + this.closeAutocomplete(); + return; + } + const prefix = trimmed.toLowerCase(); + this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix)); + this.autocompleteKind = AutocompleteKind.Command; + this.autocompleteIdx = 0; + this.autocompleteVisible = this.autocompleteCandidates.length > 0; + } + + /** Find the `@mention` token (if any) immediately before the cursor. */ + mentionQueryAtCursor(): [number, string] | null { + const beforeCursor = this.buffer.slice(0, this.cursor); + const atPos = beforeCursor.lastIndexOf("@"); + if (atPos === -1) return null; + const between = beforeCursor.slice(atPos + 1); + if (/[\s]/.test(between)) return null; + const boundaryOk = + atPos === 0 || /[\s]/.test(beforeCursor[atPos - 1] ?? "") || /\s/.test(beforeCursor[atPos - 1] ?? ""); + if (!boundaryOk) return null; + return [atPos, between]; + } + + /** Open or refresh the `@file` mention dropdown from `files`. */ + openMentionAutocomplete(files: string[]): void { + const q = this.mentionQueryAtCursor(); + if (!q) { + this.closeAutocomplete(); + return; + } + const [, query] = q; + const ql = query.toLowerCase(); + // Simple substring + prefix fuzzy match over the file list. + const matched = files + .filter((f) => { + const fl = f.toLowerCase(); + return fl.includes(ql) || f.replace(/.*\//, "").toLowerCase().startsWith(ql); + }) + .slice(0, 10); + this.autocompleteCandidates = matched; + this.autocompleteKind = AutocompleteKind.FileMention; + this.mentionStart = q[0]; + this.autocompleteIdx = 0; + this.autocompleteVisible = matched.length > 0; + } + + /** Move the autocomplete selection up (false) or down (true). */ + cycleAutocomplete(forward: boolean): void { + const n = this.autocompleteCandidates.length; + if (n === 0) return; + if (forward) { + this.autocompleteIdx = (this.autocompleteIdx + 1) % n; + } else { + this.autocompleteIdx = + this.autocompleteIdx === 0 ? n - 1 : this.autocompleteIdx - 1; + } + } + + /** Accept the currently selected autocomplete candidate. Returns true on success. */ + selectAutocomplete(): boolean { + const candidate = this.autocompleteCandidates[this.autocompleteIdx]; + if (candidate === undefined || candidate === null) return false; + if (this.autocompleteKind === AutocompleteKind.Command) { + this.buffer = candidate; + this.cursor = this.buffer.length; + } else { + if (this.cursor < this.mentionStart || this.mentionStart > this.buffer.length) { + this.closeAutocomplete(); + return false; + } + const replacement = `@${candidate} `; + this.buffer = + this.buffer.slice(0, this.mentionStart) + replacement + this.buffer.slice(this.cursor); + this.cursor = this.mentionStart + replacement.length; + } + this.closeAutocomplete(); + return true; + } + + /** Tab-complete: open dropdown or cycle forward. */ + tabComplete(): void { + if (this.autocompleteVisible) { + this.cycleAutocomplete(true); + } else { + this.openAutocomplete(); + } + } + + /** Insert a character at the cursor position. */ + insert(c: string): void { + this.buffer = this.buffer.slice(0, this.cursor) + c + this.buffer.slice(this.cursor); + this.cursor += c.length; + } + + /** Delete the character to the left of the cursor (backspace). */ + deleteLeft(): void { + if (this.cursor > 0) { + this.cursor -= 1; + this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1); + } + } + + /** Delete the character at the cursor position (forward delete). */ + deleteRight(): void { + if (this.cursor < this.buffer.length) { + this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1); + } + } + + /** Submit the current buffer and return the submitted text. */ + submit(): string { + const result = this.buffer; + if (result.length > 0) { + if (this.history[this.history.length - 1] !== result) { + this.history.push(result); + } + this.historyIdx = null; + } + this.buffer = ""; + this.cursor = 0; + return result; + } + + /** Navigate backward through input history. */ + historyUp(): void { + if (this.history.length === 0) return; + let idx: number; + if (this.historyIdx !== null && this.historyIdx > 0) { + idx = this.historyIdx - 1; + } else if (this.historyIdx === null) { + idx = this.history.length - 1; + } else { + return; + } + this.historyIdx = idx; + this.buffer = this.history[idx] ?? ""; + this.cursor = this.buffer.length; + } + + /** Navigate forward through input history. */ + historyDown(): void { + if (this.historyIdx !== null && this.historyIdx < this.history.length - 1) { + const idx = this.historyIdx + 1; + this.historyIdx = idx; + this.buffer = this.history[idx] ?? ""; + this.cursor = this.buffer.length; + } else if (this.historyIdx !== null) { + this.historyIdx = null; + this.buffer = ""; + this.cursor = 0; + } + // else nothing to do + } +} + +/* ── Overlay ──────────────────────────────────────────────────────── */ + +/** Which modal overlay, if any, is currently shown over the main TUI view. */ +export const Overlay = { + None: "none", + Help: "help", + Settings: "settings", + Bash: "bash", + QuitConfirm: "quit_confirm", + KeyInput: "key_input", + Editor: "editor", + Effort: "effort", + Mcp: "mcp", + Todo: "todo", + Plan: "plan", + Rewind: "rewind", + Learning: "learning", + Usage: "usage", + Loading: "loading", + ModelSelector: "model_selector", + ClearConfirm: "clear_confirm", + Diff: "diff", +} as const; +export type Overlay = (typeof Overlay)[keyof typeof Overlay]; + +export function overlayIsActive(overlay: Overlay): boolean { + return overlay !== Overlay.None; +} + +/* ── Editor state ─────────────────────────────────────────────────── */ + +/** Simple inline editor state for the TUI. */ +export class EditorState { + constructor( + public path: string, + public content: string, + public cursor: number = content.length, + ) {} + + asString(): string { + return this.content; + } + + deleteLeft(): void { + if (this.cursor > 0) { + this.cursor -= 1; + this.content = this.content.slice(0, this.cursor) + this.content.slice(this.cursor + 1); + } + } +} + +/* ── Misc state ───────────────────────────────────────────────────── */ + +/** The "miscellaneous" slice of app state. */ +export class MiscState { + overlay: Overlay = Overlay.None; + toasts: { kind: string; message: string; createdMs: number; ttlMs: number }[] = []; + lastStalenessSweepMs = 0; + thinking = false; + effortLevel = 1; + selectedIndex = 0; + editor: EditorState | null = null; + apiConnected = false; + tickCount = 0; + todoContent = ""; + planContent = ""; + lessonRunning = false; + pendingClipboardCopy: string | null = null; + diffContent = ""; + diffScroll = 0; + + pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void { + this.toasts.push(toast); + } + + drainExpiredToasts(nowMs: number): void { + this.toasts = this.toasts.filter((t) => !(nowMs - t.createdMs >= t.ttlMs)); + } +} + +/* ── Scroll state ─────────────────────────────────────────────────── */ + +/** Viewport scroll state: current offset and visible-line count. */ +export class ScrollState { + offset = 0; + maxVisible = 30; + + scrollUp(amount: number): void { + this.offset += amount; + } + scrollDown(amount: number): void { + this.offset = Math.max(0, this.offset - amount); + } +} + +/* ── Transcript ───────────────────────────────────────────────────── */ + +/** A single transcript entry rendered in the TUI chat pane. */ +export interface ChatMessageDisplay { + role: Role; + content: string; + reasoning: string; + timestamp: number; +} + +export function makeChatMessage(role: Role, content: string): ChatMessageDisplay { + return { role, content, reasoning: "", timestamp: Date.now() }; +} + +/** Bounded ring of recent chat messages used to render the transcript view. */ +export class TranscriptCache { + messages: ChatMessageDisplay[] = []; + maxLines: number; + dirty = true; + + constructor(maxLines = 200) { + this.maxLines = maxLines; + } + + push(msg: ChatMessageDisplay): void { + this.messages.push(msg); + while (this.messages.length > this.maxLines) { + this.messages.shift(); + } + this.dirty = true; + } + + appendToLast(text: string, isReasoning: boolean): void { + const msg = this.messages[this.messages.length - 1]; + if (msg && msg.role === Roles.Assistant) { + if (isReasoning) { + msg.reasoning += text; + } else { + msg.content += text; + } + this.dirty = true; + } + } +} + +/* ── AppStateRest ─────────────────────────────────────────────────── */ + +/** + * The single source-of-truth state struct for the TUI interface. + * Mutated via `applyAction`; read by the renderer every frame. + */ +export interface AppStateRest { + settings: Record; + appConfig: { providers: Record }; + workspaceRoots: string[]; + sessionId: string; + sessionDir: string; + memoryDir: string; + worktreesDir: string; + transcriptCache: TranscriptCache; + scroll: ScrollState; + input: InputState; + misc: MiscState; + turnInFlightFlag: { value: boolean }; + abortFlag: { value: boolean }; + dirty: boolean; + quit: boolean; + helpText: string; + displayLinesCache: string[]; + cachedTokenCount: number; + tokenCountDirty: boolean; + lastRenderWidth: number; + cachedMsgCount: number; + cachedLastStart: number; + cachedLastLen: number; + renderWidthAtCache: number; + mentionFiles: string[]; + pendingSubmit: string | null; // set when a turn should be spawned +} + +export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts + + ─── General ─── + Ctrl+C Quit confirm + Ctrl+D Close overlay + Ctrl+Y Copy last assistant message + Esc Abort turn / Close overlay + Tab Autocomplete + + ─── Navigation ─── + ↑ / ↓ History browse / Overlay navigate + Ctrl+↑/↓ Scroll transcript + PgUp / PgDown Scroll transcript + Enter Submit / Select autocomplete + + ─── Overlays ─── + /help Show this help + /settings Open settings overlay + /todo Open tasks (todo) overlay + /usage Open usage statistics + /bash Open bash jobs overlay + /mcp Open MCP server management + /model Open model selector + /compact Compact conversation + /clear Clear transcript + /rewind Rewind conversation history + + ─── Editor Mode ─── + /edit Open file for inline editing + Ctrl+S Save changes + Esc Dismiss editor`; + +/** Construct fresh TUI state. */ +export function createTuiState( + workspaceRoots: string[], + sessionDir: string, + memoryDir: string, +): AppStateRest { + const sessionId = sessionDir.split(/[/\\]/).filter(Boolean).pop() ?? ""; + const worktreesDir = memoryDir.replace(/[\\/][^\\/]*$/, "") + "/worktrees"; + return { + settings: { provider: "claude" }, + appConfig: { providers: {} }, + workspaceRoots, + sessionId, + sessionDir, + memoryDir, + worktreesDir, + transcriptCache: new TranscriptCache(200), + scroll: new ScrollState(), + input: new InputState(), + misc: new MiscState(), + turnInFlightFlag: { value: false }, + abortFlag: { value: false }, + dirty: true, + quit: false, + helpText: DEFAULT_HELP_TEXT, + displayLinesCache: [], + cachedTokenCount: 0, + tokenCountDirty: true, + lastRenderWidth: 0, + cachedMsgCount: 0, + cachedLastStart: 0, + cachedLastLen: 0, + renderWidthAtCache: 0, + mentionFiles: [], + pendingSubmit: null, + }; +} + +/** Whether an agent turn is currently running. */ +export function turnInFlight(state: AppStateRest): boolean { + return state.turnInFlightFlag.value; +} + +/** Append a message to the transcript. */ +export function pushTranscript(state: AppStateRest, msg: ChatMessageDisplay): void { + state.transcriptCache.push(msg); + state.tokenCountDirty = true; + state.dirty = true; +} + +/** Append text to the last assistant message. */ +export function appendToLastTranscript(state: AppStateRest, text: string, isReasoning: boolean): void { + state.transcriptCache.appendToLast(text, isReasoning); + if (state.transcriptCache.dirty) state.dirty = true; +} + +export function markDirty(state: AppStateRest): void { + state.dirty = true; +} + +export function pushToast( + state: AppStateRest, + toast: { kind: string; message: string; createdMs: number; ttlMs: number }, +): void { + state.misc.pushToast(toast); + state.dirty = true; +} + +export function toastInfo(state: AppStateRest, message: string): void { + pushToast(state, { kind: "info", message, createdMs: Date.now(), ttlMs: 4000 }); +} +export function toastSuccess(state: AppStateRest, message: string): void { + pushToast(state, { kind: "success", message, createdMs: Date.now(), ttlMs: 4000 }); +} +export function toastWarning(state: AppStateRest, message: string): void { + pushToast(state, { kind: "warning", message, createdMs: Date.now(), ttlMs: 4000 }); +} +export function toastError(state: AppStateRest, message: string): void { + pushToast(state, { kind: "error", message, createdMs: Date.now(), ttlMs: 5000 }); +} + +/** Resolve the base directory of the store (grandparent of the session dir). */ +export function storeBaseDir(state: AppStateRest): string { + return state.sessionDir.replace(/[\\/][^\\/]*[\\/][^\\/]*$/, ""); +} diff --git a/apps/interfaces/tui/src/tui.test.ts b/apps/interfaces/tui/src/tui.test.ts new file mode 100644 index 0000000..6bcd94e --- /dev/null +++ b/apps/interfaces/tui/src/tui.test.ts @@ -0,0 +1,127 @@ +/** + * Tests for the TUI logic layer: command parsing, action dispatch, and the + * key controller. Mirrors the Rust `#[cfg(test)]` blocks. + */ +import { describe, test, expect } from "bun:test"; +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 { Roles } from "@zesdex/domain"; + +function freshState() { + return createTuiState(["/tmp/zesdex-tui-test"], "/tmp/zesdex-tui-test/sess", "/tmp/zesdex-tui-test/mem"); +} + +describe("parseCommand", () => { + test("recognises /todo", () => { + expect(parseCommand("/todo")).toEqual({ tag: "TodoOpen" }); + }); + test("recognises /usage", () => { + expect(parseCommand("/usage")).toEqual({ tag: "UsageOpen" }); + }); + test("non-slash input is Unknown", () => { + expect(parseCommand("hello")).toEqual({ tag: "Unknown", text: "hello" }); + }); + test("parses /edit path", () => { + expect(parseCommand("/edit src/a.ts")).toEqual({ tag: "Edit", path: "src/a.ts" }); + }); + test("parses /clear with arg as Clear, without as ClearConfirm", () => { + expect(parseCommand("/clear foo")).toEqual({ tag: "Clear" }); + expect(parseCommand("/clear")).toEqual({ tag: "ClearConfirm" }); + }); +}); + +describe("applyCommand", () => { + test("Help opens help overlay", () => { + expect(applyCommand({ tag: "Help" })).toEqual([{ tag: "OpenOverlay", overlay: "help" }]); + }); + test("Unknown slash produces SystemNote error", () => { + expect(applyCommand({ tag: "Unknown", text: "/nope" })).toEqual([ + { tag: "SystemNote", kind: "error", message: "Unknown command: /nope" }, + ]); + }); +}); + +describe("applyAction", () => { + test("SubmitInput pushes user transcript and sets pendingSubmit", () => { + const s = freshState(); + applyAction(s, { tag: "SubmitInput", text: "hi" }); + const last = s.transcriptCache.messages[s.transcriptCache.messages.length - 1]; + expect(last?.role).toBe(Roles.User); + expect(last?.content).toBe("hi"); + expect(s.pendingSubmit).toBe("hi"); + }); + + test("ForceQuit sets quit", () => { + const s = freshState(); + applyAction(s, { tag: "ForceQuit" }); + expect(s.quit).toBe(true); + }); + + test("QuitConfirm sets overlay", () => { + const s = freshState(); + applyAction(s, { tag: "QuitConfirm" }); + expect(s.misc.overlay).toBe("quit_confirm"); + }); + + test("AbortTurn sets abort flag", () => { + const s = freshState(); + applyAction(s, { tag: "AbortTurn" }); + expect(s.abortFlag.value).toBe(true); + }); +}); + +describe("handleKey", () => { + test("Ctrl+C produces QuitConfirm", () => { + const s = freshState(); + const actions = handleKey({ code: "Char", value: "c", ctrl: true, alt: false }, s); + expect(actions).toEqual([{ tag: "QuitConfirm" }]); + }); + + test("typing chars inserts into buffer", () => { + const s = freshState(); + handleKey({ code: "Char", value: "h", ctrl: false, alt: false }, s); + handleKey({ code: "Char", value: "i", ctrl: false, alt: false }, s); + expect(s.input.buffer).toBe("hi"); + }); + + test("Enter submits input as SubmitInput", () => { + const s = freshState(); + handleKey({ code: "Char", value: "a", ctrl: false, alt: false }, s); + const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s); + expect(actions).toEqual([{ tag: "SubmitInput", text: "a" }]); + }); + + test("Ctrl+Y copies last assistant message", () => { + const s = freshState(); + pushTranscript(s, makeChatMessage(Roles.User, "hi")); + pushTranscript(s, makeChatMessage(Roles.Assistant, "first")); + pushTranscript(s, makeChatMessage(Roles.Tool, "tool")); + pushTranscript(s, makeChatMessage(Roles.Assistant, "second")); + handleKey({ code: "Char", value: "y", ctrl: true, alt: false }, s); + expect(s.misc.pendingClipboardCopy).toBe("second"); + }); + + test("Ctrl+Y with no assistant pushes info toast", () => { + const s = freshState(); + handleKey({ code: "Char", value: "y", ctrl: true, alt: false }, s); + expect(s.misc.pendingClipboardCopy).toBeNull(); + expect(s.misc.toasts.length).toBe(1); + }); +}); + +describe("decodeKey", () => { + test("Enter decodes", () => { + expect(decodeKey("\r")).toEqual({ code: "Enter", ctrl: false, alt: false }); + }); + test("arrow up decodes", () => { + expect(decodeKey("\u001b[A")).toEqual({ code: "ArrowUp", ctrl: false, alt: false }); + }); + test("ctrl-c decodes to ctrl Char c", () => { + expect(decodeKey("\u0003")).toEqual({ code: "Char", value: "c", ctrl: true, alt: false }); + }); + test("plain letter decodes", () => { + expect(decodeKey("x")).toEqual({ code: "Char", value: "x", ctrl: false, alt: false }); + }); +}); diff --git a/bun.lock b/bun.lock index c0d56b0..773e8f0 100644 --- a/bun.lock +++ b/bun.lock @@ -60,6 +60,16 @@ "@types/bun": "^1.2.0", }, }, + "apps/interfaces/tui": { + "name": "@zesdex/tui", + "version": "1.21.2", + "dependencies": { + "@zesdex/domain": "workspace:*", + }, + "devDependencies": { + "@types/bun": "^1.2.0", + }, + }, "apps/interfaces/web": { "name": "@zesdex/web", "version": "1.21.2", @@ -158,6 +168,8 @@ "@zesdex/infrastructure": ["@zesdex/infrastructure@workspace:apps/packages/infrastructure"], + "@zesdex/tui": ["@zesdex/tui@workspace:apps/interfaces/tui"], + "@zesdex/web": ["@zesdex/web@workspace:apps/interfaces/web"], "@zesdex/ws": ["@zesdex/ws@workspace:apps/interfaces/ws"], diff --git a/tsconfig.json b/tsconfig.json index 18fd0d0..16da892 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ "@zesdex/ws": ["./apps/interfaces/ws/src/index.ts"], "@zesdex/grpc": ["./apps/interfaces/grpc/src/index.ts"], "@zesdex/web": ["./apps/interfaces/web/src/index.ts"], - "@zesdex/daemon": ["./apps/interfaces/daemon/src/index.ts"] + "@zesdex/daemon": ["./apps/interfaces/daemon/src/index.ts"], + "@zesdex/tui": ["./apps/interfaces/tui/src/index.ts"] } }, "include": ["apps/packages/*/src", "apps/interfaces/*/src", "apps/interfaces/*/bin"]