From 6301c0ab57c4d51e338206919bb268dd02220b1d Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:22:06 +0900 Subject: [PATCH] feat(tui): unify session history and context --- README.md | 2 +- docs/cli-reference.md | 5 + .../websocket/tests/test_websocket_channel.py | 30 +++++ nanobot/cli/agent.py | 4 +- nanobot/cli/tui_launcher.py | 31 ++++- nanobot/session/manager.py | 4 + nanobot/webui/session_context.py | 51 ++++++++ nanobot/webui/ws_http.py | 23 ++++ tests/cli/test_tui_launcher.py | 24 ++++ tests/session/test_session_store.py | 11 ++ tests/webui/test_session_context.py | 56 ++++++++ tui/README.md | 9 +- tui/src/app.test.ts | 123 +++++++++++++++++- tui/src/app.ts | 116 ++++++++++++++++- tui/src/command-menu.ts | 2 +- tui/src/context-panel.ts | 116 +++++++++++++++++ tui/src/index.ts | 1 + tui/src/protocol.test.ts | 112 +++++++++++----- tui/src/protocol.ts | 94 +++++++++++-- tui/src/session-menu.test.ts | 4 + tui/src/session-menu.ts | 9 +- tui/src/session-state.test.ts | 27 ++++ tui/src/session-state.ts | 23 ++++ tui/src/transcript.ts | 87 +++++++++---- 24 files changed, 880 insertions(+), 84 deletions(-) create mode 100644 nanobot/webui/session_context.py create mode 100644 tests/webui/test_session_context.py create mode 100644 tui/src/context-panel.ts create mode 100644 tui/src/session-state.test.ts create mode 100644 tui/src/session-state.ts diff --git a/README.md b/README.md index 0db820005..1a28c5224 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,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 or `/new-chat` to preserve this conversation and start another one. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts a local gateway only when needed and releases it when you exit. 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 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, or `/context` to inspect the compacted summary and raw message suffix available to the agent. Press `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another one. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts a local gateway only when needed and releases it when you exit. 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 9b5168ef5..99a3849eb 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -96,6 +96,11 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r | `nanobot agent --no-markdown` | Use the classic prompt and print plain text instead of Markdown | | `nanobot agent --logs` | Use the classic prompt and show runtime logs while chatting | +Inside the native TUI, `/sessions` switches saved conversations, `/new-chat` starts another saved +conversation, and `/context` explains the compacted summary and raw session suffix available to +the next agent turn. `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. + ## Session Storage and Rollback Session JSONL files live under `/sessions//`, outside the diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index e89181116..d44155ab1 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -4747,6 +4747,36 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None: assert body["has_pending_tool_calls"] is False +@pytest.mark.asyncio +async def test_handle_session_context_get_reads_detached_session() -> None: + from urllib.parse import quote + + from websockets.datastructures import Headers + from websockets.http11 import Request + + from nanobot.session import Session + + session = Session( + key="websocket:context-route", + messages=[{"role": "user", "content": "hello"}], + ) + manager = MagicMock() + manager.read_session_snapshot.return_value = session + gateway = _basic_handler(MagicMock(), session_manager=manager) + gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0 + encoded = quote(session.key, safe="") + request = Request( + f"/api/sessions/{encoded}/context", + Headers([("Authorization", "Bearer tok")]), + ) + + response = await gateway.http._handle_session_context_get(request, encoded) + + assert response.status_code == 200 + assert json.loads(response.body.decode())["replay_messages"] == 1 + manager.read_session_snapshot.assert_called_once_with(session.key) + + def test_handle_webui_thread_get_reports_registered_turn_as_pending( tmp_path, monkeypatch, diff --git a/nanobot/cli/agent.py b/nanobot/cli/agent.py index 53825ca00..99a953163 100644 --- a/nanobot/cli/agent.py +++ b/nanobot/cli/agent.py @@ -48,7 +48,7 @@ console = Console() def agent( message: str | None = typer.Option(None, "--message", "-m", help="Message to send to the agent"), - session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"), + session_id: str | None = typer.Option(None, "--session", "-s", help="Session ID"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), markdown: bool = typer.Option( @@ -111,6 +111,8 @@ def agent( raise typer.Exit(exit_code) return + session_id = session_id or "cli:direct" + try: provider = make_provider(runtime_config) except ValueError as exc: diff --git a/nanobot/cli/tui_launcher.py b/nanobot/cli/tui_launcher.py index 3d19020d9..90bc13d02 100644 --- a/nanobot/cli/tui_launcher.py +++ b/nanobot/cli/tui_launcher.py @@ -48,7 +48,7 @@ def launch_tui( *, config_path: Path, workspace_override: str | None, - session_id: str, + session_id: str | None, theme: str, ) -> int: """Run the native TUI, owning a local gateway only when one is not running.""" @@ -78,7 +78,9 @@ def launch_tui( "NANOBOT_TUI_THEME": theme, } ) - chat_id = _websocket_chat_id(session_id) + state_path = config_path.parent / "tui" / "state.json" + env["NANOBOT_TUI_STATE_PATH"] = str(state_path) + chat_id = _initial_tui_chat_id(session_id, state_path) if chat_id: env["NANOBOT_TUI_CHAT_ID"] = chat_id else: @@ -328,3 +330,28 @@ def _websocket_chat_id(session_id: str) -> str | None: if session_id == "cli:direct": return "tui-direct" return session_id.split(":", 1)[-1] or None + + +def _initial_tui_chat_id(session_id: str | None, state_path: Path) -> str | None: + """Resume the default TUI, while keeping an explicit selector authoritative.""" + if session_id is not None: + return _websocket_chat_id(session_id) + return _read_tui_chat_id(state_path) or _websocket_chat_id("cli:direct") + + +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 diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index fde8da91b..bde92a84d 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -1800,6 +1800,10 @@ class SessionManager: """Read a session without populating the cache.""" return cast(dict[str, Any] | None, self._store.read(key)) + def read_session_snapshot(self, key: str) -> Session | None: + """Load a detached session snapshot without populating the runtime cache.""" + return self._store.load(key) + def read_session_metadata(self, key: str) -> dict[str, Any] | None: """Read session metadata without loading the transcript.""" return cast(dict[str, Any] | None, self._store.read_metadata(key)) diff --git a/nanobot/webui/session_context.py b/nanobot/webui/session_context.py new file mode 100644 index 000000000..ba5e2deb3 --- /dev/null +++ b/nanobot/webui/session_context.py @@ -0,0 +1,51 @@ +"""Read-only projection of the session material available to the agent.""" + +from __future__ import annotations + +from typing import Any, cast + +from nanobot.session.manager import Session +from nanobot.utils.helpers import estimate_message_tokens, truncate_text + +_SUMMARY_PREVIEW_CHARS = 4_000 + + +def session_context_payload(session: Session) -> dict[str, Any]: + """Return an explainable view of session replay without building a model prompt. + + The final prompt also contains workspace instructions, memory, skills, and a + model-specific token budget. This projection deliberately reports only the + session-owned part: archived summary plus the replayable raw suffix. + """ + replay = session.get_history(max_messages=0, include_runtime_context=False) + raw_summary = session.metadata.get("_last_summary") + summary = "" + summary_preview = "" + summary_at: str | None = None + if isinstance(raw_summary, dict): + summary_data = cast(dict[str, object], raw_summary) + text = summary_data.get("text") + last_active = summary_data.get("last_active") + if isinstance(text, str): + summary = text.strip() + summary_preview = truncate_text(summary, _SUMMARY_PREVIEW_CHARS) + if isinstance(last_active, str): + summary_at = last_active + + replay_tokens = sum(estimate_message_tokens(message) for message in replay) + summary_tokens = ( + estimate_message_tokens({"role": "system", "content": summary}) if summary else 0 + ) + + return { + "schema_version": 1, + "session_key": session.key, + "total_messages": len(session.messages), + "archived_messages": min(session.last_consolidated, len(session.messages)), + "replay_messages": len(replay), + "estimated_replay_tokens": replay_tokens, + "estimated_summary_tokens": summary_tokens, + "estimated_session_tokens": replay_tokens + summary_tokens, + "archived_summary": summary_preview or None, + "archived_summary_at": summary_at, + } diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index ff4c383fd..0cc9419dd 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -97,6 +97,7 @@ from nanobot.webui.session_automations import ( session_automation_jobs, session_automations_payload, ) +from nanobot.webui.session_context import session_context_payload from nanobot.webui.session_list_index import ( WEBUI_SESSION_INDEX_INTERNAL_FIELDS, indexed_workspace_scope, @@ -678,6 +679,10 @@ class GatewayHTTPHandler: if m: return self._handle_webui_thread_get(request, m.group(1)) + m = re.match(r"^/api/sessions/([^/]+)/context$", got) + if m: + return await self._handle_session_context_get(request, m.group(1)) + m = re.match(r"^/api/sessions/([^/]+)/file-preview$", got) if m: return self._handle_file_preview(request, m.group(1)) @@ -692,6 +697,24 @@ class GatewayHTTPHandler: return None + async def _handle_session_context_get(self, request: WsRequest, key: str) -> Response: + if not self.check_api_token(request): + return _http_error(401, "Unauthorized") + decoded_key = _decode_api_key(key) + if decoded_key is None: + return _http_error(400, "invalid session key") + if not _is_websocket_channel_session_key(decoded_key): + return _http_error(404, "session not found") + if self.session_manager is None: + return _http_error(503, "session manager unavailable") + session = await asyncio.to_thread( + self.session_manager.read_session_snapshot, + decoded_key, + ) + if session is None: + return _http_error(404, "session not found") + return _http_json_response(session_context_payload(session)) + async def _handle_sessions_list(self, request: WsRequest) -> Response: if not self.check_api_token(request): return _http_error(401, "Unauthorized") diff --git a/tests/cli/test_tui_launcher.py b/tests/cli/test_tui_launcher.py index 3a3ed4e4c..c055c4ee3 100644 --- a/tests/cli/test_tui_launcher.py +++ b/tests/cli/test_tui_launcher.py @@ -10,6 +10,8 @@ from nanobot.cli.tui_launcher import ( _authenticated_ws_url, _download_release_tui, _ensure_gateway, + _initial_tui_chat_id, + _read_tui_chat_id, _resolve_tui_command, _websocket_chat_id, ) @@ -36,6 +38,28 @@ def test_websocket_chat_id(session_id: str, expected: str | None) -> None: assert _websocket_chat_id(session_id) == expected +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_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") + + assert _initial_tui_chat_id(None, path) == "saved-chat" + assert _initial_tui_chat_id("cli:direct", path) == "tui-direct" + assert _initial_tui_chat_id("websocket:chosen", path) == "chosen" + + def test_explicit_tui_binary_must_exist( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/session/test_session_store.py b/tests/session/test_session_store.py index 92a1c5ac5..0d36c22d4 100644 --- a/tests/session/test_session_store.py +++ b/tests/session/test_session_store.py @@ -94,6 +94,17 @@ def test_manager_renames_model_preset_in_live_and_persisted_sessions(tmp_path) - ) +def test_read_session_snapshot_does_not_populate_runtime_cache(tmp_path) -> None: + stored = Session(key="websocket:context") + store = MagicMock(spec=SessionStore) + store.load.return_value = stored + manager = SessionManager(tmp_path, store=store) + + assert manager.read_session_snapshot(stored.key) is stored + assert manager.get_cached(stored.key) is None + store.load.assert_called_once_with(stored.key) + + def test_manager_applies_file_cap_before_store_save(tmp_path) -> None: store = MagicMock(spec=SessionStore) archiver = MagicMock() diff --git a/tests/webui/test_session_context.py b/tests/webui/test_session_context.py new file mode 100644 index 000000000..c863b5637 --- /dev/null +++ b/tests/webui/test_session_context.py @@ -0,0 +1,56 @@ +from nanobot.session import Session +from nanobot.utils.helpers import estimate_message_tokens +from nanobot.webui.session_context import session_context_payload + + +def test_session_context_separates_archive_progress_from_replay() -> None: + messages = [ + {"role": "user", "content": "old question"}, + {"role": "assistant", "content": "old answer"}, + {"role": "user", "content": "recent question"}, + {"role": "assistant", "content": "recent answer"}, + ] + session = Session( + key="websocket:context", + messages=messages, + last_consolidated=2, + metadata={ + "_last_summary": { + "text": "The archived conversation settled the old question.", + "last_active": "2026-08-13T10:00:00Z", + } + }, + ) + + replay = session.get_history(max_messages=0, include_runtime_context=False) + replay_tokens = sum(estimate_message_tokens(message) for message in replay) + summary_tokens = estimate_message_tokens( + {"role": "system", "content": "The archived conversation settled the old question."} + ) + payload = session_context_payload(session) + + assert payload == { + "schema_version": 1, + "session_key": "websocket:context", + "total_messages": 4, + "archived_messages": 2, + "replay_messages": len(replay), + "estimated_replay_tokens": replay_tokens, + "estimated_summary_tokens": summary_tokens, + "estimated_session_tokens": replay_tokens + summary_tokens, + "archived_summary": "The archived conversation settled the old question.", + "archived_summary_at": "2026-08-13T10:00:00Z", + } + + +def test_session_context_tolerates_untrusted_summary_metadata() -> None: + session = Session( + key="websocket:context", + messages=[{"role": "user", "content": "hello"}], + metadata={"_last_summary": "invalid"}, + ) + + payload = session_context_payload(session) + + assert payload["archived_summary"] is None + assert payload["archived_summary_at"] is None diff --git a/tui/README.md b/tui/README.md index 4d0f64933..253d3ff4e 100644 --- a/tui/README.md +++ b/tui/README.md @@ -18,4 +18,11 @@ to move, `Tab` to complete, and `Esc` to close the menu. 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. +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. + +`/context` explains the session-owned material available for the next agent turn: the compacted +summary, replayable raw suffix, and an estimated token count. It deliberately does not expose +private reasoning and does not pretend to be the complete model prompt; workspace instructions, +memory, and skills are assembled separately by the Python runtime. diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index e2da76207..7e87d1a4c 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -346,9 +346,14 @@ describe("NanobotTui layout", () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const original = globalThis.fetch let resolveFetch: ((response: Response) => void) | undefined - globalThis.fetch = (() => new Promise((resolve) => { - resolveFetch = resolve - })) as unknown as typeof fetch + globalThis.fetch = ((input: string | URL | Request) => { + if (String(input).endsWith("/api/webui/sidebar-state")) { + return Promise.resolve(new Response(JSON.stringify({}))) + } + return new Promise((resolve) => { + resolveFetch = resolve + }) + }) as typeof fetch const sent: string[] = [] const app = NanobotTui.mount( setup.renderer, @@ -388,9 +393,14 @@ describe("NanobotTui layout", () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const original = globalThis.fetch let resolveFetch: ((response: Response) => void) | undefined - globalThis.fetch = (() => new Promise((resolve) => { - resolveFetch = resolve - })) as unknown as typeof fetch + globalThis.fetch = ((input: string | URL | Request) => { + if (String(input).endsWith("/api/webui/sidebar-state")) { + return Promise.resolve(new Response(JSON.stringify({}))) + } + return new Promise((resolve) => { + resolveFetch = resolve + }) + }) as typeof fetch const app = NanobotTui.mount( setup.renderer, { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, @@ -425,6 +435,107 @@ describe("NanobotTui layout", () => { } }) + test("explains the session-owned agent context without exposing private reasoning", async () => { + setup = await createRenderer({ width: 96, height: 26, screenMode: "alternate-screen" }) + const original = globalThis.fetch + globalThis.fetch = ((input: string | URL | Request) => { + expect(String(input)).toContain("/api/sessions/websocket%3Achat/context") + return Promise.resolve(new Response(JSON.stringify({ + total_messages: 24, + archived_messages: 16, + replay_messages: 10, + estimated_replay_tokens: 2048, + estimated_summary_tokens: 128, + estimated_session_tokens: 2176, + archived_summary: "The earlier turns agreed on a release plan.", + archived_summary_at: "2026-08-13T10:00:00Z", + }))) + }) as typeof fetch + const app = NanobotTui.mount( + setup.renderer, + { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, + client(), + new MockTreeSitterClient({ autoResolveTimeout: 0 }), + ) + app.accept({ event: "attached", chat_id: "chat" }) + const ui = app as unknown as { + composer: TextareaRenderable + contextPanel: { visible: boolean } + } + + try { + ui.composer.setText("/context") + ui.composer.submit() + await waitUntil(() => ui.contextPanel.visible) + await setup.flush() + const frame = setup.captureCharFrame() + + expect(frame).toContain("Agent context") + expect(frame).toContain("~2.2k session tokens · 10 replay messages · 16 archived · summary active") + expect(frame).toContain("The earlier turns agreed on a release plan.") + expect(frame).toContain("memory, instructions, and skills are added separately") + + setup.resize(40, 10) + await setup.renderOnce() + const compact = setup.captureCharFrame() + expect(occurrences(compact, "Agent context")).toBe(1) + expect(occurrences(compact, "Ask nanobot anything")).toBe(1) + + setup.mockInput.pressEscape() + await waitUntil(() => !ui.contextPanel.visible) + expect(ui.contextPanel.visible).toBe(false) + } finally { + globalThis.fetch = original + } + }) + + test("loads earlier transcript pages in place when PageUp reaches the top", async () => { + setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" }) + const original = globalThis.fetch + const requests: string[] = [] + globalThis.fetch = ((input: string | URL | Request) => { + const url = String(input) + requests.push(url) + const older = url.includes("before=older-page") + return Promise.resolve(new Response(JSON.stringify({ + messages: older + ? [ + { role: "user", content: "oldest question" }, + { role: "assistant", content: "oldest answer" }, + ] + : [ + { role: "user", content: "recent question" }, + { role: "assistant", content: "recent answer" }, + ], + page: older + ? { has_more_before: false, before_cursor: null } + : { has_more_before: true, before_cursor: "older-page" }, + }))) + }) as typeof fetch + const app = NanobotTui.mount( + setup.renderer, + { ...options, apiUrl: "http://nanobot.test", apiToken: "secret", chatId: "chat" }, + client(), + new MockTreeSitterClient({ autoResolveTimeout: 0 }), + ) + + try { + app.accept({ event: "attached", chat_id: "chat" }) + await waitUntil(() => (app as unknown as { ready: boolean }).ready) + setup.mockInput.pressKey("\u001B[5~") + await waitUntil(() => requests.length === 2) + await waitUntil(() => !(app as unknown as { historyLoadingOlder: boolean }).historyLoadingOlder) + await setup.flush() + const frame = setup.captureCharFrame() + + expect(frame.indexOf("oldest question")).toBeLessThan(frame.indexOf("recent question")) + expect(frame.indexOf("oldest answer")).toBeLessThan(frame.indexOf("recent answer")) + expect((app as unknown as { historyHasMore: boolean }).historyHasMore).toBe(false) + } finally { + globalThis.fetch = original + } + }) + test("survives rapid narrow resizes with long CJK and code", async () => { setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" }) const app = mount(setup) diff --git a/tui/src/app.ts b/tui/src/app.ts index 257ed30d2..9603c02b3 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -16,6 +16,7 @@ import { import { NanobotClient, fetchHistory, + fetchSessionContext, fetchSessions, fetchSlashCommands, type ConnectionStatus, @@ -30,7 +31,9 @@ import { type TuiCommand, } from "./command-menu" import { SessionMenu } from "./session-menu" +import { ContextPanel, type ContextPanelTheme } from "./context-panel" import { Transcript, type TranscriptTheme } from "./transcript" +import { rememberChat } from "./session-state" interface AppOptions { wsUrl: string @@ -42,6 +45,7 @@ interface AppOptions { version: string access: string theme: "auto" | ThemeMode + statePath?: string } interface ChatClient { @@ -109,6 +113,12 @@ const LOCAL_COMMANDS: TuiCommand[] = [ description: "Keep this conversation and start another", action: "new-chat", }, + { + command: "/context", + title: "Agent context", + description: "Explain what this session contributes to the next prompt", + action: "context", + }, ] function syntaxStyle(palette: Palette): SyntaxStyle { @@ -157,6 +167,15 @@ function commandMenuTheme(palette: Palette): CommandMenuTheme { } } +function contextPanelTheme(palette: Palette): ContextPanelTheme { + return { + text: palette.text, + muted: palette.muted, + border: palette.border, + accent: palette.accent, + } +} + function formatElapsed(milliseconds: number): string { const seconds = Math.max(0, Math.floor(milliseconds / 1000)) if (seconds < 60) return `${seconds}s` @@ -187,6 +206,7 @@ export class NanobotTui { private readonly transcript: Transcript private readonly commandMenu: CommandMenu private readonly sessionMenu: SessionMenu + private readonly contextPanel: ContextPanel private readonly client: ChatClient private readonly shell: BoxRenderable private readonly title: TextRenderable @@ -203,6 +223,9 @@ export class NanobotTui { private finalMessage = "" private turnHadAnswer = false private historyLoaded = false + private historyBeforeCursor: string | null = null + private historyHasMore = false + private historyLoadingOlder = false private attachedOnce = false private pendingEvents: InboundEvent[] | null = null private hydrationId = 0 @@ -234,6 +257,7 @@ export class NanobotTui { this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) this.commandMenu.setCommands([], LOCAL_COMMANDS) this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette)) + this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette)) this.client = client || new NanobotClient({ url: options.wsUrl, chatId: options.chatId, @@ -292,6 +316,7 @@ export class NanobotTui { { name: "return", meta: true, action: "newline" }, ], onContentChange: () => { + if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide() this.syncComposerPlaceholder() if (this.sessionMenu.visible) this.syncSessionMenu() else this.syncCommandMenu() @@ -335,6 +360,7 @@ export class NanobotTui { this.shell.add(this.transcript.root) this.shell.add(this.commandMenu.root) this.shell.add(this.sessionMenu.root) + this.shell.add(this.contextPanel.root) this.shell.add(this.title) this.shell.add(this.composerFrame) this.shell.add(statusRow) @@ -425,6 +451,7 @@ export class NanobotTui { const command = this.commandMenu.resolve(content) if (command?.source === "tui") { if (command.command.action === "sessions") void this.openSessions() + else if (command.command.action === "context") void this.openContext() else this.startNewChat() return } @@ -464,6 +491,7 @@ export class NanobotTui { accept(event: InboundEvent): void { if (event.event === "attached") { + void rememberChat(this.options.statePath, event.chat_id) this.commandTurns.clear() const restoring = this.attachedOnce this.attachedOnce = true @@ -577,6 +605,10 @@ export class NanobotTui { private async prepareChat(chatId: string, restoring: boolean, hydrationId: number): Promise { try { if (restoring) { + this.contextPanel.hide() + this.historyBeforeCursor = null + this.historyHasMore = false + this.historyLoadingOlder = false this.transcript.reset({ model: this.modelName, workspace: this.options.workspace, @@ -588,9 +620,8 @@ export class NanobotTui { this.historyLoaded = true const history = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId) if (hydrationId !== this.hydrationId) return - if (history.truncated) { - this.transcript.notice("Earlier messages omitted · open WebUI to load the full history") - } + this.historyBeforeCursor = history.beforeCursor + this.historyHasMore = history.hasMoreBefore this.transcript.history(history.messages) } } catch (error) { @@ -599,7 +630,9 @@ export class NanobotTui { } finally { if (hydrationId !== this.hydrationId) return this.ready = true - if (!this.activeTurn) this.status.content = "Ready" + if (!this.activeTurn) { + this.status.content = this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready" + } } } @@ -658,6 +691,12 @@ export class NanobotTui { } private handleKey = (key: KeyEvent): void => { + if (this.contextPanel.visible && key.name === "escape") { + this.contextPanel.hide() + this.updateMeta() + key.preventDefault() + return + } if (this.sessionLoading && key.name === "escape") { this.closeSessions() this.status.content = "Ready" @@ -748,7 +787,10 @@ export class NanobotTui { } if (key.name === "pageup" || key.name === "pagedown") { key.preventDefault() - this.transcript.scrollByPage(key.name === "pageup" ? -1 : 1) + const pageUp = key.name === "pageup" + const wasAtTop = this.transcript.atTop + this.transcript.scrollByPage(pageUp ? -1 : 1) + if (pageUp && (wasAtTop || this.transcript.atTop)) void this.loadOlderHistory() return } if (key.ctrl && (key.name === "home" || key.name === "end")) { @@ -791,6 +833,7 @@ export class NanobotTui { this.transcript.setTheme(transcriptTheme(this.palette)) this.commandMenu.setTheme(commandMenuTheme(this.palette)) this.sessionMenu.setTheme(commandMenuTheme(this.palette)) + this.contextPanel.setTheme(contextPanelTheme(this.palette)) this.composerFrame.borderColor = this.palette.border this.composer.textColor = this.palette.text this.composer.focusedTextColor = this.palette.text @@ -802,6 +845,7 @@ export class NanobotTui { private handleResize = (): void => { this.resizeComposer() + this.contextPanel.resize(this.renderer.height) this.title.visible = this.renderer.height >= 14 this.updateMeta() } @@ -823,6 +867,10 @@ export class NanobotTui { : "enter open · esc close" return } + if (this.contextPanel.visible) { + this.meta.content = "esc close · pgup/pgdn scroll" + return + } this.meta.content = this.renderer.width >= 112 ? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop" : this.renderer.width >= 72 @@ -891,6 +939,7 @@ export class NanobotTui { return } this.commandMenu.hide() + this.contextPanel.hide() this.composer.setText("") this.sessionLoading = true const loadId = ++this.sessionLoadId @@ -947,6 +996,7 @@ export class NanobotTui { } this.commandMenu.hide() this.sessionMenu.hide() + this.contextPanel.hide() this.composer.setText("") try { this.ready = false @@ -1019,6 +1069,62 @@ export class NanobotTui { this.updateMeta() } + private async openContext(): Promise { + this.commandMenu.hide() + this.sessionMenu.hide() + this.composer.setText("") + this.status.content = "Reading agent context…" + try { + const context = await fetchSessionContext( + this.options.apiUrl, + this.options.apiToken, + this.client.activeChatId, + ) + if (!context) { + this.status.content = "Context unavailable · new session or older gateway" + return + } + this.contextPanel.show(context) + this.status.content = "Context snapshot" + this.updateMeta() + } catch (error) { + this.status.content = error instanceof Error ? error.message : String(error) + } + } + + private async loadOlderHistory(): Promise { + if ( + this.historyLoadingOlder + || !this.historyHasMore + || !this.historyBeforeCursor + || !this.client.activeChatId + ) return + const hydrationId = this.hydrationId + const chatId = this.client.activeChatId + this.historyLoadingOlder = true + this.status.content = "Loading earlier messages…" + try { + const history = await fetchHistory( + this.options.apiUrl, + this.options.apiToken, + chatId, + this.historyBeforeCursor, + ) + if (hydrationId !== this.hydrationId || chatId !== this.client.activeChatId) return + await this.transcript.prependHistory(history.messages) + this.historyBeforeCursor = history.beforeCursor + this.historyHasMore = history.hasMoreBefore + this.status.content = history.hasMoreBefore + ? `${history.messages.length} earlier messages · PageUp for more` + : "Start of session" + } catch (error) { + if (hydrationId !== this.hydrationId) return + this.status.content = error instanceof Error ? error.message : String(error) + } finally { + if (hydrationId === this.hydrationId) this.historyLoadingOlder = false + } + } + private async copySelection(text: string): Promise { if (!text) return try { diff --git a/tui/src/command-menu.ts b/tui/src/command-menu.ts index 812733d27..fb2bcac92 100644 --- a/tui/src/command-menu.ts +++ b/tui/src/command-menu.ts @@ -5,7 +5,7 @@ import { PickerMenu, type PickerMenuTheme } from "./picker-menu" export type CommandMenuTheme = PickerMenuTheme -export type TuiCommandAction = "sessions" | "new-chat" +export type TuiCommandAction = "sessions" | "new-chat" | "context" export interface TuiCommand { command: string diff --git a/tui/src/context-panel.ts b/tui/src/context-panel.ts new file mode 100644 index 000000000..8b29cfd42 --- /dev/null +++ b/tui/src/context-panel.ts @@ -0,0 +1,116 @@ +import { + BoxRenderable, + TextAttributes, + TextRenderable, + type CliRenderer, +} from "@opentui/core" + +import type { SessionContextSnapshot } from "./protocol" + +export interface ContextPanelTheme { + text: string + muted: string + border: string + accent: string +} + +function tokens(value: number): string { + if (value < 1_000) return String(value) + const compact = value >= 10_000 ? Math.round(value / 1_000) : Math.round(value / 100) / 10 + return `${compact}k` +} + +/** Read-only explanation of the session-owned context replayed to the agent. */ +export class ContextPanel { + readonly root: BoxRenderable + private readonly title: TextRenderable + private readonly stats: TextRenderable + private readonly summary: TextRenderable + private readonly note: TextRenderable + + constructor(renderer: CliRenderer, theme: ContextPanelTheme) { + this.root = new BoxRenderable(renderer, { + id: "nanobot-tui-context-panel", + width: "100%", + maxHeight: 12, + flexShrink: 0, + flexDirection: "column", + border: true, + borderStyle: "rounded", + borderColor: theme.border, + paddingLeft: 1, + paddingRight: 1, + visible: false, + }) + this.title = new TextRenderable(renderer, { + id: "nanobot-tui-context-title", + content: "Agent context", + width: "100%", + height: 1, + fg: theme.text, + attributes: TextAttributes.BOLD, + }) + this.stats = new TextRenderable(renderer, { + id: "nanobot-tui-context-stats", + content: "", + width: "100%", + fg: theme.accent, + wrapMode: "word", + }) + this.summary = new TextRenderable(renderer, { + id: "nanobot-tui-context-summary", + content: "", + width: "100%", + maxHeight: 6, + fg: theme.text, + wrapMode: "word", + }) + this.note = new TextRenderable(renderer, { + id: "nanobot-tui-context-note", + content: "Session view only · memory, instructions, and skills are added separately · Esc close", + width: "100%", + fg: theme.muted, + wrapMode: "word", + }) + this.root.add(this.title) + this.root.add(this.stats) + this.root.add(this.summary) + this.root.add(this.note) + } + + get visible(): boolean { + return this.root.visible + } + + show(context: SessionContextSnapshot): void { + const archived = context.archivedMessages > 0 + ? `${context.archivedMessages} archived · summary ${context.archivedSummary ? "active" : "unavailable"}` + : "No archived messages" + this.stats.content = `~${tokens(context.estimatedSessionTokens)} session tokens · ${context.replayMessages} replay messages · ${archived}` + this.summary.content = context.archivedSummary + ? `Summary\n${context.archivedSummary}` + : "The agent is currently replaying raw session messages; no compacted summary exists yet." + this.root.visible = true + } + + hide(): void { + this.root.visible = false + } + + resize(terminalHeight: number): void { + const compact = terminalHeight < 14 + const medium = terminalHeight < 20 + this.summary.visible = !compact + this.summary.maxHeight = medium ? 2 : 6 + this.note.visible = !medium + this.root.maxHeight = compact ? 4 : medium ? 6 : 12 + } + + setTheme(theme: ContextPanelTheme): void { + this.root.borderColor = theme.border + this.title.fg = theme.text + this.stats.fg = theme.accent + this.summary.fg = theme.text + this.note.fg = theme.muted + } +} diff --git a/tui/src/index.ts b/tui/src/index.ts index 9d42fc869..f8c317152 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -22,6 +22,7 @@ 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 d7bf5726d..81e72769b 100644 --- a/tui/src/protocol.test.ts +++ b/tui/src/protocol.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test" import { NanobotClient, fetchHistory, + fetchSessionContext, fetchSessions, fetchSlashCommands, type InboundEvent, @@ -177,24 +178,28 @@ describe("gateway protocol", () => { test("reports when the bounded history snapshot omits earlier turns", async () => { const original = globalThis.fetch - globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({ - messages: [ - { role: "user", content: "hello" }, - { - role: "tool", - kind: "trace", - content: "read_file", - traces: ["read_file"], - toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }], - }, - { role: "assistant", kind: "reasoning", content: "private thought" }, - { role: "assistant", content: "hi" }, - ], - page: { has_more_before: true }, - })))) as unknown as typeof fetch + let requested = "" + globalThis.fetch = ((input: string | URL | Request) => { + requested = String(input) + return Promise.resolve(new Response(JSON.stringify({ + messages: [ + { role: "user", content: "hello" }, + { + role: "tool", + kind: "trace", + content: "read_file", + traces: ["read_file"], + toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }], + }, + { role: "assistant", kind: "reasoning", content: "private thought" }, + { role: "assistant", content: "hi" }, + ], + page: { has_more_before: true, before_cursor: "older-1" }, + }))) + }) as typeof fetch try { - const history = await fetchHistory("http://nanobot.test", "token", "chat") + const history = await fetchHistory("http://nanobot.test", "token", "chat", "newer-page") expect(history).toEqual({ messages: [ { role: "user", content: "hello" }, @@ -205,7 +210,38 @@ describe("gateway protocol", () => { }, { role: "assistant", content: "hi" }, ], - truncated: true, + hasMoreBefore: true, + beforeCursor: "older-1", + }) + expect(requested).toContain("before=newer-page") + } finally { + globalThis.fetch = original + } + }) + + test("loads the explainable session context projection", async () => { + const original = globalThis.fetch + globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({ + total_messages: 24, + archived_messages: 16, + replay_messages: 10, + estimated_replay_tokens: 2048, + estimated_summary_tokens: 128, + estimated_session_tokens: 2176, + archived_summary: "Older work was compacted.", + archived_summary_at: "2026-08-13T10:00:00Z", + })))) as unknown as typeof fetch + + try { + expect(await fetchSessionContext("http://nanobot.test", "secret", "chat")).toEqual({ + totalMessages: 24, + archivedMessages: 16, + replayMessages: 10, + estimatedReplayTokens: 2048, + estimatedSummaryTokens: 128, + estimatedSessionTokens: 2176, + archivedSummary: "Older work was compacted.", + archivedSummaryAt: "2026-08-13T10:00:00Z", }) } finally { globalThis.fetch = original @@ -267,29 +303,41 @@ describe("gateway protocol", () => { test("loads and normalizes WebUI sessions", async () => { const original = globalThis.fetch - globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({ - sessions: [ - { - key: "websocket:chat-1", - title: "Release plan", - preview: "Prepare the release", - created_at: "2026-08-12T10:00:00Z", - updated_at: "2026-08-13T10:00:00Z", - run_started_at: 123, - }, - { key: "cli:direct", title: "Not a WebUI session" }, - { key: 42 }, - ], - })))) as unknown as typeof fetch + globalThis.fetch = ((input: string | URL | Request) => { + const url = String(input) + if (url.endsWith("/api/webui/sidebar-state")) { + return Promise.resolve(new Response(JSON.stringify({ + pinned_keys: ["websocket:chat-1"], + archived_keys: [], + title_overrides: { "websocket:chat-1": "Pinned release" }, + }))) + } + return Promise.resolve(new Response(JSON.stringify({ + sessions: [ + { + key: "websocket:chat-1", + title: "Release plan", + preview: "Prepare the release", + created_at: "2026-08-12T10:00:00Z", + updated_at: "2026-08-13T10:00:00Z", + run_started_at: 123, + }, + { key: "cli:direct", title: "Not a WebUI session" }, + { key: 42 }, + ], + }))) + }) as typeof fetch try { expect(await fetchSessions("http://nanobot.test", "secret")).toEqual([{ chatId: "chat-1", - title: "Release plan", + title: "Pinned release", preview: "Prepare the release", createdAt: "2026-08-12T10:00:00Z", updatedAt: "2026-08-13T10:00:00Z", runStartedAt: 123, + pinned: true, + archived: false, }]) } finally { globalThis.fetch = original diff --git a/tui/src/protocol.ts b/tui/src/protocol.ts index 7daf399d4..6c5d347f1 100644 --- a/tui/src/protocol.ts +++ b/tui/src/protocol.ts @@ -84,7 +84,19 @@ export interface HistoryMessage { export interface HistorySnapshot { messages: HistoryMessage[] - truncated: boolean + hasMoreBefore: boolean + beforeCursor: string | null +} + +export interface SessionContextSnapshot { + totalMessages: number + archivedMessages: number + replayMessages: number + estimatedReplayTokens: number + estimatedSummaryTokens: number + estimatedSessionTokens: number + archivedSummary: string | null + archivedSummaryAt: string | null } export interface SlashCommand { @@ -110,6 +122,8 @@ export interface SessionSummary { createdAt: string | null updatedAt: string | null runStartedAt: number | null + pinned: boolean + archived: boolean } const SLASH_COMMAND_LIFECYCLES = new Set([ @@ -220,17 +234,24 @@ export async function fetchHistory( apiUrl: string, apiToken: string, chatId: string, + beforeCursor?: string | null, ): Promise { - if (!apiUrl || !apiToken) return { messages: [], truncated: false } + if (!apiUrl || !apiToken) { + return { messages: [], hasMoreBefore: false, beforeCursor: null } + } const key = encodeURIComponent(`websocket:${chatId}`) - const response = await fetch(`${apiUrl}/api/sessions/${key}/webui-thread?limit=120&direction=latest`, { + const params = new URLSearchParams({ limit: "120", direction: "latest" }) + if (beforeCursor) params.set("before", beforeCursor) + const response = await fetch(`${apiUrl}/api/sessions/${key}/webui-thread?${params}`, { headers: { Authorization: `Bearer ${apiToken}` }, }) - if (response.status === 404) return { messages: [], truncated: false } + if (response.status === 404) { + return { messages: [], hasMoreBefore: false, beforeCursor: null } + } if (!response.ok) throw new Error(`history request failed: HTTP ${response.status}`) const payload = (await response.json()) as { messages?: Array> - page?: { has_more_before?: boolean } + page?: { has_more_before?: boolean; before_cursor?: string } } const messages: HistoryMessage[] = (payload.messages || []).flatMap((message) => { const role = message.role @@ -258,7 +279,41 @@ export async function fetchHistory( } return [{ role: role as HistoryMessage["role"], content }] }) - return { messages, truncated: payload.page?.has_more_before === true } + return { + messages, + hasMoreBefore: payload.page?.has_more_before === true, + beforeCursor: typeof payload.page?.before_cursor === "string" + ? payload.page.before_cursor + : null, + } +} + +export async function fetchSessionContext( + apiUrl: string, + apiToken: string, + chatId: string, +): Promise { + if (!apiUrl || !apiToken) return null + const key = encodeURIComponent(`websocket:${chatId}`) + const response = await fetch(`${apiUrl}/api/sessions/${key}/context`, { + headers: { Authorization: `Bearer ${apiToken}` }, + }) + if (response.status === 404) return null + if (!response.ok) throw new Error(`context request failed: HTTP ${response.status}`) + const value = await response.json() as Record + const number = (key: string) => typeof value[key] === "number" ? value[key] as number : 0 + return { + totalMessages: number("total_messages"), + archivedMessages: number("archived_messages"), + replayMessages: number("replay_messages"), + estimatedReplayTokens: number("estimated_replay_tokens"), + estimatedSummaryTokens: number("estimated_summary_tokens"), + estimatedSessionTokens: number("estimated_session_tokens"), + archivedSummary: typeof value.archived_summary === "string" ? value.archived_summary : null, + archivedSummaryAt: typeof value.archived_summary_at === "string" + ? value.archived_summary_at + : null, + } } export async function fetchSlashCommands( @@ -294,24 +349,43 @@ export async function fetchSessions( apiToken: string, ): Promise { if (!apiUrl || !apiToken) return [] - const response = await fetch(`${apiUrl}/api/sessions`, { - headers: { Authorization: `Bearer ${apiToken}` }, - }) + const headers = { Authorization: `Bearer ${apiToken}` } + const [response, sidebarResponse] = await Promise.all([ + fetch(`${apiUrl}/api/sessions`, { headers }), + fetch(`${apiUrl}/api/webui/sidebar-state`, { headers }).catch(() => null), + ]) if (!response.ok) throw new Error(`session request failed: HTTP ${response.status}`) const payload = await response.json() as { sessions?: unknown[] } + let sidebar: Record = {} + if (sidebarResponse?.ok) { + try { + const value: unknown = await sidebarResponse.json() + if (isRecord(value)) sidebar = value + } catch { + // Session navigation remains available against older or damaged sidebar state. + } + } + const pinned = new Set(Array.isArray(sidebar.pinned_keys) ? sidebar.pinned_keys : []) + const archived = new Set(Array.isArray(sidebar.archived_keys) ? sidebar.archived_keys : []) + const titles = isRecord(sidebar.title_overrides) ? sidebar.title_overrides : {} return (payload.sessions || []).flatMap((value) => { if (!isRecord(value) || typeof value.key !== "string" || !value.key.startsWith("websocket:")) { return [] } const chatId = value.key.slice("websocket:".length) if (!chatId) return [] + const titleOverride = titles[value.key] return [{ chatId, - title: typeof value.title === "string" ? value.title : "", + title: typeof titleOverride === "string" + ? titleOverride + : typeof value.title === "string" ? value.title : "", preview: typeof value.preview === "string" ? value.preview : "", createdAt: typeof value.created_at === "string" ? value.created_at : null, updatedAt: typeof value.updated_at === "string" ? value.updated_at : null, runStartedAt: typeof value.run_started_at === "number" ? value.run_started_at : null, + pinned: pinned.has(value.key), + archived: archived.has(value.key), }] }) } diff --git a/tui/src/session-menu.test.ts b/tui/src/session-menu.test.ts index ebb8347ba..3982181b2 100644 --- a/tui/src/session-menu.test.ts +++ b/tui/src/session-menu.test.ts @@ -12,6 +12,8 @@ const sessions: SessionSummary[] = [ createdAt: "2026-08-12T10:00:00Z", updatedAt: "2026-08-13T10:00:00Z", runStartedAt: null, + pinned: true, + archived: false, }, { chatId: "two", @@ -20,6 +22,8 @@ const sessions: SessionSummary[] = [ createdAt: "2026-08-11T10:00:00Z", updatedAt: "2026-08-12T10:00:00Z", runStartedAt: null, + pinned: false, + archived: false, }, ] diff --git a/tui/src/session-menu.ts b/tui/src/session-menu.ts index e9aac2bc1..8c89c7cc5 100644 --- a/tui/src/session-menu.ts +++ b/tui/src/session-menu.ts @@ -35,7 +35,8 @@ export class SessionMenu { const detail = [age, preview && preview !== sessionLabel(session) ? preview : ""] .filter(Boolean) .join(" · ") - return `${session.active ? "● " : ""}${sessionLabel(session)}${detail ? ` ${detail}` : ""}` + const marker = session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : "" + return `${marker}${sessionLabel(session)}${detail ? ` ${detail}` : ""}` }, emptyText: "No matching sessions", }) @@ -49,7 +50,11 @@ export class SessionMenu { open(sessions: SessionSummary[], currentChatId: string, limit: number): void { const rows = sessions .map((session) => ({ ...session, active: session.chatId === currentChatId })) - .sort((left, right) => Number(right.active) - Number(left.active)) + .sort((left, right) => { + return Number(right.active) - Number(left.active) + || Number(right.pinned) - Number(left.pinned) + || Number(left.archived) - Number(right.archived) + }) this.picker.show(rows, "", limit) } diff --git a/tui/src/session-state.test.ts b/tui/src/session-state.test.ts new file mode 100644 index 000000000..fc0c2d59a --- /dev/null +++ b/tui/src/session-state.test.ts @@ -0,0 +1,27 @@ +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 new file mode 100644 index 000000000..752e2fd94 --- /dev/null +++ b/tui/src/session-state.ts @@ -0,0 +1,23 @@ +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(() => {}) + } +} diff --git a/tui/src/transcript.ts b/tui/src/transcript.ts index 13adaceb4..7d2183bde 100644 --- a/tui/src/transcript.ts +++ b/tui/src/transcript.ts @@ -144,6 +144,38 @@ export class Transcript { this.finishActivity() } + async prependHistory(messages: HistoryMessage[]): Promise { + if (messages.length === 0) return + const previousTop = this.root.scrollTop + const previousHeight = this.root.scrollHeight + let index = 1 // Keep the launch header first. + for (const message of messages) { + if (message.role === "user") { + this.writeRole("›", message.content, "user", index++) + } else if (message.role === "assistant") { + this.writeMarkdown(message.content, false, index++) + } else { + const activity = this.createActivity(index++) + const events: ToolProgressEvent[] = message.fileEdits?.length + ? message.fileEdits.map((edit) => ({ + call_id: `file:${edit.call_id || edit.path || "unknown"}`, + phase: edit.status === "error" ? "error" : edit.phase, + name: edit.path ? `${edit.tool || "edit"} ${edit.path}` : "edit file", + arguments: edit.error || formatDiffStat(edit), + })) + : message.toolEvents || [] + this.updateActivity(activity, message.content, events) + } + } + this.renderer.requestRender() + await this.renderer.idle() + this.root.scrollTop = previousTop + Math.max(0, this.root.scrollHeight - previousHeight) + } + + get atTop(): boolean { + return this.root.scrollTop <= 0 + } + user(content: string): void { this.finishActivity() this.writeRole("›", content, "user") @@ -192,23 +224,9 @@ export class Transcript { } progress(content: string, events: ToolProgressEvent[] = []): string { - const lines = events.length > 0 - ? events.map(formatToolEvent).filter(Boolean) - : content.split("\n").map(cleanProgress).filter(Boolean) - if (lines.length === 0) return "" + if (events.length === 0 && !content.split("\n").some((line) => cleanProgress(line))) return "" if (!this.activity) this.activity = this.createActivity() - for (const [index, line] of lines.entries()) { - const key = events[index]?.call_id ? `tool:${events[index]?.call_id}` : undefined - const existing = key ? this.activity.keys.get(key) : undefined - if (existing !== undefined) { - this.activity.lines[existing] = line - } else if (line !== this.activity.lines.at(-1)) { - if (key) this.activity.keys.set(key, this.activity.lines.length) - this.activity.lines.push(line) - } - } - this.renderActivity(this.activity) - return lines.at(-1) || "" + return this.updateActivity(this.activity, content, events) } fileEdits(edits: FileEditEvent[]): string { @@ -263,7 +281,7 @@ export class Transcript { }) } - private createActivity(): Activity { + private createActivity(index?: number): Activity { const row = this.createRow("activity") const text = new TextRenderable(this.renderer, { id: this.id("agent-activity"), @@ -273,7 +291,7 @@ export class Transcript { fg: this.theme.muted, }) row.add(text) - this.root.add(row) + this.root.add(row, index) this.styledText.push({ renderable: text, tone: "muted" }) this.wrote = true const activity = { text, lines: [], keys: new Map(), expanded: false } @@ -281,6 +299,28 @@ export class Transcript { return activity } + private updateActivity( + activity: Activity, + content: string, + events: ToolProgressEvent[] = [], + ): string { + const lines = events.length > 0 + ? events.map(formatToolEvent).filter(Boolean) + : content.split("\n").map(cleanProgress).filter(Boolean) + for (const [index, line] of lines.entries()) { + const key = events[index]?.call_id ? `tool:${events[index]?.call_id}` : undefined + const existing = key ? activity.keys.get(key) : undefined + if (existing !== undefined) { + activity.lines[existing] = line + } else if (line !== activity.lines.at(-1)) { + if (key) activity.keys.set(key, activity.lines.length) + activity.lines.push(line) + } + } + this.renderActivity(activity) + return lines.at(-1) || "" + } + private renderActivity(activity: Activity): void { if (activity.expanded || activity.lines.length <= ACTIVITY_PREVIEW_LINES) { activity.text.content = activity.lines.join("\n") @@ -313,6 +353,7 @@ export class Transcript { marker: string, content: string, tone: "muted" | "error" | "user", + index?: number, ): void { const row = this.createRow(tone === "user" ? "user" : "notice", "row") const prefix = this.createText(marker, tone, true, "role-marker") @@ -324,7 +365,7 @@ export class Transcript { text.flexGrow = 1 row.add(prefix) row.add(text) - this.root.add(row) + this.root.add(row, index) this.wrote = true } @@ -344,18 +385,18 @@ export class Transcript { return markdown } - private writeMarkdown(content: string, streaming: boolean): void { - this.writeAssistant(this.createMarkdown(content, streaming)) + private writeMarkdown(content: string, streaming: boolean, index?: number): void { + this.writeAssistant(this.createMarkdown(content, streaming), index) } - private writeAssistant(markdown: MarkdownRenderable): BoxRenderable { + private writeAssistant(markdown: MarkdownRenderable, index?: number): BoxRenderable { const row = this.createRow("assistant", "row") const prefix = this.createText("•", "assistant", false, "role-marker") prefix.width = 2 prefix.flexShrink = 0 row.add(prefix) row.add(markdown) - this.root.add(row) + this.root.add(row, index) this.wrote = true return row }