#!/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 { 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; // 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); }