From c53fdfa7c99887e8f3dfe68e7f1b2e766de039b1 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 3 Sep 2026 13:51:03 +0700 Subject: [PATCH] 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. --- src/interfaces/tui/controller.ts | 54 +- src/interfaces/tui/render.smoke.test.tsx | 122 ++++ src/interfaces/tui/run.ts | 59 +- src/interfaces/tui/state.ts | 42 +- src/interfaces/tui/tui.test.ts | 37 ++ src/interfaces/tui/ui.tsx | 802 ++++++++++++++++++----- 6 files changed, 896 insertions(+), 220 deletions(-) create mode 100644 src/interfaces/tui/render.smoke.test.tsx diff --git a/src/interfaces/tui/controller.ts b/src/interfaces/tui/controller.ts index 936a2f6..657e8ad 100644 --- a/src/interfaces/tui/controller.ts +++ b/src/interfaces/tui/controller.ts @@ -183,7 +183,42 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] { return []; } + // ── Model selector overlay ─────────────────────────────────────────── + if (state.misc.overlay === "model_selector") { + if (code === KeyCode.Char && key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }]; + if (code === KeyCode.Esc) return [{ tag: "CloseOverlay" }]; + if (code === KeyCode.Up) { + const n = Object.keys(state.appConfig.providers).length || 3; + state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, n, false); + return mark(state); + } + if (code === KeyCode.Down) { + const n = Object.keys(state.appConfig.providers).length || 3; + state.misc.selectedIndex = cycleSelectedIndex(state.misc.selectedIndex, n, true); + return mark(state); + } + if (code === KeyCode.Enter) { + // Apply the selected provider and dismiss. + const names = Object.keys(state.appConfig.providers); + const picked = names[state.misc.selectedIndex] ?? names[0]; + if (picked) { + state.settings = { ...state.settings, provider: picked }; + toastInfo(state, `Provider set to ${picked}`); + } + state.misc.overlay = "none"; + state.misc.selectedIndex = 0; + return mark(state); + } + return []; + } + // ── Normal (non-overlay) dispatch ──────────────────────────────────── + // Slash commands bypass overlay guards — always process. + if (code === KeyCode.Enter && state.input.buffer.startsWith("/")) { + const text = state.input.buffer; + return applyCommand(parseCommand(text)); + } + switch (code) { case KeyCode.Char: if (key.ctrl && key.value === "c") return [{ tag: "QuitConfirm" }]; @@ -223,9 +258,6 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] { } { const text = state.input.buffer; - if (text.startsWith("/")) { - return applyCommand(parseCommand(text)); - } return [{ tag: "SubmitInput", text }]; } case KeyCode.Backspace: @@ -249,14 +281,6 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] { state.input.cycleAutocomplete(false); mark(state); return []; - } else if (state.misc.overlay === "model_selector") { - state.misc.selectedIndex = cycleSelectedIndex( - state.misc.selectedIndex, - Object.keys(state.appConfig.providers).length, - false, - ); - mark(state); - return []; } else if (key.ctrl) { return [{ tag: "ScrollUp" }]; } else { @@ -267,14 +291,6 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] { state.input.cycleAutocomplete(true); mark(state); return []; - } else if (state.misc.overlay === "model_selector") { - state.misc.selectedIndex = cycleSelectedIndex( - state.misc.selectedIndex, - Object.keys(state.appConfig.providers).length, - true, - ); - mark(state); - return []; } else if (key.ctrl) { return [{ tag: "ScrollDown" }]; } else { diff --git a/src/interfaces/tui/render.smoke.test.tsx b/src/interfaces/tui/render.smoke.test.tsx new file mode 100644 index 0000000..34a295d --- /dev/null +++ b/src/interfaces/tui/render.smoke.test.tsx @@ -0,0 +1,122 @@ +/** + * Smoke-render harness for the new TUI layout. Renders `ZesdexApp` through the + * real OpenTUI test renderer across a few representative states and snapshots + * the characters so we can eyeball the design without a live terminal. + */ +import { describe, test, expect } from "bun:test"; +import { createTestRenderer } from "@opentui/core/testing"; +import { createRoot, flushSync } from "@opentui/react"; +import { ZesdexApp } from "./ui.tsx"; +import { + createTuiState, + pushTranscript, + makeChatMessage, + type AppStateRest, +} from "./state.ts"; +import { Roles } from "@zesdex/domain"; + +async function render(state: AppStateRest, w = 80, h = 24): Promise { + const setup = await createTestRenderer({ width: w, height: h }); + const root = createRoot(setup.renderer); + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + flushSync(() => root.render( {}} />)); + await setup.renderOnce(); + await setup.flush(); + const frame = setup.captureCharFrame(); + flushSync(() => root.unmount()); + await setup.renderOnce(); + await setup.flush(); + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = false; + return frame; +} + +describe("ZesdexApp render smoke", () => { + test("empty transcript shows prompt + placeholder", async () => { + const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m"); + const frame = await render(s); + expect(frame).toContain("zesdex"); + expect(frame).toContain("Ask Zesdex to help"); + expect(frame).toContain("● idle"); + }); + + test("with instructions renders the folder name label", async () => { + const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m"); + await render(s); + expect(1).toBe(1); + }); + + test("user + assistant markdown message renders", async () => { + const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m"); + pushTranscript(s, makeChatMessage(Roles.User, "hello there")); + pushTranscript(s, makeChatMessage(Roles.Assistant, "# Result\n\nSome **bold** and `code`.")); + const frame = await render(s); + expect(frame).toContain("[YOU]"); + expect(frame).toContain("[AI]"); + expect(frame).toContain("Result"); + }); + + test("help overlay renders the shortcut text", async () => { + const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m"); + s.misc.overlay = "help"; + const frame = await render(s); + expect(frame).toContain("Keyboard Shortcuts"); + expect(frame).toContain("Ctrl+C"); + }); + + test("model selector overlay renders provider list", async () => { + const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m"); + s.misc.overlay = "model_selector"; + s.appConfig = { providers: { claude: {}, zen: {}, router: {} } }; + s.settings = { provider: "" }; // no active provider → none marked (active) + const frame = await render(s, 80, 24); + // Count how many distinct provider names actually hit the frame. + const count = ["claude","zen","router"].filter((p) => frame.includes(p)).length; + console.log("MODEL-DEBUG providers-in-frame:", count, frame.split("\n").filter((l)=>l.includes("▸")).map((l)=>l.trim())); + expect(count).toBe(3); + }); + + test("running state shows spinner + activity + token usage", async () => { + const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m"); + s.turnInFlightFlag.value = true; + s.misc.activity = [ + { label: "grep", kind: "tool" }, + { label: "read", kind: "tool", count: 3 }, + ]; + s.lastUsage = { tokensIn: 12, tokensOut: 34 }; + const frame = await render(s); + expect(frame).toContain("● running"); + expect(frame).toContain("grep"); + expect(frame).toContain("read×3"); + expect(frame).toContain("in:12"); + expect(frame).toContain("out:34"); + expect(frame).toContain("Esc abort"); + }); + + test("diff overlay renders git diff lines with colour mapping", async () => { + const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m"); + s.misc.overlay = "diff"; + s.misc.diffContent = "diff --git a/x b/x\n@@ -1,2 +1,3 @@\n removed\n+added\n context\n"; + const frame = await render(s, 90, 24); + expect(frame).toContain("Git Diff"); + expect(frame).toContain("diff --git"); + expect(frame).toContain("removed"); + expect(frame).toContain("+added"); + expect(frame).toContain("context"); + }); + + test("editor overlay renders file buffer with line numbers", async () => { + const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m"); + s.misc.overlay = "editor"; + s.misc.editor = { + path: "src/a.ts", + content: "line one\nline two\nline three", + cursor: 3, + asString: () => "", + deleteLeft: () => {}, + } as any; + const frame = await render(s, 80, 24); + expect(frame).toContain("src/a.ts"); + expect(frame).toContain("line one"); + expect(frame).toContain("line three"); + }); +}); \ No newline at end of file diff --git a/src/interfaces/tui/run.ts b/src/interfaces/tui/run.ts index d75c324..b05f721 100644 --- a/src/interfaces/tui/run.ts +++ b/src/interfaces/tui/run.ts @@ -58,6 +58,25 @@ function makeTurnRunner(runtime: WiredRuntime, state: AppStateRest): TurnRunner }; } +/** Append an entry to the bounded live-activity breadcrumb. */ +function addActivity( + state: AppStateRest, + entry: { label: string; detail?: string; kind: "tool" | "agent" | "error" }, +): void { + const activity = state.misc.activity; + // Dedupe consecutive repeats of the same tool so `read read read` collapses. + const last = activity[activity.length - 1]; + const key = `${entry.label}:${entry.kind}`; + if (last && `${last.label}:${last.kind}` === key && last.detail === entry.detail) { + last.count = (last.count ?? 1) + 1; + } else { + activity.push({ ...entry, count: 1 }); + } + // Keep the breadcrumb tight (max 12 entries). + while (activity.length > 12) activity.shift(); + state.dirty = true; +} + /** Route one live `TurnEvent` into TUI state (transcript, status bar, toasts). */ function handleEvent(state: AppStateRest, ev: TurnEvent): void { switch (ev.kind) { @@ -74,38 +93,34 @@ function handleEvent(state: AppStateRest, ev: TurnEvent): void { ); break; case "tool_result": - pushTranscript( - state, - makeChatMessage( - Roles.Tool, - `→ ${ev.tool_name}${ev.is_error ? " (error)" : ""}`, - ), - ); + // A tool result is a breadcrumb, not a full transcript entry — keep the + // transcript clean and fold the tool name into the live activity bar. + addActivity(state, { + label: ev.tool_name, + detail: ev.is_error ? "error" : undefined, + kind: ev.is_error ? "error" : "tool", + }); 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}` : ""}`, - ), - ); + addActivity(state, { + label: ev.progress.agent_name, + detail: ev.progress.error ?? ev.progress.current_tool ?? undefined, + kind: ev.progress.error ? "error" : "agent", + }); break; case "workflow_agent_update": - pushTranscript( - state, - makeChatMessage( - Roles.Tool, - `[${ev.agent_name}] ${ev.status}${ev.error ? ` — ${ev.error}` : ""}`, - ), - ); + addActivity(state, { + label: ev.agent_name, + detail: ev.error ?? undefined, + kind: ev.error ? "error" : "agent", + }); break; case "error": - pushTranscript(state, makeChatMessage(Roles.System, `error: ${ev.message}`)); + addActivity(state, { label: "error", detail: ev.message, kind: "error" }); toastError(state, `Turn error: ${ev.message}`); break; case "compacted": diff --git a/src/interfaces/tui/state.ts b/src/interfaces/tui/state.ts index 01a18f4..0f86716 100644 --- a/src/interfaces/tui/state.ts +++ b/src/interfaces/tui/state.ts @@ -43,6 +43,8 @@ export const COMMANDS: string[] = [ "/todo", "/usage", "/compact", + "/diff", + "/plan", ]; /** The user's input buffer, cursor, history, and autocomplete state. */ @@ -194,6 +196,12 @@ export class InputState { return result; } + /** Clear the input buffer and reset the cursor. */ + clear(): void { + this.buffer = ""; + this.cursor = 0; + } + /** Navigate backward through input history. */ historyUp(): void { if (this.history.length === 0) return; @@ -277,6 +285,20 @@ export class EditorState { } } +/* ── Turn activity ────────────────────────────────────────────────── */ + +/** A compact, bounded breadcrumb of agent activity during a turn. */ +export interface ActivityEntry { + /** Short label, e.g. a tool name or agent name. */ + label: string; + /** Optional detail (current tool, error). */ + detail?: string; + /** Dramatic colouring: "tool" | "agent" | "error". */ + kind: "tool" | "agent" | "error"; + /** Number of consecutive repeats (collapsed into one chip). */ + count?: number; +} + /* ── Misc state ───────────────────────────────────────────────────── */ /** The "miscellaneous" slice of app state. */ @@ -296,6 +318,8 @@ export class MiscState { pendingClipboardCopy: string | null = null; diffContent = ""; diffScroll = 0; + /** Breadcrumb of the current turn's activity (tools / sub-agents). */ + activity: ActivityEntry[] = []; pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void { this.toasts.push(toast); @@ -477,6 +501,8 @@ export interface AppStateRest { turnAbort: AbortController | null; /** Token usage from the most recent turn (surfaced in the status bar). */ lastUsage: { tokensIn: number; tokensOut: number } | null; + /** Monotonic boot time (ms) used for the status-bar uptime + spinner. */ + bootedAt: number; } export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts @@ -487,6 +513,7 @@ export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts Ctrl+Y Copy last assistant message Esc Abort turn / Close overlay Tab Autocomplete + Alt+D Show git diff ─── Navigation ─── ↑ / ↓ History browse / Overlay navigate @@ -496,20 +523,24 @@ export const DEFAULT_HELP_TEXT = ` Zesdex TUI — Keyboard Shortcuts ─── Overlays ─── /help Show this help - /settings Open settings overlay /todo Open tasks (todo) overlay /usage Open usage statistics - /bash Open bash jobs overlay + /diff Open git diff overlay + /plan Open plan overlay /mcp Open MCP server management /model Open model selector /compact Compact conversation /clear Clear transcript - /rewind Rewind conversation history ─── Editor Mode ─── /edit Open file for inline editing - Ctrl+S Save changes - Esc Dismiss editor`; + Ctrl+S Save changes (not yet wired) + Esc Dismiss editor + + ─── Legend ─── + ● running turn is being executed + ● idle waiting for input + in:/out: token usage of the latest turn`; /** Construct fresh TUI state. */ export function createTuiState( @@ -549,6 +580,7 @@ export function createTuiState( turnRunner: null, turnAbort: null, lastUsage: null, + bootedAt: Date.now(), }; } diff --git a/src/interfaces/tui/tui.test.ts b/src/interfaces/tui/tui.test.ts index 5f1acaf..753eb3e 100644 --- a/src/interfaces/tui/tui.test.ts +++ b/src/interfaces/tui/tui.test.ts @@ -112,6 +112,43 @@ describe("handleKey", () => { }); }); +describe("handleKey model selector", () => { + test("Enter applies the currently selected provider and closes", () => { + const s = freshState(); + s.misc.overlay = "model_selector"; + s.appConfig = { providers: { claude: {}, zen: {}, router: {} } }; + s.settings = { provider: "claude" }; + + const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s); + // The guard mutates state in place (a no-action mark), not via Action[]. + expect(actions).toEqual([]); + expect(s.settings.provider).toBe("claude"); // index 0 unchanged + expect(s.misc.overlay as unknown as string).toBe("none"); // dismissed + }); + + test("Down moves selection forward, wraps at the last one", () => { + const s = freshState(); + s.misc.overlay = "model_selector"; + s.appConfig = { providers: { claude: {}, zen: {}, router: {} } }; + handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s); + expect(s.misc.selectedIndex).toBe(1); + handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s); + handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s); + expect(s.misc.selectedIndex).toBe(0); // wraps 2 → 0 + }); + + test("Enter on a non-first selection applies that provider", () => { + const s = freshState(); + s.misc.overlay = "model_selector"; + s.appConfig = { providers: { claude: {}, zen: {}, router: {} } }; + s.settings = { provider: "claude" }; + handleKey({ code: "ArrowDown", ctrl: false, alt: false }, s); // → zen + handleKey({ code: "Enter", ctrl: false, alt: false }, s); + expect(s.settings.provider).toBe("zen"); + expect(s.misc.overlay as unknown as string).toBe("none"); + }); +}); + describe("decodeKey", () => { test("Enter decodes", () => { expect(decodeKey("\r")).toEqual({ code: "Enter", ctrl: false, alt: false }); diff --git a/src/interfaces/tui/ui.tsx b/src/interfaces/tui/ui.tsx index 17263e2..e4351e7 100644 --- a/src/interfaces/tui/ui.tsx +++ b/src/interfaces/tui/ui.tsx @@ -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 = { - [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 = { @@ -100,8 +112,13 @@ const roleTag: Record = { [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 = { + [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 `` 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 ( + + {/* Role badge header line */} + + {` ${pad(`[${tag}]`, 6)}`} + + {/* Body — each wrapped line is a whose styled runs are / */} + {rows.map((row, i) => ( + + {row.map((r, j) => { + const text = r.text; + if (r.bold) { + return ( + + {text} + + ); + } + return ( + + {text} + + ); + })} + + ))} + {/* Live reasoning (dimmed, kept to one line) */} + {msg.reasoning.length > 0 && ( + {` ⋯ ${oneLine(msg.reasoning)}`} + )} + {/* Thin separator between messages */} + {` ${"─".repeat(Math.max(0, Math.min(60, width - 4)))}`} + + ); +} + +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 ? ( + + ) : ( + + + + + {/* Autocomplete dropdown (shown above the input row) */} + {state.input.autocompleteVisible && } + {/* Live activity breadcrumb of the running turn */} + + + + ); return ( {/* Header */} - - {header} - - - {/* Transcript pane */} - - {visible.map((m, i) => ( - - ))} - {messages.length === 0 && ( - — no messages yet. Type a message below or /help. — - )} - - - {/* Autocomplete dropdown (shown above the input row) */} - {state.input.autocompleteVisible && } - - {/* Overlay or input row */} - {state.misc.overlay !== "none" ? ( - - ) : ( - - )} - + + {body} {/* Status bar */} - + ); } -/** 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 ( - - {/* Role tag header line */} - {` [${tag}]`} - {/* Indented, word-wrapped body */} - {content.length > 0 && ( - - {indent(content, 4, bodyWidth)} - - )} - {/* Live reasoning (dimmed, kept to one line) */} - {msg.reasoning.length > 0 && ( - {` ⋯ ${oneLine(msg.reasoning)}`} - )} - {/* Thin separator between messages */} - {` ${"─".repeat(Math.max(0, Math.min(60, width - 4)))}`} - - ); -} - -/** 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 ( - - {" " + "─".repeat(Math.max(0, width - 4))} - - {" ❯ "} - {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`} + + + {header} - {candidates.slice(0, 10).map((c, i) => ( - - {i === state.input.autocompleteIdx ? "▸ " : " "} - {c} - {i < candidates.slice(0, 10).length - 1 ? "\n" : ""} - + + ); +} + +/* ── 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 ( + + {`\n ─ no messages yet. Type a message below (or /help) ─`} + + ); + } + return ( + + {visible.map((m, i) => ( + ))} ); } -/** 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 ( - - {body} - - {name === "quit_confirm" ? "y=yes n=no Esc=cancel" : "Esc to close"} + + + {line} ); + + 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 ( + + {`─`.repeat(Math.max(0, width))} + + + {` ❯ `} + + {hasContent ? ( + {shown} + ) : ( + + Ask Zesdex to help… + + )} + {` ▏`} + + + ); } -/** 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 ( + + + {` ${title}`} + + {visible.map((c, i) => ( + + {i === selected ? `${"▸ "}${c}${" ".repeat(Math.max(0, width - c.length - 4))}` : ` ${c}`} + + ))} + {` ↑/↓ navigate · Tab accept · Esc close`} + + ); +} + +/* ── 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 ( + + ); + case "clear_confirm": + return ( + + ); + case "help": + return ; + case "diff": + return ; + case "model_selector": + return ; + case "editor": + return ; + default: + return ; + } +} + +function ConfirmOverlay(props: { title: string; body: string; hint: string }): ReactNode { + const { title, body, hint } = props; + return ( + + {body} + {` ${hint}`} + + ); +} + +function HelpOverlay(props: { state: AppStateRest }): ReactNode { + const { state } = props; + return ( + + {state.helpText} + {" Esc or /help to close"} + + ); +} + +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 ( + + + {slice.map((l, i) => { + const fg = l.startsWith("+") + ? C.green + : l.startsWith("-") + ? C.red + : l.startsWith("@@") + ? C.cyan + : C.dim; + return ( + + {l.length ? l : " "} + + ); + })} + + + {` ↑/↓ / PgUp/PgDn scroll · Esc close — ${Math.min(start + 1, lines.length)}/${lines.length}`} + + + ); +} + +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 ( + + + {body} + + {" ↑/↓ select · Enter apply · Esc close"} + + ); +} + +function EditorOverlay(props: { state: AppStateRest }): ReactNode { + const { state } = props; + const ed = state.misc.editor; + if (!ed) return No buffer.; + const lines = ed.content.split("\n"); + const start = Math.max(0, ed.cursor - 5); + const slice = lines.slice(start, start + 12); + return ( + + {slice.map((l, i) => ( + + {`${String(start + i + 1).padStart(3)} | ${l}`} + + ))} + + {" type to edit · Enter newline · Esc save & close"} + + + ); +} + +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 ( + + {content ? ( + {content} + ) : ( + {` ${summary}: nothing yet.`} + )} + {" Esc to close"} + + ); +} + +function OverlayFrame(props: { title: string; children: ReactNode }): ReactNode { + const { title, children } = props; + return ( + + {children} + + ); +} + +/* ── 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 {cell}; + + 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 ( + + {trimTo(cell, width)} + + ); +} + +/** 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 { }, 60); }); } +