mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
feat(tui): unify session history and context
This commit is contained in:
@@ -202,7 +202,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 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:
|
For one request and an immediate exit, use:
|
||||||
|
|
||||||
|
|||||||
@@ -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 --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 |
|
| `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 Storage and Rollback
|
||||||
|
|
||||||
Session JSONL files live under `<config-dir>/sessions/<workspace-id>/`, outside the
|
Session JSONL files live under `<config-dir>/sessions/<workspace-id>/`, outside the
|
||||||
|
|||||||
@@ -4747,6 +4747,36 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
|
|||||||
assert body["has_pending_tool_calls"] is False
|
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(
|
def test_handle_webui_thread_get_reports_registered_turn_as_pending(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ console = Console()
|
|||||||
|
|
||||||
def agent(
|
def agent(
|
||||||
message: str | None = typer.Option(None, "--message", "-m", help="Message to send to the 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"),
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
markdown: bool = typer.Option(
|
markdown: bool = typer.Option(
|
||||||
@@ -111,6 +111,8 @@ def agent(
|
|||||||
raise typer.Exit(exit_code)
|
raise typer.Exit(exit_code)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
session_id = session_id or "cli:direct"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
provider = make_provider(runtime_config)
|
provider = make_provider(runtime_config)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def launch_tui(
|
|||||||
*,
|
*,
|
||||||
config_path: Path,
|
config_path: Path,
|
||||||
workspace_override: str | None,
|
workspace_override: str | None,
|
||||||
session_id: str,
|
session_id: str | None,
|
||||||
theme: str,
|
theme: str,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run the native TUI, owning a local gateway only when one is not running."""
|
"""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,
|
"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:
|
if chat_id:
|
||||||
env["NANOBOT_TUI_CHAT_ID"] = chat_id
|
env["NANOBOT_TUI_CHAT_ID"] = chat_id
|
||||||
else:
|
else:
|
||||||
@@ -328,3 +330,28 @@ def _websocket_chat_id(session_id: str) -> str | None:
|
|||||||
if session_id == "cli:direct":
|
if session_id == "cli:direct":
|
||||||
return "tui-direct"
|
return "tui-direct"
|
||||||
return session_id.split(":", 1)[-1] or None
|
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
|
||||||
|
|||||||
@@ -1800,6 +1800,10 @@ class SessionManager:
|
|||||||
"""Read a session without populating the cache."""
|
"""Read a session without populating the cache."""
|
||||||
return cast(dict[str, Any] | None, self._store.read(key))
|
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:
|
def read_session_metadata(self, key: str) -> dict[str, Any] | None:
|
||||||
"""Read session metadata without loading the transcript."""
|
"""Read session metadata without loading the transcript."""
|
||||||
return cast(dict[str, Any] | None, self._store.read_metadata(key))
|
return cast(dict[str, Any] | None, self._store.read_metadata(key))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ from nanobot.webui.session_automations import (
|
|||||||
session_automation_jobs,
|
session_automation_jobs,
|
||||||
session_automations_payload,
|
session_automations_payload,
|
||||||
)
|
)
|
||||||
|
from nanobot.webui.session_context import session_context_payload
|
||||||
from nanobot.webui.session_list_index import (
|
from nanobot.webui.session_list_index import (
|
||||||
WEBUI_SESSION_INDEX_INTERNAL_FIELDS,
|
WEBUI_SESSION_INDEX_INTERNAL_FIELDS,
|
||||||
indexed_workspace_scope,
|
indexed_workspace_scope,
|
||||||
@@ -678,6 +679,10 @@ class GatewayHTTPHandler:
|
|||||||
if m:
|
if m:
|
||||||
return self._handle_webui_thread_get(request, m.group(1))
|
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)
|
m = re.match(r"^/api/sessions/([^/]+)/file-preview$", got)
|
||||||
if m:
|
if m:
|
||||||
return self._handle_file_preview(request, m.group(1))
|
return self._handle_file_preview(request, m.group(1))
|
||||||
@@ -692,6 +697,24 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
return None
|
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:
|
async def _handle_sessions_list(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from nanobot.cli.tui_launcher import (
|
|||||||
_authenticated_ws_url,
|
_authenticated_ws_url,
|
||||||
_download_release_tui,
|
_download_release_tui,
|
||||||
_ensure_gateway,
|
_ensure_gateway,
|
||||||
|
_initial_tui_chat_id,
|
||||||
|
_read_tui_chat_id,
|
||||||
_resolve_tui_command,
|
_resolve_tui_command,
|
||||||
_websocket_chat_id,
|
_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
|
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(
|
def test_explicit_tui_binary_must_exist(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -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:
|
def test_manager_applies_file_cap_before_store_save(tmp_path) -> None:
|
||||||
store = MagicMock(spec=SessionStore)
|
store = MagicMock(spec=SessionStore)
|
||||||
archiver = MagicMock()
|
archiver = MagicMock()
|
||||||
|
|||||||
@@ -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
|
||||||
+8
-1
@@ -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.
|
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.
|
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.
|
||||||
|
|||||||
+117
-6
@@ -346,9 +346,14 @@ describe("NanobotTui layout", () => {
|
|||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
let resolveFetch: ((response: Response) => void) | undefined
|
let resolveFetch: ((response: Response) => void) | undefined
|
||||||
globalThis.fetch = (() => new Promise<Response>((resolve) => {
|
globalThis.fetch = ((input: string | URL | Request) => {
|
||||||
resolveFetch = resolve
|
if (String(input).endsWith("/api/webui/sidebar-state")) {
|
||||||
})) as unknown as typeof fetch
|
return Promise.resolve(new Response(JSON.stringify({})))
|
||||||
|
}
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
resolveFetch = resolve
|
||||||
|
})
|
||||||
|
}) as typeof fetch
|
||||||
const sent: string[] = []
|
const sent: string[] = []
|
||||||
const app = NanobotTui.mount(
|
const app = NanobotTui.mount(
|
||||||
setup.renderer,
|
setup.renderer,
|
||||||
@@ -388,9 +393,14 @@ describe("NanobotTui layout", () => {
|
|||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
let resolveFetch: ((response: Response) => void) | undefined
|
let resolveFetch: ((response: Response) => void) | undefined
|
||||||
globalThis.fetch = (() => new Promise<Response>((resolve) => {
|
globalThis.fetch = ((input: string | URL | Request) => {
|
||||||
resolveFetch = resolve
|
if (String(input).endsWith("/api/webui/sidebar-state")) {
|
||||||
})) as unknown as typeof fetch
|
return Promise.resolve(new Response(JSON.stringify({})))
|
||||||
|
}
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
resolveFetch = resolve
|
||||||
|
})
|
||||||
|
}) as typeof fetch
|
||||||
const app = NanobotTui.mount(
|
const app = NanobotTui.mount(
|
||||||
setup.renderer,
|
setup.renderer,
|
||||||
{ ...options, apiUrl: "http://nanobot.test", apiToken: "secret" },
|
{ ...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 () => {
|
test("survives rapid narrow resizes with long CJK and code", async () => {
|
||||||
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
|
|||||||
+111
-5
@@ -16,6 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
NanobotClient,
|
NanobotClient,
|
||||||
fetchHistory,
|
fetchHistory,
|
||||||
|
fetchSessionContext,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
fetchSlashCommands,
|
fetchSlashCommands,
|
||||||
type ConnectionStatus,
|
type ConnectionStatus,
|
||||||
@@ -30,7 +31,9 @@ import {
|
|||||||
type TuiCommand,
|
type TuiCommand,
|
||||||
} from "./command-menu"
|
} from "./command-menu"
|
||||||
import { SessionMenu } from "./session-menu"
|
import { SessionMenu } from "./session-menu"
|
||||||
|
import { ContextPanel, type ContextPanelTheme } from "./context-panel"
|
||||||
import { Transcript, type TranscriptTheme } from "./transcript"
|
import { Transcript, type TranscriptTheme } from "./transcript"
|
||||||
|
import { rememberChat } from "./session-state"
|
||||||
|
|
||||||
interface AppOptions {
|
interface AppOptions {
|
||||||
wsUrl: string
|
wsUrl: string
|
||||||
@@ -42,6 +45,7 @@ interface AppOptions {
|
|||||||
version: string
|
version: string
|
||||||
access: string
|
access: string
|
||||||
theme: "auto" | ThemeMode
|
theme: "auto" | ThemeMode
|
||||||
|
statePath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatClient {
|
interface ChatClient {
|
||||||
@@ -109,6 +113,12 @@ const LOCAL_COMMANDS: TuiCommand[] = [
|
|||||||
description: "Keep this conversation and start another",
|
description: "Keep this conversation and start another",
|
||||||
action: "new-chat",
|
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 {
|
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 {
|
function formatElapsed(milliseconds: number): string {
|
||||||
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
|
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
|
||||||
if (seconds < 60) return `${seconds}s`
|
if (seconds < 60) return `${seconds}s`
|
||||||
@@ -187,6 +206,7 @@ export class NanobotTui {
|
|||||||
private readonly transcript: Transcript
|
private readonly transcript: Transcript
|
||||||
private readonly commandMenu: CommandMenu
|
private readonly commandMenu: CommandMenu
|
||||||
private readonly sessionMenu: SessionMenu
|
private readonly sessionMenu: SessionMenu
|
||||||
|
private readonly contextPanel: ContextPanel
|
||||||
private readonly client: ChatClient
|
private readonly client: ChatClient
|
||||||
private readonly shell: BoxRenderable
|
private readonly shell: BoxRenderable
|
||||||
private readonly title: TextRenderable
|
private readonly title: TextRenderable
|
||||||
@@ -203,6 +223,9 @@ export class NanobotTui {
|
|||||||
private finalMessage = ""
|
private finalMessage = ""
|
||||||
private turnHadAnswer = false
|
private turnHadAnswer = false
|
||||||
private historyLoaded = false
|
private historyLoaded = false
|
||||||
|
private historyBeforeCursor: string | null = null
|
||||||
|
private historyHasMore = false
|
||||||
|
private historyLoadingOlder = false
|
||||||
private attachedOnce = false
|
private attachedOnce = false
|
||||||
private pendingEvents: InboundEvent[] | null = null
|
private pendingEvents: InboundEvent[] | null = null
|
||||||
private hydrationId = 0
|
private hydrationId = 0
|
||||||
@@ -234,6 +257,7 @@ export class NanobotTui {
|
|||||||
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
|
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
|
||||||
this.commandMenu.setCommands([], LOCAL_COMMANDS)
|
this.commandMenu.setCommands([], LOCAL_COMMANDS)
|
||||||
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
|
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
|
||||||
|
this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette))
|
||||||
this.client = client || new NanobotClient({
|
this.client = client || new NanobotClient({
|
||||||
url: options.wsUrl,
|
url: options.wsUrl,
|
||||||
chatId: options.chatId,
|
chatId: options.chatId,
|
||||||
@@ -292,6 +316,7 @@ export class NanobotTui {
|
|||||||
{ name: "return", meta: true, action: "newline" },
|
{ name: "return", meta: true, action: "newline" },
|
||||||
],
|
],
|
||||||
onContentChange: () => {
|
onContentChange: () => {
|
||||||
|
if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
|
||||||
this.syncComposerPlaceholder()
|
this.syncComposerPlaceholder()
|
||||||
if (this.sessionMenu.visible) this.syncSessionMenu()
|
if (this.sessionMenu.visible) this.syncSessionMenu()
|
||||||
else this.syncCommandMenu()
|
else this.syncCommandMenu()
|
||||||
@@ -335,6 +360,7 @@ export class NanobotTui {
|
|||||||
this.shell.add(this.transcript.root)
|
this.shell.add(this.transcript.root)
|
||||||
this.shell.add(this.commandMenu.root)
|
this.shell.add(this.commandMenu.root)
|
||||||
this.shell.add(this.sessionMenu.root)
|
this.shell.add(this.sessionMenu.root)
|
||||||
|
this.shell.add(this.contextPanel.root)
|
||||||
this.shell.add(this.title)
|
this.shell.add(this.title)
|
||||||
this.shell.add(this.composerFrame)
|
this.shell.add(this.composerFrame)
|
||||||
this.shell.add(statusRow)
|
this.shell.add(statusRow)
|
||||||
@@ -425,6 +451,7 @@ export class NanobotTui {
|
|||||||
const command = this.commandMenu.resolve(content)
|
const command = this.commandMenu.resolve(content)
|
||||||
if (command?.source === "tui") {
|
if (command?.source === "tui") {
|
||||||
if (command.command.action === "sessions") void this.openSessions()
|
if (command.command.action === "sessions") void this.openSessions()
|
||||||
|
else if (command.command.action === "context") void this.openContext()
|
||||||
else this.startNewChat()
|
else this.startNewChat()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -464,6 +491,7 @@ 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.commandTurns.clear()
|
this.commandTurns.clear()
|
||||||
const restoring = this.attachedOnce
|
const restoring = this.attachedOnce
|
||||||
this.attachedOnce = true
|
this.attachedOnce = true
|
||||||
@@ -577,6 +605,10 @@ export class NanobotTui {
|
|||||||
private async prepareChat(chatId: string, restoring: boolean, hydrationId: number): Promise<void> {
|
private async prepareChat(chatId: string, restoring: boolean, hydrationId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (restoring) {
|
if (restoring) {
|
||||||
|
this.contextPanel.hide()
|
||||||
|
this.historyBeforeCursor = null
|
||||||
|
this.historyHasMore = false
|
||||||
|
this.historyLoadingOlder = false
|
||||||
this.transcript.reset({
|
this.transcript.reset({
|
||||||
model: this.modelName,
|
model: this.modelName,
|
||||||
workspace: this.options.workspace,
|
workspace: this.options.workspace,
|
||||||
@@ -588,9 +620,8 @@ export class NanobotTui {
|
|||||||
this.historyLoaded = true
|
this.historyLoaded = true
|
||||||
const history = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId)
|
const history = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId)
|
||||||
if (hydrationId !== this.hydrationId) return
|
if (hydrationId !== this.hydrationId) return
|
||||||
if (history.truncated) {
|
this.historyBeforeCursor = history.beforeCursor
|
||||||
this.transcript.notice("Earlier messages omitted · open WebUI to load the full history")
|
this.historyHasMore = history.hasMoreBefore
|
||||||
}
|
|
||||||
this.transcript.history(history.messages)
|
this.transcript.history(history.messages)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -599,7 +630,9 @@ export class NanobotTui {
|
|||||||
} finally {
|
} finally {
|
||||||
if (hydrationId !== this.hydrationId) return
|
if (hydrationId !== this.hydrationId) return
|
||||||
this.ready = true
|
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 => {
|
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") {
|
if (this.sessionLoading && key.name === "escape") {
|
||||||
this.closeSessions()
|
this.closeSessions()
|
||||||
this.status.content = "Ready"
|
this.status.content = "Ready"
|
||||||
@@ -748,7 +787,10 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
if (key.name === "pageup" || key.name === "pagedown") {
|
if (key.name === "pageup" || key.name === "pagedown") {
|
||||||
key.preventDefault()
|
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
|
return
|
||||||
}
|
}
|
||||||
if (key.ctrl && (key.name === "home" || key.name === "end")) {
|
if (key.ctrl && (key.name === "home" || key.name === "end")) {
|
||||||
@@ -791,6 +833,7 @@ export class NanobotTui {
|
|||||||
this.transcript.setTheme(transcriptTheme(this.palette))
|
this.transcript.setTheme(transcriptTheme(this.palette))
|
||||||
this.commandMenu.setTheme(commandMenuTheme(this.palette))
|
this.commandMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
this.sessionMenu.setTheme(commandMenuTheme(this.palette))
|
this.sessionMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
|
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
||||||
this.composerFrame.borderColor = this.palette.border
|
this.composerFrame.borderColor = this.palette.border
|
||||||
this.composer.textColor = this.palette.text
|
this.composer.textColor = this.palette.text
|
||||||
this.composer.focusedTextColor = this.palette.text
|
this.composer.focusedTextColor = this.palette.text
|
||||||
@@ -802,6 +845,7 @@ export class NanobotTui {
|
|||||||
|
|
||||||
private handleResize = (): void => {
|
private handleResize = (): void => {
|
||||||
this.resizeComposer()
|
this.resizeComposer()
|
||||||
|
this.contextPanel.resize(this.renderer.height)
|
||||||
this.title.visible = this.renderer.height >= 14
|
this.title.visible = this.renderer.height >= 14
|
||||||
this.updateMeta()
|
this.updateMeta()
|
||||||
}
|
}
|
||||||
@@ -823,6 +867,10 @@ export class NanobotTui {
|
|||||||
: "enter open · esc close"
|
: "enter open · esc close"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (this.contextPanel.visible) {
|
||||||
|
this.meta.content = "esc close · pgup/pgdn scroll"
|
||||||
|
return
|
||||||
|
}
|
||||||
this.meta.content = this.renderer.width >= 112
|
this.meta.content = this.renderer.width >= 112
|
||||||
? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop"
|
? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop"
|
||||||
: this.renderer.width >= 72
|
: this.renderer.width >= 72
|
||||||
@@ -891,6 +939,7 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
|
this.contextPanel.hide()
|
||||||
this.composer.setText("")
|
this.composer.setText("")
|
||||||
this.sessionLoading = true
|
this.sessionLoading = true
|
||||||
const loadId = ++this.sessionLoadId
|
const loadId = ++this.sessionLoadId
|
||||||
@@ -947,6 +996,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.sessionMenu.hide()
|
||||||
|
this.contextPanel.hide()
|
||||||
this.composer.setText("")
|
this.composer.setText("")
|
||||||
try {
|
try {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
@@ -1019,6 +1069,62 @@ export class NanobotTui {
|
|||||||
this.updateMeta()
|
this.updateMeta()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async openContext(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
private async copySelection(text: string): Promise<void> {
|
||||||
if (!text) return
|
if (!text) return
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
|||||||
|
|
||||||
export type CommandMenuTheme = PickerMenuTheme
|
export type CommandMenuTheme = PickerMenuTheme
|
||||||
|
|
||||||
export type TuiCommandAction = "sessions" | "new-chat"
|
export type TuiCommandAction = "sessions" | "new-chat" | "context"
|
||||||
|
|
||||||
export interface TuiCommand {
|
export interface TuiCommand {
|
||||||
command: string
|
command: string
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ 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
|
||||||
|
|||||||
+80
-32
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import {
|
import {
|
||||||
NanobotClient,
|
NanobotClient,
|
||||||
fetchHistory,
|
fetchHistory,
|
||||||
|
fetchSessionContext,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
fetchSlashCommands,
|
fetchSlashCommands,
|
||||||
type InboundEvent,
|
type InboundEvent,
|
||||||
@@ -177,24 +178,28 @@ describe("gateway protocol", () => {
|
|||||||
|
|
||||||
test("reports when the bounded history snapshot omits earlier turns", async () => {
|
test("reports when the bounded history snapshot omits earlier turns", async () => {
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
let requested = ""
|
||||||
messages: [
|
globalThis.fetch = ((input: string | URL | Request) => {
|
||||||
{ role: "user", content: "hello" },
|
requested = String(input)
|
||||||
{
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
role: "tool",
|
messages: [
|
||||||
kind: "trace",
|
{ role: "user", content: "hello" },
|
||||||
content: "read_file",
|
{
|
||||||
traces: ["read_file"],
|
role: "tool",
|
||||||
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
|
kind: "trace",
|
||||||
},
|
content: "read_file",
|
||||||
{ role: "assistant", kind: "reasoning", content: "private thought" },
|
traces: ["read_file"],
|
||||||
{ role: "assistant", content: "hi" },
|
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
|
||||||
],
|
},
|
||||||
page: { has_more_before: true },
|
{ role: "assistant", kind: "reasoning", content: "private thought" },
|
||||||
})))) as unknown as typeof fetch
|
{ role: "assistant", content: "hi" },
|
||||||
|
],
|
||||||
|
page: { has_more_before: true, before_cursor: "older-1" },
|
||||||
|
})))
|
||||||
|
}) as typeof fetch
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const history = await fetchHistory("http://nanobot.test", "token", "chat")
|
const history = await fetchHistory("http://nanobot.test", "token", "chat", "newer-page")
|
||||||
expect(history).toEqual({
|
expect(history).toEqual({
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "user", content: "hello" },
|
{ role: "user", content: "hello" },
|
||||||
@@ -205,7 +210,38 @@ describe("gateway protocol", () => {
|
|||||||
},
|
},
|
||||||
{ role: "assistant", content: "hi" },
|
{ 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 {
|
} finally {
|
||||||
globalThis.fetch = original
|
globalThis.fetch = original
|
||||||
@@ -267,29 +303,41 @@ describe("gateway protocol", () => {
|
|||||||
|
|
||||||
test("loads and normalizes WebUI sessions", async () => {
|
test("loads and normalizes WebUI sessions", async () => {
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
|
globalThis.fetch = ((input: string | URL | Request) => {
|
||||||
sessions: [
|
const url = String(input)
|
||||||
{
|
if (url.endsWith("/api/webui/sidebar-state")) {
|
||||||
key: "websocket:chat-1",
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
title: "Release plan",
|
pinned_keys: ["websocket:chat-1"],
|
||||||
preview: "Prepare the release",
|
archived_keys: [],
|
||||||
created_at: "2026-08-12T10:00:00Z",
|
title_overrides: { "websocket:chat-1": "Pinned release" },
|
||||||
updated_at: "2026-08-13T10:00:00Z",
|
})))
|
||||||
run_started_at: 123,
|
}
|
||||||
},
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
{ key: "cli:direct", title: "Not a WebUI session" },
|
sessions: [
|
||||||
{ key: 42 },
|
{
|
||||||
],
|
key: "websocket:chat-1",
|
||||||
})))) as unknown as typeof fetch
|
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 {
|
try {
|
||||||
expect(await fetchSessions("http://nanobot.test", "secret")).toEqual([{
|
expect(await fetchSessions("http://nanobot.test", "secret")).toEqual([{
|
||||||
chatId: "chat-1",
|
chatId: "chat-1",
|
||||||
title: "Release plan",
|
title: "Pinned release",
|
||||||
preview: "Prepare the release",
|
preview: "Prepare the release",
|
||||||
createdAt: "2026-08-12T10:00:00Z",
|
createdAt: "2026-08-12T10:00:00Z",
|
||||||
updatedAt: "2026-08-13T10:00:00Z",
|
updatedAt: "2026-08-13T10:00:00Z",
|
||||||
runStartedAt: 123,
|
runStartedAt: 123,
|
||||||
|
pinned: true,
|
||||||
|
archived: false,
|
||||||
}])
|
}])
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = original
|
globalThis.fetch = original
|
||||||
|
|||||||
+84
-10
@@ -84,7 +84,19 @@ export interface HistoryMessage {
|
|||||||
|
|
||||||
export interface HistorySnapshot {
|
export interface HistorySnapshot {
|
||||||
messages: HistoryMessage[]
|
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 {
|
export interface SlashCommand {
|
||||||
@@ -110,6 +122,8 @@ export interface SessionSummary {
|
|||||||
createdAt: string | null
|
createdAt: string | null
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
runStartedAt: number | null
|
runStartedAt: number | null
|
||||||
|
pinned: boolean
|
||||||
|
archived: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SLASH_COMMAND_LIFECYCLES = new Set([
|
const SLASH_COMMAND_LIFECYCLES = new Set([
|
||||||
@@ -220,17 +234,24 @@ export async function fetchHistory(
|
|||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
apiToken: string,
|
apiToken: string,
|
||||||
chatId: string,
|
chatId: string,
|
||||||
|
beforeCursor?: string | null,
|
||||||
): Promise<HistorySnapshot> {
|
): Promise<HistorySnapshot> {
|
||||||
if (!apiUrl || !apiToken) return { messages: [], truncated: false }
|
if (!apiUrl || !apiToken) {
|
||||||
|
return { messages: [], hasMoreBefore: false, beforeCursor: null }
|
||||||
|
}
|
||||||
const key = encodeURIComponent(`websocket:${chatId}`)
|
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}` },
|
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}`)
|
if (!response.ok) throw new Error(`history request failed: HTTP ${response.status}`)
|
||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
messages?: Array<Record<string, unknown>>
|
messages?: Array<Record<string, unknown>>
|
||||||
page?: { has_more_before?: boolean }
|
page?: { has_more_before?: boolean; before_cursor?: string }
|
||||||
}
|
}
|
||||||
const messages: HistoryMessage[] = (payload.messages || []).flatMap((message) => {
|
const messages: HistoryMessage[] = (payload.messages || []).flatMap((message) => {
|
||||||
const role = message.role
|
const role = message.role
|
||||||
@@ -258,7 +279,41 @@ export async function fetchHistory(
|
|||||||
}
|
}
|
||||||
return [{ role: role as HistoryMessage["role"], content }]
|
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<SessionContextSnapshot | null> {
|
||||||
|
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<string, unknown>
|
||||||
|
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(
|
export async function fetchSlashCommands(
|
||||||
@@ -294,24 +349,43 @@ export async function fetchSessions(
|
|||||||
apiToken: string,
|
apiToken: string,
|
||||||
): Promise<SessionSummary[]> {
|
): Promise<SessionSummary[]> {
|
||||||
if (!apiUrl || !apiToken) return []
|
if (!apiUrl || !apiToken) return []
|
||||||
const response = await fetch(`${apiUrl}/api/sessions`, {
|
const headers = { Authorization: `Bearer ${apiToken}` }
|
||||||
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}`)
|
if (!response.ok) throw new Error(`session request failed: HTTP ${response.status}`)
|
||||||
const payload = await response.json() as { sessions?: unknown[] }
|
const payload = await response.json() as { sessions?: unknown[] }
|
||||||
|
let sidebar: Record<string, unknown> = {}
|
||||||
|
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) => {
|
return (payload.sessions || []).flatMap((value) => {
|
||||||
if (!isRecord(value) || typeof value.key !== "string" || !value.key.startsWith("websocket:")) {
|
if (!isRecord(value) || typeof value.key !== "string" || !value.key.startsWith("websocket:")) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const chatId = value.key.slice("websocket:".length)
|
const chatId = value.key.slice("websocket:".length)
|
||||||
if (!chatId) return []
|
if (!chatId) return []
|
||||||
|
const titleOverride = titles[value.key]
|
||||||
return [{
|
return [{
|
||||||
chatId,
|
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 : "",
|
preview: typeof value.preview === "string" ? value.preview : "",
|
||||||
createdAt: typeof value.created_at === "string" ? value.created_at : null,
|
createdAt: typeof value.created_at === "string" ? value.created_at : null,
|
||||||
updatedAt: typeof value.updated_at === "string" ? value.updated_at : null,
|
updatedAt: typeof value.updated_at === "string" ? value.updated_at : null,
|
||||||
runStartedAt: typeof value.run_started_at === "number" ? value.run_started_at : null,
|
runStartedAt: typeof value.run_started_at === "number" ? value.run_started_at : null,
|
||||||
|
pinned: pinned.has(value.key),
|
||||||
|
archived: archived.has(value.key),
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const sessions: SessionSummary[] = [
|
|||||||
createdAt: "2026-08-12T10:00:00Z",
|
createdAt: "2026-08-12T10:00:00Z",
|
||||||
updatedAt: "2026-08-13T10:00:00Z",
|
updatedAt: "2026-08-13T10:00:00Z",
|
||||||
runStartedAt: null,
|
runStartedAt: null,
|
||||||
|
pinned: true,
|
||||||
|
archived: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
chatId: "two",
|
chatId: "two",
|
||||||
@@ -20,6 +22,8 @@ const sessions: SessionSummary[] = [
|
|||||||
createdAt: "2026-08-11T10:00:00Z",
|
createdAt: "2026-08-11T10:00:00Z",
|
||||||
updatedAt: "2026-08-12T10:00:00Z",
|
updatedAt: "2026-08-12T10:00:00Z",
|
||||||
runStartedAt: null,
|
runStartedAt: null,
|
||||||
|
pinned: false,
|
||||||
|
archived: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export class SessionMenu {
|
|||||||
const detail = [age, preview && preview !== sessionLabel(session) ? preview : ""]
|
const detail = [age, preview && preview !== sessionLabel(session) ? preview : ""]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")
|
.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",
|
emptyText: "No matching sessions",
|
||||||
})
|
})
|
||||||
@@ -49,7 +50,11 @@ export class SessionMenu {
|
|||||||
open(sessions: SessionSummary[], currentChatId: string, limit: number): void {
|
open(sessions: SessionSummary[], currentChatId: string, limit: number): void {
|
||||||
const rows = sessions
|
const rows = sessions
|
||||||
.map((session) => ({ ...session, active: session.chatId === currentChatId }))
|
.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)
|
this.picker.show(rows, "", limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<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(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-23
@@ -144,6 +144,38 @@ export class Transcript {
|
|||||||
this.finishActivity()
|
this.finishActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async prependHistory(messages: HistoryMessage[]): Promise<void> {
|
||||||
|
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 {
|
user(content: string): void {
|
||||||
this.finishActivity()
|
this.finishActivity()
|
||||||
this.writeRole("›", content, "user")
|
this.writeRole("›", content, "user")
|
||||||
@@ -192,23 +224,9 @@ export class Transcript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
progress(content: string, events: ToolProgressEvent[] = []): string {
|
progress(content: string, events: ToolProgressEvent[] = []): string {
|
||||||
const lines = events.length > 0
|
if (events.length === 0 && !content.split("\n").some((line) => cleanProgress(line))) return ""
|
||||||
? events.map(formatToolEvent).filter(Boolean)
|
|
||||||
: content.split("\n").map(cleanProgress).filter(Boolean)
|
|
||||||
if (lines.length === 0) return ""
|
|
||||||
if (!this.activity) this.activity = this.createActivity()
|
if (!this.activity) this.activity = this.createActivity()
|
||||||
for (const [index, line] of lines.entries()) {
|
return this.updateActivity(this.activity, content, events)
|
||||||
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) || ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fileEdits(edits: FileEditEvent[]): string {
|
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 row = this.createRow("activity")
|
||||||
const text = new TextRenderable(this.renderer, {
|
const text = new TextRenderable(this.renderer, {
|
||||||
id: this.id("agent-activity"),
|
id: this.id("agent-activity"),
|
||||||
@@ -273,7 +291,7 @@ export class Transcript {
|
|||||||
fg: this.theme.muted,
|
fg: this.theme.muted,
|
||||||
})
|
})
|
||||||
row.add(text)
|
row.add(text)
|
||||||
this.root.add(row)
|
this.root.add(row, index)
|
||||||
this.styledText.push({ renderable: text, tone: "muted" })
|
this.styledText.push({ renderable: text, tone: "muted" })
|
||||||
this.wrote = true
|
this.wrote = true
|
||||||
const activity = { text, lines: [], keys: new Map(), expanded: false }
|
const activity = { text, lines: [], keys: new Map(), expanded: false }
|
||||||
@@ -281,6 +299,28 @@ export class Transcript {
|
|||||||
return activity
|
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 {
|
private renderActivity(activity: Activity): void {
|
||||||
if (activity.expanded || activity.lines.length <= ACTIVITY_PREVIEW_LINES) {
|
if (activity.expanded || activity.lines.length <= ACTIVITY_PREVIEW_LINES) {
|
||||||
activity.text.content = activity.lines.join("\n")
|
activity.text.content = activity.lines.join("\n")
|
||||||
@@ -313,6 +353,7 @@ export class Transcript {
|
|||||||
marker: string,
|
marker: string,
|
||||||
content: string,
|
content: string,
|
||||||
tone: "muted" | "error" | "user",
|
tone: "muted" | "error" | "user",
|
||||||
|
index?: number,
|
||||||
): void {
|
): void {
|
||||||
const row = this.createRow(tone === "user" ? "user" : "notice", "row")
|
const row = this.createRow(tone === "user" ? "user" : "notice", "row")
|
||||||
const prefix = this.createText(marker, tone, true, "role-marker")
|
const prefix = this.createText(marker, tone, true, "role-marker")
|
||||||
@@ -324,7 +365,7 @@ export class Transcript {
|
|||||||
text.flexGrow = 1
|
text.flexGrow = 1
|
||||||
row.add(prefix)
|
row.add(prefix)
|
||||||
row.add(text)
|
row.add(text)
|
||||||
this.root.add(row)
|
this.root.add(row, index)
|
||||||
this.wrote = true
|
this.wrote = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,18 +385,18 @@ export class Transcript {
|
|||||||
return markdown
|
return markdown
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeMarkdown(content: string, streaming: boolean): void {
|
private writeMarkdown(content: string, streaming: boolean, index?: number): void {
|
||||||
this.writeAssistant(this.createMarkdown(content, streaming))
|
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 row = this.createRow("assistant", "row")
|
||||||
const prefix = this.createText("•", "assistant", false, "role-marker")
|
const prefix = this.createText("•", "assistant", false, "role-marker")
|
||||||
prefix.width = 2
|
prefix.width = 2
|
||||||
prefix.flexShrink = 0
|
prefix.flexShrink = 0
|
||||||
row.add(prefix)
|
row.add(prefix)
|
||||||
row.add(markdown)
|
row.add(markdown)
|
||||||
this.root.add(row)
|
this.root.add(row, index)
|
||||||
this.wrote = true
|
this.wrote = true
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user