mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(tui): require the native interactive client
This commit is contained in:
+14
-10
@@ -83,18 +83,21 @@ def agent(
|
||||
theme = theme.strip().lower()
|
||||
if theme not in {"auto", "dark", "light"}:
|
||||
raise typer.BadParameter("must be auto, dark, or light", param_hint="--theme")
|
||||
native_tui = (
|
||||
message is None
|
||||
and not classic
|
||||
and markdown
|
||||
and not logs
|
||||
and sys.stdin.isatty()
|
||||
and sys.stdout.isatty()
|
||||
)
|
||||
native_tui = message is None and not classic
|
||||
if native_tui:
|
||||
from nanobot.cli.tui_launcher import TuiUnavailableError, launch_tui
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
raise typer.BadParameter(
|
||||
"the native TUI requires an interactive terminal; use --message for "
|
||||
"one-shot input or --classic for the legacy prompt",
|
||||
param_hint="terminal",
|
||||
)
|
||||
if not markdown:
|
||||
raise typer.BadParameter("--no-markdown requires --classic", param_hint="--no-markdown")
|
||||
if logs:
|
||||
raise typer.BadParameter("--logs requires --classic", param_hint="--logs")
|
||||
try:
|
||||
exit_code = launch_tui(
|
||||
runtime_config,
|
||||
@@ -104,8 +107,9 @@ def agent(
|
||||
theme=theme,
|
||||
)
|
||||
except TuiUnavailableError as exc:
|
||||
console.print(f"[yellow]Native TUI unavailable: {exc}[/yellow]")
|
||||
console.print("[dim]Falling back to the classic prompt.[/dim]")
|
||||
console.print(f"[red]Native TUI unavailable: {exc}[/red]")
|
||||
console.print("[dim]Use `nanobot agent --classic` only if you want the old prompt.[/dim]")
|
||||
raise typer.Exit(1) from exc
|
||||
else:
|
||||
if exit_code:
|
||||
raise typer.Exit(exit_code)
|
||||
|
||||
@@ -123,11 +123,7 @@ def _resolve_tui_command() -> list[str]:
|
||||
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")]
|
||||
return _resolve_source_tui_command(source_dir, bun)
|
||||
|
||||
downloaded = _download_release_tui(asset)
|
||||
if downloaded is not None:
|
||||
@@ -139,6 +135,25 @@ def _resolve_tui_command() -> list[str]:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
|
||||
dependency = source_dir / "node_modules" / "@opentui" / "core"
|
||||
try:
|
||||
install = subprocess.run(
|
||||
[bun, "install", "--frozen-lockfile"],
|
||||
cwd=source_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise TuiUnavailableError(f"could not install TUI dependencies: {exc}") from exc
|
||||
if install.returncode != 0 or not dependency.is_dir():
|
||||
detail = (install.stderr or install.stdout).strip().splitlines()
|
||||
suffix = f": {detail[-1]}" if detail else ""
|
||||
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
|
||||
return [bun, str(source_dir / "src" / "index.ts")]
|
||||
|
||||
|
||||
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":
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import hashlib
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
|
||||
from nanobot.cli.agent import agent
|
||||
from nanobot.cli.tui_launcher import (
|
||||
@@ -12,6 +14,7 @@ from nanobot.cli.tui_launcher import (
|
||||
_ensure_gateway,
|
||||
_initial_tui_chat_id,
|
||||
_read_tui_chat_id,
|
||||
_resolve_source_tui_command,
|
||||
_resolve_tui_command,
|
||||
_websocket_chat_id,
|
||||
)
|
||||
@@ -70,7 +73,7 @@ def test_explicit_tui_binary_must_exist(
|
||||
_resolve_tui_command()
|
||||
|
||||
|
||||
def test_windows_arm64_uses_the_classic_prompt(
|
||||
def test_windows_arm64_fails_instead_of_using_the_classic_prompt(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("NANOBOT_TUI_BIN", raising=False)
|
||||
@@ -120,6 +123,129 @@ def test_interactive_agent_uses_native_tui(
|
||||
}
|
||||
|
||||
|
||||
def test_interactive_agent_does_not_silently_fall_back(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config = Config()
|
||||
output: list[str] = []
|
||||
|
||||
def unavailable(*_args: object, **_kwargs: object) -> int:
|
||||
raise TuiUnavailableError("missing sidecar")
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: config)
|
||||
monkeypatch.setattr("nanobot.cli.agent.console.print", lambda value: output.append(value))
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.launch_tui", unavailable)
|
||||
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: tmp_path / "config.json")
|
||||
monkeypatch.setattr("nanobot.cli.agent.sys.stdin", SimpleNamespace(isatty=lambda: True))
|
||||
monkeypatch.setattr("nanobot.cli.agent.sys.stdout", SimpleNamespace(isatty=lambda: True))
|
||||
|
||||
with pytest.raises(typer.Exit) as exc_info:
|
||||
agent(
|
||||
message=None,
|
||||
session_id=None,
|
||||
workspace=None,
|
||||
config=None,
|
||||
markdown=True,
|
||||
logs=False,
|
||||
classic=False,
|
||||
theme="auto",
|
||||
)
|
||||
|
||||
assert exc_info.value.exit_code == 1
|
||||
assert output == [
|
||||
"[red]Native TUI unavailable: missing sidecar[/red]",
|
||||
"[dim]Use `nanobot agent --classic` only if you want the old prompt.[/dim]",
|
||||
]
|
||||
|
||||
|
||||
def test_default_agent_does_not_fall_back_outside_a_terminal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: Config())
|
||||
monkeypatch.setattr("nanobot.cli.agent.sys.stdin", SimpleNamespace(isatty=lambda: False))
|
||||
monkeypatch.setattr("nanobot.cli.agent.sys.stdout", SimpleNamespace(isatty=lambda: True))
|
||||
|
||||
with pytest.raises(typer.BadParameter, match="requires an interactive terminal"):
|
||||
agent(
|
||||
message=None,
|
||||
session_id=None,
|
||||
workspace=None,
|
||||
config=None,
|
||||
markdown=True,
|
||||
logs=False,
|
||||
classic=False,
|
||||
theme="auto",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("markdown", "logs", "option"),
|
||||
[
|
||||
(False, False, "--no-markdown"),
|
||||
(True, True, "--logs"),
|
||||
],
|
||||
)
|
||||
def test_classic_options_require_an_explicit_classic_prompt(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
markdown: bool,
|
||||
logs: bool,
|
||||
option: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: Config())
|
||||
monkeypatch.setattr("nanobot.cli.agent.sys.stdin", SimpleNamespace(isatty=lambda: True))
|
||||
monkeypatch.setattr("nanobot.cli.agent.sys.stdout", SimpleNamespace(isatty=lambda: True))
|
||||
|
||||
with pytest.raises(typer.BadParameter, match=f"{option} requires --classic"):
|
||||
agent(
|
||||
message=None,
|
||||
session_id=None,
|
||||
workspace=None,
|
||||
config=None,
|
||||
markdown=markdown,
|
||||
logs=logs,
|
||||
classic=False,
|
||||
theme="auto",
|
||||
)
|
||||
|
||||
|
||||
def test_source_checkout_refreshes_locked_tui_dependencies(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source_dir = tmp_path / "tui"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "node_modules" / "@opentui" / "core").mkdir(parents=True)
|
||||
bun = str(tmp_path / "bun")
|
||||
|
||||
def install(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
assert command == [bun, "install", "--frozen-lockfile"]
|
||||
assert kwargs["cwd"] == source_dir
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
|
||||
|
||||
assert _resolve_source_tui_command(source_dir, bun) == [
|
||||
bun,
|
||||
str(source_dir / "src" / "index.ts"),
|
||||
]
|
||||
|
||||
|
||||
def test_source_checkout_fails_when_locked_dependencies_cannot_be_refreshed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source_dir = tmp_path / "tui"
|
||||
source_dir.mkdir()
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.tui_launcher.subprocess.run",
|
||||
lambda *args, **kwargs: subprocess.CompletedProcess(args, 1, "", "lockfile mismatch"),
|
||||
)
|
||||
|
||||
with pytest.raises(TuiUnavailableError, match="lockfile mismatch"):
|
||||
_resolve_source_tui_command(source_dir, "bun")
|
||||
|
||||
|
||||
def test_release_tui_is_verified_and_cached(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ 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.
|
||||
`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. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
|
||||
|
||||
The renderer uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user