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._webui_connections.discard(connection)
self._discard_webui_request_lock_if_idle(connection) self._discard_webui_request_lock_if_idle(connection)
async def _maybe_push_active_goal_state(self, chat_id: str) -> None: async def _maybe_push_persisted_goal_state(self, chat_id: str) -> None:
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed. """Replay actionable goal state after *chat_id* is subscribed.
Goal metadata lives on the session JSONL and survives gateway restarts, but Goal metadata lives on the session JSONL and survives gateway restarts, but
connected clients normally see it via ``goal_state`` / ``turn_end`` frames. connected clients normally see it via ``goal_state`` / ``turn_end`` frames.
@@ -554,7 +554,7 @@ class WebSocketChannel(BaseChannel):
if not isinstance(meta, dict): if not isinstance(meta, dict):
meta = {} meta = {}
blob = goal_state_ws_blob(cast(dict[str, Any], 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 return
await self.send_goal_state(chat_id, blob) 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: async def _hydrate_after_subscribe(self, chat_id: str) -> None:
"""Replay persisted or actively running per-chat state after subscribe.""" """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) await self._maybe_push_turn_run_wall_clock(chat_id)
async def _send_event( 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)) channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock() mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1") 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() 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() mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1") 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() 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() mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1") 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() mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0]) body = json.loads(mock_ws.send.await_args.args[0])
assert body["event"] == "goal_state" 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" 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 @pytest.mark.asyncio
async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> None: async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> None:
bus = MagicMock() 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]: 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).""" """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 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() objective = str(goal.get("objective") or "").strip()
if len(objective) > _MAX_OBJECTIVE_WS: if len(objective) > _MAX_OBJECTIVE_WS:
objective = objective[:_MAX_OBJECTIVE_WS].rstrip() + "" objective = objective[:_MAX_OBJECTIVE_WS].rstrip() + ""
summary = str(goal.get("ui_summary") or "").strip()[:120] 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: if summary:
blob["ui_summary"] = summary blob["ui_summary"] = summary
if objective: if objective:
blob["objective"] = objective blob["objective"] = objective
recap = str(goal.get("recap") or "").strip()[:240]
if recap:
blob["recap"] = recap
return blob return blob
return {"active": False} 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 isinstance(call.event, GoalStateSyncEvent)
assert call.event.goal_state == { assert call.event.goal_state == {
"active": True, "active": True,
"status": "active",
"ui_summary": "alpha", "ui_summary": "alpha",
"objective": "Objective 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) == { assert goal_state_ws_blob(meta) == {
"active": True, "active": True,
"status": "active",
"ui_summary": "feat", "ui_summary": "feat",
"objective": "Build feature.", "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(): def test_sustained_goal_active_false_when_missing_or_completed():
assert sustained_goal_active(None) is False assert sustained_goal_active(None) is False
assert sustained_goal_active({}) 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 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 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. 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 { NanobotTui, type AppOptions } from "./app"
import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol" import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol"
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
const options: AppOptions = { const options: AppOptions = {
wsUrl: "ws://localhost.invalid/ws", wsUrl: "ws://localhost.invalid/ws",
@@ -1588,7 +1589,7 @@ describe("NanobotTui layout", () => {
expect(frame).toMatch(/Working\s+0s/u) expect(frame).toMatch(/Working\s+0s/u)
expect(frame).not.toMatch(/[]/u) expect(frame).not.toMatch(/[]/u)
expect(frame).toContain(" Command pwd") expect(frame).toContain(" Running pwd")
app.accept({ event: "turn_end", chat_id: "chat" }) 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") { if (process.platform !== "win32") {
test("restores the terminal after SIGTERM", async () => { test("restores the terminal after SIGTERM", async () => {
const child = Bun.spawn(["bun", "src/index.ts"], { const child = Bun.spawn(["bun", "src/index.ts"], {
+149 -6
View File
@@ -75,6 +75,7 @@ import {
type FooterMode, type FooterMode,
type FooterHintTheme, type FooterHintTheme,
} from "./footer-hints" } from "./footer-hints"
import { createTuiHost, currentGitBranch, type TuiHost } from "./host"
interface AppOptions { interface AppOptions {
wsUrl: string wsUrl: string
@@ -84,6 +85,8 @@ interface AppOptions {
model: string model: string
modelPreset: string modelPreset: string
workspace: string workspace: string
hostWorkspace?: string
branch?: string
version: string version: string
access: string access: string
theme: "auto" | ThemeMode 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` 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> { async function copyWithSystemClipboard(text: string): Promise<void> {
const commands = process.platform === "darwin" const commands = process.platform === "darwin"
? [["pbcopy"]] ? [["pbcopy"]]
@@ -359,6 +366,8 @@ export class NanobotTui {
private readonly composer: TextareaRenderable private readonly composer: TextareaRenderable
private readonly status: TextRenderable private readonly status: TextRenderable
private readonly meta: TextRenderable private readonly meta: TextRenderable
private readonly host: TuiHost
private readonly localCommands: TuiCommand[]
private readonly draft = new ComposerDraft() private readonly draft = new ComposerDraft()
private readonly promptQueue = new PromptQueue() private readonly promptQueue = new PromptQueue()
private palette: Palette private palette: Palette
@@ -411,30 +420,43 @@ export class NanobotTui {
private readonly silentCommandTurns = new Set<string>() private readonly silentCommandTurns = new Set<string>()
private currentFileEdits: FileEditEvent[] = [] private currentFileEdits: FileEditEvent[] = []
private lastFileEdits: FileEditEvent[] = [] private lastFileEdits: FileEditEvent[] = []
private currentTask = ""
private currentAction = ""
private hostBlocked = false
private hostWorkspace: string
private hostBranch: string
private constructor( private constructor(
renderer: CliRenderer, renderer: CliRenderer,
private readonly options: AppOptions, private readonly options: AppOptions,
client?: ChatClient, client?: ChatClient,
treeSitterClient = getTreeSitterClient(), treeSitterClient = getTreeSitterClient(),
host: TuiHost = createTuiHost({}),
) { ) {
this.renderer = renderer this.renderer = renderer
this.defaultModelName = options.model this.defaultModelName = options.model
this.defaultModelPreset = options.modelPreset this.defaultModelPreset = options.modelPreset
this.modelName = options.model this.modelName = options.model
this.modelPreset = options.modelPreset this.modelPreset = options.modelPreset
this.hostWorkspace = options.hostWorkspace || options.workspace
this.hostBranch = options.branch || ""
this.sessionModelPreset = options.chatId ? undefined : null this.sessionModelPreset = options.chatId ? undefined : null
this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null
this.activeThemeMode = this.resolveThemeMode(renderer.themeMode) this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK 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( this.transcript = new Transcript(
renderer, renderer,
transcriptTheme(this.palette, this.backgroundKnown), transcriptTheme(this.palette, this.backgroundKnown),
treeSitterClient, treeSitterClient,
(state) => this.handleTranscriptNavigation(state), (state) => this.handleTranscriptNavigation(state),
!host.hosted,
) )
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) 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.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
this.mentionMenu = new MentionMenu(renderer, commandMenuTheme(this.palette)) this.mentionMenu = new MentionMenu(renderer, commandMenuTheme(this.palette))
this.branchMenu = new BranchMenu(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.titleText)
if (!host.hosted) {
this.title.add(this.runtimeControls.modelText) this.title.add(this.runtimeControls.modelText)
this.title.add(this.runtimeControls.accessText) this.title.add(this.runtimeControls.accessText)
this.title.add(this.runtimeControls.contextText) this.title.add(this.runtimeControls.contextText)
}
const composerSurface = this.composerSurface() const composerSurface = this.composerSurface()
this.composerFrame = new BoxRenderable(renderer, { this.composerFrame = new BoxRenderable(renderer, {
id: "nanobot-tui-composer-frame", id: "nanobot-tui-composer-frame",
@@ -633,18 +657,20 @@ export class NanobotTui {
this.handleResize() this.handleResize()
this.composer.focus() this.composer.focus()
this.transcript.header(options) this.transcript.header(options)
this.syncHostMetadata()
} }
static async create(options: AppOptions): Promise<NanobotTui> { static async create(options: AppOptions): Promise<NanobotTui> {
const host = createTuiHost()
const renderer = await createCliRenderer({ const renderer = await createCliRenderer({
targetFps: 30, targetFps: 30,
exitOnCtrlC: false, exitOnCtrlC: false,
useMouse: true, useMouse: true,
screenMode: "alternate-screen", screenMode: host.hosted ? "main-screen" : "alternate-screen",
externalOutputMode: "passthrough", externalOutputMode: "passthrough",
consoleMode: "disabled", consoleMode: "disabled",
}) })
return NanobotTui.mount(renderer, options) return NanobotTui.mount(renderer, options, undefined, undefined, host)
} }
static mount( static mount(
@@ -652,8 +678,9 @@ export class NanobotTui {
options: AppOptions, options: AppOptions,
client?: ChatClient, client?: ChatClient,
treeSitterClient?: TreeSitterClient, treeSitterClient?: TreeSitterClient,
host?: TuiHost,
): NanobotTui { ): NanobotTui {
return new NanobotTui(renderer, options, client, treeSitterClient) return new NanobotTui(renderer, options, client, treeSitterClient, host)
} }
async start(): Promise<void> { async start(): Promise<void> {
@@ -666,6 +693,7 @@ export class NanobotTui {
if (this.options.theme === "auto" && this.renderer.themeMode) { if (this.options.theme === "auto" && this.renderer.themeMode) {
this.applyTheme(this.renderer.themeMode) this.applyTheme(this.renderer.themeMode)
} }
this.host.reportState("unknown", "Connecting")
this.client.connect() this.client.connect()
void this.loadCommands() void this.loadCommands()
void this.loadMentions() void this.loadMentions()
@@ -770,6 +798,8 @@ export class NanobotTui {
this.mentionMenu.hide() this.mentionMenu.hide()
this.recordPrompt(prompt.content) this.recordPrompt(prompt.content)
this.transcript.user(prompt.content, turnId) this.transcript.user(prompt.content, turnId)
this.hostBlocked = false
this.setCurrentTask(prompt.content)
if (steering) { if (steering) {
this.status.content = `Steering current turn${this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""}` this.status.content = `Steering current turn${this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""}`
this.updateMeta() this.updateMeta()
@@ -787,7 +817,9 @@ export class NanobotTui {
this.lastProgress = "" this.lastProgress = ""
this.activeLabel = "Thinking" this.activeLabel = "Thinking"
this.currentFileEdits = [] this.currentFileEdits = []
this.setCurrentAction("Thinking")
this.setActive(true, startedAt) this.setActive(true, startedAt)
this.reportHostWorking()
} }
private reconcileTurnOwnership(event: { private reconcileTurnOwnership(event: {
@@ -809,6 +841,7 @@ export class NanobotTui {
accept(event: InboundEvent): void { accept(event: InboundEvent): void {
if (event.event === "attached") { if (event.event === "attached") {
void rememberChat(this.options.statePath, event.chat_id) void rememberChat(this.options.statePath, event.chat_id)
this.host.reportSession(event.chat_id)
if (event.usage) this.lastUsage = event.usage if (event.usage) this.lastUsage = event.usage
if (event.model_preset !== undefined) { if (event.model_preset !== undefined) {
this.applyModelPreset(event.model_preset) this.applyModelPreset(event.model_preset)
@@ -855,12 +888,17 @@ export class NanobotTui {
attachments.length ? `Attachments: ${attachments.join(", ")}` : "", attachments.length ? `Attachments: ${attachments.join(", ")}` : "",
].filter(Boolean).join("\n") ].filter(Boolean).join("\n")
if (this.transcript.user(content, event.turn_id)) this.recordPrompt(event.text) if (this.transcript.user(content, event.turn_id)) this.recordPrompt(event.text)
this.hostBlocked = false
this.setCurrentTask(event.text)
this.reconcileTurnOwnership(event) this.reconcileTurnOwnership(event)
if (this.activeTurn) this.reportHostWorking()
return return
} }
case "delta": case "delta":
this.setActive(true) this.setActive(true)
this.activeLabel = "Writing" this.activeLabel = "Writing"
if (!this.currentAction) this.setCurrentAction("Writing")
this.reportHostWorking()
this.turnHadAnswer = true this.turnHadAnswer = true
this.transcript.stream(event.text) this.transcript.stream(event.text)
return return
@@ -881,7 +919,10 @@ export class NanobotTui {
if (event.kind) { if (event.kind) {
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking" this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
this.lastProgress = this.transcript.progress(event.text, event.tool_events) 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.setActive(true)
this.reportHostWorking()
} else { } else {
this.finalMessage = event.text this.finalMessage = event.text
} }
@@ -891,7 +932,9 @@ export class NanobotTui {
this.currentFileEdits = mergeFileEdits(this.currentFileEdits, event.edits) this.currentFileEdits = mergeFileEdits(this.currentFileEdits, event.edits)
if (this.diffViewer.visible) this.diffViewer.update(this.currentFileEdits) if (this.diffViewer.visible) this.diffViewer.update(this.currentFileEdits)
this.lastProgress = this.transcript.fileEdits(event.edits) this.lastProgress = this.transcript.fileEdits(event.edits)
this.setCurrentAction(this.lastProgress || "Editing")
this.setActive(true) this.setActive(true)
this.reportHostWorking()
return return
case "reasoning_delta": case "reasoning_delta":
this.activeLabel = "Thinking" this.activeLabel = "Thinking"
@@ -925,6 +968,7 @@ export class NanobotTui {
if (typeof event.context_window_tokens === "number") { if (typeof event.context_window_tokens === "number") {
this.contextWindowTokens = event.context_window_tokens this.contextWindowTokens = event.context_window_tokens
} }
this.applyHostGoalState(event.goal_state)
this.updateTitle() this.updateTitle()
this.setActive(false) this.setActive(false)
// A synthetic/rehydrated turn may already be idle, in which case // 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` ? `${(event.latency_ms / 1000).toFixed(1)}s`
: "" : ""
this.status.content = this.readyStatus() this.status.content = this.readyStatus()
this.reportHostResting()
if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id) if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id)
this.sendNextFollowUp() this.sendNextFollowUp()
return return
@@ -942,12 +987,17 @@ export class NanobotTui {
if (event.status === "running") { if (event.status === "running") {
if (event.turn_id) this.activeTurnId = event.turn_id if (event.turn_id) this.activeTurnId = event.turn_id
this.activeLabel = "Working" this.activeLabel = "Working"
if (!this.currentAction) this.setCurrentAction("Working")
this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined) this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined)
this.reportHostWorking()
} else { } else {
this.setActive(false) this.setActive(false)
this.reportHostResting()
} }
return return
case "goal_state": case "goal_state":
this.applyHostGoalState(event.goal_state)
if (!this.activeTurn) this.reportHostResting()
return return
case "turn_model_updated": case "turn_model_updated":
if (typeof event.context_window_tokens === "number") { if (typeof event.context_window_tokens === "number") {
@@ -991,6 +1041,8 @@ export class NanobotTui {
this.turnHadAnswer = false this.turnHadAnswer = false
this.restoreQueuedPrompts() this.restoreQueuedPrompts()
this.setActive(false) this.setActive(false)
this.setCurrentAction(event.reason || event.detail || "Error")
this.reportHostResting()
return return
} }
} }
@@ -1020,6 +1072,13 @@ export class NanobotTui {
this.historyHasMore = history.hasMoreBefore this.historyHasMore = history.hasMoreBefore
this.transcript.history(history.messages) this.transcript.history(history.messages)
this.restorePromptHistory(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) this.lastFileEdits = latestTurnFileEdits(history.messages)
if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits) if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
} }
@@ -1031,6 +1090,7 @@ export class NanobotTui {
this.ready = true this.ready = true
if (!this.activeTurn) { if (!this.activeTurn) {
this.status.content = this.readyStatus() this.status.content = this.readyStatus()
this.reportHostResting()
} }
} }
} }
@@ -1044,22 +1104,26 @@ export class NanobotTui {
private handleStatus(status: ConnectionStatus, detail?: string): void { private handleStatus(status: ConnectionStatus, detail?: string): void {
if (status === "connected") { if (status === "connected") {
this.ready = false this.ready = false
this.host.reportState("unknown", "Connecting")
this.status.content = "Connected · preparing chat…" this.status.content = "Connected · preparing chat…"
return return
} }
if (status === "connecting") { if (status === "connecting") {
this.ready = false this.ready = false
this.host.reportState("unknown", detail ? "Reconnecting" : "Connecting")
if (detail) this.setActive(false) if (detail) this.setActive(false)
this.status.content = detail ? "Reconnecting…" : "Connecting…" this.status.content = detail ? "Reconnecting…" : "Connecting…"
return return
} }
if (status === "error") { if (status === "error") {
this.setActive(false) this.setActive(false)
this.host.reportState("unknown", detail || "Connection error")
this.status.content = detail || "Connection error" this.status.content = detail || "Connection error"
return return
} }
if (!this.quitting) { if (!this.quitting) {
this.setActive(false) this.setActive(false)
this.host.reportState("unknown", "Disconnected")
this.status.content = "Disconnected" this.status.content = "Disconnected"
} }
} }
@@ -1399,7 +1463,7 @@ export class NanobotTui {
this.resizeComposer() this.resizeComposer()
this.contextPanel.resize(this.renderer.height) this.contextPanel.resize(this.renderer.height)
this.diffViewer.resize(this.renderer.width) 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.runtimeControls.resize(this.renderer.width)
this.updateTitle() this.updateTitle()
this.updateMeta() this.updateMeta()
@@ -1467,6 +1531,13 @@ export class NanobotTui {
} }
private updateTitle(): void { 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" const identity = this.sessionTitle.trim() || "nanobot"
this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38)) this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
this.titleText.content = identity this.titleText.content = identity
@@ -1477,6 +1548,61 @@ export class NanobotTui {
: ""} ctx` : ""} ctx`
this.runtimeControls.updateModel(this.modelName, this.modelPreset) this.runtimeControls.updateModel(this.modelName, this.modelPreset)
this.runtimeControls.updateContext(context) 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 { private resizeComposer(): void {
@@ -1591,7 +1717,7 @@ export class NanobotTui {
// Local navigation remains available against older gateways. // Local navigation remains available against older gateways.
} }
const commands = new Map(discovered.map((command) => [command.command, command])) 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() this.syncCommandMenu()
} }
@@ -1605,6 +1731,11 @@ export class NanobotTui {
} }
private applyWorkspaceScope(scope: WorkspaceScopePayload): void { 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.runtimeControls.updateWorkspaceScope(scope)
this.updateTitle() this.updateTitle()
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus() if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
@@ -1674,6 +1805,8 @@ export class NanobotTui {
this.clearPromptQueue() this.clearPromptQueue()
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.sessionTitle = `Fork · ${preview.slice(0, 48)}` this.sessionTitle = `Fork · ${preview.slice(0, 48)}`
this.clearHostContext()
this.setCurrentTask(preview)
this.contextTokens = null this.contextTokens = null
this.lastUsage = null this.lastUsage = null
this.readyDetail = "" this.readyDetail = ""
@@ -1758,6 +1891,7 @@ export class NanobotTui {
this.ready = false this.ready = false
this.clearPromptQueue() this.clearPromptQueue()
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.clearHostContext()
this.sessionTitle = sessionLabel(session) this.sessionTitle = sessionLabel(session)
this.applySessionModel(session) this.applySessionModel(session)
this.applySessionScope(session) this.applySessionScope(session)
@@ -1791,6 +1925,7 @@ export class NanobotTui {
this.ready = false this.ready = false
this.clearPromptQueue() this.clearPromptQueue()
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.clearHostContext()
this.sessionTitle = "New chat" this.sessionTitle = "New chat"
this.sessionModelPreset = null this.sessionModelPreset = null
this.modelName = this.defaultModelName this.modelName = this.defaultModelName
@@ -1836,13 +1971,17 @@ export class NanobotTui {
if (!silent) this.recordPrompt(content) if (!silent) this.recordPrompt(content)
if (lifecycle === "agent_turn") { if (lifecycle === "agent_turn") {
this.hostBlocked = false
this.setCurrentTask(content)
this.activeTurnId = turnId this.activeTurnId = turnId
this.finalMessage = "" this.finalMessage = ""
this.turnHadAnswer = false this.turnHadAnswer = false
this.lastProgress = "" this.lastProgress = ""
this.activeLabel = "Thinking" this.activeLabel = "Thinking"
this.currentFileEdits = [] this.currentFileEdits = []
this.setCurrentAction("Thinking")
this.setActive(true) this.setActive(true)
this.reportHostWorking()
} else if (lifecycle === "finalize_active_turn") { } else if (lifecycle === "finalize_active_turn") {
this.activeTurnId = null this.activeTurnId = null
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage) this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
@@ -1850,10 +1989,12 @@ export class NanobotTui {
this.finalMessage = "" this.finalMessage = ""
this.turnHadAnswer = false this.turnHadAnswer = false
this.setActive(false) this.setActive(false)
this.reportHostResting()
this.status.content = "Resetting chat…" this.status.content = "Resetting chat…"
} else if (lifecycle === "stop_active_turn") { } else if (lifecycle === "stop_active_turn") {
this.activeTurnId = null this.activeTurnId = null
this.setActive(false) this.setActive(false)
this.reportHostResting()
this.status.content = "Stopping…" this.status.content = "Stopping…"
} else if (!this.activeTurn) { } else if (!this.activeTurn) {
this.status.content = `Running ${content.split(/\s+/u, 1)[0]}` this.status.content = `Running ${content.split(/\s+/u, 1)[0]}`
@@ -2020,6 +2161,7 @@ export class NanobotTui {
this.quitting = true this.quitting = true
this.submitGeneration += 1 this.submitGeneration += 1
this.submitPending = false this.submitPending = false
this.host.release()
this.client.close() this.client.close()
this.renderer.destroy() this.renderer.destroy()
} }
@@ -2028,6 +2170,7 @@ export class NanobotTui {
if (this.shimmerTimer) clearInterval(this.shimmerTimer) if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.transcript.destroy() this.transcript.destroy()
this.diffViewer.destroy() this.diffViewer.destroy()
this.host.release()
this.client.close() 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 { NanobotTui, type AppOptions } from "./app"
import { currentGitBranch } from "./host"
function required(name: string): string { function required(name: string): string {
const value = process.env[name]?.trim() 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") 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 = { const options: AppOptions = {
wsUrl: required("NANOBOT_TUI_WS_URL"), wsUrl: required("NANOBOT_TUI_WS_URL"),
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "", apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
@@ -19,7 +22,9 @@ const options: AppOptions = {
chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined, chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined,
model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model", model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model",
modelPreset: process.env.NANOBOT_TUI_MODEL_PRESET?.trim() || "default", 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", version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access", access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
theme: themePreference(), theme: themePreference(),
+16 -1
View File
@@ -243,19 +243,34 @@ describe("gateway protocol", () => {
socket.emit("message", { socket.emit("message", {
data: JSON.stringify({ event: "attached", chat_id: "one", model_preset: 42 }), 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", { socket.emit("message", {
data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: "metadata" }), 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: "future_gateway_event" }) })
socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) }) socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) })
expect(statuses).toContain("error:gateway sent an invalid event") 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({ expect(events).toContainEqual({
event: "session_updated", event: "session_updated",
chat_id: "one", chat_id: "one",
scope: "metadata", scope: "metadata",
}) })
expect(events).toContainEqual({ event: "error", detail: "global failure" }) 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 { } finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original }) Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
} }
+4 -2
View File
@@ -108,6 +108,7 @@ export type InboundEvent =
turn_id?: string turn_id?: string
usage?: TokenUsage usage?: TokenUsage
context_window_tokens?: number context_window_tokens?: number
goal_state?: Record<string, unknown>
} }
| { | {
event: "goal_status" event: "goal_status"
@@ -421,10 +422,11 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
name === "turn_end" name === "turn_end"
&& (!optional(record.latency_ms, "number") && (!optional(record.latency_ms, "number")
|| !optional(record.context_window_tokens, "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 ) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") 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 ( if (
name === "session_updated" name === "session_updated"
&& (!optional(record.scope, "string") && (!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: "start", name: "exec", arguments: { cmd: "git status" } },
{ call_id: "exec-1", phase: "end", name: "exec", result: { output: "clean" } }, { 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", () => { 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" })) expect(renderToolEvent({ phase: "error", name: "web_fetch", error: "timeout" }))
.toBe(" × Fetch 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 args = record(event.arguments)
const result = record(event.result) const result = record(event.result)
const detail = phase === "error" ? compact(event.error) : toolDetail(name, args, 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 { function toolLabel(
if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) return "Command" 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 (/^(?:read_file|read)$/u.test(name)) return "Read"
if (/^(?:write_file|write)$/u.test(name)) return "Write" if (/^(?:write_file|write)$/u.test(name)) return phase === "end" ? "Edited" : "Editing"
if (/^(?:edit_file|apply_patch|edit)$/u.test(name)) return "Edit" if (/^(?:edit_file|apply_patch|edit)$/u.test(name)) return phase === "end" ? "Edited" : "Editing"
if (name === "web_search") return "Search web" if (name === "web_search") return "Search web"
if (name === "web_fetch") return "Fetch" if (name === "web_fetch") return "Fetch"
return name return name
+3 -1
View File
@@ -70,6 +70,7 @@ export class Transcript {
private theme: TranscriptTheme, private theme: TranscriptTheme,
private readonly treeSitterClient: TreeSitterClient, private readonly treeSitterClient: TreeSitterClient,
private readonly onNavigationChange?: (state: TranscriptNavigation) => void, private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
private readonly showHeader = true,
) { ) {
this.root = new ScrollBoxRenderable(renderer, { this.root = new ScrollBoxRenderable(renderer, {
id: "nanobot-tui-transcript", id: "nanobot-tui-transcript",
@@ -113,6 +114,7 @@ export class Transcript {
} }
header(options: TranscriptHeader): void { header(options: TranscriptHeader): void {
if (!this.showHeader) return
const row = new BoxRenderable(this.renderer, { const row = new BoxRenderable(this.renderer, {
id: this.id("header-row"), id: this.id("header-row"),
width: "100%", width: "100%",
@@ -174,7 +176,7 @@ export class Transcript {
if (messages.length === 0) return if (messages.length === 0) return
const previousTop = this.root.scrollTop const previousTop = this.root.scrollTop
const previousHeight = this.root.scrollHeight const previousHeight = this.root.scrollHeight
let index = 1 // Keep the launch header first. let index = this.showHeader ? 1 : 0
for (const message of messages) { for (const message of messages) {
if (message.role === "user") { if (message.role === "user") {
if (message.turnId && this.userTurnIds.has(message.turnId)) continue 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``). */ /** WebSocket snapshot for sustained goals (`goal_state` events; keyed by ``chat_id``). */
export interface GoalStateWsPayload { export interface GoalStateWsPayload {
active: boolean; active: boolean;
status?: "active" | "blocked";
ui_summary?: string; ui_summary?: string;
objective?: string; objective?: string;
recap?: string;
} }
export interface ToolProgressEvent { export interface ToolProgressEvent {