fix(tui): repair layout overflow + streaming dup; polish UI/state

Two real bugs squashed:
- Layout: giving the header box and scrollbox an explicit width in a
  column-flex root made OpenTUI overflow vertically, pushing the input
  row and status bar entirely off-screen (only header+one message showed).
  Root cause confirmed in a minimal probe; fix = drop the redundant
  width so column children size off the root box.
- Streaming: 9router's OpenAI-compatible layer emits FULL-TEXT chunks
  (the whole transcript so far) instead of incremental deltas, so naive
  content+=token duplicated text ("Hello! 👋 How canHello! 👋 How can...").
  Added reconcileStream() that accepts both incremental and full-text
  modes (shorter-replay → ignore, growing-authoritative → adopt, else
  append), unit-tested for both.

UI/state polish:
- Rich transcript: per-role colour + labelled blocks (YOU/AI/SYS/TOOL),
  word-wrapped to terminal width, reasoning dimmed, no misleading
  "(empty)" on tool-calling assistant messages.
- Header: app · real model · session, spread across full width.
- Status bar: ● running/idle, live token usage (in/out), autocomplete
  hint, active toast.
- Bordered autocomplete dropdown + full-width help/overlay panels.
- Turn runner streams all TurnEvents into state (tool markers, agent
  progress, workflow updates, toasts, usage) — ui.tsx is now display-only.
This commit is contained in:
asepharyana
2026-09-03 11:00:29 +07:00
parent 23e68dc979
commit a54f1644f5
4 changed files with 338 additions and 91 deletions
+96 -8
View File
@@ -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<void> {
const cwd = process.cwd();
@@ -57,10 +138,17 @@ export async function runTui(): Promise<void> {
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<string, unknown>;
// 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);
}
+36 -2
View File
@@ -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,
};
}
+30 -1
View File
@@ -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");
});
});
+176 -80
View File
@@ -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<string, string> = {
[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<string, string> = {
[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 (
<box flexDirection="column" width={dimensions.width} height={dimensions.height}>
<box flexDirection="column" width={width} height={height}>
{/* Header */}
<box width={dimensions.width} backgroundColor="blue">
<box backgroundColor="blue">
<text fg="white">{header}</text>
</box>
{/* Transcript pane */}
<scrollbox flexGrow={1} width={dimensions.width} style={{ backgroundColor: "#0b0f14" }}>
<scrollbox flexGrow={1} style={{ backgroundColor: "#0b0f14" }}>
{visible.map((m, i) => (
<text key={`${m.timestamp}-${i}`} fg={roleColor[m.role] ?? "white"}>
{`${(m.role ?? "?").padEnd(9)} `}
{oneLine(m.content)}
</text>
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
))}
{/* 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 (
<text fg="gray">
{`reasoning ${oneLine(last.reasoning)}`}
</text>
);
}
return null;
})()}
{messages.length === 0 && (
<text fg="gray"> — no messages yet. Type a message below or /help. —</text>
)}
</scrollbox>
{/* Overlay or input area */}
{state.misc.overlay !== "none" ? <OverlayView state={state} /> : <InputView state={state} />}
{/* Autocomplete dropdown (shown above the input row) */}
{state.input.autocompleteVisible && <AutocompleteView state={state} />}
{/* Status footer */}
<text fg={state.turnInFlightFlag.value ? "green" : "gray"}>
{state.turnInFlightFlag.value ? "● running" : "● idle"}
</text>
{/* Overlay or input row */}
{state.misc.overlay !== "none" ? (
<OverlayView state={state} />
) : (
<InputView state={state} width={width} />
)}
{/* Status bar */}
<StatusBar state={state} running={running} />
</box>
);
}
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 `<text>` is used (not `<markdown>`) so live
// streaming re-renders a single clean string — no overlap artifacts, and the
// body fills the full transcript width.
return (
<box flexDirection="column" width="100%">
<text fg="cyan">{"> "}</text>
<text>{shown}</text>
<text fg="gray">{hint}{toast}</text>
<text fg={color}>{` ${tag.padEnd(4)} `}</text>
{content.length > 0 && (
<text fg={color}>{wrapText(content, Math.max(20, width - 4))}</text>
)}
{msg.reasoning.length > 0 && (
<text fg="gray">{` ⋯ ${oneLine(msg.reasoning)}`}</text>
)}
</box>
);
}
/** 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 (
<box flexDirection="row" width="100%">
<text fg="cyan">{"> "}</text>
<text fg="white">{shown}</text>
</box>
);
}
/** 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 (
<box width="100%" style={{ border: true, padding: 0 }}>
<text fg="magenta">
{` ${title} (↑/↓ navigate, Tab select, Esc close)\n`}
</text>
{candidates.slice(0, 10).map((c, i) => (
<text key={c} fg={i === state.input.autocompleteIdx ? "cyan" : "white"}>
{i === state.input.autocompleteIdx ? "▸ " : " "}
{c}
{i < candidates.slice(0, 10).length - 1 ? "\n" : ""}
</text>
))}
</box>
);
}
/** 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 (
<box flexDirection="column" width="100%" style={{ border: true, padding: 1 }}>
<text fg="yellow">{body.split("\n").map((l, i) => (<text key={i}>{l}</text>))}</text>
<text fg="gray">{name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"}</text>
<text fg="yellow">{body}</text>
<text fg="gray">
{name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"}
</text>
</box>
);
}
/** 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 <text fg={toast ? "yellow" : running ? "green" : "gray"}>{cell}</text>;
}
/** Boot the OpenTUI renderer and mount the Zesdex app. */
export async function bootTui(state: AppStateRest): Promise<void> {
pushTranscript(state, makeChatMessage(Roles.System, "Zesdex ready. Type a message or /help."));