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