feat(tui): start fresh chats in launch workspace

This commit is contained in:
chengyongru
2026-08-20 11:16:09 +08:00
committed by chengyongru
parent 1018bdb7fe
commit c615aee2ca
11 changed files with 85 additions and 105 deletions
+1 -1
View File
@@ -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 same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. 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. The next launch returns to your last session unless `--session` selects another WebSocket session; 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. 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:
+3 -2
View File
@@ -100,8 +100,9 @@ Inside the native TUI, `/sessions` switches saved conversations, `/new-chat` sta
conversation, and `/context` explains the compacted summary and raw session suffix available to conversation, and `/context` explains the compacted summary and raw session suffix available to
the next agent turn. `/branch` forks a saved conversation from a completed reply, and `/diff` the next agent turn. `/branch` forks a saved conversation from a completed reply, and `/diff`
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. The default `PageUp` loads older transcript pages when you reach the top. By default, each launch starts a
launch returns to the last attached TUI session; `--session` selects a specific session instead. new session using the launch directory as its workspace. `--session` selects a specific existing
session, and `--workspace` overrides the launch directory.
## Session Storage and Rollback ## Session Storage and Rollback
+10 -24
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import hashlib import hashlib
import io import io
import json
import os import os
import platform import platform
import shutil import shutil
@@ -77,8 +76,8 @@ def launch_tui(
theme: str, theme: str,
) -> int: ) -> int:
"""Run the native TUI against the shared local gateway.""" """Run the native TUI against the shared local gateway."""
state_path = config_path.parent / "tui" / "state.json" chat_id = _initial_tui_chat_id(session_id)
chat_id = _initial_tui_chat_id(session_id, state_path) tui_workspace = _initial_tui_workspace(workspace_override)
command = _resolve_tui_command() command = _resolve_tui_command()
base_url, bootstrap_secret = _tui_gateway_connection(config) base_url, bootstrap_secret = _tui_gateway_connection(config)
gateway: _GatewayHandle | None = None gateway: _GatewayHandle | None = None
@@ -93,7 +92,7 @@ def launch_tui(
"NANOBOT_TUI_API_URL": base_url, "NANOBOT_TUI_API_URL": base_url,
"NANOBOT_TUI_MODEL": _model_display(config)[0], "NANOBOT_TUI_MODEL": _model_display(config)[0],
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default", "NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path), "NANOBOT_TUI_WORKSPACE": str(tui_workspace),
"NANOBOT_TUI_VERSION": __version__, "NANOBOT_TUI_VERSION": __version__,
"NANOBOT_TUI_ACCESS": ( "NANOBOT_TUI_ACCESS": (
"workspace access" if config.tools.restrict_to_workspace else "full access" "workspace access" if config.tools.restrict_to_workspace else "full access"
@@ -105,7 +104,6 @@ def launch_tui(
env["NANOBOT_TUI_BOOTSTRAP_SECRET"] = bootstrap_secret env["NANOBOT_TUI_BOOTSTRAP_SECRET"] = bootstrap_secret
else: else:
env.pop("NANOBOT_TUI_BOOTSTRAP_SECRET", None) env.pop("NANOBOT_TUI_BOOTSTRAP_SECRET", None)
env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
if chat_id: if chat_id:
env["NANOBOT_TUI_CHAT_ID"] = chat_id env["NANOBOT_TUI_CHAT_ID"] = chat_id
else: else:
@@ -478,26 +476,14 @@ def _websocket_chat_id(session_id: str) -> str | None:
return session_id or None return session_id or None
def _initial_tui_chat_id(session_id: str | None, state_path: Path) -> str | None: def _initial_tui_chat_id(session_id: str | None) -> str | None:
"""Resume the last TUI chat, while keeping an explicit selector authoritative.""" """Start fresh unless the caller explicitly selects a TUI chat."""
if session_id is not None: if session_id is not None:
return _websocket_chat_id(session_id) return _websocket_chat_id(session_id)
return _read_tui_chat_id(state_path) return None
def _read_tui_chat_id(path: Path) -> str | None: def _initial_tui_workspace(workspace_override: str | None) -> Path:
"""Read the last attached chat without making launch depend on optional state.""" """Use the launch directory unless the caller explicitly selects a workspace."""
try: workspace = Path(workspace_override) if workspace_override is not None else Path.cwd()
raw_payload: Any = json.loads(path.read_text(encoding="utf-8")) return workspace.expanduser().resolve(strict=False)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(raw_payload, dict):
return None
payload = cast(dict[str, Any], raw_payload)
value = payload.get("chat_id")
if not isinstance(value, str):
return None
value = value.strip()
if not value or len(value) > 256 or any(character in value for character in "\r\n"):
return None
return value
+16 -20
View File
@@ -16,7 +16,7 @@ from nanobot.cli.tui_launcher import (
_download_release_tui, _download_release_tui,
_ensure_gateway, _ensure_gateway,
_initial_tui_chat_id, _initial_tui_chat_id,
_read_tui_chat_id, _initial_tui_workspace,
_resolve_source_tui_command, _resolve_source_tui_command,
_resolve_tui_command, _resolve_tui_command,
_websocket_chat_id, _websocket_chat_id,
@@ -67,28 +67,22 @@ def test_native_tui_rejects_a_session_owned_by_another_channel() -> None:
_websocket_chat_id("telegram:123") _websocket_chat_id("telegram:123")
def test_tui_chat_state_is_optional_and_validated(tmp_path: Path) -> None: def test_default_tui_starts_fresh_but_explicit_session_wins() -> None:
path = tmp_path / "tui" / "state.json" assert _initial_tui_chat_id(None) is None
assert _read_tui_chat_id(path) is None assert _initial_tui_chat_id("websocket:chosen") == "chosen"
path.parent.mkdir()
path.write_text('{"schema_version": 1, "chat_id": "saved-chat"}', encoding="utf-8")
assert _read_tui_chat_id(path) == "saved-chat"
path.write_text('{"chat_id": "bad\\nchat"}', encoding="utf-8")
assert _read_tui_chat_id(path) is None
def test_default_tui_resumes_but_explicit_session_wins(tmp_path: Path) -> None: def test_default_tui_workspace_is_the_launch_directory(
path = tmp_path / "tui" / "state.json" monkeypatch: pytest.MonkeyPatch,
path.parent.mkdir() tmp_path: Path,
path.write_text('{"chat_id": "saved-chat"}', encoding="utf-8") ) -> None:
launch_directory = tmp_path / "project"
override = tmp_path / "override"
launch_directory.mkdir()
monkeypatch.chdir(launch_directory)
assert _initial_tui_chat_id(None, path) == "saved-chat" assert _initial_tui_workspace(None) == launch_directory.resolve()
assert _initial_tui_chat_id("websocket:chosen", path) == "chosen" assert _initial_tui_workspace(str(override)) == override.resolve()
path.unlink()
assert _initial_tui_chat_id(None, path) is None
def test_launcher_passes_the_canonical_model_preset_to_the_tui( def test_launcher_passes_the_canonical_model_preset_to_the_tui(
@@ -144,6 +138,7 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
assert result == 0 assert result == 0
assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6" assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6"
assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research" assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research"
assert captured["NANOBOT_TUI_WORKSPACE"] == str(Path.cwd().resolve())
assert captured["NANOBOT_TUI_BOOTSTRAP_URL"] == ( assert captured["NANOBOT_TUI_BOOTSTRAP_URL"] == (
"http://127.0.0.1:8765/webui/bootstrap" "http://127.0.0.1:8765/webui/bootstrap"
) )
@@ -151,6 +146,7 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
assert "NANOBOT_TUI_WS_URL" not in captured assert "NANOBOT_TUI_WS_URL" not in captured
assert "NANOBOT_TUI_API_TOKEN" not in captured assert "NANOBOT_TUI_API_TOKEN" not in captured
assert "NANOBOT_TUI_CHAT_ID" not in captured assert "NANOBOT_TUI_CHAT_ID" not in captured
assert "NANOBOT_TUI_STATE_PATH" not in captured
assert events == ["spawned", "waited"] assert events == ["spawned", "waited"]
assert released == [True] assert released == [True]
+4 -3
View File
@@ -41,9 +41,10 @@ Unsent prompts return to the composer if the turn stops or fails.
Use `/sessions` to search and switch persisted conversations without leaving the terminal. Use `/sessions` to search and switch persisted conversations without leaving the terminal.
`/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. The next launch returns to command keeps its cross-channel behavior and resets the current chat. Each launch starts a new
the last session unless `--session` selects another one. When earlier transcript pages exist, session using the launch directory as its workspace; `--session` selects an existing session and
press `PageUp` at the top to load them in place. `--workspace` overrides the launch directory. 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;
+4 -3
View File
@@ -59,7 +59,6 @@ import {
type TranscriptNavigation, type TranscriptNavigation,
type TranscriptTheme, type TranscriptTheme,
} from "./transcript" } from "./transcript"
import { rememberChat } from "./session-state"
import { ComposerDraft } from "./composer-draft" import { ComposerDraft } from "./composer-draft"
import { BranchMenu, branchPoints } from "./branch-menu" import { BranchMenu, branchPoints } from "./branch-menu"
import { import {
@@ -95,7 +94,6 @@ interface AppOptions {
version: string version: string
access: string access: string
theme: "auto" | ThemeMode theme: "auto" | ThemeMode
statePath?: string
} }
interface ChatClient { interface ChatClient {
@@ -508,6 +506,10 @@ export class NanobotTui {
} }
: { url: options.wsUrl }), : { url: options.wsUrl }),
chatId: options.chatId, chatId: options.chatId,
initialWorkspaceScope: {
project_path: options.workspace,
access_mode: options.access.toLocaleLowerCase().includes("full") ? "full" : "restricted",
},
onEvent: (event) => this.accept(event), onEvent: (event) => this.accept(event),
onStatus: (status, detail) => this.handleStatus(status, detail), onStatus: (status, detail) => this.handleStatus(status, detail),
}) })
@@ -914,7 +916,6 @@ export class NanobotTui {
accept(event: InboundEvent): void { accept(event: InboundEvent): void {
if (event.event === "attached") { if (event.event === "attached") {
void rememberChat(this.options.statePath, event.chat_id)
this.host.reportSession(event.chat_id) this.host.reportSession(event.chat_id)
if (event.usage) this.lastUsage = event.usage if (event.usage) this.lastUsage = event.usage
if (event.model_preset !== undefined) { if (event.model_preset !== undefined) {
-1
View File
@@ -32,7 +32,6 @@ 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(),
statePath: process.env.NANOBOT_TUI_STATE_PATH?.trim() || undefined,
} }
let app: NanobotTui | undefined let app: NanobotTui | undefined
+45
View File
@@ -259,6 +259,10 @@ describe("gateway protocol", () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://nanobot.test/ws", url: "ws://nanobot.test/ws",
chatId: "terminal", chatId: "terminal",
initialWorkspaceScope: {
project_path: "/tmp/project",
access_mode: "restricted",
},
onEvent: (event) => events.push(event), onEvent: (event) => events.push(event),
onStatus: () => undefined, onStatus: () => undefined,
}) })
@@ -324,6 +328,47 @@ describe("gateway protocol", () => {
} }
}) })
test("starts a fresh chat in the launch workspace", () => {
const original = globalThis.WebSocket
let socket: FakeSocket | undefined
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor() {
super()
socket = this
}
},
})
try {
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
initialWorkspaceScope: {
project_path: "/tmp/launch-project",
access_mode: "restricted",
},
onEvent: () => undefined,
onStatus: () => undefined,
})
client.connect()
if (!socket) throw new Error("socket was not created")
socket.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
})
expect(socket.sent.map((value) => JSON.parse(value))).toEqual([{
type: "new_chat",
workspace_scope: {
project_path: "/tmp/launch-project",
access_mode: "restricted",
},
}])
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("loads runtime model and access controls from canonical APIs", async () => { test("loads runtime model and access controls from canonical APIs", async () => {
const original = globalThis.fetch const original = globalThis.fetch
globalThis.fetch = (async (input: string | URL | Request) => { globalThis.fetch = (async (input: string | URL | Request) => {
+2 -1
View File
@@ -157,6 +157,7 @@ export interface ClientOptions {
connectionRetryLabel?: string connectionRetryLabel?: string
startupRetryMaxDelayMs?: number startupRetryMaxDelayMs?: number
chatId?: string chatId?: string
initialWorkspaceScope?: WorkspaceScopePayload
reconnectDelayMs?: number reconnectDelayMs?: number
onEvent: (event: InboundEvent) => void onEvent: (event: InboundEvent) => void
onStatus: (status: ConnectionStatus, detail?: string) => void onStatus: (status: ConnectionStatus, detail?: string) => void
@@ -995,7 +996,7 @@ export class NanobotClient {
this.chatId = requestedChatId this.chatId = requestedChatId
this.write({ type: "attach", chat_id: this.chatId }) this.write({ type: "attach", chat_id: this.chatId })
} else { } else {
this.write({ type: "new_chat" }) this.newChat(this.options.initialWorkspaceScope)
} }
} else if (event.event === "attached") { } else if (event.event === "attached") {
this.chatId = event.chat_id this.chatId = event.chat_id
-27
View File
@@ -1,27 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, readFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { rememberChat } from "./session-state"
describe("TUI session state", () => {
let directory = ""
afterEach(async () => {
if (directory) await rm(directory, { recursive: true, force: true })
directory = ""
})
test("atomically remembers the last attached gateway chat", async () => {
directory = await mkdtemp(join(tmpdir(), "nanobot-tui-state-"))
const path = join(directory, "nested", "state.json")
await rememberChat(path, "chat-123")
expect(JSON.parse(await readFile(path, "utf8"))).toEqual({
schema_version: 1,
chat_id: "chat-123",
})
})
})
-23
View File
@@ -1,23 +0,0 @@
import { mkdir, rename, rm } from "node:fs/promises"
import { dirname } from "node:path"
export async function rememberChat(path: string | undefined, chatId: string): Promise<void> {
if (!path || !chatId) return
const temporary = `${path}.tmp-${process.pid}`
try {
await mkdir(dirname(path), { recursive: true })
const content = `${JSON.stringify({ schema_version: 1, chat_id: chatId })}\n`
await Bun.write(temporary, content)
try {
await rename(temporary, path)
} catch {
// Windows cannot always atomically replace an existing destination.
await Bun.write(path, content)
await rm(temporary, { force: true })
}
} catch {
// Session navigation must keep working when this optional convenience file
// is read-only, on a network volume, or removed during shutdown.
await rm(temporary, { force: true }).catch(() => {})
}
}