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:
Xubin Ren
2026-08-17 20:56:10 +08:00
co-authored by Bingxi Zhao chengyongru
parent c27b1f14c3
commit ce070c832d
19 changed files with 1536 additions and 8 deletions
+29
View File
@@ -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
+49
View File
@@ -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
+2
View File
@@ -16,6 +16,8 @@ webui/node_modules/
webui/dist/
webui/coverage/
webui/.vite/
tui/node_modules/
tui/dist/
*.tsbuildinfo
# Python bytecode & caches
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+277
View File
@@ -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
+4 -2
View File
@@ -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='')}"
+1 -1
View File
@@ -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"]
+123
View File
@@ -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")
+14
View File
@@ -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.
+63
View File
@@ -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=="],
}
}
+19
View File
@@ -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"
}
}
+34
View File
@@ -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
View File
@@ -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 }
+21
View File
@@ -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()
+108
View File
@@ -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 })
}
})
})
+142
View File
@@ -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))
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"types": ["bun"]
},
"include": ["src", "scripts"]
}