mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +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:
@@ -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