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:
asepharyana
2026-09-02 22:50:38 +07:00
parent 2e81412605
commit 8098133ba5
13 changed files with 1637 additions and 1 deletions
+16
View File
@@ -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"
}
}
+200
View File
@@ -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);
}
+113
View File
@@ -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 }];
}
}
+330
View File
@@ -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;
}
+26
View File
@@ -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).";
}
+31
View File
@@ -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";
+8
View File
@@ -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();
+116
View File
@@ -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;
}
+145
View File
@@ -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");
}
+511
View File
@@ -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(/[\\/][^\\/]*[\\/][^\\/]*$/, "");
}
+127
View File
@@ -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 });
});
});
+12
View File
@@ -60,6 +60,16 @@
"@types/bun": "^1.2.0", "@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": { "apps/interfaces/web": {
"name": "@zesdex/web", "name": "@zesdex/web",
"version": "1.21.2", "version": "1.21.2",
@@ -158,6 +168,8 @@
"@zesdex/infrastructure": ["@zesdex/infrastructure@workspace:apps/packages/infrastructure"], "@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/web": ["@zesdex/web@workspace:apps/interfaces/web"],
"@zesdex/ws": ["@zesdex/ws@workspace:apps/interfaces/ws"], "@zesdex/ws": ["@zesdex/ws@workspace:apps/interfaces/ws"],
+2 -1
View File
@@ -27,7 +27,8 @@
"@zesdex/ws": ["./apps/interfaces/ws/src/index.ts"], "@zesdex/ws": ["./apps/interfaces/ws/src/index.ts"],
"@zesdex/grpc": ["./apps/interfaces/grpc/src/index.ts"], "@zesdex/grpc": ["./apps/interfaces/grpc/src/index.ts"],
"@zesdex/web": ["./apps/interfaces/web/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"] "include": ["apps/packages/*/src", "apps/interfaces/*/src", "apps/interfaces/*/bin"]