From 79d51be71fa759ffb1b22478870930c4dcf6d745 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:08:23 +0800 Subject: [PATCH] feat(tui): integrate with Herdr host --- nanobot/channels/websocket/runtime.py | 8 +- .../websocket/tests/test_websocket_channel.py | 39 +++- nanobot/session/goal_state.py | 8 +- tests/agent/tools/test_long_task.py | 1 + tests/session/test_goal_state.py | 19 ++ tui/README.md | 10 +- tui/src/app.test.ts | 87 ++++++++- tui/src/app.ts | 161 +++++++++++++++- tui/src/host.test.ts | 82 +++++++++ tui/src/host.ts | 174 ++++++++++++++++++ tui/src/index.ts | 7 +- tui/src/protocol.test.ts | 17 +- tui/src/protocol.ts | 6 +- tui/src/tool-renderers.test.ts | 9 +- tui/src/tool-renderers.ts | 20 +- tui/src/transcript.ts | 4 +- webui/src/lib/types.ts | 2 + 17 files changed, 622 insertions(+), 32 deletions(-) create mode 100644 tui/src/host.test.ts create mode 100644 tui/src/host.ts diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 64e5dfb25..9d3e8fcec 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -539,8 +539,8 @@ class WebSocketChannel(BaseChannel): self._webui_connections.discard(connection) self._discard_webui_request_lock_if_idle(connection) - async def _maybe_push_active_goal_state(self, chat_id: str) -> None: - """Replay an active sustained goal from session metadata after *chat_id* is subscribed. + async def _maybe_push_persisted_goal_state(self, chat_id: str) -> None: + """Replay actionable goal state after *chat_id* is subscribed. Goal metadata lives on the session JSONL and survives gateway restarts, but connected clients normally see it via ``goal_state`` / ``turn_end`` frames. @@ -554,7 +554,7 @@ class WebSocketChannel(BaseChannel): if not isinstance(meta, dict): meta = {} blob = goal_state_ws_blob(cast(dict[str, Any], meta)) - if not blob.get("active"): + if not blob.get("active") and blob.get("status") != "blocked": return await self.send_goal_state(chat_id, blob) @@ -572,7 +572,7 @@ class WebSocketChannel(BaseChannel): async def _hydrate_after_subscribe(self, chat_id: str) -> None: """Replay persisted or actively running per-chat state after subscribe.""" - await self._maybe_push_active_goal_state(chat_id) + await self._maybe_push_persisted_goal_state(chat_id) await self._maybe_push_turn_run_wall_clock(chat_id) async def _send_event( diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 9f264bf31..93c952c90 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -3186,7 +3186,7 @@ async def test_maybe_push_active_goal_state_noop_without_session_manager() -> No channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus)) mock_ws = AsyncMock() channel._attach(mock_ws, "chat-1") - await channel._maybe_push_active_goal_state("chat-1") + await channel._maybe_push_persisted_goal_state("chat-1") mock_ws.send.assert_not_called() @@ -3202,7 +3202,7 @@ async def test_maybe_push_active_goal_state_skips_when_no_goal_on_disk() -> None ) mock_ws = AsyncMock() channel._attach(mock_ws, "chat-1") - await channel._maybe_push_active_goal_state("chat-1") + await channel._maybe_push_persisted_goal_state("chat-1") mock_ws.send.assert_not_called() @@ -3227,7 +3227,7 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk() ) mock_ws = AsyncMock() channel._attach(mock_ws, "chat-1") - await channel._maybe_push_active_goal_state("chat-1") + await channel._maybe_push_persisted_goal_state("chat-1") mock_ws.send.assert_awaited_once() body = json.loads(mock_ws.send.await_args.args[0]) assert body["event"] == "goal_state" @@ -3237,6 +3237,39 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk() assert body["goal_state"]["ui_summary"] == "Docs" +@pytest.mark.asyncio +async def test_maybe_push_goal_state_restores_blocked_attention_on_disk() -> None: + bus = MagicMock() + sm = MagicMock() + sm.read_session_file.return_value = { + "metadata": { + "goal_state": { + "status": "blocked", + "objective": "deploy safely", + "ui_summary": "Approval required", + }, + }, + "messages": [], + } + channel = WebSocketChannel( + {"enabled": True, "allowFrom": ["*"]}, + bus, + gateway=_basic_handler(bus, session_manager=sm), + ) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel._maybe_push_persisted_goal_state("chat-1") + + body = json.loads(mock_ws.send.await_args.args[0]) + assert body["goal_state"] == { + "active": False, + "status": "blocked", + "ui_summary": "Approval required", + "objective": "deploy safely", + } + + @pytest.mark.asyncio async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> None: bus = MagicMock() diff --git a/nanobot/session/goal_state.py b/nanobot/session/goal_state.py index 4c2193fcf..8fbbba597 100644 --- a/nanobot/session/goal_state.py +++ b/nanobot/session/goal_state.py @@ -98,16 +98,20 @@ def goal_state_runtime_lines(metadata: Mapping[str, Any] | None) -> list[str]: def goal_state_ws_blob(metadata: Mapping[str, Any] | None) -> dict[str, Any]: """JSON-safe snapshot for WebSocket ``goal_state`` events (one chat_id per frame).""" goal = parse_goal_state(_session_goal_raw(metadata)) if metadata else None - if isinstance(goal, dict) and goal.get("status") == "active": + if isinstance(goal, dict) and goal.get("status") in {"active", "blocked"}: + status = str(goal.get("status")) objective = str(goal.get("objective") or "").strip() if len(objective) > _MAX_OBJECTIVE_WS: objective = objective[:_MAX_OBJECTIVE_WS].rstrip() + "…" summary = str(goal.get("ui_summary") or "").strip()[:120] - blob: dict[str, Any] = {"active": True} + blob: dict[str, Any] = {"active": status == "active", "status": status} if summary: blob["ui_summary"] = summary if objective: blob["objective"] = objective + recap = str(goal.get("recap") or "").strip()[:240] + if recap: + blob["recap"] = recap return blob return {"active": False} diff --git a/tests/agent/tools/test_long_task.py b/tests/agent/tools/test_long_task.py index 4aa94c685..0889b061b 100644 --- a/tests/agent/tools/test_long_task.py +++ b/tests/agent/tools/test_long_task.py @@ -404,6 +404,7 @@ async def test_goal_state_events_publish_active_then_inactive(tmp_path): assert isinstance(call.event, GoalStateSyncEvent) assert call.event.goal_state == { "active": True, + "status": "active", "ui_summary": "alpha", "objective": "Objective alpha", } diff --git a/tests/session/test_goal_state.py b/tests/session/test_goal_state.py index aef274ea2..72ab7f121 100644 --- a/tests/session/test_goal_state.py +++ b/tests/session/test_goal_state.py @@ -100,11 +100,30 @@ def test_goal_state_ws_blob_active_shape(): } assert goal_state_ws_blob(meta) == { "active": True, + "status": "active", "ui_summary": "feat", "objective": "Build feature.", } +def test_goal_state_ws_blob_preserves_blocked_state_for_host_attention(): + meta = { + GOAL_STATE_KEY: { + "status": "blocked", + "objective": "Deploy safely.", + "ui_summary": "Approval required", + "recap": "Production access is required.", + }, + } + assert goal_state_ws_blob(meta) == { + "active": False, + "status": "blocked", + "ui_summary": "Approval required", + "objective": "Deploy safely.", + "recap": "Production access is required.", + } + + def test_sustained_goal_active_false_when_missing_or_completed(): assert sustained_goal_active(None) is False assert sustained_goal_active({}) is False diff --git a/tui/README.md b/tui/README.md index f83ee5f54..b91689147 100644 --- a/tui/README.md +++ b/tui/README.md @@ -9,9 +9,15 @@ bun run --cwd tui test bun run --cwd tui build ``` -`nanobot agent` launches this client, attaches to the shared local gateway or starts it in the background, and passes an authenticated local endpoint through environment variables. Exiting one client leaves that gateway available to other terminals and the WebUI; stop it explicitly with `nanobot gateway stop`. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`. +`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes an authenticated local endpoint through environment variables. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`. -The renderer uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen. +Standalone terminals use OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen. + +## 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; hosted command discovery therefore omits the duplicate session/new-chat/branch controls. + +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. The model preset and workspace access labels above the composer are live controls. Click either label, then click a choice; arrow keys, `Enter`, and `Esc` provide the same flow without a mouse. diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 6beb4b225..8a17d602d 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -8,6 +8,7 @@ import { 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", @@ -1588,7 +1589,7 @@ describe("NanobotTui layout", () => { expect(frame).toMatch(/Working\s+0s/u) expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u) - expect(frame).toContain("› Command pwd") + expect(frame).toContain("› Running pwd") app.accept({ event: "turn_end", chat_id: "chat" }) }) @@ -1881,6 +1882,90 @@ describe("NanobotTui layout", () => { }) }) +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", + }) + + 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"], { diff --git a/tui/src/app.ts b/tui/src/app.ts index 60ce86d22..3b9532bb1 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -75,6 +75,7 @@ import { type FooterMode, type FooterHintTheme, } from "./footer-hints" +import { createTuiHost, currentGitBranch, type TuiHost } from "./host" interface AppOptions { wsUrl: string @@ -84,6 +85,8 @@ interface AppOptions { model: string modelPreset: string workspace: string + hostWorkspace?: string + branch?: string version: string access: string theme: "auto" | ThemeMode @@ -321,6 +324,10 @@ function formatElapsed(milliseconds: number): string { return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s` } +function singleLine(value: string, limit = 120): string { + return value.replace(/\s+/gu, " ").trim().slice(0, limit) +} + async function copyWithSystemClipboard(text: string): Promise { const commands = process.platform === "darwin" ? [["pbcopy"]] @@ -359,6 +366,8 @@ export class NanobotTui { private readonly composer: TextareaRenderable private readonly status: TextRenderable private readonly meta: TextRenderable + private readonly host: TuiHost + private readonly localCommands: TuiCommand[] private readonly draft = new ComposerDraft() private readonly promptQueue = new PromptQueue() private palette: Palette @@ -411,30 +420,43 @@ export class NanobotTui { private readonly silentCommandTurns = new Set() private currentFileEdits: FileEditEvent[] = [] private lastFileEdits: FileEditEvent[] = [] + private currentTask = "" + private currentAction = "" + private hostBlocked = false + private hostWorkspace: string + private hostBranch: string private constructor( renderer: CliRenderer, private readonly options: AppOptions, client?: ChatClient, treeSitterClient = getTreeSitterClient(), + host: TuiHost = createTuiHost({}), ) { this.renderer = renderer this.defaultModelName = options.model this.defaultModelPreset = options.modelPreset this.modelName = options.model this.modelPreset = options.modelPreset + this.hostWorkspace = options.hostWorkspace || options.workspace + this.hostBranch = options.branch || "" this.sessionModelPreset = options.chatId ? undefined : null this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null this.activeThemeMode = this.resolveThemeMode(renderer.themeMode) this.palette = this.activeThemeMode === "light" ? LIGHT : DARK + this.host = host + this.localCommands = host.hosted + ? LOCAL_COMMANDS.filter(({ command }) => command === "/context" || command === "/diff") + : LOCAL_COMMANDS this.transcript = new Transcript( renderer, transcriptTheme(this.palette, this.backgroundKnown), treeSitterClient, (state) => this.handleTranscriptNavigation(state), + !host.hosted, ) this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) - this.commandMenu.setCommands([], LOCAL_COMMANDS) + this.commandMenu.setCommands([], this.localCommands) this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette)) this.mentionMenu = new MentionMenu(renderer, commandMenuTheme(this.palette)) this.branchMenu = new BranchMenu(renderer, commandMenuTheme(this.palette)) @@ -523,9 +545,11 @@ export class NanobotTui { }, ) this.title.add(this.titleText) - this.title.add(this.runtimeControls.modelText) - this.title.add(this.runtimeControls.accessText) - this.title.add(this.runtimeControls.contextText) + if (!host.hosted) { + this.title.add(this.runtimeControls.modelText) + this.title.add(this.runtimeControls.accessText) + this.title.add(this.runtimeControls.contextText) + } const composerSurface = this.composerSurface() this.composerFrame = new BoxRenderable(renderer, { id: "nanobot-tui-composer-frame", @@ -633,18 +657,20 @@ export class NanobotTui { this.handleResize() this.composer.focus() this.transcript.header(options) + this.syncHostMetadata() } static async create(options: AppOptions): Promise { + const host = createTuiHost() const renderer = await createCliRenderer({ targetFps: 30, exitOnCtrlC: false, useMouse: true, - screenMode: "alternate-screen", + screenMode: host.hosted ? "main-screen" : "alternate-screen", externalOutputMode: "passthrough", consoleMode: "disabled", }) - return NanobotTui.mount(renderer, options) + return NanobotTui.mount(renderer, options, undefined, undefined, host) } static mount( @@ -652,8 +678,9 @@ export class NanobotTui { options: AppOptions, client?: ChatClient, treeSitterClient?: TreeSitterClient, + host?: TuiHost, ): NanobotTui { - return new NanobotTui(renderer, options, client, treeSitterClient) + return new NanobotTui(renderer, options, client, treeSitterClient, host) } async start(): Promise { @@ -666,6 +693,7 @@ export class NanobotTui { if (this.options.theme === "auto" && this.renderer.themeMode) { this.applyTheme(this.renderer.themeMode) } + this.host.reportState("unknown", "Connecting") this.client.connect() void this.loadCommands() void this.loadMentions() @@ -770,6 +798,8 @@ export class NanobotTui { this.mentionMenu.hide() this.recordPrompt(prompt.content) this.transcript.user(prompt.content, turnId) + this.hostBlocked = false + this.setCurrentTask(prompt.content) if (steering) { this.status.content = `Steering current turn${this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""}` this.updateMeta() @@ -787,7 +817,9 @@ export class NanobotTui { this.lastProgress = "" this.activeLabel = "Thinking" this.currentFileEdits = [] + this.setCurrentAction("Thinking") this.setActive(true, startedAt) + this.reportHostWorking() } private reconcileTurnOwnership(event: { @@ -809,6 +841,7 @@ export class NanobotTui { accept(event: InboundEvent): void { if (event.event === "attached") { void rememberChat(this.options.statePath, event.chat_id) + this.host.reportSession(event.chat_id) if (event.usage) this.lastUsage = event.usage if (event.model_preset !== undefined) { this.applyModelPreset(event.model_preset) @@ -855,12 +888,17 @@ export class NanobotTui { attachments.length ? `Attachments: ${attachments.join(", ")}` : "", ].filter(Boolean).join("\n") if (this.transcript.user(content, event.turn_id)) this.recordPrompt(event.text) + this.hostBlocked = false + this.setCurrentTask(event.text) this.reconcileTurnOwnership(event) + if (this.activeTurn) this.reportHostWorking() return } case "delta": this.setActive(true) this.activeLabel = "Writing" + if (!this.currentAction) this.setCurrentAction("Writing") + this.reportHostWorking() this.turnHadAnswer = true this.transcript.stream(event.text) return @@ -881,7 +919,10 @@ export class NanobotTui { if (event.kind) { this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking" this.lastProgress = this.transcript.progress(event.text, event.tool_events) + if (this.lastProgress) this.setCurrentAction(this.lastProgress) + else if (!this.currentAction) this.setCurrentAction(this.activeLabel) this.setActive(true) + this.reportHostWorking() } else { this.finalMessage = event.text } @@ -891,7 +932,9 @@ export class NanobotTui { this.currentFileEdits = mergeFileEdits(this.currentFileEdits, event.edits) if (this.diffViewer.visible) this.diffViewer.update(this.currentFileEdits) this.lastProgress = this.transcript.fileEdits(event.edits) + this.setCurrentAction(this.lastProgress || "Editing") this.setActive(true) + this.reportHostWorking() return case "reasoning_delta": this.activeLabel = "Thinking" @@ -925,6 +968,7 @@ export class NanobotTui { if (typeof event.context_window_tokens === "number") { this.contextWindowTokens = event.context_window_tokens } + this.applyHostGoalState(event.goal_state) this.updateTitle() this.setActive(false) // A synthetic/rehydrated turn may already be idle, in which case @@ -934,6 +978,7 @@ export class NanobotTui { ? `${(event.latency_ms / 1000).toFixed(1)}s` : "" this.status.content = this.readyStatus() + this.reportHostResting() if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id) this.sendNextFollowUp() return @@ -942,12 +987,17 @@ export class NanobotTui { if (event.status === "running") { if (event.turn_id) this.activeTurnId = event.turn_id this.activeLabel = "Working" + if (!this.currentAction) this.setCurrentAction("Working") this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined) + this.reportHostWorking() } else { this.setActive(false) + this.reportHostResting() } return case "goal_state": + this.applyHostGoalState(event.goal_state) + if (!this.activeTurn) this.reportHostResting() return case "turn_model_updated": if (typeof event.context_window_tokens === "number") { @@ -991,6 +1041,8 @@ export class NanobotTui { this.turnHadAnswer = false this.restoreQueuedPrompts() this.setActive(false) + this.setCurrentAction(event.reason || event.detail || "Error") + this.reportHostResting() return } } @@ -1020,6 +1072,13 @@ export class NanobotTui { this.historyHasMore = history.hasMoreBefore this.transcript.history(history.messages) this.restorePromptHistory(history.messages) + const reversedHistory = [...history.messages].reverse() + const lastUser = reversedHistory.find((message) => message.role === "user") + if (lastUser) this.setCurrentTask(lastUser.content) + const lastActivity = reversedHistory.find((message) => message.role === "activity") + if (lastActivity) { + this.setCurrentAction(lastActivity.fileEdits?.length ? "Edited" : lastActivity.content) + } this.lastFileEdits = latestTurnFileEdits(history.messages) if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits) } @@ -1031,6 +1090,7 @@ export class NanobotTui { this.ready = true if (!this.activeTurn) { this.status.content = this.readyStatus() + this.reportHostResting() } } } @@ -1044,22 +1104,26 @@ export class NanobotTui { private handleStatus(status: ConnectionStatus, detail?: string): void { if (status === "connected") { this.ready = false + this.host.reportState("unknown", "Connecting") this.status.content = "Connected · preparing chat…" return } if (status === "connecting") { this.ready = false + this.host.reportState("unknown", detail ? "Reconnecting" : "Connecting") if (detail) this.setActive(false) this.status.content = detail ? "Reconnecting…" : "Connecting…" return } if (status === "error") { this.setActive(false) + this.host.reportState("unknown", detail || "Connection error") this.status.content = detail || "Connection error" return } if (!this.quitting) { this.setActive(false) + this.host.reportState("unknown", "Disconnected") this.status.content = "Disconnected" } } @@ -1399,7 +1463,7 @@ export class NanobotTui { this.resizeComposer() this.contextPanel.resize(this.renderer.height) this.diffViewer.resize(this.renderer.width) - this.title.visible = this.renderer.height >= 14 + if (!this.host.hosted) this.title.visible = this.renderer.height >= 14 this.runtimeControls.resize(this.renderer.width) this.updateTitle() this.updateMeta() @@ -1467,6 +1531,13 @@ 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}` : "" + this.title.visible = Boolean(this.currentTask) && this.renderer.height >= 8 + this.syncHostMetadata() + return + } const identity = this.sessionTitle.trim() || "nanobot" this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38)) this.titleText.content = identity @@ -1477,6 +1548,61 @@ export class NanobotTui { : ""} ctx` this.runtimeControls.updateModel(this.modelName, this.modelPreset) this.runtimeControls.updateContext(context) + this.syncHostMetadata() + } + + private setCurrentTask(task: string): void { + const next = singleLine(task) + if (!next || next === this.currentTask) return + this.currentTask = next + this.updateTitle() + } + + private setCurrentAction(action: string): void { + const next = singleLine(action.replace(/^\s*[·›✓×]\s*/u, ""), 80) + if (!next || next === this.currentAction) return + this.currentAction = next + this.syncHostMetadata() + } + + private clearHostContext(): void { + this.currentTask = "" + this.currentAction = "" + this.hostBlocked = false + this.updateTitle() + } + + private syncHostMetadata(): void { + const model = [this.modelPreset, this.modelName].filter(Boolean).join(" · ") + this.host.reportMetadata({ + model, + branch: this.hostBranch, + workspace: this.hostWorkspace, + task: this.currentTask, + action: this.currentAction, + }) + } + + private applyHostGoalState(state: Record | undefined): void { + if (!state) return + this.hostBlocked = state.status === "blocked" + if (!this.hostBlocked) return + const summary = typeof state.ui_summary === "string" ? state.ui_summary : "" + const recap = typeof state.recap === "string" ? state.recap : "" + const objective = typeof state.objective === "string" ? state.objective : "" + this.setCurrentAction(summary || recap || objective || "Needs input") + this.host.reportState("blocked", summary || recap || objective || this.currentTask) + } + + private reportHostResting(): void { + this.host.reportState( + this.hostBlocked ? "blocked" : "idle", + this.hostBlocked ? this.currentAction || this.currentTask : this.currentAction, + ) + } + + private reportHostWorking(): void { + if (!this.hostBlocked) this.host.reportState("working", this.currentTask) } private resizeComposer(): void { @@ -1591,7 +1717,7 @@ export class NanobotTui { // Local navigation remains available against older gateways. } const commands = new Map(discovered.map((command) => [command.command, command])) - this.commandMenu.setCommands([...commands.values()], LOCAL_COMMANDS) + this.commandMenu.setCommands([...commands.values()], this.localCommands) this.syncCommandMenu() } @@ -1605,6 +1731,11 @@ export class NanobotTui { } private applyWorkspaceScope(scope: WorkspaceScopePayload): void { + if (scope.project_path) { + this.hostWorkspace = scope.project_path + this.hostBranch = currentGitBranch(scope.project_path) + this.syncHostMetadata() + } this.runtimeControls.updateWorkspaceScope(scope) this.updateTitle() if (!this.activeTurn && this.ready) this.status.content = this.readyStatus() @@ -1674,6 +1805,8 @@ export class NanobotTui { this.clearPromptQueue() this.sessionMetadataId += 1 this.sessionTitle = `Fork · ${preview.slice(0, 48)}` + this.clearHostContext() + this.setCurrentTask(preview) this.contextTokens = null this.lastUsage = null this.readyDetail = "" @@ -1758,6 +1891,7 @@ export class NanobotTui { this.ready = false this.clearPromptQueue() this.sessionMetadataId += 1 + this.clearHostContext() this.sessionTitle = sessionLabel(session) this.applySessionModel(session) this.applySessionScope(session) @@ -1791,6 +1925,7 @@ export class NanobotTui { this.ready = false this.clearPromptQueue() this.sessionMetadataId += 1 + this.clearHostContext() this.sessionTitle = "New chat" this.sessionModelPreset = null this.modelName = this.defaultModelName @@ -1836,13 +1971,17 @@ export class NanobotTui { if (!silent) this.recordPrompt(content) if (lifecycle === "agent_turn") { + this.hostBlocked = false + this.setCurrentTask(content) this.activeTurnId = turnId this.finalMessage = "" this.turnHadAnswer = false this.lastProgress = "" this.activeLabel = "Thinking" this.currentFileEdits = [] + this.setCurrentAction("Thinking") this.setActive(true) + this.reportHostWorking() } else if (lifecycle === "finalize_active_turn") { this.activeTurnId = null this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage) @@ -1850,10 +1989,12 @@ export class NanobotTui { this.finalMessage = "" this.turnHadAnswer = false this.setActive(false) + this.reportHostResting() this.status.content = "Resetting chat…" } else if (lifecycle === "stop_active_turn") { this.activeTurnId = null this.setActive(false) + this.reportHostResting() this.status.content = "Stopping…" } else if (!this.activeTurn) { this.status.content = `Running ${content.split(/\s+/u, 1)[0]}…` @@ -2020,6 +2161,7 @@ export class NanobotTui { this.quitting = true this.submitGeneration += 1 this.submitPending = false + this.host.release() this.client.close() this.renderer.destroy() } @@ -2028,6 +2170,7 @@ export class NanobotTui { if (this.shimmerTimer) clearInterval(this.shimmerTimer) this.transcript.destroy() this.diffViewer.destroy() + this.host.release() this.client.close() } } diff --git a/tui/src/host.test.ts b/tui/src/host.test.ts new file mode 100644 index 000000000..3afde871d --- /dev/null +++ b/tui/src/host.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" + +import { createTuiHost, currentGitBranch } from "./host" + +async function settle(): Promise { + await Bun.sleep(0) + await Bun.sleep(0) +} + +describe("TUI host integration", () => { + test("reads the current workspace branch without leaking git errors", () => { + expect(currentGitBranch(process.cwd())).not.toBe("") + expect(currentGitBranch("/definitely/not/a/repository")).toBe("") + }) + + test("standalone terminals remain a no-op", async () => { + const commands: string[][] = [] + const host = createTuiHost({}, async (command) => { commands.push([...command]) }) + + host.reportState("working", "task") + host.reportSession("chat") + host.reportMetadata({ model: "gpt", task: "task" }) + host.release() + await settle() + + expect(host.hosted).toBe(false) + expect(commands).toEqual([]) + }) + + test("reports semantic lifecycle, session identity, metadata, and release", async () => { + const commands: string[][] = [] + const host = createTuiHost( + { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2", HERDR_BIN_PATH: "/bin/herdr" }, + async (command) => { commands.push([...command]) }, + ) + + host.reportMetadata({ + model: "openai/gpt", + branch: "feat/host", + workspace: "/repo", + task: " Fix\nHerdr integration ", + action: "Testing", + }) + host.reportSession("chat-1") + host.reportState("working", "Fix Herdr integration") + host.reportState("working", "Fix Herdr integration") + host.reportState("blocked", "Approval required") + host.release() + await settle() + + expect(host.hosted).toBe(true) + expect(commands).toHaveLength(6) + expect(commands[0]).toContain("pane") + expect(commands[0]).toContain("report-metadata") + expect(commands[0]).toContain("task=Fix Herdr integration") + expect(commands[1]).toContain("report-agent-session") + expect(commands[1]).toContain("chat-1") + expect(commands[2]).toContain("working") + expect(commands[2]).toContain("--agent-session-id") + expect(commands[3]).toContain("blocked") + expect(commands[4]).toContain("--clear-token") + expect(commands[5]).toContain("release-agent") + }) + + test("metadata patches only changed tokens", async () => { + const commands: string[][] = [] + const host = createTuiHost( + { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2" }, + async (command) => { commands.push([...command]) }, + ) + + host.reportMetadata({ model: "gpt", branch: "main" }) + host.reportMetadata({ model: "gpt", branch: "main" }) + host.reportMetadata({ model: "gpt", branch: "" }) + await settle() + + expect(commands).toHaveLength(2) + expect(commands[1]).toContain("--clear-token") + expect(commands[1]).toContain("branch") + expect(commands[1]).not.toContain("model=gpt") + }) +}) diff --git a/tui/src/host.ts b/tui/src/host.ts new file mode 100644 index 000000000..787c51522 --- /dev/null +++ b/tui/src/host.ts @@ -0,0 +1,174 @@ +export type HostAgentState = "idle" | "working" | "blocked" | "unknown" + +export interface HostMetadata { + model?: string + branch?: string + workspace?: string + task?: string + action?: string +} + +export interface TuiHost { + readonly hosted: boolean + reportState(state: HostAgentState, message?: string): void + reportSession(sessionId: string): void + reportMetadata(metadata: HostMetadata): void + release(): void +} + +export function currentGitBranch(workspace: string): string { + const path = workspace.trim() + if (!path) return "" + try { + const branch = spawnText(["git", "-C", path, "branch", "--show-current"]) + if (branch) return branch + const revision = spawnText(["git", "-C", path, "rev-parse", "--short", "HEAD"]) + return revision ? `@${revision}` : "" + } catch { + return "" + } +} + +type Environment = Record +type CommandRunner = (command: readonly string[]) => Promise + +const AGENT = "nanobot" +const LIFECYCLE_SOURCE = "nanobot:tui" +const METADATA_SOURCE = "nanobot:tui:metadata" +const METADATA_KEYS = ["model", "branch", "workspace", "task", "action"] as const + +class StandaloneHost implements TuiHost { + readonly hosted = false + reportState(): void {} + reportSession(): void {} + reportMetadata(): void {} + release(): void {} +} + +class HerdrHost implements TuiHost { + readonly hosted = true + private sequence = 0 + private released = false + private lastState = "" + private lastSession = "" + private metadata: HostMetadata = {} + private queue: Promise = Promise.resolve() + + constructor( + private readonly paneId: string, + private readonly binary: string, + private readonly run: CommandRunner, + ) {} + + reportState(state: HostAgentState, message = ""): void { + if (this.released) return + const cleanMessage = normalize(message) + const fingerprint = `${state}\0${cleanMessage}\0${this.lastSession}` + if (fingerprint === this.lastState) return + this.lastState = fingerprint + const args = [ + "pane", "report-agent", this.paneId, + "--source", LIFECYCLE_SOURCE, + "--agent", AGENT, + "--state", state, + "--seq", String(this.nextSequence()), + ] + if (cleanMessage) args.push("--message", cleanMessage) + if (this.lastSession) args.push("--agent-session-id", this.lastSession) + this.enqueue(args) + } + + reportSession(sessionId: string): void { + if (this.released) return + const cleanSession = normalize(sessionId, 256) + if (!cleanSession || cleanSession === this.lastSession) return + this.lastSession = cleanSession + this.lastState = "" + this.enqueue([ + "pane", "report-agent-session", this.paneId, + "--source", LIFECYCLE_SOURCE, + "--agent", AGENT, + "--agent-session-id", cleanSession, + "--seq", String(this.nextSequence()), + ]) + } + + reportMetadata(next: HostMetadata): void { + if (this.released) return + const changed: Array<[typeof METADATA_KEYS[number], string]> = [] + for (const key of METADATA_KEYS) { + const value = normalize(next[key]) + if (value === normalize(this.metadata[key])) continue + changed.push([key, value]) + } + if (!changed.length) return + this.metadata = { ...this.metadata, ...next } + const args = [ + "pane", "report-metadata", this.paneId, + "--source", METADATA_SOURCE, + "--agent", AGENT, + "--display-agent", AGENT, + "--seq", String(this.nextSequence()), + ] + const task = normalize(this.metadata.task) + args.push(task ? "--title" : "--clear-title") + if (task) args.push(task) + for (const [key, value] of changed) { + args.push(value ? "--token" : "--clear-token", value ? `${key}=${value}` : key) + } + this.enqueue(args) + } + + release(): void { + if (this.released) return + this.released = true + const clear = [ + "pane", "report-metadata", this.paneId, + "--source", METADATA_SOURCE, + "--clear-title", "--clear-display-agent", "--clear-state-labels", + "--seq", String(this.nextSequence()), + ] + for (const key of METADATA_KEYS) clear.push("--clear-token", key) + this.enqueue(clear) + this.enqueue([ + "pane", "release-agent", this.paneId, + "--source", LIFECYCLE_SOURCE, + "--agent", AGENT, + "--seq", String(this.nextSequence()), + ]) + } + + private nextSequence(): number { + this.sequence += 1 + return this.sequence + } + + private enqueue(args: string[]): void { + const command = [this.binary, ...args] + this.queue = this.queue.then(() => this.run(command)).catch(() => {}) + } +} + +function normalize(value: string | undefined, limit = 80): string { + return (value || "").replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit) +} + +async function runCommand(command: readonly string[]): Promise { + const child = Bun.spawn([...command], { stdout: "ignore", stderr: "ignore" }) + await child.exited +} + +function spawnText(command: readonly string[]): string { + const result = Bun.spawnSync([...command], { stdout: "pipe", stderr: "ignore" }) + if (result.exitCode !== 0) return "" + return new TextDecoder().decode(result.stdout).trim() +} + +export function createTuiHost( + environment: Environment = process.env, + run: CommandRunner = runCommand, +): TuiHost { + const paneId = environment.HERDR_PANE_ID?.trim() || "" + if (environment.HERDR_ENV !== "1" || !paneId) return new StandaloneHost() + return new HerdrHost(paneId, environment.HERDR_BIN_PATH?.trim() || "herdr", run) +} diff --git a/tui/src/index.ts b/tui/src/index.ts index 789dce1bd..a6273d3d3 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -1,4 +1,5 @@ import { NanobotTui, type AppOptions } from "./app" +import { currentGitBranch } from "./host" function required(name: string): string { const value = process.env[name]?.trim() @@ -12,6 +13,8 @@ function themePreference(): AppOptions["theme"] { throw new Error("NANOBOT_TUI_THEME must be auto, dark, or light") } +const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || "" +const hostWorkspace = process.cwd() const options: AppOptions = { wsUrl: required("NANOBOT_TUI_WS_URL"), apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "", @@ -19,7 +22,9 @@ const options: AppOptions = { chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined, model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model", modelPreset: process.env.NANOBOT_TUI_MODEL_PRESET?.trim() || "default", - workspace: process.env.NANOBOT_TUI_WORKSPACE?.trim() || "", + workspace, + hostWorkspace, + branch: currentGitBranch(hostWorkspace), version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev", access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access", theme: themePreference(), diff --git a/tui/src/protocol.test.ts b/tui/src/protocol.test.ts index 709bedf4c..228d5e843 100644 --- a/tui/src/protocol.test.ts +++ b/tui/src/protocol.test.ts @@ -243,19 +243,34 @@ describe("gateway protocol", () => { socket.emit("message", { data: JSON.stringify({ event: "attached", chat_id: "one", model_preset: 42 }), }) + socket.emit("message", { + data: JSON.stringify({ event: "turn_end", chat_id: "one", goal_state: [] }), + }) socket.emit("message", { data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: "metadata" }), }) + socket.emit("message", { + data: JSON.stringify({ + event: "turn_end", + chat_id: "one", + goal_state: { active: false, status: "blocked", ui_summary: "Approval required" }, + }), + }) socket.emit("message", { data: JSON.stringify({ event: "future_gateway_event" }) }) socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) }) expect(statuses).toContain("error:gateway sent an invalid event") - expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(8) + expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(9) expect(events).toContainEqual({ event: "session_updated", chat_id: "one", scope: "metadata", }) expect(events).toContainEqual({ event: "error", detail: "global failure" }) + expect(events).toContainEqual({ + event: "turn_end", + chat_id: "one", + goal_state: { active: false, status: "blocked", ui_summary: "Approval required" }, + }) } finally { Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original }) } diff --git a/tui/src/protocol.ts b/tui/src/protocol.ts index 8e76d602e..d48957bf0 100644 --- a/tui/src/protocol.ts +++ b/tui/src/protocol.ts @@ -108,6 +108,7 @@ export type InboundEvent = turn_id?: string usage?: TokenUsage context_window_tokens?: number + goal_state?: Record } | { event: "goal_status" @@ -421,10 +422,11 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined { name === "turn_end" && (!optional(record.latency_ms, "number") || !optional(record.context_window_tokens, "number") - || (record.usage !== undefined && !isTokenUsage(record.usage))) + || (record.usage !== undefined && !isTokenUsage(record.usage)) + || (record.goal_state !== undefined && !isRecord(record.goal_state))) ) return null if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null - if (name === "goal_state" && (!record.goal_state || typeof record.goal_state !== "object")) return null + if (name === "goal_state" && !isRecord(record.goal_state)) return null if ( name === "session_updated" && (!optional(record.scope, "string") diff --git a/tui/src/tool-renderers.test.ts b/tui/src/tool-renderers.test.ts index 1bdd5833b..730491aa3 100644 --- a/tui/src/tool-renderers.test.ts +++ b/tui/src/tool-renderers.test.ts @@ -8,7 +8,7 @@ describe("tool renderers", () => { { call_id: "exec-1", phase: "start", name: "exec", arguments: { cmd: "git status" } }, { call_id: "exec-1", phase: "end", name: "exec", result: { output: "clean" } }, ) - expect(renderToolEvent(event)).toBe(" ✓ Command git status") + expect(renderToolEvent(event)).toBe(" ✓ Ran git status") }) test("uses stable task language for common file and web tools", () => { @@ -19,4 +19,11 @@ describe("tool renderers", () => { expect(renderToolEvent({ phase: "error", name: "web_fetch", error: "timeout" })) .toBe(" × Fetch timeout") }) + + test("compresses verification and edits into outcome language", () => { + expect(renderToolEvent({ phase: "end", name: "exec", arguments: { cmd: "bun test" } })) + .toBe(" ✓ Testing bun test") + expect(renderToolEvent({ phase: "end", name: "apply_patch", arguments: { path: "app.ts" } })) + .toBe(" ✓ Edited app.ts") + }) }) diff --git a/tui/src/tool-renderers.ts b/tui/src/tool-renderers.ts index 467dc144e..328a87fbe 100644 --- a/tui/src/tool-renderers.ts +++ b/tui/src/tool-renderers.ts @@ -21,14 +21,24 @@ export function renderToolEvent(event: ToolProgressEvent): string { const args = record(event.arguments) const result = record(event.result) const detail = phase === "error" ? compact(event.error) : toolDetail(name, args, result) - return ` ${marker} ${toolLabel(name)}${detail ? ` ${detail}` : ""}` + return ` ${marker} ${toolLabel(name, phase, args)}${detail ? ` ${detail}` : ""}` } -function toolLabel(name: string): string { - if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) return "Command" +function toolLabel( + name: string, + phase: string, + args: Record, +): string { + if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) { + const command = compact(args.command ?? args.cmd) + if (/(?:^|\s)(?:pytest|ruff|pyright|basedpyright|cargo\s+test|go\s+test|(?:bun|npm|pnpm|yarn)(?:\s+run)?\s+test)(?:\s|$)/iu.test(command)) { + return "Testing" + } + return phase === "end" ? "Ran" : phase === "error" ? "Command failed" : "Running" + } if (/^(?:read_file|read)$/u.test(name)) return "Read" - if (/^(?:write_file|write)$/u.test(name)) return "Write" - if (/^(?:edit_file|apply_patch|edit)$/u.test(name)) return "Edit" + 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 (name === "web_search") return "Search web" if (name === "web_fetch") return "Fetch" return name diff --git a/tui/src/transcript.ts b/tui/src/transcript.ts index 83bc3aaae..82da9fc8c 100644 --- a/tui/src/transcript.ts +++ b/tui/src/transcript.ts @@ -70,6 +70,7 @@ export class Transcript { private theme: TranscriptTheme, private readonly treeSitterClient: TreeSitterClient, private readonly onNavigationChange?: (state: TranscriptNavigation) => void, + private readonly showHeader = true, ) { this.root = new ScrollBoxRenderable(renderer, { id: "nanobot-tui-transcript", @@ -113,6 +114,7 @@ export class Transcript { } header(options: TranscriptHeader): void { + if (!this.showHeader) return const row = new BoxRenderable(this.renderer, { id: this.id("header-row"), width: "100%", @@ -174,7 +176,7 @@ export class Transcript { if (messages.length === 0) return const previousTop = this.root.scrollTop const previousHeight = this.root.scrollHeight - let index = 1 // Keep the launch header first. + let index = this.showHeader ? 1 : 0 for (const message of messages) { if (message.role === "user") { if (message.turnId && this.userTurnIds.has(message.turnId)) continue diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index daa94db08..9038d9f13 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -279,8 +279,10 @@ export interface AgentUIBlob { /** WebSocket snapshot for sustained goals (`goal_state` events; keyed by ``chat_id``). */ export interface GoalStateWsPayload { active: boolean; + status?: "active" | "blocked"; ui_summary?: string; objective?: string; + recap?: string; } export interface ToolProgressEvent {