diff --git a/README.md b/README.md index f3cf5c652..7ccded2f5 100644 --- a/README.md +++ b/README.md @@ -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. 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 `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. For one request and an immediate exit, use: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 91f5cd762..4c7b05e1a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -102,7 +102,8 @@ the next agent turn. `/branch` forks a saved conversation from a completed reply opens the latest turn's file changes as a full-screen unified diff. `PageUp` loads older transcript pages when you reach the top. By default, each launch starts a new session using the launch directory as its workspace. `--session` selects a specific existing -session, and `--workspace` overrides the launch directory. +session, and `--workspace` overrides the launch directory. When the TUI exits, it prints a +ready-to-run `nanobot agent --session ...` command for the current session. ## Session Storage and Rollback diff --git a/tui/README.md b/tui/README.md index 7c6f87e82..a3ad3f2ff 100644 --- a/tui/README.md +++ b/tui/README.md @@ -43,8 +43,9 @@ Use `/sessions` to search and switch persisted conversations without leaving the `/new-chat` preserves the current conversation and starts another one; nanobot's existing `/new` command keeps its cross-channel behavior and resets the current chat. Each launch starts a new session using the launch directory as its workspace; `--session` selects an existing session and -`--workspace` overrides the launch directory. When earlier transcript pages exist, press `PageUp` -at the top to load them in place. +`--workspace` overrides the launch directory. On exit, the restored terminal prints a ready-to-run +`nanobot agent --session ...` command that resumes the current session. When earlier transcript +pages exist, press `PageUp` at the top to load them in place. The native client accepts bare WebSocket chat IDs or `websocket:` selectors. Use `nanobot agent --classic --session ` to resume a session owned by another channel; diff --git a/tui/scripts/conpty_smoke.py b/tui/scripts/conpty_smoke.py index 9c19f3c4e..b4cd65a78 100644 --- a/tui/scripts/conpty_smoke.py +++ b/tui/scripts/conpty_smoke.py @@ -13,6 +13,7 @@ from typing import Any ROOT = Path(__file__).resolve().parents[1] ENTER_ALT_SCREEN = "\x1b[?1049h" LEAVE_ALT_SCREEN = "\x1b[?1049l" +RESUME_COMMAND = "Resume with: nanobot agent --session websocket:resume-chat" def _read(process: Any, timeout: float) -> str: @@ -71,6 +72,7 @@ def main() -> int: "NANOBOT_TUI_WS_URL": "ws://127.0.0.1:9/ws", "NANOBOT_TUI_API_URL": "", "NANOBOT_TUI_API_TOKEN": "", + "NANOBOT_TUI_CHAT_ID": "resume-chat", "NANOBOT_TUI_MODEL": "test/model", "NANOBOT_TUI_WORKSPACE": r"C:\nanobot-tui-conpty", "NANOBOT_TUI_VERSION": "test", @@ -111,6 +113,7 @@ def main() -> int: process.write("exit\r") output.append(_wait_for(process, LEAVE_ALT_SCREEN, 8)) exit_code = _wait_for_exit(process, 8) + output.append(_read(process, 0.5)) finally: if process.isalive(): process.close(force=True) @@ -123,7 +126,11 @@ def main() -> int: raise AssertionError(f"TUI did not render committed input {glyph!r}") if "Traceback" in text or "Task exception was never retrieved" in text: raise AssertionError("TUI emitted an exception during shutdown") - print("ConPTY smoke test passed: Unicode input, resize, and terminal restoration") + if text.count(RESUME_COMMAND) != 1: + raise AssertionError("TUI did not print exactly one reusable session command") + if text.index(RESUME_COMMAND) < text.index(LEAVE_ALT_SCREEN): + raise AssertionError("TUI printed the session command before restoring the terminal") + print("ConPTY smoke test passed: Unicode input, resize, restoration, and session resume") return 0 diff --git a/tui/scripts/pty_smoke.py b/tui/scripts/pty_smoke.py index e525f2387..f44a5e14c 100644 --- a/tui/scripts/pty_smoke.py +++ b/tui/scripts/pty_smoke.py @@ -24,6 +24,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] ENTER_ALT_SCREEN = b"\x1b[?1049h" LEAVE_ALT_SCREEN = b"\x1b[?1049l" +RESUME_COMMAND = b"Resume with: nanobot agent --session websocket:resume-chat" def _resize(fd: int, rows: int, columns: int) -> None: @@ -82,6 +83,7 @@ def main() -> int: "NANOBOT_TUI_WS_URL": "ws://127.0.0.1:9/ws", "NANOBOT_TUI_API_URL": "", "NANOBOT_TUI_API_TOKEN": "", + "NANOBOT_TUI_CHAT_ID": "resume-chat", "NANOBOT_TUI_MODEL": "test/model", "NANOBOT_TUI_WORKSPACE": "/tmp/nanobot-tui-pty", "NANOBOT_TUI_VERSION": "test", @@ -126,6 +128,7 @@ def main() -> int: os.write(master, b"exit\r") output.extend(_wait_for(master, LEAVE_ALT_SCREEN, 5)) exit_code = _wait_for_exit(pid, 5) + output.extend(_read(master, 0.5)) reaped = True finally: if not reaped: @@ -146,6 +149,10 @@ def main() -> int: raise AssertionError(f"TUI did not render committed input {glyph!r}") if "Traceback" in text or "Task exception was never retrieved" in text: raise AssertionError("TUI emitted an exception during shutdown") + if output.count(RESUME_COMMAND) != 1: + raise AssertionError("TUI did not print exactly one reusable session command") + if output.index(RESUME_COMMAND) < output.index(LEAVE_ALT_SCREEN): + raise AssertionError("TUI printed the session command before restoring the terminal") # The UI must inherit the host terminal background. Fixed RGB/indexed # surfaces become black strips in embedded terminals after long output. for escape in ( @@ -156,7 +163,7 @@ def main() -> int: ): if escape in output: raise AssertionError(f"TUI painted a fixed background: {escape!r}") - print("PTY smoke test passed: Unicode input, resize, and terminal restoration") + print("PTY smoke test passed: Unicode input, resize, restoration, and session resume") return 0 diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index cb5aa175b..ccd3e796b 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -6,7 +6,7 @@ import { type TestRendererSetup, } from "@opentui/core/testing" -import { NanobotTui, type AppOptions } from "./app" +import { NanobotTui, sessionExitMessage, type AppOptions } from "./app" import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol" import type { HostAgentState, HostMetadata, TuiHost } from "./host" @@ -38,6 +38,12 @@ function occurrences(frame: string, value: string): number { return frame.split(value).length - 1 } +test("formats a reusable session ID after exit", () => { + expect(sessionExitMessage("resume-chat")).toBe( + "Resume with: nanobot agent --session websocket:resume-chat\n", + ) +}) + function contrastRatio(foreground: string, background: string): number { const luminance = (color: string) => { const channel = (offset: number) => { @@ -2055,19 +2061,29 @@ describe("NanobotTui layout", () => { test("destroys the renderer and transport together", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) let closed = false + const exited: string[] = [] const transport = client() transport.close = () => { closed = true } const app = NanobotTui.mount( setup.renderer, - options, + { + ...options, + chatId: "original-chat", + onExit: (chatId) => { + expect(setup?.renderer.isDestroyed).toBe(true) + exited.push(chatId) + }, + }, transport, new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) + app.stop() app.stop() expect(closed).toBe(true) expect(setup.renderer.isDestroyed).toBe(true) + expect(exited).toEqual(["chat"]) }) test("exits immediately when Ctrl+C is pressed on an idle empty composer", async () => { @@ -2244,6 +2260,7 @@ if (process.platform !== "win32") { NANOBOT_TUI_WS_URL: "ws://127.0.0.1:9/ws", NANOBOT_TUI_API_URL: "", NANOBOT_TUI_API_TOKEN: "", + NANOBOT_TUI_CHAT_ID: "resume-chat", NANOBOT_TUI_MODEL: "test/model", NANOBOT_TUI_WORKSPACE: "/tmp/nanobot-test", NANOBOT_TUI_VERSION: "test", @@ -2278,5 +2295,9 @@ if (process.platform !== "win32") { expect(error).toBe("") expect(output).toContain("\x1b[?1049h") expect(output).toContain("\x1b[?1049l") + expect(output.indexOf("\x1b[?1049l")).toBeLessThan(output.indexOf("Resume with:")) + expect(output).toContain( + "Resume with: nanobot agent --session websocket:resume-chat\n", + ) }) } diff --git a/tui/src/app.ts b/tui/src/app.ts index 678654829..ad8590459 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -94,6 +94,7 @@ interface AppOptions { version: string access: string theme: "auto" | ThemeMode + onExit?: (chatId: string) => void } interface ChatClient { @@ -336,6 +337,11 @@ function singleLine(value: string, limit = 120): string { return value.replace(/\s+/gu, " ").trim().slice(0, limit) } +export function sessionExitMessage(chatId: string): string { + const sessionId = `websocket:${chatId}` + return `Resume with: nanobot agent --session ${sessionId}\n` +} + async function copyWithSystemClipboard(text: string): Promise { const commands = process.platform === "darwin" ? [["pbcopy"]] @@ -2312,6 +2318,8 @@ export class NanobotTui { this.host.release() this.client.close() this.renderer.destroy() + const chatId = this.client.activeChatId || this.options.chatId + if (chatId) this.options.onExit?.(chatId) } private handleDestroy = (): void => { diff --git a/tui/src/index.ts b/tui/src/index.ts index c63d63aed..609975a74 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -1,4 +1,4 @@ -import { NanobotTui, type AppOptions } from "./app" +import { NanobotTui, sessionExitMessage, type AppOptions } from "./app" import { currentGitBranch } from "./host" function themePreference(): AppOptions["theme"] { @@ -32,6 +32,9 @@ const options: AppOptions = { version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev", access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access", theme: themePreference(), + onExit: (chatId) => { + process.stdout.write(sessionExitMessage(chatId)) + }, } let app: NanobotTui | undefined