mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
feat(cli): add native TypeScript terminal UI
Rebuild the terminal client on OpenTUI while keeping the Python gateway as the single agent, session, tool, and memory runtime. Preserve a classic prompt fallback and publish version-matched native sidecars for supported platforms. Co-authored-by: Bingxi Zhao <150592536+pancacake@users.noreply.github.com> Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
co-authored by
Bingxi Zhao
chengyongru
parent
c27b1f14c3
commit
ce070c832d
@@ -179,6 +179,35 @@ jobs:
|
||||
working-directory: webui
|
||||
run: bun run build
|
||||
|
||||
tui:
|
||||
name: Terminal UI
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
|
||||
- name: Install TUI dependencies
|
||||
working-directory: tui
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Check TUI
|
||||
working-directory: tui
|
||||
run: bun run check
|
||||
|
||||
- name: Test TUI
|
||||
working-directory: tui
|
||||
run: bun run test
|
||||
|
||||
- name: Build TUI
|
||||
working-directory: tui
|
||||
run: bun run build
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Publish Terminal UI
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-15
|
||||
target: darwin-arm64
|
||||
- os: macos-15-intel
|
||||
target: darwin-x64
|
||||
- os: ubuntu-latest
|
||||
target: linux-x64
|
||||
- os: windows-latest
|
||||
target: win32-x64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: tui
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build ${{ matrix.target }}
|
||||
working-directory: tui
|
||||
run: bun run build -- ${{ matrix.target }}
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
shell: bash
|
||||
run: gh release upload "$TAG" tui/dist/nanobot-tui-* --clobber
|
||||
@@ -16,6 +16,8 @@ webui/node_modules/
|
||||
webui/dist/
|
||||
webui/coverage/
|
||||
webui/.vite/
|
||||
tui/node_modules/
|
||||
tui/dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Python bytecode & caches
|
||||
|
||||
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
This opens an interactive terminal chat with the same configured model, workspace, and tools while keeping its own CLI session history. It does not open a browser or keep chat channels and automations running after you exit. Type `exit` or press `Ctrl+C` when you are done.
|
||||
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. It keeps a terminal-specific session, starts a local gateway only when needed, and releases that gateway 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:
|
||||
|
||||
|
||||
+10
-3
@@ -91,8 +91,9 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
|
||||
| `nanobot agent --session <id>` | Use a specific session key |
|
||||
| `nanobot agent --workspace <path>` | Override workspace |
|
||||
| `nanobot agent --config <path>` | Use a specific config file |
|
||||
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
|
||||
| `nanobot agent --logs` | Show runtime logs while chatting |
|
||||
| `nanobot agent --classic` | Use the classic Python prompt instead of the native terminal UI |
|
||||
| `nanobot agent --no-markdown` | Use the classic prompt and print plain text instead of Markdown |
|
||||
| `nanobot agent --logs` | Use the classic prompt and show runtime logs while chatting |
|
||||
|
||||
## Session Storage and Rollback
|
||||
|
||||
@@ -112,7 +113,13 @@ 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.
|
||||
|
||||
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
|
||||
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, the command starts one for the lifetime of the terminal UI and stops it on exit.
|
||||
|
||||
`Enter` sends the current message. Press `Alt+Enter` to add a newline. `Ctrl+C` stops a running turn, clears a non-empty composer, or exits when idle. Normal terminal scrollback remains available above the fixed composer.
|
||||
|
||||
Packaged releases fetch a version-matched, checksummed terminal binary for macOS, Linux, or Windows on first use and cache it under the nanobot data directory. 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`.
|
||||
|
||||
Non-interactive input/output, `--logs`, and `--no-markdown` automatically retain the classic prompt so existing scripts and diagnostic workflows do not acquire terminal control sequences or silently ignore their options.
|
||||
|
||||
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||
|
||||
|
||||
+34
-1
@@ -47,7 +47,7 @@ console = Console()
|
||||
|
||||
|
||||
def agent(
|
||||
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
|
||||
message: str | None = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
|
||||
session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
@@ -61,6 +61,12 @@ def agent(
|
||||
"--logs/--no-logs",
|
||||
help="Show nanobot runtime logs during chat",
|
||||
),
|
||||
classic: bool = typer.Option(
|
||||
False,
|
||||
"--classic",
|
||||
"--no-tui",
|
||||
help="Use the classic Python prompt instead of the native terminal UI",
|
||||
),
|
||||
):
|
||||
"""Interact with the agent directly."""
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -69,6 +75,33 @@ def agent(
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
|
||||
runtime_config = _load_runtime_config(config, workspace)
|
||||
native_tui = (
|
||||
message is None
|
||||
and not classic
|
||||
and markdown
|
||||
and not logs
|
||||
and sys.stdin.isatty()
|
||||
and sys.stdout.isatty()
|
||||
)
|
||||
if native_tui:
|
||||
from nanobot.cli.tui_launcher import TuiUnavailableError, launch_tui
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
try:
|
||||
exit_code = launch_tui(
|
||||
runtime_config,
|
||||
config_path=get_config_path().resolve(strict=False),
|
||||
workspace_override=workspace,
|
||||
session_id=session_id,
|
||||
)
|
||||
except TuiUnavailableError as exc:
|
||||
console.print(f"[yellow]Native TUI unavailable: {exc}[/yellow]")
|
||||
console.print("[dim]Falling back to the classic prompt.[/dim]")
|
||||
else:
|
||||
if exit_code:
|
||||
raise typer.Exit(exit_code)
|
||||
return
|
||||
|
||||
try:
|
||||
provider = make_provider(runtime_config)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Launch the TypeScript terminal client against the local gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from nanobot import __version__
|
||||
from nanobot.cli.runtime_config import _model_display
|
||||
from nanobot.cli.webui_support import (
|
||||
_gateway_health_ready,
|
||||
_webui_browser_url,
|
||||
_webui_endpoint_reachable,
|
||||
webui_bootstrap_secret,
|
||||
)
|
||||
from nanobot.config.paths import get_data_dir
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
class TuiUnavailableError(RuntimeError):
|
||||
"""Raised when the native TypeScript TUI cannot run on this installation."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _GatewayLease:
|
||||
runtime: Any
|
||||
owned: bool
|
||||
base_url: str
|
||||
|
||||
def close(self) -> None:
|
||||
if self.owned:
|
||||
self.runtime.stop(timeout_s=20)
|
||||
|
||||
|
||||
def launch_tui(
|
||||
config: Config,
|
||||
*,
|
||||
config_path: Path,
|
||||
workspace_override: str | None,
|
||||
session_id: str,
|
||||
) -> int:
|
||||
"""Run the native TUI, owning a local gateway only when one is not running."""
|
||||
command = _resolve_tui_command()
|
||||
lease = _ensure_gateway(
|
||||
config,
|
||||
config_path=config_path,
|
||||
workspace_override=workspace_override,
|
||||
)
|
||||
try:
|
||||
bootstrap = _fetch_bootstrap(
|
||||
lease.base_url,
|
||||
secret=webui_bootstrap_secret(config),
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap),
|
||||
"NANOBOT_TUI_API_URL": lease.base_url,
|
||||
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
|
||||
"NANOBOT_TUI_MODEL": _model_display(config)[0],
|
||||
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
|
||||
"NANOBOT_TUI_VERSION": __version__,
|
||||
"NANOBOT_TUI_ACCESS": (
|
||||
"workspace access" if config.tools.restrict_to_workspace else "full access"
|
||||
),
|
||||
}
|
||||
)
|
||||
chat_id = _websocket_chat_id(session_id)
|
||||
if chat_id:
|
||||
env["NANOBOT_TUI_CHAT_ID"] = chat_id
|
||||
else:
|
||||
env.pop("NANOBOT_TUI_CHAT_ID", None)
|
||||
try:
|
||||
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
|
||||
finally:
|
||||
lease.close()
|
||||
|
||||
|
||||
def _resolve_tui_command() -> list[str]:
|
||||
override = os.environ.get("NANOBOT_TUI_BIN", "").strip()
|
||||
if override:
|
||||
executable = Path(override).expanduser().resolve(strict=False)
|
||||
if not executable.is_file():
|
||||
raise TuiUnavailableError(f"NANOBOT_TUI_BIN does not exist: {executable}")
|
||||
return [str(executable)]
|
||||
|
||||
suffix = ".exe" if os.name == "nt" else ""
|
||||
system = {"Windows": "win32", "Darwin": "darwin", "Linux": "linux"}.get(
|
||||
platform.system(),
|
||||
platform.system().lower(),
|
||||
)
|
||||
machine = {"x86_64": "x64", "AMD64": "x64", "aarch64": "arm64"}.get(
|
||||
platform.machine(),
|
||||
platform.machine().lower(),
|
||||
)
|
||||
asset = f"nanobot-tui-{system}-{machine}{suffix}"
|
||||
packaged = Path(__file__).resolve().parents[1] / "tui" / "bin" / asset
|
||||
if packaged.is_file():
|
||||
return [str(packaged)]
|
||||
|
||||
source_dir = Path(__file__).resolve().parents[2] / "tui"
|
||||
bun = shutil.which("bun")
|
||||
if bun and (source_dir / "package.json").is_file():
|
||||
if not (source_dir / "node_modules" / "@opentui" / "core").is_dir():
|
||||
raise TuiUnavailableError(
|
||||
f"TUI dependencies are missing; run `bun install --cwd {source_dir}`"
|
||||
)
|
||||
return [bun, str(source_dir / "src" / "index.ts")]
|
||||
|
||||
downloaded = _download_release_tui(asset)
|
||||
if downloaded is not None:
|
||||
return [str(downloaded)]
|
||||
|
||||
raise TuiUnavailableError(
|
||||
"this build does not include the native TUI; install Bun for a source checkout "
|
||||
"or use `nanobot agent --classic`"
|
||||
)
|
||||
|
||||
|
||||
def _download_release_tui(asset: str) -> Path | None:
|
||||
"""Install the version-matched release sidecar into nanobot's data directory."""
|
||||
if os.environ.get("NANOBOT_TUI_NO_DOWNLOAD") == "1":
|
||||
return None
|
||||
version = __version__.strip()
|
||||
if not version or version.endswith((".dev0", "+dev")):
|
||||
return None
|
||||
|
||||
target_dir = get_data_dir() / "bin" / "tui" / version
|
||||
target = target_dir / asset
|
||||
if target.is_file():
|
||||
return target
|
||||
|
||||
base = f"https://github.com/HKUDS/nanobot/releases/download/v{version}"
|
||||
try:
|
||||
checksum = _read_release_asset(f"{base}/{asset}.sha256", max_bytes=1024).decode()
|
||||
expected = checksum.split()[0].lower()
|
||||
if len(expected) != 64:
|
||||
return None
|
||||
binary = _read_release_asset(f"{base}/{asset}", max_bytes=150 * 1024 * 1024)
|
||||
except (OSError, TimeoutError, urllib.error.URLError, urllib.error.HTTPError):
|
||||
return None
|
||||
if hashlib.sha256(binary).hexdigest() != expected:
|
||||
raise TuiUnavailableError("downloaded TUI binary failed checksum verification")
|
||||
|
||||
temporary = target.with_suffix(f"{target.suffix}.tmp-{os.getpid()}")
|
||||
try:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary.write_bytes(binary)
|
||||
if os.name != "nt":
|
||||
temporary.chmod(0o755)
|
||||
temporary.replace(target)
|
||||
except OSError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def _read_release_asset(url: str, *, max_bytes: int) -> bytes:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": f"nanobot/{__version__}"})
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length and int(content_length) > max_bytes:
|
||||
raise OSError("release asset exceeds size limit")
|
||||
body = response.read(max_bytes + 1)
|
||||
if len(body) > max_bytes:
|
||||
raise OSError("release asset exceeds size limit")
|
||||
return body
|
||||
|
||||
|
||||
def _ensure_gateway(
|
||||
config: Config,
|
||||
*,
|
||||
config_path: Path,
|
||||
workspace_override: str | None,
|
||||
) -> _GatewayLease:
|
||||
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
||||
|
||||
base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/")
|
||||
if _webui_endpoint_reachable(base_url):
|
||||
return _GatewayLease(runtime=None, owned=False, base_url=base_url)
|
||||
|
||||
workspace = (
|
||||
str(Path(workspace_override).expanduser().resolve(strict=False))
|
||||
if workspace_override
|
||||
else None
|
||||
)
|
||||
runtime = GatewayRuntime(
|
||||
paths=GatewayRuntimePaths.for_instance(
|
||||
data_dir=config_path.parent,
|
||||
workspace=workspace,
|
||||
config_path=str(config_path),
|
||||
)
|
||||
)
|
||||
result = runtime.start_background(
|
||||
GatewayStartOptions(
|
||||
port=config.gateway.port,
|
||||
workspace=workspace,
|
||||
config_path=str(config_path),
|
||||
)
|
||||
)
|
||||
owned = result.ok
|
||||
if not result.ok and result.message != "gateway_already_running":
|
||||
raise TuiUnavailableError(
|
||||
f"could not start the local gateway ({result.message}); logs: {result.status.log_path}"
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 20
|
||||
while time.monotonic() < deadline:
|
||||
if _webui_endpoint_reachable(base_url):
|
||||
return _GatewayLease(runtime=runtime, owned=owned, base_url=base_url)
|
||||
if not runtime.status().running and not _gateway_health_ready(
|
||||
config.gateway.host,
|
||||
config.gateway.port,
|
||||
):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
if owned:
|
||||
runtime.stop(timeout_s=5)
|
||||
raise TuiUnavailableError(
|
||||
f"local gateway did not become ready; logs: {result.status.log_path}"
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
def _websocket_chat_id(session_id: str) -> str | None:
|
||||
"""Map the CLI selector to the WebSocket namespace used by the native TUI."""
|
||||
if session_id.startswith("websocket:"):
|
||||
return session_id.split(":", 1)[1] or None
|
||||
if session_id == "cli:direct":
|
||||
return "tui-direct"
|
||||
return session_id.split(":", 1)[-1] or None
|
||||
@@ -49,6 +49,7 @@ __all__ = [
|
||||
"_validate_gateway_startup",
|
||||
"_warn_webui_bind_scope",
|
||||
"_webui_browser_url",
|
||||
"webui_bootstrap_secret",
|
||||
"_webui_build_mode_for_interactive",
|
||||
"_webui_channel_enabled",
|
||||
"_webui_display_url",
|
||||
@@ -224,7 +225,8 @@ def _gateway_health_bind_note(host: str) -> str:
|
||||
return "" if is_loopback_host(host) else f" [dim](listening on {host})[/dim]"
|
||||
|
||||
|
||||
def _webui_bootstrap_secret(config: Config) -> str:
|
||||
def webui_bootstrap_secret(config: Config) -> str:
|
||||
"""Return the shared local bootstrap credential for WebUI protocol clients."""
|
||||
ws_cfg = _webui_config_dict(config)
|
||||
return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip()
|
||||
|
||||
@@ -236,7 +238,7 @@ def _webui_browser_url(config: Config) -> str:
|
||||
host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
|
||||
port = int(ws_cfg.get("port") or 8765)
|
||||
base_url = f"http://{host}:{port}"
|
||||
secret = _webui_bootstrap_secret(config)
|
||||
secret = webui_bootstrap_secret(config)
|
||||
if not secret:
|
||||
return base_url
|
||||
return f"{base_url}/#/?bootstrapSecret={quote(secret, safe='')}"
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_interactive_agent_routes_a_complete_user_turn(
|
||||
monkeypatch.setattr("nanobot.cli.terminal._read_interactive_input_async", read_input)
|
||||
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", print_response)
|
||||
|
||||
result = runner.invoke(app, ["agent", "--session", "cli:journey"])
|
||||
result = runner.invoke(app, ["agent", "--classic", "--session", "cli:journey"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
inbound = seen["inbound"]
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.cli.agent import agent
|
||||
from nanobot.cli.tui_launcher import (
|
||||
TuiUnavailableError,
|
||||
_authenticated_ws_url,
|
||||
_download_release_tui,
|
||||
_resolve_tui_command,
|
||||
_websocket_chat_id,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
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"),
|
||||
[
|
||||
("cli:direct", "tui-direct"),
|
||||
("websocket:abc", "abc"),
|
||||
("abc", "abc"),
|
||||
],
|
||||
)
|
||||
def test_websocket_chat_id(session_id: str, expected: str | None) -> None:
|
||||
assert _websocket_chat_id(session_id) == expected
|
||||
|
||||
|
||||
def test_explicit_tui_binary_must_exist(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
missing = tmp_path / "missing"
|
||||
monkeypatch.setenv("NANOBOT_TUI_BIN", str(missing))
|
||||
with pytest.raises(TuiUnavailableError, match="does not exist"):
|
||||
_resolve_tui_command()
|
||||
|
||||
|
||||
def test_interactive_agent_uses_native_tui(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config = Config()
|
||||
config_path = tmp_path / "config.json"
|
||||
launched: dict[str, object] = {}
|
||||
|
||||
def launch(*args: object, **kwargs: object) -> int:
|
||||
launched["args"] = args
|
||||
launched["kwargs"] = kwargs
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: config)
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.launch_tui", launch)
|
||||
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("nanobot.cli.agent.sys.stdin", SimpleNamespace(isatty=lambda: True))
|
||||
monkeypatch.setattr("nanobot.cli.agent.sys.stdout", SimpleNamespace(isatty=lambda: True))
|
||||
|
||||
agent(
|
||||
message=None,
|
||||
session_id="websocket:terminal-chat",
|
||||
workspace=None,
|
||||
config=None,
|
||||
markdown=True,
|
||||
logs=False,
|
||||
classic=False,
|
||||
)
|
||||
|
||||
assert launched["args"] == (config,)
|
||||
assert launched["kwargs"] == {
|
||||
"config_path": config_path,
|
||||
"workspace_override": None,
|
||||
"session_id": "websocket:terminal-chat",
|
||||
}
|
||||
|
||||
|
||||
def test_release_tui_is_verified_and_cached(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
binary = b"native-tui"
|
||||
digest = hashlib.sha256(binary).hexdigest().encode()
|
||||
downloads: list[str] = []
|
||||
|
||||
def read_asset(url: str, *, max_bytes: int) -> bytes:
|
||||
downloads.append(url)
|
||||
return digest if url.endswith(".sha256") else binary
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.__version__", "9.9.9")
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.get_data_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher._read_release_asset", read_asset)
|
||||
|
||||
target = _download_release_tui("nanobot-tui-linux-x64")
|
||||
|
||||
assert target == tmp_path / "bin" / "tui" / "9.9.9" / "nanobot-tui-linux-x64"
|
||||
assert target.read_bytes() == binary
|
||||
assert len(downloads) == 2
|
||||
|
||||
assert _download_release_tui("nanobot-tui-linux-x64") == target
|
||||
assert len(downloads) == 2
|
||||
|
||||
|
||||
def test_release_tui_rejects_bad_checksum(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.__version__", "9.9.9")
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.get_data_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.tui_launcher._read_release_asset",
|
||||
lambda url, *, max_bytes: b"0" * 64 if url.endswith(".sha256") else b"tampered",
|
||||
)
|
||||
|
||||
with pytest.raises(TuiUnavailableError, match="checksum"):
|
||||
_download_release_tui("nanobot-tui-linux-x64")
|
||||
@@ -0,0 +1,14 @@
|
||||
# nanobot Terminal UI
|
||||
|
||||
The terminal UI is a TypeScript client for nanobot's existing WebSocket gateway. It owns presentation and input only; the Python gateway remains the single implementation of sessions, the agent loop, tools, memory, and security policy.
|
||||
|
||||
```bash
|
||||
bun install --cwd tui
|
||||
bun run --cwd tui check
|
||||
bun run --cwd tui test
|
||||
bun run --cwd tui build
|
||||
```
|
||||
|
||||
`nanobot agent` launches this client, attaches to an existing local gateway or leases one for the process lifetime, and passes an authenticated local endpoint through environment variables. Use `nanobot agent --classic` to run the legacy Python prompt.
|
||||
|
||||
The renderer uses OpenTUI's split-footer mode: transcript rows are committed to native terminal scrollback while the composer remains fixed at the bottom. This preserves normal terminal selection and scrolling instead of implementing a second scroll model.
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@nanobot/tui",
|
||||
"dependencies": {
|
||||
"@opentui/core": "0.5.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.13",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@opentui/core": ["@opentui/core@0.5.1", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.1", "@opentui/core-darwin-x64": "0.5.1", "@opentui/core-linux-arm64": "0.5.1", "@opentui/core-linux-arm64-musl": "0.5.1", "@opentui/core-linux-x64": "0.5.1", "@opentui/core-linux-x64-musl": "0.5.1", "@opentui/core-win32-arm64": "0.5.1", "@opentui/core-win32-x64": "0.5.1" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-mIBFyqIP4rkhQ35uldLXWawWQ6S9tvNWvmxGmDJ7W9cLXjegG6gKEfZ/4NyIMma755ERs/sqO/pIh3Ytf3DDFg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Yl3JBLYRrBN+SxXY/gYaqCT/JNrN50K4xO7hYC+/Si8/FgOrBlbRmfJIUNQdZMMLUvOMA+I813+hDw3xfarBzQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-kqMVu+LGuHSCxYFkVJtmuyLLLTMztILSNnlx1eSpHHUiDV4PMc+zkxwRIXO+o0TFTW3gNUKleKUkggriYje7Vw=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-PpE1nCHRkxEvSYyZFMToPHjQoVh50A7+BbgetlTX/5ImXzo6iSO83a+7M/1WgZnNu+uZJf5GZKAAcLoRrvQl3Q=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-rmFMtiCm8I0fESB834sTN/ewoI+QDSber588ZO+i08JR6mbv7hkiKW2H/MhiAY1GxGK4nXApleBMyGVOlVDvgQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-/CxFxFv+ffMof2nYQrpgEfNkWKkKxYUSfwdt2RdDN5fZRhcxjE949743rV0Oovw5Az63qxPgbyfcZVNVO2HVNg=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-WO8RjhqKyqW/7P0xHdEVT8JGfU2MO7RlK0kdkNnRSnAEVwsTNd2ibhmKDPLGpo/DKaLuA00CsnrNiLGZZiQJKQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-AgeTjZbdMxSiuBjyLvcug91qd1Ds6Dlg5z4lCInqL7mPQicDEnKZs5lF2FAaktcU7RPi2wLybbQ/vM0NbpXYmw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VttbQHVoZQ5uW5IcQeUHPEx/WFQ2mMflukhhbBjpNSdZOPdzmmC4QGFPQznJVwuzXTnjQ2Nll4AY0ROJ/Q3nkw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@nanobot/tui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun src/index.ts",
|
||||
"build": "bun scripts/build.ts",
|
||||
"check": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.13",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { chmod, mkdir } from "node:fs/promises"
|
||||
import { basename, join } from "node:path"
|
||||
|
||||
const target = process.argv[2] || `${process.platform}-${process.arch}`
|
||||
const [platform, arch] = target.split("-")
|
||||
|
||||
if (!platform || !arch || !["darwin", "linux", "win32"].includes(platform)) {
|
||||
throw new Error(`unsupported target: ${target}`)
|
||||
}
|
||||
|
||||
const bunPlatform = platform === "win32" ? "windows" : platform
|
||||
const extension = platform === "win32" ? ".exe" : ""
|
||||
const outputDir = join(import.meta.dir, "..", "dist")
|
||||
const output = join(outputDir, `nanobot-tui-${platform}-${arch}${extension}`)
|
||||
|
||||
await mkdir(outputDir, { recursive: true })
|
||||
const result = await Bun.build({
|
||||
entrypoints: [join(import.meta.dir, "..", "src", "index.ts")],
|
||||
compile: {
|
||||
target: `bun-${bunPlatform}-${arch}` as Bun.Build.CompileTarget,
|
||||
outfile: output,
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
for (const log of result.logs) console.error(log)
|
||||
process.exit(1)
|
||||
}
|
||||
if (platform !== "win32") await chmod(output, 0o755)
|
||||
const digest = new Bun.CryptoHasher("sha256")
|
||||
.update(await Bun.file(output).arrayBuffer())
|
||||
.digest("hex")
|
||||
await Bun.write(`${output}.sha256`, `${digest} ${basename(output)}\n`)
|
||||
console.log(output)
|
||||
+592
@@ -0,0 +1,592 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
CliRenderEvents,
|
||||
MarkdownRenderable,
|
||||
RGBA,
|
||||
SyntaxStyle,
|
||||
TextareaRenderable,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
createCliRenderer,
|
||||
getTreeSitterClient,
|
||||
type CliRenderer,
|
||||
type KeyEvent,
|
||||
type ScrollbackSurface,
|
||||
} from "@opentui/core"
|
||||
|
||||
import {
|
||||
NanobotClient,
|
||||
fetchHistory,
|
||||
type ConnectionStatus,
|
||||
type HistoryMessage,
|
||||
type InboundEvent,
|
||||
} from "./protocol"
|
||||
|
||||
interface AppOptions {
|
||||
wsUrl: string
|
||||
apiUrl: string
|
||||
apiToken: string
|
||||
chatId?: string
|
||||
model: string
|
||||
workspace: string
|
||||
version: string
|
||||
access: string
|
||||
}
|
||||
|
||||
interface Palette {
|
||||
background: string
|
||||
panel: string
|
||||
text: string
|
||||
muted: string
|
||||
faint: string
|
||||
border: string
|
||||
accent: string
|
||||
success: string
|
||||
error: string
|
||||
user: string
|
||||
}
|
||||
|
||||
const DARK: Palette = {
|
||||
background: "#0E0F11",
|
||||
panel: "#17181B",
|
||||
text: "#ECEDEE",
|
||||
muted: "#A1A1AA",
|
||||
faint: "#71717A",
|
||||
border: "#3F3F46",
|
||||
accent: "#8B7CF6",
|
||||
success: "#5CC489",
|
||||
error: "#F87171",
|
||||
user: "#60A5FA",
|
||||
}
|
||||
|
||||
const LIGHT: Palette = {
|
||||
background: "#FAFAFA",
|
||||
panel: "#F4F4F5",
|
||||
text: "#18181B",
|
||||
muted: "#71717A",
|
||||
faint: "#A1A1AA",
|
||||
border: "#D4D4D8",
|
||||
accent: "#6D5BD0",
|
||||
success: "#218358",
|
||||
error: "#DC2626",
|
||||
user: "#2563EB",
|
||||
}
|
||||
|
||||
function syntaxStyle(palette: Palette): SyntaxStyle {
|
||||
const color = (value: string) => {
|
||||
const parsed = RGBA.fromHex(value)
|
||||
return { fg: parsed }
|
||||
}
|
||||
return SyntaxStyle.fromStyles({
|
||||
default: color(palette.text),
|
||||
keyword: { ...color(palette.accent), bold: true },
|
||||
string: color(palette.success),
|
||||
comment: { ...color(palette.muted), italic: true },
|
||||
number: color(palette.user),
|
||||
function: color("#C26A25"),
|
||||
type: color("#168A96"),
|
||||
variable: color(palette.text),
|
||||
property: color(palette.user),
|
||||
"markup.heading": { ...color(palette.accent), bold: true },
|
||||
"markup.strong": { ...color(palette.text), bold: true },
|
||||
"markup.italic": { ...color(palette.muted), italic: true },
|
||||
"markup.link": { ...color(palette.user), underline: true },
|
||||
"markup.link.label": { ...color(palette.user), underline: true },
|
||||
"markup.link.url": { ...color(palette.user), underline: true },
|
||||
"markup.raw": color("#C26A25"),
|
||||
conceal: color(palette.faint),
|
||||
})
|
||||
}
|
||||
|
||||
class Transcript {
|
||||
private writeChain = Promise.resolve()
|
||||
private live: { surface: ScrollbackSurface; text: TextRenderable; content: string } | null = null
|
||||
private wrote = false
|
||||
|
||||
constructor(
|
||||
private readonly renderer: CliRenderer,
|
||||
private palette: Palette,
|
||||
) {}
|
||||
|
||||
setPalette(palette: Palette): void {
|
||||
this.palette = palette
|
||||
}
|
||||
|
||||
header(options: AppOptions): void {
|
||||
this.enqueue(async () => {
|
||||
const lines = [
|
||||
`>_ nanobot v${options.version}`,
|
||||
`${options.model} · ${options.access}`,
|
||||
options.workspace,
|
||||
]
|
||||
await this.writeText(lines.join("\n"), this.palette.text, true, true)
|
||||
})
|
||||
}
|
||||
|
||||
async history(messages: HistoryMessage[]): Promise<void> {
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") this.user(message.content)
|
||||
else this.assistant(message.content)
|
||||
}
|
||||
await this.writeChain
|
||||
}
|
||||
|
||||
user(content: string): void {
|
||||
this.enqueue(() => this.writeText(`› ${content}`, this.palette.user, true))
|
||||
}
|
||||
|
||||
assistant(content: string): void {
|
||||
if (!content.trim()) return
|
||||
this.enqueue(() => this.writeMarkdown(content))
|
||||
}
|
||||
|
||||
notice(content: string, error = false): void {
|
||||
this.enqueue(() => this.writeText(content, error ? this.palette.error : this.palette.muted))
|
||||
}
|
||||
|
||||
stream(delta: string): void {
|
||||
if (!delta) return
|
||||
if (!this.live) {
|
||||
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote })
|
||||
const text = new TextRenderable(surface.renderContext, {
|
||||
id: `assistant-stream-${Date.now()}`,
|
||||
content: "",
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
fg: this.palette.text,
|
||||
})
|
||||
surface.root.add(text)
|
||||
this.live = { surface, text, content: "" }
|
||||
}
|
||||
this.live.content += delta
|
||||
this.live.text.content = this.live.content
|
||||
this.live.surface.render()
|
||||
}
|
||||
|
||||
finishStream(fallback = ""): void {
|
||||
const content = this.live?.content || fallback
|
||||
if (this.live) {
|
||||
this.live.surface.destroy()
|
||||
this.live = null
|
||||
}
|
||||
if (content.trim()) this.assistant(content)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.live?.surface.destroy()
|
||||
this.live = null
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): void {
|
||||
this.writeChain = this.writeChain.then(operation).catch((error) => {
|
||||
console.error("transcript render failed", error)
|
||||
})
|
||||
}
|
||||
|
||||
private async writeText(
|
||||
content: string,
|
||||
color: string,
|
||||
bold = false,
|
||||
framed = false,
|
||||
): Promise<void> {
|
||||
this.spacer()
|
||||
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote })
|
||||
const root = framed
|
||||
? new BoxRenderable(surface.renderContext, {
|
||||
id: `text-frame-${Date.now()}`,
|
||||
width: "100%",
|
||||
border: true,
|
||||
borderStyle: "rounded",
|
||||
borderColor: this.palette.border,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
flexDirection: "column",
|
||||
})
|
||||
: new BoxRenderable(surface.renderContext, {
|
||||
id: `text-row-${Date.now()}`,
|
||||
width: "100%",
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
flexDirection: "column",
|
||||
})
|
||||
root.add(
|
||||
new TextRenderable(surface.renderContext, {
|
||||
id: `text-${Date.now()}`,
|
||||
content,
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
fg: color,
|
||||
attributes: bold ? TextAttributes.BOLD : 0,
|
||||
}),
|
||||
)
|
||||
surface.root.add(root)
|
||||
surface.render()
|
||||
surface.commitRows(0, surface.height, { trailingNewline: true })
|
||||
surface.destroy()
|
||||
this.wrote = true
|
||||
}
|
||||
|
||||
private async writeMarkdown(content: string): Promise<void> {
|
||||
this.spacer()
|
||||
const surface = this.renderer.createScrollbackSurface({ startOnNewLine: this.wrote })
|
||||
const markdown = new MarkdownRenderable(surface.renderContext, {
|
||||
id: `markdown-${Date.now()}`,
|
||||
content,
|
||||
width: "100%",
|
||||
syntaxStyle: syntaxStyle(this.palette),
|
||||
streaming: false,
|
||||
internalBlockMode: "top-level",
|
||||
treeSitterClient: getTreeSitterClient(),
|
||||
})
|
||||
surface.root.add(markdown)
|
||||
await surface.settle()
|
||||
surface.commitRows(0, surface.height, { trailingNewline: true })
|
||||
surface.destroy()
|
||||
this.wrote = true
|
||||
}
|
||||
|
||||
private spacer(): void {
|
||||
if (!this.wrote) return
|
||||
this.renderer.writeToScrollback((context) => {
|
||||
const root = new TextRenderable(context.renderContext, {
|
||||
id: `spacer-${Date.now()}`,
|
||||
content: "",
|
||||
width: Math.max(1, context.width),
|
||||
height: 1,
|
||||
})
|
||||
return { root, width: Math.max(1, context.width), height: 1, startOnNewLine: true, trailingNewline: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class NanobotTui {
|
||||
private readonly renderer: CliRenderer
|
||||
private readonly transcript: Transcript
|
||||
private readonly client: NanobotClient
|
||||
private readonly shell: BoxRenderable
|
||||
private readonly title: TextRenderable
|
||||
private readonly composerFrame: BoxRenderable
|
||||
private readonly composer: TextareaRenderable
|
||||
private readonly status: TextRenderable
|
||||
private readonly meta: TextRenderable
|
||||
private palette: Palette
|
||||
private activeTurn = false
|
||||
private lastProgress = ""
|
||||
private finalMessage = ""
|
||||
private historyLoaded = false
|
||||
private ready = false
|
||||
private shimmerFrame = 0
|
||||
private shimmerTimer: ReturnType<typeof setInterval> | null = null
|
||||
private quitting = false
|
||||
|
||||
private constructor(renderer: CliRenderer, private readonly options: AppOptions) {
|
||||
this.renderer = renderer
|
||||
this.palette = renderer.themeMode === "light" ? LIGHT : DARK
|
||||
this.transcript = new Transcript(renderer, this.palette)
|
||||
this.client = new NanobotClient({
|
||||
url: options.wsUrl,
|
||||
chatId: options.chatId,
|
||||
onEvent: (event) => this.handleEvent(event),
|
||||
onStatus: (status, detail) => this.handleStatus(status, detail),
|
||||
})
|
||||
|
||||
this.renderer.setBackgroundColor(this.palette.background)
|
||||
this.shell = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-footer",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
flexDirection: "column",
|
||||
backgroundColor: this.palette.background,
|
||||
})
|
||||
this.title = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-title",
|
||||
content: `nanobot · ${options.model}`,
|
||||
height: 1,
|
||||
fg: this.palette.muted,
|
||||
})
|
||||
this.composerFrame = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-composer-frame",
|
||||
width: "100%",
|
||||
minHeight: 3,
|
||||
flexGrow: 1,
|
||||
border: true,
|
||||
borderStyle: "rounded",
|
||||
borderColor: this.palette.border,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
backgroundColor: this.palette.panel,
|
||||
})
|
||||
this.composer = new TextareaRenderable(renderer, {
|
||||
id: "nanobot-tui-composer",
|
||||
width: "100%",
|
||||
minHeight: 1,
|
||||
flexGrow: 1,
|
||||
wrapMode: "word",
|
||||
placeholder: "Ask nanobot anything",
|
||||
placeholderColor: this.palette.faint,
|
||||
textColor: this.palette.text,
|
||||
focusedTextColor: this.palette.text,
|
||||
backgroundColor: this.palette.panel,
|
||||
focusedBackgroundColor: this.palette.panel,
|
||||
cursorColor: this.palette.accent,
|
||||
showCursor: true,
|
||||
keyBindings: [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
],
|
||||
onSubmit: () => this.submit(),
|
||||
})
|
||||
this.status = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-status",
|
||||
content: "Connecting…",
|
||||
fg: this.palette.muted,
|
||||
height: 1,
|
||||
flexGrow: 1,
|
||||
})
|
||||
this.meta = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-meta",
|
||||
content: "enter send · alt+enter newline · ctrl+c stop",
|
||||
fg: this.palette.faint,
|
||||
height: 1,
|
||||
})
|
||||
|
||||
const statusRow = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-status-row",
|
||||
width: "100%",
|
||||
height: 1,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
})
|
||||
this.composerFrame.add(this.composer)
|
||||
statusRow.add(this.status)
|
||||
statusRow.add(this.meta)
|
||||
this.shell.add(this.title)
|
||||
this.shell.add(this.composerFrame)
|
||||
this.shell.add(statusRow)
|
||||
this.renderer.root.add(this.shell)
|
||||
|
||||
this.renderer.keyInput.on("keypress", this.handleKey)
|
||||
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleTheme)
|
||||
this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.handleResize()
|
||||
this.composer.focus()
|
||||
this.transcript.header(options)
|
||||
this.client.connect()
|
||||
}
|
||||
|
||||
static async create(options: AppOptions): Promise<NanobotTui> {
|
||||
const renderer = await createCliRenderer({
|
||||
targetFps: 30,
|
||||
exitOnCtrlC: false,
|
||||
useMouse: true,
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 7,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
return new NanobotTui(renderer, options)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.renderer.start()
|
||||
}
|
||||
|
||||
private submit(): void {
|
||||
const content = this.composer.plainText.trim()
|
||||
if (!content) return
|
||||
if (!this.ready) {
|
||||
this.status.content = "Preparing chat…"
|
||||
return
|
||||
}
|
||||
if (["exit", "quit", "/exit", "/quit", ":q"].includes(content.toLowerCase())) {
|
||||
this.quit()
|
||||
return
|
||||
}
|
||||
if (this.activeTurn) {
|
||||
this.status.content = "A turn is already running · Ctrl+C to stop"
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.client.send(content)
|
||||
} catch (error) {
|
||||
this.status.content = error instanceof Error ? error.message : String(error)
|
||||
return
|
||||
}
|
||||
this.composer.setText("")
|
||||
this.transcript.user(content)
|
||||
this.finalMessage = ""
|
||||
this.lastProgress = ""
|
||||
this.setActive(true)
|
||||
}
|
||||
|
||||
private handleEvent(event: InboundEvent): void {
|
||||
if (event.event === "attached") {
|
||||
void this.prepareChat(event.chat_id)
|
||||
return
|
||||
}
|
||||
if (
|
||||
"chat_id" in event
|
||||
&& event.chat_id
|
||||
&& this.client.activeChatId
|
||||
&& event.chat_id !== this.client.activeChatId
|
||||
) return
|
||||
|
||||
switch (event.event) {
|
||||
case "message_accepted":
|
||||
return
|
||||
case "delta":
|
||||
this.setActive(true)
|
||||
this.transcript.stream(event.text)
|
||||
return
|
||||
case "message":
|
||||
if (event.kind) {
|
||||
this.lastProgress = event.text.trim()
|
||||
if (this.lastProgress) this.status.content = this.lastProgress
|
||||
this.setActive(true)
|
||||
} else {
|
||||
this.finalMessage = event.text
|
||||
}
|
||||
return
|
||||
case "reasoning_delta":
|
||||
this.setActive(true)
|
||||
return
|
||||
case "stream_end":
|
||||
if (event.text) this.finalMessage = event.text
|
||||
return
|
||||
case "turn_end":
|
||||
this.transcript.finishStream(this.finalMessage)
|
||||
this.finalMessage = ""
|
||||
this.setActive(false)
|
||||
if (typeof event.latency_ms === "number") {
|
||||
this.status.content = `Ready · ${(event.latency_ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
return
|
||||
case "turn_model_updated":
|
||||
this.title.content = `nanobot · ${event.model_name}`
|
||||
return
|
||||
case "runtime_model_updated":
|
||||
this.title.content = `nanobot · ${event.model_name}`
|
||||
return
|
||||
case "error":
|
||||
this.transcript.finishStream(this.finalMessage)
|
||||
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
|
||||
this.setActive(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareChat(chatId: string): Promise<void> {
|
||||
try {
|
||||
if (!this.historyLoaded && this.options.chatId) {
|
||||
this.historyLoaded = true
|
||||
const messages = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId)
|
||||
await this.transcript.history(messages)
|
||||
}
|
||||
} catch (error) {
|
||||
this.transcript.notice(error instanceof Error ? error.message : String(error), true)
|
||||
} finally {
|
||||
this.ready = true
|
||||
this.status.content = "Ready"
|
||||
}
|
||||
}
|
||||
|
||||
private handleStatus(status: ConnectionStatus, detail?: string): void {
|
||||
if (status === "connected") {
|
||||
this.status.content = "Connected · preparing chat…"
|
||||
return
|
||||
}
|
||||
if (status === "connecting") {
|
||||
this.status.content = "Connecting…"
|
||||
return
|
||||
}
|
||||
if (status === "error") {
|
||||
this.status.content = detail || "Connection error"
|
||||
return
|
||||
}
|
||||
if (!this.quitting) this.status.content = "Disconnected"
|
||||
}
|
||||
|
||||
private setActive(active: boolean): void {
|
||||
if (this.activeTurn === active) return
|
||||
this.activeTurn = active
|
||||
if (active) {
|
||||
this.shimmerFrame = 0
|
||||
this.shimmerTimer = setInterval(() => {
|
||||
const dots = "·".repeat((this.shimmerFrame++ % 3) + 1)
|
||||
const detail = this.lastProgress ? ` ${this.lastProgress}` : ""
|
||||
this.status.content = `Working ${dots}${detail}`
|
||||
}, 260)
|
||||
return
|
||||
}
|
||||
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
||||
this.shimmerTimer = null
|
||||
this.lastProgress = ""
|
||||
this.status.content = "Ready"
|
||||
}
|
||||
|
||||
private handleKey = (key: KeyEvent): void => {
|
||||
if (key.ctrl && key.name === "c") {
|
||||
key.preventDefault()
|
||||
if (this.activeTurn) {
|
||||
try {
|
||||
this.client.send("/stop")
|
||||
this.status.content = "Stopping…"
|
||||
} catch {
|
||||
this.setActive(false)
|
||||
}
|
||||
} else if (this.composer.plainText) {
|
||||
this.composer.setText("")
|
||||
} else {
|
||||
this.quit()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.ctrl && key.name === "d" && !this.composer.plainText) {
|
||||
key.preventDefault()
|
||||
this.quit()
|
||||
}
|
||||
}
|
||||
|
||||
private handleTheme = (): void => {
|
||||
this.palette = this.renderer.themeMode === "light" ? LIGHT : DARK
|
||||
this.transcript.setPalette(this.palette)
|
||||
this.renderer.setBackgroundColor(this.palette.background)
|
||||
this.shell.backgroundColor = this.palette.background
|
||||
this.composerFrame.backgroundColor = this.palette.panel
|
||||
this.composerFrame.borderColor = this.palette.border
|
||||
this.composer.backgroundColor = this.palette.panel
|
||||
this.composer.focusedBackgroundColor = this.palette.panel
|
||||
this.composer.textColor = this.palette.text
|
||||
this.composer.focusedTextColor = this.palette.text
|
||||
this.composer.cursorColor = this.palette.accent
|
||||
this.title.fg = this.palette.muted
|
||||
this.status.fg = this.palette.muted
|
||||
this.meta.fg = this.palette.faint
|
||||
}
|
||||
|
||||
private handleResize = (): void => {
|
||||
this.meta.content = this.renderer.width >= 72
|
||||
? "enter send · alt+enter newline · ctrl+c stop"
|
||||
: this.renderer.width >= 48
|
||||
? "enter send · alt+enter newline"
|
||||
: ""
|
||||
}
|
||||
|
||||
private quit(): void {
|
||||
if (this.quitting) return
|
||||
this.quitting = true
|
||||
this.client.close()
|
||||
this.renderer.destroy()
|
||||
}
|
||||
|
||||
private handleDestroy = (): void => {
|
||||
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
||||
this.transcript.destroy()
|
||||
this.client.close()
|
||||
}
|
||||
}
|
||||
|
||||
export type { AppOptions }
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NanobotTui, type AppOptions } from "./app"
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) throw new Error(`${name} is required`)
|
||||
return value
|
||||
}
|
||||
|
||||
const options: AppOptions = {
|
||||
wsUrl: required("NANOBOT_TUI_WS_URL"),
|
||||
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,
|
||||
model: process.env.NANOBOT_TUI_MODEL?.trim() || "unknown model",
|
||||
workspace: process.env.NANOBOT_TUI_WORKSPACE?.trim() || "",
|
||||
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
|
||||
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
|
||||
}
|
||||
|
||||
const app = await NanobotTui.create(options)
|
||||
app.start()
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { NanobotClient, type InboundEvent } from "./protocol"
|
||||
|
||||
class FakeSocket {
|
||||
static readonly OPEN = 1
|
||||
readonly sent: string[] = []
|
||||
readyState = FakeSocket.OPEN
|
||||
private readonly listeners = new Map<string, Array<(event: { data?: string }) => void>>()
|
||||
|
||||
addEventListener(name: string, listener: (event: { data?: string }) => void): void {
|
||||
const listeners = this.listeners.get(name) || []
|
||||
listeners.push(listener)
|
||||
this.listeners.set(name, listeners)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3
|
||||
}
|
||||
|
||||
send(value: string): void {
|
||||
this.sent.push(value)
|
||||
}
|
||||
|
||||
emit(name: string, event: { data?: string } = {}): void {
|
||||
for (const listener of this.listeners.get(name) || []) listener(event)
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway protocol", () => {
|
||||
test("represents lifecycle frames without browser state", () => {
|
||||
const events: InboundEvent[] = [
|
||||
{ event: "delta", chat_id: "one", text: "hello" },
|
||||
{ event: "stream_end", chat_id: "one", resuming: false },
|
||||
{ event: "turn_end", chat_id: "one", latency_ms: 12 },
|
||||
]
|
||||
expect(events.map((event) => event.event)).toEqual(["delta", "stream_end", "turn_end"])
|
||||
})
|
||||
|
||||
test("attaches and sends turns through the gateway envelope", () => {
|
||||
const original = globalThis.WebSocket
|
||||
let socket: FakeSocket | undefined
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
configurable: true,
|
||||
value: class extends FakeSocket {
|
||||
constructor() {
|
||||
super()
|
||||
socket = this
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const events: InboundEvent[] = []
|
||||
const client = new NanobotClient({
|
||||
url: "ws://nanobot.test/ws",
|
||||
chatId: "terminal",
|
||||
onEvent: (event) => events.push(event),
|
||||
onStatus: () => undefined,
|
||||
})
|
||||
client.connect()
|
||||
if (!socket) throw new Error("socket was not created")
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
|
||||
})
|
||||
socket.emit("message", { data: JSON.stringify({ event: "attached", chat_id: "terminal" }) })
|
||||
client.send("hello")
|
||||
|
||||
const outbound = socket.sent.map((value) => JSON.parse(value) as Record<string, unknown>)
|
||||
expect(outbound[0]).toEqual({ type: "attach", chat_id: "terminal" })
|
||||
expect(outbound[1]?.type).toBe("message")
|
||||
expect(outbound[1]?.chat_id).toBe("terminal")
|
||||
expect(outbound[1]?.content).toBe("hello")
|
||||
expect(events.map((event) => event.event)).toEqual(["ready", "attached"])
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects malformed gateway events", () => {
|
||||
const original = globalThis.WebSocket
|
||||
let socket: FakeSocket | undefined
|
||||
Object.defineProperty(globalThis, "WebSocket", {
|
||||
configurable: true,
|
||||
value: class extends FakeSocket {
|
||||
constructor() {
|
||||
super()
|
||||
socket = this
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const statuses: string[] = []
|
||||
const client = new NanobotClient({
|
||||
url: "ws://nanobot.test/ws",
|
||||
onEvent: () => undefined,
|
||||
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
|
||||
})
|
||||
client.connect()
|
||||
if (!socket) throw new Error("socket was not created")
|
||||
socket.emit("message", { data: "[]" })
|
||||
expect(statuses).toContain("error:gateway sent an invalid event")
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
export type ConnectionStatus = "connecting" | "connected" | "closed" | "error"
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string }
|
||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||
| {
|
||||
event: "message"
|
||||
chat_id: string
|
||||
text: string
|
||||
kind?: "tool_hint" | "progress" | "reasoning"
|
||||
turn_id?: string
|
||||
}
|
||||
| { event: "delta"; chat_id: string; text: string; stream_id?: string; turn_id?: string }
|
||||
| {
|
||||
event: "stream_end"
|
||||
chat_id: string
|
||||
text?: string
|
||||
stream_id?: string
|
||||
resuming?: boolean
|
||||
merge_next?: boolean
|
||||
turn_id?: string
|
||||
}
|
||||
| { event: "reasoning_delta"; chat_id: string; text: 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: "runtime_model_updated"; model_name: string; model_preset?: string | null }
|
||||
| { event: "turn_model_updated"; chat_id: string; model_name: string }
|
||||
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
|
||||
|
||||
type OutboundEvent =
|
||||
| { type: "new_chat" }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "message"; chat_id: string; content: string; turn_id: string; webui: true }
|
||||
|
||||
export interface ClientOptions {
|
||||
url: string
|
||||
chatId?: string
|
||||
onEvent: (event: InboundEvent) => void
|
||||
onStatus: (status: ConnectionStatus, detail?: string) => void
|
||||
}
|
||||
|
||||
export interface HistoryMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
export async function fetchHistory(
|
||||
apiUrl: string,
|
||||
apiToken: string,
|
||||
chatId: string,
|
||||
): Promise<HistoryMessage[]> {
|
||||
if (!apiUrl || !apiToken) return []
|
||||
const key = encodeURIComponent(`websocket:${chatId}`)
|
||||
const response = await fetch(`${apiUrl}/api/sessions/${key}/webui-thread?limit=120&direction=latest`, {
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
})
|
||||
if (response.status === 404) return []
|
||||
if (!response.ok) throw new Error(`history request failed: HTTP ${response.status}`)
|
||||
const payload = (await response.json()) as { messages?: Array<Record<string, unknown>> }
|
||||
return (payload.messages || []).flatMap((message) => {
|
||||
const role = message.role
|
||||
const content = message.content
|
||||
if ((role !== "user" && role !== "assistant") || typeof content !== "string" || !content.trim()) {
|
||||
return []
|
||||
}
|
||||
return [{ role, content }]
|
||||
})
|
||||
}
|
||||
|
||||
export class NanobotClient {
|
||||
private socket: WebSocket | null = null
|
||||
private chatId = ""
|
||||
|
||||
constructor(private readonly options: ClientOptions) {}
|
||||
|
||||
get activeChatId(): string {
|
||||
return this.chatId
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.options.onStatus("connecting")
|
||||
const socket = new WebSocket(this.options.url)
|
||||
this.socket = socket
|
||||
socket.addEventListener("open", () => this.options.onStatus("connected"))
|
||||
socket.addEventListener("message", (message) => this.handleMessage(String(message.data)))
|
||||
socket.addEventListener("error", () => this.options.onStatus("error", "connection failed"))
|
||||
socket.addEventListener("close", () => this.options.onStatus("closed"))
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.socket?.close()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
send(content: string): string {
|
||||
if (!this.chatId) throw new Error("chat is not ready")
|
||||
const turnId = crypto.randomUUID()
|
||||
this.write({
|
||||
type: "message",
|
||||
chat_id: this.chatId,
|
||||
content,
|
||||
turn_id: turnId,
|
||||
webui: true,
|
||||
})
|
||||
return turnId
|
||||
}
|
||||
|
||||
private handleMessage(raw: string): void {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(raw) as unknown
|
||||
} catch {
|
||||
this.options.onStatus("error", "gateway sent invalid JSON")
|
||||
return
|
||||
}
|
||||
if (!value || typeof value !== "object" || !("event" in value)) {
|
||||
this.options.onStatus("error", "gateway sent an invalid event")
|
||||
return
|
||||
}
|
||||
const event = value as InboundEvent
|
||||
|
||||
if (event.event === "ready") {
|
||||
if (this.options.chatId) {
|
||||
this.chatId = this.options.chatId
|
||||
this.write({ type: "attach", chat_id: this.chatId })
|
||||
} else {
|
||||
this.write({ type: "new_chat" })
|
||||
}
|
||||
} else if (event.event === "attached") {
|
||||
this.chatId = event.chat_id
|
||||
}
|
||||
this.options.onEvent(event)
|
||||
}
|
||||
|
||||
private write(event: OutboundEvent): void {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("gateway connection is not open")
|
||||
}
|
||||
this.socket.send(JSON.stringify(event))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src", "scripts"]
|
||||
}
|
||||
Reference in New Issue
Block a user