feat(cli): add interactive TUI chat

Add a Textual chat interface with threaded SSE streaming, slash commands, interrupt handling, and bare nexus dispatch. Package it behind the tui extra, document usage, and cover command routing, dependencies, and headless interaction with tests.
This commit is contained in:
Athena Kaminsky
2026-08-26 08:17:31 -05:00
committed by Athena
parent 5f67d19e80
commit 1449280fcd
8 changed files with 586 additions and 18 deletions
+16 -1
View File
@@ -31,8 +31,23 @@ def run_cli(tmp_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
def test_help_exposes_portable_command_tree(tmp_path):
result = run_cli(tmp_path, "--help")
assert result.returncode == 0, result.stderr
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor"):
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor", "tui"):
assert command in result.stdout
assert "interactive TUI" in result.stdout or "TUI" in result.stdout
def test_bare_nexus_defaults_to_tui_command():
"""No subcommand → TUI entry (Hermes-style). Non-TTY exits 2 without launching."""
from unittest import mock
from nexusos_cli.cli import build_parser, cmd_tui
parser = build_parser()
args = parser.parse_args([])
assert args.command is None # filled in by main()
with mock.patch("sys.stdin.isatty", return_value=False), \
mock.patch("sys.stdout.isatty", return_value=False):
assert cmd_tui(args) == 2
def test_legacy_cli_spellings_remain_compatible():
+2 -1
View File
@@ -29,7 +29,8 @@ DISTRIBUTION_OF = {
}
# Provided by another declared distribution rather than named directly.
TRANSITIVE = {"starlette", "socketio", "engineio"}
# rich: Textual depends on it, so the tui extra already pulls it in.
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
# Modules that ship inside this repo.
FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"}
+85
View File
@@ -0,0 +1,85 @@
"""TUI helpers and headless App.run_test coverage."""
from __future__ import annotations
import asyncio
import pytest
from rich.text import Text
from nexusos_cli.tui_app import (
_compact_status,
_escape,
_status_line,
format_assistant_line,
format_user_line,
)
def test_status_line_mentions_services():
snap = {
"version": "1.0.0",
"services": {
"backend": {"running": True},
"memory": {"running": False},
"provider": {"reachable": True},
},
"api": {"online": True, "action_tool_policy": "ask"},
"host": {"cpu_pct": 10.0},
"toolchains": [{"lang": "python", "ready": True}],
}
line = _status_line(snap)
assert "backend=UP" in line
assert "memory=DOWN" in line
assert "provider=UP" in line
assert "tools=ask" in line
assert "run=python" in line
def test_compact_status_handles_api_down():
snap = {
"host": {},
"api": {"online": False},
"recent_tools": [],
}
assert "api DOWN" in _compact_status(snap)
def test_escape_preserves_code_brackets_in_display():
raw = "idx = arr[i] and rng = [a-z]+"
plain = Text.from_markup(format_assistant_line(raw)).plain
assert "arr[i]" in plain
assert "[a-z]+" in plain
# Unescaped markup would drop the bracket contents.
assert plain != "nexus> idx = arr and rng = +"
def test_closing_tag_in_model_output_does_not_raise():
raw = "close with [/] please"
plain = Text.from_markup(format_assistant_line(raw)).plain
assert "[/]" in plain
def test_user_line_escapes_markup():
plain = Text.from_markup(format_user_line("use [bold] please")).plain
assert "[bold]" in plain
def test_finish_stream_markup_does_not_wedge_busy():
"""A stray '[/]' used to raise before _busy=False and lock the TUI forever."""
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
async def _run():
async with app.run_test():
app._busy = True
app._finish_stream("see [/] and arr[i]")
assert app._busy is False
assert app.history[-1]["content"] == "see [/] and arr[i]"
asyncio.run(_run())
def test_escape_round_trip_helper():
assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]")