feat(tui): add /detach command (#5461)

* feat(tui): add detach command

* fix(tui): print exact detached gateway stop command
This commit is contained in:
chengyongru
2026-08-21 15:50:07 +08:00
committed by GitHub
parent 5b44ebdfd7
commit 26764f2423
9 changed files with 163 additions and 9 deletions
+1 -1
View File
@@ -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.
+59
View File
@@ -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
View File
@@ -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 => {
+8 -1
View File
@@ -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
+11
View File
@@ -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))
},