mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
feat(tui): add agent interaction workflows
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, `/new-chat` to preserve this conversation and start another one, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. 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.
|
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` queues a follow-up and a second empty `Enter` steers the current turn. 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:
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
|
|||||||
|
|
||||||
Inside the native TUI, `/sessions` switches saved conversations, `/new-chat` starts another saved
|
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
|
conversation, and `/context` explains the compacted summary and raw session suffix available to
|
||||||
the next agent turn. `/diff` opens the latest turn's file changes as a full-screen unified diff.
|
the next agent turn. `/branch` forks a saved conversation from a completed reply, and `/diff`
|
||||||
|
opens the latest turn's file changes as a full-screen unified diff.
|
||||||
`PageUp` loads older transcript pages when you reach the top. The default
|
`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.
|
launch returns to the last attached TUI session; `--session` selects a specific session instead.
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the s
|
|||||||
|
|
||||||
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably.
|
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably.
|
||||||
|
|
||||||
`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` opens a searchable conversation picker, while `/new-chat` preserves the current conversation and starts another one. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
`Enter` sends the current message. While a turn is active, it queues a follow-up; press `Enter` again on an empty composer to steer with the newest queued prompt. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, or type `@` to complete installed apps, configured MCP servers, and saved sessions. Use the arrow keys to choose an item and `Tab` to complete it. `/sessions` opens a searchable conversation picker, `/new-chat` preserves the current conversation and starts another one, and `/branch` forks from a completed reply. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. The footer reports provider token/cache usage when available. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
||||||
|
|
||||||
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
|
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ class TurnContext:
|
|||||||
turn_wall_started_at: float = field(default_factory=time.time)
|
turn_wall_started_at: float = field(default_factory=time.time)
|
||||||
visible_run_started_at: float | None = None
|
visible_run_started_at: float | None = None
|
||||||
turn_latency_ms: int | None = None
|
turn_latency_ms: int | None = None
|
||||||
|
usage: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
def require_runtime(self) -> LLMRuntime:
|
def require_runtime(self) -> LLMRuntime:
|
||||||
"""Return the runtime established by the BUILD stage."""
|
"""Return the runtime established by the BUILD stage."""
|
||||||
@@ -1897,6 +1898,8 @@ class AgentLoop:
|
|||||||
ctx.all_messages = all_msgs
|
ctx.all_messages = all_msgs
|
||||||
ctx.stop_reason = stop_reason
|
ctx.stop_reason = stop_reason
|
||||||
ctx.had_injections = had_injections
|
ctx.had_injections = had_injections
|
||||||
|
ctx.usage = dict(self._last_usage)
|
||||||
|
ctx.delivery.record_usage(ctx.usage)
|
||||||
if ctx.kind is TurnKind.USER:
|
if ctx.kind is TurnKind.USER:
|
||||||
await turn_continuation.maybe_continue_turn(ctx)
|
await turn_continuation.maybe_continue_turn(ctx)
|
||||||
|
|
||||||
@@ -1922,6 +1925,8 @@ class AgentLoop:
|
|||||||
else ctx.turn_wall_started_at
|
else ctx.turn_wall_started_at
|
||||||
)
|
)
|
||||||
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
|
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
|
||||||
|
if ctx.usage and not ctx.ephemeral:
|
||||||
|
session.metadata["_last_usage"] = dict(ctx.usage)
|
||||||
self._save_turn(
|
self._save_turn(
|
||||||
session, ctx.all_messages, ctx.save_skip,
|
session, ctx.all_messages, ctx.save_skip,
|
||||||
turn_latency_ms=ctx.turn_latency_ms,
|
turn_latency_ms=ctx.turn_latency_ms,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
@@ -203,6 +203,9 @@ class TurnDelivery:
|
|||||||
def record_latency(self, latency_ms: int | None) -> None:
|
def record_latency(self, latency_ms: int | None) -> None:
|
||||||
self.runtime_event_publisher.record_turn_latency(self.session_key, latency_ms)
|
self.runtime_event_publisher.record_turn_latency(self.session_key, latency_ms)
|
||||||
|
|
||||||
|
def record_usage(self, usage: Mapping[str, int]) -> None:
|
||||||
|
self.runtime_event_publisher.record_turn_usage(self.session_key, usage)
|
||||||
|
|
||||||
def background_response(
|
def background_response(
|
||||||
self,
|
self,
|
||||||
content: str | None,
|
content: str | None,
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ class StreamedResponseEvent(OutboundEvent):
|
|||||||
class TurnEndEvent(OutboundEvent):
|
class TurnEndEvent(OutboundEvent):
|
||||||
latency_ms: int | None = None
|
latency_ms: int | None = None
|
||||||
goal_state: dict[str, Any] | None = None
|
goal_state: dict[str, Any] | None = None
|
||||||
|
usage: dict[str, int] | None = None
|
||||||
|
context_window_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -88,6 +90,7 @@ class TurnModelUpdatedEvent(OutboundEvent):
|
|||||||
|
|
||||||
model: str
|
model: str
|
||||||
model_preset: str | None = None
|
model_preset: str | None = None
|
||||||
|
context_window_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def outbound_message_for_event(
|
def outbound_message_for_event(
|
||||||
@@ -172,6 +175,12 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
|
|||||||
return TurnEndEvent(
|
return TurnEndEvent(
|
||||||
latency_ms=_metadata_int(meta, "latency_ms"),
|
latency_ms=_metadata_int(meta, "latency_ms"),
|
||||||
goal_state=cast(dict[str, Any], goal_state) if isinstance(goal_state, dict) else None,
|
goal_state=cast(dict[str, Any], goal_state) if isinstance(goal_state, dict) else None,
|
||||||
|
usage=(
|
||||||
|
cast(dict[str, int], meta.get("usage"))
|
||||||
|
if isinstance(meta.get("usage"), dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
context_window_tokens=_metadata_int(meta, "context_window_tokens"),
|
||||||
)
|
)
|
||||||
if meta.get("_session_updated"):
|
if meta.get("_session_updated"):
|
||||||
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
|
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ class TurnCompleted:
|
|||||||
context: RuntimeEventContext
|
context: RuntimeEventContext
|
||||||
latency_ms: int | None = None
|
latency_ms: int | None = None
|
||||||
runtime: LLMRuntime | None = None
|
runtime: LLMRuntime | None = None
|
||||||
|
usage: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -169,6 +170,7 @@ class RuntimeEventPublisher:
|
|||||||
self.bus = bus or RuntimeEventBus()
|
self.bus = bus or RuntimeEventBus()
|
||||||
self._turn_latency_ms: dict[str, int] = {}
|
self._turn_latency_ms: dict[str, int] = {}
|
||||||
self._turn_runtime: dict[str, LLMRuntime] = {}
|
self._turn_runtime: dict[str, LLMRuntime] = {}
|
||||||
|
self._turn_usage: dict[str, dict[str, int]] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _context(
|
def _context(
|
||||||
@@ -194,9 +196,17 @@ class RuntimeEventPublisher:
|
|||||||
if latency_ms is not None:
|
if latency_ms is not None:
|
||||||
self._turn_latency_ms[session_key] = int(latency_ms)
|
self._turn_latency_ms[session_key] = int(latency_ms)
|
||||||
|
|
||||||
|
def record_turn_usage(self, session_key: str, usage: Mapping[str, int]) -> None:
|
||||||
|
self._turn_usage[session_key] = {
|
||||||
|
key: int(value)
|
||||||
|
for key, value in usage.items()
|
||||||
|
if type(value) is int and value >= 0
|
||||||
|
}
|
||||||
|
|
||||||
def clear_turn(self, session_key: str) -> None:
|
def clear_turn(self, session_key: str) -> None:
|
||||||
self._turn_latency_ms.pop(session_key, None)
|
self._turn_latency_ms.pop(session_key, None)
|
||||||
self._turn_runtime.pop(session_key, None)
|
self._turn_runtime.pop(session_key, None)
|
||||||
|
self._turn_usage.pop(session_key, None)
|
||||||
|
|
||||||
async def session_turn_started(
|
async def session_turn_started(
|
||||||
self,
|
self,
|
||||||
@@ -295,6 +305,7 @@ class RuntimeEventPublisher:
|
|||||||
),
|
),
|
||||||
latency_ms=self._turn_latency_ms.pop(session_key, None),
|
latency_ms=self._turn_latency_ms.pop(session_key, None),
|
||||||
runtime=self._turn_runtime.pop(session_key, None),
|
runtime=self._turn_runtime.pop(session_key, None),
|
||||||
|
usage=self._turn_usage.pop(session_key, {}),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -434,18 +434,34 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._subs.setdefault(chat_id, set()).add(connection)
|
self._subs.setdefault(chat_id, set()).add(connection)
|
||||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||||
|
|
||||||
def _attached_model_fields(self, chat_id: str) -> dict[str, str | None]:
|
def _attached_model_fields(self, chat_id: str) -> dict[str, Any]:
|
||||||
"""Expose the session's canonical preset on the attach handshake."""
|
"""Expose small session runtime facts on the attach handshake."""
|
||||||
sessions = self.gateway.session_manager
|
sessions = self.gateway.session_manager
|
||||||
if sessions is None:
|
if sessions is None:
|
||||||
return {}
|
return {}
|
||||||
snapshot = sessions.read_session_metadata(f"websocket:{chat_id}")
|
snapshot = sessions.read_session_metadata(f"websocket:{chat_id}")
|
||||||
metadata = snapshot.get("metadata") if isinstance(snapshot, dict) else None
|
raw_metadata = snapshot.get("metadata") if snapshot is not None else None
|
||||||
|
metadata = cast(dict[str, object], raw_metadata) if isinstance(raw_metadata, dict) else None
|
||||||
|
fields: dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
return {"model_preset": model_preset_from_metadata(metadata)}
|
fields["model_preset"] = model_preset_from_metadata(metadata)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
|
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
|
||||||
return {"model_preset": None}
|
fields["model_preset"] = None
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
usage = metadata.get("_last_usage")
|
||||||
|
if isinstance(usage, dict):
|
||||||
|
sanitized_usage: dict[str, int | float] = {}
|
||||||
|
for key, value in cast(dict[object, object], usage).items():
|
||||||
|
if (
|
||||||
|
isinstance(key, str)
|
||||||
|
and isinstance(value, (int, float))
|
||||||
|
and not isinstance(value, bool)
|
||||||
|
and value >= 0
|
||||||
|
):
|
||||||
|
sanitized_usage[key] = value
|
||||||
|
fields["usage"] = sanitized_usage
|
||||||
|
return fields
|
||||||
|
|
||||||
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
||||||
chats = self._conn_chats.get(connection)
|
chats = self._conn_chats.get(connection)
|
||||||
@@ -1564,6 +1580,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
model_name=event.model,
|
model_name=event.model,
|
||||||
model_preset=event.model_preset,
|
model_preset=event.model_preset,
|
||||||
|
context_window_tokens=event.context_window_tokens,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, GoalStateSyncEvent):
|
if isinstance(event, GoalStateSyncEvent):
|
||||||
@@ -1608,6 +1625,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
latency_ms=event.latency_ms,
|
latency_ms=event.latency_ms,
|
||||||
goal_state=event.goal_state,
|
goal_state=event.goal_state,
|
||||||
|
usage=event.usage,
|
||||||
|
context_window_tokens=event.context_window_tokens,
|
||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
||||||
)
|
)
|
||||||
@@ -1822,16 +1841,25 @@ class WebSocketChannel(BaseChannel):
|
|||||||
latency_ms: int | None = None,
|
latency_ms: int | None = None,
|
||||||
*,
|
*,
|
||||||
goal_state: dict[str, Any] | None = None,
|
goal_state: dict[str, Any] | None = None,
|
||||||
|
usage: dict[str, int] | None = None,
|
||||||
|
context_window_tokens: int | None = None,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
turn_owner: str | None = None,
|
turn_owner: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Signal that the agent has fully finished processing the current turn."""
|
"""Signal that the agent has fully finished processing the current turn."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
|
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
|
||||||
|
turn_id = (metadata or {}).get(WEBUI_TURN_METADATA_KEY)
|
||||||
|
if isinstance(turn_id, str) and turn_id:
|
||||||
|
body["turn_id"] = turn_id
|
||||||
if latency_ms is not None:
|
if latency_ms is not None:
|
||||||
body["latency_ms"] = int(latency_ms)
|
body["latency_ms"] = int(latency_ms)
|
||||||
if goal_state is not None:
|
if goal_state is not None:
|
||||||
body["goal_state"] = goal_state
|
body["goal_state"] = goal_state
|
||||||
|
if usage:
|
||||||
|
body["usage"] = usage
|
||||||
|
if context_window_tokens is not None:
|
||||||
|
body["context_window_tokens"] = int(context_window_tokens)
|
||||||
canonical_webui_turn = (metadata or {}).get("webui") is True
|
canonical_webui_turn = (metadata or {}).get("webui") is True
|
||||||
prior_persistence_failure = (
|
prior_persistence_failure = (
|
||||||
canonical_webui_turn
|
canonical_webui_turn
|
||||||
@@ -1932,6 +1960,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
*,
|
*,
|
||||||
model_name: Any,
|
model_name: Any,
|
||||||
model_preset: Any = None,
|
model_preset: Any = None,
|
||||||
|
context_window_tokens: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Notify one chat's subscribers which model is handling its current request."""
|
"""Notify one chat's subscribers which model is handling its current request."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
@@ -1948,6 +1977,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
if isinstance(model_preset, str) and model_preset.strip():
|
if isinstance(model_preset, str) and model_preset.strip():
|
||||||
body["model_preset"] = model_preset.strip()
|
body["model_preset"] = model_preset.strip()
|
||||||
|
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
|
||||||
|
body["context_window_tokens"] = context_window_tokens
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
||||||
|
|||||||
@@ -1926,6 +1926,7 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
|||||||
event=TurnModelUpdatedEvent(
|
event=TurnModelUpdatedEvent(
|
||||||
model="deepseek/deepseek-chat",
|
model="deepseek/deepseek-chat",
|
||||||
model_preset="Deep Research",
|
model_preset="Deep Research",
|
||||||
|
context_window_tokens=128_000,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1936,10 +1937,37 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
|||||||
"chat_id": "chat-1",
|
"chat_id": "chat-1",
|
||||||
"model_name": "deepseek/deepseek-chat",
|
"model_name": "deepseek/deepseek-chat",
|
||||||
"model_preset": "Deep Research",
|
"model_preset": "Deep Research",
|
||||||
|
"context_window_tokens": 128_000,
|
||||||
}
|
}
|
||||||
chat_two.send.assert_not_awaited()
|
chat_two.send.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_fields_restore_the_session_model_and_latest_usage() -> None:
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.read_session_metadata.return_value = {
|
||||||
|
"metadata": {
|
||||||
|
SESSION_MODEL_PRESET_METADATA_KEY: "Deep Research",
|
||||||
|
"_last_usage": {
|
||||||
|
"prompt_tokens": 120,
|
||||||
|
"completion_tokens": 8,
|
||||||
|
"negative": -1,
|
||||||
|
"boolean": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(bus, session_manager=manager),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert channel._attached_model_fields("chat-1") == {
|
||||||
|
"model_preset": "Deep Research",
|
||||||
|
"usage": {"prompt_tokens": 120, "completion_tokens": 8},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
@@ -2938,11 +2966,21 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
|
|||||||
channel="websocket",
|
channel="websocket",
|
||||||
chat_id="chat-1",
|
chat_id="chat-1",
|
||||||
content="",
|
content="",
|
||||||
event=TurnEndEvent(latency_ms=1500),
|
event=TurnEndEvent(
|
||||||
|
latency_ms=1500,
|
||||||
|
usage={"prompt_tokens": 80, "completion_tokens": 20, "cached_tokens": 40},
|
||||||
|
context_window_tokens=128_000,
|
||||||
|
),
|
||||||
))
|
))
|
||||||
|
|
||||||
assert _sent_ws_payloads(mock_ws) == [
|
assert _sent_ws_payloads(mock_ws) == [
|
||||||
{"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500},
|
{
|
||||||
|
"event": "turn_end",
|
||||||
|
"chat_id": "chat-1",
|
||||||
|
"latency_ms": 1500,
|
||||||
|
"usage": {"prompt_tokens": 80, "completion_tokens": 20, "cached_tokens": 40},
|
||||||
|
"context_window_tokens": 128_000,
|
||||||
|
},
|
||||||
{"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
|
{"event": "session_updated", "chat_id": "chat-1", "scope": "thread"},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4787,6 +4825,7 @@ async def test_handle_session_context_get_reads_detached_session() -> None:
|
|||||||
session = Session(
|
session = Session(
|
||||||
key="websocket:context-route",
|
key="websocket:context-route",
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
messages=[{"role": "user", "content": "hello"}],
|
||||||
|
metadata={"_last_usage": {"prompt_tokens": 12, "completion_tokens": 3}},
|
||||||
)
|
)
|
||||||
manager = MagicMock()
|
manager = MagicMock()
|
||||||
manager.read_session_snapshot.return_value = session
|
manager.read_session_snapshot.return_value = session
|
||||||
@@ -4801,7 +4840,9 @@ async def test_handle_session_context_get_reads_detached_session() -> None:
|
|||||||
response = await gateway.http._handle_session_context_get(request, encoded)
|
response = await gateway.http._handle_session_context_get(request, encoded)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert json.loads(response.body.decode())["replay_messages"] == 1
|
body = json.loads(response.body.decode())
|
||||||
|
assert body["replay_messages"] == 1
|
||||||
|
assert body["last_usage"] == {"prompt_tokens": 12, "completion_tokens": 3}
|
||||||
manager.read_session_snapshot.assert_called_once_with(session.key)
|
manager.read_session_snapshot.assert_called_once_with(session.key)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -559,6 +559,7 @@ class WebuiTurnCoordinator:
|
|||||||
event=TurnModelUpdatedEvent(
|
event=TurnModelUpdatedEvent(
|
||||||
model=event.runtime.model,
|
model=event.runtime.model,
|
||||||
model_preset=event.runtime.model_preset,
|
model_preset=event.runtime.model_preset,
|
||||||
|
context_window_tokens=event.runtime.context_window_tokens,
|
||||||
),
|
),
|
||||||
metadata=event.context.metadata,
|
metadata=event.context.metadata,
|
||||||
)
|
)
|
||||||
@@ -572,6 +573,10 @@ class WebuiTurnCoordinator:
|
|||||||
msg,
|
msg,
|
||||||
session_key=event.context.session_key,
|
session_key=event.context.session_key,
|
||||||
latency_ms=event.latency_ms,
|
latency_ms=event.latency_ms,
|
||||||
|
usage=event.usage,
|
||||||
|
context_window_tokens=(
|
||||||
|
event.runtime.context_window_tokens if event.runtime is not None else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self._schedule_title_update_from_event(event)
|
self._schedule_title_update_from_event(event)
|
||||||
|
|
||||||
@@ -619,6 +624,8 @@ class WebuiTurnCoordinator:
|
|||||||
*,
|
*,
|
||||||
session_key: str,
|
session_key: str,
|
||||||
latency_ms: int | None,
|
latency_ms: int | None,
|
||||||
|
usage: dict[str, int] | None = None,
|
||||||
|
context_window_tokens: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if msg.channel != "websocket":
|
if msg.channel != "websocket":
|
||||||
return
|
return
|
||||||
@@ -631,6 +638,8 @@ class WebuiTurnCoordinator:
|
|||||||
event=TurnEndEvent(
|
event=TurnEndEvent(
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
goal_state=goal_state_ws_blob(session.metadata),
|
goal_state=goal_state_ws_blob(session.metadata),
|
||||||
|
usage=usage or None,
|
||||||
|
context_window_tokens=context_window_tokens,
|
||||||
),
|
),
|
||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ def session_context_payload(session: Session) -> dict[str, Any]:
|
|||||||
summary_tokens = (
|
summary_tokens = (
|
||||||
estimate_message_tokens({"role": "system", "content": summary}) if summary else 0
|
estimate_message_tokens({"role": "system", "content": summary}) if summary else 0
|
||||||
)
|
)
|
||||||
|
raw_usage = session.metadata.get("_last_usage")
|
||||||
|
last_usage = (
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in cast(dict[object, object], raw_usage).items()
|
||||||
|
if isinstance(key, str)
|
||||||
|
and type(value) is int
|
||||||
|
and value >= 0
|
||||||
|
}
|
||||||
|
if isinstance(raw_usage, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
@@ -48,4 +60,5 @@ def session_context_payload(session: Session) -> dict[str, Any]:
|
|||||||
"estimated_session_tokens": replay_tokens + summary_tokens,
|
"estimated_session_tokens": replay_tokens + summary_tokens,
|
||||||
"archived_summary": summary_preview or None,
|
"archived_summary": summary_preview or None,
|
||||||
"archived_summary_at": summary_at,
|
"archived_summary_at": summary_at,
|
||||||
|
"last_usage": last_usage,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1819,6 +1819,33 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_turn_usage_is_persisted_with_the_saved_session(tmp_path: Path) -> None:
|
||||||
|
loop = _make_full_loop(tmp_path)
|
||||||
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||||
|
|
||||||
|
async def fake_run_agent_loop(initial_messages, **_kwargs):
|
||||||
|
loop._last_usage = {"prompt_tokens": 64, "completion_tokens": 9}
|
||||||
|
return (
|
||||||
|
"done",
|
||||||
|
[],
|
||||||
|
[*initial_messages, {"role": "assistant", "content": "done"}],
|
||||||
|
"stop",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
|
||||||
|
await loop._process_message(
|
||||||
|
InboundMessage(channel="cli", sender_id="user", chat_id="usage", content="hello")
|
||||||
|
)
|
||||||
|
|
||||||
|
loop.sessions.invalidate("cli:usage")
|
||||||
|
assert loop.sessions.get_or_create("cli:usage").metadata["_last_usage"] == {
|
||||||
|
"prompt_tokens": 64,
|
||||||
|
"completion_tokens": 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_system_subagent_followup_does_not_log_content(tmp_path: Path) -> None:
|
async def test_system_subagent_followup_does_not_log_content(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ async def test_runtime_event_publisher_consumes_turn_metadata_on_complete() -> N
|
|||||||
bus.subscribe(seen.append)
|
bus.subscribe(seen.append)
|
||||||
publisher.record_turn_runtime("cli:direct", "runtime")
|
publisher.record_turn_runtime("cli:direct", "runtime")
|
||||||
publisher.record_turn_latency("cli:direct", 123)
|
publisher.record_turn_latency("cli:direct", 123)
|
||||||
|
publisher.record_turn_usage("cli:direct", {"prompt_tokens": 40, "completion_tokens": 2})
|
||||||
|
|
||||||
await publisher.turn_completed(
|
await publisher.turn_completed(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -119,9 +120,11 @@ async def test_runtime_event_publisher_consumes_turn_metadata_on_complete() -> N
|
|||||||
assert first.context.metadata == {"source": "test"}
|
assert first.context.metadata == {"source": "test"}
|
||||||
assert first.latency_ms == 123
|
assert first.latency_ms == 123
|
||||||
assert first.runtime == "runtime"
|
assert first.runtime == "runtime"
|
||||||
|
assert first.usage == {"prompt_tokens": 40, "completion_tokens": 2}
|
||||||
assert isinstance(second, TurnCompleted)
|
assert isinstance(second, TurnCompleted)
|
||||||
assert second.latency_ms is None
|
assert second.latency_ms is None
|
||||||
assert second.runtime is None
|
assert second.runtime is None
|
||||||
|
assert second.usage == {}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ def test_session_context_separates_archive_progress_from_replay() -> None:
|
|||||||
"estimated_session_tokens": replay_tokens + summary_tokens,
|
"estimated_session_tokens": replay_tokens + summary_tokens,
|
||||||
"archived_summary": "The archived conversation settled the old question.",
|
"archived_summary": "The archived conversation settled the old question.",
|
||||||
"archived_summary_at": "2026-08-13T10:00:00Z",
|
"archived_summary_at": "2026-08-13T10:00:00Z",
|
||||||
|
"last_usage": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -54,3 +55,22 @@ def test_session_context_tolerates_untrusted_summary_metadata() -> None:
|
|||||||
|
|
||||||
assert payload["archived_summary"] is None
|
assert payload["archived_summary"] is None
|
||||||
assert payload["archived_summary_at"] is None
|
assert payload["archived_summary_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_context_sanitizes_usage_metadata() -> None:
|
||||||
|
session = Session(
|
||||||
|
key="websocket:context",
|
||||||
|
metadata={
|
||||||
|
"_last_usage": {
|
||||||
|
"prompt_tokens": 120,
|
||||||
|
"completion_tokens": 8,
|
||||||
|
"negative": -1,
|
||||||
|
"boolean": True,
|
||||||
|
"text": "invalid",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = session_context_payload(session)
|
||||||
|
|
||||||
|
assert payload["last_usage"] == {"prompt_tokens": 120, "completion_tokens": 8}
|
||||||
|
|||||||
@@ -20,12 +20,21 @@ composer; nanobot sends the original text unchanged.
|
|||||||
Type `/` to discover slash commands published by the connected gateway. Use the arrow keys
|
Type `/` to discover slash commands published by the connected gateway. Use the arrow keys
|
||||||
to move, `Tab` to complete, and `Esc` to close the menu.
|
to move, `Tab` to complete, and `Esc` to close the menu.
|
||||||
|
|
||||||
|
Type `@` to complete installed CLI apps, configured MCP servers, or saved sessions through the
|
||||||
|
same gateway metadata used by the WebUI. While nanobot is working, `Enter` queues a follow-up;
|
||||||
|
press `Enter` again on an empty composer to steer the current turn with the newest queued prompt.
|
||||||
|
Unsent prompts return to the composer if the turn stops or fails.
|
||||||
|
|
||||||
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
|
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
|
||||||
`/new-chat` preserves the current conversation and starts another one; nanobot's existing `/new`
|
`/new-chat` preserves the current conversation and starts another one; nanobot's existing `/new`
|
||||||
command keeps its cross-channel behavior and resets the current chat. The next launch returns to
|
command keeps its cross-channel behavior and resets the current chat. The next launch returns to
|
||||||
the last session unless `--session` selects another one. When earlier transcript pages exist,
|
the last session unless `--session` selects another one. When earlier transcript pages exist,
|
||||||
press `PageUp` at the top to load them in place.
|
press `PageUp` at the top to load them in place.
|
||||||
|
|
||||||
|
`/branch` creates a new saved conversation from a completed reply without changing the source
|
||||||
|
session. The picker uses durable history indices, so paginated transcripts branch at the selected
|
||||||
|
turn rather than the currently visible row.
|
||||||
|
|
||||||
`/context` explains the session-owned material available for the next agent turn: the compacted
|
`/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
|
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,
|
private reasoning and does not pretend to be the complete model prompt; workspace instructions,
|
||||||
@@ -34,3 +43,5 @@ memory, and skills are assembled separately by the Python runtime.
|
|||||||
`/diff` opens the latest turn's file changes in a full-screen unified diff. Use `Left`/`Right`
|
`/diff` opens the latest turn's file changes in a full-screen unified diff. Use `Left`/`Right`
|
||||||
to switch edits, `PageUp`/`PageDown` or `Home`/`End` to navigate, and `Esc` to return to chat.
|
to switch edits, `PageUp`/`PageDown` or `Home`/`End` to navigate, and `Esc` to return to chat.
|
||||||
The gateway remains the source of the patch; the TUI never rereads workspace files to rebuild it.
|
The gateway remains the source of the patch; the TUI never rereads workspace files to rebuild it.
|
||||||
|
The footer reports provider token/cache usage when available, and tool activity uses compact,
|
||||||
|
tool-specific summaries while retaining the full event history behind `Ctrl+O`.
|
||||||
|
|||||||
+86
-7
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@opentui/core/testing"
|
} from "@opentui/core/testing"
|
||||||
|
|
||||||
import { NanobotTui, type AppOptions } from "./app"
|
import { NanobotTui, type AppOptions } from "./app"
|
||||||
import type { SlashCommand } from "./protocol"
|
import type { MessageOptions, SlashCommand } from "./protocol"
|
||||||
|
|
||||||
const options: AppOptions = {
|
const options: AppOptions = {
|
||||||
wsUrl: "ws://localhost.invalid/ws",
|
wsUrl: "ws://localhost.invalid/ws",
|
||||||
@@ -55,13 +55,20 @@ async function waitUntil(predicate: () => boolean, timeout = 1_000): Promise<voi
|
|||||||
if (!predicate()) throw new Error(`condition was not met within ${timeout}ms`)
|
if (!predicate()) throw new Error(`condition was not met within ${timeout}ms`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function client(sent: string[] = [], attached: string[] = [], newChats: string[] = []) {
|
function client(
|
||||||
|
sent: string[] = [],
|
||||||
|
attached: string[] = [],
|
||||||
|
newChats: string[] = [],
|
||||||
|
sentOptions: MessageOptions[] = [],
|
||||||
|
forks: Array<{ source: string; before: number; title?: string }> = [],
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
activeChatId: "chat",
|
activeChatId: "chat",
|
||||||
connect() {},
|
connect() {},
|
||||||
close() {},
|
close() {},
|
||||||
send(content: string) {
|
send(content: string, options: MessageOptions = {}) {
|
||||||
sent.push(content)
|
sent.push(content)
|
||||||
|
sentOptions.push(options)
|
||||||
return "turn"
|
return "turn"
|
||||||
},
|
},
|
||||||
attach(chatId: string) {
|
attach(chatId: string) {
|
||||||
@@ -70,6 +77,9 @@ function client(sent: string[] = [], attached: string[] = [], newChats: string[]
|
|||||||
newChat() {
|
newChat() {
|
||||||
newChats.push("new")
|
newChats.push("new")
|
||||||
},
|
},
|
||||||
|
forkChat(source: string, before: number, title?: string) {
|
||||||
|
forks.push({ source, before, ...(title ? { title } : {}) })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,8 +145,8 @@ describe("NanobotTui layout", () => {
|
|||||||
|
|
||||||
expect(occurrences(frame, "First **answer**.")).toBe(1)
|
expect(occurrences(frame, "First **answer**.")).toBe(1)
|
||||||
expect(occurrences(frame, "Second answer.")).toBe(1)
|
expect(occurrences(frame, "Second answer.")).toBe(1)
|
||||||
expect(frame).toContain("✓ read_file")
|
expect(frame).toContain("✓ Read config.json")
|
||||||
expect(frame).not.toContain("› read_file")
|
expect(frame).not.toContain("› Read")
|
||||||
expect(frame).not.toContain("private chain of thought")
|
expect(frame).not.toContain("private chain of thought")
|
||||||
expect(frame).toContain("Ready · 1.2s")
|
expect(frame).toContain("Ready · 1.2s")
|
||||||
})
|
})
|
||||||
@@ -204,6 +214,68 @@ describe("NanobotTui layout", () => {
|
|||||||
expect(ui.composer.plainText).toBe("")
|
expect(ui.composer.plainText).toBe("")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("queues follow-ups and promotes the armed prompt to steering", async () => {
|
||||||
|
const sent: string[] = []
|
||||||
|
const sentOptions: MessageOptions[] = []
|
||||||
|
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
|
||||||
|
const app = NanobotTui.mount(
|
||||||
|
setup.renderer,
|
||||||
|
options,
|
||||||
|
client(sent, [], [], sentOptions),
|
||||||
|
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||||
|
)
|
||||||
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
|
const ui = app as unknown as {
|
||||||
|
ready: boolean
|
||||||
|
composer: TextareaRenderable
|
||||||
|
mentionCandidates: Array<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
await waitUntil(() => ui.ready)
|
||||||
|
ui.mentionCandidates = [{
|
||||||
|
kind: "cli",
|
||||||
|
name: "github",
|
||||||
|
displayName: "GitHub",
|
||||||
|
description: "CLI",
|
||||||
|
}]
|
||||||
|
|
||||||
|
ui.composer.setText("first")
|
||||||
|
ui.composer.submit()
|
||||||
|
await waitUntil(() => sent.length === 1)
|
||||||
|
|
||||||
|
ui.composer.setText("ask @github next")
|
||||||
|
ui.composer.submit()
|
||||||
|
await waitUntil(() => ui.composer.plainText === "")
|
||||||
|
expect(sent).toEqual(["first"])
|
||||||
|
|
||||||
|
ui.composer.submit()
|
||||||
|
await waitUntil(() => sent.length === 2)
|
||||||
|
expect(sentOptions[1]).toEqual({
|
||||||
|
cliApps: [{ name: "github" }],
|
||||||
|
mcpPresets: [],
|
||||||
|
sessionMentions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
ui.composer.setText("after this turn")
|
||||||
|
ui.composer.submit()
|
||||||
|
await waitUntil(() => ui.composer.plainText === "")
|
||||||
|
app.accept({
|
||||||
|
event: "error",
|
||||||
|
chat_id: "chat",
|
||||||
|
turn_id: "failed-steering",
|
||||||
|
reason: "steering rejected",
|
||||||
|
})
|
||||||
|
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue()
|
||||||
|
|
||||||
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
|
await waitUntil(() => ui.ready)
|
||||||
|
app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" })
|
||||||
|
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn" })
|
||||||
|
await waitUntil(() => sent.length === 3)
|
||||||
|
expect(sent[2]).toBe("after this turn")
|
||||||
|
app.accept({ event: "goal_status", chat_id: "chat", status: "idle", turn_id: "prior" })
|
||||||
|
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
test("recalls submitted prompts without stealing multiline cursor movement", async () => {
|
test("recalls submitted prompts without stealing multiline cursor movement", async () => {
|
||||||
const sent: string[] = []
|
const sent: string[] = []
|
||||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||||
@@ -991,11 +1063,18 @@ describe("NanobotTui layout", () => {
|
|||||||
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
app.accept({ event: "attached", chat_id: "chat" })
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
app.accept({ event: "turn_end", chat_id: "chat", latency_ms: 1700 })
|
app.accept({
|
||||||
|
event: "turn_end",
|
||||||
|
chat_id: "chat",
|
||||||
|
latency_ms: 1700,
|
||||||
|
usage: { prompt_tokens: 1200, completion_tokens: 80, cached_tokens: 900 },
|
||||||
|
context_window_tokens: 128_000,
|
||||||
|
})
|
||||||
await setup.flush()
|
await setup.flush()
|
||||||
|
|
||||||
const footer = setup.captureCharFrame().split("\n").find((line) => line.includes("Ready · 1.7s")) || ""
|
const footer = setup.captureCharFrame().split("\n").find((line) => line.includes("Ready · 1.7s")) || ""
|
||||||
expect(footer).toContain("Ready · 1.7s")
|
expect(footer).toContain("Ready · 1.7s")
|
||||||
|
expect(footer).toContain("↑1.2k ↓80")
|
||||||
expect(footer).toContain("enter send")
|
expect(footer).toContain("enter send")
|
||||||
expect(footer).not.toContain("1.7senter")
|
expect(footer).not.toContain("1.7senter")
|
||||||
|
|
||||||
@@ -1205,7 +1284,7 @@ describe("NanobotTui layout", () => {
|
|||||||
|
|
||||||
expect(frame).toMatch(/Working\s+0s/u)
|
expect(frame).toMatch(/Working\s+0s/u)
|
||||||
expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u)
|
expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u)
|
||||||
expect(frame).toContain("› exec")
|
expect(frame).toContain("› Command pwd")
|
||||||
app.accept({ event: "turn_end", chat_id: "chat" })
|
app.accept({ event: "turn_end", chat_id: "chat" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+342
-21
@@ -21,6 +21,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
NanobotClient,
|
NanobotClient,
|
||||||
fetchHistory,
|
fetchHistory,
|
||||||
|
fetchMentionCandidates,
|
||||||
fetchSessionContext,
|
fetchSessionContext,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
fetchSlashCommands,
|
fetchSlashCommands,
|
||||||
@@ -28,8 +29,11 @@ import {
|
|||||||
type FileEditEvent,
|
type FileEditEvent,
|
||||||
type HistoryMessage,
|
type HistoryMessage,
|
||||||
type InboundEvent,
|
type InboundEvent,
|
||||||
|
type MentionCandidate,
|
||||||
|
type MessageOptions,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
|
type TokenUsage,
|
||||||
} from "./protocol"
|
} from "./protocol"
|
||||||
import {
|
import {
|
||||||
CommandMenu,
|
CommandMenu,
|
||||||
@@ -53,6 +57,15 @@ import {
|
|||||||
} from "./transcript"
|
} from "./transcript"
|
||||||
import { rememberChat } from "./session-state"
|
import { rememberChat } from "./session-state"
|
||||||
import { ComposerDraft } from "./composer-draft"
|
import { ComposerDraft } from "./composer-draft"
|
||||||
|
import { BranchMenu, branchPoints } from "./branch-menu"
|
||||||
|
import {
|
||||||
|
MentionMenu,
|
||||||
|
insertMention,
|
||||||
|
mentionOptions,
|
||||||
|
mentionQuery,
|
||||||
|
type MentionQuery,
|
||||||
|
} from "./mention-menu"
|
||||||
|
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
|
||||||
|
|
||||||
interface AppOptions {
|
interface AppOptions {
|
||||||
wsUrl: string
|
wsUrl: string
|
||||||
@@ -72,9 +85,10 @@ interface ChatClient {
|
|||||||
readonly activeChatId: string
|
readonly activeChatId: string
|
||||||
connect(): void
|
connect(): void
|
||||||
close(): void
|
close(): void
|
||||||
send(content: string): string
|
send(content: string, options?: MessageOptions): string
|
||||||
attach(chatId: string): void
|
attach(chatId: string): void
|
||||||
newChat(): void
|
newChat(): void
|
||||||
|
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Palette {
|
interface Palette {
|
||||||
@@ -156,6 +170,12 @@ const LOCAL_COMMANDS: TuiCommand[] = [
|
|||||||
description: "Inspect file changes from the latest turn",
|
description: "Inspect file changes from the latest turn",
|
||||||
action: "diff",
|
action: "diff",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
command: "/branch",
|
||||||
|
title: "Branch from reply",
|
||||||
|
description: "Continue from an earlier completed reply",
|
||||||
|
action: "branch",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function syntaxStyle(palette: Palette): SyntaxStyle {
|
function syntaxStyle(palette: Palette): SyntaxStyle {
|
||||||
@@ -265,6 +285,22 @@ function formatElapsed(milliseconds: number): string {
|
|||||||
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
|
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usageStatus(usage: TokenUsage | null): string {
|
||||||
|
if (!usage) return ""
|
||||||
|
const prompt = usage.prompt_tokens
|
||||||
|
const completion = usage.completion_tokens
|
||||||
|
const tokens = typeof prompt === "number" || typeof completion === "number"
|
||||||
|
? `↑${formatTokenCount(prompt || 0)} ↓${formatTokenCount(completion || 0)}`
|
||||||
|
: typeof usage.total_tokens === "number" ? `${formatTokenCount(usage.total_tokens)} tok` : ""
|
||||||
|
const cached = typeof usage.cached_tokens === "number" && usage.cached_tokens > 0
|
||||||
|
? `${formatTokenCount(usage.cached_tokens)} cached`
|
||||||
|
: ""
|
||||||
|
const cost = typeof usage.cost_usd === "number" && usage.cost_usd > 0
|
||||||
|
? `$${usage.cost_usd < 0.01 ? usage.cost_usd.toFixed(4) : usage.cost_usd.toFixed(2)}`
|
||||||
|
: ""
|
||||||
|
return [tokens, cached, cost].filter(Boolean).join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
async function copyWithSystemClipboard(text: string): Promise<void> {
|
async function copyWithSystemClipboard(text: string): Promise<void> {
|
||||||
const commands = process.platform === "darwin"
|
const commands = process.platform === "darwin"
|
||||||
? [["pbcopy"]]
|
? [["pbcopy"]]
|
||||||
@@ -289,6 +325,8 @@ 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 mentionMenu: MentionMenu
|
||||||
|
private readonly branchMenu: BranchMenu
|
||||||
private readonly contextPanel: ContextPanel
|
private readonly contextPanel: ContextPanel
|
||||||
private readonly diffViewer: DiffViewer
|
private readonly diffViewer: DiffViewer
|
||||||
private readonly client: ChatClient
|
private readonly client: ChatClient
|
||||||
@@ -301,10 +339,12 @@ export class NanobotTui {
|
|||||||
private readonly status: TextRenderable
|
private readonly status: TextRenderable
|
||||||
private readonly meta: TextRenderable
|
private readonly meta: TextRenderable
|
||||||
private readonly draft = new ComposerDraft()
|
private readonly draft = new ComposerDraft()
|
||||||
|
private readonly promptQueue = new PromptQueue()
|
||||||
private palette: Palette
|
private palette: Palette
|
||||||
private activeThemeMode: ThemeMode
|
private activeThemeMode: ThemeMode
|
||||||
private backgroundKnown: boolean
|
private backgroundKnown: boolean
|
||||||
private activeTurn = false
|
private activeTurn = false
|
||||||
|
private activeTurnId: string | null = null
|
||||||
private activeLabel = "Thinking"
|
private activeLabel = "Thinking"
|
||||||
private activeStartedAt = 0
|
private activeStartedAt = 0
|
||||||
private lastProgress = ""
|
private lastProgress = ""
|
||||||
@@ -333,6 +373,11 @@ export class NanobotTui {
|
|||||||
private sessionTitle = ""
|
private sessionTitle = ""
|
||||||
private sessionMetadataId = 0
|
private sessionMetadataId = 0
|
||||||
private contextTokens: number | null = null
|
private contextTokens: number | null = null
|
||||||
|
private contextWindowTokens: number | null = null
|
||||||
|
private lastUsage: TokenUsage | null = null
|
||||||
|
private readyDetail = ""
|
||||||
|
private mentionCandidates: MentionCandidate[] = []
|
||||||
|
private activeMentionQuery: MentionQuery | null = null
|
||||||
private transcriptNavigation: TranscriptNavigation = {
|
private transcriptNavigation: TranscriptNavigation = {
|
||||||
awayFromBottom: false,
|
awayFromBottom: false,
|
||||||
unseenOutput: false,
|
unseenOutput: false,
|
||||||
@@ -369,6 +414,8 @@ 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.mentionMenu = new MentionMenu(renderer, commandMenuTheme(this.palette))
|
||||||
|
this.branchMenu = new BranchMenu(renderer, commandMenuTheme(this.palette))
|
||||||
this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette))
|
this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette))
|
||||||
this.diffViewer = new DiffViewer(
|
this.diffViewer = new DiffViewer(
|
||||||
renderer,
|
renderer,
|
||||||
@@ -455,7 +502,8 @@ export class NanobotTui {
|
|||||||
if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
|
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 if (this.branchMenu.visible) this.syncBranchMenu()
|
||||||
|
else this.syncComposerMenus()
|
||||||
this.resizeComposer()
|
this.resizeComposer()
|
||||||
},
|
},
|
||||||
// IMEs may commit their final composed glyph after Enter. Matching the
|
// IMEs may commit their final composed glyph after Enter. Matching the
|
||||||
@@ -497,6 +545,8 @@ 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.mentionMenu.root)
|
||||||
|
this.shell.add(this.branchMenu.root)
|
||||||
this.shell.add(this.contextPanel.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)
|
||||||
@@ -547,6 +597,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
this.client.connect()
|
this.client.connect()
|
||||||
void this.loadCommands()
|
void this.loadCommands()
|
||||||
|
void this.loadMentions()
|
||||||
this.renderer.start()
|
this.renderer.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,7 +630,23 @@ export class NanobotTui {
|
|||||||
if (session) this.switchSession(session)
|
if (session) this.switchSession(session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!visibleContent) return
|
if (this.branchMenu.visible) {
|
||||||
|
const point = this.branchMenu.choose()
|
||||||
|
if (point) this.createBranch(point.beforeUserIndex, point.preview)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.mentionMenu.visible && this.activeMentionQuery) {
|
||||||
|
const candidate = this.mentionMenu.choose()
|
||||||
|
if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!visibleContent) {
|
||||||
|
if (this.activeTurn) {
|
||||||
|
const steering = this.promptQueue.takeSteering()
|
||||||
|
if (steering) this.sendPrompt(steering, true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const completion = this.commandMenu.completion(visibleContent)
|
const completion = this.commandMenu.completion(visibleContent)
|
||||||
if (completion) {
|
if (completion) {
|
||||||
this.setComposer(completion)
|
this.setComposer(completion)
|
||||||
@@ -592,6 +659,7 @@ export class NanobotTui {
|
|||||||
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 if (command.command.action === "context") void this.openContext()
|
||||||
else if (command.command.action === "diff") this.openDiff()
|
else if (command.command.action === "diff") this.openDiff()
|
||||||
|
else if (command.command.action === "branch") void this.openBranch()
|
||||||
else this.startNewChat()
|
else this.startNewChat()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -608,31 +676,53 @@ export class NanobotTui {
|
|||||||
this.quit()
|
this.quit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
|
||||||
if (this.activeTurn) {
|
if (this.activeTurn) {
|
||||||
this.status.content = "A turn is already running · Ctrl+C to stop"
|
this.promptQueue.enqueue(prompt)
|
||||||
|
this.clearComposer()
|
||||||
|
this.commandMenu.hide()
|
||||||
|
this.mentionMenu.hide()
|
||||||
|
this.recordPrompt(content)
|
||||||
|
this.renderActiveStatus()
|
||||||
|
this.updateMeta()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
this.sendPrompt(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendPrompt(prompt: QueuedPrompt, steering = false): boolean {
|
||||||
|
let turnId: string
|
||||||
try {
|
try {
|
||||||
this.client.send(content)
|
turnId = this.client.send(prompt.content, prompt.options)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.status.content = error instanceof Error ? error.message : String(error)
|
this.status.content = error instanceof Error ? error.message : String(error)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.recordPrompt(content)
|
this.mentionMenu.hide()
|
||||||
this.transcript.user(content)
|
this.recordPrompt(prompt.content)
|
||||||
|
this.transcript.user(prompt.content)
|
||||||
|
if (steering) {
|
||||||
|
this.status.content = `Steering current turn${this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""}`
|
||||||
|
this.updateMeta()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
this.activeTurnId = turnId
|
||||||
|
this.readyDetail = ""
|
||||||
this.finalMessage = ""
|
this.finalMessage = ""
|
||||||
this.turnHadAnswer = false
|
this.turnHadAnswer = false
|
||||||
this.lastProgress = ""
|
this.lastProgress = ""
|
||||||
this.activeLabel = "Thinking"
|
this.activeLabel = "Thinking"
|
||||||
this.currentFileEdits = []
|
this.currentFileEdits = []
|
||||||
this.setActive(true)
|
this.setActive(true)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
accept(event: InboundEvent): void {
|
accept(event: InboundEvent): void {
|
||||||
if (event.event === "attached") {
|
if (event.event === "attached") {
|
||||||
void rememberChat(this.options.statePath, event.chat_id)
|
void rememberChat(this.options.statePath, event.chat_id)
|
||||||
|
if (event.usage) this.lastUsage = event.usage
|
||||||
if (event.model_preset !== undefined) {
|
if (event.model_preset !== undefined) {
|
||||||
this.applyModelPreset(event.model_preset)
|
this.applyModelPreset(event.model_preset)
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
@@ -641,7 +731,10 @@ export class NanobotTui {
|
|||||||
this.modelCommandTurns.clear()
|
this.modelCommandTurns.clear()
|
||||||
const restoring = this.attachedOnce
|
const restoring = this.attachedOnce
|
||||||
this.attachedOnce = true
|
this.attachedOnce = true
|
||||||
if (restoring) this.setActive(false)
|
if (restoring) {
|
||||||
|
this.activeTurnId = null
|
||||||
|
this.setActive(false)
|
||||||
|
}
|
||||||
const queuesEvents = restoring || (!this.historyLoaded && Boolean(this.options.chatId))
|
const queuesEvents = restoring || (!this.historyLoaded && Boolean(this.options.chatId))
|
||||||
if (queuesEvents) {
|
if (queuesEvents) {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
@@ -716,6 +809,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
case "turn_end":
|
case "turn_end":
|
||||||
|
if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) return
|
||||||
if (event.turn_id) {
|
if (event.turn_id) {
|
||||||
this.commandTurns.delete(event.turn_id)
|
this.commandTurns.delete(event.turn_id)
|
||||||
this.modelCommandTurns.delete(event.turn_id)
|
this.modelCommandTurns.delete(event.turn_id)
|
||||||
@@ -727,14 +821,24 @@ export class NanobotTui {
|
|||||||
if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
|
if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
|
||||||
this.finalMessage = ""
|
this.finalMessage = ""
|
||||||
this.turnHadAnswer = false
|
this.turnHadAnswer = false
|
||||||
this.setActive(false)
|
this.activeTurnId = null
|
||||||
if (typeof event.latency_ms === "number") {
|
if (event.usage) this.lastUsage = event.usage
|
||||||
this.status.content = this.readyStatus(`${(event.latency_ms / 1000).toFixed(1)}s`)
|
if (typeof event.context_window_tokens === "number") {
|
||||||
|
this.contextWindowTokens = event.context_window_tokens
|
||||||
}
|
}
|
||||||
|
this.updateTitle()
|
||||||
|
this.setActive(false)
|
||||||
|
this.readyDetail = typeof event.latency_ms === "number"
|
||||||
|
? `${(event.latency_ms / 1000).toFixed(1)}s`
|
||||||
|
: ""
|
||||||
|
this.status.content = this.readyStatus()
|
||||||
if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id)
|
if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id)
|
||||||
|
this.sendNextFollowUp()
|
||||||
return
|
return
|
||||||
case "goal_status":
|
case "goal_status":
|
||||||
|
if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) return
|
||||||
if (event.status === "running") {
|
if (event.status === "running") {
|
||||||
|
if (event.turn_id) this.activeTurnId = event.turn_id
|
||||||
this.activeLabel = "Working"
|
this.activeLabel = "Working"
|
||||||
this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined)
|
this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined)
|
||||||
} else {
|
} else {
|
||||||
@@ -744,6 +848,9 @@ export class NanobotTui {
|
|||||||
case "goal_state":
|
case "goal_state":
|
||||||
return
|
return
|
||||||
case "turn_model_updated":
|
case "turn_model_updated":
|
||||||
|
if (typeof event.context_window_tokens === "number") {
|
||||||
|
this.contextWindowTokens = event.context_window_tokens
|
||||||
|
}
|
||||||
this.setTurnModel(event.model_name, event.model_preset)
|
this.setTurnModel(event.model_name, event.model_preset)
|
||||||
return
|
return
|
||||||
case "runtime_model_updated":
|
case "runtime_model_updated":
|
||||||
@@ -765,6 +872,10 @@ export class NanobotTui {
|
|||||||
this.commandTurns.delete(event.turn_id)
|
this.commandTurns.delete(event.turn_id)
|
||||||
this.modelCommandTurns.delete(event.turn_id)
|
this.modelCommandTurns.delete(event.turn_id)
|
||||||
}
|
}
|
||||||
|
if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) {
|
||||||
|
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||||
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
|
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
|
||||||
if (!commandLifecycle || commandLifecycle === "agent_turn") {
|
if (!commandLifecycle || commandLifecycle === "agent_turn") {
|
||||||
@@ -774,6 +885,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
this.finalMessage = ""
|
this.finalMessage = ""
|
||||||
this.turnHadAnswer = false
|
this.turnHadAnswer = false
|
||||||
|
this.restoreQueuedPrompts()
|
||||||
this.setActive(false)
|
this.setActive(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -877,24 +989,41 @@ export class NanobotTui {
|
|||||||
? ` · ${this.lastProgress.replace(/^\s*[·›✓×]\s*/u, "")}`
|
? ` · ${this.lastProgress.replace(/^\s*[·›✓×]\s*/u, "")}`
|
||||||
: ""
|
: ""
|
||||||
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
|
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
|
||||||
|
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
|
||||||
this.status.content = shimmerStatus(
|
this.status.content = shimmerStatus(
|
||||||
this.activeLabel,
|
this.activeLabel,
|
||||||
` ${elapsed}${progress}${navigation}`,
|
` ${elapsed}${progress}${queued}${navigation}`,
|
||||||
this.shimmerFrame,
|
this.shimmerFrame,
|
||||||
this.palette,
|
this.palette,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private readyStatus(detail = ""): string {
|
private readyStatus(detail = this.readyDetail): string {
|
||||||
if (this.transcriptNavigation.awayFromBottom) {
|
if (this.transcriptNavigation.awayFromBottom) {
|
||||||
return this.transcriptNavigation.unseenOutput
|
return this.transcriptNavigation.unseenOutput
|
||||||
? "New output · Ctrl+End latest"
|
? "New output · Ctrl+End latest"
|
||||||
: "History · Ctrl+End latest"
|
: "History · Ctrl+End latest"
|
||||||
}
|
}
|
||||||
if (detail) return `Ready · ${detail}`
|
const usage = usageStatus(this.lastUsage)
|
||||||
|
const suffix = [detail, usage].filter(Boolean).join(" · ")
|
||||||
|
if (suffix) return `Ready · ${suffix}`
|
||||||
return this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready"
|
return this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sendNextFollowUp(): void {
|
||||||
|
if (!this.ready || this.activeTurn || this.quitting) return
|
||||||
|
const prompt = this.promptQueue.takeFollowUp()
|
||||||
|
if (!prompt) return
|
||||||
|
this.sendPrompt(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreQueuedPrompts(): void {
|
||||||
|
const queued = this.promptQueue.restore()
|
||||||
|
if (!queued.length) return
|
||||||
|
const current = this.draft.expand(this.composer.plainText).trim()
|
||||||
|
this.setComposer([current, ...queued.map((prompt) => prompt.content)].filter(Boolean).join("\n\n"))
|
||||||
|
}
|
||||||
|
|
||||||
private handleTranscriptNavigation(state: TranscriptNavigation): void {
|
private handleTranscriptNavigation(state: TranscriptNavigation): void {
|
||||||
this.transcriptNavigation = state
|
this.transcriptNavigation = state
|
||||||
if (this.activeTurn) this.renderActiveStatus()
|
if (this.activeTurn) this.renderActiveStatus()
|
||||||
@@ -940,6 +1069,38 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.branchMenu.visible) {
|
||||||
|
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
|
||||||
|
this.branchMenu.move(key.name === "up" ? -1 : 1)
|
||||||
|
key.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "escape") {
|
||||||
|
this.closeBranch()
|
||||||
|
key.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.mentionMenu.visible) {
|
||||||
|
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
|
||||||
|
this.mentionMenu.move(key.name === "up" ? -1 : 1)
|
||||||
|
key.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!key.ctrl && !key.meta && key.name === "tab" && this.activeMentionQuery) {
|
||||||
|
const candidate = this.mentionMenu.choose()
|
||||||
|
if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
|
||||||
|
key.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "escape") {
|
||||||
|
this.mentionMenu.hide()
|
||||||
|
this.activeMentionQuery = null
|
||||||
|
this.updateMeta()
|
||||||
|
key.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if (this.commandMenu.visible) {
|
if (this.commandMenu.visible) {
|
||||||
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
|
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
|
||||||
this.commandMenu.move(key.name === "up" ? -1 : 1)
|
this.commandMenu.move(key.name === "up" ? -1 : 1)
|
||||||
@@ -992,6 +1153,7 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.activeTurn) {
|
if (this.activeTurn) {
|
||||||
|
this.restoreQueuedPrompts()
|
||||||
try {
|
try {
|
||||||
this.client.send("/stop")
|
this.client.send("/stop")
|
||||||
this.status.content = "Stopping…"
|
this.status.content = "Stopping…"
|
||||||
@@ -1061,6 +1223,8 @@ export class NanobotTui {
|
|||||||
this.transcript.setTheme(transcriptTheme(this.palette, this.backgroundKnown))
|
this.transcript.setTheme(transcriptTheme(this.palette, this.backgroundKnown))
|
||||||
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.mentionMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
|
this.branchMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
||||||
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
|
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
|
||||||
this.updateComposerAppearance()
|
this.updateComposerAppearance()
|
||||||
@@ -1083,10 +1247,22 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateMeta(): void {
|
private updateMeta(): void {
|
||||||
|
if (this.mentionMenu.visible) {
|
||||||
|
this.meta.content = this.renderer.width >= 64
|
||||||
|
? "↑↓ choose · tab/enter insert · esc close"
|
||||||
|
: "enter insert · esc close"
|
||||||
|
return
|
||||||
|
}
|
||||||
if (this.activeTurn) {
|
if (this.activeTurn) {
|
||||||
this.meta.content = this.renderer.width >= 72 && this.transcriptNavigation.awayFromBottom
|
this.meta.content = this.renderer.width >= 96
|
||||||
? "ctrl+end latest · ctrl+c stop"
|
? "enter queue · enter again steer · ctrl+c stop"
|
||||||
: this.renderer.width >= 48 ? "ctrl+c stop" : ""
|
: this.renderer.width >= 64 ? "enter queue · ctrl+c stop" : ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.branchMenu.visible) {
|
||||||
|
this.meta.content = this.renderer.width >= 64
|
||||||
|
? "type to filter · ↑↓ choose · enter branch · esc close"
|
||||||
|
: "enter branch · esc close"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.commandMenu.visible) {
|
if (this.commandMenu.visible) {
|
||||||
@@ -1154,7 +1330,11 @@ export class NanobotTui {
|
|||||||
const identity = this.sessionTitle.trim() || "nanobot"
|
const identity = this.sessionTitle.trim() || "nanobot"
|
||||||
this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
|
this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
|
||||||
this.titleText.content = identity
|
this.titleText.content = identity
|
||||||
const context = this.contextTokens === null ? "" : ` · ~${formatTokenCount(this.contextTokens)} ctx`
|
const context = this.contextTokens === null
|
||||||
|
? ""
|
||||||
|
: ` · ~${formatTokenCount(this.contextTokens)}${this.contextWindowTokens
|
||||||
|
? `/${formatTokenCount(this.contextWindowTokens)}`
|
||||||
|
: ""} ctx`
|
||||||
const runtime = this.modelPreset !== "default"
|
const runtime = this.modelPreset !== "default"
|
||||||
? [this.modelPreset, this.modelName].filter(Boolean).join(" · ")
|
? [this.modelPreset, this.modelName].filter(Boolean).join(" · ")
|
||||||
: this.modelName
|
: this.modelName
|
||||||
@@ -1186,7 +1366,9 @@ export class NanobotTui {
|
|||||||
// prevents stale placeholder text in differential/embedded terminals.
|
// prevents stale placeholder text in differential/embedded terminals.
|
||||||
const placeholder = this.composer.plainText
|
const placeholder = this.composer.plainText
|
||||||
? null
|
? null
|
||||||
: this.sessionMenu.visible ? "Search sessions" : COMPOSER_PLACEHOLDER
|
: this.sessionMenu.visible
|
||||||
|
? "Search sessions"
|
||||||
|
: this.branchMenu.visible ? "Search branch points" : COMPOSER_PLACEHOLDER
|
||||||
if (this.composer.placeholder !== placeholder) this.composer.placeholder = placeholder
|
if (this.composer.placeholder !== placeholder) this.composer.placeholder = placeholder
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,12 +1378,43 @@ export class NanobotTui {
|
|||||||
this.updateMeta()
|
this.updateMeta()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncComposerMenus(): void {
|
||||||
|
this.activeMentionQuery = mentionQuery(this.composer.plainText, this.composer.cursorOffset)
|
||||||
|
const candidates = this.availableMentions()
|
||||||
|
if (this.activeMentionQuery && candidates.length) {
|
||||||
|
this.commandMenu.hide()
|
||||||
|
const limit = this.renderer.height >= 20 ? 7 : 4
|
||||||
|
if (this.mentionMenu.visible) this.mentionMenu.update(this.activeMentionQuery.query, limit)
|
||||||
|
else this.mentionMenu.show(candidates, this.activeMentionQuery.query, limit)
|
||||||
|
this.updateMeta()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.mentionMenu.hide()
|
||||||
|
this.syncCommandMenu()
|
||||||
|
}
|
||||||
|
|
||||||
private syncSessionMenu(): void {
|
private syncSessionMenu(): void {
|
||||||
const limit = this.renderer.height >= 20 ? 8 : 4
|
const limit = this.renderer.height >= 20 ? 8 : 4
|
||||||
this.sessionMenu.update(this.composer.plainText, limit)
|
this.sessionMenu.update(this.composer.plainText, limit)
|
||||||
this.updateMeta()
|
this.updateMeta()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncBranchMenu(): void {
|
||||||
|
const limit = this.renderer.height >= 20 ? 8 : 4
|
||||||
|
this.branchMenu.update(this.composer.plainText, limit)
|
||||||
|
this.updateMeta()
|
||||||
|
}
|
||||||
|
|
||||||
|
private chooseMention(candidate: MentionCandidate, query: MentionQuery): void {
|
||||||
|
const inserted = insertMention(this.composer.plainText, candidate, query)
|
||||||
|
this.composer.setText(inserted.value)
|
||||||
|
this.composer.cursorOffset = inserted.cursor
|
||||||
|
this.mentionMenu.hide()
|
||||||
|
this.activeMentionQuery = null
|
||||||
|
this.syncComposerPlaceholder()
|
||||||
|
this.updateMeta()
|
||||||
|
}
|
||||||
|
|
||||||
private setComposer(content: string): void {
|
private setComposer(content: string): void {
|
||||||
this.draft.clear()
|
this.draft.clear()
|
||||||
this.composer.setText(content)
|
this.composer.setText(content)
|
||||||
@@ -1239,12 +1452,102 @@ export class NanobotTui {
|
|||||||
this.syncCommandMenu()
|
this.syncCommandMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadMentions(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.mentionCandidates = await fetchMentionCandidates(
|
||||||
|
this.options.apiUrl,
|
||||||
|
this.options.apiToken,
|
||||||
|
)
|
||||||
|
if (this.activeMentionQuery) this.syncComposerMenus()
|
||||||
|
} catch {
|
||||||
|
// Mentions are additive; plain text input remains fully functional.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private availableMentions(): MentionCandidate[] {
|
||||||
|
const currentKey = this.client.activeChatId
|
||||||
|
? `websocket:${this.client.activeChatId}`
|
||||||
|
: ""
|
||||||
|
return this.mentionCandidates.filter((candidate) => (
|
||||||
|
candidate.session?.session_key !== currentKey
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openBranch(): Promise<void> {
|
||||||
|
if (this.activeTurn) {
|
||||||
|
this.status.content = "Wait for the current turn or press Ctrl+C"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.ready) {
|
||||||
|
this.status.content = "Preparing chat…"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.commandMenu.hide()
|
||||||
|
this.sessionMenu.hide()
|
||||||
|
this.contextPanel.hide()
|
||||||
|
this.clearComposer()
|
||||||
|
this.status.content = "Loading branch points…"
|
||||||
|
const chatId = this.client.activeChatId
|
||||||
|
try {
|
||||||
|
const history = await fetchHistory(
|
||||||
|
this.options.apiUrl,
|
||||||
|
this.options.apiToken,
|
||||||
|
chatId,
|
||||||
|
)
|
||||||
|
if (chatId !== this.client.activeChatId) return
|
||||||
|
const points = branchPoints(history.messages)
|
||||||
|
const limit = this.renderer.height >= 20 ? 8 : 4
|
||||||
|
this.branchMenu.open(points, limit)
|
||||||
|
this.syncComposerPlaceholder()
|
||||||
|
this.updateMeta()
|
||||||
|
this.status.content = points.length ? `${points.length} branch points` : "No completed replies"
|
||||||
|
} catch (error) {
|
||||||
|
this.status.content = error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createBranch(beforeUserIndex: number, preview: string): void {
|
||||||
|
if (!this.ready || this.activeTurn) return
|
||||||
|
this.branchMenu.hide()
|
||||||
|
this.clearComposer()
|
||||||
|
try {
|
||||||
|
if (!this.client.forkChat) throw new Error("branching is unavailable")
|
||||||
|
this.ready = false
|
||||||
|
this.promptQueue.clear()
|
||||||
|
this.sessionMetadataId += 1
|
||||||
|
this.sessionTitle = `Fork · ${preview.slice(0, 48)}`
|
||||||
|
this.contextTokens = null
|
||||||
|
this.lastUsage = null
|
||||||
|
this.readyDetail = ""
|
||||||
|
this.updateTitle()
|
||||||
|
this.status.content = "Creating branch…"
|
||||||
|
this.client.forkChat(
|
||||||
|
this.client.activeChatId,
|
||||||
|
beforeUserIndex,
|
||||||
|
this.sessionTitle,
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
this.ready = true
|
||||||
|
this.status.content = error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeBranch(): void {
|
||||||
|
this.branchMenu.hide()
|
||||||
|
this.clearComposer()
|
||||||
|
this.syncComposerPlaceholder()
|
||||||
|
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
|
||||||
|
this.updateMeta()
|
||||||
|
}
|
||||||
|
|
||||||
private async openSessions(): Promise<void> {
|
private async openSessions(): Promise<void> {
|
||||||
if (this.activeTurn) {
|
if (this.activeTurn) {
|
||||||
this.status.content = "Wait for the current turn or press Ctrl+C"
|
this.status.content = "Wait for the current turn or press Ctrl+C"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
|
this.mentionMenu.hide()
|
||||||
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.sessionLoading = true
|
this.sessionLoading = true
|
||||||
@@ -1293,10 +1596,13 @@ export class NanobotTui {
|
|||||||
this.closeSessions()
|
this.closeSessions()
|
||||||
try {
|
try {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
|
this.promptQueue.clear()
|
||||||
this.sessionMetadataId += 1
|
this.sessionMetadataId += 1
|
||||||
this.sessionTitle = sessionLabel(session)
|
this.sessionTitle = sessionLabel(session)
|
||||||
this.applySessionModel(session)
|
this.applySessionModel(session)
|
||||||
this.contextTokens = null
|
this.contextTokens = null
|
||||||
|
this.lastUsage = null
|
||||||
|
this.readyDetail = ""
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
this.status.content = "Opening session…"
|
this.status.content = "Opening session…"
|
||||||
this.client.attach(session.chatId)
|
this.client.attach(session.chatId)
|
||||||
@@ -1316,16 +1622,21 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.sessionMenu.hide()
|
||||||
|
this.mentionMenu.hide()
|
||||||
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
try {
|
try {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
|
this.promptQueue.clear()
|
||||||
this.sessionMetadataId += 1
|
this.sessionMetadataId += 1
|
||||||
this.sessionTitle = "New chat"
|
this.sessionTitle = "New chat"
|
||||||
this.sessionModelPreset = null
|
this.sessionModelPreset = null
|
||||||
this.modelName = this.defaultModelName
|
this.modelName = this.defaultModelName
|
||||||
this.modelPreset = this.defaultModelPreset
|
this.modelPreset = this.defaultModelPreset
|
||||||
this.contextTokens = null
|
this.contextTokens = null
|
||||||
|
this.lastUsage = null
|
||||||
|
this.readyDetail = ""
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
this.status.content = "Starting a new chat…"
|
this.status.content = "Starting a new chat…"
|
||||||
this.client.newChat()
|
this.client.newChat()
|
||||||
@@ -1361,6 +1672,7 @@ export class NanobotTui {
|
|||||||
this.recordPrompt(content)
|
this.recordPrompt(content)
|
||||||
|
|
||||||
if (lifecycle === "agent_turn") {
|
if (lifecycle === "agent_turn") {
|
||||||
|
this.activeTurnId = turnId
|
||||||
this.finalMessage = ""
|
this.finalMessage = ""
|
||||||
this.turnHadAnswer = false
|
this.turnHadAnswer = false
|
||||||
this.lastProgress = ""
|
this.lastProgress = ""
|
||||||
@@ -1368,6 +1680,7 @@ export class NanobotTui {
|
|||||||
this.currentFileEdits = []
|
this.currentFileEdits = []
|
||||||
this.setActive(true)
|
this.setActive(true)
|
||||||
} else if (lifecycle === "finalize_active_turn") {
|
} else if (lifecycle === "finalize_active_turn") {
|
||||||
|
this.activeTurnId = null
|
||||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||||
this.transcript.finishActivity()
|
this.transcript.finishActivity()
|
||||||
this.finalMessage = ""
|
this.finalMessage = ""
|
||||||
@@ -1375,6 +1688,7 @@ export class NanobotTui {
|
|||||||
this.setActive(false)
|
this.setActive(false)
|
||||||
this.status.content = "Resetting chat…"
|
this.status.content = "Resetting chat…"
|
||||||
} else if (lifecycle === "stop_active_turn") {
|
} else if (lifecycle === "stop_active_turn") {
|
||||||
|
this.activeTurnId = null
|
||||||
this.setActive(false)
|
this.setActive(false)
|
||||||
this.status.content = "Stopping…"
|
this.status.content = "Stopping…"
|
||||||
} else if (!this.activeTurn) {
|
} else if (!this.activeTurn) {
|
||||||
@@ -1414,6 +1728,8 @@ export class NanobotTui {
|
|||||||
private async openContext(): Promise<void> {
|
private async openContext(): Promise<void> {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.sessionMenu.hide()
|
||||||
|
this.mentionMenu.hide()
|
||||||
|
this.branchMenu.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.status.content = "Reading agent context…"
|
this.status.content = "Reading agent context…"
|
||||||
try {
|
try {
|
||||||
@@ -1427,6 +1743,7 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.contextTokens = context.estimatedSessionTokens
|
this.contextTokens = context.estimatedSessionTokens
|
||||||
|
this.lastUsage = context.lastUsage
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
this.contextPanel.show(context)
|
this.contextPanel.show(context)
|
||||||
this.status.content = "Context snapshot"
|
this.status.content = "Context snapshot"
|
||||||
@@ -1462,9 +1779,11 @@ export class NanobotTui {
|
|||||||
this.options.apiToken,
|
this.options.apiToken,
|
||||||
chatId,
|
chatId,
|
||||||
)
|
)
|
||||||
if (!context || chatId !== this.client.activeChatId || this.contextTokens === null) return
|
if (!context || chatId !== this.client.activeChatId) return
|
||||||
this.contextTokens = context.estimatedSessionTokens
|
this.contextTokens = context.estimatedSessionTokens
|
||||||
|
this.lastUsage = context.lastUsage || this.lastUsage
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
|
if (!this.activeTurn) this.status.content = this.readyStatus()
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the last known estimate; it is intentionally informational.
|
// Keep the last known estimate; it is intentionally informational.
|
||||||
}
|
}
|
||||||
@@ -1473,6 +1792,8 @@ export class NanobotTui {
|
|||||||
private openDiff(): void {
|
private openDiff(): void {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.sessionMenu.hide()
|
||||||
|
this.mentionMenu.hide()
|
||||||
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.composer.blur()
|
this.composer.blur()
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { branchPoints } from "./branch-menu"
|
||||||
|
|
||||||
|
test("branch points preserve absolute user indices from paginated history", () => {
|
||||||
|
expect(branchPoints([
|
||||||
|
{ role: "user", content: "question" },
|
||||||
|
{ role: "assistant", content: " first\nreply ", forkIndex: 11 },
|
||||||
|
{ role: "activity", content: "read_file" },
|
||||||
|
{ role: "assistant", content: "second", forkIndex: 12 },
|
||||||
|
])).toEqual([
|
||||||
|
{ beforeUserIndex: 11, preview: "first reply" },
|
||||||
|
{ beforeUserIndex: 12, preview: "second" },
|
||||||
|
])
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
|
||||||
|
|
||||||
|
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
||||||
|
import type { HistoryMessage } from "./protocol"
|
||||||
|
|
||||||
|
export interface BranchPoint {
|
||||||
|
beforeUserIndex: number
|
||||||
|
preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function branchPoints(messages: HistoryMessage[]): BranchPoint[] {
|
||||||
|
return messages.flatMap((message) => (
|
||||||
|
message.role === "assistant" && typeof message.forkIndex === "number"
|
||||||
|
? [{ beforeUserIndex: message.forkIndex, preview: message.content.replace(/\s+/gu, " ").trim() }]
|
||||||
|
: []
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BranchMenu {
|
||||||
|
readonly root: BoxRenderable
|
||||||
|
private readonly picker: PickerMenu<BranchPoint>
|
||||||
|
|
||||||
|
constructor(renderer: CliRenderer, theme: PickerMenuTheme) {
|
||||||
|
this.picker = new PickerMenu(renderer, theme, {
|
||||||
|
id: "nanobot-tui-branch-menu",
|
||||||
|
searchText: (point) => point.preview,
|
||||||
|
render: (point) => `After turn ${point.beforeUserIndex} ${point.preview}`,
|
||||||
|
emptyText: "No completed replies to branch from",
|
||||||
|
})
|
||||||
|
this.root = this.picker.root
|
||||||
|
}
|
||||||
|
|
||||||
|
get visible(): boolean { return this.picker.visible }
|
||||||
|
open(points: BranchPoint[], limit: number): void { this.picker.show(points, "", limit) }
|
||||||
|
update(query: string, limit: number): void { this.picker.update(query, limit) }
|
||||||
|
move(direction: -1 | 1): boolean { return this.picker.move(direction) }
|
||||||
|
choose(): BranchPoint | null { return this.picker.current() }
|
||||||
|
hide(): void { this.picker.hide() }
|
||||||
|
setTheme(theme: PickerMenuTheme): void { this.picker.setTheme(theme) }
|
||||||
|
}
|
||||||
@@ -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" | "context" | "diff"
|
export type TuiCommandAction = "sessions" | "new-chat" | "context" | "diff" | "branch"
|
||||||
|
|
||||||
export interface TuiCommand {
|
export interface TuiCommand {
|
||||||
command: string
|
command: string
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { insertMention, mentionOptions, mentionQuery } from "./mention-menu"
|
||||||
|
import type { MentionCandidate } from "./protocol"
|
||||||
|
|
||||||
|
const candidates: MentionCandidate[] = [
|
||||||
|
{ kind: "cli", name: "github", displayName: "GitHub", description: "CLI" },
|
||||||
|
{ kind: "mcp", name: "linear", displayName: "Linear", description: "MCP" },
|
||||||
|
{
|
||||||
|
kind: "session",
|
||||||
|
name: "release-plan",
|
||||||
|
displayName: "Release plan",
|
||||||
|
description: "Session",
|
||||||
|
session: { name: "release-plan", session_key: "websocket:release", title: "Release plan" },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe("mention projection", () => {
|
||||||
|
test("finds and replaces only the mention under the cursor", () => {
|
||||||
|
const value = "ask @rel about this"
|
||||||
|
const query = mentionQuery(value, 8)
|
||||||
|
expect(query).toEqual({ query: "rel", start: 4, end: 8 })
|
||||||
|
expect(insertMention(value, candidates[2]!, query!).value).toBe("ask @release-plan about this")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps namespaced completion aliases separate from gateway capability ids", () => {
|
||||||
|
const options = mentionOptions("use @github-2", [{
|
||||||
|
kind: "mcp",
|
||||||
|
name: "github-2",
|
||||||
|
targetName: "github",
|
||||||
|
displayName: "GitHub MCP",
|
||||||
|
description: "MCP server",
|
||||||
|
}])
|
||||||
|
|
||||||
|
expect(options.mcpPresets).toEqual([{ name: "github" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps visible mentions onto the gateway metadata lanes", () => {
|
||||||
|
expect(mentionOptions("Use @github with @linear and @release-plan", candidates)).toEqual({
|
||||||
|
cliApps: [{ name: "github" }],
|
||||||
|
mcpPresets: [{ name: "linear" }],
|
||||||
|
sessionMentions: [{
|
||||||
|
name: "release-plan",
|
||||||
|
session_key: "websocket:release",
|
||||||
|
title: "Release plan",
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
|
||||||
|
|
||||||
|
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
||||||
|
import type { MentionCandidate, MessageOptions } from "./protocol"
|
||||||
|
|
||||||
|
export interface MentionQuery {
|
||||||
|
query: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mentionQuery(value: string, cursor: number): MentionQuery | null {
|
||||||
|
const end = Math.min(Math.max(cursor, 0), value.length)
|
||||||
|
const match = /(?:^|[\s([{])@([\p{L}\p{N}_-]*)$/u.exec(value.slice(0, end))
|
||||||
|
if (!match) return null
|
||||||
|
const valueQuery = match[1] ?? ""
|
||||||
|
const start = end - valueQuery.length - 1
|
||||||
|
return { query: valueQuery.toLocaleLowerCase(), start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertMention(
|
||||||
|
value: string,
|
||||||
|
candidate: MentionCandidate,
|
||||||
|
query: MentionQuery,
|
||||||
|
): { value: string; cursor: number } {
|
||||||
|
const suffix = value.slice(query.end)
|
||||||
|
const tail = /^\s/u.test(suffix) ? "" : " "
|
||||||
|
const inserted = `@${candidate.name}${tail}`
|
||||||
|
return {
|
||||||
|
value: `${value.slice(0, query.start)}${inserted}${suffix}`,
|
||||||
|
cursor: query.start + inserted.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mentionOptions(value: string, candidates: MentionCandidate[]): MessageOptions {
|
||||||
|
const names = new Set(
|
||||||
|
[...value.matchAll(/(?:^|[\s([{])@([\p{L}\p{N}_-]+)/gu)]
|
||||||
|
.flatMap((match) => match[1] ? [match[1].toLocaleLowerCase()] : []),
|
||||||
|
)
|
||||||
|
const selected = candidates.filter((candidate) => names.has(candidate.name.toLocaleLowerCase()))
|
||||||
|
return {
|
||||||
|
cliApps: selected
|
||||||
|
.filter((item) => item.kind === "cli")
|
||||||
|
.map((item) => ({ name: item.targetName || item.name })),
|
||||||
|
mcpPresets: selected
|
||||||
|
.filter((item) => item.kind === "mcp")
|
||||||
|
.map((item) => ({ name: item.targetName || item.name })),
|
||||||
|
sessionMentions: selected.flatMap((item) => item.session ? [item.session] : []),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MentionMenu {
|
||||||
|
readonly root: BoxRenderable
|
||||||
|
private readonly picker: PickerMenu<MentionCandidate>
|
||||||
|
|
||||||
|
constructor(renderer: CliRenderer, theme: PickerMenuTheme) {
|
||||||
|
this.picker = new PickerMenu(renderer, theme, {
|
||||||
|
id: "nanobot-tui-mention-menu",
|
||||||
|
searchText: (item) => `${item.name} ${item.displayName} ${item.description}`,
|
||||||
|
render: (item) => `${item.displayName} @${item.name} · ${item.kind}`,
|
||||||
|
emptyText: "No matching sessions or tools",
|
||||||
|
})
|
||||||
|
this.root = this.picker.root
|
||||||
|
}
|
||||||
|
|
||||||
|
get visible(): boolean { return this.picker.visible }
|
||||||
|
show(items: MentionCandidate[], query: string, limit: number): void {
|
||||||
|
this.picker.show(items, query, limit)
|
||||||
|
}
|
||||||
|
update(query: string, limit: number): void { this.picker.update(query, limit) }
|
||||||
|
move(direction: -1 | 1): boolean { return this.picker.move(direction) }
|
||||||
|
choose(): MentionCandidate | null { return this.picker.current() }
|
||||||
|
hide(): void { this.picker.hide() }
|
||||||
|
setTheme(theme: PickerMenuTheme): void { this.picker.setTheme(theme) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { PromptQueue } from "./prompt-queue"
|
||||||
|
|
||||||
|
const prompt = (content: string) => ({ content, options: {} })
|
||||||
|
|
||||||
|
describe("PromptQueue", () => {
|
||||||
|
test("promotes the newest armed prompt to steering", () => {
|
||||||
|
const queue = new PromptQueue()
|
||||||
|
queue.enqueue(prompt("next one"))
|
||||||
|
queue.enqueue(prompt("steer now"))
|
||||||
|
|
||||||
|
expect(queue.takeSteering()?.content).toBe("steer now")
|
||||||
|
expect(queue.takeSteering()).toBeNull()
|
||||||
|
expect(queue.takeFollowUp()?.content).toBe("next one")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps follow-ups FIFO and restores unsent drafts", () => {
|
||||||
|
const queue = new PromptQueue()
|
||||||
|
queue.enqueue(prompt("first"))
|
||||||
|
queue.enqueue(prompt("second"))
|
||||||
|
|
||||||
|
expect(queue.takeFollowUp()?.content).toBe("first")
|
||||||
|
expect(queue.restore().map(({ content }) => content)).toEqual(["second"])
|
||||||
|
expect(queue.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { MessageOptions } from "./protocol"
|
||||||
|
|
||||||
|
export interface QueuedPrompt {
|
||||||
|
content: string
|
||||||
|
options: MessageOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns the difference between steering the active turn and starting the next one. */
|
||||||
|
export class PromptQueue {
|
||||||
|
private prompts: QueuedPrompt[] = []
|
||||||
|
private armed = false
|
||||||
|
|
||||||
|
get length(): number {
|
||||||
|
return this.prompts.length
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(prompt: QueuedPrompt): void {
|
||||||
|
this.prompts.push(prompt)
|
||||||
|
this.armed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A second Enter immediately promotes the newest queued prompt to steering. */
|
||||||
|
takeSteering(): QueuedPrompt | null {
|
||||||
|
if (!this.armed) return null
|
||||||
|
this.armed = false
|
||||||
|
return this.prompts.pop() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
takeFollowUp(): QueuedPrompt | null {
|
||||||
|
this.armed = false
|
||||||
|
return this.prompts.shift() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
restore(): QueuedPrompt[] {
|
||||||
|
const prompts = this.prompts
|
||||||
|
this.prompts = []
|
||||||
|
this.armed = false
|
||||||
|
return prompts
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.prompts = []
|
||||||
|
this.armed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import {
|
import {
|
||||||
NanobotClient,
|
NanobotClient,
|
||||||
fetchHistory,
|
fetchHistory,
|
||||||
|
fetchMentionCandidates,
|
||||||
fetchSessionContext,
|
fetchSessionContext,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
fetchSlashCommands,
|
fetchSlashCommands,
|
||||||
@@ -77,17 +78,31 @@ describe("gateway protocol", () => {
|
|||||||
model_preset: "Deep Research",
|
model_preset: "Deep Research",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
client.send("hello")
|
client.send("hello", {
|
||||||
|
cliApps: [{ name: "github" }],
|
||||||
|
sessionMentions: [{ name: "plan", session_key: "websocket:plan" }],
|
||||||
|
})
|
||||||
client.attach("other-chat")
|
client.attach("other-chat")
|
||||||
client.newChat()
|
client.newChat()
|
||||||
|
client.forkChat("terminal", 3, "Alternative")
|
||||||
|
|
||||||
const outbound = socket.sent.map((value) => JSON.parse(value) as Record<string, unknown>)
|
const outbound = socket.sent.map((value) => JSON.parse(value) as Record<string, unknown>)
|
||||||
expect(outbound[0]).toEqual({ type: "attach", chat_id: "terminal" })
|
expect(outbound[0]).toEqual({ type: "attach", chat_id: "terminal" })
|
||||||
expect(outbound[1]?.type).toBe("message")
|
expect(outbound[1]?.type).toBe("message")
|
||||||
expect(outbound[1]?.chat_id).toBe("terminal")
|
expect(outbound[1]?.chat_id).toBe("terminal")
|
||||||
expect(outbound[1]?.content).toBe("hello")
|
expect(outbound[1]?.content).toBe("hello")
|
||||||
|
expect(outbound[1]?.cli_apps).toEqual([{ name: "github" }])
|
||||||
|
expect(outbound[1]?.session_mentions).toEqual([
|
||||||
|
{ name: "plan", session_key: "websocket:plan" },
|
||||||
|
])
|
||||||
expect(outbound[2]).toEqual({ type: "attach", chat_id: "other-chat" })
|
expect(outbound[2]).toEqual({ type: "attach", chat_id: "other-chat" })
|
||||||
expect(outbound[3]).toEqual({ type: "new_chat" })
|
expect(outbound[3]).toEqual({ type: "new_chat" })
|
||||||
|
expect(outbound[4]).toEqual({
|
||||||
|
type: "fork_chat",
|
||||||
|
source_chat_id: "terminal",
|
||||||
|
before_user_index: 3,
|
||||||
|
title: "Alternative",
|
||||||
|
})
|
||||||
expect(events.map((event) => event.event)).toEqual(["ready", "attached"])
|
expect(events.map((event) => event.event)).toEqual(["ready", "attached"])
|
||||||
expect(events[1]).toEqual({
|
expect(events[1]).toEqual({
|
||||||
event: "attached",
|
event: "attached",
|
||||||
@@ -268,7 +283,7 @@ describe("gateway protocol", () => {
|
|||||||
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
|
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
|
||||||
},
|
},
|
||||||
{ role: "assistant", kind: "reasoning", content: "private thought" },
|
{ role: "assistant", kind: "reasoning", content: "private thought" },
|
||||||
{ role: "assistant", content: "hi" },
|
{ role: "assistant", content: "hi", forkIndex: 1 },
|
||||||
],
|
],
|
||||||
page: { has_more_before: true, before_cursor: "older-1" },
|
page: { has_more_before: true, before_cursor: "older-1" },
|
||||||
})))
|
})))
|
||||||
@@ -284,10 +299,11 @@ describe("gateway protocol", () => {
|
|||||||
content: "read_file",
|
content: "read_file",
|
||||||
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
|
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
|
||||||
},
|
},
|
||||||
{ role: "assistant", content: "hi" },
|
{ role: "assistant", content: "hi", forkIndex: 1 },
|
||||||
],
|
],
|
||||||
hasMoreBefore: true,
|
hasMoreBefore: true,
|
||||||
beforeCursor: "older-1",
|
beforeCursor: "older-1",
|
||||||
|
userMessageOffset: 0,
|
||||||
})
|
})
|
||||||
expect(requested).toContain("before=newer-page")
|
expect(requested).toContain("before=newer-page")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -318,6 +334,7 @@ describe("gateway protocol", () => {
|
|||||||
estimatedSessionTokens: 2176,
|
estimatedSessionTokens: 2176,
|
||||||
archivedSummary: "Older work was compacted.",
|
archivedSummary: "Older work was compacted.",
|
||||||
archivedSummaryAt: "2026-08-13T10:00:00Z",
|
archivedSummaryAt: "2026-08-13T10:00:00Z",
|
||||||
|
lastUsage: null,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = original
|
globalThis.fetch = original
|
||||||
@@ -421,4 +438,61 @@ describe("gateway protocol", () => {
|
|||||||
globalThis.fetch = original
|
globalThis.fetch = original
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("combines installed tools and saved sessions in one mention namespace", async () => {
|
||||||
|
const original = globalThis.fetch
|
||||||
|
globalThis.fetch = ((input: string | URL | Request) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url.includes("cli-apps")) {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
|
apps: [{ name: "github", display_name: "GitHub", description: "Repository tools", installed: true }],
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
if (url.includes("mcp-presets")) {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
|
presets: [{
|
||||||
|
name: "linear",
|
||||||
|
display_name: "Linear",
|
||||||
|
description: "Issue tracker",
|
||||||
|
installed: true,
|
||||||
|
configured: true,
|
||||||
|
}],
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
if (url.includes("sidebar-state")) return Promise.resolve(new Response("{}"))
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({
|
||||||
|
sessions: [{ key: "websocket:plan", title: "Release plan", preview: "Ship it" }],
|
||||||
|
})))
|
||||||
|
}) as typeof fetch
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(await fetchMentionCandidates("http://nanobot.test", "secret")).toEqual([
|
||||||
|
{
|
||||||
|
kind: "cli",
|
||||||
|
name: "github",
|
||||||
|
displayName: "GitHub",
|
||||||
|
description: "Repository tools",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "mcp",
|
||||||
|
name: "linear",
|
||||||
|
displayName: "Linear",
|
||||||
|
description: "Issue tracker",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "session",
|
||||||
|
name: "Release-plan",
|
||||||
|
displayName: "Release plan",
|
||||||
|
description: "Ship it",
|
||||||
|
session: {
|
||||||
|
name: "Release-plan",
|
||||||
|
session_key: "websocket:plan",
|
||||||
|
title: "Release plan",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = original
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+212
-16
@@ -38,7 +38,12 @@ export interface FileDiff {
|
|||||||
|
|
||||||
export type InboundEvent =
|
export type InboundEvent =
|
||||||
| { event: "ready"; chat_id: string; client_id: string }
|
| { event: "ready"; chat_id: string; client_id: string }
|
||||||
| { event: "attached"; chat_id: string; model_preset?: string | null }
|
| {
|
||||||
|
event: "attached"
|
||||||
|
chat_id: string
|
||||||
|
model_preset?: string | null
|
||||||
|
usage?: TokenUsage
|
||||||
|
}
|
||||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||||
| {
|
| {
|
||||||
event: "message"
|
event: "message"
|
||||||
@@ -61,7 +66,14 @@ export type InboundEvent =
|
|||||||
}
|
}
|
||||||
| { event: "reasoning_delta"; chat_id: string; text: string; turn_id?: string }
|
| { event: "reasoning_delta"; chat_id: string; text: string; turn_id?: string }
|
||||||
| { event: "reasoning_end"; chat_id: string; turn_id?: string }
|
| { event: "reasoning_end"; chat_id: string; turn_id?: string }
|
||||||
| { event: "turn_end"; chat_id: string; latency_ms?: number; turn_id?: string }
|
| {
|
||||||
|
event: "turn_end"
|
||||||
|
chat_id: string
|
||||||
|
latency_ms?: number
|
||||||
|
turn_id?: string
|
||||||
|
usage?: TokenUsage
|
||||||
|
context_window_tokens?: number
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
event: "goal_status"
|
event: "goal_status"
|
||||||
chat_id: string
|
chat_id: string
|
||||||
@@ -77,13 +89,24 @@ export type InboundEvent =
|
|||||||
chat_id: string
|
chat_id: string
|
||||||
model_name: string
|
model_name: string
|
||||||
model_preset?: string | null
|
model_preset?: string | null
|
||||||
|
context_window_tokens?: number
|
||||||
}
|
}
|
||||||
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
|
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
|
||||||
|
|
||||||
type OutboundEvent =
|
type OutboundEvent =
|
||||||
| { type: "new_chat" }
|
| { type: "new_chat" }
|
||||||
|
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||||
| { type: "attach"; chat_id: string }
|
| { type: "attach"; chat_id: string }
|
||||||
| { type: "message"; chat_id: string; content: string; turn_id: string; webui: true }
|
| {
|
||||||
|
type: "message"
|
||||||
|
chat_id: string
|
||||||
|
content: string
|
||||||
|
turn_id: string
|
||||||
|
webui: true
|
||||||
|
cli_apps?: Array<{ name: string }>
|
||||||
|
mcp_presets?: Array<{ name: string }>
|
||||||
|
session_mentions?: SessionMention[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClientOptions {
|
export interface ClientOptions {
|
||||||
url: string
|
url: string
|
||||||
@@ -98,12 +121,24 @@ export interface HistoryMessage {
|
|||||||
content: string
|
content: string
|
||||||
toolEvents?: ToolProgressEvent[]
|
toolEvents?: ToolProgressEvent[]
|
||||||
fileEdits?: FileEditEvent[]
|
fileEdits?: FileEditEvent[]
|
||||||
|
forkIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistorySnapshot {
|
export interface HistorySnapshot {
|
||||||
messages: HistoryMessage[]
|
messages: HistoryMessage[]
|
||||||
hasMoreBefore: boolean
|
hasMoreBefore: boolean
|
||||||
beforeCursor: string | null
|
beforeCursor: string | null
|
||||||
|
userMessageOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUsage {
|
||||||
|
prompt_tokens?: number
|
||||||
|
completion_tokens?: number
|
||||||
|
cached_tokens?: number
|
||||||
|
total_tokens?: number
|
||||||
|
provider_tokens?: number
|
||||||
|
estimated_tokens?: number
|
||||||
|
cost_usd?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionContextSnapshot {
|
export interface SessionContextSnapshot {
|
||||||
@@ -115,6 +150,28 @@ export interface SessionContextSnapshot {
|
|||||||
estimatedSessionTokens: number
|
estimatedSessionTokens: number
|
||||||
archivedSummary: string | null
|
archivedSummary: string | null
|
||||||
archivedSummaryAt: string | null
|
archivedSummaryAt: string | null
|
||||||
|
lastUsage: TokenUsage | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMention {
|
||||||
|
name: string
|
||||||
|
session_key: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MentionCandidate {
|
||||||
|
kind: "session" | "cli" | "mcp"
|
||||||
|
name: string
|
||||||
|
targetName?: string
|
||||||
|
displayName: string
|
||||||
|
description: string
|
||||||
|
session?: SessionMention
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageOptions {
|
||||||
|
cliApps?: Array<{ name: string }>
|
||||||
|
mcpPresets?: Array<{ name: string }>
|
||||||
|
sessionMentions?: SessionMention[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
@@ -213,6 +270,19 @@ function isFileDiff(value: unknown): value is FileDiff {
|
|||||||
&& optional(value.text, "string")
|
&& optional(value.text, "string")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTokenUsage(value: unknown): value is TokenUsage {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
return [
|
||||||
|
"prompt_tokens",
|
||||||
|
"completion_tokens",
|
||||||
|
"cached_tokens",
|
||||||
|
"total_tokens",
|
||||||
|
"provider_tokens",
|
||||||
|
"estimated_tokens",
|
||||||
|
"cost_usd",
|
||||||
|
].every((key) => optional(value[key], "number"))
|
||||||
|
}
|
||||||
|
|
||||||
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||||
if (!isRecord(value)) return null
|
if (!isRecord(value)) return null
|
||||||
const record = value
|
const record = value
|
||||||
@@ -240,9 +310,10 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
|||||||
if (typeof record.chat_id !== "string") return null
|
if (typeof record.chat_id !== "string") return null
|
||||||
if (
|
if (
|
||||||
name === "attached"
|
name === "attached"
|
||||||
&& record.model_preset !== undefined
|
&& ((record.model_preset !== undefined
|
||||||
&& record.model_preset !== null
|
&& record.model_preset !== null
|
||||||
&& typeof record.model_preset !== "string"
|
&& typeof record.model_preset !== "string")
|
||||||
|
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|
||||||
) return null
|
) return null
|
||||||
if (["message", "delta", "reasoning_delta"].includes(name) && typeof record.text !== "string") {
|
if (["message", "delta", "reasoning_delta"].includes(name) && typeof record.text !== "string") {
|
||||||
return null
|
return null
|
||||||
@@ -261,7 +332,12 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
|||||||
|| !optional(record.resuming, "boolean")
|
|| !optional(record.resuming, "boolean")
|
||||||
|| !optional(record.merge_next, "boolean"))
|
|| !optional(record.merge_next, "boolean"))
|
||||||
) return null
|
) return null
|
||||||
if (name === "turn_end" && !optional(record.latency_ms, "number")) return null
|
if (
|
||||||
|
name === "turn_end"
|
||||||
|
&& (!optional(record.latency_ms, "number")
|
||||||
|
|| !optional(record.context_window_tokens, "number")
|
||||||
|
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|
||||||
|
) return null
|
||||||
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
|
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
|
||||||
if (name === "goal_state" && (!record.goal_state || typeof record.goal_state !== "object")) return null
|
if (name === "goal_state" && (!record.goal_state || typeof record.goal_state !== "object")) return null
|
||||||
if (name === "session_updated" && !optional(record.scope, "string")) return null
|
if (name === "session_updated" && !optional(record.scope, "string")) return null
|
||||||
@@ -270,7 +346,8 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
|||||||
&& (typeof record.model_name !== "string"
|
&& (typeof record.model_name !== "string"
|
||||||
|| (record.model_preset !== undefined
|
|| (record.model_preset !== undefined
|
||||||
&& record.model_preset !== null
|
&& record.model_preset !== null
|
||||||
&& typeof record.model_preset !== "string"))
|
&& typeof record.model_preset !== "string")
|
||||||
|
|| !optional(record.context_window_tokens, "number"))
|
||||||
) return null
|
) return null
|
||||||
return value as InboundEvent
|
return value as InboundEvent
|
||||||
}
|
}
|
||||||
@@ -282,7 +359,7 @@ export async function fetchHistory(
|
|||||||
beforeCursor?: string | null,
|
beforeCursor?: string | null,
|
||||||
): Promise<HistorySnapshot> {
|
): Promise<HistorySnapshot> {
|
||||||
if (!apiUrl || !apiToken) {
|
if (!apiUrl || !apiToken) {
|
||||||
return { messages: [], hasMoreBefore: false, beforeCursor: null }
|
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
|
||||||
}
|
}
|
||||||
const key = encodeURIComponent(`websocket:${chatId}`)
|
const key = encodeURIComponent(`websocket:${chatId}`)
|
||||||
const params = new URLSearchParams({ limit: "120", direction: "latest" })
|
const params = new URLSearchParams({ limit: "120", direction: "latest" })
|
||||||
@@ -291,14 +368,18 @@ export async function fetchHistory(
|
|||||||
headers: { Authorization: `Bearer ${apiToken}` },
|
headers: { Authorization: `Bearer ${apiToken}` },
|
||||||
})
|
})
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return { messages: [], hasMoreBefore: false, beforeCursor: null }
|
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
|
||||||
}
|
}
|
||||||
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; before_cursor?: string }
|
page?: { has_more_before?: boolean; before_cursor?: string; user_message_offset?: number }
|
||||||
}
|
}
|
||||||
const messages: HistoryMessage[] = (payload.messages || []).flatMap((message) => {
|
let userIndex = typeof payload.page?.user_message_offset === "number"
|
||||||
|
? Math.max(0, payload.page.user_message_offset)
|
||||||
|
: 0
|
||||||
|
const messages: HistoryMessage[] = []
|
||||||
|
for (const message of payload.messages || []) {
|
||||||
const role = message.role
|
const role = message.role
|
||||||
const content = message.content
|
const content = message.content
|
||||||
if (role === "tool" && message.kind === "trace") {
|
if (role === "tool" && message.kind === "trace") {
|
||||||
@@ -312,7 +393,13 @@ export async function fetchHistory(
|
|||||||
? message.fileEdits.filter(isFileEdit)
|
? message.fileEdits.filter(isFileEdit)
|
||||||
: undefined
|
: undefined
|
||||||
const activity = traces.join("\n") || (typeof content === "string" ? content : "")
|
const activity = traces.join("\n") || (typeof content === "string" ? content : "")
|
||||||
return [{ role: "activity", content: activity, toolEvents, fileEdits }]
|
messages.push({
|
||||||
|
role: "activity",
|
||||||
|
content: activity,
|
||||||
|
...(toolEvents?.length ? { toolEvents } : {}),
|
||||||
|
...(fileEdits?.length ? { fileEdits } : {}),
|
||||||
|
})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
(role !== "user" && role !== "assistant")
|
(role !== "user" && role !== "assistant")
|
||||||
@@ -320,16 +407,24 @@ export async function fetchHistory(
|
|||||||
|| typeof content !== "string"
|
|| typeof content !== "string"
|
||||||
|| !content.trim()
|
|| !content.trim()
|
||||||
) {
|
) {
|
||||||
return []
|
continue
|
||||||
|
}
|
||||||
|
if (role === "user") {
|
||||||
|
userIndex += 1
|
||||||
|
messages.push({ role: "user", content })
|
||||||
|
} else {
|
||||||
|
messages.push({ role: "assistant", content, forkIndex: userIndex })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [{ role: role as HistoryMessage["role"], content }]
|
|
||||||
})
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
hasMoreBefore: payload.page?.has_more_before === true,
|
hasMoreBefore: payload.page?.has_more_before === true,
|
||||||
beforeCursor: typeof payload.page?.before_cursor === "string"
|
beforeCursor: typeof payload.page?.before_cursor === "string"
|
||||||
? payload.page.before_cursor
|
? payload.page.before_cursor
|
||||||
: null,
|
: null,
|
||||||
|
userMessageOffset: typeof payload.page?.user_message_offset === "number"
|
||||||
|
? Math.max(0, payload.page.user_message_offset)
|
||||||
|
: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +453,7 @@ export async function fetchSessionContext(
|
|||||||
archivedSummaryAt: typeof value.archived_summary_at === "string"
|
archivedSummaryAt: typeof value.archived_summary_at === "string"
|
||||||
? value.archived_summary_at
|
? value.archived_summary_at
|
||||||
: null,
|
: null,
|
||||||
|
lastUsage: isTokenUsage(value.last_usage) ? value.last_usage : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +534,92 @@ export async function fetchSessions(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionMentionName(session: SessionSummary): string {
|
||||||
|
const label = (session.title || session.preview || "session")
|
||||||
|
.normalize("NFKC")
|
||||||
|
.replace(/\s+/gu, "-")
|
||||||
|
.replace(/[^\p{L}\p{N}_-]+/gu, "")
|
||||||
|
.replace(/-+/gu, "-")
|
||||||
|
.replace(/^-|-$/gu, "")
|
||||||
|
return Array.from(label || "session").slice(0, 40).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installed capabilities and saved chats share one mention namespace. */
|
||||||
|
export async function fetchMentionCandidates(
|
||||||
|
apiUrl: string,
|
||||||
|
apiToken: string,
|
||||||
|
): Promise<MentionCandidate[]> {
|
||||||
|
if (!apiUrl || !apiToken) return []
|
||||||
|
const headers = { Authorization: `Bearer ${apiToken}` }
|
||||||
|
const [sessions, appsResponse, mcpResponse] = await Promise.all([
|
||||||
|
fetchSessions(apiUrl, apiToken),
|
||||||
|
fetch(`${apiUrl}/api/settings/cli-apps?installed_only=1`, { headers }).catch(() => null),
|
||||||
|
fetch(`${apiUrl}/api/settings/mcp-presets`, { headers }).catch(() => null),
|
||||||
|
])
|
||||||
|
const used = new Set<string>()
|
||||||
|
const uniqueName = (raw: string) => {
|
||||||
|
const base = raw || "session"
|
||||||
|
let name = base
|
||||||
|
let suffix = 2
|
||||||
|
while (used.has(name.toLocaleLowerCase())) name = `${base}-${suffix++}`
|
||||||
|
used.add(name.toLocaleLowerCase())
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
const candidates: MentionCandidate[] = []
|
||||||
|
if (appsResponse?.ok) {
|
||||||
|
const payload = await appsResponse.json() as { apps?: unknown[] }
|
||||||
|
for (const value of payload.apps || []) {
|
||||||
|
if (!isRecord(value) || value.installed !== true || typeof value.name !== "string") continue
|
||||||
|
const name = uniqueName(value.name)
|
||||||
|
candidates.push({
|
||||||
|
kind: "cli",
|
||||||
|
name,
|
||||||
|
...(name === value.name ? {} : { targetName: value.name }),
|
||||||
|
displayName: typeof value.display_name === "string" ? value.display_name : name,
|
||||||
|
description: typeof value.description === "string" ? value.description : "CLI app",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mcpResponse?.ok) {
|
||||||
|
const payload = await mcpResponse.json() as { presets?: unknown[] }
|
||||||
|
for (const value of payload.presets || []) {
|
||||||
|
if (
|
||||||
|
!isRecord(value)
|
||||||
|
|| value.installed !== true
|
||||||
|
|| value.configured !== true
|
||||||
|
|| typeof value.name !== "string"
|
||||||
|
) continue
|
||||||
|
const name = uniqueName(value.name)
|
||||||
|
candidates.push({
|
||||||
|
kind: "mcp",
|
||||||
|
name,
|
||||||
|
...(name === value.name ? {} : { targetName: value.name }),
|
||||||
|
displayName: typeof value.display_name === "string" ? value.display_name : name,
|
||||||
|
description: typeof value.description === "string" ? value.description : "MCP server",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const session of sessions) {
|
||||||
|
const name = uniqueName(sessionMentionName(session))
|
||||||
|
candidates.push({
|
||||||
|
kind: "session",
|
||||||
|
name,
|
||||||
|
displayName: sessionLabelForMention(session),
|
||||||
|
description: session.preview || "Saved session",
|
||||||
|
session: {
|
||||||
|
name,
|
||||||
|
session_key: `websocket:${session.chatId}`,
|
||||||
|
title: session.title || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionLabelForMention(session: SessionSummary): string {
|
||||||
|
return (session.title || session.preview || "Untitled chat").replace(/\s+/gu, " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
export class NanobotClient {
|
export class NanobotClient {
|
||||||
private socket: WebSocket | null = null
|
private socket: WebSocket | null = null
|
||||||
private chatId = ""
|
private chatId = ""
|
||||||
@@ -492,7 +674,7 @@ export class NanobotClient {
|
|||||||
socket?.close()
|
socket?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
send(content: string): string {
|
send(content: string, options: MessageOptions = {}): string {
|
||||||
if (!this.chatId) throw new Error("chat is not ready")
|
if (!this.chatId) throw new Error("chat is not ready")
|
||||||
const turnId = crypto.randomUUID()
|
const turnId = crypto.randomUUID()
|
||||||
this.write({
|
this.write({
|
||||||
@@ -501,6 +683,11 @@ export class NanobotClient {
|
|||||||
content,
|
content,
|
||||||
turn_id: turnId,
|
turn_id: turnId,
|
||||||
webui: true,
|
webui: true,
|
||||||
|
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
|
||||||
|
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
|
||||||
|
...(options.sessionMentions?.length
|
||||||
|
? { session_mentions: options.sessionMentions }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
return turnId
|
return turnId
|
||||||
}
|
}
|
||||||
@@ -514,6 +701,15 @@ export class NanobotClient {
|
|||||||
this.write({ type: "new_chat" })
|
this.write({ type: "new_chat" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
forkChat(sourceChatId: string, beforeUserIndex: number, title?: string): void {
|
||||||
|
this.write({
|
||||||
|
type: "fork_chat",
|
||||||
|
source_chat_id: sourceChatId,
|
||||||
|
before_user_index: beforeUserIndex,
|
||||||
|
...(title?.trim() ? { title: title.trim() } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private handleMessage(raw: string): void {
|
private handleMessage(raw: string): void {
|
||||||
let value: unknown
|
let value: unknown
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { mergeToolEvent, renderToolEvent } from "./tool-renderers"
|
||||||
|
|
||||||
|
describe("tool renderers", () => {
|
||||||
|
test("retains start arguments when an end frame only carries output", () => {
|
||||||
|
const event = mergeToolEvent(
|
||||||
|
{ call_id: "exec-1", phase: "start", name: "exec", arguments: { cmd: "git status" } },
|
||||||
|
{ call_id: "exec-1", phase: "end", name: "exec", result: { output: "clean" } },
|
||||||
|
)
|
||||||
|
expect(renderToolEvent(event)).toBe(" ✓ Command git status")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses stable task language for common file and web tools", () => {
|
||||||
|
expect(renderToolEvent({ phase: "end", name: "read_file", arguments: { path: "README.md" } }))
|
||||||
|
.toBe(" ✓ Read README.md")
|
||||||
|
expect(renderToolEvent({ phase: "start", name: "web_search", arguments: { query: "nanobot" } }))
|
||||||
|
.toBe(" › Search web nanobot")
|
||||||
|
expect(renderToolEvent({ phase: "error", name: "web_fetch", error: "timeout" }))
|
||||||
|
.toBe(" × Fetch timeout")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { ToolProgressEvent } from "./protocol"
|
||||||
|
|
||||||
|
export function mergeToolEvent(
|
||||||
|
previous: ToolProgressEvent | undefined,
|
||||||
|
next: ToolProgressEvent,
|
||||||
|
): ToolProgressEvent {
|
||||||
|
if (!previous) return next
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
...next,
|
||||||
|
arguments: next.arguments ?? previous.arguments,
|
||||||
|
result: next.result ?? previous.result,
|
||||||
|
error: next.error ?? previous.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderToolEvent(event: ToolProgressEvent): string {
|
||||||
|
const phase = event.phase || "start"
|
||||||
|
const marker = phase === "error" ? "×" : phase === "end" ? "✓" : "›"
|
||||||
|
const name = (event.name || "tool").trim()
|
||||||
|
const args = record(event.arguments)
|
||||||
|
const result = record(event.result)
|
||||||
|
const detail = phase === "error" ? compact(event.error) : toolDetail(name, args, result)
|
||||||
|
return ` ${marker} ${toolLabel(name)}${detail ? ` ${detail}` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolLabel(name: string): string {
|
||||||
|
if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) return "Command"
|
||||||
|
if (/^(?:read_file|read)$/u.test(name)) return "Read"
|
||||||
|
if (/^(?:write_file|write)$/u.test(name)) return "Write"
|
||||||
|
if (/^(?:edit_file|apply_patch|edit)$/u.test(name)) return "Edit"
|
||||||
|
if (name === "web_search") return "Search web"
|
||||||
|
if (name === "web_fetch") return "Fetch"
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolDetail(
|
||||||
|
name: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
result: Record<string, unknown>,
|
||||||
|
): string {
|
||||||
|
if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) {
|
||||||
|
return compact(args.command ?? args.cmd ?? result.output)
|
||||||
|
}
|
||||||
|
if (/^(?:read_file|write_file|edit_file|apply_patch|read|write|edit)$/u.test(name)) {
|
||||||
|
return compact(args.path ?? args.file_path ?? result.path)
|
||||||
|
}
|
||||||
|
if (name === "web_search") return compact(args.query ?? args.q)
|
||||||
|
if (name === "web_fetch") return compact(args.url)
|
||||||
|
if (/session/u.test(name)) return compact(args.session_key ?? args.chat_id ?? args.query)
|
||||||
|
if (Object.keys(args).length) return compact(args)
|
||||||
|
return Object.keys(result).length ? compact(result) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compact(value: unknown): string {
|
||||||
|
if (value == null || value === "") return ""
|
||||||
|
const text = typeof value === "string" ? value : JSON.stringify(value)
|
||||||
|
return text.length > 88 ? `${text.slice(0, 85)}…` : text
|
||||||
|
}
|
||||||
+21
-25
@@ -12,6 +12,7 @@ import {
|
|||||||
|
|
||||||
import type { FileEditEvent, HistoryMessage, ToolProgressEvent } from "./protocol"
|
import type { FileEditEvent, HistoryMessage, ToolProgressEvent } from "./protocol"
|
||||||
import { hideScrollbars } from "./scrollbox"
|
import { hideScrollbars } from "./scrollbox"
|
||||||
|
import { mergeToolEvent, renderToolEvent } from "./tool-renderers"
|
||||||
|
|
||||||
export interface TranscriptTheme {
|
export interface TranscriptTheme {
|
||||||
text: string
|
text: string
|
||||||
@@ -40,6 +41,7 @@ interface Activity {
|
|||||||
lines: string[]
|
lines: string[]
|
||||||
keys: Map<string, number>
|
keys: Map<string, number>
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
|
events: Map<string, ToolProgressEvent>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVITY_PREVIEW_LINES = 6
|
const ACTIVITY_PREVIEW_LINES = 6
|
||||||
@@ -182,8 +184,8 @@ export class Transcript {
|
|||||||
? message.fileEdits.map((edit) => ({
|
? message.fileEdits.map((edit) => ({
|
||||||
call_id: `file:${edit.call_id || edit.path || "unknown"}`,
|
call_id: `file:${edit.call_id || edit.path || "unknown"}`,
|
||||||
phase: edit.status === "error" ? "error" : edit.phase,
|
phase: edit.status === "error" ? "error" : edit.phase,
|
||||||
name: edit.path ? `${edit.tool || "edit"} ${edit.path}` : "edit file",
|
name: edit.tool || "edit_file",
|
||||||
arguments: edit.error || formatDiffStat(edit),
|
arguments: { path: edit.path, stat: edit.error || formatDiffStat(edit) },
|
||||||
}))
|
}))
|
||||||
: message.toolEvents || []
|
: message.toolEvents || []
|
||||||
this.updateActivity(activity, message.content, events)
|
this.updateActivity(activity, message.content, events)
|
||||||
@@ -260,8 +262,8 @@ export class Transcript {
|
|||||||
return this.progress("", edits.map((edit) => ({
|
return this.progress("", edits.map((edit) => ({
|
||||||
call_id: `file:${edit.call_id || edit.path || "unknown"}`,
|
call_id: `file:${edit.call_id || edit.path || "unknown"}`,
|
||||||
phase: edit.status === "error" ? "error" : edit.phase,
|
phase: edit.status === "error" ? "error" : edit.phase,
|
||||||
name: edit.path ? `${edit.tool || "edit"} ${edit.path}` : "edit file",
|
name: edit.tool || "edit_file",
|
||||||
arguments: edit.error || formatDiffStat(edit),
|
arguments: { path: edit.path, stat: edit.error || formatDiffStat(edit) },
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +362,13 @@ export class Transcript {
|
|||||||
this.root.add(row, index)
|
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<string, number>(),
|
||||||
|
expanded: false,
|
||||||
|
events: new Map<string, ToolProgressEvent>(),
|
||||||
|
}
|
||||||
this.activities.add(activity)
|
this.activities.add(activity)
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
@@ -370,11 +378,17 @@ export class Transcript {
|
|||||||
content: string,
|
content: string,
|
||||||
events: ToolProgressEvent[] = [],
|
events: ToolProgressEvent[] = [],
|
||||||
): string {
|
): string {
|
||||||
|
const projected = events.map((event) => {
|
||||||
|
const key = event.call_id ? `tool:${event.call_id}` : ""
|
||||||
|
const merged = key ? mergeToolEvent(activity.events.get(key), event) : event
|
||||||
|
if (key) activity.events.set(key, merged)
|
||||||
|
return { key, line: renderToolEvent(merged) }
|
||||||
|
})
|
||||||
const lines = events.length > 0
|
const lines = events.length > 0
|
||||||
? events.map(formatToolEvent).filter(Boolean)
|
? projected.map(({ line }) => line).filter(Boolean)
|
||||||
: content.split("\n").map(cleanProgress).filter(Boolean)
|
: content.split("\n").map(cleanProgress).filter(Boolean)
|
||||||
for (const [index, line] of lines.entries()) {
|
for (const [index, line] of lines.entries()) {
|
||||||
const key = events[index]?.call_id ? `tool:${events[index]?.call_id}` : undefined
|
const key = projected[index]?.key || undefined
|
||||||
const existing = key ? activity.keys.get(key) : undefined
|
const existing = key ? activity.keys.get(key) : undefined
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
activity.lines[existing] = line
|
activity.lines[existing] = line
|
||||||
@@ -479,24 +493,6 @@ function cleanProgress(value: string): string {
|
|||||||
return text ? ` · ${text}` : ""
|
return text ? ` · ${text}` : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatToolEvent(event: ToolProgressEvent): string {
|
|
||||||
const phase = event.phase || "start"
|
|
||||||
const marker = phase === "error" ? "×" : phase === "end" ? "✓" : "›"
|
|
||||||
const name = event.name?.trim() || "tool"
|
|
||||||
const detail = phase === "error"
|
|
||||||
? compactValue(event.error)
|
|
||||||
: phase === "start"
|
|
||||||
? compactValue(event.arguments)
|
|
||||||
: ""
|
|
||||||
return ` ${marker} ${name}${detail ? ` ${detail}` : ""}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactValue(value: unknown): string {
|
|
||||||
if (value == null || value === "") return ""
|
|
||||||
const text = typeof value === "string" ? value : JSON.stringify(value)
|
|
||||||
return text.length > 72 ? `${text.slice(0, 69)}…` : text
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDiffStat(edit: FileEditEvent): string {
|
function formatDiffStat(edit: FileEditEvent): string {
|
||||||
const added = typeof edit.added === "number" ? `+${edit.added}` : ""
|
const added = typeof edit.added === "number" ? `+${edit.added}` : ""
|
||||||
const deleted = typeof edit.deleted === "number" ? `-${edit.deleted}` : ""
|
const deleted = typeof edit.deleted === "number" ? `-${edit.deleted}` : ""
|
||||||
|
|||||||
Reference in New Issue
Block a user