183 lines
5.9 KiB
TypeScript
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);
|
|
}
|