fix(tui): preserve full UI in Herdr panes

This commit is contained in:
chengyongru
2026-08-28 16:36:07 +08:00
committed by chengyongru
parent e73cce706c
commit a339966543
7 changed files with 97 additions and 400 deletions
+3 -5
View File
@@ -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.
+13 -40
View File
@@ -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)
+30 -167
View File
@@ -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<string>()
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<GatewayApiConnection> | 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<NanobotTui> {
@@ -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<void> {
@@ -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<string, unknown> | 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]}`
+38 -53
View File
@@ -1,82 +1,67 @@
import { describe, expect, test } from "bun:test"
import { createTuiHost, currentGitBranch } from "./host"
import { createTuiHost } from "./host"
async function settle(): Promise<void> {
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")
})
})
+12 -128
View File
@@ -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<string, string | undefined>
type CommandRunner = (command: readonly string[]) => Promise<void>
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<typeof METADATA_KEYS[number]>()
private metadataTimer: ReturnType<typeof setTimeout> | null = null
private lastTitle = ""
private queue: Promise<void> = 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<void> {
@@ -176,12 +66,6 @@ async function runCommand(command: readonly string[]): Promise<void> {
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,
-4
View File
@@ -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(),
+1 -3
View File
@@ -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