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