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
+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