fix(tui): preserve full UI in Herdr panes

This commit is contained in:
chengyongru
2026-08-28 16:36:07 +08:00
committed by chengyongru
parent e73cce706c
commit a339966543
7 changed files with 97 additions and 400 deletions
+3 -5
View File
@@ -11,17 +11,15 @@ bun run --cwd tui build
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. `/detach` closes the TUI after promoting the gateway to persistent background mode, keeping any active agent turn running without clients; the restored terminal prints the exact stop command for that config and explicit workspace. `nanobot gateway --background` can start or promote it persistently before opening a client. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. 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 the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. `/detach` closes the TUI after promoting the gateway to persistent background mode, keeping any active agent turn running without clients; the restored terminal prints the exact stop command for that config and explicit workspace. `nanobot gateway --background` can start or promote it persistently before opening a client. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
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. The TUI 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.
Assistant math written with `$...$`, `$$...$$`, `\\(...\\)`, or `\\[...\\]` is presented as Assistant math written with `$...$`, `$$...$$`, `\\(...\\)`, or `\\[...\\]` is presented as
Unicode plain text so formulas remain readable in terminals without a math renderer. Currency and Unicode plain text so formulas remain readable in terminals without a math renderer. Currency and
LaTeX inside inline or fenced code remain literal. LaTeX inside inline or fenced code remain literal.
## Herdr host mode ## Herdr pane titles
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/task chrome, and keeps only the transcript, compact progress, and composer. Herdr remains responsible for workspace, tab, pane, task, and attention navigation, while nanobot keeps its application-level session, new-chat, and branch commands. When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot keeps the same full-screen layout, controls, and navigation available in any other terminal. Its only host-specific behavior is reporting the latest user task as the Herdr pane title through the supported pane CLI. Creating a new chat, switching to a chat without a task, and exiting the TUI clear that title. Nanobot does not report agent lifecycle, session, model, Git branch, workspace, or action metadata to Herdr.
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.
+13 -40
View File
@@ -21,7 +21,7 @@ import type {
SlashCommand, SlashCommand,
WorkspaceScopePayload, WorkspaceScopePayload,
} from "./protocol" } from "./protocol"
import type { HostAgentState, HostMetadata, TuiHost } from "./host" import type { TuiHost } from "./host"
import type { ClipboardImageReader } from "./clipboard-image" import type { ClipboardImageReader } from "./clipboard-image"
import { userMessageText, type Transcript } from "./transcript" import { userMessageText, type Transcript } from "./transcript"
@@ -3075,23 +3075,18 @@ describe("NanobotTui layout", () => {
}) })
}) })
describe("NanobotTui in a Herdr pane", () => { describe("NanobotTui with a Herdr pane title reporter", () => {
test("keeps local navigation while reporting task, session, lifecycle, and metadata", async () => { test("keeps the full terminal experience while reporting task titles", async () => {
const setup = await createTestRenderer({ width: 80, height: 22, screenMode: "main-screen" }) const setup = await createTestRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
const states: Array<{ state: HostAgentState; message?: string }> = [] const titles: string[] = []
const metadata: HostMetadata[] = []
const sessions: string[] = []
let released = false let released = false
const host: TuiHost = { const host: TuiHost = {
hosted: true, reportTitle(title) { titles.push(title) },
reportState(state, message) { states.push({ state, ...(message ? { message } : {}) }) },
reportSession(sessionId) { sessions.push(sessionId) },
reportMetadata(value) { metadata.push(value) },
release() { released = true }, release() { released = true },
} }
const app = NanobotTui.mount( const app = NanobotTui.mount(
setup.renderer, setup.renderer,
{ ...options, branch: "feat/herdr" }, options,
client(), client(),
new MockTreeSitterClient({ autoResolveTimeout: 0 }), new MockTreeSitterClient({ autoResolveTimeout: 0 }),
host, host,
@@ -3127,10 +3122,14 @@ describe("NanobotTui in a Herdr pane", () => {
}) })
await setup.flush() await setup.flush()
const activeFrame = setup.captureCharFrame() const activeFrame = setup.captureCharFrame()
expect(activeFrame).toContain(">_ nanobot")
expect(activeFrame).toContain("test/model")
expect(occurrences(activeFrame, " Ship the Herdr integration")).toBe(1) expect(occurrences(activeFrame, " Ship the Herdr integration")).toBe(1)
expect(occurrences(activeFrame, "app.ts")).toBe(1) expect(occurrences(activeFrame, "app.ts")).toBe(1)
expect(ui.composer.placeholder).toBe("Enter send now · Tab send next") expect(ui.composer.placeholder).toBe("Enter send now · Tab send next")
expect(ui.composerFrame.height).toBe(3) expect(ui.composerFrame.height).toBe(3)
expect(titles).toEqual(["Ship the Herdr integration"])
app.accept({ app.accept({
event: "turn_end", event: "turn_end",
chat_id: "chat", chat_id: "chat",
@@ -3141,27 +3140,6 @@ describe("NanobotTui in a Herdr pane", () => {
ui_summary: "Approval required", ui_summary: "Approval required",
}, },
}) })
await setup.flush()
const frame = setup.captureCharFrame()
expect(sessions).toEqual(["chat"])
expect(occurrences(frame, " Ship the Herdr integration")).toBe(1)
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",
})
setup.resize(42, 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain(" Ship the Herdr integration")
app.accept({ app.accept({
event: "user_message", event: "user_message",
chat_id: "chat", chat_id: "chat",
@@ -3169,13 +3147,8 @@ describe("NanobotTui in a Herdr pane", () => {
turn_id: "turn-2", turn_id: "turn-2",
starts_turn: true, starts_turn: true,
}) })
app.accept({
event: "turn_end", expect(titles).toEqual(["Ship the Herdr integration", "Approved"])
chat_id: "chat",
turn_id: "turn-2",
goal_state: { active: false },
})
expect(states.at(-1)?.state).toBe("idle")
app.stop() app.stop()
expect(released).toBe(true) expect(released).toBe(true)
+30 -167
View File
@@ -94,7 +94,7 @@ import {
type FooterMode, type FooterMode,
type FooterHintTheme, type FooterHintTheme,
} from "./footer-hints" } from "./footer-hints"
import { createTuiHost, currentGitBranch, type TuiHost } from "./host" import { createTuiHost, type TuiHost } from "./host"
interface AppOptions { interface AppOptions {
wsUrl?: string wsUrl?: string
@@ -107,8 +107,6 @@ 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
@@ -404,10 +402,6 @@ function connectionStatusText(
return "Session ended" return "Session ended"
} }
function singleLine(value: string, limit = 120): string {
return value.replace(/\s+/gu, " ").trim().slice(0, limit)
}
export function sessionExitMessage(chatId: string): string { export function sessionExitMessage(chatId: string): string {
const sessionId = `websocket:${chatId}` const sessionId = `websocket:${chatId}`
return `Resume with: nanobot agent --session ${sessionId}\n` return `Resume with: nanobot agent --session ${sessionId}\n`
@@ -465,7 +459,6 @@ export class NanobotTui {
private activeTurnId: string | null = null private activeTurnId: string | null = null
private activeLabel = "Thinking" private activeLabel = "Thinking"
private activeStartedAt = 0 private activeStartedAt = 0
private lastProgress = ""
private finalMessage = "" private finalMessage = ""
private turnHadAnswer = false private turnHadAnswer = false
private historyLoaded = false private historyLoaded = false
@@ -514,13 +507,8 @@ 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 recoveryState: RecoveryState | null = null private recoveryState: RecoveryState | null = null
private recoveryPending = false private recoveryPending = false
private hostWorkspace: string
private hostBranch: string
private readonly apiReauthenticator: ApiReauthenticator | undefined private readonly apiReauthenticator: ApiReauthenticator | undefined
private readonly clipboardImageReader: ClipboardImageReader private readonly clipboardImageReader: ClipboardImageReader
private apiRefreshPromise: Promise<GatewayApiConnection> | null = null private apiRefreshPromise: Promise<GatewayApiConnection> | null = null
@@ -545,8 +533,6 @@ export class NanobotTui {
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.apiReauthenticator = options.bootstrapUrl this.apiReauthenticator = options.bootstrapUrl
? (rejectedApiToken) => this.refreshApiConnection(rejectedApiToken) ? (rejectedApiToken) => this.refreshApiConnection(rejectedApiToken)
: undefined : undefined
@@ -561,7 +547,6 @@ export class NanobotTui {
transcriptTheme(this.palette, this.backgroundKnown), transcriptTheme(this.palette, this.backgroundKnown),
treeSitterClient, treeSitterClient,
(state) => this.handleTranscriptNavigation(state), (state) => this.handleTranscriptNavigation(state),
!host.hosted,
options.workspace, options.workspace,
) )
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
@@ -672,21 +657,19 @@ export class NanobotTui {
truncate: true, truncate: true,
fg: this.palette.muted, fg: this.palette.muted,
selectable: false, selectable: false,
...(host.hosted ? {} : { onMouseOver: () => { this.titleText.fg = this.palette.accent },
onMouseOver: () => { this.titleText.fg = this.palette.accent }, onMouseOut: () => this.renderTitleColor(),
onMouseOut: () => this.renderTitleColor(), onMouseDown: (event) => {
onMouseDown: (event) => { if (event.button !== 0) return
if (event.button !== 0) return event.preventDefault()
event.preventDefault() event.stopPropagation()
event.stopPropagation() this.renderer.clearSelection()
this.renderer.clearSelection() if (this.sessionLoading || this.sessionMenu.visible) {
if (this.sessionLoading || this.sessionMenu.visible) { this.closeSessions()
this.closeSessions() return
return }
} void this.openSessions()
void this.openSessions() },
},
}),
}) })
this.runtimeControls = new RuntimeControls( this.runtimeControls = new RuntimeControls(
renderer, renderer,
@@ -721,11 +704,9 @@ 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",
@@ -819,7 +800,7 @@ export class NanobotTui {
this.shell.add(this.branchMenu.root) this.shell.add(this.branchMenu.root)
this.shell.add(this.contextPanel.root) this.shell.add(this.contextPanel.root)
this.shell.add(this.runtimeControls.menuRoot) this.shell.add(this.runtimeControls.menuRoot)
if (!host.hosted) this.shell.add(this.title) this.shell.add(this.title)
this.shell.add(this.queuePreview.root) this.shell.add(this.queuePreview.root)
this.shell.add(this.recoveryNotice.root) this.shell.add(this.recoveryNotice.root)
this.shell.add(this.composerFrame) this.shell.add(this.composerFrame)
@@ -836,7 +817,6 @@ 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> {
@@ -845,7 +825,7 @@ export class NanobotTui {
targetFps: 30, targetFps: 30,
exitOnCtrlC: false, exitOnCtrlC: false,
useMouse: true, useMouse: true,
screenMode: host.hosted ? "main-screen" : "alternate-screen", screenMode: "alternate-screen",
externalOutputMode: "passthrough", externalOutputMode: "passthrough",
consoleMode: "disabled", consoleMode: "disabled",
}) })
@@ -874,7 +854,6 @@ export class NanobotTui {
// Network setup and small menu payloads do not depend on terminal colors. // Network setup and small menu payloads do not depend on terminal colors.
// Start them while OSC theme detection is in flight instead of serializing // Start them while OSC theme detection is in flight instead of serializing
// up to one second of otherwise independent startup work. // up to one second of otherwise independent startup work.
this.host.reportState("unknown", "Getting ready")
this.client.connect() this.client.connect()
void this.loadCommands() void this.loadCommands()
void this.loadMentions() void this.loadMentions()
@@ -1021,8 +1000,7 @@ export class NanobotTui {
prompt.options.media, prompt.options.media,
prompt.displayContent, prompt.displayContent,
) )
this.hostBlocked = false this.host.reportTitle(prompt.content)
this.setCurrentTask(prompt.content)
if (steering) { if (steering) {
this.renderActiveStatus() this.renderActiveStatus()
this.updateMeta() this.updateMeta()
@@ -1037,12 +1015,9 @@ export class NanobotTui {
this.readyDetail = "" this.readyDetail = ""
this.finalMessage = "" this.finalMessage = ""
this.turnHadAnswer = false this.turnHadAnswer = false
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: {
@@ -1065,7 +1040,6 @@ export class NanobotTui {
if (event.event === "attached") { if (event.event === "attached") {
const switchedSession = Boolean(this.currentChatId && this.currentChatId !== event.chat_id) const switchedSession = Boolean(this.currentChatId && this.currentChatId !== event.chat_id)
this.currentChatId = event.chat_id this.currentChatId = 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)
@@ -1117,17 +1091,13 @@ export class NanobotTui {
)) { )) {
this.recordPrompt(event.text) this.recordPrompt(event.text)
} }
this.hostBlocked = false this.host.reportTitle(event.text)
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
@@ -1147,11 +1117,8 @@ 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.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
} }
@@ -1160,10 +1127,8 @@ export class NanobotTui {
this.activeLabel = "Editing" this.activeLabel = "Editing"
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.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"
@@ -1197,7 +1162,6 @@ 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
@@ -1207,7 +1171,6 @@ 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
@@ -1216,17 +1179,12 @@ 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 "recovery_state": case "recovery_state":
this.applyRecoveryState(event) this.applyRecoveryState(event)
@@ -1273,8 +1231,6 @@ 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
} }
} }
@@ -1312,11 +1268,7 @@ export class NanobotTui {
this.restorePromptHistory(history.messages) this.restorePromptHistory(history.messages)
const reversedHistory = [...history.messages].reverse() const reversedHistory = [...history.messages].reverse()
const lastUser = reversedHistory.find((message) => message.role === "user") const lastUser = reversedHistory.find((message) => message.role === "user")
if (lastUser) this.setCurrentTask(lastUser.content) if (lastUser) this.host.reportTitle(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)
} }
@@ -1328,7 +1280,6 @@ 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()
} }
} }
} }
@@ -1354,35 +1305,24 @@ export class NanobotTui {
this.recoveryPending = false this.recoveryPending = false
if (state.status === "resuming") { if (state.status === "resuming") {
this.recoveryNotice.hide() this.recoveryNotice.hide()
this.hostBlocked = false
this.activeLabel = "Continuing" this.activeLabel = "Continuing"
this.setCurrentAction("Continuing interrupted task")
this.setActive(true) this.setActive(true)
this.reportHostWorking()
return return
} }
if (state.status === "awaiting_user" || state.status === "failed") { if (state.status === "awaiting_user" || state.status === "failed") {
this.activeTurnId = null this.activeTurnId = null
this.setActive(false) this.setActive(false)
this.hostBlocked = true
this.recoveryNotice.show(state) this.recoveryNotice.show(state)
const detail = state.reason || (state.status === "failed"
? "Recovery failed"
: "Task interrupted")
this.setCurrentAction(detail)
this.status.content = state.can_continue === false this.status.content = state.can_continue === false
? "Interrupted · dismiss to start a new message" ? "Interrupted · dismiss to start a new message"
: "Interrupted · continue or dismiss" : "Interrupted · continue or dismiss"
this.host.reportState("blocked", detail)
this.composer.focus() this.composer.focus()
return return
} }
this.clearRecoveryState() this.clearRecoveryState()
this.activeTurnId = null this.activeTurnId = null
this.hostBlocked = false
this.setActive(false) this.setActive(false)
if (this.ready) this.status.content = this.readyStatus() if (this.ready) this.status.content = this.readyStatus()
this.reportHostResting()
} }
private async updateRecovery(action: "continue" | "dismiss"): Promise<void> { private async updateRecovery(action: "continue" | "dismiss"): Promise<void> {
@@ -1414,7 +1354,6 @@ export class NanobotTui {
this.recoveryPending = false this.recoveryPending = false
this.recoveryNotice.setBusy(false) this.recoveryNotice.setBusy(false)
this.status.content = error instanceof Error ? error.message : String(error) this.status.content = error instanceof Error ? error.message : String(error)
this.host.reportState("blocked", state.reason || "Task interrupted")
} finally { } finally {
this.composer.focus() this.composer.focus()
} }
@@ -1468,13 +1407,11 @@ export class NanobotTui {
this.connectionMessage = connectionStatusText(status, info) this.connectionMessage = connectionStatusText(status, info)
if (status === "connected") { if (status === "connected") {
this.ready = false this.ready = false
this.host.reportState("unknown", "Getting ready")
this.renderConnectionMessage() this.renderConnectionMessage()
return return
} }
if (["starting", "connecting", "reconnecting", "unavailable"].includes(status)) { if (["starting", "connecting", "reconnecting", "unavailable"].includes(status)) {
this.ready = false this.ready = false
this.host.reportState("unknown", this.connectionMessage)
if (status === "reconnecting" || status === "unavailable") this.setActive(false) if (status === "reconnecting" || status === "unavailable") this.setActive(false)
this.renderConnectionMessage() this.renderConnectionMessage()
return return
@@ -1482,14 +1419,12 @@ export class NanobotTui {
if (status === "error") { if (status === "error") {
if (info) this.ready = false if (info) this.ready = false
this.setActive(false) this.setActive(false)
this.host.reportState("unknown", this.connectionMessage)
this.renderConnectionMessage() this.renderConnectionMessage()
return return
} }
if (!this.quitting) { if (!this.quitting) {
this.ready = false this.ready = false
this.setActive(false) this.setActive(false)
this.host.reportState("unknown", "Disconnected")
this.renderConnectionMessage() this.renderConnectionMessage()
} }
} }
@@ -1529,7 +1464,6 @@ export class NanobotTui {
} }
if (this.shimmerTimer) clearInterval(this.shimmerTimer) if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.shimmerTimer = null this.shimmerTimer = null
this.lastProgress = ""
this.status.content = this.readyStatus() this.status.content = this.readyStatus()
} }
@@ -1953,7 +1887,7 @@ export class NanobotTui {
this.syncComposerPlaceholder() this.syncComposerPlaceholder()
this.contextPanel.resize(this.renderer.height) this.contextPanel.resize(this.renderer.height)
this.diffViewer.resize(this.renderer.width) this.diffViewer.resize(this.renderer.width)
if (!this.host.hosted) this.title.visible = this.renderer.height >= 14 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()
@@ -2022,10 +1956,6 @@ export class NanobotTui {
} }
private updateTitle(): void { private updateTitle(): void {
if (this.host.hosted) {
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
@@ -2036,69 +1966,14 @@ 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 renderTitleColor(): void { private renderTitleColor(): void {
this.titleText.fg = !this.host.hosted && (this.sessionLoading || this.sessionMenu.visible) this.titleText.fg = this.sessionLoading || this.sessionMenu.visible
? this.palette.accent ? this.palette.accent
: this.palette.muted : this.palette.muted
} }
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 {
const verticalPadding = this.renderer.height >= 12 ? 1 : 0 const verticalPadding = this.renderer.height >= 12 ? 1 : 0
const maxContentHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3))) const maxContentHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
@@ -2384,11 +2259,6 @@ 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()
@@ -2480,8 +2350,7 @@ 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.host.reportTitle(preview)
this.setCurrentTask(preview)
this.contextTokens = null this.contextTokens = null
this.lastUsage = null this.lastUsage = null
this.readyDetail = "" this.readyDetail = ""
@@ -2578,7 +2447,7 @@ export class NanobotTui {
this.clearRecoveryState() this.clearRecoveryState()
this.queuePreview.update([]) this.queuePreview.update([])
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.clearHostContext() this.host.reportTitle("")
this.sessionTitle = sessionLabel(session) this.sessionTitle = sessionLabel(session)
this.applySessionModel(session) this.applySessionModel(session)
this.applySessionScope(session) this.applySessionScope(session)
@@ -2614,7 +2483,7 @@ export class NanobotTui {
this.clearRecoveryState() this.clearRecoveryState()
this.clearPromptQueue() this.clearPromptQueue()
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.clearHostContext() this.host.reportTitle("")
this.sessionTitle = "New chat" this.sessionTitle = "New chat"
this.sessionModelPreset = null this.sessionModelPreset = null
this.modelName = this.defaultModelName this.modelName = this.defaultModelName
@@ -2661,17 +2530,13 @@ 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.host.reportTitle(content)
this.setCurrentTask(content)
this.activeTurnId = turnId this.activeTurnId = turnId
this.finalMessage = "" this.finalMessage = ""
this.turnHadAnswer = false this.turnHadAnswer = false
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)
@@ -2679,12 +2544,10 @@ 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]}`
+38 -53
View File
@@ -1,82 +1,67 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createTuiHost, currentGitBranch } from "./host" import { createTuiHost } from "./host"
async function settle(): Promise<void> { async function settle(): Promise<void> {
await Bun.sleep(40) await Bun.sleep(0)
await Bun.sleep(0) await Bun.sleep(0)
} }
describe("TUI host integration", () => { 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 () => { test("standalone terminals remain a no-op", async () => {
const commands: string[][] = [] const commands: string[][] = []
const host = createTuiHost({}, async (command) => { commands.push([...command]) }) const host = createTuiHost({}, async (command) => { commands.push([...command]) })
host.reportState("working", "task") host.reportTitle("task")
host.reportSession("chat")
host.reportMetadata({ model: "gpt", task: "task" })
host.release() host.release()
await settle() await settle()
expect(host.hosted).toBe(false)
expect(commands).toEqual([]) expect(commands).toEqual([])
}) })
test("reports semantic lifecycle, session identity, metadata, and release", async () => { test("requires both Herdr environment markers", async () => {
const commands: string[][] = []
const run = async (command: readonly string[]) => { commands.push([...command]) }
createTuiHost({ HERDR_ENV: "1" }, run).reportTitle("missing pane")
createTuiHost({ HERDR_PANE_ID: "w1:p2" }, run).reportTitle("missing host")
await settle()
expect(commands).toEqual([])
})
test("reports only normalized pane title changes and clears the title on release", async () => {
const commands: string[][] = [] const commands: string[][] = []
const host = createTuiHost( const host = createTuiHost(
{ HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2", HERDR_BIN_PATH: "/bin/herdr" }, { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2", HERDR_BIN_PATH: "/bin/herdr" },
async (command) => { commands.push([...command]) }, async (command) => { commands.push([...command]) },
) )
host.reportMetadata({ host.reportTitle(" Fix\nHerdr integration ")
model: "openai/gpt", host.reportTitle("Fix Herdr integration")
branch: "feat/host", host.reportTitle("Review results")
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() host.release()
host.reportTitle("ignored after release")
await settle() await settle()
expect(host.hosted).toBe(true) expect(commands).toEqual([
expect(commands).toHaveLength(6) [
expect(commands[0]).toContain("pane") "/bin/herdr", "pane", "report-metadata", "w1:p2",
expect(commands[0]).toContain("report-metadata") "--source", "nanobot:tui:metadata", "--seq", "1",
expect(commands[0]).toContain("task=Fix Herdr integration") "--title", "Fix Herdr integration",
expect(commands[1]).toContain("report-agent-session") ],
expect(commands[1]).toContain("chat-1") [
expect(commands[2]).toContain("working") "/bin/herdr", "pane", "report-metadata", "w1:p2",
expect(commands[2]).toContain("--agent-session-id") "--source", "nanobot:tui:metadata", "--seq", "2",
expect(commands[3]).toContain("blocked") "--title", "Review results",
expect(commands[4]).toContain("--clear-token") ],
expect(commands[5]).toContain("release-agent") [
}) "/bin/herdr", "pane", "report-metadata", "w1:p2",
"--source", "nanobot:tui:metadata", "--seq", "3", "--clear-title",
test("metadata patches only changed tokens", async () => { ],
const commands: string[][] = [] ])
const host = createTuiHost( expect(commands.flat()).not.toContain("report-agent")
{ HERDR_ENV: "1", HERDR_PANE_ID: "w1:p2" }, expect(commands.flat()).not.toContain("report-agent-session")
async (command) => { commands.push([...command]) }, expect(commands.flat()).not.toContain("--token")
)
host.reportMetadata({ model: "gpt", branch: "main" })
host.reportMetadata({ model: "gpt", branch: "main" })
host.reportMetadata({ model: "gpt", branch: "" })
await settle()
expect(commands).toHaveLength(1)
expect(commands[0]).toContain("model=gpt")
expect(commands[0]).toContain("--clear-token")
expect(commands[0]).toContain("branch")
}) })
}) })
+12 -128
View File
@@ -1,60 +1,22 @@
export type HostAgentState = "idle" | "working" | "blocked" | "unknown"
export interface HostMetadata {
model?: string
branch?: string
workspace?: string
task?: string
action?: string
}
export interface TuiHost { export interface TuiHost {
readonly hosted: boolean reportTitle(title: string): void
reportState(state: HostAgentState, message?: string): void
reportSession(sessionId: string): void
reportMetadata(metadata: HostMetadata): void
release(): 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 Environment = Record<string, string | undefined>
type CommandRunner = (command: readonly string[]) => Promise<void> type CommandRunner = (command: readonly string[]) => Promise<void>
const AGENT = "nanobot"
const LIFECYCLE_SOURCE = "nanobot:tui"
const METADATA_SOURCE = "nanobot:tui:metadata" const METADATA_SOURCE = "nanobot:tui:metadata"
const METADATA_KEYS = ["model", "branch", "workspace", "task", "action"] as const
const METADATA_FLUSH_MS = 32
class StandaloneHost implements TuiHost { class StandaloneHost implements TuiHost {
readonly hosted = false reportTitle(): void {}
reportState(): void {}
reportSession(): void {}
reportMetadata(): void {}
release(): void {} release(): void {}
} }
class HerdrHost implements TuiHost { class HerdrHost implements TuiHost {
readonly hosted = true
private sequence = 0 private sequence = 0
private released = false private released = false
private lastState = "" private lastTitle = ""
private lastSession = ""
private metadata: HostMetadata = {}
private readonly pendingMetadata = new Set<typeof METADATA_KEYS[number]>()
private metadataTimer: ReturnType<typeof setTimeout> | null = null
private queue: Promise<void> = Promise.resolve() private queue: Promise<void> = Promise.resolve()
constructor( constructor(
@@ -63,97 +25,25 @@ class HerdrHost implements TuiHost {
private readonly run: CommandRunner, private readonly run: CommandRunner,
) {} ) {}
reportState(state: HostAgentState, message = ""): void { reportTitle(title: string): void {
if (this.released) return if (this.released) return
const cleanMessage = normalize(message) const cleanTitle = normalize(title)
const fingerprint = `${state}\0${cleanMessage}\0${this.lastSession}` if (cleanTitle === this.lastTitle) return
if (fingerprint === this.lastState) return this.lastTitle = cleanTitle
this.lastState = fingerprint
// Preserve causal ordering when a semantic state transition follows a
// pending metadata snapshot; repeated working heartbeats still stay free.
this.flushMetadata()
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.flushMetadata()
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
for (const key of METADATA_KEYS) {
if (!(key in next)) continue
const value = normalize(next[key])
if (value === normalize(this.metadata[key])) continue
this.pendingMetadata.add(key)
}
if (!this.pendingMetadata.size) return
this.metadata = { ...this.metadata, ...next }
if (this.metadataTimer) return
this.metadataTimer = setTimeout(() => this.flushMetadata(), METADATA_FLUSH_MS)
}
private flushMetadata(): void {
if (this.metadataTimer) clearTimeout(this.metadataTimer)
this.metadataTimer = null
if (!this.pendingMetadata.size) return
const args = [ const args = [
"pane", "report-metadata", this.paneId, "pane", "report-metadata", this.paneId,
"--source", METADATA_SOURCE, "--source", METADATA_SOURCE,
"--agent", AGENT,
"--display-agent", AGENT,
"--seq", String(this.nextSequence()), "--seq", String(this.nextSequence()),
cleanTitle ? "--title" : "--clear-title",
] ]
const task = normalize(this.metadata.task) if (cleanTitle) args.push(cleanTitle)
args.push(task ? "--title" : "--clear-title")
if (task) args.push(task)
for (const key of this.pendingMetadata) {
const value = normalize(this.metadata[key])
args.push(value ? "--token" : "--clear-token", value ? `${key}=${value}` : key)
}
this.pendingMetadata.clear()
this.enqueue(args) this.enqueue(args)
} }
release(): void { release(): void {
if (this.released) return if (this.released) return
this.flushMetadata() this.reportTitle("")
this.released = true 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 { private nextSequence(): number {
@@ -167,8 +57,8 @@ class HerdrHost implements TuiHost {
} }
} }
function normalize(value: string | undefined, limit = 80): string { function normalize(value: string, limit = 80): string {
return (value || "").replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit) return value.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, limit)
} }
async function runCommand(command: readonly string[]): Promise<void> { async function runCommand(command: readonly string[]): Promise<void> {
@@ -176,12 +66,6 @@ async function runCommand(command: readonly string[]): Promise<void> {
await child.exited 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( export function createTuiHost(
environment: Environment = process.env, environment: Environment = process.env,
run: CommandRunner = runCommand, run: CommandRunner = runCommand,
-4
View File
@@ -1,5 +1,4 @@
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app" import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
import { currentGitBranch } from "./host"
// Keep in sync with _TUI_DETACH_EXIT_CODE in nanobot/cli/tui_launcher.py. // Keep in sync with _TUI_DETACH_EXIT_CODE in nanobot/cli/tui_launcher.py.
const TUI_DETACH_EXIT_CODE = 90 const TUI_DETACH_EXIT_CODE = 90
@@ -11,7 +10,6 @@ function themePreference(): AppOptions["theme"] {
} }
const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || "" const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
const hostWorkspace = process.cwd()
const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || "" const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || ""
const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || "" const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || ""
const healthUrl = process.env.NANOBOT_TUI_HEALTH_URL?.trim() || "" const healthUrl = process.env.NANOBOT_TUI_HEALTH_URL?.trim() || ""
@@ -34,8 +32,6 @@ const options: AppOptions = {
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, 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(),
+1 -3
View File
@@ -145,7 +145,6 @@ 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,
private readonly workspace = "", private readonly workspace = "",
) { ) {
this.root = new ScrollBoxRenderable(renderer, { this.root = new ScrollBoxRenderable(renderer, {
@@ -198,7 +197,6 @@ 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%",
@@ -265,7 +263,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 = this.showHeader ? 1 : 0 let index = 1 // Keep the launch header first.
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