import { afterEach, describe, expect, test } from "bun:test" import { CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core" import { MockTreeSitterClient, createTestRenderer, type TestRendererSetup, } from "@opentui/core/testing" import { NanobotTui, type AppOptions } from "./app" import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol" import type { HostAgentState, HostMetadata, TuiHost } from "./host" const options: AppOptions = { wsUrl: "ws://localhost.invalid/ws", apiUrl: "", apiToken: "", model: "test/model", modelPreset: "default", workspace: "/tmp/nanobot-workspace", version: "test", access: "workspace access", theme: "auto", } interface HiddenScrollBar { visible: boolean slider: { visible: boolean } startArrow: { visible: boolean } endArrow: { visible: boolean } } interface HiddenScrollBox { verticalScrollBar: HiddenScrollBar horizontalScrollBar: HiddenScrollBar } function occurrences(frame: string, value: string): number { return frame.split(value).length - 1 } function contrastRatio(foreground: string, background: string): number { const luminance = (color: string) => { const channel = (offset: number) => { const value = Number.parseInt(color.slice(offset, offset + 2), 16) / 255 return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 } return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5) } const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a) return ((lighter ?? 0) + 0.05) / ((darker ?? 0) + 0.05) } async function waitUntil(predicate: () => boolean, timeout = 1_000): Promise { const deadline = Date.now() + timeout while (!predicate() && Date.now() < deadline) await Bun.sleep(5) if (!predicate()) throw new Error(`condition was not met within ${timeout}ms`) } function client( sent: string[] = [], attached: string[] = [], newChats: string[] = [], sentOptions: MessageOptions[] = [], forks: Array<{ source: string; before: number; title?: string }> = [], scopes: WorkspaceScopePayload[] = [], ) { return { activeChatId: "chat", connect() {}, close() {}, send(content: string, options: MessageOptions = {}) { sent.push(content) sentOptions.push(options) return "turn" }, attach(chatId: string) { attached.push(chatId) }, newChat(_scope?: WorkspaceScopePayload) { newChats.push("new") }, forkChat(source: string, before: number, title?: string) { forks.push({ source, before, ...(title ? { title } : {}) }) }, setWorkspaceScope(scope: WorkspaceScopePayload) { scopes.push(scope) }, } } const mount = (setup: TestRendererSetup, sent: string[] = []) => NanobotTui.mount( setup.renderer, options, client(sent), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) describe("NanobotTui layout", () => { let setup: TestRendererSetup | undefined afterEach(() => { if (setup && !setup.renderer.isDestroyed) setup.renderer.destroy() setup = undefined }) const createRenderer = (options: Parameters[0]) => createTestRenderer(options) test("reflows a single retained layout across terminal resizes", async () => { setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen", consoleMode: "disabled", }) const app = mount(setup) for (const [width, height] of [[100, 30], [56, 18], [118, 36]] as const) { setup.resize(width, height) await setup.renderOnce() const frame = setup.captureCharFrame() expect(setup.renderer.width).toBe(width) expect(setup.renderer.height).toBe(height) expect(occurrences(frame, "Ask nanobot anything")).toBe(1) expect(occurrences(frame, "Ready")).toBe(0) expect(occurrences(frame, "Connecting…")).toBe(1) expect(occurrences(frame, "nanobot · test/model")).toBe(1) } app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "delta", chat_id: "chat", text: "First **answer**." }) app.accept({ event: "stream_end", chat_id: "chat", resuming: true }) app.accept({ event: "message", chat_id: "chat", text: "read_file(config.json)", kind: "tool_hint", tool_events: [ { phase: "start", call_id: "read-1", name: "read_file", arguments: { path: "config.json" } }, { phase: "end", call_id: "read-1", name: "read_file" }, ], }) app.accept({ event: "reasoning_delta", chat_id: "chat", text: "private chain of thought" }) app.accept({ event: "reasoning_end", chat_id: "chat" }) app.accept({ event: "delta", chat_id: "chat", text: "Second answer." }) app.accept({ event: "stream_end", chat_id: "chat" }) app.accept({ event: "turn_end", chat_id: "chat", latency_ms: 1200 }) await setup.flush() const frame = setup.captureCharFrame() expect(occurrences(frame, "First **answer**.")).toBe(1) expect(occurrences(frame, "Second answer.")).toBe(1) expect(frame).toContain("✓ Read config.json") expect(frame).not.toContain("› Read") expect(frame).not.toContain("private chain of thought") expect(frame).toContain("Ready · 1.2s") }) test("waits for an IME commit before reading the submitted text", async () => { const sent: string[] = [] setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup, sent) app.accept({ event: "attached", chat_id: "chat" }) await Bun.sleep(1) const composer = (app as unknown as { composer: TextareaRenderable }).composer composer.setText("你") composer.submit() setTimeout(() => composer.setText("你好"), 0) await waitUntil(() => sent.length > 0) expect(sent).toEqual(["你好"]) }) test("inserts newlines with Shift+Enter and the universal Ctrl+J fallback", async () => { const sent: string[] = [] setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen", kittyKeyboard: true, }) const app = mount(setup, sent) app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => (app as unknown as { ready: boolean }).ready) const ui = app as unknown as { composer: TextareaRenderable composerFrame: { height: number } } await setup.mockInput.typeText("first") setup.mockInput.pressEnter({ shift: true }) await setup.mockInput.typeText("second") setup.mockInput.pressKey("j", { ctrl: true }) await setup.mockInput.typeText("third") await setup.flush() expect(ui.composer.plainText).toBe("first\nsecond\nthird") expect(sent).toEqual([]) expect(ui.composerFrame.height).toBeGreaterThanOrEqual(3) }) test("clears the placeholder on the first typed character", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) const composer = (app as unknown as { composer: TextareaRenderable }).composer await setup.renderOnce() expect(setup.captureCharFrame()).toContain("Ask nanobot anything") setup.mockInput.typeText("bu") await setup.flush() const frame = setup.captureCharFrame() expect(composer.plainText).toBe("bu") expect(composer.placeholder).toBeNull() expect(frame).toContain("bu") expect(frame).not.toContain("Ask nanobot anything") expect(frame).not.toContain("buAsk nanobot anything") setup.mockInput.pressBackspace() setup.mockInput.pressBackspace() await setup.flush() expect(composer.placeholder).toBe("Ask nanobot anything") expect(setup.captureCharFrame()).toContain("Ask nanobot anything") }) test("compacts large pastes in the composer without changing the sent text", async () => { const sent: string[] = [] setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup, sent) app.accept({ event: "attached", chat_id: "chat" }) await Bun.sleep(1) const ui = app as unknown as { composer: TextareaRenderable status: { plainText: string } } const pasted = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") await setup.mockInput.pasteBracketedText(pasted) await setup.flush() expect(ui.composer.plainText).toBe("[Pasted 12 lines] ") expect(ui.status.plainText).toContain("Pasted 12 lines") ui.composer.submit() await waitUntil(() => sent.length === 1) expect(sent).toEqual([pasted]) expect(ui.composer.plainText).toBe("") }) test("steers with Enter, queues with Tab, and restores queued text with Alt+Up", async () => { const sent: string[] = [] const sentOptions: MessageOptions[] = [] setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" }) const app = NanobotTui.mount( setup.renderer, options, client(sent, [], [], sentOptions), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) app.accept({ event: "attached", chat_id: "chat" }) const ui = app as unknown as { ready: boolean composer: TextareaRenderable mentionCandidates: Array> queuePreview: { root: { visible: boolean } } } await waitUntil(() => ui.ready) ui.mentionCandidates = [{ kind: "cli", name: "github", displayName: "GitHub", description: "CLI", }] ui.composer.setText("first") ui.composer.submit() await waitUntil(() => sent.length === 1) ui.composer.setText("ask @github next") ui.composer.submit() await waitUntil(() => sent.length === 2) expect(sentOptions[1]).toEqual({ cliApps: [{ name: "github" }], mcpPresets: [], sessionMentions: [], }) ui.composer.setText("after this turn") setup.mockInput.pressTab() await waitUntil(() => ui.composer.plainText === "") expect(sent).toHaveLength(2) expect(ui.queuePreview.root.visible).toBeTrue() setup.mockInput.pressArrow("up", { meta: true }) expect(ui.composer.plainText).toBe("after this turn") expect(ui.queuePreview.root.visible).toBeFalse() setup.mockInput.pressTab() await waitUntil(() => ui.composer.plainText === "") app.accept({ event: "error", chat_id: "chat", turn_id: "failed-steering", reason: "steering rejected", }) expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue() app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => ui.ready) app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" }) app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn" }) await waitUntil(() => sent.length === 3) expect(sent[2]).toBe("after this turn") expect(ui.queuePreview.root.visible).toBeFalse() app.accept({ event: "goal_status", chat_id: "chat", status: "idle", turn_id: "prior" }) expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue() }) test("projects user turns from another terminal without duplicating replayed history", async () => { setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => (app as unknown as { ready: boolean }).ready) const first = { event: "user_message" as const, chat_id: "chat", text: "hello from terminal A", turn_id: "remote-turn", active_turn_id: "remote-turn", starts_turn: true, started_at: 1_700_000_000, media_urls: [{ kind: "file" as const, url: "/api/media/sig/report", name: "report.pdf", }], } app.accept(first) app.accept(first) app.accept({ event: "user_message", chat_id: "chat", text: "one more remote detail", turn_id: "remote-steer", active_turn_id: "remote-turn", starts_turn: false, }) await setup.flush() const state = app as unknown as { activeTurn: boolean; activeTurnId: string | null } const frame = setup.captureCharFrame() expect(occurrences(frame, "hello from terminal A")).toBe(1) expect(occurrences(frame, "Attachments: report.pdf")).toBe(1) expect(occurrences(frame, "one more remote detail")).toBe(1) expect(state.activeTurn).toBeTrue() expect(state.activeTurnId).toBe("remote-turn") app.accept({ event: "turn_end", chat_id: "chat", turn_id: "remote-turn" }) expect(state.activeTurn).toBeFalse() }) test("reconciles simultaneous submits to the gateway-owned turn", async () => { const sent: string[] = [] setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" }) const app = mount(setup, sent) app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => (app as unknown as { ready: boolean }).ready) const ui = app as unknown as { composer: TextareaRenderable activeTurn: boolean activeTurnId: string | null } ui.composer.setText("submitted from terminal B") ui.composer.submit() await waitUntil(() => sent.length === 1) expect(ui.activeTurnId).toBe("turn") app.accept({ event: "message_accepted", chat_id: "chat", turn_id: "turn", active_turn_id: "terminal-a-turn", starts_turn: false, started_at: 1_700_000_000, }) expect(ui.activeTurn).toBeTrue() expect(ui.activeTurnId).toBe("terminal-a-turn") }) test("recalls submitted prompts without stealing multiline cursor movement", async () => { const sent: string[] = [] setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup, sent) app.accept({ event: "attached", chat_id: "chat" }) await Bun.sleep(1) const composer = (app as unknown as { composer: TextareaRenderable }).composer for (const [index, value] of ["first prompt", "second prompt"].entries()) { composer.setText(value) composer.submit() await waitUntil(() => sent.length === index + 1) app.accept({ event: "turn_end", chat_id: "chat" }) } setup.mockInput.pressArrow("up") expect(composer.plainText).toBe("second prompt") setup.mockInput.pressArrow("up") expect(composer.plainText).toBe("first prompt") setup.mockInput.pressArrow("down") expect(composer.plainText).toBe("first prompt") setup.mockInput.pressArrow("down") expect(composer.plainText).toBe("second prompt") setup.mockInput.pressArrow("down") expect(composer.plainText).toBe("") setup.resize(36, 20) const wrapped = "这是一段会在狭窄输入框中自动换行而不是显式换行的中文内容" composer.setText(wrapped) composer.cursorOffset = wrapped.length await setup.renderOnce() expect(composer.virtualLineCount).toBeGreaterThan(1) setup.mockInput.pressArrow("up") expect(composer.plainText).toBe(wrapped) }) test("discovers and completes gateway slash commands without sending them", async () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const sent: string[] = [] const app = mount(setup, sent) const ui = app as unknown as { composer: TextareaRenderable commandMenu: { visible: boolean setCommands(commands: SlashCommand[]): void } } ui.commandMenu.setCommands([{ command: "/history", title: "History", description: "Show recent messages", argHint: "[n]", lifecycle: "side_channel", acceptsArgs: true, }]) await setup.mockInput.typeText("/h") expect(ui.commandMenu.visible).toBe(true) setup.mockInput.pressTab() expect(ui.composer.plainText).toBe("/history ") expect(ui.commandMenu.visible).toBe(false) expect(sent).toEqual([]) }) test("runs bang commands through the gateway without steering the agent", async () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const sent: string[] = [] const sentOptions: MessageOptions[] = [] const app = NanobotTui.mount( setup.renderer, options, client(sent, [], [], sentOptions), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) app.accept({ event: "attached", chat_id: "chat" }) const ui = app as unknown as { ready: boolean; composer: TextareaRenderable; activeTurn: boolean } await waitUntil(() => ui.ready) app.accept({ event: "goal_status", chat_id: "chat", status: "running" }) ui.composer.setText("!pwd") ui.composer.submit() await waitUntil(() => sent.length === 1) expect(sent).toEqual(["!pwd"]) expect(sentOptions).toEqual([{ userShell: true }]) expect(ui.activeTurn).toBe(true) app.accept({ event: "message", chat_id: "chat", text: "/tmp/project", turn_id: "turn" }) await setup.flush() expect(setup.captureCharFrame()).toContain("/tmp/project") expect(ui.activeTurn).toBe(true) }) test("switches and creates gateway chats without replacing core slash commands", async () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const original = globalThis.fetch globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({ sessions: [ { key: "websocket:chat", title: "Current chat", preview: "Current work", updated_at: "2026-08-13T10:00:00Z", }, { key: "websocket:other", title: "Release checklist", preview: "Prepare stable release", updated_at: "2026-08-12T10:00:00Z", model_preset: "Deep Research", }, ], })))) as unknown as typeof fetch const attached: string[] = [] const newChats: string[] = [] const transport = client([], attached, newChats) const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, transport, new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) app.accept({ event: "attached", chat_id: "chat" }) await Bun.sleep(1) const ui = app as unknown as { composer: TextareaRenderable sessionMenu: { visible: boolean } titleText: { plainText: string } runtimeControls: { modelText: { plainText: string } } } try { ui.composer.setText("/sessions") ui.composer.submit() await waitUntil(() => ui.sessionMenu.visible) expect(ui.composer.placeholder).toBe("Search sessions") ui.composer.setText("release") ui.composer.submit() await waitUntil(() => attached.length === 1) expect(attached).toEqual(["other"]) expect(ui.titleText.plainText).toContain("Release checklist") expect(ui.runtimeControls.modelText.plainText).toContain("Deep Research") expect(ui.runtimeControls.modelText.plainText).not.toContain("test/model") app.accept({ event: "attached", chat_id: "other" }) await Bun.sleep(1) ui.composer.setText("/new-chat") ui.composer.submit() await waitUntil(() => newChats.length === 1) expect(newChats).toEqual(["new"]) expect(ui.titleText.plainText).toContain("New chat") expect(ui.runtimeControls.modelText.plainText).toContain("test/model") } finally { globalThis.fetch = original } }) test("tracks canonical presets without overwriting a session override", async () => { setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) const ui = app as unknown as { runtimeControls: { modelText: { plainText: string } } } app.accept({ event: "attached", chat_id: "chat", model_preset: "Codex" }) app.accept({ event: "turn_model_updated", chat_id: "chat", model_name: "openai/gpt-5.6", model_preset: "Codex", }) await setup.flush() expect(ui.runtimeControls.modelText.plainText).toContain("Codex · openai/gpt-5.6") app.accept({ event: "runtime_model_updated", model_name: "deepseek/deepseek-chat", model_preset: "DeepSeek", }) await setup.flush() expect(ui.runtimeControls.modelText.plainText).toContain("Codex · openai/gpt-5.6") expect(ui.runtimeControls.modelText.plainText).not.toContain("DeepSeek") }) test("returns a default-following chat to the canonical default preset", async () => { setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" }) const app = NanobotTui.mount( setup.renderer, { ...options, model: "openai/gpt-5.6", modelPreset: "Codex" }, client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) const ui = app as unknown as { runtimeControls: { modelText: { plainText: string } } } app.accept({ event: "attached", chat_id: "chat", model_preset: null }) app.accept({ event: "runtime_model_updated", model_name: "deepseek/deepseek-chat", model_preset: null, }) await setup.flush() expect(ui.runtimeControls.modelText.plainText).toContain("deepseek/deepseek-chat") expect(ui.runtimeControls.modelText.plainText).not.toContain("Codex") }) test("refreshes the canonical preset after the model command completes", async () => { setup = await createRenderer({ width: 96, height: 20, screenMode: "alternate-screen" }) const original = globalThis.fetch globalThis.fetch = ((input: string | URL | Request) => { if (String(input).endsWith("/api/webui/sidebar-state")) { return Promise.resolve(new Response(JSON.stringify({}))) } return Promise.resolve(new Response(JSON.stringify({ sessions: [{ key: "websocket:chat", model_preset: "Deep Research" }], }))) }) as typeof fetch const sent: string[] = [] const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, client(sent), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) const ui = app as unknown as { composer: TextareaRenderable commandMenu: { setCommands(commands: SlashCommand[]): void } runtimeControls: { modelText: { plainText: string } } } try { app.accept({ event: "attached", chat_id: "chat", model_preset: null }) ui.commandMenu.setCommands([{ command: "/model", title: "Model", description: "Show or switch model presets", argHint: "[preset]", lifecycle: "side_channel", acceptsArgs: true, }]) ui.composer.setText("/model deep research") ui.composer.submit() await waitUntil(() => sent.length === 1) app.accept({ event: "message", chat_id: "chat", text: "Switched model preset to Deep Research.", turn_id: "turn", }) await waitUntil(() => ui.runtimeControls.modelText.plainText.includes("Deep Research")) expect(sent).toEqual(["/model deep research"]) } finally { globalThis.fetch = original } }) test("opens composer runtime controls by mouse and applies canonical choices", async () => { const original = globalThis.fetch const sent: string[] = [] const scopes: WorkspaceScopePayload[] = [] let settingsRequests = 0 let workspaceRequests = 0 globalThis.fetch = (async (input: string | URL | Request) => { const url = String(input) if (url.endsWith("/api/settings")) { settingsRequests += 1 return new Response(JSON.stringify({ model_presets: [ { name: "default", model: "test/model" }, { name: "fast", model: "fast/model" }, ], })) } if (url.endsWith("/api/workspaces")) { workspaceRequests += 1 return new Response(JSON.stringify({ controls: { can_use_full_access: true }, })) } return new Response(JSON.stringify({ sessions: [] })) }) as typeof fetch setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" }) const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, client(sent, [], [], [], [], scopes), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) app.accept({ event: "attached", chat_id: "chat" }) const ui = app as unknown as { runtimeControls: { modelText: TextRenderable accessText: TextRenderable contextText: TextRenderable visible: boolean menuRoot: { getChildren(): unknown[] } } composer: TextareaRenderable titleText: TextRenderable status: TextRenderable meta: TextRenderable } try { await waitUntil(() => (app as unknown as { ready: boolean }).ready) await setup.renderOnce() expect(setup.renderer.getCursorState()).toMatchObject({ style: "line", blinking: false, }) expect(ui.runtimeControls.modelText.selectable).toBe(false) expect(ui.runtimeControls.accessText.selectable).toBe(false) expect(ui.runtimeControls.contextText.selectable).toBe(false) expect(ui.titleText.selectable).toBe(false) expect(ui.status.selectable).toBe(false) expect(ui.meta.selectable).toBe(false) app.accept({ event: "goal_status", chat_id: "chat", status: "running" }) await setup.flush() await setup.mockMouse.click( ui.runtimeControls.modelText.x + 2, ui.runtimeControls.modelText.y, ) await waitUntil(() => ui.runtimeControls.visible) await setup.flush() const modelRows = ui.runtimeControls.menuRoot.getChildren() as TextRenderable[] expect(modelRows.every((row) => !row.selectable)).toBe(true) const fast = modelRows.find((row) => row.plainText.includes("fast")) if (!fast) throw new Error("fast model row was not rendered") ui.composer.blur() expect(ui.composer.focused).toBe(false) await setup.mockMouse.click(fast.x + 2, fast.y) await waitUntil(() => sent.includes("/model fast")) expect(ui.composer.focused).toBe(true) await setup.mockMouse.click( ui.runtimeControls.accessText.x + 2, ui.runtimeControls.accessText.y, ) await waitUntil(() => ui.runtimeControls.visible) await setup.flush() const accessRows = ui.runtimeControls.menuRoot.getChildren() as TextRenderable[] const full = accessRows.find((row) => row.plainText.includes("Full access")) if (!full) throw new Error("full access row was not rendered") ui.composer.blur() expect(ui.composer.focused).toBe(false) await setup.mockMouse.click(full.x + 2, full.y) expect(ui.composer.focused).toBe(true) expect(scopes).toEqual([{ project_path: "/tmp/nanobot-workspace", access_mode: "full", restrict_to_workspace: false, }]) await setup.mockMouse.click( ui.runtimeControls.modelText.x + 2, ui.runtimeControls.modelText.y, ) await waitUntil(() => ui.runtimeControls.visible) ui.composer.blur() await setup.mockMouse.click(ui.status.x, ui.status.y) expect(ui.runtimeControls.visible).toBe(false) expect(ui.composer.focused).toBe(true) await setup.mockMouse.click( ui.runtimeControls.accessText.x + 2, ui.runtimeControls.accessText.y, ) await waitUntil(() => ui.runtimeControls.visible) ui.composer.blur() await setup.mockMouse.click(ui.status.x, ui.status.y) expect(ui.runtimeControls.visible).toBe(false) expect(ui.composer.focused).toBe(true) expect(settingsRequests).toBe(1) expect(workspaceRequests).toBe(1) expect((app as unknown as { activeTurn: boolean }).activeTurn).toBe(true) app.accept({ event: "goal_status", chat_id: "chat", status: "idle" }) } finally { globalThis.fetch = original } }) test("opens and switches sessions from the clickable title", async () => { const original = globalThis.fetch globalThis.fetch = ((input: string | URL | Request) => { const url = String(input) if (url.endsWith("/api/webui/sidebar-state")) { return Promise.resolve(new Response(JSON.stringify({}))) } return Promise.resolve(new Response(JSON.stringify({ sessions: [ { key: "websocket:chat", title: "Current chat", preview: "Current work" }, { key: "websocket:other", title: "Release checklist", preview: "Ship it" }, ], }))) }) as typeof fetch const attached: string[] = [] setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" }) const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, client([], attached), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) app.accept({ event: "attached", chat_id: "chat" }) const ui = app as unknown as { composer: TextareaRenderable sessionMenu: { visible: boolean; root: { getChildren(): unknown[] } } titleText: TextRenderable status: TextRenderable } try { await waitUntil(() => (app as unknown as { ready: boolean }).ready) await setup.renderOnce() await setup.mockMouse.click(ui.titleText.x + 2, ui.titleText.y) await waitUntil(() => ui.sessionMenu.visible) await setup.flush() expect(ui.composer.placeholder).toBe("Search sessions") const rows = ui.sessionMenu.root.getChildren() as TextRenderable[] const other = rows.find((row) => row.plainText.includes("Release checklist")) if (!other) throw new Error("other session row was not rendered") ui.composer.blur() await setup.mockMouse.click(other.x + 2, other.y) await waitUntil(() => attached.length === 1) expect(attached).toEqual(["other"]) expect(ui.sessionMenu.visible).toBe(false) expect(ui.composer.focused).toBe(true) expect(ui.titleText.plainText).toContain("Release checklist") app.accept({ event: "attached", chat_id: "other" }) await setup.mockMouse.click(ui.titleText.x + 2, ui.titleText.y) await waitUntil(() => ui.sessionMenu.visible) ui.composer.blur() await setup.mockMouse.click(ui.status.x, ui.status.y) expect(ui.sessionMenu.visible).toBe(false) expect(ui.composer.focused).toBe(true) } finally { globalThis.fetch = original } }) test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const sent: string[] = [] const app = mount(setup, sent) app.accept({ event: "attached", chat_id: "chat" }) await Bun.sleep(1) const ui = app as unknown as { composer: TextareaRenderable commandMenu: { setCommands(commands: SlashCommand[]): void } activeTurn: boolean } ui.commandMenu.setCommands([{ command: "/new", title: "New chat", description: "Reset this chat", argHint: "", lifecycle: "finalize_active_turn", acceptsArgs: false, }, { command: "/status", title: "Status", description: "Show status", argHint: "", lifecycle: "side_channel", acceptsArgs: false, }]) app.accept({ event: "goal_status", chat_id: "chat", status: "running" }) ui.composer.setText("/status") ui.composer.submit() await waitUntil(() => sent.includes("/status")) expect(ui.activeTurn).toBe(true) app.accept({ event: "message", chat_id: "chat", text: "Runtime healthy", turn_id: "turn", }) await setup.flush() expect(setup.captureCharFrame()).toContain("Runtime healthy") ui.composer.setText("/new") ui.composer.submit() await waitUntil(() => sent.includes("/new")) expect(ui.activeTurn).toBe(false) expect(sent).toEqual(["/status", "/new"]) await setup.flush() expect(setup.captureCharFrame()).toContain("/new") }) test("blocks sends and ignores late session results after closing the picker", async () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const original = globalThis.fetch let resolveFetch: ((response: Response) => void) | undefined globalThis.fetch = ((input: string | URL | Request) => { if (String(input).endsWith("/api/webui/sidebar-state")) { return Promise.resolve(new Response(JSON.stringify({}))) } return new Promise((resolve) => { resolveFetch = resolve }) }) as typeof fetch const sent: string[] = [] const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, client(sent), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) app.accept({ event: "attached", chat_id: "chat" }) await Bun.sleep(1) const ui = app as unknown as { composer: TextareaRenderable sessionMenu: { visible: boolean } sessionLoading: boolean } try { ui.composer.setText("/sessions") ui.composer.submit() await waitUntil(() => ui.sessionLoading) ui.composer.setText("do not send") ui.composer.submit() await Bun.sleep(10) expect(sent).toEqual([]) setup.mockInput.pressEscape() await Bun.sleep(10) resolveFetch?.(new Response(JSON.stringify({ sessions: [] }))) await Bun.sleep(10) expect(ui.sessionMenu.visible).toBe(false) expect(ui.composer.plainText).toBe("") } finally { globalThis.fetch = original } }) test("applies a query typed while sessions are still loading", async () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const original = globalThis.fetch let resolveFetch: ((response: Response) => void) | undefined globalThis.fetch = ((input: string | URL | Request) => { if (String(input).endsWith("/api/webui/sidebar-state")) { return Promise.resolve(new Response(JSON.stringify({}))) } return new Promise((resolve) => { resolveFetch = resolve }) }) as typeof fetch const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) app.accept({ event: "attached", chat_id: "chat" }) await Bun.sleep(1) const ui = app as unknown as { composer: TextareaRenderable sessionMenu: { visible: boolean } } try { ui.composer.setText("/sessions") ui.composer.submit() await waitUntil(() => resolveFetch !== undefined) ui.composer.setText("release") resolveFetch!(new Response(JSON.stringify({ sessions: [ { key: "websocket:chat", title: "Current chat", preview: "Current work" }, { key: "websocket:other", title: "Release checklist", preview: "Ship it" }, ], }))) await waitUntil(() => ui.sessionMenu.visible) await setup.flush() const frame = setup.captureCharFrame() expect(frame).toContain("Release checklist") expect(occurrences(frame, "Current chat")).toBe(1) } finally { globalThis.fetch = original } }) test("explains the session-owned agent context without exposing private reasoning", async () => { setup = await createRenderer({ width: 96, height: 26, screenMode: "alternate-screen" }) const original = globalThis.fetch globalThis.fetch = ((input: string | URL | Request) => { expect(String(input)).toContain("/api/sessions/websocket%3Achat/context") return Promise.resolve(new Response(JSON.stringify({ total_messages: 24, archived_messages: 16, replay_messages: 10, estimated_replay_tokens: 2048, estimated_summary_tokens: 128, estimated_session_tokens: 2176, archived_summary: "The earlier turns agreed on a release plan.", archived_summary_at: "2026-08-13T10:00:00Z", }))) }) as typeof fetch const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) app.accept({ event: "attached", chat_id: "chat" }) const ui = app as unknown as { composer: TextareaRenderable contextPanel: { visible: boolean } runtimeControls: { contextText: { plainText: string } } } try { ui.composer.setText("/context") ui.composer.submit() await waitUntil(() => ui.contextPanel.visible) await setup.flush() expect(ui.runtimeControls.contextText.plainText).toContain("~2.2k ctx") const frame = setup.captureCharFrame() expect(frame).toContain("Agent context") expect(frame).toContain("~2.2k session tokens · 10 replay messages · 16 archived · summary active") expect(frame).toContain("The earlier turns agreed on a release plan.") expect(frame).toContain("memory, instructions, and skills are added separately") setup.resize(40, 10) await setup.renderOnce() const compact = setup.captureCharFrame() expect(occurrences(compact, "Agent context")).toBe(1) expect(occurrences(compact, "Ask nanobot anything")).toBe(1) setup.mockInput.pressEscape() await waitUntil(() => !ui.contextPanel.visible) expect(ui.contextPanel.visible).toBe(false) } finally { globalThis.fetch = original } }) test("opens the latest turn diff as a full-screen, navigable view", async () => { setup = await createRenderer({ width: 96, height: 28, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => (app as unknown as { ready: boolean }).ready) app.accept({ event: "message_accepted", chat_id: "chat", turn_id: "edit-turn" }) app.accept({ event: "file_edit", chat_id: "chat", edits: [{ call_id: "edit-1", tool: "edit_file", path: "src/first.ts", status: "done", added: 2, deleted: 1, diff: { format: "unified", truncated: true, text: [ "--- a/src/first.ts", "+++ b/src/first.ts", "@@ -1 +1,2 @@", "-const oldValue = 1", "+const newValue = 2", "+export { newValue }", ].join("\n"), }, }, { call_id: "edit-2", tool: "write_file", path: "src/second.py", status: "done", added: 1, deleted: 0, diff: { format: "unified", text: [ "--- a/src/second.py", "+++ b/src/second.py", "@@ -0,0 +1 @@", "+print('hello')", ].join("\n"), }, }], }) app.accept({ event: "turn_end", chat_id: "chat", turn_id: "edit-turn" }) const ui = app as unknown as { composer: TextareaRenderable diffViewer: { visible: boolean scroll: { getChildren(): Array<{ addedBg?: { toInts(): number[] } }> } } } ui.composer.setText("/diff") ui.composer.submit() await waitUntil(() => ui.diffViewer.visible) await setup.flush() let frame = setup.captureCharFrame() expect(frame).toContain("Diff · Last turn · 2 changes · +3 -1") expect(frame).toContain("1/2 · src/first.ts · +2 -1") expect(frame).toContain("const newValue = 2") expect(frame).toContain("Diff truncated by the gateway") expect(frame).not.toContain("Ask nanobot anything") setup.mockInput.pressArrow("right") await setup.flush() frame = setup.captureCharFrame() expect(frame).toContain("2/2 · src/second.py · +1 -0") expect(frame).toContain("print('hello')") setup.renderer.emit(CliRenderEvents.THEME_MODE, "light") await setup.flush() expect(ui.diffViewer.scroll.getChildren()[0]?.addedBg?.toInts().slice(0, 3)).toEqual([231, 246, 236]) expect(setup.captureCharFrame()).toContain("print('hello')") setup.resize(52, 18) await setup.renderOnce() expect(setup.captureCharFrame()).toContain("←/→ file · pgup/pgdn · esc") setup.mockInput.pressEscape() await waitUntil(() => !ui.diffViewer.visible) await setup.flush() expect(setup.captureCharFrame()).toContain("Ask nanobot anything") }) test("loads earlier transcript pages in place when PageUp reaches the top", async () => { setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" }) const original = globalThis.fetch const requests: string[] = [] globalThis.fetch = ((input: string | URL | Request) => { const url = String(input) requests.push(url) const older = url.includes("before=older-page") return Promise.resolve(new Response(JSON.stringify({ messages: older ? [ { role: "user", content: "oldest question" }, { role: "assistant", content: "oldest answer" }, ] : [ { role: "user", content: "recent question" }, { role: "assistant", content: "recent answer" }, ], page: older ? { has_more_before: false, before_cursor: null } : { has_more_before: true, before_cursor: "older-page" }, }))) }) as typeof fetch const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "secret", chatId: "chat" }, client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) try { app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => (app as unknown as { ready: boolean }).ready) setup.mockInput.pressKey("\u001B[5~") await waitUntil(() => requests.length === 2) await waitUntil(() => !(app as unknown as { historyLoadingOlder: boolean }).historyLoadingOlder) await setup.flush() const frame = setup.captureCharFrame() expect(frame.indexOf("oldest question")).toBeLessThan(frame.indexOf("recent question")) expect(frame.indexOf("oldest answer")).toBeLessThan(frame.indexOf("recent answer")) expect((app as unknown as { historyHasMore: boolean }).historyHasMore).toBe(false) const composer = (app as unknown as { composer: TextareaRenderable }).composer setup.mockInput.pressArrow("up") expect(composer.plainText).toBe("recent question") setup.mockInput.pressArrow("up") expect(composer.plainText).toBe("oldest question") } finally { globalThis.fetch = original } }) test("survives rapid narrow resizes with long CJK and code", async () => { setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "delta", chat_id: "chat", text: [ "中文、emoji 👨‍💻 and combining text é reflow with the terminal.", "https://nanobot.test/a-very-long-unbroken-path-that-must-not-break-the-layout", "```ts", "const greeting = '你好,nanobot'", "```", ].join("\n\n"), }) app.accept({ event: "stream_end", chat_id: "chat" }) for (const [width, height] of [ [240, 80], [42, 12], [30, 9], [20, 6], [12, 4], [8, 3], [4, 2], [84, 24], [48, 14], [110, 32], ] as const) { setup.resize(width, height) await setup.renderOnce() const frame = setup.captureCharFrame() expect(setup.renderer.width).toBe(width) expect(setup.renderer.height).toBe(height) expect(frame).not.toContain("undefined") expect(occurrences(frame, "Ask nanobot anything")).toBeLessThanOrEqual(1) if (width >= 30 && height >= 9) { expect(occurrences(frame, "Ask nanobot anything")).toBe(1) } expect(occurrences(frame, "nanobot · test/model")).toBe(height >= 14 ? 1 : 0) } }) test("inherits the host background after long output fills the viewport", async () => { setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "delta", chat_id: "chat", text: Array.from({ length: 80 }, (_, index) => ( `### Section ${index + 1}\n中文长回答、**bold** and [link](https://nanobot.test/${index + 1})` )).join("\n\n"), }) app.accept({ event: "stream_end", chat_id: "chat" }) app.accept({ event: "turn_end", chat_id: "chat" }) await setup.flush() const internals = app as unknown as { shell: { backgroundColor: { intent: string } } composerFrame: { backgroundColor: { intent: string } } composer: { backgroundColor: { intent: string } } transcript: { root: HiddenScrollBox } diffViewer: { scroll: HiddenScrollBox } } const lines = setup.captureSpans().lines const spans = lines.flatMap((line) => line.spans) const brandedRows = lines.filter((line) => ( line.spans.some((span) => span.bg.intent !== "default") )) expect(internals.shell.backgroundColor.intent).toBe("default") expect(internals.composerFrame.backgroundColor.intent).toBe("default") expect(internals.composer.backgroundColor.intent).toBe("default") expect(spans.length).toBeGreaterThan(0) expect(brandedRows).toHaveLength(0) for (const scrollBox of [internals.transcript.root, internals.diffViewer.scroll]) { for (const bar of [scrollBox.verticalScrollBar, scrollBox.horizontalScrollBar]) { expect(bar.visible).toBeFalse() expect(bar.slider.visible).toBeFalse() expect(bar.startArrow.visible).toBeFalse() expect(bar.endArrow.visible).toBeFalse() } } }) test("reflows markdown tables without clipping columns or cell content", async () => { setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "delta", chat_id: "chat", text: [ "| Who | Content | Engagement |", "| --- | --- | --- |", "| @owner | A longer explanation that keeps context readable | 13 likes / 6 RT |", "| @reader | Short follow-up | Low interaction |", ].join("\n"), }) app.accept({ event: "stream_end", chat_id: "chat" }) app.accept({ event: "turn_end", chat_id: "chat" }) for (const width of [96, 54, 96]) { setup.resize(width, 24) await setup.flush() const rendered = setup.captureCharFrame().replace(/\s+/gu, " ") expect(rendered).toContain("Who") expect(rendered).toContain("Content") expect(rendered).toContain("Engagement") expect(rendered).toContain("@owner") expect(rendered).toContain("longer explanation") expect(rendered).toContain("keeps context") expect(rendered).toContain("readable") expect(rendered).toContain("13 likes / 6") expect(rendered).toContain("RT") expect(rendered).toContain("Low interaction") expect((app as unknown as { transcript: { root: HiddenScrollBox } }).transcript.root.horizontalScrollBar.visible).toBeFalse() } }) test("rethemes the complete retained interface when the terminal appearance changes", async () => { setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "delta", chat_id: "chat", text: "# Existing answer" }) app.accept({ event: "stream_end", chat_id: "chat" }) app.accept({ event: "message", chat_id: "chat", text: "tool", kind: "tool_hint" }) await setup.renderOnce() const internals = app as unknown as { palette: { referenceBackground: string; text: string; border: string } shell: { backgroundColor: { intent: string } } composerFrame: { backgroundColor: { intent: string; toInts(): number[] } } composer: { backgroundColor: { intent: string; toInts(): number[] } textColor: { toInts(): number[] } } transcript: { markdown: Set<{ syntaxStyle: object }> frames: Set<{ borderColor: { toInts(): number[] } }> userRows: Set<{ backgroundColor: { intent: string; toInts(): number[] } }> user(content: string): void } } internals.transcript.user("Existing question") const userRow = [...internals.transcript.userRows][0] const markdown = [...internals.transcript.markdown][0] const sessionFrame = [...internals.transcript.frames][0] const darkSyntax = markdown?.syntaxStyle expect(userRow?.backgroundColor.intent).toBe("default") setup.renderer.emit(CliRenderEvents.THEME_MODE, "light") await setup.flush() expect(internals.palette).toMatchObject({ referenceBackground: "#FAFAFA", text: "#18181B", border: "#D4D4D8", }) expect(internals.shell.backgroundColor.intent).toBe("default") expect(internals.composerFrame.backgroundColor.toInts().slice(0, 3)).toEqual([240, 240, 240]) expect(internals.composer.backgroundColor.toInts().slice(0, 3)).toEqual([240, 240, 240]) expect(internals.composer.textColor.toInts().slice(0, 3)).toEqual([24, 24, 27]) expect(sessionFrame?.borderColor.toInts().slice(0, 3)).toEqual([212, 212, 216]) expect(userRow?.backgroundColor.toInts().slice(0, 3)).toEqual([240, 240, 240]) expect(markdown?.syntaxStyle).not.toBe(darkSyntax) }) test("distinguishes the composer with a quiet focus edge", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = NanobotTui.mount( setup.renderer, { ...options, theme: "light" }, client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) const internals = app as unknown as { ready: boolean composerFrame: { border: boolean | string[] borderColor: { toInts(): number[] } backgroundColor: { toInts(): number[] } } composer: { backgroundColor: { toInts(): number[] } placeholderColor: { toInts(): number[] } } } expect(internals.composerFrame.border).toEqual(["left"]) expect(internals.composerFrame.borderColor.toInts().slice(0, 3)).toEqual([185, 77, 11]) expect(internals.composerFrame.backgroundColor.toInts().slice(0, 3)).toEqual([240, 240, 240]) expect(internals.composer.backgroundColor.toInts().slice(0, 3)).toEqual([240, 240, 240]) expect(internals.composer.placeholderColor.toInts().slice(0, 3)).toEqual([111, 111, 120]) app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => internals.ready) await setup.renderOnce() const composerLine = setup.captureCharFrame().split("\n") .find((line) => line.includes("Ask nanobot anything")) || "" expect(composerLine).toContain("│") expect(composerLine).not.toContain("┌") expect(composerLine).not.toContain("┐") }) test("uses asymmetric roles instead of chat bubbles", async () => { setup = await createRenderer({ width: 72, height: 24, screenMode: "alternate-screen" }) const app = NanobotTui.mount( setup.renderer, { ...options, theme: "dark" }, client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "delta", chat_id: "chat", text: "Agent **answer**" }) app.accept({ event: "stream_end", chat_id: "chat" }) app.accept({ event: "turn_end", chat_id: "chat" }) ;(app as unknown as { transcript: { user(content: string): void } }).transcript.user("User question") await setup.flush() const frame = setup.captureCharFrame() const userLine = frame.split("\n").find((line) => line.includes("User question")) || "" const agentLine = frame.split("\n").find((line) => line.includes("Agent **answer**")) || "" const headerLine = frame.split("\n").find((line) => line.includes(">_ nanobot")) || "" const headerBorder = frame.split("\n").find((line) => line.includes("╭")) || "" expect(userLine).toContain("› User question") expect(agentLine).toContain("• Agent **answer**") expect(userLine).not.toContain("│") expect(agentLine).not.toContain("│") expect(headerLine).toContain("│") expect(headerBorder.trim().length).toBeLessThanOrEqual(62) const transcript = (app as unknown as { transcript: { userRows: Set<{ backgroundColor: { intent: string; toInts(): number[] } }> styledText: Array<{ renderable: { id: string; fg: { toInts(): number[] } } tone: string }> } }).transcript const userRow = [...transcript.userRows][0] const assistantMarker = transcript.styledText.find(({ renderable, tone }) => ( tone === "muted" && renderable.id.includes("role-marker") )) expect(userRow?.backgroundColor.intent).toBe("rgb") expect(userRow?.backgroundColor.toInts().slice(0, 3)).toEqual([43, 44, 46]) expect(assistantMarker?.tone).toBe("muted") expect(assistantMarker?.renderable.fg.toInts().slice(0, 3)).toEqual([161, 161, 170]) }) test("uses the idle footer for model telemetry instead of permanent shortcuts", async () => { setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "turn_end", chat_id: "chat", latency_ms: 1700, usage: { prompt_tokens: 1200, completion_tokens: 80, cached_tokens: 900, generation_ms: 1600, measured_completion_tokens: 80, ttft_ms: 240, timed_requests: 1, }, context_window_tokens: 128_000, }) await setup.flush() const footer = setup.captureCharFrame().split("\n").find((line) => line.includes("Ready · 1.7s")) || "" expect(footer).toContain("Ready · 1.7s") expect(footer).toContain("50 tok/s") expect(footer).toContain("1.2K in · 80 out") expect(footer).toContain("75% cached") expect(footer).not.toContain("TTFT") expect(footer).not.toContain("enter send") app.accept({ event: "reasoning_delta", chat_id: "chat", text: "hidden" }) await Bun.sleep(130) await setup.renderOnce() const activeFooter = setup.captureCharFrame().split("\n").find((line) => line.includes("Thinking")) || "" expect(activeFooter).not.toContain("ctrl+c stop") expect(activeFooter).not.toContain("enter steer") app.accept({ event: "turn_end", chat_id: "chat" }) }) test("keeps an explicit theme stable when the terminal reports another mode", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = NanobotTui.mount( setup.renderer, { ...options, theme: "light" }, client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) const internals = app as unknown as { palette: { referenceBackground: string } } Object.defineProperty(setup.renderer, "themeMode", { configurable: true, value: "dark" }) await app.start() setup.renderer.emit(CliRenderEvents.THEME_MODE, "dark") await setup.renderOnce() expect(internals.palette.referenceBackground).toBe("#FAFAFA") }) test("overlaps automatic terminal detection with connection startup", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) let connected = false let rendered = false let resolveMode: (mode: "light") => void = () => undefined setup.renderer.start = () => { rendered = true } setup.renderer.waitForThemeMode = () => new Promise((resolve) => { resolveMode = resolve }) Object.defineProperty(setup.renderer, "themeMode", { configurable: true, value: "light" }) const transport = client() transport.connect = () => { connected = true } const app = NanobotTui.mount( setup.renderer, options, transport, new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) const starting = app.start() await Bun.sleep(1) expect(connected).toBe(true) expect(rendered).toBe(true) resolveMode("light") await starting expect((app as unknown as { palette: { referenceBackground: string } }).palette.referenceBackground).toBe("#FAFAFA") }) test("falls back to the dark palette when terminal theme probing has no answer", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) let connected = false setup.renderer.waitForThemeMode = async () => null Object.defineProperty(setup.renderer, "themeMode", { configurable: true, value: null }) const transport = client() transport.connect = () => { connected = true } const app = NanobotTui.mount( setup.renderer, options, transport, new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) await app.start() const transcript = (app as unknown as { transcript: { userRows: Set<{ backgroundColor: { intent: string } }> user(content: string): void } }).transcript transcript.user("Unknown terminal background") expect(connected).toBe(true) expect((app as unknown as { palette: { referenceBackground: string } }).palette.referenceBackground).toBe("#0E0F11") expect([...transcript.userRows][0]?.backgroundColor.intent).toBe("default") }) test("keeps semantic colors legible in both terminal appearances", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) const internals = app as unknown as { palette: Record & { referenceBackground: string; faint: string } } const assertContrast = () => { for (const tone of ["text", "muted", "accent", "link", "success", "error", "user", "warm", "cool"]) { expect(contrastRatio(internals.palette[tone] ?? "", internals.palette.referenceBackground)).toBeGreaterThanOrEqual(4.5) } expect(contrastRatio(internals.palette.faint, internals.palette.referenceBackground)).toBeGreaterThanOrEqual(3) const turnContrast = contrastRatio( internals.palette.userBackground ?? "", internals.palette.referenceBackground, ) expect(turnContrast).toBeGreaterThan(1.05) expect(turnContrast).toBeLessThan(1.5) } assertContrast() expect(internals.palette.accent).toBe("#EF8E30") expect(internals.palette.user).toBe("#EF8E30") setup.renderer.emit(CliRenderEvents.THEME_MODE, "light") assertContrast() expect(internals.palette.accent).toBe("#B94D0B") expect(internals.palette.user).toBe("#B94D0B") }) test("replaces streamed drafts with canonical stream-end text", async () => { setup = await createRenderer({ width: 80, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "delta", chat_id: "chat", text: "draft signed://expired" }) app.accept({ event: "stream_end", chat_id: "chat", text: "canonical https://nanobot.test/signed/current", resuming: true, merge_next: true, }) app.accept({ event: "delta", chat_id: "chat", text: " tail" }) app.accept({ event: "stream_end", chat_id: "chat", text: "final https://nanobot.test/signed/current tail", }) app.accept({ event: "turn_end", chat_id: "chat" }) await setup.flush() const frame = setup.captureCharFrame() expect(frame).toContain("final https://nanobot.test/signed/current tail") expect(frame).not.toContain("canonical https://nanobot.test/signed/current") expect(frame).not.toContain("draft signed://expired") }) test("paints the first token immediately and coalesces the rest per frame", async () => { setup = await createRenderer({ width: 80, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "delta", chat_id: "chat", text: "first" }) const transcript = (app as unknown as { transcript: { live: { markdown: { content: string; streaming: boolean } } | null } }).transcript const markdown = transcript.live?.markdown expect(markdown?.content).toBe("first") for (let index = 0; index < 1_000; index += 1) { app.accept({ event: "delta", chat_id: "chat", text: " token" }) } expect(markdown?.content).toBe("first") app.accept({ event: "stream_end", chat_id: "chat" }) expect(markdown?.content).toBe(`first${" token".repeat(1_000)}`) expect(markdown?.streaming).toBe(false) }) test("copies full-screen selections through OSC 52", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) let copied = "" setup.renderer.copyToClipboardOSC52 = (text: string) => { copied = text return true } await setup.renderOnce() app.accept({ event: "delta", chat_id: "chat", text: "selected answer" }) app.accept({ event: "stream_end", chat_id: "chat" }) await setup.flush() const rows = setup.captureCharFrame().split("\n") const y = rows.findIndex((row) => row.includes("selected answer")) const x = rows[y]?.indexOf("selected answer") ?? -1 expect(x).toBeGreaterThanOrEqual(0) expect(y).toBeGreaterThanOrEqual(0) await setup.mockMouse.drag(x, y, x + "selected answer".length, y) expect(setup.renderer.getSelection()?.getSelectedText()).toBe("selected answer") setup.mockInput.pressCtrlC() await Bun.sleep(10) await setup.flush() expect(copied).toBe("selected answer") expect(setup.renderer.getSelection()).toBeNull() }) test("clears transcript selection when clicking non-content chrome", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) const status = (app as unknown as { status: TextRenderable }).status app.accept({ event: "delta", chat_id: "chat", text: "selectable answer" }) app.accept({ event: "stream_end", chat_id: "chat" }) await setup.flush() const rows = setup.captureCharFrame().split("\n") const y = rows.findIndex((row) => row.includes("selectable answer")) const x = rows[y]?.indexOf("selectable answer") ?? -1 await setup.mockMouse.drag(x, y, x + "selectable answer".length, y) expect(setup.renderer.getSelection()?.getSelectedText()).toBe("selectable answer") await setup.mockMouse.click(status.x, status.y) expect(setup.renderer.getSelection()).toBeNull() }) test("returns text input focus when other TUI content is clicked", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) const ui = app as unknown as { composer: TextareaRenderable status: TextRenderable } app.accept({ event: "delta", chat_id: "chat", text: "clickable answer" }) app.accept({ event: "stream_end", chat_id: "chat" }) await setup.flush() ui.composer.blur() await setup.mockMouse.click(ui.status.x, ui.status.y) expect(ui.composer.focused).toBe(true) }) test("animates one stable status line while the agent works", async () => { setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "reasoning_delta", chat_id: "chat", text: "hidden reasoning" }) await Bun.sleep(130) await setup.renderOnce() let frame = setup.captureCharFrame() expect(frame).toMatch(/Thinking\s+0s/u) expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u) expect(frame).not.toContain("hidden reasoning") const status = (app as unknown as { status: { content: { chunks: Array<{ fg?: { toInts(): number[] } }> } plainText: string } }).status expect(status.plainText).toMatch(/^Thinking\s+0s/u) const shimmerColors = new Set( status.content.chunks .slice(0, "Thinking".length) .map((chunk) => chunk.fg?.toInts().join(",")), ) expect(shimmerColors.size).toBeGreaterThan(1) expect([...shimmerColors].some((value) => value?.startsWith("239,142,48"))).toBe(true) app.accept({ event: "message", chat_id: "chat", text: "running shell", kind: "tool_hint", tool_events: [{ phase: "start", name: "exec", arguments: { cmd: "pwd" } }], }) await Bun.sleep(130) await setup.renderOnce() frame = setup.captureCharFrame() expect(frame).toMatch(/Working\s+0s/u) expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u) expect(frame).toContain("› Running pwd") app.accept({ event: "turn_end", chat_id: "chat" }) }) test("folds long tool traces without discarding their details", async () => { setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "message", chat_id: "chat", text: "tool progress", kind: "tool_hint", tool_events: Array.from({ length: 10 }, (_, index) => ({ phase: "end", call_id: `call-${index}`, name: `tool_${index}`, })), }) await setup.renderOnce() let frame = setup.captureCharFrame() expect(frame).toContain("5 earlier steps · Ctrl+O expand") expect(frame).not.toContain("tool_0") expect(frame).toContain("tool_9") setup.mockInput.pressKey("O", { ctrl: true }) await setup.renderOnce() frame = setup.captureCharFrame() expect(frame).not.toContain("earlier steps") expect(frame).toContain("tool_0") expect(frame).toContain("tool_9") app.accept({ event: "turn_end", chat_id: "chat" }) app.accept({ event: "message", chat_id: "chat", text: "second tool group", kind: "tool_hint", tool_events: Array.from({ length: 8 }, (_, index) => ({ phase: "end", call_id: `later-${index}`, name: `later_${index}`, })), }) setup.mockInput.pressKey("O", { ctrl: true }) await setup.renderOnce() const activities = [...(app as unknown as { transcript: { activities: Set<{ expanded: boolean }> } }).transcript.activities] expect(activities.map((activity) => activity.expanded)).toEqual([true, true]) setup.mockInput.pressKey("O", { ctrl: true }) await setup.renderOnce() expect(activities.map((activity) => activity.expanded)).toEqual([true, false]) app.accept({ event: "turn_end", chat_id: "chat" }) }) test("supports keyboard transcript navigation without rebuilding the layout", async () => { setup = await createRenderer({ width: 64, height: 16, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => (app as unknown as { ready: boolean }).ready) for (let index = 0; index < 24; index += 1) { app.accept({ event: "delta", chat_id: "chat", text: `answer ${index}` }) app.accept({ event: "stream_end", chat_id: "chat" }) } await setup.flush() const internals = app as unknown as { status: { plainText: string } transcript: { root: { scrollTop: number scrollHeight: number height: number verticalScrollBar: { visible: boolean } } } } const scroll = internals.transcript.root setup.mockInput.pressKey("HOME", { ctrl: true }) await setup.renderOnce() expect(scroll.scrollTop).toBe(0) app.accept({ event: "delta", chat_id: "chat", text: "new answer while reading above" }) app.accept({ event: "stream_end", chat_id: "chat" }) await setup.renderOnce() expect(scroll.scrollTop).toBe(0) expect(scroll.verticalScrollBar.visible).toBe(false) expect(internals.status.plainText).toContain("Ctrl+End latest") setup.mockInput.pressKey("\u001B[6~") await setup.renderOnce() expect(scroll.scrollTop).toBeGreaterThan(0) setup.mockInput.pressKey("END", { ctrl: true }) await setup.renderOnce() expect(scroll.scrollTop).toBeGreaterThanOrEqual(scroll.scrollHeight - scroll.height) expect(scroll.verticalScrollBar.visible).toBe(false) expect(internals.status.plainText).not.toContain("Ctrl+End latest") setup.mockInput.pressKey("HOME", { ctrl: true }) await waitUntil(() => internals.status.plainText.includes("Ctrl+End latest")) expect(scroll.verticalScrollBar.visible).toBe(false) app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => (app as unknown as { ready: boolean }).ready) await setup.renderOnce() expect(scroll.verticalScrollBar.visible).toBe(false) expect(internals.status.plainText).not.toContain("Ctrl+End latest") }) test("reconciles active state from attach hydration after reconnect", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) const state = () => (app as unknown as { activeTurn: boolean }).activeTurn app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "delta", chat_id: "chat", text: "stale partial response" }) expect(state()).toBe(true) app.accept({ event: "attached", chat_id: "chat" }) await Bun.sleep(1) await setup.flush() expect(state()).toBe(false) const restored = setup.captureCharFrame() expect(restored).not.toContain("stale partial response") expect(occurrences(restored, ">_ nanobot")).toBe(1) app.accept({ event: "goal_status", chat_id: "chat", status: "running", started_at: Date.now() / 1000 - 2, }) expect(state()).toBe(true) app.accept({ event: "goal_status", chat_id: "chat", status: "idle" }) expect(state()).toBe(false) }) test("replays events after asynchronous history hydration", async () => { setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" }) const original = globalThis.fetch let resolveFetch: (value: Response) => void = () => undefined globalThis.fetch = (() => new Promise((resolve) => { resolveFetch = resolve })) as unknown as typeof fetch const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "token", chatId: "chat" }, client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) try { app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "delta", chat_id: "chat", text: "live after reconnect" }) expect((app as unknown as { activeTurn: boolean }).activeTurn).toBe(false) resolveFetch(new Response(JSON.stringify({ messages: [{ role: "assistant", content: "persisted before reconnect" }], page: { has_more_before: false }, }))) await Bun.sleep(5) await setup.flush() const frame = setup.captureCharFrame() expect(frame.indexOf("persisted before reconnect")).toBeLessThan( frame.indexOf("live after reconnect"), ) expect((app as unknown as { activeTurn: boolean }).activeTurn).toBe(true) app.accept({ event: "turn_end", chat_id: "chat" }) } finally { globalThis.fetch = original } }) test("blocks submission until reconnect history is hydrated", async () => { const sent: string[] = [] setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" }) const original = globalThis.fetch let request = 0 let resolveReconnect: (value: Response) => void = () => undefined globalThis.fetch = (() => { request += 1 if (request === 1) { return Promise.resolve(new Response(JSON.stringify({ messages: [{ role: "assistant", content: "initial history" }], page: { has_more_before: false }, }))) } return new Promise((resolve) => { resolveReconnect = resolve }) }) as unknown as typeof fetch const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "token", chatId: "chat" }, client(sent), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) const composer = (app as unknown as { composer: TextareaRenderable }).composer try { app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => (app as unknown as { ready: boolean }).ready) app.accept({ event: "attached", chat_id: "chat" }) composer.setText("sent during reconnect") composer.submit() await Bun.sleep(5) expect(sent).toEqual([]) expect(composer.plainText).toBe("sent during reconnect") resolveReconnect(new Response(JSON.stringify({ messages: [{ role: "assistant", content: "restored history" }], page: { has_more_before: false }, }))) await waitUntil(() => (app as unknown as { ready: boolean }).ready) composer.submit() await waitUntil(() => sent.length === 1) await setup.flush() const frame = setup.captureCharFrame() expect(sent).toEqual(["sent during reconnect"]) expect(frame.indexOf("restored history")).toBeLessThan(frame.indexOf("sent during reconnect")) } finally { globalThis.fetch = original } }) test("preserves drafts while a reconnected socket waits to attach", async () => { const sent: string[] = [] setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup, sent) const composer = (app as unknown as { composer: TextareaRenderable }).composer const connection = app as unknown as { handleStatus(status: "connecting" | "connected", detail?: string): void } app.accept({ event: "attached", chat_id: "chat" }) await Bun.sleep(1) connection.handleStatus("connecting", "reconnecting") connection.handleStatus("connected") composer.setText("draft before attach") composer.submit() await Bun.sleep(5) expect(sent).toEqual([]) expect(composer.plainText).toBe("draft before attach") app.accept({ event: "attached", chat_id: "chat" }) await waitUntil(() => (app as unknown as { ready: boolean }).ready) composer.submit() await waitUntil(() => sent.length === 1) expect(sent).toEqual(["draft before attach"]) }) test("destroys the renderer and transport together", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) let closed = false const transport = client() transport.close = () => { closed = true } const app = NanobotTui.mount( setup.renderer, options, transport, new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) app.stop() expect(closed).toBe(true) expect(setup.renderer.isDestroyed).toBe(true) }) test("exits immediately when Ctrl+C is pressed on an idle empty composer", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) let closed = false const transport = client() transport.close = () => { closed = true } NanobotTui.mount( setup.renderer, options, transport, new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) setup.mockInput.pressCtrlC() expect(closed).toBe(true) expect(setup.renderer.isDestroyed).toBe(true) }) test("accepts the exit command before the gateway connection is ready", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) let closed = false const transport = client() transport.close = () => { closed = true } const app = NanobotTui.mount( setup.renderer, options, transport, new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) const composer = (app as unknown as { composer: TextareaRenderable }).composer composer.setText("exit") composer.submit() await waitUntil(() => closed) expect(setup.renderer.isDestroyed).toBe(true) }) }) describe("NanobotTui in a Herdr pane", () => { test("stays quiet while reporting task, session, lifecycle, and metadata", async () => { const setup = await createTestRenderer({ width: 80, height: 22, screenMode: "main-screen" }) const states: Array<{ state: HostAgentState; message?: string }> = [] const metadata: HostMetadata[] = [] const sessions: string[] = [] let released = false const host: TuiHost = { hosted: true, reportState(state, message) { states.push({ state, ...(message ? { message } : {}) }) }, reportSession(sessionId) { sessions.push(sessionId) }, reportMetadata(value) { metadata.push(value) }, release() { released = true }, } const app = NanobotTui.mount( setup.renderer, { ...options, branch: "feat/herdr" }, client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), host, ) app.accept({ event: "attached", chat_id: "chat" }) app.accept({ event: "user_message", chat_id: "chat", text: "Ship the Herdr integration", turn_id: "turn-1", starts_turn: true, }) app.accept({ event: "message", chat_id: "chat", text: "", kind: "tool_hint", tool_events: [{ phase: "end", call_id: "read", name: "read_file", arguments: { path: "app.ts" } }], }) app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn-1", goal_state: { active: false, status: "blocked", ui_summary: "Approval required", }, }) await setup.flush() const frame = setup.captureCharFrame() expect(sessions).toEqual(["chat"]) expect(frame).toContain("› Ship the Herdr integration") expect(frame).not.toContain(">_ nanobot") expect(frame).not.toContain("test/model") expect(states.some(({ state }) => state === "working")).toBe(true) expect(states.at(-1)).toEqual({ state: "blocked", message: "Approval required" }) expect(metadata.at(-1)).toMatchObject({ model: "default · test/model", branch: "feat/herdr", workspace: "/tmp/nanobot-workspace", task: "Ship the Herdr integration", action: "Approval required", }) setup.resize(42, 6) await setup.renderOnce() expect(setup.captureCharFrame()).toContain("› Ship the Herdr integration") app.accept({ event: "user_message", chat_id: "chat", text: "Approved", turn_id: "turn-2", starts_turn: true, }) app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn-2", goal_state: { active: false }, }) expect(states.at(-1)?.state).toBe("idle") app.stop() expect(released).toBe(true) }) }) if (process.platform !== "win32") { test("restores the terminal after SIGTERM", async () => { const child = Bun.spawn(["bun", "src/index.ts"], { cwd: import.meta.dir.replace(/\/src$/u, ""), env: { ...process.env, NANOBOT_TUI_WS_URL: "ws://127.0.0.1:9/ws", NANOBOT_TUI_API_URL: "", NANOBOT_TUI_API_TOKEN: "", NANOBOT_TUI_MODEL: "test/model", NANOBOT_TUI_WORKSPACE: "/tmp/nanobot-test", NANOBOT_TUI_VERSION: "test", NANOBOT_TUI_ACCESS: "workspace access", NANOBOT_TUI_THEME: "dark", }, stdout: "pipe", stderr: "pipe", }) const decoder = new TextDecoder() let output = "" const collectOutput = (async () => { const reader = child.stdout.getReader() while (true) { const { done, value } = await reader.read() if (done) break output += decoder.decode(value, { stream: true }) } output += decoder.decode() })() // Slow Intel runners can spend more than 250 ms importing OpenTUI. Wait // for terminal setup, which happens after the signal handlers are // registered, before exercising shutdown. await waitUntil(() => output.includes("\x1b[?1049h"), 5_000) child.kill("SIGTERM") const exitCode = await child.exited await collectOutput const error = await new Response(child.stderr).text() expect(exitCode).toBe(0) expect(error).toBe("") expect(output).toContain("\x1b[?1049h") expect(output).toContain("\x1b[?1049l") }) }