feat(tui): unify session history and context

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent b3c3a82075
commit 6301c0ab57
24 changed files with 880 additions and 84 deletions
+1 -1
View File
@@ -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:
+5
View File
@@ -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 `<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
@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,
+3 -1
View File
@@ -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:
+29 -2
View File
@@ -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
+4
View File
@@ -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))
+51
View File
@@ -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,
}
+23
View File
@@ -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")
+24
View File
@@ -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,
+11
View File
@@ -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()
+56
View File
@@ -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
View File
@@ -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.
+117 -6
View File
@@ -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<Response>((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<Response>((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<Response>((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<Response>((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)
+111 -5
View File
@@ -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<void> {
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<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> {
if (!text) return
try {
+1 -1
View File
@@ -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
+116
View File
@@ -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
}
}
+1
View File
@@ -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
+80 -32
View File
@@ -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
+84 -10
View File
@@ -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<HistorySnapshot> {
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<Record<string, unknown>>
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<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(
@@ -294,24 +349,43 @@ export async function fetchSessions(
apiToken: string,
): Promise<SessionSummary[]> {
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<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) => {
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),
}]
})
}
+4
View File
@@ -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,
},
]
+7 -2
View File
@@ -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)
}
+27
View File
@@ -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",
})
})
})
+23
View File
@@ -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
View File
@@ -144,6 +144,38 @@ export class Transcript {
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 {
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
}