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

183 lines
5.9 KiB
TypeScript

#!/usr/bin/env bun
/**
* Zesdex TUI — bootstrapper / composition root.
*
* Wires the real agent runtime (via `runSingleProcess` from the CLI compose
* root) into the OpenTUI app, and installs a `turnRunner` that executes real
* turns against the LLM + tool executor, streaming events into the transcript.
*/
import * as fs from "node:fs";
import { newStore, userMessage } from "@zesdex/domain";
import type { TurnEvent, TurnEventSink } from "@zesdex/agent";
import { runSingleProcess, type WiredRuntime } from "../cli/compose.ts";
import {
createTuiState,
type AppStateRest,
type TurnRunner,
pushTranscript,
makeChatMessage,
appendToLastTranscript,
toastInfo,
toastError,
} from "./state.ts";
import { Roles } from "@zesdex/domain";
import { bootTui } from "./ui.tsx";
/**
* Build the real turn runner from the wired runtime.
*
* Streams every `TurnEvent` into the transcript and updates live UI state
* (status bar token usage, reasoning block, tool call markers, toasts).
*/
function makeTurnRunner(runtime: WiredRuntime, state: AppStateRest): TurnRunner {
return async (text, onEvent) => {
const abort = new AbortController();
state.turnAbort = abort;
// Live sink: forward every event to the UI immediately as it happens.
const sink: TurnEventSink = {
push(ev: TurnEvent) {
handleEvent(state, ev);
onEvent(ev);
},
drain() {
return [];
},
};
const messages = [userMessage(text)];
await runtime.turnService.runTurn({
messages,
session_dir: runtime.store.base_dir,
workspace_roots: [process.cwd()],
turn_events: sink,
in_flight: { value: false },
abort,
api_key: runtime.apiKey,
model: runtime.model,
api_base: runtime.apiBase,
max_tokens: runtime.maxTokens,
temperature: runtime.temperature,
});
};
}
/** Append an entry to the bounded live-activity breadcrumb. */
function addActivity(
state: AppStateRest,
entry: { label: string; detail?: string; kind: "tool" | "agent" | "error" },
): void {
const activity = state.misc.activity;
// Dedupe consecutive repeats of the same tool so `read read read` collapses.
const last = activity[activity.length - 1];
const key = `${entry.label}:${entry.kind}`;
if (last && `${last.label}:${last.kind}` === key && last.detail === entry.detail) {
last.count = (last.count ?? 1) + 1;
} else {
activity.push({ ...entry, count: 1 });
}
// Keep the breadcrumb tight (max 12 entries).
while (activity.length > 12) activity.shift();
state.dirty = true;
}
/** Route one live `TurnEvent` into TUI state (transcript, status bar, toasts). */
function handleEvent(state: AppStateRest, ev: TurnEvent): void {
switch (ev.kind) {
case "stream_token":
appendToLastTranscript(state, ev.content, false);
break;
case "stream_reasoning":
appendToLastTranscript(state, ev.content, true);
break;
case "system_note":
pushTranscript(
state,
makeChatMessage(Roles.System, `[${ev.systemKind}] ${ev.message}`),
);
break;
case "tool_result":
// A tool result is a breadcrumb, not a full transcript entry — keep the
// transcript clean and fold the tool name into the live activity bar.
addActivity(state, {
label: ev.tool_name,
detail: ev.is_error ? "error" : undefined,
kind: ev.is_error ? "error" : "tool",
});
break;
case "usage":
state.lastUsage = { tokensIn: ev.tokens_in, tokensOut: ev.tokens_out };
state.dirty = true;
break;
case "agent_progress":
addActivity(state, {
label: ev.progress.agent_name,
detail: ev.progress.error ?? ev.progress.current_tool ?? undefined,
kind: ev.progress.error ? "error" : "agent",
});
break;
case "workflow_agent_update":
addActivity(state, {
label: ev.agent_name,
detail: ev.error ?? undefined,
kind: ev.error ? "error" : "agent",
});
break;
case "error":
addActivity(state, { label: "error", detail: ev.message, kind: "error" });
toastError(state, `Turn error: ${ev.message}`);
break;
case "compacted":
pushTranscript(state, makeChatMessage(Roles.System, "Conversation compacted."));
break;
case "todo_update":
toastInfo(state, `TODO updated: ${ev.content.slice(0, 80)}`);
break;
case "plan_update":
toastInfo(state, `Plan updated: ${ev.content.slice(0, 80)}`);
break;
default:
break;
}
}
/** Run the interactive OpenTUI with the real agent wired in. */
export async function runTui(): Promise<void> {
const cwd = process.cwd();
// Build the real runtime once (LLM client + tool executor + turn service).
const runtime = await runSingleProcess();
const store = newStore();
void store;
const dataDir = `${process.env.HOME ?? ""}/.local/share/zesdex`;
fs.mkdirSync(dataDir, { recursive: true });
const sessionLabel = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19);
const sessionDir = `${dataDir}/sessions/${sessionLabel}`;
fs.mkdirSync(sessionDir, { recursive: true });
const memoryDir = `${dataDir}/memory`;
const state: AppStateRest = createTuiState([cwd], sessionDir, memoryDir);
state.turnRunner = makeTurnRunner(runtime, state);
state.misc.apiConnected = true;
// Surface the real effective model / provider / session in the header.
const provider = (state.settings.provider as string) || "claude";
state.settings = {
provider,
model: runtime.model,
apiBase: runtime.apiBase ?? "",
sessionId: state.sessionId,
} as Record<string, unknown>;
// Prime the /models list in the background so /model shows live models
// instead of a hardcoded catalog; the menu falls back until it lands.
if (runtime.listModels) {
void runtime.listModels().then((models) => {
if (models.length > 0) {
state.misc.availableModels = models;
state.dirty = true;
}
});
}
await bootTui(state);
}