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