diff --git a/src/interfaces/tui/run.ts b/src/interfaces/tui/run.ts index 4359196..d75c324 100644 --- a/src/interfaces/tui/run.ts +++ b/src/interfaces/tui/run.ts @@ -8,18 +8,35 @@ */ import * as fs from "node:fs"; import { newStore, userMessage } from "@zesdex/domain"; -import type { TurnEventSink } from "@zesdex/agent"; +import type { TurnEvent, TurnEventSink } from "@zesdex/agent"; import { runSingleProcess, type WiredRuntime } from "../cli/compose.ts"; -import { createTuiState, type AppStateRest, type TurnRunner } from "./state.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. */ -function makeTurnRunner(runtime: WiredRuntime): TurnRunner { +/** + * 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) { + push(ev: TurnEvent) { + handleEvent(state, ev); onEvent(ev); }, drain() { @@ -41,6 +58,70 @@ function makeTurnRunner(runtime: WiredRuntime): TurnRunner { }; } +/** 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 { const cwd = process.cwd(); @@ -57,10 +138,17 @@ export async function runTui(): Promise { const memoryDir = `${dataDir}/memory`; const state: AppStateRest = createTuiState([cwd], sessionDir, memoryDir); - state.turnRunner = makeTurnRunner(runtime); + state.turnRunner = makeTurnRunner(runtime, state); state.misc.apiConnected = true; - // Surface the real effective model/provider in the header. - state.settings = { provider: runtime.model } as Record; + + // 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; await bootTui(state); } diff --git a/src/interfaces/tui/state.ts b/src/interfaces/tui/state.ts index 27b7ec9..a14600e 100644 --- a/src/interfaces/tui/state.ts +++ b/src/interfaces/tui/state.ts @@ -357,15 +357,46 @@ export class TranscriptCache { const msg = this.messages[this.messages.length - 1]; if (msg && msg.role === Roles.Assistant) { if (isReasoning) { - msg.reasoning += text; + msg.reasoning = reconcileStream(msg.reasoning, text); } else { - msg.content += text; + msg.content = reconcileStream(msg.content, text); } this.dirty = true; } } } +/** + * Reconcile an incoming stream chunk against the accumulated text. + * + * Streaming backends disagree on chunk semantics: + * - Native OpenAI/Anthropic send small *incremental* deltas. + * - Some gateways/proxies (e.g. 9router's OpenAI-compatible layer) emit + * *full-text* chunks, where each chunk is the complete transcript so far. + * + * Blindly concatenating full-text chunks duplicates content (partial + full), + * which manifests as garbled messages. This helper accepts both modes: + * - a chunk that is a strict prefix of the tail → a shorter replay → ignore; + * - a chunk that extends/replays the whole accumulated text → take it as the + * new authoritative tail (full-text mode); + * - otherwise → treat as an incremental delta and append. + */ +export function reconcileStream(accumulated: string, chunk: string): string { + if (chunk.length === 0) return accumulated; + if (accumulated.length === 0) return chunk; + if (accumulated.length >= chunk.length && accumulated.startsWith(chunk)) { + // Shorter (or equal) replay of what we already hold — ignore. + return accumulated; + } + if (chunk.length >= accumulated.length && chunk.startsWith(accumulated)) { + // Full-text mode: the chunk is the whole transcript so far (matching our + // accumulated text plus more). Adopt it wholesale. + return chunk; + } + // Incremental delta mode: a new suffix. Append. + return accumulated + chunk; +} + /* ── AppStateRest ─────────────────────────────────────────────────── */ /** @@ -403,6 +434,8 @@ export interface AppStateRest { 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; } export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts @@ -474,6 +507,7 @@ export function createTuiState( pendingSubmit: null, turnRunner: null, turnAbort: null, + lastUsage: null, }; } diff --git a/src/interfaces/tui/tui.test.ts b/src/interfaces/tui/tui.test.ts index 7100028..3a6af64 100644 --- a/src/interfaces/tui/tui.test.ts +++ b/src/interfaces/tui/tui.test.ts @@ -4,7 +4,7 @@ */ import { describe, test, expect } from "bun:test"; import { parseCommand, applyCommand } from "./command.ts"; -import { createTuiState, pushTranscript, makeChatMessage } from "./state.ts"; +import { createTuiState, pushTranscript, makeChatMessage, reconcileStream } from "./state.ts"; import { applyAction } from "./action.ts"; import { handleKey, decodeKey } from "./controller.ts"; import { toControllerKey } from "./ui.tsx"; @@ -163,3 +163,32 @@ describe("toControllerKey (OpenTUI adapter)", () => { }); }); +describe("reconcileStream (full-text vs incremental chunk handling)", () => { + test("appends incremental deltas", () => { + let s = reconcileStream("", "Hello"); + s = reconcileStream(s, "!"); + s = reconcileStream(s, " How"); + expect(s).toBe("Hello! How"); + }); + test("full-text mode: adopts a growing authoritative chunk", () => { + let s = reconcileStream("", "Hello"); + // 9router/proxy resends the *whole* transcript each chunk — adopt it. + s = reconcileStream(s, "Hello world"); + s = reconcileStream(s, "Hello world!"); + expect(s).toBe("Hello world!"); + }); + test("shorter/equal replay of the tail is ignored (no duplication)", () => { + const tail = "Hello world"; + expect(reconcileStream(tail, tail)).toBe(tail); + expect(reconcileStream(tail, "Hello")).toBe(tail); + }); + test("partial-then-full garbled stream resolves to the full text", () => { + let s = reconcileStream("", "Hello! 更新"); + s = reconcileStream(s, "Hello! 更新 Keep going"); + expect(s).toBe("Hello! 更新 Keep going"); + }); + test("empty chunk is a no-op", () => { + expect(reconcileStream("abc", "")).toBe("abc"); + }); +}); + diff --git a/src/interfaces/tui/ui.tsx b/src/interfaces/tui/ui.tsx index 22d8c8e..c1c0629 100644 --- a/src/interfaces/tui/ui.tsx +++ b/src/interfaces/tui/ui.tsx @@ -1,12 +1,17 @@ /** * OpenTUI React bindings for the Zesdex TUI. * - * Renders `AppStateRest` as an OpenTUI layout and routes keyboard input - * through the existing `handleKey` → `applyAction` pipeline. This replaces - * the earlier raw-ANSI line renderer with a proper declarative OpenTUI tree. + * Renders `AppStateRest` as a polished OpenTUI layout and routes keyboard + * input through the existing `handleKey` → `applyAction` pipeline. + * + * Layout (column flex over the full terminal): + * 1. Header bar — app · model · session (blue bar) + * 2. Transcript — role-coloured chat scrollbox (flex-grow) + * 3. Input row — `>` prompt + buffer (same line) + * 4. Status bar — turn state · token usage · hints * * The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`) - * is untouched and remains unit-tested. + * stays logic-pure and unit-tested; this file only reads it for display. */ import { useEffect, useState } from "react"; import { createCliRenderer } from "@opentui/core"; @@ -87,7 +92,22 @@ const roleColor: Record = { [Roles.Tool]: "magenta", }; -const oneLine = (s: string): string => s.replace(/\n/g, " ").replace(/\s+/g, " ").trim(); +/** Short uppercase role tag shown before each message line. */ +const roleTag: Record = { + [Roles.User]: "YOU", + [Roles.Assistant]: "AI", + [Roles.System]: "SYS", + [Roles.Tool]: "TOOL", +}; + +const oneLine = (s: string): string => + s.replace(/\s+$/g, "").replace(/\n+/g, "\n").trim(); + +/** Column-align a pair of header cells across the full width. */ +function spreadCenter(left: string, right: string, width: number): string { + const gap = Math.max(1, width - left.length - right.length); + return left + " ".repeat(gap) + right; +} /** * Root OpenTUI component for the Zesdex TUI. @@ -118,6 +138,8 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R }); const dimensions = useTerminalDimensionsHook(); + const width = dimensions.width ?? 80; + const height = dimensions.height ?? 24; // Turn driver: when a turn is submitted, run it against the real agent and // stream every event into the transcript as it happens. @@ -147,32 +169,10 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R pushTranscript(state, makeChatMessage(Roles.Assistant, "")); try { - await state.turnRunner(captured ?? text, (ev) => { - 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 "error": - pushTranscript(state, makeChatMessage(Roles.System, `error: ${ev.message}`)); - break; - case "tool_result": - pushTranscript( - state, - makeChatMessage(Roles.Tool, `⇄ ${ev.tool_name} · ${ev.is_error ? "err" : "ok"}`), - ); - break; - default: - break; - } + await state.turnRunner(captured ?? text, () => { + // The turn runner already streams into state via handleEvent; + // re-render as new tokens arrive. + setTick((t: number) => t + 1); }); } finally { state.turnInFlightFlag.value = false; @@ -185,81 +185,153 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R }, 0); } }; - const timer = setInterval(() => poll(), 120); + const timer = setInterval(() => poll(), 100); return () => clearInterval(timer); }, [state, setTick]); - const model = String(state.settings.provider ?? "?"); - const header = ` zesdex · ${model} `; + const model = String(state.settings.model ?? state.settings.provider ?? "?"); + const sessionId = String(state.settings.sessionId ?? state.sessionId ?? ""); + const headerLeft = ` zesdex · ${model} `; + const headerRight = sessionId ? ` session:${sessionId} ` : " "; + const header = spreadCenter(headerLeft, headerRight, width); + const messages = state.transcriptCache.messages; const scrollFromEnd = messages.length - state.scroll.offset; const visible = messages.slice(Math.max(0, scrollFromEnd - 40), scrollFromEnd); + const running = state.turnInFlightFlag.value; + return ( - + {/* Header */} - + {header} {/* Transcript pane */} - + {visible.map((m, i) => ( - - {`${(m.role ?? "?").padEnd(9)} `} - {oneLine(m.content)} - + ))} - {/* Reasoning block for the last assistant message (dimmed). */} - {messages.length > 0 && - (() => { - const last = messages[messages.length - 1]; - if (last && last.role === Roles.Assistant && last.reasoning.length > 0) { - return ( - - {`reasoning ${oneLine(last.reasoning)}`} - - ); - } - return null; - })()} + {messages.length === 0 && ( + — no messages yet. Type a message below or /help. — + )} - {/* Overlay or input area */} - {state.misc.overlay !== "none" ? : } + {/* Autocomplete dropdown (shown above the input row) */} + {state.input.autocompleteVisible && } - {/* Status footer */} - - {state.turnInFlightFlag.value ? "● running" : "● idle"} - + {/* Overlay or input row */} + {state.misc.overlay !== "none" ? ( + + ) : ( + + )} + + {/* Status bar */} + ); } -function InputView(props: { state: AppStateRest }): ReactNode { - const { state } = props; - const shown = state.input.buffer.slice( - Math.max(0, state.input.cursor - 40), - state.input.cursor + 20, - ); - const hint = - state.input.autocompleteVisible && - state.input.autocompleteCandidates[state.input.autocompleteIdx] != null - ? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]` - : ""; - const toast = - state.misc.toasts.length > 0 - ? ` (${state.misc.toasts[state.misc.toasts.length - 1]?.message})` - : ""; +/** Word-wrap a string to a fixed width (used for plain-text messages). */ +function wrapText(text: string, width: number): string { + const lines: string[] = []; + for (const raw of text.split("\n")) { + // Never wrap an empty (or whitespace-only) line. + if (raw.length === 0) { + lines.push(""); + continue; + } + let line = ""; + for (const word of raw.split(/(\s+)/)) { + if (word === "\n") { + if (line) lines.push(line); + line = ""; + continue; + } + if (line.length + word.length > width) { + if (line) lines.push(line); + line = word; // long word spills; next iteration handles it + } else { + line += word; + } + } + if (line) lines.push(line); + } + return lines.join("\n"); +} + +/** One chat message rendered in the transcript. */ +function TranscriptMessage(props: { + msg: AppStateRest["transcriptCache"]["messages"][number]; + width: number; +}): ReactNode { + const { msg, width } = props; + const color = roleColor[msg.role] ?? "white"; + const tag = roleTag[msg.role] ?? msg.role?.toUpperCase() ?? "?"; + // Assistant messages legitimately have no body while a tool call is being + // made (or mid-stream) — don't fill that with a misleading placeholder. + const content = + msg.role === Roles.Assistant && !msg.content.trim() + ? "" + : msg.content || "(empty)"; + + // Each message is a labelled block: a coloured role tag line followed by the + // word-wrapped body. Plain `` is used (not ``) so live + // streaming re-renders a single clean string — no overlap artifacts, and the + // body fills the full transcript width. return ( - {"> "} - {shown} - {hint}{toast} + {` ${tag.padEnd(4)} `} + {content.length > 0 && ( + {wrapText(content, Math.max(20, width - 4))} + )} + {msg.reasoning.length > 0 && ( + {` ⋯ ${oneLine(msg.reasoning)}`} + )} ); } +/** Bottom input row: `>` prompt + buffer on one line. */ +function InputView(props: { state: AppStateRest; width: number }): ReactNode { + const { state, width } = props; + const shown = state.input.buffer.slice( + Math.max(0, state.input.cursor - Math.max(10, width - 12)), + Math.max(0, state.input.cursor) + 40, + ); + return ( + + {"> "} + {shown} + + ); +} + +/** Autocomplete / file-mention dropdown rendered as a bordered panel. */ +function AutocompleteView(props: { state: AppStateRest }): ReactNode { + const { state } = props; + const candidates = state.input.autocompleteCandidates; + const kind = state.input.autocompleteKind; + const title = kind === "command" ? "commands" : "files"; + return ( + + + {` ${title} (↑/↓ navigate, Tab select, Esc close)\n`} + + {candidates.slice(0, 10).map((c, i) => ( + + {i === state.input.autocompleteIdx ? "▸ " : " "} + {c} + {i < candidates.slice(0, 10).length - 1 ? "\n" : ""} + + ))} + + ); +} + +/** Full-screen modal overlays (help, diff, editor, model selector, etc.). */ function OverlayView(props: { state: AppStateRest }): ReactNode { const { state } = props; const name = state.misc.overlay; @@ -274,12 +346,36 @@ function OverlayView(props: { state: AppStateRest }): ReactNode { else body = `Overlay: ${name} (Esc to close)`; return ( - {body.split("\n").map((l, i) => ({l}))} - {name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"} + {body} + + {name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"} + ); } +/** Bottom status bar: turn state · token usage · live toast/hints. */ +function StatusBar(props: { state: AppStateRest; running: boolean }): ReactNode { + const { state, running } = props; + const left = running ? "● running" : "● idle"; + const usage = state.lastUsage; + const usageStr = usage + ? `in:${usage.tokensIn} · out:${usage.tokensOut}` + : "in:– · out:–"; + // Autocomplete hint for the active input. + const ac = + state.input.autocompleteVisible && + state.input.autocompleteCandidates[state.input.autocompleteIdx] != null + ? ` [tab: ${state.input.autocompleteCandidates[state.input.autocompleteIdx]}]` + : ""; + // Most recent live toast (expires via MiscState). + const toasts = state.misc.toasts; + const toast = toasts.length > 0 ? ` (${toasts[toasts.length - 1]?.message})` : ""; + const hints = running ? "Esc abort" : "/help · Ctrl+C quit · ↑ history"; + const cell = ` ${left} ${usageStr}${ac}${toast} ${hints} `; + return {cell}; +} + /** Boot the OpenTUI renderer and mount the Zesdex app. */ export async function bootTui(state: AppStateRest): Promise { pushTranscript(state, makeChatMessage(Roles.System, "Zesdex ready. Type a message or /help."));