diff --git a/tui/README.md b/tui/README.md index 1a29859c6..0a7bae458 100644 --- a/tui/README.md +++ b/tui/README.md @@ -15,7 +15,7 @@ Standalone terminals use OpenTUI's retained full-screen layout: the transcript r ## Herdr host mode -When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model chrome, and keeps only the transcript, last user task, current progress, and composer. Herdr remains responsible for workspace, tab, pane, and attention navigation, while nanobot keeps its application-level session, new-chat, and branch commands. +When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model/task chrome, and keeps only the transcript, compact progress, and composer. Herdr remains responsible for workspace, tab, pane, task, and attention navigation, while nanobot keeps its application-level session, new-chat, and branch commands. The TUI reports its WebSocket session ID, model, Git branch, workspace, last task, and current action through Herdr's supported pane CLI. Sending work reports `working`; a persisted explicit nanobot goal block reports `blocked`; a completed turn reports `idle`; exit releases lifecycle authority. The gateway session remains the durable transcript and resume path. Standalone terminals keep the richer full-screen navigation described below. @@ -27,6 +27,10 @@ When you scroll away from the latest output, the scrollbar and `Ctrl+End` hint a you return to the bottom. Large pastes are represented by a short editable placeholder in the composer; nanobot sends the original text unchanged. +While a turn is running, the composer remains available for steering and uses `Steer this turn…` +as its prompt. The footer shows the lifecycle and elapsed time without repeating the latest tool +activity already visible in the transcript. + Type `/` to discover slash commands published by the connected gateway. Use the arrow keys to move, `Tab` to complete, and `Esc` to close the menu. diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index fa3305fb3..4e55242b2 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" -import { CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core" +import { BoxRenderable, CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core" import { MockTreeSitterClient, createTestRenderer, @@ -1279,9 +1279,9 @@ describe("NanobotTui layout", () => { 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) + expect(occurrences(frame, "Steer this turn…")).toBeLessThanOrEqual(1) if (width >= 30 && height >= 9) { - expect(occurrences(frame, "Ask nanobot anything")).toBe(1) + expect(occurrences(frame, "Steer this turn…")).toBe(1) } expect(occurrences(frame, "nanobot · test/model")).toBe(height >= 14 ? 1 : 0) } @@ -1774,13 +1774,18 @@ describe("NanobotTui layout", () => { expect(frame).toMatch(/Thinking\s+0s/u) expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u) expect(frame).not.toContain("hidden reasoning") - const status = (app as unknown as { + const ui = app as unknown as { status: { content: { chunks: Array<{ fg?: { toInts(): number[] } }> } plainText: string } - }).status + composer: TextareaRenderable + composerFrame: BoxRenderable + } + const status = ui.status expect(status.plainText).toMatch(/^Thinking\s+0s/u) + expect(ui.composer.placeholder).toBe("Steer this turn…") + expect(ui.composerFrame.height).toBe(3) const shimmerColors = new Set( status.content.chunks .slice(0, "Thinking".length) @@ -1803,7 +1808,11 @@ describe("NanobotTui layout", () => { expect(frame).toMatch(/Working\s+0s/u) expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u) expect(frame).toContain("› Running pwd") + expect(occurrences(frame, "pwd")).toBe(1) + expect(status.plainText).not.toContain("pwd") app.accept({ event: "turn_end", chat_id: "chat" }) + await setup.flush() + expect(ui.composer.placeholder).toBe("Ask nanobot anything") }) test("folds long tool traces without discarding their details", async () => { @@ -1823,8 +1832,9 @@ describe("NanobotTui layout", () => { await setup.renderOnce() let frame = setup.captureCharFrame() - expect(frame).toContain("5 earlier steps · Ctrl+O expand") + expect(frame).toContain("7 earlier steps · Ctrl+O expand") expect(frame).not.toContain("tool_0") + expect(frame).toContain("tool_7") expect(frame).toContain("tool_9") setup.mockInput.pressKey("O", { ctrl: true }) @@ -1860,6 +1870,37 @@ describe("NanobotTui layout", () => { app.accept({ event: "turn_end", chat_id: "chat" }) }) + test("groups consecutive file activity only in the collapsed preview", async () => { + setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) + const app = mount(setup) + app.accept({ + event: "message", + chat_id: "chat", + text: "file progress", + kind: "tool_hint", + tool_events: Array.from({ length: 6 }, (_, index) => ({ + phase: "end" as const, + call_id: `read-${index}`, + name: "read_file", + arguments: { path: `/tmp/nanobot-workspace/src/file-${index}.ts` }, + })), + }) + await setup.renderOnce() + let frame = setup.captureCharFrame() + + expect(frame).toContain("6 steps · Ctrl+O expand") + expect(frame).toContain("✓ Read 6 files") + expect(frame).not.toContain("src/file-0.ts") + + setup.mockInput.pressKey("O", { ctrl: true }) + await setup.renderOnce() + frame = setup.captureCharFrame() + + expect(frame).not.toContain("Read 6 files") + expect(frame).toContain("src/file-0.ts") + expect(frame).toContain("src/file-5.ts") + }) + test("supports keyboard transcript navigation without rebuilding the layout", async () => { setup = await createRenderer({ width: 64, height: 16, screenMode: "alternate-screen" }) const app = mount(setup) @@ -2235,6 +2276,10 @@ describe("NanobotTui in a Herdr pane", () => { new MockTreeSitterClient({ autoResolveTimeout: 0 }), host, ) + const ui = app as unknown as { + composer: TextareaRenderable + composerFrame: BoxRenderable + } await setup.mockInput.typeText("/") await setup.flush() @@ -2243,6 +2288,7 @@ describe("NanobotTui in a Herdr pane", () => { expect(commandFrame).toContain("/new-chat") expect(commandFrame).toContain("/branch") setup.mockInput.pressEscape() + ui.composer.setText("") app.accept({ event: "attached", chat_id: "chat" }) app.accept({ @@ -2259,6 +2305,12 @@ describe("NanobotTui in a Herdr pane", () => { kind: "tool_hint", tool_events: [{ phase: "end", call_id: "read", name: "read_file", arguments: { path: "app.ts" } }], }) + await setup.flush() + const activeFrame = setup.captureCharFrame() + expect(occurrences(activeFrame, "› Ship the Herdr integration")).toBe(1) + expect(occurrences(activeFrame, "app.ts")).toBe(1) + expect(ui.composer.placeholder).toBe("Steer this turn…") + expect(ui.composerFrame.height).toBe(3) app.accept({ event: "turn_end", chat_id: "chat", @@ -2273,7 +2325,7 @@ describe("NanobotTui in a Herdr pane", () => { const frame = setup.captureCharFrame() expect(sessions).toEqual(["chat"]) - expect(frame).toContain("› Ship the Herdr integration") + expect(occurrences(frame, "› Ship the Herdr integration")).toBe(1) expect(frame).not.toContain(">_ nanobot") expect(frame).not.toContain("test/model") expect(states.some(({ state }) => state === "working")).toBe(true) diff --git a/tui/src/app.ts b/tui/src/app.ts index d6ed34190..4483d8516 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -160,6 +160,7 @@ const LIGHT: Palette = { } const COMPOSER_PLACEHOLDER = "Ask nanobot anything" +const ACTIVE_COMPOSER_PLACEHOLDER = "Steer this turn…" const SHIMMER_PAUSE = 16 const SHIMMER_BAND = 4 const SHIMMER_INTERVAL_MS = 80 @@ -476,6 +477,7 @@ export class NanobotTui { treeSitterClient, (state) => this.handleTranscriptNavigation(state), !host.hosted, + options.workspace, ) this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) this.commandMenu.setCommands([], LOCAL_COMMANDS) @@ -719,7 +721,7 @@ export class NanobotTui { this.shell.add(this.branchMenu.root) this.shell.add(this.contextPanel.root) this.shell.add(this.runtimeControls.menuRoot) - this.shell.add(this.title) + if (!host.hosted) this.shell.add(this.title) this.shell.add(this.queuePreview.root) this.shell.add(this.composerFrame) this.shell.add(statusRow) @@ -1262,6 +1264,7 @@ export class NanobotTui { } this.activeTurn = active this.updateMeta() + this.syncComposerPlaceholder() if (active) { this.activeStartedAt = startedAt ?? Date.now() this.shimmerFrame = 0 @@ -1280,14 +1283,11 @@ export class NanobotTui { private renderActiveStatus(): void { const elapsed = formatElapsed(Date.now() - this.activeStartedAt) - const progress = this.lastProgress - ? ` · ${this.lastProgress.replace(/^\s*[·›✓×]\s*/u, "")}` - : "" const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : "" const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : "" this.status.content = shimmerStatus( this.activeLabel, - ` ${elapsed}${progress}${queued}${navigation}`, + ` ${elapsed}${queued}${navigation}`, this.shimmerFrame, this.palette, ) @@ -1659,11 +1659,6 @@ export class NanobotTui { private updateTitle(): void { if (this.host.hosted) { - this.titleText.maxWidth = Math.max(8, this.renderer.width - 4) - this.titleText.content = this.currentTask ? `› ${this.currentTask}` : "" - // In a hosted pane this is the resume anchor, not decorative chrome. - // Keep it visible even when Herdr temporarily makes the pane very short. - this.title.visible = Boolean(this.currentTask) this.syncHostMetadata() return } @@ -1774,7 +1769,9 @@ export class NanobotTui { ? null : this.sessionMenu.visible ? "Search sessions" - : this.branchMenu.visible ? "Search branch points" : COMPOSER_PLACEHOLDER + : this.branchMenu.visible + ? "Search branch points" + : this.activeTurn ? ACTIVE_COMPOSER_PLACEHOLDER : COMPOSER_PLACEHOLDER if (this.composer.placeholder !== placeholder) this.composer.placeholder = placeholder } diff --git a/tui/src/tool-renderers.test.ts b/tui/src/tool-renderers.test.ts index 730491aa3..aa88a8027 100644 --- a/tui/src/tool-renderers.test.ts +++ b/tui/src/tool-renderers.test.ts @@ -26,4 +26,28 @@ describe("tool renderers", () => { expect(renderToolEvent({ phase: "end", name: "apply_patch", arguments: { path: "app.ts" } })) .toBe(" ✓ Edited app.ts") }) + + test("shortens file paths relative to the workspace while preserving the useful tail", () => { + const workspace = String.raw`C:\workspace\nanobot` + expect(renderToolEvent({ + phase: "end", + name: "read_file", + arguments: { path: String.raw`C:\workspace\nanobot\tui\src\app.ts` }, + }, { workspace })).toBe(" ✓ Read tui/src/app.ts") + expect(renderToolEvent({ + phase: "end", + name: "read_file", + arguments: { + path: String.raw`C:\workspace\nanobot\.worktrees\feature\nanobot\providers\fallback_provider.py`, + }, + }, { workspace })).toBe(" ✓ Read …/feature/nanobot/providers/fallback_provider.py") + }) + + test("summarizes subagent delegation without exposing raw argument JSON", () => { + expect(renderToolEvent({ + phase: "end", + name: "spawn", + arguments: { task: "Simplify the fallback provider implementation" }, + })).toBe(" ✓ Delegated Simplify the fallback provider implementation") + }) }) diff --git a/tui/src/tool-renderers.ts b/tui/src/tool-renderers.ts index 328a87fbe..9b9479eb3 100644 --- a/tui/src/tool-renderers.ts +++ b/tui/src/tool-renderers.ts @@ -1,5 +1,11 @@ import type { ToolProgressEvent } from "./protocol" +export interface ToolRenderOptions { + workspace?: string +} + +const PATH_DETAIL_LIMIT = 52 + export function mergeToolEvent( previous: ToolProgressEvent | undefined, next: ToolProgressEvent, @@ -14,13 +20,18 @@ export function mergeToolEvent( } } -export function renderToolEvent(event: ToolProgressEvent): string { +export function renderToolEvent( + event: ToolProgressEvent, + options: ToolRenderOptions = {}, +): string { const phase = event.phase || "start" const marker = phase === "error" ? "×" : phase === "end" ? "✓" : "›" const name = (event.name || "tool").trim() const args = record(event.arguments) const result = record(event.result) - const detail = phase === "error" ? compact(event.error) : toolDetail(name, args, result) + const detail = phase === "error" + ? compact(event.error) + : toolDetail(name, args, result, options) return ` ${marker} ${toolLabel(name, phase, args)}${detail ? ` ${detail}` : ""}` } @@ -39,6 +50,9 @@ function toolLabel( if (/^(?:read_file|read)$/u.test(name)) return "Read" if (/^(?:write_file|write)$/u.test(name)) return phase === "end" ? "Edited" : "Editing" if (/^(?:edit_file|apply_patch|edit)$/u.test(name)) return phase === "end" ? "Edited" : "Editing" + if (/^(?:spawn|spawn_agent)$/u.test(name)) { + return phase === "end" ? "Delegated" : phase === "error" ? "Delegation failed" : "Delegating" + } if (name === "web_search") return "Search web" if (name === "web_fetch") return "Fetch" return name @@ -48,13 +62,15 @@ function toolDetail( name: string, args: Record, result: Record, + options: ToolRenderOptions, ): string { if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) { return compact(args.command ?? args.cmd ?? result.output) } if (/^(?:read_file|write_file|edit_file|apply_patch|read|write|edit)$/u.test(name)) { - return compact(args.path ?? args.file_path ?? result.path) + return compactPath(args.path ?? args.file_path ?? result.path, options.workspace) } + if (/^(?:spawn|spawn_agent)$/u.test(name)) return compact(args.label ?? args.task, 56) if (name === "web_search") return compact(args.query ?? args.q) if (name === "web_fetch") return compact(args.url) if (/session/u.test(name)) return compact(args.session_key ?? args.chat_id ?? args.query) @@ -68,8 +84,40 @@ function record(value: unknown): Record { : {} } -function compact(value: unknown): string { +function compact(value: unknown, limit = 88): string { if (value == null || value === "") return "" - const text = typeof value === "string" ? value : JSON.stringify(value) - return text.length > 88 ? `${text.slice(0, 85)}…` : text + const serialized = typeof value === "string" ? value : JSON.stringify(value) + const text = (serialized || String(value)).replace(/\s+/gu, " ").trim() + return text.length > limit ? `${text.slice(0, limit - 1)}…` : text +} + +function compactPath(value: unknown, workspace?: string): string { + if (value == null || value === "") return "" + const normalized = String(value).replace(/\\/gu, "/").replace(/\/+$/u, "") + const relative = workspaceRelativePath(normalized, workspace) + if (relative.length <= PATH_DETAIL_LIMIT) return relative + + const parts = relative.split("/").filter(Boolean) + let tail = parts.pop() || relative + if (tail.length + 1 >= PATH_DETAIL_LIMIT) { + return `…${tail.slice(-(PATH_DETAIL_LIMIT - 1))}` + } + while (parts.length) { + const candidate = `${parts.at(-1)}/${tail}` + if (`…/${candidate}`.length > PATH_DETAIL_LIMIT) break + tail = candidate + parts.pop() + } + return `…/${tail}` +} + +function workspaceRelativePath(path: string, workspace?: string): string { + if (!workspace) return path + const base = workspace.replace(/\\/gu, "/").replace(/\/+$/u, "") + const caseInsensitive = /^[a-z]:\//iu.test(path) || /^[a-z]:\//iu.test(base) + const comparedPath = caseInsensitive ? path.toLowerCase() : path + const comparedBase = caseInsensitive ? base.toLowerCase() : base + if (comparedPath === comparedBase) return path.split("/").at(-1) || path + if (comparedPath.startsWith(`${comparedBase}/`)) return path.slice(base.length + 1) + return path } diff --git a/tui/src/transcript.ts b/tui/src/transcript.ts index 0b4675cda..1a934fdc9 100644 --- a/tui/src/transcript.ts +++ b/tui/src/transcript.ts @@ -44,7 +44,13 @@ interface Activity { events: Map } -const ACTIVITY_PREVIEW_LINES = 6 +interface ActivityPreviewItem { + text: string + steps: number + group?: string +} + +const ACTIVITY_PREVIEW_LINES = 4 // OpenTUI renders at 30 FPS. Re-parsing the entire Markdown buffer for every // provider token turns long answers into quadratic work without producing any // additional visible frames. Paint the first token immediately, then coalesce @@ -78,6 +84,7 @@ export class Transcript { private readonly treeSitterClient: TreeSitterClient, private readonly onNavigationChange?: (state: TranscriptNavigation) => void, private readonly showHeader = true, + private readonly workspace = "", ) { this.root = new ScrollBoxRenderable(renderer, { id: "nanobot-tui-transcript", @@ -424,7 +431,7 @@ export class Transcript { const key = event.call_id ? `tool:${event.call_id}` : "" const merged = key ? mergeToolEvent(activity.events.get(key), event) : event if (key) activity.events.set(key, merged) - return { key, line: renderToolEvent(merged) } + return { key, line: renderToolEvent(merged, { workspace: this.workspace }) } }) const lines = events.length > 0 ? projected.map(({ line }) => line).filter(Boolean) @@ -448,9 +455,14 @@ export class Transcript { activity.text.content = activity.lines.join("\n") return } - const visible = activity.lines.slice(-(ACTIVITY_PREVIEW_LINES - 1)) - const hidden = activity.lines.length - visible.length - activity.text.content = [` … ${hidden} earlier steps · Ctrl+O expand`, ...visible].join("\n") + const visible = activityPreview(activity.lines).slice(-(ACTIVITY_PREVIEW_LINES - 1)) + const visibleSteps = visible.reduce((total, item) => total + item.steps, 0) + const hidden = activity.lines.length - visibleSteps + const disclosure = hidden > 0 ? `${hidden} earlier steps` : `${activity.lines.length} steps` + activity.text.content = [ + ` … ${disclosure} · Ctrl+O expand`, + ...visible.map((item) => item.text), + ].join("\n") } private createText( @@ -542,6 +554,22 @@ function cleanProgress(value: string): string { return text ? ` · ${text}` : "" } +function activityPreview(lines: readonly string[]): ActivityPreviewItem[] { + const preview: ActivityPreviewItem[] = [] + for (const line of lines) { + const match = line.match(/^ ([›✓]) (Read|Edited|Editing) /u) + const group = match ? `${match[1]}:${match[2]}` : undefined + const previous = preview.at(-1) + if (match && group && previous?.group === group) { + previous.steps += 1 + previous.text = ` ${match[1]} ${match[2]} ${previous.steps} files` + continue + } + preview.push({ text: line, steps: 1, ...(group ? { group } : {}) }) + } + return preview +} + function formatDiffStat(edit: FileEditEvent): string { const added = typeof edit.added === "number" ? `+${edit.added}` : "" const deleted = typeof edit.deleted === "number" ? `-${edit.deleted}` : ""