/** * TUI state — the single source-of-truth struct for the TUI interface plus * all the types it owns. Mirrors `apps/interfaces/tui/src/state/**`. * * Design: `AppStateRest` is mutated by `applyAction` and read by the renderer * each frame. Everything here is logic-pure (no terminal I/O), so it is * unit-testable in Bun. */ import { Roles, type Role } from "@zesdex/domain"; /* ── Input state ──────────────────────────────────────────────────── */ /** Which source populated the autocomplete dropdown. */ export const AutocompleteKind = { Command: "command", FileMention: "file_mention", } as const; export type AutocompleteKind = (typeof AutocompleteKind)[keyof typeof AutocompleteKind]; /** Builtin slash-commands recognised by the chat input autocomplete. */ export const COMMANDS: string[] = [ "/help", "/quit", "/clear", "/login", "/login zen", "/login openai", "/edit", "/mcp add", "/model", "/model ls", "/model add", "/todo", "/usage", "/compact", ]; /** The user's input buffer, cursor, history, and autocomplete state. */ export class InputState { buffer = ""; cursor = 0; history: string[] = []; historyIdx: number | null = null; autocompleteCandidates: string[] = []; autocompleteIdx = 0; autocompleteVisible = false; autocompleteKind: AutocompleteKind = AutocompleteKind.Command; mentionStart = 0; historyFile: string | null = null; /** Hide the autocomplete dropdown and clear its state. */ closeAutocomplete(): void { this.autocompleteVisible = false; this.autocompleteCandidates = []; this.autocompleteIdx = 0; this.autocompleteKind = AutocompleteKind.Command; this.mentionStart = 0; } /** Open or refresh the autocomplete dropdown by filtering COMMANDS. */ openAutocomplete(): void { const trimmed = this.buffer.trim(); if (trimmed.length === 0 || !trimmed.startsWith("/")) { this.closeAutocomplete(); return; } const prefix = trimmed.toLowerCase(); this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix)); this.autocompleteKind = AutocompleteKind.Command; this.autocompleteIdx = 0; this.autocompleteVisible = this.autocompleteCandidates.length > 0; } /** Find the `@mention` token (if any) immediately before the cursor. */ mentionQueryAtCursor(): [number, string] | null { const beforeCursor = this.buffer.slice(0, this.cursor); const atPos = beforeCursor.lastIndexOf("@"); if (atPos === -1) return null; const between = beforeCursor.slice(atPos + 1); if (/[\s]/.test(between)) return null; const boundaryOk = atPos === 0 || /[\s]/.test(beforeCursor[atPos - 1] ?? "") || /\s/.test(beforeCursor[atPos - 1] ?? ""); if (!boundaryOk) return null; return [atPos, between]; } /** Open or refresh the `@file` mention dropdown from `files`. */ openMentionAutocomplete(files: string[]): void { const q = this.mentionQueryAtCursor(); if (!q) { this.closeAutocomplete(); return; } const [, query] = q; const ql = query.toLowerCase(); // Simple substring + prefix fuzzy match over the file list. const matched = files .filter((f) => { const fl = f.toLowerCase(); return fl.includes(ql) || f.replace(/.*\//, "").toLowerCase().startsWith(ql); }) .slice(0, 10); this.autocompleteCandidates = matched; this.autocompleteKind = AutocompleteKind.FileMention; this.mentionStart = q[0]; this.autocompleteIdx = 0; this.autocompleteVisible = matched.length > 0; } /** Move the autocomplete selection up (false) or down (true). */ cycleAutocomplete(forward: boolean): void { const n = this.autocompleteCandidates.length; if (n === 0) return; if (forward) { this.autocompleteIdx = (this.autocompleteIdx + 1) % n; } else { this.autocompleteIdx = this.autocompleteIdx === 0 ? n - 1 : this.autocompleteIdx - 1; } } /** Accept the currently selected autocomplete candidate. Returns true on success. */ selectAutocomplete(): boolean { const candidate = this.autocompleteCandidates[this.autocompleteIdx]; if (candidate === undefined || candidate === null) return false; if (this.autocompleteKind === AutocompleteKind.Command) { this.buffer = candidate; this.cursor = this.buffer.length; } else { if (this.cursor < this.mentionStart || this.mentionStart > this.buffer.length) { this.closeAutocomplete(); return false; } const replacement = `@${candidate} `; this.buffer = this.buffer.slice(0, this.mentionStart) + replacement + this.buffer.slice(this.cursor); this.cursor = this.mentionStart + replacement.length; } this.closeAutocomplete(); return true; } /** Tab-complete: open dropdown or cycle forward. */ tabComplete(): void { if (this.autocompleteVisible) { this.cycleAutocomplete(true); } else { this.openAutocomplete(); } } /** Insert a character at the cursor position. */ insert(c: string): void { this.buffer = this.buffer.slice(0, this.cursor) + c + this.buffer.slice(this.cursor); this.cursor += c.length; } /** Delete the character to the left of the cursor (backspace). */ deleteLeft(): void { if (this.cursor > 0) { this.cursor -= 1; this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1); } } /** Delete the character at the cursor position (forward delete). */ deleteRight(): void { if (this.cursor < this.buffer.length) { this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1); } } /** Submit the current buffer and return the submitted text. */ submit(): string { const result = this.buffer; if (result.length > 0) { if (this.history[this.history.length - 1] !== result) { this.history.push(result); } this.historyIdx = null; } this.buffer = ""; this.cursor = 0; return result; } /** Navigate backward through input history. */ historyUp(): void { if (this.history.length === 0) return; let idx: number; if (this.historyIdx !== null && this.historyIdx > 0) { idx = this.historyIdx - 1; } else if (this.historyIdx === null) { idx = this.history.length - 1; } else { return; } this.historyIdx = idx; this.buffer = this.history[idx] ?? ""; this.cursor = this.buffer.length; } /** Navigate forward through input history. */ historyDown(): void { if (this.historyIdx !== null && this.historyIdx < this.history.length - 1) { const idx = this.historyIdx + 1; this.historyIdx = idx; this.buffer = this.history[idx] ?? ""; this.cursor = this.buffer.length; } else if (this.historyIdx !== null) { this.historyIdx = null; this.buffer = ""; this.cursor = 0; } // else nothing to do } } /* ── Overlay ──────────────────────────────────────────────────────── */ /** Which modal overlay, if any, is currently shown over the main TUI view. */ export const Overlay = { None: "none", Help: "help", Settings: "settings", Bash: "bash", QuitConfirm: "quit_confirm", KeyInput: "key_input", Editor: "editor", Effort: "effort", Mcp: "mcp", Todo: "todo", Plan: "plan", Rewind: "rewind", Learning: "learning", Usage: "usage", Loading: "loading", ModelSelector: "model_selector", ClearConfirm: "clear_confirm", Diff: "diff", } as const; export type Overlay = (typeof Overlay)[keyof typeof Overlay]; export function overlayIsActive(overlay: Overlay): boolean { return overlay !== Overlay.None; } /* ── Editor state ─────────────────────────────────────────────────── */ /** Simple inline editor state for the TUI. */ export class EditorState { constructor( public path: string, public content: string, public cursor: number = content.length, ) {} asString(): string { return this.content; } deleteLeft(): void { if (this.cursor > 0) { this.cursor -= 1; this.content = this.content.slice(0, this.cursor) + this.content.slice(this.cursor + 1); } } } /* ── Misc state ───────────────────────────────────────────────────── */ /** The "miscellaneous" slice of app state. */ export class MiscState { overlay: Overlay = Overlay.None; toasts: { kind: string; message: string; createdMs: number; ttlMs: number }[] = []; lastStalenessSweepMs = 0; thinking = false; effortLevel = 1; selectedIndex = 0; editor: EditorState | null = null; apiConnected = false; tickCount = 0; todoContent = ""; planContent = ""; lessonRunning = false; pendingClipboardCopy: string | null = null; diffContent = ""; diffScroll = 0; pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void { this.toasts.push(toast); } drainExpiredToasts(nowMs: number): void { this.toasts = this.toasts.filter((t) => !(nowMs - t.createdMs >= t.ttlMs)); } } /* ── Scroll state ─────────────────────────────────────────────────── */ /** Viewport scroll state: current offset and visible-line count. */ export class ScrollState { offset = 0; maxVisible = 30; scrollUp(amount: number): void { this.offset += amount; } scrollDown(amount: number): void { this.offset = Math.max(0, this.offset - amount); } } /* ── Transcript ───────────────────────────────────────────────────── */ /** A single transcript entry rendered in the TUI chat pane. */ export interface ChatMessageDisplay { role: Role; content: string; reasoning: string; timestamp: number; } export function makeChatMessage(role: Role, content: string): ChatMessageDisplay { return { role, content, reasoning: "", timestamp: Date.now() }; } /** Bounded ring of recent chat messages used to render the transcript view. */ export class TranscriptCache { messages: ChatMessageDisplay[] = []; maxLines: number; dirty = true; constructor(maxLines = 200) { this.maxLines = maxLines; } push(msg: ChatMessageDisplay): void { this.messages.push(msg); while (this.messages.length > this.maxLines) { this.messages.shift(); } this.dirty = true; } appendToLast(text: string, isReasoning: boolean): void { const msg = this.messages[this.messages.length - 1]; if (msg && msg.role === Roles.Assistant) { if (isReasoning) { msg.reasoning += text; } else { msg.content += text; } this.dirty = true; } } } /* ── AppStateRest ─────────────────────────────────────────────────── */ /** * The single source-of-truth state struct for the TUI interface. * Mutated via `applyAction`; read by the renderer every frame. */ export interface AppStateRest { settings: Record; appConfig: { providers: Record }; workspaceRoots: string[]; sessionId: string; sessionDir: string; memoryDir: string; worktreesDir: string; transcriptCache: TranscriptCache; scroll: ScrollState; input: InputState; misc: MiscState; turnInFlightFlag: { value: boolean }; abortFlag: { value: boolean }; dirty: boolean; quit: boolean; helpText: string; displayLinesCache: string[]; cachedTokenCount: number; tokenCountDirty: boolean; lastRenderWidth: number; cachedMsgCount: number; cachedLastStart: number; cachedLastLen: number; renderWidthAtCache: number; mentionFiles: string[]; pendingSubmit: string | null; // set when a turn should be spawned } export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts ─── General ─── Ctrl+C Quit confirm Ctrl+D Close overlay Ctrl+Y Copy last assistant message Esc Abort turn / Close overlay Tab Autocomplete ─── Navigation ─── ↑ / ↓ History browse / Overlay navigate Ctrl+↑/↓ Scroll transcript PgUp / PgDown Scroll transcript Enter Submit / Select autocomplete ─── Overlays ─── /help Show this help /settings Open settings overlay /todo Open tasks (todo) overlay /usage Open usage statistics /bash Open bash jobs overlay /mcp Open MCP server management /model Open model selector /compact Compact conversation /clear Clear transcript /rewind Rewind conversation history ─── Editor Mode ─── /edit Open file for inline editing Ctrl+S Save changes Esc Dismiss editor`; /** Construct fresh TUI state. */ export function createTuiState( workspaceRoots: string[], sessionDir: string, memoryDir: string, ): AppStateRest { const sessionId = sessionDir.split(/[/\\]/).filter(Boolean).pop() ?? ""; const worktreesDir = memoryDir.replace(/[\\/][^\\/]*$/, "") + "/worktrees"; return { settings: { provider: "claude" }, appConfig: { providers: {} }, workspaceRoots, sessionId, sessionDir, memoryDir, worktreesDir, transcriptCache: new TranscriptCache(200), scroll: new ScrollState(), input: new InputState(), misc: new MiscState(), turnInFlightFlag: { value: false }, abortFlag: { value: false }, dirty: true, quit: false, helpText: DEFAULT_HELP_TEXT, displayLinesCache: [], cachedTokenCount: 0, tokenCountDirty: true, lastRenderWidth: 0, cachedMsgCount: 0, cachedLastStart: 0, cachedLastLen: 0, renderWidthAtCache: 0, mentionFiles: [], pendingSubmit: null, }; } /** Whether an agent turn is currently running. */ export function turnInFlight(state: AppStateRest): boolean { return state.turnInFlightFlag.value; } /** Append a message to the transcript. */ export function pushTranscript(state: AppStateRest, msg: ChatMessageDisplay): void { state.transcriptCache.push(msg); state.tokenCountDirty = true; state.dirty = true; } /** Append text to the last assistant message. */ export function appendToLastTranscript(state: AppStateRest, text: string, isReasoning: boolean): void { state.transcriptCache.appendToLast(text, isReasoning); if (state.transcriptCache.dirty) state.dirty = true; } export function markDirty(state: AppStateRest): void { state.dirty = true; } export function pushToast( state: AppStateRest, toast: { kind: string; message: string; createdMs: number; ttlMs: number }, ): void { state.misc.pushToast(toast); state.dirty = true; } export function toastInfo(state: AppStateRest, message: string): void { pushToast(state, { kind: "info", message, createdMs: Date.now(), ttlMs: 4000 }); } export function toastSuccess(state: AppStateRest, message: string): void { pushToast(state, { kind: "success", message, createdMs: Date.now(), ttlMs: 4000 }); } export function toastWarning(state: AppStateRest, message: string): void { pushToast(state, { kind: "warning", message, createdMs: Date.now(), ttlMs: 4000 }); } export function toastError(state: AppStateRest, message: string): void { pushToast(state, { kind: "error", message, createdMs: Date.now(), ttlMs: 5000 }); } /** Resolve the base directory of the store (grandparent of the session dir). */ export function storeBaseDir(state: AppStateRest): string { return state.sessionDir.replace(/[\\/][^\\/]*[\\/][^\\/]*$/, ""); }