mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-03 17:52:00 +03:00
feat(tui): add /detach command (#5461)
* feat(tui): add detach command * fix(tui): print exact detached gateway stop command
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@ bun run --cwd tui test
|
||||
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. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified 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.
|
||||
|
||||
|
||||
@@ -2153,6 +2153,65 @@ describe("NanobotTui layout", () => {
|
||||
expect(sent).toEqual([])
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
})
|
||||
|
||||
test("detaches without sending a message or reporting a normal exit", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
const sent: string[] = []
|
||||
const detached: string[] = []
|
||||
const exited: string[] = []
|
||||
let closed = false
|
||||
const transport = client(sent)
|
||||
transport.close = () => { closed = true }
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
{
|
||||
...options,
|
||||
onDetach: (chatId) => { if (chatId) detached.push(chatId) },
|
||||
onExit: (chatId) => { exited.push(chatId) },
|
||||
},
|
||||
transport,
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
const ui = app as unknown as {
|
||||
composer: TextareaRenderable
|
||||
commandMenu: { visible: boolean }
|
||||
}
|
||||
|
||||
await setup.mockInput.typeText("/detach")
|
||||
await setup.flush()
|
||||
expect(ui.commandMenu.visible).toBe(true)
|
||||
expect(setup.captureCharFrame()).toContain("/detach")
|
||||
|
||||
ui.composer.submit()
|
||||
await waitUntil(() => closed)
|
||||
|
||||
expect(sent).toEqual([])
|
||||
expect(detached).toEqual(["chat"])
|
||||
expect(exited).toEqual([])
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
})
|
||||
|
||||
test("detaches before the gateway assigns a chat ID", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
let detached = false
|
||||
const transport = { ...client(), activeChatId: "" }
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
{ ...options, onDetach: (chatId) => {
|
||||
expect(chatId).toBeUndefined()
|
||||
detached = true
|
||||
} },
|
||||
transport,
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
const composer = (app as unknown as { composer: TextareaRenderable }).composer
|
||||
|
||||
composer.setText("/detach")
|
||||
composer.submit()
|
||||
await waitUntil(() => detached)
|
||||
|
||||
expect(setup.renderer.isDestroyed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("NanobotTui in a Herdr pane", () => {
|
||||
|
||||
+12
-3
@@ -94,6 +94,7 @@ interface AppOptions {
|
||||
version: string
|
||||
access: string
|
||||
theme: "auto" | ThemeMode
|
||||
onDetach?: (chatId?: string) => void
|
||||
onExit?: (chatId: string) => void
|
||||
}
|
||||
|
||||
@@ -193,6 +194,12 @@ const LOCAL_COMMANDS: TuiCommand[] = [
|
||||
description: "Continue from an earlier completed reply",
|
||||
action: "branch",
|
||||
},
|
||||
{
|
||||
command: "/detach",
|
||||
title: "Detach",
|
||||
description: "Close this terminal UI and keep the agent running",
|
||||
action: "detach",
|
||||
},
|
||||
{
|
||||
command: "/exit",
|
||||
title: "Exit",
|
||||
@@ -835,6 +842,7 @@ export class NanobotTui {
|
||||
else if (command.command.action === "context") void this.openContext()
|
||||
else if (command.command.action === "diff") this.openDiff()
|
||||
else if (command.command.action === "branch") void this.openBranch()
|
||||
else if (command.command.action === "detach") this.quit(true)
|
||||
else if (command.command.action === "exit") this.quit()
|
||||
else this.startNewChat()
|
||||
return
|
||||
@@ -1771,7 +1779,7 @@ export class NanobotTui {
|
||||
}
|
||||
|
||||
private syncCommandMenu(): void {
|
||||
const limit = this.renderer.height >= 20 ? 6 : 3
|
||||
const limit = this.renderer.height >= 20 ? 7 : 3
|
||||
this.commandMenu.update(this.composer.plainText, limit)
|
||||
this.updateMeta()
|
||||
}
|
||||
@@ -2310,7 +2318,7 @@ export class NanobotTui {
|
||||
}
|
||||
}
|
||||
|
||||
private quit(): void {
|
||||
private quit(detach = false): void {
|
||||
if (this.quitting) return
|
||||
this.quitting = true
|
||||
this.submitGeneration += 1
|
||||
@@ -2319,7 +2327,8 @@ export class NanobotTui {
|
||||
this.client.close()
|
||||
this.renderer.destroy()
|
||||
const chatId = this.client.activeChatId || this.options.chatId
|
||||
if (chatId) this.options.onExit?.(chatId)
|
||||
if (detach) this.options.onDetach?.(chatId)
|
||||
else if (chatId) this.options.onExit?.(chatId)
|
||||
}
|
||||
|
||||
private handleDestroy = (): void => {
|
||||
|
||||
@@ -5,7 +5,14 @@ import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
||||
|
||||
export type CommandMenuTheme = PickerMenuTheme
|
||||
|
||||
export type TuiCommandAction = "sessions" | "new-chat" | "context" | "diff" | "branch" | "exit"
|
||||
export type TuiCommandAction =
|
||||
| "sessions"
|
||||
| "new-chat"
|
||||
| "context"
|
||||
| "diff"
|
||||
| "branch"
|
||||
| "detach"
|
||||
| "exit"
|
||||
|
||||
export interface TuiCommand {
|
||||
command: string
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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.
|
||||
const TUI_DETACH_EXIT_CODE = 90
|
||||
|
||||
function themePreference(): AppOptions["theme"] {
|
||||
const value = process.env.NANOBOT_TUI_THEME?.trim() || "auto"
|
||||
if (value === "auto" || value === "dark" || value === "light") return value
|
||||
@@ -11,6 +14,8 @@ const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
|
||||
const hostWorkspace = process.cwd()
|
||||
const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || ""
|
||||
const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || ""
|
||||
const gatewayStopCommand = process.env.NANOBOT_TUI_GATEWAY_STOP_COMMAND?.trim()
|
||||
|| "nanobot gateway stop"
|
||||
if (!bootstrapUrl && !wsUrl) {
|
||||
throw new Error("NANOBOT_TUI_BOOTSTRAP_URL or NANOBOT_TUI_WS_URL is required")
|
||||
}
|
||||
@@ -32,6 +37,12 @@ const options: AppOptions = {
|
||||
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
|
||||
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
|
||||
theme: themePreference(),
|
||||
onDetach: (chatId) => {
|
||||
process.exitCode = TUI_DETACH_EXIT_CODE
|
||||
process.stdout.write("Detached; the agent continues in the background.\n")
|
||||
if (chatId) process.stdout.write(sessionExitMessage(chatId))
|
||||
process.stdout.write(`Stop it with: ${gatewayStopCommand}\n`)
|
||||
},
|
||||
onExit: (chatId) => {
|
||||
process.stdout.write(sessionExitMessage(chatId))
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user