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
+4 -4
View File
@@ -539,8 +539,8 @@ class WebSocketChannel(BaseChannel):
self._webui_connections.discard(connection)
self._discard_webui_request_lock_if_idle(connection)
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
async def _maybe_push_persisted_goal_state(self, chat_id: str) -> None:
"""Replay actionable goal state after *chat_id* is subscribed.
Goal metadata lives on the session JSONL and survives gateway restarts, but
connected clients normally see it via ``goal_state`` / ``turn_end`` frames.
@@ -554,7 +554,7 @@ class WebSocketChannel(BaseChannel):
if not isinstance(meta, dict):
meta = {}
blob = goal_state_ws_blob(cast(dict[str, Any], meta))
if not blob.get("active"):
if not blob.get("active") and blob.get("status") != "blocked":
return
await self.send_goal_state(chat_id, blob)
@@ -572,7 +572,7 @@ class WebSocketChannel(BaseChannel):
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
"""Replay persisted or actively running per-chat state after subscribe."""
await self._maybe_push_active_goal_state(chat_id)
await self._maybe_push_persisted_goal_state(chat_id)
await self._maybe_push_turn_run_wall_clock(chat_id)
async def _send_event(
@@ -3186,7 +3186,7 @@ async def test_maybe_push_active_goal_state_noop_without_session_manager() -> No
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_active_goal_state("chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
mock_ws.send.assert_not_called()
@@ -3202,7 +3202,7 @@ async def test_maybe_push_active_goal_state_skips_when_no_goal_on_disk() -> None
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_active_goal_state("chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
mock_ws.send.assert_not_called()
@@ -3227,7 +3227,7 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk()
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_active_goal_state("chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body["event"] == "goal_state"
@@ -3237,6 +3237,39 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk()
assert body["goal_state"]["ui_summary"] == "Docs"
@pytest.mark.asyncio
async def test_maybe_push_goal_state_restores_blocked_attention_on_disk() -> None:
bus = MagicMock()
sm = MagicMock()
sm.read_session_file.return_value = {
"metadata": {
"goal_state": {
"status": "blocked",
"objective": "deploy safely",
"ui_summary": "Approval required",
},
},
"messages": [],
}
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sm),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_persisted_goal_state("chat-1")
body = json.loads(mock_ws.send.await_args.args[0])
assert body["goal_state"] == {
"active": False,
"status": "blocked",
"ui_summary": "Approval required",
"objective": "deploy safely",
}
@pytest.mark.asyncio
async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> None:
bus = MagicMock()
+6 -2
View File
@@ -98,16 +98,20 @@ def goal_state_runtime_lines(metadata: Mapping[str, Any] | None) -> list[str]:
def goal_state_ws_blob(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""JSON-safe snapshot for WebSocket ``goal_state`` events (one chat_id per frame)."""
goal = parse_goal_state(_session_goal_raw(metadata)) if metadata else None
if isinstance(goal, dict) and goal.get("status") == "active":
if isinstance(goal, dict) and goal.get("status") in {"active", "blocked"}:
status = str(goal.get("status"))
objective = str(goal.get("objective") or "").strip()
if len(objective) > _MAX_OBJECTIVE_WS:
objective = objective[:_MAX_OBJECTIVE_WS].rstrip() + ""
summary = str(goal.get("ui_summary") or "").strip()[:120]
blob: dict[str, Any] = {"active": True}
blob: dict[str, Any] = {"active": status == "active", "status": status}
if summary:
blob["ui_summary"] = summary
if objective:
blob["objective"] = objective
recap = str(goal.get("recap") or "").strip()[:240]
if recap:
blob["recap"] = recap
return blob
return {"active": False}
+1
View File
@@ -404,6 +404,7 @@ async def test_goal_state_events_publish_active_then_inactive(tmp_path):
assert isinstance(call.event, GoalStateSyncEvent)
assert call.event.goal_state == {
"active": True,
"status": "active",
"ui_summary": "alpha",
"objective": "Objective alpha",
}
+19
View File
@@ -100,11 +100,30 @@ def test_goal_state_ws_blob_active_shape():
}
assert goal_state_ws_blob(meta) == {
"active": True,
"status": "active",
"ui_summary": "feat",
"objective": "Build feature.",
}
def test_goal_state_ws_blob_preserves_blocked_state_for_host_attention():
meta = {
GOAL_STATE_KEY: {
"status": "blocked",
"objective": "Deploy safely.",
"ui_summary": "Approval required",
"recap": "Production access is required.",
},
}
assert goal_state_ws_blob(meta) == {
"active": False,
"status": "blocked",
"ui_summary": "Approval required",
"objective": "Deploy safely.",
"recap": "Production access is required.",
}
def test_sustained_goal_active_false_when_missing_or_completed():
assert sustained_goal_active(None) is False
assert sustained_goal_active({}) is False
+8 -2
View File
@@ -9,9 +9,15 @@ bun run --cwd tui test
bun run --cwd tui build
```
`nanobot agent` launches this client, attaches to the shared local gateway or starts it in the background, and passes an authenticated local endpoint through environment variables. Exiting one client leaves that gateway available to other terminals and the WebUI; stop it explicitly with `nanobot gateway stop`. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes an authenticated local endpoint through environment variables. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
The renderer uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
Standalone terminals use OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
## Herdr host mode
When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model chrome, and keeps only the transcript, last user task, current progress, and composer. Herdr remains responsible for workspace, tab, pane, and attention navigation; hosted command discovery therefore omits the duplicate session/new-chat/branch controls.
The TUI reports its WebSocket session ID, model, Git branch, workspace, last task, and current action through Herdr's supported pane CLI. Sending work reports `working`; a persisted explicit nanobot goal block reports `blocked`; a completed turn reports `idle`; exit releases lifecycle authority. The gateway session remains the durable transcript and resume path. Standalone terminals keep the richer full-screen navigation described below.
The model preset and workspace access labels above the composer are live controls. Click either
label, then click a choice; arrow keys, `Enter`, and `Esc` provide the same flow without a mouse.
+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
+2
View File
@@ -279,8 +279,10 @@ export interface AgentUIBlob {
/** WebSocket snapshot for sustained goals (`goal_state` events; keyed by ``chat_id``). */
export interface GoalStateWsPayload {
active: boolean;
status?: "active" | "blocked";
ui_summary?: string;
objective?: string;
recap?: string;
}
export interface ToolProgressEvent {