perf(tui): reduce cold-start latency

This commit is contained in:
chengyongru
2026-08-18 16:28:46 +08:00
committed by chengyongru
parent 369a3443eb
commit df14259717
19 changed files with 669 additions and 160 deletions
+2 -2
View File
@@ -121,9 +121,9 @@ nanobot sessions restore-workspace --config ./bot-a/config.json --workspace ./bo
The command never deletes the external store and refuses to overwrite a different existing
workspace file. Back up both the config directory and workspace before changing versions.
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, either client starts it on demand. Exiting one TUI or WebUI launcher releases only that client; the last interactive launcher stops the on-demand gateway. A small gateway watchdog also reclaims an on-demand process if its last client crashes. Only an explicit `nanobot gateway --background` promotes it to persistent mode. `nanobot gateway restart` restarts a detached gateway without changing that lifetime; restart an attached foreground gateway in its owning terminal. `nanobot gateway stop` ends either mode.
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, either client starts it on demand. The TUI paints immediately while the local gateway starts, then obtains fresh bootstrap credentials and connects in the background. Exiting one TUI or WebUI launcher releases only that client; the last interactive launcher stops the on-demand gateway. A small gateway watchdog also reclaims an on-demand process if its last client crashes. Only an explicit `nanobot gateway --background` promotes it to persistent mode. `nanobot gateway restart` restarts a detached gateway without changing that lifetime; restart an attached foreground gateway in its owning terminal. `nanobot gateway stop` ends either mode.
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 model preset and workspace access labels above the composer can be clicked to open their selectors; arrow keys, `Enter`, and `Esc` provide the same controls without a mouse. Access changes still pass through the gateway's local-trust and active-turn policy checks.
The default `--theme auto` mode paints first with the terminal's default background, probes the real foreground and background colors asynchronously, and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. The model preset and workspace access labels above the composer can be clicked to open their selectors; arrow keys, `Enter`, and `Esc` provide the same controls without a mouse. Access changes still pass through the gateway's local-trust and active-turn policy checks.
`Enter` sends the current message. While a turn is active, `Enter` steers it immediately, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message to the composer. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback when a terminal cannot distinguish modified Enter keys. `Alt+Enter` and `Ctrl+Enter` are also accepted when distinguishable. 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.
+2 -2
View File
@@ -2,7 +2,7 @@
Entry point for running nanobot as a module: python -m nanobot
"""
from nanobot.cli.commands import app
from nanobot.cli.entry import main
if __name__ == "__main__":
app()
main()
+61 -34
View File
@@ -1,6 +1,7 @@
"""Direct and interactive agent CLI command."""
import asyncio
import importlib
import signal
import sys
from collections.abc import Awaitable, Callable
@@ -11,17 +12,6 @@ import typer
from rich.console import Console
from nanobot import __logo__
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.mcp import MCPProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.outbound_events import (
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
)
from nanobot.cli import terminal as cli_terminal
from nanobot.cli.log_control import _set_nanobot_logs
from nanobot.cli.runtime_config import (
_load_runtime_config,
@@ -29,22 +19,37 @@ from nanobot.cli.runtime_config import (
_model_display,
_print_agent_start_error,
)
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
from nanobot.config.paths import is_default_workspace
from nanobot.utils.helpers import (
sanitize_surrogates as _sanitize_surrogates,
)
from nanobot.utils.helpers import (
sync_workspace_templates,
)
from nanobot.utils.restart import (
consume_restart_notice_from_env,
format_restart_completed_message,
should_show_cli_restart_notice,
)
console = Console()
_CLASSIC_DEPENDENCIES = {
"AgentLoop": ("nanobot.agent.loop", "AgentLoop"),
"StreamRenderer": ("nanobot.cli.stream", "StreamRenderer"),
"consume_restart_notice_from_env": (
"nanobot.utils.restart",
"consume_restart_notice_from_env",
),
"is_default_workspace": ("nanobot.config.paths", "is_default_workspace"),
"sync_workspace_templates": ("nanobot.utils.helpers", "sync_workspace_templates"),
}
def __getattr__(name: str) -> Any:
"""Preserve patchable classic-agent symbols without loading them for the TUI."""
dependency = _CLASSIC_DEPENDENCIES.get(name)
if dependency is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, attribute = dependency
value = getattr(importlib.import_module(module_name), attribute)
globals()[name] = value
return value
def _classic_dependency(name: str) -> Any:
if name in globals():
return globals()[name]
return __getattr__(name)
def agent(
message: str | None = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
@@ -74,11 +79,6 @@ def agent(
),
):
"""Chat in the terminal or send one message non-interactively."""
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
from nanobot.providers.factory import make_provider
from nanobot.providers.image_generation import image_gen_provider_configs
runtime_config = _load_runtime_config(config, workspace)
theme = theme.strip().lower()
if theme not in {"auto", "dark", "light"}:
@@ -117,6 +117,33 @@ def agent(
raise typer.Exit(exit_code)
return
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.tools.mcp import MCPProvider
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.outbound_events import (
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
)
from nanobot.bus.queue import MessageBus
from nanobot.cli import terminal as cli_terminal
from nanobot.cli.stream import ThinkingSpinner
from nanobot.cron.service import CronService
from nanobot.providers.factory import make_provider
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.utils.helpers import sanitize_surrogates as _sanitize_surrogates
from nanobot.utils.restart import (
format_restart_completed_message,
should_show_cli_restart_notice,
)
agent_loop_class = _classic_dependency("AgentLoop")
stream_renderer_class = _classic_dependency("StreamRenderer")
consume_restart_notice_from_env = _classic_dependency("consume_restart_notice_from_env")
is_default_workspace = _classic_dependency("is_default_workspace")
sync_workspace_templates = _classic_dependency("sync_workspace_templates")
session_id = session_id or "cli:direct"
try:
@@ -142,7 +169,7 @@ def agent(
_set_nanobot_logs(logs)
try:
agent_loop = AgentLoop.from_config(
agent_loop = agent_loop_class.from_config(
runtime_config,
bus,
provider=provider,
@@ -171,7 +198,7 @@ def agent(
_thinking: ThinkingSpinner | None = None
def _make_progress(
renderer: StreamRenderer | None = None,
renderer: Any | None = None,
) -> Callable[..., Awaitable[None]]:
reasoning_buffer = cli_terminal._ReasoningBuffer()
@@ -212,7 +239,7 @@ def agent(
async def run_once() -> None:
try:
await mcp_provider.connect()
renderer = StreamRenderer(
renderer = stream_renderer_class(
render_markdown=markdown,
bot_name=runtime_config.agents.defaults.bot_name,
bot_icon=runtime_config.agents.defaults.bot_icon,
@@ -278,7 +305,7 @@ def agent(
turn_done = asyncio.Event()
turn_done.set()
turn_response: list[Any] = []
renderer: StreamRenderer | None = None
renderer: Any | None = None
reasoning_buffer = cli_terminal._ReasoningBuffer()
async def _consume_outbound() -> None:
@@ -361,7 +388,7 @@ def agent(
turn_done.clear()
turn_response.clear()
reasoning_buffer.clear()
renderer = StreamRenderer(
renderer = stream_renderer_class(
render_markdown=markdown,
bot_name=runtime_config.agents.defaults.bot_name,
bot_icon=runtime_config.agents.defaults.bot_icon,
+51
View File
@@ -0,0 +1,51 @@
"""Low-overhead console entrypoint for the native terminal client."""
from __future__ import annotations
import os
import sys
from contextlib import suppress
def _native_tui_candidate(args: list[str]) -> bool:
"""Return whether ``agent`` can start without the classic agent stack."""
if not args or args[0] != "agent":
return False
for argument in args[1:]:
if argument in {"--classic", "--no-tui", "-m", "--message"}:
return False
if argument.startswith("--message=") or (
argument.startswith("-m") and not argument.startswith("--")
):
return False
return True
def _configure_windows_console() -> None:
if sys.platform != "win32" or sys.stdout.encoding == "utf-8":
return
os.environ["PYTHONIOENCODING"] = "utf-8"
with suppress(Exception):
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if callable(reconfigure):
reconfigure(encoding="utf-8", errors="replace")
def main() -> None:
"""Dispatch native TUI startup without importing the complete CLI graph."""
_configure_windows_console()
if _native_tui_candidate(sys.argv[1:]):
import typer
from nanobot.cli.agent import agent
fast_app = typer.Typer(add_completion=False)
fast_app.command()(agent)
command = typer.main.get_command(fast_app)
command.main(args=sys.argv[2:], prog_name="nanobot agent")
return
from nanobot.cli.commands import app
app()
+58 -52
View File
@@ -11,7 +11,6 @@ import shutil
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass
@@ -22,9 +21,8 @@ from nanobot import __version__
from nanobot.cli.runtime_config import _model_display
from nanobot.cli.webui_support import (
_gateway_health_ready,
_webui_browser_url,
_host_for_local_browser,
_webui_endpoint_reachable,
webui_bootstrap_secret,
)
from nanobot.config.paths import get_data_dir
from nanobot.config.schema import Config
@@ -82,22 +80,17 @@ def launch_tui(
state_path = config_path.parent / "tui" / "state.json"
chat_id = _initial_tui_chat_id(session_id, state_path)
command = _resolve_tui_command()
gateway = _ensure_gateway(
config,
config_path=config_path,
workspace_override=workspace_override,
)
base_url, bootstrap_secret = _tui_gateway_connection(config)
gateway: _GatewayHandle | None = None
process: subprocess.Popen[Any] | None = None
try:
bootstrap = _fetch_bootstrap(
gateway.base_url,
secret=webui_bootstrap_secret(config),
)
env = os.environ.copy()
env.pop("NANOBOT_TUI_WS_URL", None)
env.pop("NANOBOT_TUI_API_TOKEN", None)
env.update(
{
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap),
"NANOBOT_TUI_API_URL": gateway.base_url,
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
"NANOBOT_TUI_BOOTSTRAP_URL": f"{base_url}/webui/bootstrap",
"NANOBOT_TUI_API_URL": base_url,
"NANOBOT_TUI_MODEL": _model_display(config)[0],
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
@@ -108,18 +101,42 @@ def launch_tui(
"NANOBOT_TUI_THEME": theme,
}
)
if bootstrap_secret:
env["NANOBOT_TUI_BOOTSTRAP_SECRET"] = bootstrap_secret
else:
env.pop("NANOBOT_TUI_BOOTSTRAP_SECRET", None)
env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
if chat_id:
env["NANOBOT_TUI_CHAT_ID"] = chat_id
else:
env.pop("NANOBOT_TUI_CHAT_ID", None)
return subprocess.run(command, env=env, check=False).returncode
except OSError as exc:
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc
try:
process = subprocess.Popen(command, env=env)
except OSError as exc:
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc
gateway = _ensure_gateway(
config,
config_path=config_path,
workspace_override=workspace_override,
wait_until_ready=False,
)
return process.wait()
except BaseException:
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
raise
finally:
lease = getattr(gateway, "lease", None)
lease = getattr(gateway, "lease", None) if gateway is not None else None
if lease is not None:
lease.release()
# Returning to the shell must not wait for process termination. The
# gateway's client monitor observes the released last lease and owns
# the orderly on-demand shutdown.
lease.release(wait_for_stop=False)
def _resolve_tui_command() -> list[str]:
@@ -364,6 +381,7 @@ def _ensure_gateway(
*,
config_path: Path,
workspace_override: str | None,
wait_until_ready: bool = True,
) -> _GatewayHandle:
from nanobot.gateway import (
GatewayClientLease,
@@ -371,7 +389,7 @@ def _ensure_gateway(
GatewayRuntime,
)
base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/")
base_url, _bootstrap_secret = _tui_gateway_connection(config)
instance = GatewayInstance.resolve(
config_path=config_path,
workspace=workspace_override,
@@ -388,7 +406,7 @@ def _ensure_gateway(
"the matching gateway instance is running on a different port; "
"restart it or use `nanobot agent --classic`"
)
if endpoint_reachable:
if endpoint_reachable or not wait_until_ready:
return _GatewayHandle(base_url=base_url, lease=lease)
elif endpoint_reachable:
raise TuiUnavailableError(
@@ -405,6 +423,9 @@ def _ensure_gateway(
f"logs: {result.status.log_path}"
)
if not wait_until_ready:
return _GatewayHandle(base_url=base_url, lease=lease)
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if _webui_endpoint_reachable(base_url):
@@ -427,37 +448,22 @@ def _ensure_gateway(
raise
def _fetch_bootstrap(base_url: str, *, secret: str) -> dict[str, Any]:
headers = {"X-Nanobot-Auth": secret} if secret else {}
request = urllib.request.Request(f"{base_url}/webui/bootstrap", headers=headers)
def _tui_gateway_connection(config: Config) -> tuple[str, str]:
"""Read the small bootstrap subset without importing the WebSocket runtime."""
raw: object = getattr(config.channels, "websocket", None)
settings = cast(dict[str, Any], raw) if isinstance(raw, dict) else {}
host = _host_for_local_browser(str(settings.get("host") or "127.0.0.1"))
try:
with urllib.request.urlopen(request, timeout=5) as response:
raw_payload: Any = json.loads(response.read().decode("utf-8"))
except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError) as exc:
raise TuiUnavailableError(
f"could not authenticate with the local gateway: {exc}"
) from exc
if not isinstance(raw_payload, dict):
raise TuiUnavailableError("gateway bootstrap response is missing ws_path")
payload = cast(dict[str, Any], raw_payload)
if not payload.get("ws_path"):
raise TuiUnavailableError("gateway bootstrap response is missing ws_path")
return payload
def _authenticated_ws_url(bootstrap: dict[str, Any]) -> str:
raw_url = str(bootstrap.get("ws_url") or "").strip()
if not raw_url:
raise TuiUnavailableError("gateway bootstrap response is missing ws_url")
parsed = urllib.parse.urlsplit(raw_url)
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
token = str(bootstrap.get("token") or "").strip()
if token:
query.append(("token", token))
query.append(("client_id", f"tui-{os.getpid()}"))
return urllib.parse.urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path, urllib.parse.urlencode(query), parsed.fragment)
)
port = int(settings.get("port") or 8765)
except (TypeError, ValueError):
port = 8765
secret = str(
settings.get("tokenIssueSecret")
or settings.get("token_issue_secret")
or settings.get("token")
or ""
).strip()
return f"http://{host}:{port}", secret
def _websocket_chat_id(session_id: str) -> str | None:
+3 -3
View File
@@ -466,8 +466,8 @@ class GatewayClientLease:
self._write_state(state)
return True
def release(self, *, timeout_s: int = 20) -> bool:
"""Release this client and stop an ephemeral gateway when it was the last."""
def release(self, *, timeout_s: int = 20, wait_for_stop: bool = True) -> bool:
"""Release this client, optionally leaving last-client shutdown to the monitor."""
if not self._acquired:
return False
while True:
@@ -482,7 +482,7 @@ class GatewayClientLease:
self._acquired = False
should_stop = not clients and bool(state.get("auto_stop"))
self._write_or_clear(state)
if not should_stop:
if not should_stop or not wait_for_stop:
return False
result = self.runtime._stop(timeout_s=timeout_s)
stopped = result.ok or result.message in {
+1 -1
View File
@@ -107,7 +107,7 @@ dev = [
]
[project.scripts]
nanobot = "nanobot.cli.commands:app"
nanobot = "nanobot.cli.entry:main"
# Third-party tool plugins register here. Built-in tools are discovered
# automatically via pkgutil scanning in ToolLoader.discover().
+15
View File
@@ -0,0 +1,15 @@
from nanobot.cli.entry import _native_tui_candidate
def test_native_agent_invocations_use_the_lightweight_entrypoint() -> None:
assert _native_tui_candidate(["agent"])
assert _native_tui_candidate(["agent", "--session", "websocket:chat"])
assert _native_tui_candidate(["agent", "--theme=light"])
def test_classic_and_one_shot_agent_invocations_keep_the_full_cli_entrypoint() -> None:
assert not _native_tui_candidate(["agent", "--classic"])
assert not _native_tui_candidate(["agent", "-m", "hello"])
assert not _native_tui_candidate(["agent", "-mhello"])
assert not _native_tui_candidate(["agent", "--message=hello"])
assert not _native_tui_candidate(["status"])
+96 -28
View File
@@ -13,7 +13,6 @@ from nanobot.cli.agent import agent
from nanobot.cli.tui_launcher import (
TuiSessionError,
TuiUnavailableError,
_authenticated_ws_url,
_download_release_tui,
_ensure_gateway,
_initial_tui_chat_id,
@@ -52,14 +51,6 @@ def _release_archive(
return payload, checksum
def test_authenticated_ws_url_preserves_existing_query(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("nanobot.cli.tui_launcher.os.getpid", lambda: 42)
url = _authenticated_ws_url(
{"ws_url": "ws://127.0.0.1:8765/ws?mode=local", "token": "a b"}
)
assert url == "ws://127.0.0.1:8765/ws?mode=local&token=a+b&client_id=tui-42"
@pytest.mark.parametrize(
("session_id", "expected"),
[
@@ -104,40 +95,43 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
config = Config(
channels={"websocket": {"tokenIssueSecret": "bootstrap-secret"}},
)
config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6")
config.agents.defaults.model_preset = "Deep Research"
captured: dict[str, str] = {}
events: list[str] = []
released: list[bool] = []
class FakeLease:
def release(self) -> None:
def release(self, *, wait_for_stop: bool = True) -> None:
assert wait_for_stop is False
released.append(True)
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
monkeypatch.setattr(
"nanobot.cli.tui_launcher._ensure_gateway",
lambda *args, **kwargs: SimpleNamespace(
def ensure_gateway(*args: object, **kwargs: object) -> SimpleNamespace:
assert events == ["spawned"]
assert kwargs["wait_until_ready"] is False
return SimpleNamespace(
base_url="http://127.0.0.1:8765",
lease=FakeLease(),
),
)
monkeypatch.setattr(
"nanobot.cli.tui_launcher._fetch_bootstrap",
lambda *args, **kwargs: {
"ws_url": "ws://127.0.0.1:8765/ws",
"token": "socket-token",
"api_token": "api-token",
},
)
)
def run(command: list[str], *, env: dict[str, str], check: bool) -> subprocess.CompletedProcess:
monkeypatch.setattr("nanobot.cli.tui_launcher._ensure_gateway", ensure_gateway)
class FakeProcess:
def wait(self) -> int:
events.append("waited")
return 0
def popen(command: list[str], *, env: dict[str, str]) -> FakeProcess:
assert command == ["nanobot-tui"]
assert check is False
captured.update(env)
return subprocess.CompletedProcess(command, 0)
events.append("spawned")
return FakeProcess()
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", run)
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.Popen", popen)
result = launch_tui(
config,
@@ -150,10 +144,57 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
assert result == 0
assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6"
assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research"
assert captured["NANOBOT_TUI_BOOTSTRAP_URL"] == (
"http://127.0.0.1:8765/webui/bootstrap"
)
assert captured["NANOBOT_TUI_BOOTSTRAP_SECRET"] == "bootstrap-secret"
assert "NANOBOT_TUI_WS_URL" not in captured
assert "NANOBOT_TUI_API_TOKEN" not in captured
assert "NANOBOT_TUI_CHAT_ID" not in captured
assert events == ["spawned", "waited"]
assert released == [True]
def test_launcher_terminates_the_tui_when_gateway_start_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
terminated: list[bool] = []
class FakeProcess:
def poll(self) -> None:
return None
def terminate(self) -> None:
terminated.append(True)
def wait(self, timeout: float | None = None) -> int:
assert timeout == 5
return 1
def fail_gateway(*args: object, **kwargs: object) -> None:
raise RuntimeError("gateway failed")
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
monkeypatch.setattr(
"nanobot.cli.tui_launcher.subprocess.Popen",
lambda *args, **kwargs: FakeProcess(),
)
monkeypatch.setattr("nanobot.cli.tui_launcher._ensure_gateway", fail_gateway)
with pytest.raises(RuntimeError, match="gateway failed"):
launch_tui(
config,
config_path=tmp_path / "config.json",
workspace_override=None,
session_id=None,
theme="dark",
)
assert terminated == [True]
def test_explicit_tui_binary_must_exist(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@@ -599,6 +640,33 @@ def test_gateway_reuses_the_matching_managed_instance(
assert gateway.base_url == "http://127.0.0.1:8765"
def test_gateway_reuse_can_return_before_the_webui_endpoint_is_ready(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
class FakeRuntime:
def __init__(self, *, paths: object) -> None:
self.paths = paths
def status(self) -> SimpleNamespace:
return SimpleNamespace(running=True, port=config.gateway.port)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
monkeypatch.setattr("nanobot.cli.tui_launcher._webui_endpoint_reachable", lambda _url: False)
gateway = _ensure_gateway(
config,
config_path=tmp_path / "config.json",
workspace_override=None,
wait_until_ready=False,
)
assert gateway.base_url == "http://127.0.0.1:8765"
assert gateway.lease is not None
def test_gateway_started_for_tui_stops_when_its_last_lease_exits(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+19
View File
@@ -479,6 +479,25 @@ def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatc
assert not webui.state_path.exists()
def test_last_client_can_leave_shutdown_to_the_gateway_monitor(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(
runtime,
"_stop",
lambda **_kwargs: pytest.fail("deferred release must not stop synchronously"),
)
client = GatewayClientLease(runtime, kind="tui", pid=os.getpid(), token="tui")
client.acquire()
client.mark_ephemeral()
assert client.release(wait_for_stop=False) is False
state = json.loads(client.state_path.read_text(encoding="utf-8"))
assert state == {"auto_stop": True, "clients": {}}
def test_last_client_shutdown_preserves_a_replacement_lease(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
+1 -1
View File
@@ -9,7 +9,7 @@ bun run --cwd tui test
bun run --cwd tui build
```
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes an authenticated local endpoint through environment variables. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
`nanobot agent` launches this client, leases the shared local gateway or starts it on demand, and passes the local bootstrap endpoint through environment variables. The client paints before gateway readiness, retries bootstrap in the background, and obtains fresh WebSocket and REST credentials for each connection. Other terminals and the WebUI keep that gateway alive; the final interactive launcher to exit releases the on-demand process. Only `nanobot gateway --background` makes it persistent without clients. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified archive that keeps the executable together with its licenses, notices, corresponding application source, source offer, and relinking instructions. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
Standalone terminals use OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
+9 -5
View File
@@ -37,7 +37,9 @@ def _wait_for(process: Any, needle: str, timeout: float) -> str:
output.append(_read(process, min(0.1, deadline - time.monotonic())))
text = "".join(output)
if needle not in text:
raise AssertionError(f"terminal output did not contain {needle!r}")
raise AssertionError(
f"terminal output did not contain {needle!r}; recent output: {text[-2000:]!r}"
)
return text
@@ -47,7 +49,7 @@ def _wait_for_exit(process: Any, timeout: float) -> int:
time.sleep(0.05)
if process.isalive():
process.close(force=True)
raise AssertionError("TUI did not exit after Ctrl+C")
raise AssertionError("TUI did not exit after the exit command")
return int(process.exitstatus or 0)
@@ -78,6 +80,8 @@ def main() -> int:
# width probes. Keep this smoke test focused on application behavior.
"OPENTUI_FORCE_EXPLICIT_WIDTH": "false",
}
env.pop("HERDR_ENV", None)
env.pop("HERDR_PANE_ID", None)
process = PtyProcess.spawn(
[bun, "src/index.ts"],
cwd=str(ROOT),
@@ -100,11 +104,11 @@ def main() -> int:
if "\x1b[18;" not in resized or ";42H" not in resized:
raise AssertionError("TUI did not repaint to the resized ConPTY dimensions")
# First Ctrl+C clears the draft. The second exits the app and must
# restore the alternate screen without an unhandled exception.
# Ctrl+C clears the draft. The local exit command must still work while
# the intentionally unavailable gateway has not attached a chat.
process.sendcontrol("c")
output.append(_read(process, 0.2))
process.sendcontrol("c")
process.write("exit\r")
output.append(_wait_for(process, LEAVE_ALT_SCREEN, 8))
exit_code = _wait_for_exit(process, 8)
finally:
+6 -4
View File
@@ -68,7 +68,7 @@ def _wait_for_exit(pid: int, timeout: float) -> int:
time.sleep(0.05)
os.kill(pid, signal.SIGKILL)
os.waitpid(pid, 0)
raise AssertionError("TUI did not exit after Ctrl+C")
raise AssertionError("TUI did not exit after the exit command")
def main() -> int:
@@ -90,6 +90,8 @@ def main() -> int:
# terminal emulator's optional OSC 10/11 response.
"NANOBOT_TUI_THEME": "dark",
}
env.pop("HERDR_ENV", None)
env.pop("HERDR_PANE_ID", None)
pid, master = pty.fork()
if pid == 0:
@@ -117,11 +119,11 @@ def main() -> int:
if b"\x1b[18;" not in resized or b";42H" not in resized:
raise AssertionError("TUI did not repaint to the resized PTY dimensions")
# First Ctrl+C clears the draft; the second exits and must restore the
# alternate screen without a prompt_toolkit-style traceback.
# Ctrl+C clears the draft. The local exit command must still work while
# the intentionally unavailable gateway has not attached a chat.
os.write(master, b"\x03")
output.extend(_read(master, 0.2))
os.write(master, b"\x03")
os.write(master, b"exit\r")
output.extend(_wait_for(master, LEAVE_ALT_SCREEN, 5))
exit_code = _wait_for_exit(pid, 5)
reaped = True
+23
View File
@@ -1488,7 +1488,9 @@ describe("NanobotTui layout", () => {
test("overlaps automatic terminal detection with connection startup", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
let connected = false
let rendered = false
let resolveMode: (mode: "light") => void = () => undefined
setup.renderer.start = () => { rendered = true }
setup.renderer.waitForThemeMode = () => new Promise((resolve) => {
resolveMode = resolve
})
@@ -1505,6 +1507,7 @@ describe("NanobotTui layout", () => {
const starting = app.start()
await Bun.sleep(1)
expect(connected).toBe(true)
expect(rendered).toBe(true)
resolveMode("light")
await starting
@@ -1994,6 +1997,26 @@ describe("NanobotTui layout", () => {
expect(closed).toBe(true)
expect(setup.renderer.isDestroyed).toBe(true)
})
test("accepts the exit command before the gateway connection is ready", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
let closed = false
const transport = client()
transport.close = () => { closed = true }
const app = NanobotTui.mount(
setup.renderer,
options,
transport,
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
const composer = (app as unknown as { composer: TextareaRenderable }).composer
composer.setText("exit")
composer.submit()
await waitUntil(() => closed)
expect(setup.renderer.isDestroyed).toBe(true)
})
})
describe("NanobotTui in a Herdr pane", () => {
+41 -12
View File
@@ -21,6 +21,7 @@ import {
import {
NanobotClient,
fetchHistory,
fetchGatewayConnection,
fetchMentionCandidates,
fetchSessionContext,
fetchSessions,
@@ -78,7 +79,9 @@ import {
import { createTuiHost, currentGitBranch, type TuiHost } from "./host"
interface AppOptions {
wsUrl: string
wsUrl?: string
bootstrapUrl?: string
bootstrapSecret?: string
apiUrl: string
apiToken: string
chatId?: string
@@ -472,7 +475,23 @@ export class NanobotTui {
)
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
this.client = client || new NanobotClient({
url: options.wsUrl,
...(options.bootstrapUrl
? {
resolveConnection: () => fetchGatewayConnection(
options.bootstrapUrl || "",
options.bootstrapSecret || "",
options.apiUrl,
`tui-${process.pid}`,
),
onConnection: (connection) => this.useGatewayConnection(
connection.apiUrl,
connection.apiToken,
),
connectionRetryLabel: "Starting local gateway",
reconnectDelayMs: 100,
startupRetryMaxDelayMs: 250,
}
: { url: options.wsUrl }),
chatId: options.chatId,
onEvent: (event) => this.accept(event),
onStatus: (status, detail) => this.handleStatus(status, detail),
@@ -719,16 +738,15 @@ export class NanobotTui {
void this.loadCommands()
void this.loadMentions()
this.runtimeControls.preload()
this.renderer.start()
// OpenTUI learns the real terminal background through OSC 10/11. Wait for
// that bounded probe before first paint, as OpenCode does, so a light
// terminal does not briefly render the dark palette. The app already owns
// the renderer here, so a signal during the probe can still restore it.
// that bounded probe after first paint. The neutral terminal background is
// safe to render immediately, and the detected palette can be applied later.
if (this.options.theme === "auto") await this.renderer.waitForThemeMode(1_000)
if (this.quitting) return
if (this.options.theme === "auto" && this.renderer.themeMode) {
this.applyTheme(this.renderer.themeMode)
}
this.renderer.start()
}
stop(): void {
@@ -775,6 +793,10 @@ export class NanobotTui {
return
}
if (!visibleContent) return
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
this.quit()
return
}
const completion = this.commandMenu.completion(visibleContent)
if (completion) {
this.setComposer(completion)
@@ -804,10 +826,6 @@ export class NanobotTui {
this.status.content = "Preparing chat…"
return
}
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
this.quit()
return
}
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
if (this.activeTurn) {
this.sendPrompt(prompt, true)
@@ -1132,6 +1150,14 @@ export class NanobotTui {
for (const event of events || []) this.accept(event)
}
private useGatewayConnection(apiUrl: string, apiToken: string): void {
this.options.apiUrl = apiUrl
this.options.apiToken = apiToken
this.runtimeControls.useApiConnection(apiUrl, apiToken)
void this.loadCommands()
void this.loadMentions()
}
private handleStatus(status: ConnectionStatus, detail?: string): void {
if (status === "connected") {
this.ready = false
@@ -1141,9 +1167,12 @@ export class NanobotTui {
}
if (status === "connecting") {
this.ready = false
this.host.reportState("unknown", detail ? "Reconnecting" : "Connecting")
const label = detail === "Starting local gateway"
? detail
: detail ? "Reconnecting" : "Connecting"
this.host.reportState("unknown", label)
if (detail) this.setActive(false)
this.status.content = detail ? "Reconnecting…" : "Connecting…"
this.status.content = `${label}`
return
}
if (status === "error") {
+11 -7
View File
@@ -1,12 +1,6 @@
import { NanobotTui, type AppOptions } from "./app"
import { currentGitBranch } from "./host"
function required(name: string): string {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required`)
return value
}
function themePreference(): AppOptions["theme"] {
const value = process.env.NANOBOT_TUI_THEME?.trim() || "auto"
if (value === "auto" || value === "dark" || value === "light") return value
@@ -15,8 +9,18 @@ function themePreference(): AppOptions["theme"] {
const workspace = process.env.NANOBOT_TUI_WORKSPACE?.trim() || ""
const hostWorkspace = process.cwd()
const bootstrapUrl = process.env.NANOBOT_TUI_BOOTSTRAP_URL?.trim() || ""
const wsUrl = process.env.NANOBOT_TUI_WS_URL?.trim() || ""
if (!bootstrapUrl && !wsUrl) {
throw new Error("NANOBOT_TUI_BOOTSTRAP_URL or NANOBOT_TUI_WS_URL is required")
}
const options: AppOptions = {
wsUrl: required("NANOBOT_TUI_WS_URL"),
...(bootstrapUrl
? {
bootstrapUrl,
bootstrapSecret: process.env.NANOBOT_TUI_BOOTSTRAP_SECRET?.trim() || "",
}
: { wsUrl }),
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
apiToken: process.env.NANOBOT_TUI_API_TOKEN?.trim() || "",
chatId: process.env.NANOBOT_TUI_CHAT_ID?.trim() || undefined,
+152
View File
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
import {
NanobotClient,
GatewayConnectionError,
fetchGatewayConnection,
fetchHistory,
fetchMentionCandidates,
fetchRuntimeControls,
@@ -37,6 +39,156 @@ class FakeSocket {
}
describe("gateway protocol", () => {
test("bootstraps fresh websocket and API credentials", async () => {
const original = globalThis.fetch
let headers: Headers | undefined
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
headers = new Headers(init?.headers)
return new Response(JSON.stringify({
ws_url: "ws://nanobot.test/ws?mode=local",
token: "socket token",
api_token: "api-token",
}))
}) as typeof fetch
try {
const connection = await fetchGatewayConnection(
"http://nanobot.test/webui/bootstrap",
"bootstrap-secret",
"http://nanobot.test",
"tui-42",
)
expect(headers?.get("X-Nanobot-Auth")).toBe("bootstrap-secret")
expect(connection).toEqual({
wsUrl: "ws://nanobot.test/ws?mode=local&token=socket+token&client_id=tui-42",
apiUrl: "http://nanobot.test",
apiToken: "api-token",
})
} finally {
globalThis.fetch = original
}
})
test("rejects malformed bootstrap responses without retrying", async () => {
const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response("not json"))) as unknown as typeof fetch
try {
await expect(fetchGatewayConnection(
"http://nanobot.test/webui/bootstrap",
"bootstrap-secret",
"http://nanobot.test",
"tui-42",
)).rejects.toMatchObject({
message: "gateway bootstrap response is invalid",
retryable: false,
})
} finally {
globalThis.fetch = original
}
})
test("waits for bootstrap before opening the websocket", async () => {
const original = globalThis.WebSocket
let resolveConnection: ((value: {
wsUrl: string
apiUrl: string
apiToken: string
}) => void) | undefined
let requestedUrl = ""
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor(url: string) {
super()
requestedUrl = url
}
},
})
try {
const connections: string[] = []
const client = new NanobotClient({
resolveConnection: () => new Promise((resolve) => { resolveConnection = resolve }),
onConnection: (connection) => connections.push(connection.apiToken),
onEvent: () => undefined,
onStatus: () => undefined,
})
client.connect()
await Bun.sleep(1)
expect(requestedUrl).toBe("")
resolveConnection?.({
wsUrl: "ws://nanobot.test/ws?token=fresh",
apiUrl: "http://nanobot.test",
apiToken: "fresh-api-token",
})
await Bun.sleep(1)
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=fresh")
expect(connections).toEqual(["fresh-api-token"])
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("retries bootstrap while the local gateway starts", async () => {
const original = globalThis.WebSocket
let attempts = 0
let requestedUrl = ""
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor(url: string) {
super()
requestedUrl = url
}
},
})
try {
const client = new NanobotClient({
resolveConnection: async () => {
attempts += 1
if (attempts === 1) throw new Error("gateway is still starting")
return {
wsUrl: "ws://nanobot.test/ws?token=second",
apiUrl: "http://nanobot.test",
apiToken: "second-api-token",
}
},
reconnectDelayMs: 1,
onEvent: () => undefined,
onStatus: () => undefined,
})
client.connect()
for (let index = 0; index < 20 && !requestedUrl; index += 1) await Bun.sleep(2)
expect(attempts).toBe(2)
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=second")
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("reports a permanent bootstrap rejection without retrying", async () => {
let attempts = 0
const statuses: string[] = []
const client = new NanobotClient({
resolveConnection: async () => {
attempts += 1
throw new GatewayConnectionError("gateway bootstrap failed: HTTP 401", false)
},
reconnectDelayMs: 1,
onEvent: () => undefined,
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
})
client.connect()
await Bun.sleep(5)
expect(attempts).toBe(1)
expect(statuses.at(-1)).toBe("error:gateway bootstrap failed: HTTP 401")
client.close()
})
test("represents lifecycle frames without browser state", () => {
const events: InboundEvent[] = [
{
+110 -9
View File
@@ -151,13 +151,30 @@ type OutboundEvent =
}
export interface ClientOptions {
url: string
url?: string
resolveConnection?: () => Promise<GatewayConnection>
onConnection?: (connection: GatewayConnection) => void
connectionRetryLabel?: string
startupRetryMaxDelayMs?: number
chatId?: string
reconnectDelayMs?: number
onEvent: (event: InboundEvent) => void
onStatus: (status: ConnectionStatus, detail?: string) => void
}
export interface GatewayConnection {
wsUrl: string
apiUrl: string
apiToken: string
}
export class GatewayConnectionError extends Error {
constructor(message: string, readonly retryable: boolean) {
super(message)
this.name = "GatewayConnectionError"
}
}
export interface HistoryMessage {
role: "user" | "assistant" | "activity"
content: string
@@ -747,12 +764,63 @@ function sessionLabelForMention(session: SessionSummary): string {
return (session.title || session.preview || "Untitled chat").replace(/\s+/gu, " ").trim()
}
/** Resolve fresh short-lived credentials once the local gateway is reachable. */
export async function fetchGatewayConnection(
bootstrapUrl: string,
bootstrapSecret: string,
apiUrl: string,
clientId: string,
): Promise<GatewayConnection> {
const response = await fetch(bootstrapUrl, {
headers: bootstrapSecret ? { "X-Nanobot-Auth": bootstrapSecret } : {},
})
if (!response.ok) {
const retryable = response.status === 408 || response.status === 429 || response.status >= 500
throw new GatewayConnectionError(
`gateway bootstrap failed: HTTP ${response.status}`,
retryable,
)
}
let payload: unknown
try {
payload = await response.json()
} catch {
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
}
if (!isRecord(payload)) {
throw new GatewayConnectionError("gateway bootstrap response is invalid", false)
}
if (typeof payload.ws_url !== "string" || !payload.ws_url.trim()) {
throw new GatewayConnectionError("gateway bootstrap response is missing ws_url", false)
}
let wsUrl: URL
try {
wsUrl = new URL(payload.ws_url)
} catch {
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
}
if (wsUrl.protocol !== "ws:" && wsUrl.protocol !== "wss:") {
throw new GatewayConnectionError("gateway bootstrap response has an invalid ws_url", false)
}
if (typeof payload.token === "string" && payload.token) {
wsUrl.searchParams.append("token", payload.token)
}
wsUrl.searchParams.append("client_id", clientId)
return {
wsUrl: wsUrl.toString(),
apiUrl,
apiToken: typeof payload.api_token === "string" ? payload.api_token : "",
}
}
export class NanobotClient {
private socket: WebSocket | null = null
private chatId = ""
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempt = 0
private closedByClient = false
private opening = false
private connectedOnce = false
constructor(private readonly options: ClientOptions) {}
@@ -762,16 +830,46 @@ export class NanobotClient {
connect(): void {
this.closedByClient = false
this.open()
void this.open()
}
private open(): void {
if (this.socket) return
private async open(): Promise<void> {
if (this.socket || this.opening || this.closedByClient) return
this.opening = true
this.options.onStatus("connecting")
const socket = new WebSocket(this.options.url)
let url = this.options.url
try {
if (this.options.resolveConnection) {
const connection = await this.options.resolveConnection()
if (this.closedByClient) return
this.options.onConnection?.(connection)
url = connection.wsUrl
}
} catch (error) {
if (!this.closedByClient) {
if (error instanceof GatewayConnectionError && !error.retryable) {
this.options.onStatus("error", error.message)
return
}
this.options.onStatus(
"connecting",
this.options.connectionRetryLabel || "gateway unavailable",
)
this.scheduleReconnect(false)
}
return
} finally {
this.opening = false
}
if (!url) {
this.options.onStatus("error", "gateway URL is not configured")
return
}
const socket = new WebSocket(url)
this.socket = socket
socket.addEventListener("open", () => {
if (this.socket !== socket) return
this.connectedOnce = true
this.reconnectAttempt = 0
this.options.onStatus("connected")
})
@@ -872,14 +970,17 @@ export class NanobotClient {
this.options.onEvent(event)
}
private scheduleReconnect(): void {
private scheduleReconnect(announce = true): void {
if (this.reconnectTimer || this.closedByClient) return
const base = this.options.reconnectDelayMs ?? 500
const delay = Math.min(8_000, base * 2 ** Math.min(this.reconnectAttempt++, 4))
this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
const maxDelay = this.connectedOnce
? 8_000
: this.options.startupRetryMaxDelayMs ?? 8_000
const delay = Math.min(maxDelay, base * 2 ** Math.min(this.reconnectAttempt++, 4))
if (announce) this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.open()
void this.open()
}, delay)
}
+8
View File
@@ -112,6 +112,14 @@ export class RuntimeControls {
void this.load().catch(() => {})
}
useApiConnection(apiUrl: string, apiToken: string): void {
this.options.apiUrl = apiUrl
this.options.apiToken = apiToken
this.controlsLoaded = false
this.controlsLoadedAt = 0
this.preload()
}
updateWorkspaceScope(scope: WorkspaceScopePayload): void {
this.scope = scope
this.render()