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:
+176
-80
@@ -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."));
|
||||
|
||||
Reference in New Issue
Block a user