mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
fix(tui): preserve full UI in Herdr panes
This commit is contained in:
+3
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user