feat(tui): enhance model selection and improve UI components

- Added tests for handling key events in model selector, including Enter and ArrowDown functionality.
- Updated TUI layout to include a new header, activity bar, and improved status bar with spinner and uptime.
- Refactored transcript rendering to support markdown and improved message display.
- Introduced overlays for model selection, help, and editor with appropriate rendering logic.
- Implemented smoke tests for various TUI states to ensure UI integrity and functionality.
This commit is contained in:
asepharyana
2026-09-03 16:13:29 +07:00
parent 5912a35bbb
commit c53fdfa7c9
6 changed files with 896 additions and 220 deletions
+628 -174
View File
@@ -5,10 +5,11 @@
* 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
* 1. Header bar — logo · model · session · live turn spinner
* 2. Transcript — role-coloured chat scrollbox (markdown-lite, flex-grow)
* 3. Activity bar — collapsed breadcrumb of the running turn's tools/agents
* 4. Input row — `❯` prompt + buffer + cursor
* 5. Status bar — turn state · token usage · uptime · toast · hints
*
* The logic layer (`state.ts`, `action.ts`, `command.ts`, `controller.ts`)
* stays logic-pure and unit-tested; this file only reads it for display.
@@ -23,7 +24,7 @@ import {
import type { KeyEvent as OpenTuiKey, CliRenderer } from "@opentui/core";
import type { ReactNode } from "react";
import type { AppStateRest } from "./state.ts";
import type { AppStateRest, ChatMessageDisplay } from "./state.ts";
import { pushTranscript, makeChatMessage, appendToLastTranscript } from "./state.ts";
import { Roles } from "@zesdex/domain";
import { handleKey, KeyCode } from "./controller.ts";
@@ -84,13 +85,24 @@ export function toControllerKey(key: MinimalKey): ZelKeyEvent {
}
}
/** Role label mapped to a colour for the transcript pane. */
const roleColor: Record<string, string> = {
[Roles.User]: "cyan",
[Roles.Assistant]: "green",
[Roles.System]: "yellow",
[Roles.Tool]: "magenta",
};
/* ── Palette ──────────────────────────────────────────────────────── */
/** Zesdex brand colours — a calm, readable "deep ocean" scheme. */
const C = {
bg: "#0d1117", // deep off-navy transcript ground
surface: "#161b22", // panel / secondary background
border: "#2a3340", // dim hairline separators
fg: "#c9d1d9", // primary foreground
dim: "#6e7681", // muted text
cyan: "#58a6ff", // user / accents
green: "#3fb950", // assistant
yellow: "#d29922", // system
magenta: "#bc8cff", // agent / tools
red: "#f85149", // errors
headerA: "#0d47a1", // header gradient start (deep blue)
headerB: "#1f3a5f", // header gradient end
accent: "#58a6ff", // prompt / highlight
} as const;
/** Short uppercase role tag shown before each message line. */
const roleTag: Record<string, string> = {
@@ -100,8 +112,13 @@ const roleTag: Record<string, string> = {
[Roles.Tool]: "TOOL",
};
const oneLine = (s: string): string =>
s.replace(/\s+$/g, "").replace(/\n+/g, "\n").trim();
/** Distinct colour per role — used for badges + body tint. */
const roleColor: Record<string, string> = {
[Roles.User]: C.cyan,
[Roles.Assistant]: C.green,
[Roles.System]: C.yellow,
[Roles.Tool]: C.magenta,
};
/** Column-align a pair of header cells across the full width. */
function spreadCenter(left: string, right: string, width: number): string {
@@ -109,6 +126,201 @@ function spreadCenter(left: string, right: string, width: number): string {
return left + " ".repeat(gap) + right;
}
/** Pad a string to at least `n` chars, clipping if longer. */
function pad(s: string, n: number): string {
return s.length >= n ? s : s + " ".repeat(n - s.length);
}
/* ── Spinner ──────────────────────────────────────────────────────── */
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/** Pick a spinner glyph that animates with the tick counter. */
function spinner(state: AppStateRest): string {
const frame = Math.floor(state.misc.tickCount / 2) % SPINNER_FRAMES.length;
return SPINNER_FRAMES[frame] ?? "⠋";
}
/* ── Markdown-lite rendering ──────────────────────────────────────── */
/** One styled run of text within a wrapped line. */
interface Run {
text: string;
fg?: string;
bold?: boolean;
}
/**
* Parse a modest Markdown subset into styled runs, then word-wrap them to a
* fixed width with a left indent. Keeps layout fully under our control (vs the
* self-drawing `<markdown>` node) so streaming + indent stay stable.
*
* Supported: **bold**, `inline code`, #headers, - bullets, > quotes, tables
* are untouched (rare in agent output). Code fences are rendered as
* single-colour blocks.
*/
function renderMarkdown(text: string, indent: number, width: number): Run[][] {
const rows: Run[][] = [];
const bodyWidth = Math.max(10, width - indent);
const wrap = new Wrap(bodyWidth);
for (const raw of stripFences(text).split("\n")) {
const line = raw.replace(/\s+$/, "");
if (line.trim().length === 0) {
wrap.break();
continue;
}
if (/^#{1,6}\s+/.test(line)) {
const seg: Run = { text: line.replace(/^#{1,6}\s+/, "").trim(), bold: true, fg: C.cyan };
wrap.add([seg]);
continue;
}
if (/^[-*]\s+/.test(line)) {
// Bullet: prefix glyph, then inline-parse the body so **bold**/`code`
// inside list items render too.
wrap.add([{ text: "• ", fg: C.fg }, ...inlineRuns(line.replace(/^[-*]\s+/, ""))]);
continue;
}
if (/^>\s?/.test(line)) {
wrap.add([{ text: "▍ ", fg: C.dim }, ...inlineRuns(line.replace(/^>\s?/, ""))]);
continue;
}
const runs = inlineRuns(line);
wrap.add(runs);
}
// Flatten wrapped rows into the output.
for (const row of wrap.rows()) {
rows.push(row);
}
return rows;
}
/** Render plain text (user / system / tool bodies) with wrapping + indent. */
function renderPlain(text: string, indent: number, width: number): Run[][] {
const bodyWidth = Math.max(10, width - indent);
const wrap = new Wrap(bodyWidth);
for (const raw of text.split("\n")) {
wrap.add([{ text: raw.replace(/\s+$/, "") }]);
}
return wrap.rows();
}
/** A line-oriented wrapper that accumulates runs and breaks on width. */
class Wrap {
private out: Run[][] = [];
private cur: Run[] = [];
private curLen = 0;
constructor(private width: number) {}
add(runs: Run[]): void {
for (const r of runs) this.pushRun(r);
this.break();
}
private pushRun(r: Run): void {
if (this.curLen + r.text.length > this.width && this.cur.length > 0) {
this.break();
}
if (r.text.length === 0) return;
this.cur.push(r);
this.curLen += r.text.length;
}
break(): void {
if (this.cur.length > 0) {
this.out.push(this.cur);
this.cur = [];
this.curLen = 0;
}
}
rows(): Run[][] {
this.break();
return this.out;
}
}
/** Tokenise a line into bold / inline-code / plain runs. */
function inlineRuns(line: string): Run[] {
const runs: Run[] = [];
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) {
if (m.index > last) runs.push({ text: line.slice(last, m.index) });
const tok = m[0];
if (tok.startsWith("**")) {
runs.push({ text: tok.slice(2, -2), bold: true, fg: C.fg });
} else {
runs.push({ text: tok.slice(1, -1), fg: C.magenta });
}
last = m.index + tok.length;
}
if (last < line.length) runs.push({ text: line.slice(last) });
return runs.length ? runs : [{ text: line }];
}
/** Neutralise fenced code blocks so inline parsing stays clean (kept as plain). */
function stripFences(text: string): string {
return text.replace(/```[a-zA-Z0-9]*\n?/g, "");
}
/* ── Transcript rendering ─────────────────────────────────────────── */
/** One chat message rendered in the transcript. */
function TranscriptMessage(props: {
msg: ChatMessageDisplay;
width: number;
}): ReactNode {
const { msg, width } = props;
const color = roleColor[msg.role] ?? C.fg;
const tag = roleTag[msg.role] ?? msg.role?.toUpperCase() ?? "?";
const isAssistant = msg.role === Roles.Assistant;
// 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 =
isAssistant && !msg.content.trim() ? "" : msg.content || "(empty)";
const rows: Run[][] =
msg.role === Roles.Assistant && content.length > 0
? renderMarkdown(content, 5, width)
: renderPlain(content, 5, width);
return (
<box flexDirection="column" width="100%">
{/* Role badge header line */}
<text fg={color}>
<b>{` ${pad(`[${tag}]`, 6)}`}</b>
</text>
{/* Body — each wrapped line is a <text> whose styled runs are <span>/<b> */}
{rows.map((row, i) => (
<text key={i} fg={C.fg}>
{row.map((r, j) => {
const text = r.text;
if (r.bold) {
return (
<b key={j} fg={r.fg ?? color}>
{text}
</b>
);
}
return (
<span key={j} fg={r.fg ?? color}>
{text}
</span>
);
})}
</text>
))}
{/* Live reasoning (dimmed, kept to one line) */}
{msg.reasoning.length > 0 && (
<text fg={C.dim}>{` ⋯ ${oneLine(msg.reasoning)}`}</text>
)}
{/* Thin separator between messages */}
<text fg={C.border}>{` ${"─".repeat(Math.max(0, Math.min(60, width - 4)))}`}</text>
</box>
);
}
const oneLine = (s: string): string =>
s.replace(/\s+$/g, "").replace(/\n+/g, "\n").trim();
/**
* Root OpenTUI component for the Zesdex TUI.
* @param state The mutable AppStateRest (mutated by applyAction).
@@ -132,6 +344,11 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
applyAction(state, act);
}
} finally {
// Clear input buffer after command dispatch (commands like /model, /help
// open overlays but don't clear the buffer via submit()).
if (zk.code === KeyCode.Enter && state.input.buffer.length > 0) {
state.input.clear();
}
// Force React to re-read the mutated `state` on the next frame.
setTick((t: number) => t + 1);
}
@@ -140,6 +357,7 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
const dimensions = useTerminalDimensionsHook();
const width = dimensions.width ?? 80;
const height = dimensions.height ?? 24;
const running = state.turnInFlightFlag.value;
// Turn driver: when a turn is submitted, run it against the real agent and
// stream every event into the transcript as it happens.
@@ -178,8 +396,10 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
state.turnInFlightFlag.value = false;
state.turnAbort = null;
state.abortFlag.value = false;
// Re-prime the next poll so queued turns run.
// Keep the last activity breadcrumb visible briefly, then clear it on
// the next turn start (see the start of poll()).
setTimeout(() => {
state.misc.activity = [];
turnRunning = false;
setTick((x: number) => x + 1);
}, 0);
@@ -189,207 +409,440 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
return () => clearInterval(timer);
}, [state, setTick]);
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);
// Tick the spinner + uptime while idle too (so ● running animates and the
// uptime clock in the status bar advances even between key presses).
useEffect(() => {
if (!running) return;
const id = setInterval(() => {
state.misc.tickCount += 1;
state.dirty = true;
setTick((t: number) => t + 1);
}, 120);
return () => clearInterval(id);
}, [running, state, setTick]);
const messages = state.transcriptCache.messages;
const scrollFromEnd = messages.length - state.scroll.offset;
const visible = messages.slice(Math.max(0, scrollFromEnd - 40), scrollFromEnd);
const overlayActive = state.misc.overlay !== "none";
const running = state.turnInFlightFlag.value;
// Modal overlays take the full body (below the header, above the status bar),
// replacing the transcript + input so their content is never cramped.
const body = overlayActive ? (
<OverlayView state={state} width={width} height={height} />
) : (
<box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
<scrollbox
flexGrow={1}
flexShrink={1}
minHeight={0}
style={{ backgroundColor: C.bg }}
stickyScroll
stickyStart="bottom"
>
<TranscriptView state={state} width={width} />
</scrollbox>
{/* Autocomplete dropdown (shown above the input row) */}
{state.input.autocompleteVisible && <AutocompleteView state={state} width={width} />}
{/* Live activity breadcrumb of the running turn */}
<ActivityBar state={state} running={running} />
<InputView state={state} width={width} />
</box>
);
return (
<box flexDirection="column" width={width} height={height}>
{/* Header */}
<box backgroundColor="blue">
<text fg="white">{header}</text>
</box>
{/* Transcript pane */}
<scrollbox flexGrow={1} style={{ backgroundColor: "#0b0f14" }}>
{visible.map((m, i) => (
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
))}
{messages.length === 0 && (
<text fg="gray"> — no messages yet. Type a message below or /help. —</text>
)}
</scrollbox>
{/* Autocomplete dropdown (shown above the input row) */}
{state.input.autocompleteVisible && <AutocompleteView state={state} />}
{/* Overlay or input row */}
{state.misc.overlay !== "none" ? (
<OverlayView state={state} />
) : (
<InputView state={state} width={width} />
)}
<HeaderView state={state} running={running} width={width} />
{body}
{/* Status bar */}
<StatusBar state={state} running={running} />
<StatusBar state={state} running={running} width={width} />
</box>
);
}
/** 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");
}
/* ── Header ───────────────────────────────────────────────────────── */
/** One chat message rendered in the transcript. */
function TranscriptMessage(props: {
msg: AppStateRest["transcriptCache"]["messages"][number];
function HeaderView(props: {
state: AppStateRest;
running: boolean;
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)";
const bodyWidth = Math.max(20, width - 6);
const { state, running, width } = props;
const model = String(state.settings.model ?? state.settings.provider ?? "?");
const sessionId = String(state.settings.sessionId ?? state.sessionId ?? "");
const shortId = sessionId.length > 12 ? sessionId.slice(0, 11) : sessionId;
const glyph = running ? spinner(state) : "◆";
const left = ` ${glyph} zesdex · ${model} `;
const right = sessionId ? ` session:${shortId} ` : " ";
const header = spreadCenter(left, right, width);
const bg = running ? C.headerA : C.headerB;
return (
<box flexDirection="column" width="100%">
{/* Role tag header line */}
<text fg={color}>{` [${tag}]`}</text>
{/* Indented, word-wrapped body */}
{content.length > 0 && (
<text fg={msg.role === Roles.Assistant ? "default" : color}>
{indent(content, 4, bodyWidth)}
</text>
)}
{/* Live reasoning (dimmed, kept to one line) */}
{msg.reasoning.length > 0 && (
<text fg="gray">{` ⋯ ${oneLine(msg.reasoning)}`}</text>
)}
{/* Thin separator between messages */}
<text fg="#2a3340">{` ${"─".repeat(Math.max(0, Math.min(60, width - 4)))}`}</text>
</box>
);
}
/** Indent every wrapped line of `text` by `pad` spaces within `width` columns. */
function indent(text: string, pad: number, width: number): string {
return wrapText(text, width)
.split("\n")
.map((l) => (l.length ? " ".repeat(pad) + l : ""))
.join("\n");
}
/** Bottom input row: `❯` prompt + buffer on one line, with a subtle divider. */
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="column" width="100%">
<text fg="#2a3340">{" " + "─".repeat(Math.max(0, width - 4))}</text>
<box flexDirection="row" width="100%">
<text fg="cyan">{" ❯ "}</text>
<text fg="white">{shown}</text>
<text fg="gray">{"▏"}</text>
</box>
</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`}
<box backgroundColor={bg}>
<text fg={running ? C.green : "white"}>
<b>{header}</b>
</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>
);
}
/* ── Transcript ───────────────────────────────────────────────────── */
function TranscriptView(props: { state: AppStateRest; width: number }): ReactNode {
const { state, width } = props;
const messages = state.transcriptCache.messages;
const scrollFromEnd = messages.length - state.scroll.offset;
const visible = messages.slice(
Math.max(0, scrollFromEnd - 40),
scrollFromEnd,
);
if (messages.length === 0) {
return (
<text fg={C.dim}>
{`\n ─ no messages yet. Type a message below (or /help) ─`}
</text>
);
}
return (
<box flexDirection="column">
{visible.map((m, i) => (
<TranscriptMessage key={`${m.timestamp}-${i}`} msg={m} width={width} />
))}
</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;
let body = "";
if (name === "quit_confirm") body = "Quit? (y=yes, n=no) Ctrl+C again / /quit to confirm";
else if (name === "help") body = state.helpText;
else if (name === "diff")
body = state.misc.diffContent
.split("\n")
.slice(state.misc.diffScroll, state.misc.diffScroll + 20)
.join("\n");
else body = `Overlay: ${name} (Esc to close)`;
/* ── Activity bar (running turn) ──────────────────────────────────── */
function ActivityBar(props: { state: AppStateRest; running: boolean }): ReactNode {
const { state, running } = props;
const activity = state.misc.activity;
if (!running || activity.length === 0) return null;
const line = ` ${spinner(state)} ${activity.map((a) => label(a)).join(" · ")} `;
return (
<box flexDirection="column" width="100%" style={{ border: true, padding: 1 }}>
<text fg="yellow">{body}</text>
<text fg="gray">
{name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"}
<box flexDirection="column" width="100%" style={{ backgroundColor: C.surface }}>
<text fg={C.magenta}>
{line}
</text>
</box>
);
function label(a: { label: string; kind: string; detail?: string; count?: number }): string {
const sym = a.kind === "error" ? "✕" : a.kind === "agent" ? "⧉" : "⚙";
const n = a.count && a.count > 1 ? `×${a.count}` : "";
return `${sym} ${a.label}${n}`;
}
}
/* ── Input row ────────────────────────────────────────────────────── */
function InputView(props: { state: AppStateRest; width: number }): ReactNode {
const { state, width } = props;
const buffer = state.input.buffer;
const cursor = state.input.cursor;
const shown = buffer.slice(
Math.max(0, cursor - Math.max(10, width - 14)),
cursor + 40,
);
const hasContent = buffer.length > 0;
return (
<box flexDirection="column" width="100%" flexShrink={0}>
<text fg={C.border}>{`─`.repeat(Math.max(0, width))}</text>
<box flexDirection="row" width="100%" flexShrink={0}>
<text fg={C.accent}>
<b>{` ❯ `}</b>
</text>
{hasContent ? (
<text fg={C.fg} flexGrow={1}>{shown}</text>
) : (
<text fg={C.dim} flexGrow={1}>
Ask Zesdex to help…
</text>
)}
<text fg={C.accent} flexShrink={0}>{` ▏`}</text>
</box>
</box>
);
}
/** Bottom status bar: turn state · token usage · live toast/hints. */
function StatusBar(props: { state: AppStateRest; running: boolean }): ReactNode {
const { state, running } = props;
/* ── Autocomplete ─────────────────────────────────────────────────── */
function AutocompleteView(props: { state: AppStateRest; width: number }): ReactNode {
const { state, width } = props;
const candidates = state.input.autocompleteCandidates;
const kind = state.input.autocompleteKind;
const title = kind === "command" ? "commands" : "files";
const selected = Math.min(state.input.autocompleteIdx, candidates.length - 1);
const visible = candidates.slice(0, 10);
return (
<box
width="100%"
style={{
border: true,
borderColor: C.border,
backgroundColor: C.surface,
padding: 1,
}}
>
<text fg={C.magenta}>
<b>{` ${title}`}</b>
</text>
{visible.map((c, i) => (
<text key={c} fg={i === selected ? C.accent : C.fg}>
{i === selected ? `${"▸ "}${c}${" ".repeat(Math.max(0, width - c.length - 4))}` : ` ${c}`}
</text>
))}
<text fg={C.dim}>{` ↑/↓ navigate · Tab accept · Esc close`}</text>
</box>
);
}
/* ── Overlays ─────────────────────────────────────────────────────── */
function OverlayView(props: {
state: AppStateRest;
width: number;
height: number;
}): ReactNode {
const { state } = props;
const name = state.misc.overlay;
switch (name) {
case "quit_confirm":
return (
<ConfirmOverlay
title="Quit Zesdex?"
body=" Are you sure you want to quit?\n\n Any in-flight turn will be aborted."
hint="y=yes n=no Esc=cancel"
/>
);
case "clear_confirm":
return (
<ConfirmOverlay
title="Clear transcript?"
body=" This clears the visible conversation from the transcript view."
hint="y=yes n=no Esc=cancel"
/>
);
case "help":
return <HelpOverlay state={state} />;
case "diff":
return <DiffOverlay state={state} />;
case "model_selector":
return <ModelSelectorOverlay state={state} />;
case "editor":
return <EditorOverlay state={state} />;
default:
return <PlaceholderOverlay name={name} state={state} />;
}
}
function ConfirmOverlay(props: { title: string; body: string; hint: string }): ReactNode {
const { title, body, hint } = props;
return (
<OverlayFrame title={title}>
<text fg={C.fg}>{body}</text>
<text fg={C.dim}>{` ${hint}`}</text>
</OverlayFrame>
);
}
function HelpOverlay(props: { state: AppStateRest }): ReactNode {
const { state } = props;
return (
<OverlayFrame title="Keyboard Shortcuts">
<text fg={C.fg}>{state.helpText}</text>
<text fg={C.dim}>{" Esc or /help to close"}</text>
</OverlayFrame>
);
}
function DiffOverlay(props: { state: AppStateRest }): ReactNode {
const { state } = props;
const lines = state.misc.diffContent.split("\n");
const start = state.misc.diffScroll;
const slice = lines.slice(start, start + 18);
return (
<OverlayFrame title="Git Diff">
<scrollbox flexGrow={1} stickyScroll stickyStart="bottom">
{slice.map((l, i) => {
const fg = l.startsWith("+")
? C.green
: l.startsWith("-")
? C.red
: l.startsWith("@@")
? C.cyan
: C.dim;
return (
<text key={i} fg={fg}>
{l.length ? l : " "}
</text>
);
})}
</scrollbox>
<text fg={C.dim}>
{` ↑/↓ / PgUp/PgDn scroll · Esc close — ${Math.min(start + 1, lines.length)}/${lines.length}`}
</text>
</OverlayFrame>
);
}
function ModelSelectorOverlay(props: { state: AppStateRest }): ReactNode {
const { state } = props;
const names = Object.keys(state.appConfig.providers);
const list = names.length ? names : ["claude", "zen", "router"];
const idx = Math.min(Math.max(0, state.misc.selectedIndex), list.length - 1);
const current = String(state.settings.provider ?? "");
const selected = idx;
const body = list
.map((p, i) => {
const isSel = i === selected;
const active = p === current;
const suffix = active ? " (active)" : "";
return `${isSel ? "▸ " : " "}${pad(p, 15)}${suffix}`;
})
.join("\n");
return (
<OverlayFrame title="Select Model / Provider">
<text fg={C.fg}>
{body}
</text>
<text fg={C.dim}>{" ↑/↓ select · Enter apply · Esc close"}</text>
</OverlayFrame>
);
}
function EditorOverlay(props: { state: AppStateRest }): ReactNode {
const { state } = props;
const ed = state.misc.editor;
if (!ed) return <OverlayFrame title="Editor"><text fg={C.fg}>No buffer.</text></OverlayFrame>;
const lines = ed.content.split("\n");
const start = Math.max(0, ed.cursor - 5);
const slice = lines.slice(start, start + 12);
return (
<OverlayFrame title={`Edit ${ed.path}`}>
{slice.map((l, i) => (
<text key={i} fg={C.fg}>
{`${String(start + i + 1).padStart(3)} | ${l}`}
</text>
))}
<text fg={C.dim}>
{" type to edit · Enter newline · Esc save & close"}
</text>
</OverlayFrame>
);
}
function PlaceholderOverlay(props: { name: string; state: AppStateRest }): ReactNode {
const { name, state } = props;
const content =
name === "todo"
? state.misc.todoContent
: name === "plan"
? state.misc.planContent
: "";
const summary =
name === "todo" ? "todo list" :
name === "plan" ? "plan outline" :
name === "usage" ? "usage statistics" :
name === "mcp" ? "MCP server management" :
name === "settings" ? "settings" :
name === "bash" ? "bash jobs" :
name === "rewind" ? "rewind history" :
name === "learning" ? "learned patterns" :
"overlay";
return (
<OverlayFrame title={`${name} — ${summary}`}>
{content ? (
<text fg={C.fg}>{content}</text>
) : (
<text fg={C.dim}>{` ${summary}: nothing yet.`}</text>
)}
<text fg={C.dim}>{" Esc to close"}</text>
</OverlayFrame>
);
}
function OverlayFrame(props: { title: string; children: ReactNode }): ReactNode {
const { title, children } = props;
return (
<box
width="100%"
flexDirection="column"
flexGrow={1}
flexShrink={0}
style={{
border: true,
borderColor: C.accent,
padding: 1,
backgroundColor: C.surface,
}}
title={title}
titleAlignment="center"
>
{children}
</box>
);
}
/* ── Status bar ───────────────────────────────────────────────────── */
function StatusBar(props: {
state: AppStateRest;
running: boolean;
width: number;
}): ReactNode {
const { state, running, width } = props;
const left = running ? "● running" : "● idle";
const usage = state.lastUsage;
const usageStr = usage
? `in:${usage.tokensIn} · out:${usage.tokensOut}`
: "in:– · out:–";
// Uptime since boot.
const uptime = fmtUptime((Date.now() - state.bootedAt) / 1000);
// 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>;
const statusColor = toast
? C.yellow
: running
? C.green
: C.dim;
const activity = running ? ` ${spinner(state)}` : "";
const cell = ` ${left}${activity} ${usageStr} ${uptime}${ac}${toast} ${hints} `;
return (
<box backgroundColor={C.surface}>
<text fg={statusColor}>{trimTo(cell, width)}</text>
</box>
);
}
/** Clip a status line to the available width (avoid overflow). */
function trimTo(s: string, width: number): string {
if (s.length <= width) return s;
return s.slice(0, Math.max(0, width - 1)) + "…";
}
/** Format seconds as a compact human uptime (e.g. 1m23s). */
function fmtUptime(sec: number): string {
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}h${String(m % 60).padStart(2, "0")}m`;
if (m > 0) return `${m}m${String(s % 60).padStart(2, "0")}s`;
return `${s}s`;
}
/** Boot the OpenTUI renderer and mount the Zesdex app. */
@@ -416,3 +869,4 @@ export async function bootTui(state: AppStateRest): Promise<void> {
}, 60);
});
}