mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
feat(tui): integrate with Herdr host
This commit is contained in:
+86
-1
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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,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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user