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

512 lines
16 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";
/* ── 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(/[\\/][^\\/]*[\\/][^\\/]*$/, "");
}