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
+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,
};
}