feat(tui): port Fase 4 TUI logic layer — InputState/Action dispatcher/key controller/slash commands + lightweight ANSI renderer + run loop; 20 test
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }];
|
||||
}
|
||||
}
|
||||
@@ -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+<char>
|
||||
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;
|
||||
}
|
||||
@@ -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).";
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
@@ -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<string, string> = {
|
||||
[Roles.User]: "you",
|
||||
[Roles.Assistant]: "zesdex",
|
||||
[Roles.System]: "system",
|
||||
[Roles.Tool]: "tool",
|
||||
};
|
||||
|
||||
/** Render the full TUI frame into an array of lines (with ANSI colours). */
|
||||
export function renderFrame(state: AppStateRest, height: number, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header bar.
|
||||
lines.push(renderHeader(state, width));
|
||||
|
||||
// Transcript pane.
|
||||
const transcriptLines = renderTranscript(state, width);
|
||||
lines.push(...transcriptLines);
|
||||
|
||||
// Padding to fill remaining height.
|
||||
const used = lines.length;
|
||||
for (let i = used; i < height - 3; i++) {
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Overlay content (if any) replaces the input area.
|
||||
if (state.misc.overlay !== Overlay.None) {
|
||||
lines.push(renderOverlay(state, width));
|
||||
} else {
|
||||
lines.push(renderInput(state, width));
|
||||
}
|
||||
|
||||
// Status/footer bar.
|
||||
lines.push(renderStatus(state, width));
|
||||
|
||||
return lines.slice(0, height);
|
||||
}
|
||||
|
||||
function renderHeader(_state: AppStateRest, width: number): string {
|
||||
const title = " zesdex ";
|
||||
const pad = Math.max(0, width - title.length);
|
||||
return `\x1b[1;44m${title}\x1b[0m${" ".repeat(pad)}`;
|
||||
}
|
||||
|
||||
function renderTranscript(state: AppStateRest, width: number): string[] {
|
||||
const out: string[] = [];
|
||||
const messages = state.transcriptCache.messages;
|
||||
// Honour scroll offset from the bottom like a typical chat TUI.
|
||||
const start = Math.max(0, messages.length - 30 - state.scroll.offset);
|
||||
for (let i = start; i < messages.length; i++) {
|
||||
const m = messages[i];
|
||||
if (!m) continue;
|
||||
const label = (roleLabel[m.role] ?? m.role).padEnd(8);
|
||||
const color = m.role === Roles.User ? "\x1b[36m" : m.role === Roles.System ? "\x1b[33m" : "\x1b[32m";
|
||||
out.push(`${color}${label}\x1b[0m ${m.content}`);
|
||||
if (m.reasoning) {
|
||||
out.push(` \x1b[2m(reasoning) ${m.reasoning}\x1b[0m`);
|
||||
}
|
||||
}
|
||||
void width;
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderInput(state: AppStateRest, width: number): string {
|
||||
const prompt = "> ";
|
||||
const shown = state.input.buffer.length > width - prompt.length
|
||||
? state.input.buffer.slice(state.input.buffer.length - (width - prompt.length))
|
||||
: state.input.buffer;
|
||||
let line = `\x1b[1m${prompt}\x1b[0m${shown}`;
|
||||
// Append autocomplete hint if visible.
|
||||
if (state.input.autocompleteVisible && state.input.autocompleteCandidates[state.input.autocompleteIdx]) {
|
||||
const cand = state.input.autocompleteCandidates[state.input.autocompleteIdx];
|
||||
line += ` \x1b[2m[tab: ${cand}]\x1b[0m`;
|
||||
}
|
||||
// Toasts.
|
||||
for (const t of state.misc.toasts.slice(-2)) {
|
||||
line += ` \x1b[3m(${t.message})\x1b[0m`;
|
||||
}
|
||||
return line.length > width ? line.slice(0, width) : line;
|
||||
}
|
||||
|
||||
function renderOverlay(state: AppStateRest, width: number): string {
|
||||
const name = state.misc.overlay;
|
||||
if (name === "diff") {
|
||||
const diffLines = state.misc.diffContent.split("\n");
|
||||
const from = state.misc.diffScroll;
|
||||
return diffLines.slice(from, from + 10).join("\n ");
|
||||
}
|
||||
if (name === "quit_confirm") {
|
||||
return " \x1b[1;31mQuit?\x1b[0m (y=yes) \x1b[2mCtrl+C again / /quit to confirm\x1b[0m";
|
||||
}
|
||||
if (name === "help") {
|
||||
return state.helpText;
|
||||
}
|
||||
void width;
|
||||
return ` \x1b[1mOverlay: ${name}\x1b[0m \x1b[2m(Esc to close)\x1b[0m`;
|
||||
}
|
||||
|
||||
function renderStatus(state: AppStateRest, width: number): string {
|
||||
const left = ` model=${(state.settings as { provider?: string }).provider ?? "?"}`;
|
||||
const right = state.turnInFlightFlag.value
|
||||
? " \x1b[5m● running\x1b[0m"
|
||||
: " \x1b[2m● idle\x1b[0m";
|
||||
const combined = `${left}${right}`;
|
||||
return combined.length > width ? combined.slice(0, width) : combined;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
appConfig: { providers: Record<string, { apiBase?: string; defaultApiKey?: string; apiKeyEnv?: string }> };
|
||||
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 <path> 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(/[\\/][^\\/]*[\\/][^\\/]*$/, "");
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
+2
-1
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user