feat(tui): integrate with Herdr host

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 783d381710
commit 79d51be71f
17 changed files with 622 additions and 32 deletions
+86 -1
View File
@@ -8,6 +8,7 @@ import {
import { NanobotTui, type AppOptions } from "./app"
import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol"
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
const options: AppOptions = {
wsUrl: "ws://localhost.invalid/ws",
@@ -1588,7 +1589,7 @@ describe("NanobotTui layout", () => {
expect(frame).toMatch(/Working\s+0s/u)
expect(frame).not.toMatch(/[]/u)
expect(frame).toContain(" Command pwd")
expect(frame).toContain(" Running pwd")
app.accept({ event: "turn_end", chat_id: "chat" })
})
@@ -1881,6 +1882,90 @@ describe("NanobotTui layout", () => {
})
})
describe("NanobotTui in a Herdr pane", () => {
test("stays quiet while reporting task, session, lifecycle, and metadata", async () => {
const setup = await createTestRenderer({ width: 80, height: 22, screenMode: "main-screen" })
const states: Array<{ state: HostAgentState; message?: string }> = []
const metadata: HostMetadata[] = []
const sessions: string[] = []
let released = false
const host: TuiHost = {
hosted: true,
reportState(state, message) { states.push({ state, ...(message ? { message } : {}) }) },
reportSession(sessionId) { sessions.push(sessionId) },
reportMetadata(value) { metadata.push(value) },
release() { released = true },
}
const app = NanobotTui.mount(
setup.renderer,
{ ...options, branch: "feat/herdr" },
client(),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
host,
)
app.accept({ event: "attached", chat_id: "chat" })
app.accept({
event: "user_message",
chat_id: "chat",
text: "Ship the Herdr integration",
turn_id: "turn-1",
starts_turn: true,
})
app.accept({
event: "message",
chat_id: "chat",
text: "",
kind: "tool_hint",
tool_events: [{ phase: "end", call_id: "read", name: "read_file", arguments: { path: "app.ts" } }],
})
app.accept({
event: "turn_end",
chat_id: "chat",
turn_id: "turn-1",
goal_state: {
active: false,
status: "blocked",
ui_summary: "Approval required",
},
})
await setup.flush()
const frame = setup.captureCharFrame()
expect(sessions).toEqual(["chat"])
expect(frame).toContain(" Ship the Herdr integration")
expect(frame).not.toContain(">_ nanobot")
expect(frame).not.toContain("test/model")
expect(states.some(({ state }) => state === "working")).toBe(true)
expect(states.at(-1)).toEqual({ state: "blocked", message: "Approval required" })
expect(metadata.at(-1)).toMatchObject({
model: "default · test/model",
branch: "feat/herdr",
workspace: "/tmp/nanobot-workspace",
task: "Ship the Herdr integration",
action: "Approval required",
})
app.accept({
event: "user_message",
chat_id: "chat",
text: "Approved",
turn_id: "turn-2",
starts_turn: true,
})
app.accept({
event: "turn_end",
chat_id: "chat",
turn_id: "turn-2",
goal_state: { active: false },
})
expect(states.at(-1)?.state).toBe("idle")
app.stop()
expect(released).toBe(true)
})
})
if (process.platform !== "win32") {
test("restores the terminal after SIGTERM", async () => {
const child = Bun.spawn(["bun", "src/index.ts"], {
+152 -9
View File
@@ -75,6 +75,7 @@ import {
type FooterMode,
type FooterHintTheme,
} from "./footer-hints"
import { createTuiHost, currentGitBranch, type TuiHost } from "./host"
interface AppOptions {
wsUrl: string
@@ -84,6 +85,8 @@ interface AppOptions {
model: string
modelPreset: string
workspace: string
hostWorkspace?: string
branch?: string
version: string
access: string
theme: "auto" | ThemeMode
@@ -321,6 +324,10 @@ function formatElapsed(milliseconds: number): string {
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
}
function singleLine(value: string, limit = 120): string {
return value.replace(/\s+/gu, " ").trim().slice(0, limit)
}
async function copyWithSystemClipboard(text: string): Promise<void> {
const commands = process.platform === "darwin"
? [["pbcopy"]]
@@ -359,6 +366,8 @@ export class NanobotTui {
private readonly composer: TextareaRenderable
private readonly status: TextRenderable
private readonly meta: TextRenderable
private readonly host: TuiHost
private readonly localCommands: TuiCommand[]
private readonly draft = new ComposerDraft()
private readonly promptQueue = new PromptQueue()
private palette: Palette
@@ -411,30 +420,43 @@ export class NanobotTui {
private readonly silentCommandTurns = new Set<string>()
private currentFileEdits: FileEditEvent[] = []
private lastFileEdits: FileEditEvent[] = []
private currentTask = ""
private currentAction = ""
private hostBlocked = false
private hostWorkspace: string
private hostBranch: string
private constructor(
renderer: CliRenderer,
private readonly options: AppOptions,
client?: ChatClient,
treeSitterClient = getTreeSitterClient(),
host: TuiHost = createTuiHost({}),
) {
this.renderer = renderer
this.defaultModelName = options.model
this.defaultModelPreset = options.modelPreset
this.modelName = options.model
this.modelPreset = options.modelPreset
this.hostWorkspace = options.hostWorkspace || options.workspace
this.hostBranch = options.branch || ""
this.sessionModelPreset = options.chatId ? undefined : null
this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null
this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
this.host = host
this.localCommands = host.hosted
? LOCAL_COMMANDS.filter(({ command }) => command === "/context" || command === "/diff")
: LOCAL_COMMANDS
this.transcript = new Transcript(
renderer,
transcriptTheme(this.palette, this.backgroundKnown),
treeSitterClient,
(state) => this.handleTranscriptNavigation(state),
!host.hosted,
)
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
this.commandMenu.setCommands([], LOCAL_COMMANDS)
this.commandMenu.setCommands([], this.localCommands)
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
this.mentionMenu = new MentionMenu(renderer, commandMenuTheme(this.palette))
this.branchMenu = new BranchMenu(renderer, commandMenuTheme(this.palette))
@@ -523,9 +545,11 @@ export class NanobotTui {
},
)
this.title.add(this.titleText)
this.title.add(this.runtimeControls.modelText)
this.title.add(this.runtimeControls.accessText)
this.title.add(this.runtimeControls.contextText)
if (!host.hosted) {
this.title.add(this.runtimeControls.modelText)
this.title.add(this.runtimeControls.accessText)
this.title.add(this.runtimeControls.contextText)
}
const composerSurface = this.composerSurface()
this.composerFrame = new BoxRenderable(renderer, {
id: "nanobot-tui-composer-frame",
@@ -633,18 +657,20 @@ export class NanobotTui {
this.handleResize()
this.composer.focus()
this.transcript.header(options)
this.syncHostMetadata()
}
static async create(options: AppOptions): Promise<NanobotTui> {
const host = createTuiHost()
const renderer = await createCliRenderer({
targetFps: 30,
exitOnCtrlC: false,
useMouse: true,
screenMode: "alternate-screen",
screenMode: host.hosted ? "main-screen" : "alternate-screen",
externalOutputMode: "passthrough",
consoleMode: "disabled",
})
return NanobotTui.mount(renderer, options)
return NanobotTui.mount(renderer, options, undefined, undefined, host)
}
static mount(
@@ -652,8 +678,9 @@ export class NanobotTui {
options: AppOptions,
client?: ChatClient,
treeSitterClient?: TreeSitterClient,
host?: TuiHost,
): NanobotTui {
return new NanobotTui(renderer, options, client, treeSitterClient)
return new NanobotTui(renderer, options, client, treeSitterClient, host)
}
async start(): Promise<void> {
@@ -666,6 +693,7 @@ export class NanobotTui {
if (this.options.theme === "auto" && this.renderer.themeMode) {
this.applyTheme(this.renderer.themeMode)
}
this.host.reportState("unknown", "Connecting")
this.client.connect()
void this.loadCommands()
void this.loadMentions()
@@ -770,6 +798,8 @@ export class NanobotTui {
this.mentionMenu.hide()
this.recordPrompt(prompt.content)
this.transcript.user(prompt.content, turnId)
this.hostBlocked = false
this.setCurrentTask(prompt.content)
if (steering) {
this.status.content = `Steering current turn${this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""}`
this.updateMeta()
@@ -787,7 +817,9 @@ export class NanobotTui {
this.lastProgress = ""
this.activeLabel = "Thinking"
this.currentFileEdits = []
this.setCurrentAction("Thinking")
this.setActive(true, startedAt)
this.reportHostWorking()
}
private reconcileTurnOwnership(event: {
@@ -809,6 +841,7 @@ export class NanobotTui {
accept(event: InboundEvent): void {
if (event.event === "attached") {
void rememberChat(this.options.statePath, event.chat_id)
this.host.reportSession(event.chat_id)
if (event.usage) this.lastUsage = event.usage
if (event.model_preset !== undefined) {
this.applyModelPreset(event.model_preset)
@@ -855,12 +888,17 @@ export class NanobotTui {
attachments.length ? `Attachments: ${attachments.join(", ")}` : "",
].filter(Boolean).join("\n")
if (this.transcript.user(content, event.turn_id)) this.recordPrompt(event.text)
this.hostBlocked = false
this.setCurrentTask(event.text)
this.reconcileTurnOwnership(event)
if (this.activeTurn) this.reportHostWorking()
return
}
case "delta":
this.setActive(true)
this.activeLabel = "Writing"
if (!this.currentAction) this.setCurrentAction("Writing")
this.reportHostWorking()
this.turnHadAnswer = true
this.transcript.stream(event.text)
return
@@ -881,7 +919,10 @@ export class NanobotTui {
if (event.kind) {
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
this.lastProgress = this.transcript.progress(event.text, event.tool_events)
if (this.lastProgress) this.setCurrentAction(this.lastProgress)
else if (!this.currentAction) this.setCurrentAction(this.activeLabel)
this.setActive(true)
this.reportHostWorking()
} else {
this.finalMessage = event.text
}
@@ -891,7 +932,9 @@ export class NanobotTui {
this.currentFileEdits = mergeFileEdits(this.currentFileEdits, event.edits)
if (this.diffViewer.visible) this.diffViewer.update(this.currentFileEdits)
this.lastProgress = this.transcript.fileEdits(event.edits)
this.setCurrentAction(this.lastProgress || "Editing")
this.setActive(true)
this.reportHostWorking()
return
case "reasoning_delta":
this.activeLabel = "Thinking"
@@ -925,6 +968,7 @@ export class NanobotTui {
if (typeof event.context_window_tokens === "number") {
this.contextWindowTokens = event.context_window_tokens
}
this.applyHostGoalState(event.goal_state)
this.updateTitle()
this.setActive(false)
// A synthetic/rehydrated turn may already be idle, in which case
@@ -934,6 +978,7 @@ export class NanobotTui {
? `${(event.latency_ms / 1000).toFixed(1)}s`
: ""
this.status.content = this.readyStatus()
this.reportHostResting()
if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id)
this.sendNextFollowUp()
return
@@ -942,12 +987,17 @@ export class NanobotTui {
if (event.status === "running") {
if (event.turn_id) this.activeTurnId = event.turn_id
this.activeLabel = "Working"
if (!this.currentAction) this.setCurrentAction("Working")
this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined)
this.reportHostWorking()
} else {
this.setActive(false)
this.reportHostResting()
}
return
case "goal_state":
this.applyHostGoalState(event.goal_state)
if (!this.activeTurn) this.reportHostResting()
return
case "turn_model_updated":
if (typeof event.context_window_tokens === "number") {
@@ -991,6 +1041,8 @@ export class NanobotTui {
this.turnHadAnswer = false
this.restoreQueuedPrompts()
this.setActive(false)
this.setCurrentAction(event.reason || event.detail || "Error")
this.reportHostResting()
return
}
}
@@ -1020,6 +1072,13 @@ export class NanobotTui {
this.historyHasMore = history.hasMoreBefore
this.transcript.history(history.messages)
this.restorePromptHistory(history.messages)
const reversedHistory = [...history.messages].reverse()
const lastUser = reversedHistory.find((message) => message.role === "user")
if (lastUser) this.setCurrentTask(lastUser.content)
const lastActivity = reversedHistory.find((message) => message.role === "activity")
if (lastActivity) {
this.setCurrentAction(lastActivity.fileEdits?.length ? "Edited" : lastActivity.content)
}
this.lastFileEdits = latestTurnFileEdits(history.messages)
if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
}
@@ -1031,6 +1090,7 @@ export class NanobotTui {
this.ready = true
if (!this.activeTurn) {
this.status.content = this.readyStatus()
this.reportHostResting()
}
}
}
@@ -1044,22 +1104,26 @@ export class NanobotTui {
private handleStatus(status: ConnectionStatus, detail?: string): void {
if (status === "connected") {
this.ready = false
this.host.reportState("unknown", "Connecting")
this.status.content = "Connected · preparing chat…"
return
}
if (status === "connecting") {
this.ready = false
this.host.reportState("unknown", detail ? "Reconnecting" : "Connecting")
if (detail) this.setActive(false)
this.status.content = detail ? "Reconnecting…" : "Connecting…"
return
}
if (status === "error") {
this.setActive(false)
this.host.reportState("unknown", detail || "Connection error")
this.status.content = detail || "Connection error"
return
}
if (!this.quitting) {
this.setActive(false)
this.host.reportState("unknown", "Disconnected")
this.status.content = "Disconnected"
}
}
@@ -1399,7 +1463,7 @@ export class NanobotTui {
this.resizeComposer()
this.contextPanel.resize(this.renderer.height)
this.diffViewer.resize(this.renderer.width)
this.title.visible = this.renderer.height >= 14
if (!this.host.hosted) this.title.visible = this.renderer.height >= 14
this.runtimeControls.resize(this.renderer.width)
this.updateTitle()
this.updateMeta()
@@ -1467,6 +1531,13 @@ export class NanobotTui {
}
private updateTitle(): void {
if (this.host.hosted) {
this.titleText.maxWidth = Math.max(8, this.renderer.width - 4)
this.titleText.content = this.currentTask ? ` ${this.currentTask}` : ""
this.title.visible = Boolean(this.currentTask) && this.renderer.height >= 8
this.syncHostMetadata()
return
}
const identity = this.sessionTitle.trim() || "nanobot"
this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
this.titleText.content = identity
@@ -1477,6 +1548,61 @@ export class NanobotTui {
: ""} ctx`
this.runtimeControls.updateModel(this.modelName, this.modelPreset)
this.runtimeControls.updateContext(context)
this.syncHostMetadata()
}
private setCurrentTask(task: string): void {
const next = singleLine(task)
if (!next || next === this.currentTask) return
this.currentTask = next
this.updateTitle()
}
private setCurrentAction(action: string): void {
const next = singleLine(action.replace(/^\s*[·›✓×]\s*/u, ""), 80)
if (!next || next === this.currentAction) return
this.currentAction = next
this.syncHostMetadata()
}
private clearHostContext(): void {
this.currentTask = ""
this.currentAction = ""
this.hostBlocked = false
this.updateTitle()
}
private syncHostMetadata(): void {
const model = [this.modelPreset, this.modelName].filter(Boolean).join(" · ")
this.host.reportMetadata({
model,
branch: this.hostBranch,
workspace: this.hostWorkspace,
task: this.currentTask,
action: this.currentAction,
})
}
private applyHostGoalState(state: Record<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 {
@@ -1591,7 +1717,7 @@ export class NanobotTui {
// Local navigation remains available against older gateways.
}
const commands = new Map(discovered.map((command) => [command.command, command]))
this.commandMenu.setCommands([...commands.values()], LOCAL_COMMANDS)
this.commandMenu.setCommands([...commands.values()], this.localCommands)
this.syncCommandMenu()
}
@@ -1605,6 +1731,11 @@ export class NanobotTui {
}
private applyWorkspaceScope(scope: WorkspaceScopePayload): void {
if (scope.project_path) {
this.hostWorkspace = scope.project_path
this.hostBranch = currentGitBranch(scope.project_path)
this.syncHostMetadata()
}
this.runtimeControls.updateWorkspaceScope(scope)
this.updateTitle()
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
@@ -1674,6 +1805,8 @@ export class NanobotTui {
this.clearPromptQueue()
this.sessionMetadataId += 1
this.sessionTitle = `Fork · ${preview.slice(0, 48)}`
this.clearHostContext()
this.setCurrentTask(preview)
this.contextTokens = null
this.lastUsage = null
this.readyDetail = ""
@@ -1758,6 +1891,7 @@ export class NanobotTui {
this.ready = false
this.clearPromptQueue()
this.sessionMetadataId += 1
this.clearHostContext()
this.sessionTitle = sessionLabel(session)
this.applySessionModel(session)
this.applySessionScope(session)
@@ -1791,6 +1925,7 @@ export class NanobotTui {
this.ready = false
this.clearPromptQueue()
this.sessionMetadataId += 1
this.clearHostContext()
this.sessionTitle = "New chat"
this.sessionModelPreset = null
this.modelName = this.defaultModelName
@@ -1836,13 +1971,17 @@ export class NanobotTui {
if (!silent) this.recordPrompt(content)
if (lifecycle === "agent_turn") {
this.hostBlocked = false
this.setCurrentTask(content)
this.activeTurnId = turnId
this.finalMessage = ""
this.turnHadAnswer = false
this.lastProgress = ""
this.activeLabel = "Thinking"
this.currentFileEdits = []
this.setCurrentAction("Thinking")
this.setActive(true)
this.reportHostWorking()
} else if (lifecycle === "finalize_active_turn") {
this.activeTurnId = null
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
@@ -1850,10 +1989,12 @@ export class NanobotTui {
this.finalMessage = ""
this.turnHadAnswer = false
this.setActive(false)
this.reportHostResting()
this.status.content = "Resetting chat…"
} else if (lifecycle === "stop_active_turn") {
this.activeTurnId = null
this.setActive(false)
this.reportHostResting()
this.status.content = "Stopping…"
} else if (!this.activeTurn) {
this.status.content = `Running ${content.split(/\s+/u, 1)[0]}`
@@ -2020,6 +2161,7 @@ export class NanobotTui {
this.quitting = true
this.submitGeneration += 1
this.submitPending = false
this.host.release()
this.client.close()
this.renderer.destroy()
}
@@ -2028,6 +2170,7 @@ export class NanobotTui {
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.transcript.destroy()
this.diffViewer.destroy()
this.host.release()
this.client.close()
}
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import { createTuiHost, currentGitBranch } from "./host"
async function settle(): Promise<void> {
await Bun.sleep(0)
await Bun.sleep(0)
}
describe("TUI host integration", () => {
test("reads the current workspace branch without leaking git errors", () => {
expect(currentGitBranch(process.cwd())).not.toBe("")
expect(currentGitBranch("/definitely/not/a/repository")).toBe("")
})
test("standalone terminals remain a no-op", async () => {
const commands: string[][] = []
const host = createTuiHost({}, async (command) => { commands.push([...command]) })
host.reportState("working", "task")
host.reportSession("chat")
host.reportMetadata({ model: "gpt", task: "task" })
host.release()
await settle()
expect(host.hosted).toBe(false)
expect(commands).toEqual([])
})
test("reports semantic lifecycle, session identity, metadata, and release", async () => {
const commands: string[][] = []
const host = createTuiHost(
{ HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2", HERDR_BIN_PATH: "/bin/herdr" },
async (command) => { commands.push([...command]) },
)
host.reportMetadata({
model: "openai/gpt",
branch: "feat/host",
workspace: "/repo",
task: " Fix\nHerdr integration ",
action: "Testing",
})
host.reportSession("chat-1")
host.reportState("working", "Fix Herdr integration")
host.reportState("working", "Fix Herdr integration")
host.reportState("blocked", "Approval required")
host.release()
await settle()
expect(host.hosted).toBe(true)
expect(commands).toHaveLength(6)
expect(commands[0]).toContain("pane")
expect(commands[0]).toContain("report-metadata")
expect(commands[0]).toContain("task=Fix Herdr integration")
expect(commands[1]).toContain("report-agent-session")
expect(commands[1]).toContain("chat-1")
expect(commands[2]).toContain("working")
expect(commands[2]).toContain("--agent-session-id")
expect(commands[3]).toContain("blocked")
expect(commands[4]).toContain("--clear-token")
expect(commands[5]).toContain("release-agent")
})
test("metadata patches only changed tokens", async () => {
const commands: string[][] = []
const host = createTuiHost(
{ HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2" },
async (command) => { commands.push([...command]) },
)
host.reportMetadata({ model: "gpt", branch: "main" })
host.reportMetadata({ model: "gpt", branch: "main" })
host.reportMetadata({ model: "gpt", branch: "" })
await settle()
expect(commands).toHaveLength(2)
expect(commands[1]).toContain("--clear-token")
expect(commands[1]).toContain("branch")
expect(commands[1]).not.toContain("model=gpt")
})
})
+174
View File
@@ -0,0 +1,174 @@
export type HostAgentState = "idle" | "working" | "blocked" | "unknown"
export interface HostMetadata {
model?: string
branch?: string
workspace?: string
task?: string
action?: string
}
export interface TuiHost {
readonly hosted: boolean
reportState(state: HostAgentState, message?: string): void
reportSession(sessionId: string): void
reportMetadata(metadata: HostMetadata): void
release(): void
}
export function currentGitBranch(workspace: string): string {
const path = workspace.trim()
if (!path) return ""
try {
const branch = spawnText(["git", "-C", path, "branch", "--show-current"])
if (branch) return branch
const revision = spawnText(["git", "-C", path, "rev-parse", "--short", "HEAD"])
return revision ? `@${revision}` : ""
} catch {
return ""
}
}
type Environment = Record<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
class StandaloneHost implements TuiHost {
readonly hosted = false
reportState(): void {}
reportSession(): void {}
reportMetadata(): void {}
release(): void {}
}
class HerdrHost implements TuiHost {
readonly hosted = true
private sequence = 0
private released = false
private lastState = ""
private lastSession = ""
private metadata: HostMetadata = {}
private queue: Promise<void> = Promise.resolve()
constructor(
private readonly paneId: string,
private readonly binary: string,
private readonly run: CommandRunner,
) {}
reportState(state: HostAgentState, message = ""): void {
if (this.released) return
const cleanMessage = normalize(message)
const fingerprint = `${state}\0${cleanMessage}\0${this.lastSession}`
if (fingerprint === this.lastState) return
this.lastState = fingerprint
const args = [
"pane", "report-agent", this.paneId,
"--source", LIFECYCLE_SOURCE,
"--agent", AGENT,
"--state", state,
"--seq", String(this.nextSequence()),
]
if (cleanMessage) args.push("--message", cleanMessage)
if (this.lastSession) args.push("--agent-session-id", this.lastSession)
this.enqueue(args)
}
reportSession(sessionId: string): void {
if (this.released) return
const cleanSession = normalize(sessionId, 256)
if (!cleanSession || cleanSession === this.lastSession) return
this.lastSession = cleanSession
this.lastState = ""
this.enqueue([
"pane", "report-agent-session", this.paneId,
"--source", LIFECYCLE_SOURCE,
"--agent", AGENT,
"--agent-session-id", cleanSession,
"--seq", String(this.nextSequence()),
])
}
reportMetadata(next: HostMetadata): void {
if (this.released) return
const changed: Array<[typeof METADATA_KEYS[number], string]> = []
for (const key of METADATA_KEYS) {
const value = normalize(next[key])
if (value === normalize(this.metadata[key])) continue
changed.push([key, value])
}
if (!changed.length) return
this.metadata = { ...this.metadata, ...next }
const args = [
"pane", "report-metadata", this.paneId,
"--source", METADATA_SOURCE,
"--agent", AGENT,
"--display-agent", AGENT,
"--seq", String(this.nextSequence()),
]
const task = normalize(this.metadata.task)
args.push(task ? "--title" : "--clear-title")
if (task) args.push(task)
for (const [key, value] of changed) {
args.push(value ? "--token" : "--clear-token", value ? `${key}=${value}` : key)
}
this.enqueue(args)
}
release(): void {
if (this.released) return
this.released = true
const clear = [
"pane", "report-metadata", this.paneId,
"--source", METADATA_SOURCE,
"--clear-title", "--clear-display-agent", "--clear-state-labels",
"--seq", String(this.nextSequence()),
]
for (const key of METADATA_KEYS) clear.push("--clear-token", key)
this.enqueue(clear)
this.enqueue([
"pane", "release-agent", this.paneId,
"--source", LIFECYCLE_SOURCE,
"--agent", AGENT,
"--seq", String(this.nextSequence()),
])
}
private nextSequence(): number {
this.sequence += 1
return this.sequence
}
private enqueue(args: string[]): void {
const command = [this.binary, ...args]
this.queue = this.queue.then(() => this.run(command)).catch(() => {})
}
}
function normalize(value: string | undefined, limit = 80): string {
return (value || "").replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit)
}
async function runCommand(command: readonly string[]): Promise<void> {
const child = Bun.spawn([...command], { stdout: "ignore", stderr: "ignore" })
await child.exited
}
function spawnText(command: readonly string[]): string {
const result = Bun.spawnSync([...command], { stdout: "pipe", stderr: "ignore" })
if (result.exitCode !== 0) return ""
return new TextDecoder().decode(result.stdout).trim()
}
export function createTuiHost(
environment: Environment = process.env,
run: CommandRunner = runCommand,
): TuiHost {
const paneId = environment.HERDR_PANE_ID?.trim() || ""
if (environment.HERDR_ENV !== "1" || !paneId) return new StandaloneHost()
return new HerdrHost(paneId, environment.HERDR_BIN_PATH?.trim() || "herdr", run)
}
+6 -1
View File
@@ -1,4 +1,5 @@
import { NanobotTui, type AppOptions } from "./app"
import { currentGitBranch } from "./host"
function required(name: string): string {
const value = process.env[name]?.trim()
@@ -12,6 +13,8 @@ function themePreference(): AppOptions["theme"] {
throw new Error("NANOBOT_TUI_THEME must be auto, dark, or light")
}
const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
const hostWorkspace = process.cwd()
const options: AppOptions = {
wsUrl: required("NANOBOT_TUI_WS_URL"),
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
@@ -19,7 +22,9 @@ const options: AppOptions = {
chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined,
model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model",
modelPreset: process.env.NANOBOT_TUI_MODEL_PRESET?.trim() || "default",
workspace: process.env.NANOBOT_TUI_WORKSPACE?.trim() || "",
workspace,
hostWorkspace,
branch: currentGitBranch(hostWorkspace),
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
theme: themePreference(),
+16 -1
View File
@@ -243,19 +243,34 @@ describe("gateway protocol", () => {
socket.emit("message", {
data: JSON.stringify({ event: "attached", chat_id: "one", model_preset: 42 }),
})
socket.emit("message", {
data: JSON.stringify({ event: "turn_end", chat_id: "one", goal_state: [] }),
})
socket.emit("message", {
data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: "metadata" }),
})
socket.emit("message", {
data: JSON.stringify({
event: "turn_end",
chat_id: "one",
goal_state: { active: false, status: "blocked", ui_summary: "Approval required" },
}),
})
socket.emit("message", { data: JSON.stringify({ event: "future_gateway_event" }) })
socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) })
expect(statuses).toContain("error:gateway sent an invalid event")
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(8)
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(9)
expect(events).toContainEqual({
event: "session_updated",
chat_id: "one",
scope: "metadata",
})
expect(events).toContainEqual({ event: "error", detail: "global failure" })
expect(events).toContainEqual({
event: "turn_end",
chat_id: "one",
goal_state: { active: false, status: "blocked", ui_summary: "Approval required" },
})
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
+4 -2
View File
@@ -108,6 +108,7 @@ export type InboundEvent =
turn_id?: string
usage?: TokenUsage
context_window_tokens?: number
goal_state?: Record<string, unknown>
}
| {
event: "goal_status"
@@ -421,10 +422,11 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
name === "turn_end"
&& (!optional(record.latency_ms, "number")
|| !optional(record.context_window_tokens, "number")
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|| (record.usage !== undefined && !isTokenUsage(record.usage))
|| (record.goal_state !== undefined && !isRecord(record.goal_state)))
) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
if (name === "goal_state" && (!record.goal_state || typeof record.goal_state !== "object")) return null
if (name === "goal_state" && !isRecord(record.goal_state)) return null
if (
name === "session_updated"
&& (!optional(record.scope, "string")
+8 -1
View File
@@ -8,7 +8,7 @@ describe("tool renderers", () => {
{ call_id: "exec-1", phase: "start", name: "exec", arguments: { cmd: "git status" } },
{ call_id: "exec-1", phase: "end", name: "exec", result: { output: "clean" } },
)
expect(renderToolEvent(event)).toBe(" ✓ Command git status")
expect(renderToolEvent(event)).toBe(" ✓ Ran git status")
})
test("uses stable task language for common file and web tools", () => {
@@ -19,4 +19,11 @@ describe("tool renderers", () => {
expect(renderToolEvent({ phase: "error", name: "web_fetch", error: "timeout" }))
.toBe(" × Fetch timeout")
})
test("compresses verification and edits into outcome language", () => {
expect(renderToolEvent({ phase: "end", name: "exec", arguments: { cmd: "bun test" } }))
.toBe(" ✓ Testing bun test")
expect(renderToolEvent({ phase: "end", name: "apply_patch", arguments: { path: "app.ts" } }))
.toBe(" ✓ Edited app.ts")
})
})
+15 -5
View File
@@ -21,14 +21,24 @@ export function renderToolEvent(event: ToolProgressEvent): string {
const args = record(event.arguments)
const result = record(event.result)
const detail = phase === "error" ? compact(event.error) : toolDetail(name, args, result)
return ` ${marker} ${toolLabel(name)}${detail ? ` ${detail}` : ""}`
return ` ${marker} ${toolLabel(name, phase, args)}${detail ? ` ${detail}` : ""}`
}
function toolLabel(name: string): string {
if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) return "Command"
function toolLabel(
name: string,
phase: string,
args: Record<string, unknown>,
): string {
if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) {
const command = compact(args.command ?? args.cmd)
if (/(?:^|\s)(?:pytest|ruff|pyright|basedpyright|cargo\s+test|go\s+test|(?:bun|npm|pnpm|yarn)(?:\s+run)?\s+test)(?:\s|$)/iu.test(command)) {
return "Testing"
}
return phase === "end" ? "Ran" : phase === "error" ? "Command failed" : "Running"
}
if (/^(?:read_file|read)$/u.test(name)) return "Read"
if (/^(?:write_file|write)$/u.test(name)) return "Write"
if (/^(?:edit_file|apply_patch|edit)$/u.test(name)) return "Edit"
if (/^(?:write_file|write)$/u.test(name)) return phase === "end" ? "Edited" : "Editing"
if (/^(?:edit_file|apply_patch|edit)$/u.test(name)) return phase === "end" ? "Edited" : "Editing"
if (name === "web_search") return "Search web"
if (name === "web_fetch") return "Fetch"
return name
+3 -1
View File
@@ -70,6 +70,7 @@ export class Transcript {
private theme: TranscriptTheme,
private readonly treeSitterClient: TreeSitterClient,
private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
private readonly showHeader = true,
) {
this.root = new ScrollBoxRenderable(renderer, {
id: "nanobot-tui-transcript",
@@ -113,6 +114,7 @@ export class Transcript {
}
header(options: TranscriptHeader): void {
if (!this.showHeader) return
const row = new BoxRenderable(this.renderer, {
id: this.id("header-row"),
width: "100%",
@@ -174,7 +176,7 @@ export class Transcript {
if (messages.length === 0) return
const previousTop = this.root.scrollTop
const previousHeight = this.root.scrollHeight
let index = 1 // Keep the launch header first.
let index = this.showHeader ? 1 : 0
for (const message of messages) {
if (message.role === "user") {
if (message.turnId && this.userTurnIds.has(message.turnId)) continue