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
@@ -209,7 +209,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
nanobot agent
```
This opens the native terminal client with the configured model and tools, using the launch directory as its workspace. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message for editing. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback for terminals that cannot distinguish modified Enter keys. Use `PageUp` at the top to load earlier transcript pages. Each launch starts a new session; `--session` selects an existing WebSocket session, while `--workspace` overrides the launch directory. Use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. `nanobot agent` and `nanobot webui` share one on-demand local gateway: either command can start it, each launcher releases only its own client, and the last interactive launcher to exit stops it. Use `nanobot gateway --background` when the gateway must stay alive with no local clients. Type `exit` or press `Ctrl+C` when you are done; after the terminal is restored, nanobot prints a ready-to-run `nanobot agent --session ...` command that resumes the session. Use `nanobot agent --classic` for the legacy Python prompt.
This opens the native terminal client with the configured model and tools, using the launch directory as its workspace. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message for editing. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback for terminals that cannot distinguish modified Enter keys. Use `PageUp` at the top to load earlier transcript pages. Each launch starts a new session; `--session` selects an existing WebSocket session, while `--workspace` overrides the launch directory. Use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. `nanobot agent` and `nanobot webui` share one on-demand local gateway: either command can start it, each launcher releases only its own client, and the last interactive launcher to exit stops it. Use `/detach` to close the TUI while keeping the gateway and any active agent turn running in the background; after the terminal is restored, nanobot prints the exact `nanobot gateway stop` command for that config and workspace. Use `nanobot gateway --background` to start persistently before opening a client. Type `exit` or press `Ctrl+C` when you are done; after the terminal is restored, nanobot prints a ready-to-run `nanobot agent --session ...` command that resumes the session. Use `nanobot agent --classic` for the legacy Python prompt.
For one request and an immediate exit, use:
+2 -2
View File
@@ -123,7 +123,7 @@ nanobot sessions restore-workspace --config ./bot-a/config.json --workspace ./bo
The command never deletes the external store and refuses to overwrite a different existing
workspace file. Back up both the config directory and workspace before changing versions.
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, either client starts it on demand. The TUI paints immediately while the local gateway starts, then obtains fresh bootstrap credentials and connects in the background. Exiting one TUI or WebUI launcher releases only that client; the last interactive launcher stops the on-demand gateway. A small gateway watchdog also reclaims an on-demand process if its last client crashes. Only an explicit `nanobot gateway --background` promotes it to persistent mode. `nanobot gateway restart` restarts a detached gateway without changing that lifetime; restart an attached foreground gateway in its owning terminal. `nanobot gateway stop` ends either mode.
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, either client starts it on demand. The TUI paints immediately while the local gateway starts, then obtains fresh bootstrap credentials and connects in the background. Exiting one TUI or WebUI launcher releases only that client; the last interactive launcher stops the on-demand gateway. A small gateway watchdog also reclaims an on-demand process if its last client crashes. `/detach` promotes the shared gateway to persistent background mode before closing the TUI, so active agent work continues without a connected client. An explicit `nanobot gateway --background` starts or promotes the gateway the same way before opening a client. `nanobot gateway restart` restarts a detached gateway without changing that lifetime; restart an attached foreground gateway in its owning terminal. `nanobot gateway stop` ends either mode.
The default `--theme auto` mode paints first with the terminal's default background, probes the real foreground and background colors asynchronously, and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. The model preset and workspace access labels above the composer can be clicked to open their selectors; arrow keys, `Enter`, and `Esc` provide the same controls without a mouse. Access changes still pass through the gateway's local-trust and active-turn policy checks.
@@ -133,7 +133,7 @@ Packaged releases fetch a version-matched, checksummed terminal archive for macO
Non-interactive input/output, `--logs`, and `--no-markdown` automatically retain the classic prompt so existing scripts and diagnostic workflows do not acquire terminal control sequences or silently ignore their options.
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`. Use `/detach` instead to close the TUI without stopping the shared gateway or its active agent work. The restored terminal prints a copyable stop command with the same `--config` and explicit `--workspace` selectors.
## WebUI
+15 -1
View File
@@ -20,6 +20,7 @@ from nanobot import __version__
from nanobot.cli.runtime_config import _model_display
from nanobot.cli.webui_support import (
_gateway_health_ready,
_gateway_instance_command,
_host_for_local_browser,
_webui_endpoint_reachable,
)
@@ -59,6 +60,8 @@ _TUI_RELEASE_LIMITS = {
"nanobot-tui-source.tar.gz": 20 * 1024 * 1024,
"MANIFEST.sha256": 64 * 1024,
}
# Keep in sync with TUI_DETACH_EXIT_CODE in tui/src/index.ts.
_TUI_DETACH_EXIT_CODE = 90
@dataclass(frozen=True)
@@ -98,6 +101,11 @@ def launch_tui(
"workspace access" if config.tools.restrict_to_workspace else "full access"
),
"NANOBOT_TUI_THEME": theme,
"NANOBOT_TUI_GATEWAY_STOP_COMMAND": _gateway_instance_command(
"stop",
config_path=config_path,
workspace=workspace_override,
),
}
)
if bootstrap_secret:
@@ -118,7 +126,13 @@ def launch_tui(
workspace_override=workspace_override,
wait_until_ready=False,
)
return process.wait()
exit_code = process.wait()
if exit_code == _TUI_DETACH_EXIT_CODE:
lease = gateway.lease
if lease is not None:
lease.mark_persistent()
return 0
return exit_code
except BaseException:
if process is not None and process.poll() is None:
process.terminate()
+54
View File
@@ -191,6 +191,60 @@ def test_launcher_terminates_the_tui_when_gateway_start_fails(
assert terminated == [True]
def test_launcher_promotes_the_gateway_when_the_tui_detaches(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
events: list[str] = []
captured: dict[str, str] = {}
class FakeLease:
def mark_persistent(self) -> bool:
events.append("promoted")
return True
def release(self, *, wait_for_stop: bool = True) -> None:
assert wait_for_stop is False
events.append("released")
class FakeProcess:
def wait(self) -> int:
events.append("waited")
return tui_launcher._TUI_DETACH_EXIT_CODE
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
def popen(command: list[str], *, env: dict[str, str]) -> FakeProcess:
assert command == ["nanobot-tui"]
captured.update(env)
return FakeProcess()
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.Popen", popen)
monkeypatch.setattr(
"nanobot.cli.tui_launcher._ensure_gateway",
lambda *args, **kwargs: SimpleNamespace(
base_url="http://127.0.0.1:8765",
lease=FakeLease(),
),
)
config_path = tmp_path / "custom config" / "config.json"
workspace = tmp_path / "custom workspace"
result = launch_tui(
config,
config_path=config_path,
workspace_override=str(workspace),
session_id=None,
theme="auto",
)
assert result == 0
assert events == ["waited", "promoted", "released"]
assert captured["NANOBOT_TUI_GATEWAY_STOP_COMMAND"] == (
f"nanobot gateway stop --config '{config_path}' --workspace '{workspace.resolve()}'"
)
def test_explicit_tui_binary_must_exist(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+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))
},