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

154 lines
4.8 KiB
TypeScript
Raw Normal View History

#!/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,
});
};
}
/** 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":
pushTranscript(
state,
makeChatMessage(
Roles.Tool,
`→ ${ev.tool_name}${ev.is_error ? " (error)" : ""}`,
),
);
break;
case "usage":
state.lastUsage = { tokensIn: ev.tokens_in, tokensOut: ev.tokens_out };
state.dirty = true;
break;
case "agent_progress":
pushTranscript(
state,
makeChatMessage(
Roles.Tool,
`[${ev.progress.agent_name}] ${ev.progress.status}${ev.progress.error ? ` — ${ev.progress.error}` : ev.progress.current_tool ? ` · ${ev.progress.current_tool}` : ""}`,
),
);
break;
case "workflow_agent_update":
pushTranscript(
state,
makeChatMessage(
Roles.Tool,
`[${ev.agent_name}] ${ev.status}${ev.error ? ` — ${ev.error}` : ""}`,
),
);
break;
case "error":
pushTranscript(state, makeChatMessage(Roles.System, `error: ${ev.message}`));
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>;
await bootTui(state);
}