diff --git a/README.md b/README.md index 3109aeef4..f3cf5c652 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 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: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f018e62e2..91f5cd762 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 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. -`PageUp` loads older transcript pages when you reach the top. The default -launch returns to the last attached TUI session; `--session` selects a specific session instead. +`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 Storage and Rollback diff --git a/nanobot/cli/tui_launcher.py b/nanobot/cli/tui_launcher.py index f24f8ca3c..fedd52866 100644 --- a/nanobot/cli/tui_launcher.py +++ b/nanobot/cli/tui_launcher.py @@ -4,7 +4,6 @@ from __future__ import annotations import hashlib import io -import json import os import platform import shutil @@ -77,8 +76,8 @@ def launch_tui( theme: str, ) -> int: """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, state_path) + chat_id = _initial_tui_chat_id(session_id) + tui_workspace = _initial_tui_workspace(workspace_override) command = _resolve_tui_command() base_url, bootstrap_secret = _tui_gateway_connection(config) gateway: _GatewayHandle | None = None @@ -93,7 +92,7 @@ def launch_tui( "NANOBOT_TUI_API_URL": base_url, "NANOBOT_TUI_MODEL": _model_display(config)[0], "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_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 else: env.pop("NANOBOT_TUI_BOOTSTRAP_SECRET", None) - env["NANOBOT_TUI_STATE_PATH"] = str(state_path) if chat_id: env["NANOBOT_TUI_CHAT_ID"] = chat_id else: @@ -478,26 +476,14 @@ def _websocket_chat_id(session_id: str) -> str | None: return session_id or None -def _initial_tui_chat_id(session_id: str | None, state_path: Path) -> str | None: - """Resume the last TUI chat, while keeping an explicit selector authoritative.""" +def _initial_tui_chat_id(session_id: str | None) -> str | None: + """Start fresh unless the caller explicitly selects a TUI chat.""" if session_id is not None: return _websocket_chat_id(session_id) - return _read_tui_chat_id(state_path) + return None -def _read_tui_chat_id(path: Path) -> str | None: - """Read the last attached chat without making launch depend on optional state.""" - try: - raw_payload: Any = json.loads(path.read_text(encoding="utf-8")) - 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 +def _initial_tui_workspace(workspace_override: str | None) -> Path: + """Use the launch directory unless the caller explicitly selects a workspace.""" + workspace = Path(workspace_override) if workspace_override is not None else Path.cwd() + return workspace.expanduser().resolve(strict=False) diff --git a/tests/cli/test_tui_launcher.py b/tests/cli/test_tui_launcher.py index 3b0dd78d0..14c43db25 100644 --- a/tests/cli/test_tui_launcher.py +++ b/tests/cli/test_tui_launcher.py @@ -16,7 +16,7 @@ from nanobot.cli.tui_launcher import ( _download_release_tui, _ensure_gateway, _initial_tui_chat_id, - _read_tui_chat_id, + _initial_tui_workspace, _resolve_source_tui_command, _resolve_tui_command, _websocket_chat_id, @@ -67,28 +67,22 @@ def test_native_tui_rejects_a_session_owned_by_another_channel() -> None: _websocket_chat_id("telegram:123") -def test_tui_chat_state_is_optional_and_validated(tmp_path: Path) -> None: - path = tmp_path / "tui" / "state.json" - assert _read_tui_chat_id(path) is None - - 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_starts_fresh_but_explicit_session_wins() -> None: + assert _initial_tui_chat_id(None) is None + assert _initial_tui_chat_id("websocket:chosen") == "chosen" -def test_default_tui_resumes_but_explicit_session_wins(tmp_path: Path) -> None: - path = tmp_path / "tui" / "state.json" - path.parent.mkdir() - path.write_text('{"chat_id": "saved-chat"}', encoding="utf-8") +def test_default_tui_workspace_is_the_launch_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> 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_chat_id("websocket:chosen", path) == "chosen" - - path.unlink() - assert _initial_tui_chat_id(None, path) is None + assert _initial_tui_workspace(None) == launch_directory.resolve() + assert _initial_tui_workspace(str(override)) == override.resolve() 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 captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6" assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research" + assert captured["NANOBOT_TUI_WORKSPACE"] == str(Path.cwd().resolve()) assert captured["NANOBOT_TUI_BOOTSTRAP_URL"] == ( "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_API_TOKEN" not in captured assert "NANOBOT_TUI_CHAT_ID" not in captured + assert "NANOBOT_TUI_STATE_PATH" not in captured assert events == ["spawned", "waited"] assert released == [True] diff --git a/tui/README.md b/tui/README.md index 1629f6e09..b75b1aad0 100644 --- a/tui/README.md +++ b/tui/README.md @@ -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. `/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 -the last session unless `--session` selects another one. When earlier transcript pages exist, -press `PageUp` at the top to load them in place. +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. 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/src/app.ts b/tui/src/app.ts index 190a8f498..76aa10391 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -59,7 +59,6 @@ import { type TranscriptNavigation, type TranscriptTheme, } from "./transcript" -import { rememberChat } from "./session-state" import { ComposerDraft } from "./composer-draft" import { BranchMenu, branchPoints } from "./branch-menu" import { @@ -95,7 +94,6 @@ interface AppOptions { version: string access: string theme: "auto" | ThemeMode - statePath?: string } interface ChatClient { @@ -508,6 +506,10 @@ export class NanobotTui { } : { url: options.wsUrl }), chatId: options.chatId, + initialWorkspaceScope: { + project_path: options.workspace, + access_mode: options.access.toLocaleLowerCase().includes("full") ? "full" : "restricted", + }, onEvent: (event) => this.accept(event), onStatus: (status, detail) => this.handleStatus(status, detail), }) @@ -914,7 +916,6 @@ export class NanobotTui { accept(event: InboundEvent): void { if (event.event === "attached") { - void rememberChat(this.options.statePath, event.chat_id) this.host.reportSession(event.chat_id) if (event.usage) this.lastUsage = event.usage if (event.model_preset !== undefined) { diff --git a/tui/src/index.ts b/tui/src/index.ts index 515a5e99c..c63d63aed 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -32,7 +32,6 @@ const options: AppOptions = { version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev", access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access", theme: themePreference(), - statePath: process.env.NANOBOT_TUI_STATE_PATH?.trim() || undefined, } let app: NanobotTui | undefined diff --git a/tui/src/protocol.test.ts b/tui/src/protocol.test.ts index 3489f285c..36686b41b 100644 --- a/tui/src/protocol.test.ts +++ b/tui/src/protocol.test.ts @@ -259,6 +259,10 @@ describe("gateway protocol", () => { const client = new NanobotClient({ url: "ws://nanobot.test/ws", chatId: "terminal", + initialWorkspaceScope: { + project_path: "/tmp/project", + access_mode: "restricted", + }, onEvent: (event) => events.push(event), 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 () => { const original = globalThis.fetch globalThis.fetch = (async (input: string | URL | Request) => { diff --git a/tui/src/protocol.ts b/tui/src/protocol.ts index 1849ba980..0f190a040 100644 --- a/tui/src/protocol.ts +++ b/tui/src/protocol.ts @@ -157,6 +157,7 @@ export interface ClientOptions { connectionRetryLabel?: string startupRetryMaxDelayMs?: number chatId?: string + initialWorkspaceScope?: WorkspaceScopePayload reconnectDelayMs?: number onEvent: (event: InboundEvent) => void onStatus: (status: ConnectionStatus, detail?: string) => void @@ -995,7 +996,7 @@ export class NanobotClient { this.chatId = requestedChatId this.write({ type: "attach", chat_id: this.chatId }) } else { - this.write({ type: "new_chat" }) + this.newChat(this.options.initialWorkspaceScope) } } else if (event.event === "attached") { this.chatId = event.chat_id diff --git a/tui/src/session-state.test.ts b/tui/src/session-state.test.ts deleted file mode 100644 index fc0c2d59a..000000000 --- a/tui/src/session-state.test.ts +++ /dev/null @@ -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", - }) - }) -}) diff --git a/tui/src/session-state.ts b/tui/src/session-state.ts deleted file mode 100644 index 752e2fd94..000000000 --- a/tui/src/session-state.ts +++ /dev/null @@ -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 { - 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(() => {}) - } -}