mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
feat(tui): print resume command on exit
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. 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:
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user