mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
feat(tui): integrate with Herdr host
This commit is contained in:
+8
-2
@@ -9,9 +9,15 @@ bun run --cwd tui test
|
||||
bun run --cwd tui build
|
||||
```
|
||||
|
||||
`nanobot agent` launches this client, attaches to the shared local gateway or starts it in the background, and passes an authenticated local endpoint through environment variables. Exiting one client leaves that gateway available to other terminals and the WebUI; stop it explicitly with `nanobot gateway stop`. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
|
||||
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes an authenticated local endpoint through environment variables. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
|
||||
|
||||
The renderer uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
|
||||
Standalone terminals use OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
|
||||
|
||||
## Herdr host mode
|
||||
|
||||
When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model chrome, and keeps only the transcript, last user task, current progress, and composer. Herdr remains responsible for workspace, tab, pane, and attention navigation; hosted command discovery therefore omits the duplicate session/new-chat/branch controls.
|
||||
|
||||
The TUI reports its WebSocket session ID, model, Git branch, workspace, last task, and current action through Herdr's supported pane CLI. Sending work reports `working`; a persisted explicit nanobot goal block reports `blocked`; a completed turn reports `idle`; exit releases lifecycle authority. The gateway session remains the durable transcript and resume path. Standalone terminals keep the richer full-screen navigation described below.
|
||||
|
||||
The model preset and workspace access labels above the composer are live controls. Click either
|
||||
label, then click a choice; arrow keys, `Enter`, and `Esc` provide the same flow without a mouse.
|
||||
|
||||
+86
-1
@@ -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