Files
zesdex/src/interfaces/tui/state.ts
T

708 lines
24 KiB
TypeScript

/**
* 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";
import type { TurnEvent } from "@zesdex/agent";
/**
* Driver for executing a single agent turn in the TUI. Implemented by the
* composition root (`run.ts` → `runSingleProcess`), keeping the TUI logic
* layer free of direct wiring. Returns a promise that resolves when the turn
* (including all tool calls) finishes or is aborted.
*/
export type TurnRunner = (
text: string,
onEvent: (ev: TurnEvent) => void,
) => Promise<void>;
/* ── 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",
"/edit",
"/mcp",
"/model",
"/config",
"/todo",
"/usage",
"/compact",
"/diff",
"/plan",
];
/** 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;
/** Start index of the command token being completed (for in-place replace). */
autocompleteTokenStart = 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;
this.autocompleteTokenStart = 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;
}
// Filter on the *command token* (the word before any space), so typing
// "/edit foo" still matches "/edit". A trailing/embedded space must not
// kill the dropdown.
const word = trimmed.split(/\s+/)[0] ?? "";
const prefix = word.toLowerCase();
if (!prefix.startsWith("/")) {
this.closeAutocomplete();
return;
}
// Prefer exact-match-first so a fully-typed command jumps to the top.
this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix)).sort(
(a, b) => Number(a === word) - Number(b === word),
);
// Record where the command token starts in the *raw buffer* (not the
// trimmed word) so selectAutocomplete can replace just that token.
this.autocompleteTokenStart = this.buffer.indexOf("/");
this.autocompleteKind = AutocompleteKind.Command;
this.autocompleteIdx = 0;
// A fully-typed command needs no dropdown — entering closes it so Enter
// runs the command immediately instead of "selecting" the same string.
const exact = this.autocompleteCandidates.includes(word);
this.autocompleteVisible = this.autocompleteCandidates.length > 0 && !exact;
}
/** 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;
}
}
/** Index just past the command token (stops at a space or end of buffer). */
private endOfToken(): number {
const start = this.autocompleteTokenStart;
if (start < 0 || start > this.buffer.length) return start;
const rest = this.buffer.slice(start);
const space = rest.search(/\s/);
return space === -1 ? this.buffer.length : start + space;
}
/** 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) {
// Replace only the command token (e.g. "/mo" → "/model"), keeping any
// text the user already typed after it (e.g. "/edit path" stays).
const start = this.autocompleteTokenStart;
const end = this.endOfToken();
this.buffer = this.buffer.slice(0, start) + candidate + this.buffer.slice(end);
this.cursor = start + candidate.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;
}
/** Clear the input buffer and reset the cursor. */
clear(): void {
this.buffer = "";
this.cursor = 0;
}
/** 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",
Menu: "menu",
} 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);
}
}
}
/* ── Turn activity ────────────────────────────────────────────────── */
/** A compact, bounded breadcrumb of agent activity during a turn. */
export interface ActivityEntry {
/** Short label, e.g. a tool name or agent name. */
label: string;
/** Optional detail (current tool, error). */
detail?: string;
/** Dramatic colouring: "tool" | "agent" | "error". */
kind: "tool" | "agent" | "error";
/** Number of consecutive repeats (collapsed into one chip). */
count?: number;
}
/* ── Interactive menu ─────────────────────────────────────────────── */
/**
* A single frame in the interactive menu stack. Reusable across every
* configuration surface (models, settings, MCP, todo, …) so each is a
* picker like Claude Code / opencode — never inline sub-commands.
*/
export interface MenuFrame {
/** Stable id used by the resolver to route Enter (e.g. "model:provider"). */
id: string;
title: string;
/** Selectable option labels (one line each). */
items: string[];
/** Optional dim detail shown under each option. */
details?: string[];
/** Footer hint line (keybindings / current value). */
hint?: string;
selected: number;
/** Live type-to-filter: typing narrows/highlights matching rows. */
filter?: string;
}
export const emptyMenuFrame = (): MenuFrame => ({
id: "",
title: "",
items: [],
selected: 0,
});
/* ── 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;
/** Breadcrumb of the current turn's activity (tools / sub-agents). */
activity: ActivityEntry[] = [];
/** Stack of interactive menu frames (top = currently shown). */
menuStack: MenuFrame[] = [];
/** Model ids fetched live from the provider's /models endpoint. */
availableModels: string[] = [];
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;
/** Sliding-window replay cursor for content + reasoning (transient). */
_posContent: number;
_posReasoning: number;
}
export function makeChatMessage(role: Role, content: string): ChatMessageDisplay {
return { role, content, reasoning: "", timestamp: Date.now(), _posContent: 0, _posReasoning: 0 };
}
/** 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 = reconcileStream(msg.reasoning, text, msg, true);
} else {
msg.content = reconcileStream(msg.content, text, msg, false);
}
this.dirty = true;
}
}
}
/**
* Reconcile an incoming stream chunk against the accumulated text, deduping the
* sliding-window replays that 9router's OpenAI-compatible layer emits.
*
* Streaming backends disagree on chunk semantics:
* - Native OpenAI/Anthropic send small *incremental* deltas.
* - 9router/gateways re-send the FULL accumulated text as a repeated window:
* each "wave" replays the already-emitted segments from the start, then one
* fresh token. So the stream is s1,s2,…,sk then s1,s2,…,sk,s_{k+1} then
* s1,…,s_{k+1},s_{k+2} … where s_i + … = the final text.
*
* Naive concatenation doubles the text (garbled output). We track a per-stream
* replay cursor `pos` into `accumulated`: a chunk that continues at `pos` (or
* restarts at 0, i.e. begins a fresh wave) is a replay → ignore it and advance
* the cursor; only a chunk that matches nothing already present is brand-new
* content, which we append at the end and reset the cursor.
*
* @param accumulated current merged text
* @param chunk next chunk from the stream
* @param msg the message being streamed (holds the replay cursor)
* @param isReasoning whether this targets the reasoning buffer or the content
* @returns the updated accumulated text
*/
export function reconcileStream(
accumulated: string,
chunk: string,
msg: ChatMessageDisplay,
isReasoning: boolean,
): string {
if (chunk.length === 0) return accumulated;
const posRef = { pos: isReasoning ? msg._posReasoning : msg._posContent };
if (accumulated.length === 0) {
accumulated = chunk;
posRef.pos = chunk.length;
writePos();
return accumulated;
}
// 1) A chunk that continues the current replay cursor is a replayed segment
// (it already exists earlier in the text) → skip it, advance the cursor.
if (
posRef.pos > 0 &&
posRef.pos < accumulated.length &&
chunk.length <= accumulated.length - posRef.pos &&
accumulated.startsWith(chunk, posRef.pos)
) {
posRef.pos += chunk.length;
writePos();
return accumulated;
}
// 2) A chunk that matches at the very start begins a NEW replay wave → skip
// it, set the cursor to just past the matched prefix.
if (accumulated.startsWith(chunk)) {
posRef.pos = chunk.length;
writePos();
return accumulated;
}
// 3) Otherwise this is brand-new content: append it and reset the cursor so
// the next wave starts fresh.
accumulated += chunk;
posRef.pos = -1;
writePos();
return accumulated;
function writePos(): void {
if (isReasoning) msg._posReasoning = posRef.pos;
else msg._posContent = posRef.pos;
}
}
/* ── 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
/** Real agent turn driver (wired by the composition root). */
turnRunner: TurnRunner | null;
/** Abort controller for the in-flight turn (driven by AbortTurn). */
turnAbort: AbortController | null;
/** Token usage from the most recent turn (surfaced in the status bar). */
lastUsage: { tokensIn: number; tokensOut: number } | null;
/** Monotonic boot time (ms) used for the status-bar uptime + spinner. */
bootedAt: number;
}
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
Alt+D Show git diff
─── Navigation ───
↑ / ↓ History browse / Overlay navigate
Ctrl+↑/↓ Scroll transcript
PgUp / PgDown Scroll transcript
Enter Submit / Select autocomplete
─── Overlays ───
/help Show this help
/model Pick provider → model (interactive)
/config Settings menu (internet, review, provider)
/todo Open tasks (todo) overlay
/usage Open usage statistics
/diff Open git diff overlay
/plan Open plan overlay
/mcp Open MCP server management
/compact Compact conversation
/clear Clear transcript
─── Menus (interactive pickers) ───
↑/↓ · j/k Move selection
PgUp/PgDn Page Home/End — jump
g / G Top / Bottom
type a-z Type-to-filter
Enter Open / apply
Esc Back / close
─── Editor Mode ───
/edit <path> Open file for inline editing
Esc Dismiss editor
─── Legend ───
● running turn is being executed
● idle waiting for input
in:/out: token usage of the latest turn`;
/** 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,
turnRunner: null,
turnAbort: null,
lastUsage: null,
bootedAt: Date.now(),
};
}
/** 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(/[\\/][^\\/]*[\\/][^\\/]*$/, "");
}