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
|
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:
|
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.
|
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
|
`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
|
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
|
## 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`
|
`/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
|
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
|
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`
|
`--workspace` overrides the launch directory. On exit, the restored terminal prints a ready-to-run
|
||||||
at the top to load them in place.
|
`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
|
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;
|
`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]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
ENTER_ALT_SCREEN = "\x1b[?1049h"
|
ENTER_ALT_SCREEN = "\x1b[?1049h"
|
||||||
LEAVE_ALT_SCREEN = "\x1b[?1049l"
|
LEAVE_ALT_SCREEN = "\x1b[?1049l"
|
||||||
|
RESUME_COMMAND = "Resume with: nanobot agent --session websocket:resume-chat"
|
||||||
|
|
||||||
|
|
||||||
def _read(process: Any, timeout: float) -> str:
|
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_WS_URL": "ws://127.0.0.1:9/ws",
|
||||||
"NANOBOT_TUI_API_URL": "",
|
"NANOBOT_TUI_API_URL": "",
|
||||||
"NANOBOT_TUI_API_TOKEN": "",
|
"NANOBOT_TUI_API_TOKEN": "",
|
||||||
|
"NANOBOT_TUI_CHAT_ID": "resume-chat",
|
||||||
"NANOBOT_TUI_MODEL": "test/model",
|
"NANOBOT_TUI_MODEL": "test/model",
|
||||||
"NANOBOT_TUI_WORKSPACE": r"C:\nanobot-tui-conpty",
|
"NANOBOT_TUI_WORKSPACE": r"C:\nanobot-tui-conpty",
|
||||||
"NANOBOT_TUI_VERSION": "test",
|
"NANOBOT_TUI_VERSION": "test",
|
||||||
@@ -111,6 +113,7 @@ def main() -> int:
|
|||||||
process.write("exit\r")
|
process.write("exit\r")
|
||||||
output.append(_wait_for(process, LEAVE_ALT_SCREEN, 8))
|
output.append(_wait_for(process, LEAVE_ALT_SCREEN, 8))
|
||||||
exit_code = _wait_for_exit(process, 8)
|
exit_code = _wait_for_exit(process, 8)
|
||||||
|
output.append(_read(process, 0.5))
|
||||||
finally:
|
finally:
|
||||||
if process.isalive():
|
if process.isalive():
|
||||||
process.close(force=True)
|
process.close(force=True)
|
||||||
@@ -123,7 +126,11 @@ def main() -> int:
|
|||||||
raise AssertionError(f"TUI did not render committed input {glyph!r}")
|
raise AssertionError(f"TUI did not render committed input {glyph!r}")
|
||||||
if "Traceback" in text or "Task exception was never retrieved" in text:
|
if "Traceback" in text or "Task exception was never retrieved" in text:
|
||||||
raise AssertionError("TUI emitted an exception during shutdown")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
ENTER_ALT_SCREEN = b"\x1b[?1049h"
|
ENTER_ALT_SCREEN = b"\x1b[?1049h"
|
||||||
LEAVE_ALT_SCREEN = b"\x1b[?1049l"
|
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:
|
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_WS_URL": "ws://127.0.0.1:9/ws",
|
||||||
"NANOBOT_TUI_API_URL": "",
|
"NANOBOT_TUI_API_URL": "",
|
||||||
"NANOBOT_TUI_API_TOKEN": "",
|
"NANOBOT_TUI_API_TOKEN": "",
|
||||||
|
"NANOBOT_TUI_CHAT_ID": "resume-chat",
|
||||||
"NANOBOT_TUI_MODEL": "test/model",
|
"NANOBOT_TUI_MODEL": "test/model",
|
||||||
"NANOBOT_TUI_WORKSPACE": "/tmp/nanobot-tui-pty",
|
"NANOBOT_TUI_WORKSPACE": "/tmp/nanobot-tui-pty",
|
||||||
"NANOBOT_TUI_VERSION": "test",
|
"NANOBOT_TUI_VERSION": "test",
|
||||||
@@ -126,6 +128,7 @@ def main() -> int:
|
|||||||
os.write(master, b"exit\r")
|
os.write(master, b"exit\r")
|
||||||
output.extend(_wait_for(master, LEAVE_ALT_SCREEN, 5))
|
output.extend(_wait_for(master, LEAVE_ALT_SCREEN, 5))
|
||||||
exit_code = _wait_for_exit(pid, 5)
|
exit_code = _wait_for_exit(pid, 5)
|
||||||
|
output.extend(_read(master, 0.5))
|
||||||
reaped = True
|
reaped = True
|
||||||
finally:
|
finally:
|
||||||
if not reaped:
|
if not reaped:
|
||||||
@@ -146,6 +149,10 @@ def main() -> int:
|
|||||||
raise AssertionError(f"TUI did not render committed input {glyph!r}")
|
raise AssertionError(f"TUI did not render committed input {glyph!r}")
|
||||||
if "Traceback" in text or "Task exception was never retrieved" in text:
|
if "Traceback" in text or "Task exception was never retrieved" in text:
|
||||||
raise AssertionError("TUI emitted an exception during shutdown")
|
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
|
# The UI must inherit the host terminal background. Fixed RGB/indexed
|
||||||
# surfaces become black strips in embedded terminals after long output.
|
# surfaces become black strips in embedded terminals after long output.
|
||||||
for escape in (
|
for escape in (
|
||||||
@@ -156,7 +163,7 @@ def main() -> int:
|
|||||||
):
|
):
|
||||||
if escape in output:
|
if escape in output:
|
||||||
raise AssertionError(f"TUI painted a fixed background: {escape!r}")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -6,7 +6,7 @@ import {
|
|||||||
type TestRendererSetup,
|
type TestRendererSetup,
|
||||||
} from "@opentui/core/testing"
|
} 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 { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol"
|
||||||
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
|
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
|
||||||
|
|
||||||
@@ -38,6 +38,12 @@ function occurrences(frame: string, value: string): number {
|
|||||||
return frame.split(value).length - 1
|
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 {
|
function contrastRatio(foreground: string, background: string): number {
|
||||||
const luminance = (color: string) => {
|
const luminance = (color: string) => {
|
||||||
const channel = (offset: number) => {
|
const channel = (offset: number) => {
|
||||||
@@ -2055,19 +2061,29 @@ describe("NanobotTui layout", () => {
|
|||||||
test("destroys the renderer and transport together", async () => {
|
test("destroys the renderer and transport together", async () => {
|
||||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||||
let closed = false
|
let closed = false
|
||||||
|
const exited: string[] = []
|
||||||
const transport = client()
|
const transport = client()
|
||||||
transport.close = () => { closed = true }
|
transport.close = () => { closed = true }
|
||||||
const app = NanobotTui.mount(
|
const app = NanobotTui.mount(
|
||||||
setup.renderer,
|
setup.renderer,
|
||||||
options,
|
{
|
||||||
|
...options,
|
||||||
|
chatId: "original-chat",
|
||||||
|
onExit: (chatId) => {
|
||||||
|
expect(setup?.renderer.isDestroyed).toBe(true)
|
||||||
|
exited.push(chatId)
|
||||||
|
},
|
||||||
|
},
|
||||||
transport,
|
transport,
|
||||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.stop()
|
||||||
app.stop()
|
app.stop()
|
||||||
|
|
||||||
expect(closed).toBe(true)
|
expect(closed).toBe(true)
|
||||||
expect(setup.renderer.isDestroyed).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 () => {
|
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_WS_URL: "ws://127.0.0.1:9/ws",
|
||||||
NANOBOT_TUI_API_URL: "",
|
NANOBOT_TUI_API_URL: "",
|
||||||
NANOBOT_TUI_API_TOKEN: "",
|
NANOBOT_TUI_API_TOKEN: "",
|
||||||
|
NANOBOT_TUI_CHAT_ID: "resume-chat",
|
||||||
NANOBOT_TUI_MODEL: "test/model",
|
NANOBOT_TUI_MODEL: "test/model",
|
||||||
NANOBOT_TUI_WORKSPACE: "/tmp/nanobot-test",
|
NANOBOT_TUI_WORKSPACE: "/tmp/nanobot-test",
|
||||||
NANOBOT_TUI_VERSION: "test",
|
NANOBOT_TUI_VERSION: "test",
|
||||||
@@ -2278,5 +2295,9 @@ if (process.platform !== "win32") {
|
|||||||
expect(error).toBe("")
|
expect(error).toBe("")
|
||||||
expect(output).toContain("\x1b[?1049h")
|
expect(output).toContain("\x1b[?1049h")
|
||||||
expect(output).toContain("\x1b[?1049l")
|
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
|
version: string
|
||||||
access: string
|
access: string
|
||||||
theme: "auto" | ThemeMode
|
theme: "auto" | ThemeMode
|
||||||
|
onExit?: (chatId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatClient {
|
interface ChatClient {
|
||||||
@@ -336,6 +337,11 @@ function singleLine(value: string, limit = 120): string {
|
|||||||
return value.replace(/\s+/gu, " ").trim().slice(0, limit)
|
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> {
|
async function copyWithSystemClipboard(text: string): Promise<void> {
|
||||||
const commands = process.platform === "darwin"
|
const commands = process.platform === "darwin"
|
||||||
? [["pbcopy"]]
|
? [["pbcopy"]]
|
||||||
@@ -2312,6 +2318,8 @@ export class NanobotTui {
|
|||||||
this.host.release()
|
this.host.release()
|
||||||
this.client.close()
|
this.client.close()
|
||||||
this.renderer.destroy()
|
this.renderer.destroy()
|
||||||
|
const chatId = this.client.activeChatId || this.options.chatId
|
||||||
|
if (chatId) this.options.onExit?.(chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleDestroy = (): void => {
|
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"
|
import { currentGitBranch } from "./host"
|
||||||
|
|
||||||
function themePreference(): AppOptions["theme"] {
|
function themePreference(): AppOptions["theme"] {
|
||||||
@@ -32,6 +32,9 @@ const options: AppOptions = {
|
|||||||
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
|
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
|
||||||
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
|
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
|
||||||
theme: themePreference(),
|
theme: themePreference(),
|
||||||
|
onExit: (chatId) => {
|
||||||
|
process.stdout.write(sessionExitMessage(chatId))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
let app: NanobotTui | undefined
|
let app: NanobotTui | undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user