feat(tui): print resume command on exit

This commit is contained in:
chengyongru
2026-08-20 17:20:36 +08:00
committed by chengyongru
parent 997bdfc79d
commit 415df576b4
8 changed files with 57 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. 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:
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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:<id>` selectors. Use
`nanobot agent --classic --session <channel:id>` to resume a session owned by another channel;
+8 -1
View File
@@ -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
+8 -1
View File
@@ -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
+23 -2
View File
@@ -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",
)
})
}
+8
View File
@@ -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<void> {
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 => {
+4 -1
View File
@@ -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