diff --git a/tui/README.md b/tui/README.md index 2228b725a..987e02e25 100644 --- a/tui/README.md +++ b/tui/README.md @@ -11,17 +11,15 @@ bun run --cwd tui build `nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. `/detach` closes the TUI after promoting the gateway to persistent background mode, keeping any active agent turn running without clients; the restored terminal prints the exact stop command for that config and explicit workspace. `nanobot gateway --background` can start or promote it persistently before opening a client. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`. -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. +The TUI 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. Assistant math written with `$...$`, `$$...$$`, `\\(...\\)`, or `\\[...\\]` is presented as Unicode plain text so formulas remain readable in terminals without a math renderer. Currency and LaTeX inside inline or fenced code remain literal. -## Herdr host mode +## Herdr pane titles -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. +When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot keeps the same full-screen layout, controls, and navigation available in any other terminal. Its only host-specific behavior is reporting the latest user task as the Herdr pane title through the supported pane CLI. Creating a new chat, switching to a chat without a task, and exiting the TUI clear that title. Nanobot does not report agent lifecycle, session, model, Git branch, workspace, or action metadata to Herdr. 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 9e649f8bb..dea1de056 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -21,7 +21,7 @@ import type { SlashCommand, WorkspaceScopePayload, } from "./protocol" -import type { HostAgentState, HostMetadata, TuiHost } from "./host" +import type { TuiHost } from "./host" import type { ClipboardImageReader } from "./clipboard-image" import { userMessageText, type Transcript } from "./transcript" @@ -3075,23 +3075,18 @@ describe("NanobotTui layout", () => { }) }) -describe("NanobotTui in a Herdr pane", () => { - test("keeps local navigation 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[] = [] +describe("NanobotTui with a Herdr pane title reporter", () => { + test("keeps the full terminal experience while reporting task titles", async () => { + const setup = await createTestRenderer({ width: 80, height: 22, screenMode: "alternate-screen" }) + const titles: 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) }, + reportTitle(title) { titles.push(title) }, release() { released = true }, } const app = NanobotTui.mount( setup.renderer, - { ...options, branch: "feat/herdr" }, + options, client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), host, @@ -3127,10 +3122,14 @@ describe("NanobotTui in a Herdr pane", () => { }) await setup.flush() const activeFrame = setup.captureCharFrame() + expect(activeFrame).toContain(">_ nanobot") + expect(activeFrame).toContain("test/model") expect(occurrences(activeFrame, "› Ship the Herdr integration")).toBe(1) expect(occurrences(activeFrame, "app.ts")).toBe(1) expect(ui.composer.placeholder).toBe("Enter send now · Tab send next") expect(ui.composerFrame.height).toBe(3) + expect(titles).toEqual(["Ship the Herdr integration"]) + app.accept({ event: "turn_end", chat_id: "chat", @@ -3141,27 +3140,6 @@ describe("NanobotTui in a Herdr pane", () => { ui_summary: "Approval required", }, }) - await setup.flush() - const frame = setup.captureCharFrame() - - expect(sessions).toEqual(["chat"]) - 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) - 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", @@ -3169,13 +3147,8 @@ describe("NanobotTui in a Herdr pane", () => { 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") + + expect(titles).toEqual(["Ship the Herdr integration", "Approved"]) app.stop() expect(released).toBe(true) diff --git a/tui/src/app.ts b/tui/src/app.ts index 11f4097d8..71d18fe30 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -94,7 +94,7 @@ import { type FooterMode, type FooterHintTheme, } from "./footer-hints" -import { createTuiHost, currentGitBranch, type TuiHost } from "./host" +import { createTuiHost, type TuiHost } from "./host" interface AppOptions { wsUrl?: string @@ -107,8 +107,6 @@ interface AppOptions { model: string modelPreset: string workspace: string - hostWorkspace?: string - branch?: string version: string access: string theme: "auto" | ThemeMode @@ -404,10 +402,6 @@ function connectionStatusText( return "Session ended" } -function singleLine(value: string, limit = 120): string { - return value.replace(/\s+/gu, " ").trim().slice(0, limit) -} - export function sessionExitMessage(chatId: string): string { const sessionId = `websocket:${chatId}` return `Resume with: nanobot agent --session ${sessionId}\n` @@ -465,7 +459,6 @@ export class NanobotTui { private activeTurnId: string | null = null private activeLabel = "Thinking" private activeStartedAt = 0 - private lastProgress = "" private finalMessage = "" private turnHadAnswer = false private historyLoaded = false @@ -514,13 +507,8 @@ export class NanobotTui { private readonly silentCommandTurns = new Set() private currentFileEdits: FileEditEvent[] = [] private lastFileEdits: FileEditEvent[] = [] - private currentTask = "" - private currentAction = "" - private hostBlocked = false private recoveryState: RecoveryState | null = null private recoveryPending = false - private hostWorkspace: string - private hostBranch: string private readonly apiReauthenticator: ApiReauthenticator | undefined private readonly clipboardImageReader: ClipboardImageReader private apiRefreshPromise: Promise | null = null @@ -545,8 +533,6 @@ export class NanobotTui { this.defaultModelPreset = options.modelPreset this.modelName = options.model this.modelPreset = options.modelPreset - this.hostWorkspace = options.hostWorkspace || options.workspace - this.hostBranch = options.branch || "" this.apiReauthenticator = options.bootstrapUrl ? (rejectedApiToken) => this.refreshApiConnection(rejectedApiToken) : undefined @@ -561,7 +547,6 @@ export class NanobotTui { transcriptTheme(this.palette, this.backgroundKnown), treeSitterClient, (state) => this.handleTranscriptNavigation(state), - !host.hosted, options.workspace, ) this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) @@ -672,21 +657,19 @@ export class NanobotTui { truncate: true, fg: this.palette.muted, selectable: false, - ...(host.hosted ? {} : { - onMouseOver: () => { this.titleText.fg = this.palette.accent }, - onMouseOut: () => this.renderTitleColor(), - onMouseDown: (event) => { - if (event.button !== 0) return - event.preventDefault() - event.stopPropagation() - this.renderer.clearSelection() - if (this.sessionLoading || this.sessionMenu.visible) { - this.closeSessions() - return - } - void this.openSessions() - }, - }), + onMouseOver: () => { this.titleText.fg = this.palette.accent }, + onMouseOut: () => this.renderTitleColor(), + onMouseDown: (event) => { + if (event.button !== 0) return + event.preventDefault() + event.stopPropagation() + this.renderer.clearSelection() + if (this.sessionLoading || this.sessionMenu.visible) { + this.closeSessions() + return + } + void this.openSessions() + }, }) this.runtimeControls = new RuntimeControls( renderer, @@ -721,11 +704,9 @@ export class NanobotTui { }, ) this.title.add(this.titleText) - if (!host.hosted) { - this.title.add(this.runtimeControls.modelText) - this.title.add(this.runtimeControls.accessText) - this.title.add(this.runtimeControls.contextText) - } + 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", @@ -819,7 +800,7 @@ export class NanobotTui { this.shell.add(this.branchMenu.root) this.shell.add(this.contextPanel.root) this.shell.add(this.runtimeControls.menuRoot) - if (!host.hosted) this.shell.add(this.title) + this.shell.add(this.title) this.shell.add(this.queuePreview.root) this.shell.add(this.recoveryNotice.root) this.shell.add(this.composerFrame) @@ -836,7 +817,6 @@ export class NanobotTui { this.handleResize() this.composer.focus() this.transcript.header(options) - this.syncHostMetadata() } static async create(options: AppOptions): Promise { @@ -845,7 +825,7 @@ export class NanobotTui { targetFps: 30, exitOnCtrlC: false, useMouse: true, - screenMode: host.hosted ? "main-screen" : "alternate-screen", + screenMode: "alternate-screen", externalOutputMode: "passthrough", consoleMode: "disabled", }) @@ -874,7 +854,6 @@ export class NanobotTui { // Network setup and small menu payloads do not depend on terminal colors. // Start them while OSC theme detection is in flight instead of serializing // up to one second of otherwise independent startup work. - this.host.reportState("unknown", "Getting ready") this.client.connect() void this.loadCommands() void this.loadMentions() @@ -1021,8 +1000,7 @@ export class NanobotTui { prompt.options.media, prompt.displayContent, ) - this.hostBlocked = false - this.setCurrentTask(prompt.content) + this.host.reportTitle(prompt.content) if (steering) { this.renderActiveStatus() this.updateMeta() @@ -1037,12 +1015,9 @@ export class NanobotTui { this.readyDetail = "" this.finalMessage = "" this.turnHadAnswer = false - this.lastProgress = "" this.activeLabel = "Thinking" this.currentFileEdits = [] - this.setCurrentAction("Thinking") this.setActive(true, startedAt) - this.reportHostWorking() } private reconcileTurnOwnership(event: { @@ -1065,7 +1040,6 @@ export class NanobotTui { if (event.event === "attached") { const switchedSession = Boolean(this.currentChatId && this.currentChatId !== event.chat_id) this.currentChatId = 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) @@ -1117,17 +1091,13 @@ export class NanobotTui { )) { this.recordPrompt(event.text) } - this.hostBlocked = false - this.setCurrentTask(event.text) + this.host.reportTitle(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 @@ -1147,11 +1117,8 @@ 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.transcript.progress(event.text, event.tool_events) this.setActive(true) - this.reportHostWorking() } else { this.finalMessage = event.text } @@ -1160,10 +1127,8 @@ export class NanobotTui { this.activeLabel = "Editing" 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.transcript.fileEdits(event.edits) this.setActive(true) - this.reportHostWorking() return case "reasoning_delta": this.activeLabel = "Thinking" @@ -1197,7 +1162,6 @@ 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 @@ -1207,7 +1171,6 @@ 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 @@ -1216,17 +1179,12 @@ 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 "recovery_state": this.applyRecoveryState(event) @@ -1273,8 +1231,6 @@ export class NanobotTui { this.turnHadAnswer = false this.restoreQueuedPrompts() this.setActive(false) - this.setCurrentAction(event.reason || event.detail || "Error") - this.reportHostResting() return } } @@ -1312,11 +1268,7 @@ export class NanobotTui { 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) - } + if (lastUser) this.host.reportTitle(lastUser.content) this.lastFileEdits = latestTurnFileEdits(history.messages) if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits) } @@ -1328,7 +1280,6 @@ export class NanobotTui { this.ready = true if (!this.activeTurn) { this.status.content = this.readyStatus() - this.reportHostResting() } } } @@ -1354,35 +1305,24 @@ export class NanobotTui { this.recoveryPending = false if (state.status === "resuming") { this.recoveryNotice.hide() - this.hostBlocked = false this.activeLabel = "Continuing" - this.setCurrentAction("Continuing interrupted task") this.setActive(true) - this.reportHostWorking() return } if (state.status === "awaiting_user" || state.status === "failed") { this.activeTurnId = null this.setActive(false) - this.hostBlocked = true this.recoveryNotice.show(state) - const detail = state.reason || (state.status === "failed" - ? "Recovery failed" - : "Task interrupted") - this.setCurrentAction(detail) this.status.content = state.can_continue === false ? "Interrupted · dismiss to start a new message" : "Interrupted · continue or dismiss" - this.host.reportState("blocked", detail) this.composer.focus() return } this.clearRecoveryState() this.activeTurnId = null - this.hostBlocked = false this.setActive(false) if (this.ready) this.status.content = this.readyStatus() - this.reportHostResting() } private async updateRecovery(action: "continue" | "dismiss"): Promise { @@ -1414,7 +1354,6 @@ export class NanobotTui { this.recoveryPending = false this.recoveryNotice.setBusy(false) this.status.content = error instanceof Error ? error.message : String(error) - this.host.reportState("blocked", state.reason || "Task interrupted") } finally { this.composer.focus() } @@ -1468,13 +1407,11 @@ export class NanobotTui { this.connectionMessage = connectionStatusText(status, info) if (status === "connected") { this.ready = false - this.host.reportState("unknown", "Getting ready") this.renderConnectionMessage() return } if (["starting", "connecting", "reconnecting", "unavailable"].includes(status)) { this.ready = false - this.host.reportState("unknown", this.connectionMessage) if (status === "reconnecting" || status === "unavailable") this.setActive(false) this.renderConnectionMessage() return @@ -1482,14 +1419,12 @@ export class NanobotTui { if (status === "error") { if (info) this.ready = false this.setActive(false) - this.host.reportState("unknown", this.connectionMessage) this.renderConnectionMessage() return } if (!this.quitting) { this.ready = false this.setActive(false) - this.host.reportState("unknown", "Disconnected") this.renderConnectionMessage() } } @@ -1529,7 +1464,6 @@ export class NanobotTui { } if (this.shimmerTimer) clearInterval(this.shimmerTimer) this.shimmerTimer = null - this.lastProgress = "" this.status.content = this.readyStatus() } @@ -1953,7 +1887,7 @@ export class NanobotTui { this.syncComposerPlaceholder() this.contextPanel.resize(this.renderer.height) this.diffViewer.resize(this.renderer.width) - if (!this.host.hosted) this.title.visible = this.renderer.height >= 14 + this.title.visible = this.renderer.height >= 14 this.runtimeControls.resize(this.renderer.width) this.updateTitle() this.updateMeta() @@ -2022,10 +1956,6 @@ export class NanobotTui { } private updateTitle(): void { - if (this.host.hosted) { - 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 @@ -2036,69 +1966,14 @@ export class NanobotTui { : ""} ctx` this.runtimeControls.updateModel(this.modelName, this.modelPreset) this.runtimeControls.updateContext(context) - this.syncHostMetadata() } private renderTitleColor(): void { - this.titleText.fg = !this.host.hosted && (this.sessionLoading || this.sessionMenu.visible) + this.titleText.fg = this.sessionLoading || this.sessionMenu.visible ? this.palette.accent : this.palette.muted } - 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 { const verticalPadding = this.renderer.height >= 12 ? 1 : 0 const maxContentHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3))) @@ -2384,11 +2259,6 @@ 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() @@ -2480,8 +2350,7 @@ export class NanobotTui { this.clearPromptQueue() this.sessionMetadataId += 1 this.sessionTitle = `Fork · ${preview.slice(0, 48)}` - this.clearHostContext() - this.setCurrentTask(preview) + this.host.reportTitle(preview) this.contextTokens = null this.lastUsage = null this.readyDetail = "" @@ -2578,7 +2447,7 @@ export class NanobotTui { this.clearRecoveryState() this.queuePreview.update([]) this.sessionMetadataId += 1 - this.clearHostContext() + this.host.reportTitle("") this.sessionTitle = sessionLabel(session) this.applySessionModel(session) this.applySessionScope(session) @@ -2614,7 +2483,7 @@ export class NanobotTui { this.clearRecoveryState() this.clearPromptQueue() this.sessionMetadataId += 1 - this.clearHostContext() + this.host.reportTitle("") this.sessionTitle = "New chat" this.sessionModelPreset = null this.modelName = this.defaultModelName @@ -2661,17 +2530,13 @@ export class NanobotTui { if (!silent) this.recordPrompt(content) if (lifecycle === "agent_turn") { - this.hostBlocked = false - this.setCurrentTask(content) + this.host.reportTitle(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) @@ -2679,12 +2544,10 @@ 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]}…` diff --git a/tui/src/host.test.ts b/tui/src/host.test.ts index 4b04f2956..0ae9183f1 100644 --- a/tui/src/host.test.ts +++ b/tui/src/host.test.ts @@ -1,82 +1,67 @@ import { describe, expect, test } from "bun:test" -import { createTuiHost, currentGitBranch } from "./host" +import { createTuiHost } from "./host" async function settle(): Promise { - await Bun.sleep(40) + 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.reportTitle("task") host.release() await settle() - expect(host.hosted).toBe(false) expect(commands).toEqual([]) }) - test("reports semantic lifecycle, session identity, metadata, and release", async () => { + test("requires both Herdr environment markers", async () => { + const commands: string[][] = [] + const run = async (command: readonly string[]) => { commands.push([...command]) } + + createTuiHost({ HERDR_ENV: "1" }, run).reportTitle("missing pane") + createTuiHost({ HERDR_PANE_ID: "w1:p2" }, run).reportTitle("missing host") + await settle() + + expect(commands).toEqual([]) + }) + + test("reports only normalized pane title changes and clears the title on 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.reportTitle(" Fix\nHerdr integration ") + host.reportTitle("Fix Herdr integration") + host.reportTitle("Review results") host.release() + host.reportTitle("ignored after 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(1) - expect(commands[0]).toContain("model=gpt") - expect(commands[0]).toContain("--clear-token") - expect(commands[0]).toContain("branch") + expect(commands).toEqual([ + [ + "/bin/herdr", "pane", "report-metadata", "w1:p2", + "--source", "nanobot:tui:metadata", "--seq", "1", + "--title", "Fix Herdr integration", + ], + [ + "/bin/herdr", "pane", "report-metadata", "w1:p2", + "--source", "nanobot:tui:metadata", "--seq", "2", + "--title", "Review results", + ], + [ + "/bin/herdr", "pane", "report-metadata", "w1:p2", + "--source", "nanobot:tui:metadata", "--seq", "3", "--clear-title", + ], + ]) + expect(commands.flat()).not.toContain("report-agent") + expect(commands.flat()).not.toContain("report-agent-session") + expect(commands.flat()).not.toContain("--token") }) }) diff --git a/tui/src/host.ts b/tui/src/host.ts index c934f8f30..95c03d770 100644 --- a/tui/src/host.ts +++ b/tui/src/host.ts @@ -1,60 +1,22 @@ -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 + reportTitle(title: string): 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 -const METADATA_FLUSH_MS = 32 class StandaloneHost implements TuiHost { - readonly hosted = false - reportState(): void {} - reportSession(): void {} - reportMetadata(): void {} + reportTitle(): void {} release(): void {} } class HerdrHost implements TuiHost { - readonly hosted = true private sequence = 0 private released = false - private lastState = "" - private lastSession = "" - private metadata: HostMetadata = {} - private readonly pendingMetadata = new Set() - private metadataTimer: ReturnType | null = null + private lastTitle = "" private queue: Promise = Promise.resolve() constructor( @@ -63,97 +25,25 @@ class HerdrHost implements TuiHost { private readonly run: CommandRunner, ) {} - reportState(state: HostAgentState, message = ""): void { + reportTitle(title: string): 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 - // Preserve causal ordering when a semantic state transition follows a - // pending metadata snapshot; repeated working heartbeats still stay free. - this.flushMetadata() - 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.flushMetadata() - 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 - for (const key of METADATA_KEYS) { - if (!(key in next)) continue - const value = normalize(next[key]) - if (value === normalize(this.metadata[key])) continue - this.pendingMetadata.add(key) - } - if (!this.pendingMetadata.size) return - this.metadata = { ...this.metadata, ...next } - if (this.metadataTimer) return - this.metadataTimer = setTimeout(() => this.flushMetadata(), METADATA_FLUSH_MS) - } - - private flushMetadata(): void { - if (this.metadataTimer) clearTimeout(this.metadataTimer) - this.metadataTimer = null - if (!this.pendingMetadata.size) return + const cleanTitle = normalize(title) + if (cleanTitle === this.lastTitle) return + this.lastTitle = cleanTitle const args = [ "pane", "report-metadata", this.paneId, "--source", METADATA_SOURCE, - "--agent", AGENT, - "--display-agent", AGENT, "--seq", String(this.nextSequence()), + cleanTitle ? "--title" : "--clear-title", ] - const task = normalize(this.metadata.task) - args.push(task ? "--title" : "--clear-title") - if (task) args.push(task) - for (const key of this.pendingMetadata) { - const value = normalize(this.metadata[key]) - args.push(value ? "--token" : "--clear-token", value ? `${key}=${value}` : key) - } - this.pendingMetadata.clear() + if (cleanTitle) args.push(cleanTitle) this.enqueue(args) } release(): void { if (this.released) return - this.flushMetadata() + this.reportTitle("") 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 { @@ -167,8 +57,8 @@ class HerdrHost implements TuiHost { } } -function normalize(value: string | undefined, limit = 80): string { - return (value || "").replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit) +function normalize(value: string, limit = 80): string { + return value.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit) } async function runCommand(command: readonly string[]): Promise { @@ -176,12 +66,6 @@ async function runCommand(command: readonly string[]): Promise { 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, diff --git a/tui/src/index.ts b/tui/src/index.ts index e78eb5112..7060dcf2f 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -1,5 +1,4 @@ import { NanobotTui, sessionExitMessage, type AppOptions } from "./app" -import { currentGitBranch } from "./host" // Keep in sync with _TUI_DETACH_EXIT_CODE in nanobot/cli/tui_launcher.py. const TUI_DETACH_EXIT_CODE = 90 @@ -11,7 +10,6 @@ function themePreference(): AppOptions["theme"] { } const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || "" -const hostWorkspace = process.cwd() const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || "" const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || "" const healthUrl = process.env.NANOBOT_TUI_HEALTH_URL?.trim() || "" @@ -34,8 +32,6 @@ const options: AppOptions = { model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model", modelPreset: process.env.NANOBOT_TUI_MODEL_PRESET?.trim() || "default", 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/transcript.ts b/tui/src/transcript.ts index c705c43f4..86b04b5b4 100644 --- a/tui/src/transcript.ts +++ b/tui/src/transcript.ts @@ -145,7 +145,6 @@ export class Transcript { private theme: TranscriptTheme, private readonly treeSitterClient: TreeSitterClient, private readonly onNavigationChange?: (state: TranscriptNavigation) => void, - private readonly showHeader = true, private readonly workspace = "", ) { this.root = new ScrollBoxRenderable(renderer, { @@ -198,7 +197,6 @@ export class Transcript { } header(options: TranscriptHeader): void { - if (!this.showHeader) return const row = new BoxRenderable(this.renderer, { id: this.id("header-row"), width: "100%", @@ -265,7 +263,7 @@ export class Transcript { if (messages.length === 0) return const previousTop = this.root.scrollTop const previousHeight = this.root.scrollHeight - let index = this.showHeader ? 1 : 0 + let index = 1 // Keep the launch header first. for (const message of messages) { if (message.role === "user") { if (message.turnId && this.userTurnIds.has(message.turnId)) continue