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:
@@ -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(/[\\/][^\\/]*[\\/][^\\/]*$/, "");
|
||||
}
|
||||
Reference in New Issue
Block a user