diff --git a/.env.navi_code.example b/.env.navi_code.example new file mode 100644 index 0000000..068ba96 --- /dev/null +++ b/.env.navi_code.example @@ -0,0 +1,37 @@ +# ── Navi Code — local terminal-first configuration example ───────────────────── +# Copy this file to .env and adjust values for your local setup. +# Navi Code runs without auth; every request is treated as the local admin user. + +NAVI_AUTH_ENABLED=false + +# Default profile for terminal coding. Must match a profile directory under navi/profiles/. +NAVI_DEFAULT_PROFILE_ID=navi_code + +# Global persona file for Navi Code. +NAVI_PERSONA_FILE=persona_navi_code.txt + +# ── LLM ───────────────────────────────────────────────────────────────────────── +OLLAMA_HOST=http://localhost:11434 +OLLAMA_DEFAULT_MODEL=gemma4:26b-a4b-it-q4_K_M +OLLAMA_NUM_CTX=8192 +OLLAMA_THINK=true + +# ── Database ──────────────────────────────────────────────────────────────────── +# PostgreSQL 15+ with pgvector extension. +DATABASE_URL=postgresql://navi:navipass@localhost:5432/navidb + +# ── Local sandboxing ──────────────────────────────────────────────────────────── +# "*" means unrestricted access. Safe only on a single-user local machine. +FS_ALLOWED_PATHS=* +TERMINAL_ALLOWED_COMMANDS=* + +# ── Public URL (not critical in terminal mode; used only if any tool builds links) +PUBLIC_URL=http://localhost:8000 + +# ── Files / storage ───────────────────────────────────────────────────────────── +SESSION_FILES_DIR=session_files +TOOLS_DIR=tools +CONTEXT_PROVIDERS_DIR=context_providers + +# ── Logging ───────────────────────────────────────────────────────────────────── +LOG_LEVEL=INFO diff --git a/README.md b/README.md index 5bd5f04..7e694aa 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,28 @@ Полная спецификация: [`docs/websocket.md`](docs/websocket.md) и [`docs/api.md`](docs/api.md) +## Navi Code — терминальный режим + +Локальный вариант Navi без авторизации, управляемый из терминала: + +```bash +# 1. Скопировать конфиг для терминального режима +cp .env.navi_code.example .env + +# 2. Запустить сервер (см. раздел "Запуск" выше) + +# 3. В другом терминале +navi-code +# или one-shot +navi-code "перепиши функцию на async/await" +``` + +- Профиль по умолчанию: `navi_code`. +- Без авторизации: `NAVI_AUTH_ENABLED=false`. +- CLI сохраняет `session_id` в `~/.navi_code/state.json`. + +Подробнее: [`docs/navi_code.md`](docs/navi_code.md) и [`docs/navi_code_cli.md`](docs/navi_code_cli.md). + ## Структура ``` @@ -80,7 +102,7 @@ ├── exceptions.py # доменные исключения ├── llm/ # LLM бэкенды: ollama.py, openai_backend.py ├── tools/ # встроенные инструменты (~20 шт.) -├── profiles/ # профили агентов: secretary, server_admin, developer, tool_developer, discuss, modeler_3d +├── profiles/ # профили агентов: secretary, server_admin, developer, tool_developer, discuss, modeler_3d, navi_code ├── core/ # Agent, registry, session, compressor, events ├── memory/ # долгосрочная память (PostgreSQL + pgvector) ├── workers/ # post-turn workers (CompressionWorker, MemoryWorker) @@ -93,9 +115,13 @@ ├── gmail.py └── weather.py +clients/ # клиенты +└── terminal/ # CLI-клиент Navi Code (navi-code) + manuals/ # markdown-мануалы для tool_manual docs/ # архитектурная документация persona.txt # глобальная личность и инструкции агента +persona_navi_code.txt # персона для терминального режима ``` ## Профили @@ -108,6 +134,7 @@ | `tool_developer` | Написание, тестирование и отладка пользовательских инструментов | 0.35 | ✓ | | `discuss` | Свободное обсуждение, мозговой штурм, лёгкие беседы | 0.85 | — | | `modeler_3d` | 3D-моделирование для 3D-печати (OpenSCAD → STL) | 0.35 | ✓ | +| `navi_code` | Локальный терминальный кодинг-ассистент | 0.35 | ✓ | `tool_developer` — единственный профиль с `reload_tools`, `delete_tool` и `test_tool`. diff --git a/clients/terminal/__init__.py b/clients/terminal/__init__.py new file mode 100644 index 0000000..052ed77 --- /dev/null +++ b/clients/terminal/__init__.py @@ -0,0 +1,3 @@ +"""Terminal client for Navi Code.""" + +__version__ = "0.1.0" diff --git a/clients/terminal/__main__.py b/clients/terminal/__main__.py new file mode 100644 index 0000000..c9c8f98 --- /dev/null +++ b/clients/terminal/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for `python -m clients.terminal`.""" + +from clients.terminal.cli import main + +if __name__ == "__main__": + main() diff --git a/clients/terminal/api.py b/clients/terminal/api.py new file mode 100644 index 0000000..400214b --- /dev/null +++ b/clients/terminal/api.py @@ -0,0 +1,49 @@ +"""REST API helpers for the terminal client.""" + +from __future__ import annotations + +import httpx + +from clients.terminal.config import settings + + +def _client() -> httpx.Client: + return httpx.Client(base_url=settings.base_url, timeout=30.0) + + +def get_profiles() -> list[dict]: + with _client() as client: + resp = client.get("/agents/profiles") + resp.raise_for_status() + return resp.json() + + +def list_sessions() -> list[dict]: + with _client() as client: + resp = client.get("/sessions") + resp.raise_for_status() + return resp.json() + + +def create_session(profile_id: str | None = None) -> dict: + body: dict = {} + if profile_id: + body["profile_id"] = profile_id + with _client() as client: + resp = client.post("/sessions", json=body) + resp.raise_for_status() + return resp.json() + + +def get_session(session_id: str) -> dict: + with _client() as client: + resp = client.get(f"/sessions/{session_id}") + resp.raise_for_status() + return resp.json() + + +def stop_session(session_id: str) -> dict: + with _client() as client: + resp = client.post(f"/sessions/{session_id}/stop") + resp.raise_for_status() + return resp.json() diff --git a/clients/terminal/cli.py b/clients/terminal/cli.py new file mode 100644 index 0000000..46bf036 --- /dev/null +++ b/clients/terminal/cli.py @@ -0,0 +1,230 @@ +"""Click CLI for the Navi Code terminal client.""" + +from __future__ import annotations + +import asyncio + +import click + +from clients.terminal import api +from clients.terminal.config import settings +from clients.terminal.render import Renderer +from clients.terminal.state import StateManager +from clients.terminal.ws_client import NaviWebSocketClient + + +@click.command(context_settings={"ignore_unknown_options": True, "allow_extra_args": True}) +@click.argument("prompt", required=False) +@click.option("--base-url", default=None, help="Navi API base URL.") +@click.option("--ws-url", default=None, help="Navi WebSocket URL (defaults to base-url).") +@click.option("--profile-id", default=None, help="Profile to use for new sessions.") +@click.option("--new-session", is_flag=True, help="Create a new session even if state exists.") +@click.option("--show-thinking", is_flag=True, help="Show model reasoning blocks.") +@click.option("--show-events/--no-events", default=True, help="Show tool call events.") +@click.option("--theme", default=None, help="TUI theme name to start with.") +@click.option("--raw", is_flag=True, help="Use the plain CLI instead of the TUI.") +@click.version_option(version="0.1.0", prog_name="navi-code") +def main( + prompt: str | None, + base_url: str | None, + ws_url: str | None, + profile_id: str | None, + new_session: bool, + show_thinking: bool, + show_events: bool, + theme: str | None, + raw: bool, +) -> None: + """Navi Code — terminal client for Navi. + + Without PROMPT, runs the TUI. With PROMPT, sends one message and exits. + Use --raw to run the plain CLI instead of the TUI. + """ + if base_url: + settings.base_url = base_url + if ws_url: + settings.ws_url = ws_url + if show_thinking: + settings.show_thinking = True + settings.show_events = show_events + + if raw or prompt: + _run_raw(prompt, new_session, profile_id) + return + + _run_tui(profile_id, new_session, theme) + + +def _run_raw(prompt: str | None, new_session: bool, profile_id: str | None) -> None: + state = StateManager() + session_id = _resolve_session_id(state, new_session, profile_id) + if not session_id: + raise click.ClickException("Failed to create or resume a session.") + + renderer = Renderer(show_thinking=settings.show_thinking, show_events=settings.show_events) + client = NaviWebSocketClient(session_id, renderer=renderer) + + if prompt: + asyncio.run(_run_one_shot(client, prompt)) + return + + asyncio.run(_run_interactive(client, state)) + + +def _run_tui(profile_id: str | None, new_session: bool, theme: str | None) -> None: + from clients.terminal.tui.tui_app import NaviCodeTui + + app = NaviCodeTui(profile_id=profile_id, new_session=new_session, theme_name=theme) + app.run(mouse=app._mouse_enabled) + + +def _resolve_session_id(state: StateManager, force_new: bool, profile_id: str | None) -> str | None: + if not force_new: + saved = state.get_session_id() + if saved: + try: + session = api.get_session(saved) + click.secho( + f"Resumed session {session['id'][:8]} (profile {session['profile_id']})", + fg="bright_black", + ) + return session["id"] + except Exception: + state.clear_session_id() + + profile = profile_id or settings.default_profile_id + try: + session = api.create_session(profile) + except Exception as exc: + click.secho(f"Failed to create session: {exc}", fg="red", err=True) + return None + + state.set_session_id(session["id"]) + click.secho( + f"Created session {session['id'][:8]} (profile {session['profile_id']})", + fg="green", + ) + return session["id"] + + +async def _run_one_shot(client: NaviWebSocketClient, prompt: str) -> None: + await client.run_one_shot(prompt) + + +async def _run_interactive(client: NaviWebSocketClient, state: StateManager) -> None: + click.secho("Navi Code interactive mode. Type /quit to exit, /help for commands.", fg="cyan") + + await client.connect() + receive_task = asyncio.create_task(client.receive_loop()) + + try: + while True: + try: + user_input = await asyncio.get_event_loop().run_in_executor( + None, lambda: input(click.style("You: ", fg="blue", bold=True)), + ) + except EOFError: + break + + user_input = user_input.strip() + if not user_input: + continue + + if user_input.startswith("/"): + handled = await _handle_command(user_input, state, client) + if handled: + continue + + client.enqueue(user_input) + finally: + client.stop_input() + receive_task.cancel() + try: + await receive_task + except asyncio.CancelledError: + pass + await client.close() + + +async def _handle_command(cmd: str, state: StateManager, client: NaviWebSocketClient) -> bool: + parts = cmd.split() + head = parts[0].lower() + + if head == "/quit": + raise click.exceptions.Exit(0) + + if head == "/help": + click.echo("Commands:") + click.echo(" /new create a new session") + click.echo(" /sessions list server sessions") + click.echo(" /switch switch to another session") + click.echo(" /profile show current session profile") + click.echo(" /clear clear local session state") + click.echo(" /quit exit") + return True + + if head == "/new": + try: + session = api.create_session(settings.default_profile_id) + except Exception as exc: + click.secho(f"Failed to create session: {exc}", fg="red", err=True) + return True + state.set_session_id(session["id"]) + click.secho( + f"Switched to new session {session['id'][:8]} (profile {session['profile_id']})", + fg="green", + ) + return True + + if head == "/sessions": + try: + sessions = api.list_sessions() + except Exception as exc: + click.secho(f"Failed to list sessions: {exc}", fg="red", err=True) + return True + for s in sessions: + click.echo(f" {s['id'][:8]} {s.get('profile_id', 'unknown')} {s.get('title', '')}") + return True + + if head == "/switch" and len(parts) == 2: + target = parts[1] + try: + session = api.get_session(target) + except Exception as exc: + # Try to find by prefix + try: + sessions = api.list_sessions() + matches = [s for s in sessions if s["id"].startswith(target)] + if len(matches) == 1: + session = matches[0] + else: + raise exc + except Exception: + click.secho(f"Session not found: {target}", fg="red", err=True) + return True + state.set_session_id(session["id"]) + click.secho( + f"Switched to session {session['id'][:8]} (profile {session['profile_id']})", + fg="green", + ) + return True + + if head == "/profile": + try: + current = api.get_session(state.get_session_id() or "") + click.echo(f"Profile: {current.get('profile_id', 'unknown')}") + click.echo(f"Session: {current['id']}") + except Exception as exc: + click.secho(f"Failed to get session: {exc}", fg="red", err=True) + return True + + if head == "/clear": + state.clear_session_id() + click.secho("Local session state cleared.", fg="green") + return True + + return False + + +if __name__ == "__main__": + main() diff --git a/clients/terminal/config.py b/clients/terminal/config.py new file mode 100644 index 0000000..6fa5ff4 --- /dev/null +++ b/clients/terminal/config.py @@ -0,0 +1,37 @@ +"""Pydantic settings for the terminal client.""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Configuration loaded from environment / .env file.""" + + model_config = SettingsConfigDict( + env_prefix="NAVI_CODE_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + base_url: str = "http://localhost:8000" + ws_url: str | None = None + state_dir: Path = Path.home() / ".navi_code" + default_profile_id: str = "navi_code" + show_thinking: bool = False + show_events: bool = True + + def websocket_url(self, session_id: str) -> str: + """Build WebSocket URL for a session.""" + base = (self.ws_url or self.base_url).rstrip("/") + if base.startswith("http://"): + base = base.replace("http://", "ws://", 1) + elif base.startswith("https://"): + base = base.replace("https://", "wss://", 1) + return f"{base}/ws/sessions/{session_id}" + + +settings = Settings() diff --git a/clients/terminal/render.py b/clients/terminal/render.py new file mode 100644 index 0000000..ecfffcb --- /dev/null +++ b/clients/terminal/render.py @@ -0,0 +1,89 @@ +"""Terminal rendering helpers for Navi WebSocket events.""" + +from __future__ import annotations + +import click + + +class Renderer: + """Render stream events to the terminal.""" + + def __init__(self, show_thinking: bool = False, show_events: bool = True) -> None: + self.show_thinking = show_thinking + self.show_events = show_events + self._thinking_buffer: list[str] = [] + self._in_thinking = False + + def _print(self, text: str = "", *, color: str | None = None, nl: bool = True) -> None: + click.secho(text, fg=color, nl=nl) + + def render(self, msg: dict) -> None: + msg_type = msg.get("type") + + if msg_type == "heartbeat": + return + + if msg_type == "session_sync": + if self.show_events: + profile_id = msg.get("profile_id") or "unknown" + self._print(f"[session {msg.get('session_id')[:8]} | profile {profile_id}]", color="bright_black") + return + + if msg_type == "stream_start": + if self.show_events: + self._print("[Navi] ", color="cyan", nl=False) + return + + if msg_type == "thinking_delta": + if self.show_thinking: + self._thinking_buffer.append(msg.get("delta", "")) + return + + if msg_type == "thinking_end": + if self.show_thinking and self._thinking_buffer: + self._print("\n[thinking]", color="bright_black") + self._print("".join(self._thinking_buffer), color="bright_black") + self._print("[/thinking]", color="bright_black") + self._thinking_buffer.clear() + return + + if msg_type == "stream_delta": + self._print(msg.get("delta", ""), nl=False) + return + + if msg_type == "tool_started": + if self.show_events: + tool = msg.get("tool", "?") + args = msg.get("args") or {} + self._print(f"\n[tool: {tool}]", color="yellow") + if args: + self._print(str(args), color="bright_black") + return + + if msg_type == "tool_call": + if self.show_events: + tool = msg.get("tool", "?") + success = msg.get("success", True) + color = "green" if success else "red" + self._print(f"[tool result: {tool} success={success}]", color=color) + result = msg.get("result") + if result: + preview = str(result).replace("\n", " ")[:400] + self._print(preview, color="bright_black") + return + + if msg_type == "stream_end": + self._print() # newline after response + return + + if msg_type == "error": + self._print(f"\n[error] {msg.get('message', 'unknown error')}", color="red") + return + + if msg_type == "context_compressed": + if self.show_events: + self._print("[context compressed]", color="bright_black") + return + + if self.show_events: + self._print(f"[event: {msg_type}] {msg}", color="bright_black") diff --git a/clients/terminal/state.py b/clients/terminal/state.py new file mode 100644 index 0000000..6a03213 --- /dev/null +++ b/clients/terminal/state.py @@ -0,0 +1,47 @@ +"""Persistent session state for the terminal client.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from clients.terminal.config import settings + + +class StateManager: + """Read/write ~/.navi_code/state.json.""" + + def __init__(self, state_dir: Path | None = None) -> None: + self.state_dir = state_dir or settings.state_dir + self.state_file = self.state_dir / "state.json" + + def _ensure_dir(self) -> None: + self.state_dir.mkdir(parents=True, exist_ok=True) + + def load(self) -> dict[str, Any]: + if not self.state_file.exists(): + return {} + try: + with self.state_file.open("r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + def save(self, data: dict[str, Any]) -> None: + self._ensure_dir() + with self.state_file.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + def get_session_id(self) -> str | None: + return self.load().get("session_id") + + def set_session_id(self, session_id: str) -> None: + state = self.load() + state["session_id"] = session_id + self.save(state) + + def clear_session_id(self) -> None: + state = self.load() + state.pop("session_id", None) + self.save(state) diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py new file mode 100644 index 0000000..2fd87b6 --- /dev/null +++ b/clients/terminal/tui/chat_model.py @@ -0,0 +1,89 @@ +"""In-memory model for the chat panel. + +Converts raw WebSocket events into logical chat messages that renderers understand. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + + +@dataclass +class ChatItem: + """Logical item shown in the chat history.""" + + kind: Literal[ + "user_message", + "assistant_message", + "thinking_block", + "tool_started", + "tool_call", + "error", + "status", + ] + content: str = "" + meta: dict = field(default_factory=dict) + id: str = "" + + +class ChatModel: + """Accumulate stream deltas and tool events into ChatItems.""" + + def __init__(self) -> None: + self.items: list[ChatItem] = [] + self._current_assistant: ChatItem | None = None + self._current_thinking: ChatItem | None = None + + def add_user_message(self, text: str) -> None: + self.items.append(ChatItem(kind="user_message", content=text)) + self._current_assistant = None + + def handle_ws_event(self, msg: dict) -> ChatItem | None: + msg_type = msg.get("type") + + if msg_type == "stream_start": + self._current_assistant = ChatItem(kind="assistant_message", content="") + self.items.append(self._current_assistant) + return None + + if msg_type == "thinking_delta": + if self._current_thinking is None: + self._current_thinking = ChatItem(kind="thinking_block", content="") + self.items.append(self._current_thinking) + self._current_thinking.content += msg.get("delta", "") + return None + + if msg_type == "thinking_end": + self._current_thinking = None + return None + + if msg_type == "stream_delta": + if self._current_assistant is None: + self._current_assistant = ChatItem(kind="assistant_message", content="") + self.items.append(self._current_assistant) + self._current_assistant.content += msg.get("delta", "") + return None + + if msg_type == "tool_started": + item = ChatItem(kind="tool_started", meta=msg) + self.items.append(item) + return item + + if msg_type == "tool_call": + item = ChatItem(kind="tool_call", meta=msg) + self.items.append(item) + return item + + if msg_type == "error": + item = ChatItem(kind="error", content=msg.get("message", "")) + self.items.append(item) + return item + + if msg_type in ("stream_end", "context_compressed", "heartbeat", "session_sync"): + return None + + # Unknown event — store as status for debugging. + item = ChatItem(kind="status", content=f"{msg_type}: {msg}") + self.items.append(item) + return item diff --git a/clients/terminal/tui/commands/__init__.py b/clients/terminal/tui/commands/__init__.py new file mode 100644 index 0000000..c47a855 --- /dev/null +++ b/clients/terminal/tui/commands/__init__.py @@ -0,0 +1,8 @@ +"""Slash commands and command registry.""" + +from __future__ import annotations + +from .base import BaseCommand, CommandMeta +from .registry import CommandRegistry, get_registry + +__all__ = ["BaseCommand", "CommandMeta", "CommandRegistry", "get_registry"] diff --git a/clients/terminal/tui/commands/base.py b/clients/terminal/tui/commands/base.py new file mode 100644 index 0000000..9869f30 --- /dev/null +++ b/clients/terminal/tui/commands/base.py @@ -0,0 +1,32 @@ +"""Base class for slash commands.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from clients.terminal.tui.context import TuiContext + + +@dataclass +class CommandMeta: + name: str + aliases: tuple[str, ...] + description: str + keybind: str | None = None + + +CommandHandler = Callable[["TuiContext", str], None] + + +class BaseCommand(ABC): + """A slash command registered in the command palette.""" + + meta: CommandMeta + + @abstractmethod + async def execute(self, ctx: "TuiContext", args: str) -> None: + """Run the command. args is the raw text after the command name.""" + raise NotImplementedError diff --git a/clients/terminal/tui/commands/builtin.py b/clients/terminal/tui/commands/builtin.py new file mode 100644 index 0000000..591920b --- /dev/null +++ b/clients/terminal/tui/commands/builtin.py @@ -0,0 +1,380 @@ +"""Built-in slash commands for Navi Code TUI.""" + +from __future__ import annotations + +import datetime +import os +import subprocess +import sys + +from clients.terminal import api +from clients.terminal.config import settings +from clients.terminal.tui.commands.base import BaseCommand, CommandMeta +from clients.terminal.tui.context import TuiContext +from clients.terminal.tui.events import SessionInfo, SessionListUpdated +from clients.terminal.tui.settings import get_tui_settings + + +class HelpCommand(BaseCommand): + meta = CommandMeta( + name="help", + aliases=(), + description="Show available slash commands.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + from clients.terminal.tui.commands.registry import get_registry + + registry = get_registry() + lines = ["[b]Slash commands[/b]"] + for cmd in registry.all(): + aliases = f" ({', '.join(cmd.meta.aliases)})" if cmd.meta.aliases else "" + key = f" [{cmd.meta.keybind}]" if cmd.meta.keybind else "" + lines.append(f" /{cmd.meta.name}{aliases}{key} — {cmd.meta.description}") + ctx.chat_panel.handle_ws_event({"type": "status", "content": "\n".join(lines)}) + + +class NewCommand(BaseCommand): + meta = CommandMeta( + name="new", + aliases=("clear",), + description="Start a new session.", + keybind="ctrl+x n", + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + try: + session = api.create_session(settings.default_profile_id) + except Exception as exc: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to create session: {exc}"} + ) + return + ctx.session_id = session["session_id"] + ctx.profile_id = session.get("profile_id") + ctx.state.set_session_id(session["session_id"]) + ctx.status_panel.set_session(session["session_id"]) + ctx.status_panel.set_profile(ctx.profile_id or settings.default_profile_id) + ctx.chat_panel.handle_ws_event( + {"type": "status", "content": f"Created session {session['session_id'][:8]}"} + ) + await _broadcast_session_list(ctx) + await _reconnect_ws(ctx) + + +class SessionsCommand(BaseCommand): + meta = CommandMeta( + name="sessions", + aliases=("resume", "continue"), + description="List and switch between sessions.", + keybind="ctrl+x l", + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + try: + sessions = api.list_sessions() + except Exception as exc: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to list sessions: {exc}"} + ) + return + lines = ["[b]Sessions[/b]"] + for s in sessions: + sid = s.get("session_id", "") + marker = "● " if sid == ctx.session_id else " " + title = s.get("name", "") or s.get("preview", "") + lines.append(f"{marker}{sid[:8]} {s.get('profile_id', 'unknown')} {title}") + ctx.chat_panel.handle_ws_event({"type": "status", "content": "\n".join(lines)}) + await _broadcast_session_list(ctx) + + +class SwitchCommand(BaseCommand): + meta = CommandMeta( + name="switch", + aliases=(), + description="Switch to another session by id or prefix.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + target = args.strip() + if not target: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": "Usage: /switch "} + ) + return + try: + session = api.get_session(target) + except Exception: + try: + sessions = api.list_sessions() + matches = [s for s in sessions if s.get("session_id", "").startswith(target)] + if len(matches) == 1: + session = matches[0] + else: + raise Exception("no unique match") + except Exception as exc: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Session not found: {target} ({exc})"} + ) + return + ctx.session_id = session["session_id"] + ctx.profile_id = session.get("profile_id") + ctx.state.set_session_id(session["session_id"]) + ctx.status_panel.set_session(session["session_id"]) + ctx.status_panel.set_profile(ctx.profile_id or settings.default_profile_id) + ctx.chat_panel.handle_ws_event( + {"type": "status", "content": f"Switched to {session['session_id'][:8]}"} + ) + await _broadcast_session_list(ctx) + await _reconnect_ws(ctx) + + +class ProfileCommand(BaseCommand): + meta = CommandMeta( + name="profile", + aliases=(), + description="Show current session profile.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + if not ctx.session_id: + ctx.chat_panel.handle_ws_event({"type": "error", "message": "No active session"}) + return + try: + session = api.get_session(ctx.session_id) + except Exception as exc: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to get session: {exc}"} + ) + return + ctx.chat_panel.handle_ws_event( + { + "type": "status", + "content": f"Profile: {session.get('profile_id')}\nSession: {session.get('session_id')}", + } + ) + + +class QuitCommand(BaseCommand): + meta = CommandMeta( + name="quit", + aliases=("exit", "q"), + description="Exit Navi Code.", + keybind="ctrl+x q", + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + app = ctx.app() + app.exit() + + +class ThinkingCommand(BaseCommand): + meta = CommandMeta( + name="thinking", + aliases=(), + description="Toggle thinking block visibility.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + settings.show_thinking = not settings.show_thinking + ctx.chat_panel.handle_ws_event( + { + "type": "status", + "content": f"Thinking blocks: {'on' if settings.show_thinking else 'off'}", + } + ) + + +class CompactCommand(BaseCommand): + meta = CommandMeta( + name="compact", + aliases=(), + description="Compact the current session (ask model to summarize context).", + keybind="ctrl+x c", + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + if ctx.ws_client: + ctx.ws_client.enqueue("Please summarize and compact our conversation so far.") + + +class ThemesCommand(BaseCommand): + meta = CommandMeta( + name="themes", + aliases=(), + description="Open the theme picker.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + app = ctx.app() + current = getattr(app, "_theme_name", "gnexus-dark") + + def on_picked(theme_name: str | None) -> None: + if theme_name is None: + ctx.chat_panel.handle_ws_event( + {"type": "status", "content": "Theme selection cancelled"} + ) + return + app = ctx.app() + app._theme_name = theme_name + app._apply_theme() + tui_settings = ctx.settings or get_tui_settings() + tui_settings.theme = theme_name + tui_settings.save() + ctx.chat_panel.handle_ws_event( + {"type": "status", "content": f"Theme set to {theme_name}"} + ) + + from clients.terminal.tui.screens.theme_picker import ThemePickerScreen + + app.push_screen(ThemePickerScreen(current), callback=on_picked) + + +class MouseCommand(BaseCommand): + meta = CommandMeta( + name="mouse", + aliases=(), + description="Toggle mouse support in the TUI (requires restart).", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + tui_settings = ctx.settings or get_tui_settings() + if args.strip().lower() in ("on", "true", "1", "yes"): + tui_settings.mouse = True + elif args.strip().lower() in ("off", "false", "0", "no"): + tui_settings.mouse = False + else: + tui_settings.mouse = not tui_settings.mouse + tui_settings.save() + state = "on" if tui_settings.mouse else "off" + ctx.chat_panel.handle_ws_event( + { + "type": "status", + "content": f"Mouse support set to {state}. Restart navi-code to apply.", + } + ) + + +class ExportCommand(BaseCommand): + meta = CommandMeta( + name="export", + aliases=("save",), + description="Export current session to markdown and open $EDITOR.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + if not ctx.session_id: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": "No active session to export"} + ) + return + try: + session = api.get_session(ctx.session_id) + except Exception as exc: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to get session: {exc}"} + ) + return + + short_id = ctx.session_id[:8] + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{short_id}_{timestamp}.md" + exports_dir = ctx.state.state_dir / "exports" + exports_dir.mkdir(parents=True, exist_ok=True) + file_path = exports_dir / filename + + try: + file_path.write_text(_render_export_markdown(session), encoding="utf-8") + except OSError as exc: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to write export: {exc}"} + ) + return + + _open_in_editor(str(file_path)) + ctx.chat_panel.handle_ws_event({"type": "status", "content": f"Exported to {file_path}"}) + + +def _render_export_markdown(session: dict) -> str: + lines: list[str] = [] + session_id = session.get("session_id", "unknown") + profile_id = session.get("profile_id", "unknown") + created = session.get("created_at", "") + lines.append(f"# Navi Code Export — {session_id[:8]}") + lines.append("") + lines.append(f"- **Profile:** {profile_id}") + lines.append(f"- **Session:** {session_id}") + if created: + lines.append(f"- **Created:** {created}") + lines.append("") + + messages = session.get("messages", []) + for msg in messages: + role = msg.get("role", "unknown") + content = msg.get("content", "") + if not content: + continue + heading = role.capitalize() + lines.append(f"## {heading}") + lines.append("") + lines.append(content) + lines.append("") + + return "\n".join(lines) + + +def _open_in_editor(path: str) -> None: + editor = os.environ.get("EDITOR") + if not editor: + editor = "notepad" if sys.platform == "win32" else "vi" + # Detach so the TUI is not blocked while the editor runs. + subprocess.Popen([editor, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +async def _broadcast_session_list(ctx: TuiContext) -> None: + """Refresh the sessions panel with the latest server state.""" + if not ctx.app: + return + try: + sessions = api.list_sessions() + except Exception: + return + info_list = [ + SessionInfo( + id=s.get("session_id", ""), + profile_id=s.get("profile_id", "unknown"), + title=s.get("name", "") or s.get("preview", ""), + created_at=s.get("created_at", ""), + ) + for s in sessions + ] + ctx.app().post_message(SessionListUpdated(info_list, ctx.session_id)) + + +async def _reconnect_ws(ctx: TuiContext) -> None: + """Close old WebSocket and open a new one for the current session.""" + if ctx.ws_client: + await ctx.ws_client.close() + if not ctx.session_id: + return + from clients.terminal.tui.events import ConnectionStatusChanged + from clients.terminal.ws_client import NaviWebSocketClient + + new_client = NaviWebSocketClient(ctx.session_id) + ctx.ws_client = new_client + try: + await new_client.connect() + app = ctx.app() + app.run_worker(new_client.receive_loop) + ctx.status_panel.set_connection(True, "") + app.post_message(ConnectionStatusChanged(True, "")) + except Exception as exc: + ctx.status_panel.set_connection(False, str(exc)) + ctx.app().post_message(ConnectionStatusChanged(False, str(exc))) diff --git a/clients/terminal/tui/commands/registry.py b/clients/terminal/tui/commands/registry.py new file mode 100644 index 0000000..9357d2e --- /dev/null +++ b/clients/terminal/tui/commands/registry.py @@ -0,0 +1,62 @@ +"""Registry for slash commands.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .base import BaseCommand + + +class CommandRegistry: + """Store and resolve slash commands by name/alias.""" + + def __init__(self) -> None: + self._commands: dict[str, BaseCommand] = {} + + def register(self, command: "BaseCommand") -> None: + self._commands[command.meta.name] = command + for alias in command.meta.aliases: + self._commands[alias] = command + + def get(self, name: str) -> "BaseCommand | None": + return self._commands.get(name.lower()) + + def all(self) -> list["BaseCommand"]: + """Return unique commands by canonical name.""" + seen: set[int] = set() + out: list[BaseCommand] = [] + for cmd in self._commands.values(): + cid = id(cmd) + if cid not in seen: + seen.add(cid) + out.append(cmd) + return out + + +_GLOBAL_REGISTRY: CommandRegistry | None = None + + +def get_registry() -> CommandRegistry: + global _GLOBAL_REGISTRY + if _GLOBAL_REGISTRY is None: + _GLOBAL_REGISTRY = _build_default_registry() + return _GLOBAL_REGISTRY + + +def _build_default_registry() -> CommandRegistry: + from . import builtin + + registry = CommandRegistry() + registry.register(builtin.HelpCommand()) + registry.register(builtin.NewCommand()) + registry.register(builtin.SessionsCommand()) + registry.register(builtin.SwitchCommand()) + registry.register(builtin.ProfileCommand()) + registry.register(builtin.QuitCommand()) + registry.register(builtin.ThinkingCommand()) + registry.register(builtin.CompactCommand()) + registry.register(builtin.ThemesCommand()) + registry.register(builtin.MouseCommand()) + registry.register(builtin.ExportCommand()) + return registry diff --git a/clients/terminal/tui/context.py b/clients/terminal/tui/context.py new file mode 100644 index 0000000..418c597 --- /dev/null +++ b/clients/terminal/tui/context.py @@ -0,0 +1,37 @@ +"""Shared context passed to commands and components.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from clients.terminal.state import StateManager + from clients.terminal.tui.chat_model import ChatModel + from clients.terminal.tui.settings import TuiSettings + from clients.terminal.tui.widgets.chat_panel import ChatPanel + from clients.terminal.tui.widgets.sessions_panel import SessionsPanel + from clients.terminal.tui.widgets.status_panel import StatusPanel + from clients.terminal.ws_client import NaviWebSocketClient + + +@dataclass +class TuiContext: + """Mutable bag of services and widgets available to commands.""" + + session_id: str | None = None + profile_id: str | None = None + ws_client: "NaviWebSocketClient | None" = None + state: "StateManager | None" = None + settings: "TuiSettings | None" = None + chat_panel: "ChatPanel | None" = None + status_panel: "StatusPanel | None" = None + sessions_panel: "SessionsPanel | None" = None + chat_model: "ChatModel | None" = None + + def app(self): + """Return the running TuiApp instance.""" + from textual import app as textual_app + + # active_app is a ContextVar containing the currently running App. + return textual_app.active_app.get() diff --git a/clients/terminal/tui/events.py b/clients/terminal/tui/events.py new file mode 100644 index 0000000..012aaba --- /dev/null +++ b/clients/terminal/tui/events.py @@ -0,0 +1,76 @@ +"""Typed events used inside the TUI app and between components.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from textual.message import Message + + +class WsEvent(Message): + """Raw WebSocket event forwarded from the backend.""" + + def __init__(self, payload: dict) -> None: + self.payload = payload + super().__init__() + + +class ConnectionStatusChanged(Message): + """Fired when WebSocket connection state changes.""" + + def __init__(self, connected: bool, detail: str = "") -> None: + self.connected = connected + self.detail = detail + super().__init__() + + +class UserSubmitted(Message): + """User pressed Enter in the input box.""" + + def __init__(self, text: str) -> None: + self.text = text + super().__init__() + + +class CommandTriggered(Message): + """User typed a slash command.""" + + def __init__(self, name: str, args: str) -> None: + self.name = name + self.args = args + super().__init__() + + +class PermissionRequest(Message): + """Tool call requires destructive-operation confirmation (component fallback).""" + + def __init__(self, tool: str, args: dict, message: str) -> None: + self.tool = tool + self.args = args + self.message = message + super().__init__() + + +@dataclass +class SessionInfo: + id: str + profile_id: str + title: str = "" + created_at: str = "" + + +class SessionListUpdated(Message): + """Sessions list changed (loaded, switched, created).""" + + def __init__(self, sessions: list[SessionInfo], current_id: str | None) -> None: + self.sessions = sessions + self.current_id = current_id + super().__init__() + + +class SessionSelected(Message): + """User selected a session from the sessions panel.""" + + def __init__(self, session_id: str) -> None: + self.session_id = session_id + super().__init__() diff --git a/clients/terminal/tui/file_refs.py b/clients/terminal/tui/file_refs.py new file mode 100644 index 0000000..34ca6f5 --- /dev/null +++ b/clients/terminal/tui/file_refs.py @@ -0,0 +1,317 @@ +"""Resolve @path references inside user input. + +Supported forms: + @path/to/file.py → file content wrapped in code fence + @dir/ → list of files in directory (recursive if trailing /) + @tests/**/*.py → glob expansion, files only + +Size limits apply per-file and in total to avoid flooding the LLM context. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable + +from clients.terminal.tui.renderers.language import guess_language + + +MAX_FILE_BYTES = 64_000 +MAX_TOTAL_BYTES = 128_000 +TRUNCATED_NOTICE = "\n... [truncated by Navi Code]" + +# Sensitive paths/patterns that should never be attached automatically. +SENSITIVE_NAMES: set[str] = { + ".env", + ".env.local", + ".env.production", + ".env.staging", + ".git", + ".gitignore", + ".ssh", + ".aws", + ".docker", + ".npmrc", + ".pypirc", + ".netrc", + ".pgpass", + "id_rsa", + "id_rsa.pub", + "id_dsa", + "id_dsa.pub", + "id_ecdsa", + "id_ecdsa.pub", + "id_ed25519", + "id_ed25519.pub", + ".DS_Store", + "Thumbs.db", +} + +SENSITIVE_SUFFIXES: tuple[str, ...] = ( + ".pem", + ".key", + ".crt", + ".p12", + ".pfx", + ".keystore", + ".jks", + ".pyc", + ".pyo", +) + +SENSITIVE_DIR_NAMES: set[str] = { + ".git", + ".ssh", + ".aws", + ".venv", + "venv", + "node_modules", + "__pycache__", + ".tox", + ".pytest_cache", + ".mypy_cache", + ".egg-info", + "dist", + "build", +} + + +@dataclass +class ResolvedFile: + """A file resolved from an @ reference.""" + + path: Path + display_path: str + content: str + truncated: bool = False + + +@dataclass +class FileRefResult: + """Result of resolving @ references in a prompt.""" + + prompt: str # user-visible prompt (with @ markers replaced by file list) + attachments: list[ResolvedFile] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + total_bytes: int = 0 + + def is_empty(self) -> bool: + return not self.attachments and not self.errors + + def to_message(self) -> str: + """Build the full message to send to the backend.""" + if not self.attachments and not self.errors: + return self.prompt + + parts = [self.prompt] + if self.attachments: + parts.append("") + parts.append("--- attached files ---") + for f in self.attachments: + lang = guess_language(f.path) + label = f"file: {f.display_path}" + if f.truncated: + label += " (truncated)" + parts.append(f"```{lang} {label}") + parts.append(f.content) + parts.append("```") + if self.errors: + parts.append("") + parts.append("--- attachment errors ---") + for err in self.errors: + parts.append(f"- {err}") + return "\n".join(parts) + + +_ref_pattern = re.compile(r"@((?:[A-Za-z0-9_\-\.~/$*?\[\]\\]|\\\s)+)") + + +def find_refs(text: str) -> list[str]: + """Return all @path tokens found in text, in order, without duplicates.""" + seen: set[str] = set() + refs: list[str] = [] + for raw in _ref_pattern.findall(text): + # Un-escape backslash-space inside the token. + ref = raw.replace("\\ ", " ") + if ref not in seen: + seen.add(ref) + refs.append(ref) + return refs + + +class FileRefResolver: + """Resolve @ references relative to a base directory.""" + + def __init__(self, base_dir: Path | str | None = None) -> None: + self.base_dir = Path(base_dir or Path.cwd()).expanduser().resolve() + self._home_dir = Path.home().expanduser().resolve() + + def resolve(self, text: str) -> FileRefResult: + refs = find_refs(text) + if not refs: + return FileRefResult(prompt=text) + + result = FileRefResult(prompt=text) + for ref in refs: + self._resolve_ref(ref, result) + if result.total_bytes >= MAX_TOTAL_BYTES: + result.errors.append("total attachment size limit reached; remaining files skipped") + break + return result + + def _resolve_ref(self, ref: str, result: FileRefResult) -> None: + path = self._expand_path(ref) + if path is None: + result.errors.append(f"could not resolve {ref!r}") + return + + if path.exists(): + if path.is_dir(): + files = sorted(_collect_files(path, recursive=ref.endswith("/"))) + if not files: + result.errors.append(f"no files found in {ref}") + return + for file_path in files: + self._attach_file(file_path, result, root_dir=path) + if result.total_bytes >= MAX_TOTAL_BYTES: + return + return + + if path.is_file(): + self._attach_file(path, result) + return + + result.errors.append(f"not a file or directory: {ref}") + return + + # Non-existent path: try glob expansion if it looks like a pattern. + if _is_glob(ref): + matches = sorted(self.base_dir.glob(ref)) + if not matches: + result.errors.append(f"no matches for {ref}") + return + for file_path in matches: + if not file_path.is_file(): + continue + self._attach_file(file_path, result) + if result.total_bytes >= MAX_TOTAL_BYTES: + return + return + + result.errors.append(f"not found: {ref}") + + def _attach_file(self, path: Path, result: FileRefResult, root_dir: Path | None = None) -> None: + if _is_sensitive_path(path): + display = _display_path(path, self.base_dir, root_dir) + result.errors.append(f"skipped sensitive file: {display}") + return + + display = _display_path(path, self.base_dir, root_dir) + try: + data = path.read_bytes() + except Exception as exc: + result.errors.append(f"failed to read {path}: {exc}") + return + + if b"\x00" in data: + result.errors.append(f"skipped binary file: {display}") + return + + truncated = False + if len(data) > MAX_FILE_BYTES: + data = data[:MAX_FILE_BYTES] + truncated = True + + remaining = MAX_TOTAL_BYTES - result.total_bytes + if remaining <= 0: + return + if len(data) > remaining: + data = data[:remaining] + truncated = True + + try: + text = data.decode("utf-8", errors="replace") + except Exception as exc: + result.errors.append(f"failed to decode {path}: {exc}") + return + + if truncated: + text += TRUNCATED_NOTICE + result.total_bytes += len(TRUNCATED_NOTICE.encode("utf-8")) + result.attachments.append(ResolvedFile(path=path, display_path=display, content=text, truncated=truncated)) + result.total_bytes += len(data) + + def _expand_path(self, ref: str) -> Path | None: + """Expand a raw @ reference into an absolute, validated Path. + + Paths are restricted to the resolver's base directory. The only + exception is explicit ``~`` expansion, which is allowed inside the + user's home directory. + """ + # Strip any trailing slash for expansion, but keep the flag later. + clean = ref.rstrip("/") + if not clean: + return None + if clean.startswith("~"): + candidate = Path(clean).expanduser().resolve() + allowed_root = self._home_dir + else: + candidate = (self.base_dir / clean).resolve() + allowed_root = self.base_dir + + if not _is_under_root(candidate, allowed_root): + return None + return candidate + + +def _is_glob(ref: str) -> bool: + """Return True if ref contains glob metacharacters.""" + return "*" in ref or "?" in ref or "[" in ref + + +def _collect_files(path: Path, recursive: bool = False) -> Iterable[Path]: + """Yield non-sensitive files inside a directory.""" + iterator = path.rglob("*") if recursive else path.iterdir() + for p in sorted(iterator): + if p.is_file() and not _is_inside_sensitive_dir(p): + yield p + + +def _display_path(path: Path, base: Path, root_dir: Path | None = None) -> str: + for candidate in (root_dir, base): + if candidate is not None: + try: + return str(path.relative_to(candidate)) + except ValueError: + continue + return str(path) + + +def _is_under_root(candidate: Path, root: Path) -> bool: + """Return True if candidate stays inside root (after symlink resolution).""" + try: + candidate.relative_to(root) + return True + except ValueError: + return False + + +def _is_inside_sensitive_dir(path: Path) -> bool: + """Return True if path lives inside a directory that should be skipped.""" + for part in path.parts: + if part in SENSITIVE_DIR_NAMES: + return True + return False + + +def _is_sensitive_path(path: Path) -> bool: + """Return True if path matches a sensitive file pattern.""" + if _is_inside_sensitive_dir(path): + return True + if path.name in SENSITIVE_NAMES: + return True + if path.name.lower().endswith(SENSITIVE_SUFFIXES): + return True + return False diff --git a/clients/terminal/tui/permissions.py b/clients/terminal/tui/permissions.py new file mode 100644 index 0000000..7250e55 --- /dev/null +++ b/clients/terminal/tui/permissions.py @@ -0,0 +1,124 @@ +"""Permission engine for destructive tool operations.""" + +from __future__ import annotations + +import fnmatch +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + + +@dataclass +class PermissionRule: + """A rule that decides whether a tool call needs confirmation.""" + + tool: str + action: str | None = None # filesystem action, terminal command pattern, etc. + pattern: str | None = None # glob pattern for paths/commands + message: str = "" + + +# Default destructive patterns. +DEFAULT_RULES: list[PermissionRule] = [ + PermissionRule(tool="filesystem", action="delete", message="Delete file/directory"), + PermissionRule(tool="filesystem", action="move", message="Move/overwrite file"), + PermissionRule(tool="filesystem", action="write", message="Overwrite existing file"), + PermissionRule(tool="terminal", pattern="rm *", message="Remove files/directories"), + PermissionRule(tool="terminal", pattern="*format*", message="Format operation"), + PermissionRule(tool="terminal", pattern="*drop*", message="Drop database/table"), + PermissionRule(tool="code_exec", message="Execute arbitrary code"), + PermissionRule(tool="ssh_exec", message="Execute remote command"), + PermissionRule(tool="shell", action="run", message="Local shell command"), +] + + +PermissionCallback = Callable[[bool], None] + + +class PermissionEngine: + """Check whether a tool call requires user confirmation.""" + + def __init__(self, store_path: Path | None = None, rules: list[PermissionRule] | None = None) -> None: + self._rules = rules or list(DEFAULT_RULES) + self._store_path = store_path or (Path.home() / ".navi_code" / "permissions.json") + self._always_allow: set[str] = set() + self._always_deny: set[str] = set() + self._load() + + def _load(self) -> None: + if not self._store_path.exists(): + return + try: + data = json.loads(self._store_path.read_text()) + self._always_allow = set(data.get("allow", [])) + self._always_deny = set(data.get("deny", [])) + except Exception: + self._always_allow = set() + self._always_deny = set() + + def _save(self) -> None: + self._store_path.parent.mkdir(parents=True, exist_ok=True) + data = {"allow": sorted(self._always_allow), "deny": sorted(self._always_deny)} + self._store_path.write_text(json.dumps(data, indent=2)) + + def check(self, tool: str, args: dict) -> PermissionRule | None: + """Return matching rule if confirmation is needed, else None. + + Always-allow and always-deny entries both bypass the confirmation + dialog. Callers that need to actively reject always-deny matches can + use :meth:`is_always_deny`. + """ + rule_key = self._rule_key(tool, args) + if rule_key in self._always_allow or rule_key in self._always_deny: + return None + + for rule in self._rules: + if rule.tool != tool: + continue + if rule.action is not None: + if args.get("action") != rule.action: + continue + if rule.pattern is not None: + target = self.extract_target(tool, args) + if target is None or not fnmatch.fnmatch(target, rule.pattern): + continue + return rule + return None + + def is_always_deny(self, tool: str, args: dict) -> bool: + """Return True if this tool call was permanently denied by the user.""" + return self._rule_key(tool, args) in self._always_deny + + def extract_target(self, tool: str, args: dict) -> str: + """Public helper for extracting the human-readable target of a tool call.""" + return self._extract_target(tool, args) + + def set_always_allow(self, tool: str, args: dict) -> None: + self._always_allow.add(self._rule_key(tool, args)) + self._always_deny.discard(self._rule_key(tool, args)) + self._save() + + def set_always_deny(self, tool: str, args: dict) -> None: + self._always_deny.add(self._rule_key(tool, args)) + self._always_allow.discard(self._rule_key(tool, args)) + self._save() + + def _rule_key(self, tool: str, args: dict) -> str: + action = args.get("action", "") + target = self.extract_target(tool, args) + if target: + return f"{tool}:{action}:{target}" + return f"{tool}:{action}" + + @staticmethod + def _extract_target(tool: str, args: dict) -> str: + if tool == "filesystem": + return args.get("path", "") or args.get("destination", "") + if tool == "terminal": + return args.get("command", "") or args.get("action", "") + if tool == "code_exec": + return args.get("language", "") or args.get("code", "")[:40] + if tool == "ssh_exec": + return args.get("host", "") or args.get("command", "") + return "" diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py new file mode 100644 index 0000000..fc30a6e --- /dev/null +++ b/clients/terminal/tui/renderers/__init__.py @@ -0,0 +1,30 @@ +"""Content renderers for the TUI chat panel.""" + +from __future__ import annotations + +from .base import ContentRenderer +from .registry import RendererRegistry +from . import message, tool, thinking, error, markdown_content, plain, diff, artifact + + +def default_registry() -> RendererRegistry: + """Return a registry with all built-in renderers.""" + reg = RendererRegistry() + reg.register(message.UserMessageRenderer()) + reg.register(message.AssistantMessageRenderer()) + reg.register(thinking.ThinkingRenderer()) + reg.register(tool.ToolStartedRenderer()) + reg.register(tool.ToolResultRenderer()) + reg.register(error.ErrorRenderer()) + reg.register(markdown_content.MarkdownRenderer()) + reg.register(diff.DiffRenderer()) + reg.register(artifact.ArtifactRenderer()) + reg.register(plain.PlainRenderer()) + return reg + + +__all__ = [ + "ContentRenderer", + "RendererRegistry", + "default_registry", +] diff --git a/clients/terminal/tui/renderers/artifact.py b/clients/terminal/tui/renderers/artifact.py new file mode 100644 index 0000000..0f5db13 --- /dev/null +++ b/clients/terminal/tui/renderers/artifact.py @@ -0,0 +1,55 @@ +"""Renderer for code artifact messages.""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.panel import Panel +from rich.syntax import Syntax +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer +from .language import guess_language + + +def _theme_aware_code_theme(theme_name: str) -> str: + """Pick a Pygments code theme that matches the Navi theme brightness.""" + return "dracula" if theme_name == "gnexus-dark" else "github-light" + + +class ArtifactRenderer(ContentRenderer): + """Render a file-like artifact with syntax highlighting.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "artifact" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + path = msg.get("path", "artifact") + language = msg.get("language") or guess_language(path) + content = msg.get("content", "") + + if not content.strip(): + return Panel( + Text("(empty artifact)", style=theme.text_dim.hex), + title=path, + title_align="left", + border_style=theme.border.hex, + ) + + code_theme = _theme_aware_code_theme(theme.name) + syntax = Syntax( + content, + language, + theme=code_theme, + background_color=theme.surface.hex, + line_numbers=True, + word_wrap=True, + ) + return Panel( + syntax, + title=f"{path} [{language}]", + title_align="left", + border_style=theme.tool_border.hex, + ) diff --git a/clients/terminal/tui/renderers/base.py b/clients/terminal/tui/renderers/base.py new file mode 100644 index 0000000..d5ca6e5 --- /dev/null +++ b/clients/terminal/tui/renderers/base.py @@ -0,0 +1,23 @@ +"""Base class for content renderers used in the chat panel.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rich.console import RenderableType + + +class ContentRenderer(ABC): + """Render a single WebSocket event or chat message into a Rich renderable.""" + + @abstractmethod + def accepts(self, msg: dict) -> bool: + """Return True if this renderer can handle the event/message.""" + raise NotImplementedError + + @abstractmethod + def render(self, msg: dict) -> "RenderableType": + """Return a Rich renderable.""" + raise NotImplementedError diff --git a/clients/terminal/tui/renderers/diff.py b/clients/terminal/tui/renderers/diff.py new file mode 100644 index 0000000..7f99592 --- /dev/null +++ b/clients/terminal/tui/renderers/diff.py @@ -0,0 +1,45 @@ +"""Renderer for unified diff messages.""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + + +class DiffRenderer(ContentRenderer): + """Render a unified diff with added/removed line highlighting.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "diff" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + content = msg.get("content", "") + old_label = msg.get("old_label", "---") + new_label = msg.get("new_label", "+++") + + lines = content.splitlines() + highlighted = Text() + for idx, line in enumerate(lines): + if idx: + highlighted.append("\n") + if line.startswith("+") and not line.startswith("+++"): + highlighted.append(line, style=theme.success.hex) + elif line.startswith("-") and not line.startswith("---"): + highlighted.append(line, style=theme.error.hex) + elif line.startswith("@@"): + highlighted.append(line, style=theme.text_dim.hex) + else: + highlighted.append(line, style=theme.text.hex) + + return Panel( + highlighted, + title=f"diff: {old_label} → {new_label}", + title_align="left", + border_style=theme.border.hex, + ) diff --git a/clients/terminal/tui/renderers/error.py b/clients/terminal/tui/renderers/error.py new file mode 100644 index 0000000..e389a46 --- /dev/null +++ b/clients/terminal/tui/renderers/error.py @@ -0,0 +1,30 @@ +"""Renderer for error events.""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.box import ROUNDED +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + + +class ErrorRenderer(ContentRenderer): + """Render an error message panel.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "error" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + text = msg.get("message", "unknown error") + return Panel( + Text(text, style=f"bold {theme.error.hex}"), + title="error", + title_align="left", + border_style=theme.error.hex, + box=ROUNDED, + ) diff --git a/clients/terminal/tui/renderers/language.py b/clients/terminal/tui/renderers/language.py new file mode 100644 index 0000000..2cfe919 --- /dev/null +++ b/clients/terminal/tui/renderers/language.py @@ -0,0 +1,47 @@ +"""Shared language-guessing helpers for code fences and syntax highlighting.""" + +from __future__ import annotations + +from pathlib import Path + + +LANGUAGE_MAPPING: dict[str, str] = { + ".py": "python", + ".js": "javascript", + ".ts": "typescript", + ".tsx": "tsx", + ".jsx": "jsx", + ".go": "go", + ".rs": "rust", + ".c": "c", + ".cpp": "cpp", + ".h": "c", + ".java": "java", + ".kt": "kotlin", + ".sh": "bash", + ".zsh": "bash", + ".bash": "bash", + ".md": "markdown", + ".json": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".html": "html", + ".css": "css", + ".scss": "scss", + ".sql": "sql", + ".dockerfile": "dockerfile", + ".lock": "text", + ".txt": "text", + ".env": "bash", +} + + +def guess_language(path: Path | str) -> str: + """Best-effort language tag for a file path or artifact name.""" + name = str(path) + lower_name = name.lower() + for ext, lang in LANGUAGE_MAPPING.items(): + if lower_name.endswith(ext): + return lang + return "text" diff --git a/clients/terminal/tui/renderers/markdown_content.py b/clients/terminal/tui/renderers/markdown_content.py new file mode 100644 index 0000000..32f5af4 --- /dev/null +++ b/clients/terminal/tui/renderers/markdown_content.py @@ -0,0 +1,63 @@ +"""Renderer for markdown content.""" + +from __future__ import annotations + +from rich.console import Console, RenderableType, ConsoleOptions, RenderResult +from rich.markdown import Markdown +from rich.segment import Segment +from rich.style import Style +from rich.theme import Theme as RichTheme + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + + +def _theme_aware_code_theme(theme_name: str) -> str: + """Pick a Pygments code theme that matches the Navi theme brightness.""" + return "dracula" if theme_name == "gnexus-dark" else "github-light" + + +class ThemedMarkdownRenderable: + """Wrap Markdown and render it with Navi theme colors applied.""" + + def __init__(self, markdown: Markdown, theme_name: str) -> None: + self._markdown = markdown + self._theme_name = theme_name + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + theme = get_active_theme() + themed_console = Console( + width=console.width, + color_system=console.color_system, + theme=RichTheme(theme.rich_theme_styles()), + force_terminal=True, + ) + segments = list(themed_console.render(self._markdown)) + link_color = Style.parse(theme.link.hex).color + for segment in segments: + style = segment.style + if style and style.link and link_color is not None: + segment = Segment( + segment.text, + Style(color=link_color, underline=True, link=style.link), + ) + yield segment + + +class MarkdownRenderer(ContentRenderer): + """Render markdown text with syntax highlighting.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "markdown" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + text = msg.get("content", "") + code_theme = _theme_aware_code_theme(theme.name) + md = Markdown( + text, + code_theme=code_theme, + inline_code_theme=code_theme, + ) + return ThemedMarkdownRenderable(md, theme.name) diff --git a/clients/terminal/tui/renderers/message.py b/clients/terminal/tui/renderers/message.py new file mode 100644 index 0000000..6cfb4b2 --- /dev/null +++ b/clients/terminal/tui/renderers/message.py @@ -0,0 +1,53 @@ +"""Renderers for user and assistant chat messages.""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.box import ROUNDED +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + + +def _hex_style(color) -> str: + """Return a Rich-compatible hex style string from a Textual Color.""" + return color.hex + + +class UserMessageRenderer(ContentRenderer): + """Render a user message bubble.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "user_message" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + text = msg.get("content", "") + return Panel( + Text(text, style=_hex_style(theme.text)), + title="You", + title_align="left", + border_style=_hex_style(theme.user_bubble), + box=ROUNDED, + ) + + +class AssistantMessageRenderer(ContentRenderer): + """Render a completed assistant response as a panel.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "assistant_message" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + text = msg.get("content", "") + return Panel( + Text(text, style=_hex_style(theme.text)), + title="Navi", + title_align="left", + border_style=_hex_style(theme.assistant_bubble), + box=ROUNDED, + ) diff --git a/clients/terminal/tui/renderers/plain.py b/clients/terminal/tui/renderers/plain.py new file mode 100644 index 0000000..ffd36b2 --- /dev/null +++ b/clients/terminal/tui/renderers/plain.py @@ -0,0 +1,22 @@ +"""Fallback plain text renderer.""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + + +class PlainRenderer(ContentRenderer): + """Render any event as plain text; always accepts as fallback.""" + + def accepts(self, msg: dict) -> bool: + return True + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + text = msg.get("content", "") or str(msg) + return Text(text, style=theme.text.hex) diff --git a/clients/terminal/tui/renderers/registry.py b/clients/terminal/tui/renderers/registry.py new file mode 100644 index 0000000..b3ebfc1 --- /dev/null +++ b/clients/terminal/tui/renderers/registry.py @@ -0,0 +1,26 @@ +"""Renderer registry: pick the first renderer that accepts a message.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rich.console import RenderableType + + from .base import ContentRenderer + + +class RendererRegistry: + """Ordered list of renderers; first accepting renderer wins.""" + + def __init__(self) -> None: + self._renderers: list[ContentRenderer] = [] + + def register(self, renderer: "ContentRenderer") -> None: + self._renderers.append(renderer) + + def render(self, msg: dict) -> "RenderableType": + for renderer in self._renderers: + if renderer.accepts(msg): + return renderer.render(msg) + return str(msg) diff --git a/clients/terminal/tui/renderers/thinking.py b/clients/terminal/tui/renderers/thinking.py new file mode 100644 index 0000000..93e05e4 --- /dev/null +++ b/clients/terminal/tui/renderers/thinking.py @@ -0,0 +1,30 @@ +"""Renderer for thinking/reasoning blocks.""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.box import ROUNDED +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + + +class ThinkingRenderer(ContentRenderer): + """Render a complete thinking block.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "thinking_block" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + text = msg.get("content", "") + return Panel( + Text(text, style=theme.text_dim.hex), + title="thinking", + title_align="left", + border_style=theme.thinking.hex, + box=ROUNDED, + ) diff --git a/clients/terminal/tui/renderers/tool.py b/clients/terminal/tui/renderers/tool.py new file mode 100644 index 0000000..e1e4a82 --- /dev/null +++ b/clients/terminal/tui/renderers/tool.py @@ -0,0 +1,65 @@ +"""Renderers for tool_started and tool_call events.""" + +from __future__ import annotations + +import json + +from rich.console import RenderableType +from rich.json import JSON as RichJSON +from rich.box import ROUNDED +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + + +class ToolStartedRenderer(ContentRenderer): + """Render a tool call start marker.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "tool_started" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + tool = msg.get("tool", "?") + args = msg.get("args") or {} + title = f"→ {tool}" + if args: + try: + body = RichJSON(json.dumps(args)) + except Exception: + body = Text(str(args), style=theme.text_dim.hex) + else: + body = Text("") + return Panel( + body, + title=title, + title_align="left", + border_style=theme.tool_border.hex, + box=ROUNDED, + ) + + +class ToolResultRenderer(ContentRenderer): + """Render a tool call result.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "tool_call" + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + tool = msg.get("tool", "?") + success = msg.get("success", True) + result = msg.get("result") + color = theme.tool_success if success else theme.tool_error + title = f"← {tool} {'✓' if success else '✗'}" + body = Text(str(result) if result is not None else "", style=theme.text_dim.hex) + return Panel( + body, + title=title, + title_align="left", + border_style=color.hex, + box=ROUNDED, + ) diff --git a/clients/terminal/tui/screens/__init__.py b/clients/terminal/tui/screens/__init__.py new file mode 100644 index 0000000..5c9e4f2 --- /dev/null +++ b/clients/terminal/tui/screens/__init__.py @@ -0,0 +1,5 @@ +"""Textual screens for the Navi Code TUI.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/clients/terminal/tui/screens/command_palette.py b/clients/terminal/tui/screens/command_palette.py new file mode 100644 index 0000000..163d95f --- /dev/null +++ b/clients/terminal/tui/screens/command_palette.py @@ -0,0 +1,204 @@ +"""Command palette modal screen for Navi Code TUI.""" + +from __future__ import annotations + +from textual import events +from textual.app import ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Input, Label, ListItem, ListView, Static + +from clients.terminal.tui.commands.base import BaseCommand, CommandMeta + + +class CommandPaletteScreen(ModalScreen[BaseCommand | None]): + """A centered command palette with fuzzy filtering. + + Dismisses with the selected command (or None if cancelled). The caller is + responsible for executing the selected command with the TUI context. + """ + + DEFAULT_CSS = """ + CommandPaletteScreen { + align: center middle; + } + CommandPaletteScreen > Container { + width: 70; + height: auto; + max-height: 24; + border: thick $tui-primary; + background: $tui-surface; + padding: 0 0 1 0; + } + CommandPaletteScreen .title { + text-style: bold; + color: $tui-primary; + background: $tui-panel; + padding: 1; + height: auto; + text-align: center; + } + CommandPaletteScreen Input { + height: auto; + border: none; + border-bottom: solid $tui-border; + background: $tui-background; + color: $tui-text; + padding: 0 1; + margin: 0; + } + CommandPaletteScreen ListView { + height: auto; + max-height: 16; + border: none; + background: $tui-surface; + padding: 0; + margin: 0; + } + CommandPaletteScreen ListItem { + color: $tui-text; + background: transparent; + height: auto; + padding: 0 1; + } + CommandPaletteScreen ListItem.--highlight { + background: $tui-selection; + color: $tui-background; + } + CommandPaletteScreen ListItem.--highlight .cmd-description { + color: $tui-background; + } + CommandPaletteScreen ListItem.--highlight .cmd-keybind { + color: $tui-background; + } + CommandPaletteScreen .cmd-line { + height: auto; + width: 100%; + } + CommandPaletteScreen .cmd-name { + text-style: bold; + width: auto; + } + CommandPaletteScreen .cmd-description { + color: $tui-text-muted; + } + CommandPaletteScreen .cmd-keybind { + color: $tui-accent; + text-align: right; + dock: right; + width: auto; + } + CommandPaletteScreen .empty { + color: $tui-text-dim; + text-align: center; + padding: 1; + } + """ + + BINDINGS = [ + ("escape", "dismiss_cancel", "Cancel"), + ("ctrl+p", "dismiss_cancel", "Cancel"), + ] + + def __init__(self, commands: list[BaseCommand]) -> None: + super().__init__() + self._commands = commands + self._filtered = list(commands) + self._list_items: list[ListItem] = [] + + def compose(self) -> ComposeResult: + with Container(): + yield Static("Command palette", classes="title") + yield Input(placeholder="Type to filter commands...", id="palette-input") + yield ListView(id="palette-list") + + def on_mount(self) -> None: + self._render_list() + self.query_one("#palette-input", Input).focus() + + def _render_list(self) -> None: + list_view = self.query_one("#palette-list", ListView) + list_view.clear() + self._list_items = [] + if not self._filtered: + list_view.append(ListItem(Static("No matching commands", classes="empty"))) + return + for index, cmd in enumerate(self._filtered): + item = self._build_item(index, cmd.meta) + self._list_items.append(item) + list_view.append(item) + + def _build_item(self, index: int, meta: CommandMeta) -> ListItem: + keybind_text = f" {meta.keybind}" if meta.keybind else "" + aliases_text = f" ({', '.join(meta.aliases)})" if meta.aliases else "" + name_line = f"/{meta.name}{aliases_text}" + line = Label(f"{name_line}{keybind_text}", classes="cmd-line") + line.renderable = self._render_rich_line(name_line, meta.description, keybind_text) + return ListItem(line, id=f"palette-item-{index}-{meta.name}") + + def _render_rich_line(self, name: str, description: str, keybind: str): + from rich.text import Text + + parts: list[Text] = [] + parts.append(Text(name, style="bold")) + if keybind: + parts.append(Text(keybind, style="dim")) + parts.append(Text(f" — {description}", style="dim")) + return Text.assemble(*parts) + + def _filter(self, query: str) -> None: + query = query.strip().lower() + if not query: + self._filtered = list(self._commands) + else: + self._filtered = [ + cmd + for cmd in self._commands + if self._matches(cmd, query) + ] + self._render_list() + + def _matches(self, cmd: BaseCommand, query: str) -> bool: + meta = cmd.meta + haystack = " ".join( + [meta.name, " ".join(meta.aliases), meta.description, meta.keybind or ""] + ).lower() + return all(part in haystack for part in query.split()) + + def on_input_changed(self, event: Input.Changed) -> None: + self._filter(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + if self._filtered: + self.dismiss(self._filtered[0]) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + index = self._list_items.index(event.item) if event.item in self._list_items else None + if index is not None: + self.dismiss(self._filtered[index]) + + def on_key(self, event: events.Key) -> None: + list_view = self.query_one("#palette-list", ListView) + if event.key == "down": + list_view.action_cursor_down() + event.stop() + event.prevent_default() + elif event.key == "up": + list_view.action_cursor_up() + event.stop() + event.prevent_default() + elif event.key in ("enter", "return"): + highlighted = list_view.index + if 0 <= highlighted < len(self._filtered): + self.dismiss(self._filtered[highlighted]) + elif self._filtered: + self.dismiss(self._filtered[0]) + event.stop() + event.prevent_default() + elif event.key in ("escape", "ctrl+p"): + self.dismiss(None) + event.stop() + event.prevent_default() + + def action_dismiss_cancel(self) -> None: + self.dismiss(None) diff --git a/clients/terminal/tui/screens/permission_dialog.py b/clients/terminal/tui/screens/permission_dialog.py new file mode 100644 index 0000000..1d95024 --- /dev/null +++ b/clients/terminal/tui/screens/permission_dialog.py @@ -0,0 +1,80 @@ +"""Modal permission dialog for destructive tool operations.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Grid, Horizontal +from textual.screen import ModalScreen +from textual.widgets import Button, Static + + +class PermissionDialogScreen(ModalScreen[str | None]): + """Ask user whether to allow a potentially destructive tool call. + + Returns: + "allow_once" | "allow_always" | "deny_once" | "deny_always" | None (dismissed) + """ + + DEFAULT_CSS = """ + PermissionDialogScreen { align: center middle; } + PermissionDialogScreen > Grid { + grid-size: 1; + grid-gutter: 1 2; + padding: 1 2; + border: thick $tui-error; + background: $tui-surface; + width: 60; + height: auto; + } + PermissionDialogScreen > Grid > Static { + width: 100%; + color: $tui-text; + } + PermissionDialogScreen .tool-name { + color: $tui-error; + text-style: bold; + } + PermissionDialogScreen .details { + color: $tui-text-dim; + } + PermissionDialogScreen .buttons { height: auto; } + PermissionDialogScreen Button { + margin: 0 1; + } + """ + + def __init__( + self, + tool: str, + action: str, + target: str, + details: str, + ) -> None: + super().__init__() + self._tool = tool + self._action = action + self._target = target + self._details = details + + def compose(self) -> ComposeResult: + with Grid(): + yield Static("Permission required", classes="tool-name") + yield Static(f"Tool: {self._tool}", classes="details") + if self._action: + yield Static(f"Action: {self._action}", classes="details") + if self._target: + yield Static(f"Target: {self._target}", classes="details") + if self._details: + yield Static(self._details, classes="details") + with Horizontal(classes="buttons"): + yield Button("Allow once", id="allow_once", variant="primary") + yield Button("Always allow", id="allow_always", variant="primary") + yield Button("Deny", id="deny_once", variant="error") + yield Button("Always deny", id="deny_always", variant="error") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id) + + def on_key(self, event) -> None: + if event.key == "escape": + self.dismiss("deny_once") diff --git a/clients/terminal/tui/screens/theme_picker.py b/clients/terminal/tui/screens/theme_picker.py new file mode 100644 index 0000000..75f6b0a --- /dev/null +++ b/clients/terminal/tui/screens/theme_picker.py @@ -0,0 +1,186 @@ +"""Theme picker modal screen for Navi Code TUI.""" + +from __future__ import annotations + +from textual import events +from textual.app import ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Input, Label, ListItem, ListView, Static + +from clients.terminal.tui.themes import ThemeRegistry, set_active_theme + + +class ThemePickerScreen(ModalScreen[str | None]): + """A palette-like screen for switching themes with live preview. + + Dismisses with the selected theme name or None if cancelled. + """ + + NAME = "ThemePickerScreen" + + DEFAULT_CSS = """ + ThemePickerScreen { + align: center middle; + } + ThemePickerScreen > Container { + width: 60; + height: auto; + max-height: 24; + border: thick $tui-primary; + background: $tui-surface; + padding: 0 0 1 0; + } + ThemePickerScreen .title { + text-style: bold; + color: $tui-primary; + background: $tui-panel; + padding: 1; + height: auto; + text-align: center; + } + ThemePickerScreen Input { + height: auto; + border: none; + border-bottom: solid $tui-border; + background: $tui-background; + color: $tui-text; + padding: 0 1; + margin: 0; + } + ThemePickerScreen ListView { + height: auto; + max-height: 16; + border: none; + background: $tui-surface; + padding: 0; + margin: 0; + } + ThemePickerScreen ListItem { + color: $tui-text; + background: transparent; + height: auto; + padding: 0 1; + } + ThemePickerScreen ListItem.--highlight { + background: $tui-selection; + color: $tui-background; + } + ThemePickerScreen .empty { + color: $tui-text-dim; + text-align: center; + padding: 1; + } + """ + + BINDINGS = [ + ("escape", "dismiss_cancel", "Cancel"), + ] + + def __init__(self, current_theme: str) -> None: + super().__init__() + self._current_theme = current_theme + self._original_theme = current_theme + self._themes = ThemeRegistry.all() + self._filtered = list(self._themes) + self._list_items: list[ListItem] = [] + + def compose(self) -> ComposeResult: + with Container(): + yield Static("Pick a theme (live preview)", classes="title") + yield Input(placeholder="Type to filter themes...", id="theme-input") + yield ListView(id="theme-list") + + def on_mount(self) -> None: + self._render_list() + list_view = self.query_one("#theme-list", ListView) + list_view.focus() + + def _render_list(self) -> None: + list_view = self.query_one("#theme-list", ListView) + list_view.clear() + self._list_items = [] + if not self._filtered: + list_view.append(ListItem(Static("No matching themes", classes="empty"))) + return + selected_index = 0 + for index, name in enumerate(self._filtered): + label = Label(name) + if name == self._current_theme: + label.update(f"[b]* {name}[/b]") + selected_index = index + self._list_items.append(ListItem(label)) + list_view.append(self._list_items[-1]) + list_view.index = selected_index + + def _preview_theme(self, name: str) -> None: + self._current_theme = name + app = self.app + app.theme = name + set_active_theme(name) + if hasattr(app, "_apply_theme"): + app._apply_theme() + self._render_list() + + def _restore_original(self) -> None: + app = self.app + app.theme = self._original_theme + set_active_theme(self._original_theme) + if hasattr(app, "_apply_theme"): + app._apply_theme() + + def _filter(self, query: str) -> None: + query = query.strip().lower() + if not query: + self._filtered = list(self._themes) + else: + self._filtered = [name for name in self._themes if query in name.lower()] + self._render_list() + + def _select_highlighted(self) -> None: + list_view = self.query_one("#theme-list", ListView) + highlighted = list_view.index + if highlighted is not None and 0 <= highlighted < len(self._filtered): + self.dismiss(self._filtered[highlighted]) + elif self._filtered: + self.dismiss(self._filtered[0]) + + def on_input_changed(self, event: Input.Changed) -> None: + self._filter(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + self._select_highlighted() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + index = self._list_items.index(event.item) if event.item in self._list_items else None + if index is not None: + self.dismiss(self._filtered[index]) + + def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: + index = self._list_items.index(event.item) if event.item in self._list_items else None + if index is not None: + self._preview_theme(self._filtered[index]) + + def on_key(self, event: events.Key) -> None: + list_view = self.query_one("#theme-list", ListView) + if event.key == "down": + list_view.action_cursor_down() + event.stop() + event.prevent_default() + elif event.key == "up": + list_view.action_cursor_up() + event.stop() + event.prevent_default() + elif event.key in ("enter", "return"): + self._select_highlighted() + event.stop() + event.prevent_default() + elif event.key == "escape": + self._restore_original() + self.dismiss(None) + event.stop() + event.prevent_default() + + def action_dismiss_cancel(self) -> None: + self._restore_original() + self.dismiss(None) diff --git a/clients/terminal/tui/settings.py b/clients/terminal/tui/settings.py new file mode 100644 index 0000000..c6fdb52 --- /dev/null +++ b/clients/terminal/tui/settings.py @@ -0,0 +1,91 @@ +"""Persistent TUI settings stored in ~/.navi_code/tui.json.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from clients.terminal.config import settings + + +@dataclass +class TuiSettings: + """User-facing TUI configuration persisted across restarts.""" + + theme: str = "gnexus-dark" + mouse: bool = True + scroll_speed: int = 1 + diff_style: str = "unified" + keybinds: dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "TuiSettings": + field_defaults = {f.name: f.default for f in cls.__dataclass_fields__.values()} + merged = dict(field_defaults) + merged.update(data) + keybinds = merged.get("keybinds", {}) + if keybinds is None or not isinstance(keybinds, dict): + keybinds = {} + return cls( + theme=merged.get("theme", "gnexus-dark"), + mouse=merged.get("mouse", True), + scroll_speed=merged.get("scroll_speed", 1), + diff_style=merged.get("diff_style", "unified"), + keybinds=dict(keybinds), + ) + + @property + def settings_dir(self) -> Path: + return settings.state_dir + + @property + def settings_file(self) -> Path: + return self.settings_dir / "tui.json" + + def load(self) -> "TuiSettings": + if not self.settings_file.exists(): + return self._save_and_return() + try: + with self.settings_file.open("r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return self._save_and_return() + loaded = self.from_dict(data) + loaded._ensure_dir() + return loaded + + def save(self) -> None: + self._ensure_dir() + with self.settings_file.open("w", encoding="utf-8") as f: + json.dump(self.to_dict(), f, indent=2, ensure_ascii=False) + + def _ensure_dir(self) -> None: + self.settings_dir.mkdir(parents=True, exist_ok=True) + + def _save_and_return(self) -> "TuiSettings": + self.save() + return self + + +# Global lazy-loaded instance. +_tui_settings: TuiSettings | None = None + + +def get_tui_settings() -> TuiSettings: + """Return the loaded TUI settings, caching them for the process lifetime.""" + global _tui_settings + if _tui_settings is None: + _tui_settings = TuiSettings().load() + return _tui_settings + + +def reload_tui_settings() -> TuiSettings: + """Force reload from disk and return fresh settings.""" + global _tui_settings + _tui_settings = TuiSettings().load() + return _tui_settings diff --git a/clients/terminal/tui/shell_runner.py b/clients/terminal/tui/shell_runner.py new file mode 100644 index 0000000..0d0329f --- /dev/null +++ b/clients/terminal/tui/shell_runner.py @@ -0,0 +1,84 @@ +"""Run local shell commands from !input in the TUI.""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +DEFAULT_TIMEOUT = 30.0 +MAX_OUTPUT_LINES = 200 + + +@dataclass +class ShellResult: + """Result of running a shell command.""" + + command: str + returncode: int + stdout: str + stderr: str + truncated: bool = False + + def summary(self) -> str: + """Return a short, chat-friendly summary.""" + marker = "✓" if self.returncode == 0 else "✗" + lines = [f"{marker} $ {self.command}", ""] + if self.stdout: + lines.append(self.stdout) + if self.stderr: + if self.stdout: + lines.append("") + lines.append(f"--- stderr ---\n{self.stderr}") + lines.append("") + lines.append(f"exit code: {self.returncode}") + return "\n".join(lines) + + +def run_shell_command(raw: str, cwd: Path | str | None = None, timeout: float = DEFAULT_TIMEOUT) -> ShellResult: + """Run a shell command from user input (without the leading !). + + The command is passed to a real shell so pipes, redirections and globs work. + """ + command = raw[1:] if raw.startswith("!") else raw + command = command.strip() + if not command: + return ShellResult(command="", returncode=1, stdout="", stderr="empty command") + + work_dir = Path(cwd or Path.cwd()).expanduser().resolve() + try: + proc = subprocess.run( + command, + shell=True, + cwd=work_dir, + capture_output=True, + text=True, + timeout=timeout, + ) + stdout, stdout_truncated = _truncate(proc.stdout) + stderr, stderr_truncated = _truncate(proc.stderr) + return ShellResult( + command=command, + returncode=proc.returncode, + stdout=stdout, + stderr=stderr, + truncated=stdout_truncated or stderr_truncated, + ) + except subprocess.TimeoutExpired: + return ShellResult(command=command, returncode=124, stdout="", stderr=f"timed out after {timeout}s", truncated=False) + except Exception as exc: + return ShellResult(command=command, returncode=1, stdout="", stderr=str(exc), truncated=False) + + +def _truncate(text: str) -> tuple[str, bool]: + """Limit output to the last MAX_OUTPUT_LINES lines to avoid flooding the UI. + + Returns the possibly-truncated text and a flag indicating whether truncation + actually happened. + """ + lines = text.splitlines() + if len(lines) <= MAX_OUTPUT_LINES: + return text, False + truncated = lines[-MAX_OUTPUT_LINES:] + return f"... [{len(lines) - MAX_OUTPUT_LINES} lines truncated]\n" + "\n".join(truncated), True diff --git a/clients/terminal/tui/themes.py b/clients/terminal/tui/themes.py new file mode 100644 index 0000000..d823a45 --- /dev/null +++ b/clients/terminal/tui/themes.py @@ -0,0 +1,246 @@ +"""Color themes for the Navi Code TUI. + +The default dark theme uses the exact gnexus-ui-kit cyberpunk palette +(light Tokyo Night influence) found in webclient/vendor/gnexus-ui-kit. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar + +from textual.color import Color +from textual.theme import Theme as TextualTheme + + +@dataclass(frozen=True) +class Theme: + """A complete color theme for the TUI.""" + + name: str + background: Color + surface: Color + panel: Color + border: Color + primary: Color + primary_dark: Color + secondary: Color + accent: Color + text: Color + text_muted: Color + text_dim: Color + success: Color + warning: Color + error: Color + info: Color + user_bubble: Color + assistant_bubble: Color + thinking: Color + tool_border: Color + tool_success: Color + tool_error: Color + status_online: Color + status_offline: Color + prompt_border: Color + selection: Color + link: Color + + def rich_theme_styles(self) -> dict[str, str]: + """Return Rich console theme entries for markdown rendering.""" + return { + "markdown.h1": f"bold {self.accent.hex}", + "markdown.h2": f"bold {self.accent.hex}", + "markdown.h3": f"bold {self.primary.hex}", + "markdown.h4": f"bold {self.primary.hex}", + "markdown.h5": f"{self.primary.hex}", + "markdown.h6": f"{self.text_muted.hex}", + "markdown.code": f"{self.accent.hex} on {self.surface.hex}", + "markdown.link": f"{self.link.hex} underline", + "markdown.block_quote": f"{self.text_muted.hex} italic", + } + + def css(self) -> dict[str, str]: + """Return CSS variable dictionary for Textual.""" + return { + "tui-background": self.background.hex, + "tui-surface": self.surface.hex, + "tui-panel": self.panel.hex, + "tui-border": self.border.hex, + "tui-primary": self.primary.hex, + "tui-primary-dark": self.primary_dark.hex, + "tui-secondary": self.secondary.hex, + "tui-accent": self.accent.hex, + "tui-text": self.text.hex, + "tui-text-muted": self.text_muted.hex, + "tui-text-dim": self.text_dim.hex, + "tui-success": self.success.hex, + "tui-warning": self.warning.hex, + "tui-error": self.error.hex, + "tui-info": self.info.hex, + "tui-user-bubble": self.user_bubble.hex, + "tui-assistant-bubble": self.assistant_bubble.hex, + "tui-thinking": self.thinking.hex, + "tui-tool-border": self.tool_border.hex, + "tui-tool-success": self.tool_success.hex, + "tui-tool-error": self.tool_error.hex, + "tui-status-online": self.status_online.hex, + "tui-status-offline": self.status_offline.hex, + "tui-prompt-border": self.prompt_border.hex, + "tui-selection": self.selection.hex, + "tui-link": self.link.hex, + } + + def to_textual_theme(self) -> TextualTheme: + """Return a registered Textual theme that installs our CSS variables.""" + return TextualTheme( + name=self.name, + primary=self.primary.hex, + secondary=self.secondary.hex, + warning=self.warning.hex, + error=self.error.hex, + success=self.success.hex, + accent=self.accent.hex, + foreground=self.text.hex, + background=self.background.hex, + surface=self.surface.hex, + panel=self.panel.hex, + variables=self.css(), + ) + + def to_css_string(self) -> str: + """Generate a Textual CSS string with all theme variables.""" + lines = ["/* Auto-generated TUI theme */"] + for key, value in self.css().items(): + lines.append(f"$ {key}: {value};") + return "\n".join(lines) + + +# ── gnexus-ui-kit palette (webclient/vendor/gnexus-ui-kit/src/scss/_palette-colors.scss) ─ +# neutrals +# $color-black: #16161E +# $color-dark: #1F2335 +# $color-grey: #414868 +# prime neons +# $color-cyan: #7DCFFF +# $color-magenta: #FF00CC +# $color-hot-pink: #FF1492 +# $color-electric-blue:#7AA2F7 +# $color-orange: #FF9E64 +# secondary accents +# $color-purple: #BB9AF7 +# $color-indigo: #565F89 +# $color-teal: #73DACA +# highlights +# $color-neon-yellow: #E0AF68 +# $color-neon-green: #9ECE6A +# text tones +# $color-text-light: #C0CAF5 +# $color-text-medium: #A9B1D6 +# $color-text-dark: #787C99 +# UI state +# $color-primary: $color-text-light +# $color-secondary: $color-electric-blue +# $color-accent: $color-orange +# $color-success: $color-neon-green +# $color-warning: $color-neon-yellow +# $color-error: #F7768E +# $color-info: $color-purple + +GNEXUS_DARK = Theme( + name="gnexus-dark", + background=Color.parse("#16161E"), + surface=Color.parse("#1F2335"), + panel=Color.parse("#24283B"), # slightly lighter than surface for panels + border=Color.parse("#414868"), + primary=Color.parse("#C0CAF5"), # text-light, primary UI color + primary_dark=Color.parse("#A9B1D6"), + secondary=Color.parse("#7AA2F7"), # electric-blue + accent=Color.parse("#FF9E64"), # orange + text=Color.parse("#C0CAF5"), + text_muted=Color.parse("#A9B1D6"), + text_dim=Color.parse("#787C99"), + success=Color.parse("#9ECE6A"), # neon-green + warning=Color.parse("#E0AF68"), # neon-yellow + error=Color.parse("#F7768E"), + info=Color.parse("#BB9AF7"), # purple + user_bubble=Color.parse("#565F89"), # indigo + assistant_bubble=Color.parse("#1F2335"), + thinking=Color.parse("#787C99"), + tool_border=Color.parse("#FF9E64"), + tool_success=Color.parse("#9ECE6A"), + tool_error=Color.parse("#F7768E"), + status_online=Color.parse("#9ECE6A"), + status_offline=Color.parse("#F7768E"), + prompt_border=Color.parse("#7DCFFF"), # cyan highlight for input + selection=Color.parse("#FF00CC"), # magenta — active selection / cursor line + link=Color.parse("#73DACA"), # teal — URLs and references +) + + +GNEXUS_LIGHT = Theme( + name="gnexus-light", + background=Color.parse("#F5F6FA"), + surface=Color.parse("#FFFFFF"), + panel=Color.parse("#ECEEF5"), + border=Color.parse("#A9B1D6"), + primary=Color.parse("#1F2335"), + primary_dark=Color.parse("#16161E"), + secondary=Color.parse("#2B3A67"), + accent=Color.parse("#FF6C00"), + text=Color.parse("#16161E"), + text_muted=Color.parse("#414868"), + text_dim=Color.parse("#787C99"), + success=Color.parse("#3F8F2A"), + warning=Color.parse("#B45309"), + error=Color.parse("#C41E3A"), + info=Color.parse("#7C3AED"), + user_bubble=Color.parse("#DBEAFE"), + assistant_bubble=Color.parse("#FFFFFF"), + thinking=Color.parse("#787C99"), + tool_border=Color.parse("#B45309"), + tool_success=Color.parse("#3F8F2A"), + tool_error=Color.parse("#C41E3A"), + status_online=Color.parse("#3F8F2A"), + status_offline=Color.parse("#C41E3A"), + prompt_border=Color.parse("#7AA2F7"), + selection=Color.parse("#FF1492"), # hot-pink — active selection / cursor line + link=Color.parse("#0D9488"), # darker teal for readability on light background +) + + +_active_theme_name: str = "gnexus-dark" + + +def set_active_theme(name: str) -> None: + """Mark the named theme as the currently active one.""" + global _active_theme_name + _active_theme_name = name + + +def get_active_theme() -> Theme: + """Return the currently active theme, falling back to the dark default.""" + return ThemeRegistry.get(_active_theme_name) + + +class ThemeRegistry: + """Store and resolve themes by name.""" + + _themes: ClassVar[dict[str, Theme]] = {} + + @classmethod + def register(cls, theme: Theme) -> None: + cls._themes[theme.name] = theme + + @classmethod + def get(cls, name: str) -> Theme: + if name not in cls._themes: + return cls._themes.get("gnexus-dark", GNEXUS_DARK) + return cls._themes[name] + + @classmethod + def all(cls) -> list[str]: + return list(cls._themes.keys()) + + +ThemeRegistry.register(GNEXUS_DARK) +ThemeRegistry.register(GNEXUS_LIGHT) diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py new file mode 100644 index 0000000..c19f7fa --- /dev/null +++ b/clients/terminal/tui/tui_app.py @@ -0,0 +1,419 @@ +"""Textual TUI application for Navi Code.""" + +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Footer, Header + +from clients.terminal import api +from clients.terminal.config import settings +from clients.terminal.state import StateManager +from clients.terminal.tui.context import TuiContext +from clients.terminal.tui.events import ( + ConnectionStatusChanged, + PermissionRequest, + SessionInfo, + SessionListUpdated, + SessionSelected, + UserSubmitted, + WsEvent, +) +from clients.terminal.tui.file_refs import FileRefResolver +from clients.terminal.tui.permissions import PermissionEngine, PermissionRule +from clients.terminal.tui.shell_runner import run_shell_command +from clients.terminal.tui.settings import get_tui_settings +from clients.terminal.tui.themes import ThemeRegistry, set_active_theme +from clients.terminal.tui.commands.registry import get_registry +from clients.terminal.tui.screens.command_palette import CommandPaletteScreen +from clients.terminal.tui.screens.permission_dialog import PermissionDialogScreen +from clients.terminal.tui.widgets import ChatPanel, InputBox, SessionsPanel, StatusPanel +from clients.terminal.tui.ws_bridge import WsBridge + + +class NaviCodeTui(App): + """OpenCode-inspired terminal UI for Navi.""" + + BINDINGS = [ + ("ctrl+p", "command_palette", "Palette"), + ("ctrl+x q", "quit", "Quit"), + ("ctrl+x n", "new_session", "New"), + ("ctrl+x l", "list_sessions", "Sessions"), + ("ctrl+x c", "compact", "Compact"), + ("ctrl+x t", "toggle_thinking", "Thinking"), + ] + + def __init__( + self, + session_id: str | None = None, + profile_id: str | None = None, + new_session: bool = False, + theme_name: str | None = None, + ) -> None: + self._tui_settings = get_tui_settings() + self._theme_name = theme_name or self._tui_settings.theme + self._mouse_enabled = self._tui_settings.mouse + super().__init__() + self._register_textual_themes() + self.theme = self._theme_name + set_active_theme(self._theme_name) + self._chat_panel = ChatPanel() + self._status_panel = StatusPanel() + self._sessions_panel = SessionsPanel() + self._input_box = InputBox() + self._state = StateManager() + self._ctx = TuiContext( + state=self._state, + chat_panel=self._chat_panel, + status_panel=self._status_panel, + sessions_panel=self._sessions_panel, + settings=self._tui_settings, + ) + self._bridge: WsBridge | None = None + self._permission_engine = PermissionEngine() + self._pending_permission: PermissionRequest | None = None + self._requested_session_id = session_id + self._requested_profile_id = profile_id + self._force_new_session = new_session + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + with Horizontal(): + yield self._chat_panel + with Vertical(): + yield self._status_panel + yield self._sessions_panel + yield self._input_box + yield Footer() + + def on_mount(self) -> None: + self._apply_theme() + self.run_worker(self._startup) + + def _register_textual_themes(self) -> None: + """Register every Navi theme as a Textual theme so $tui-* variables resolve.""" + for name in ThemeRegistry.all(): + self.register_theme(ThemeRegistry.get(name).to_textual_theme()) + + def _apply_theme(self) -> None: + """Activate the selected theme and update global active theme state.""" + set_active_theme(self._theme_name) + self.theme = self._theme_name + if self._status_panel: + self._status_panel.set_theme(self._theme_name) + + async def _startup(self) -> None: + session_id = await self._resolve_session( + self._requested_session_id, + self._requested_profile_id, + self._force_new_session, + ) + if session_id: + await self._attach_session(session_id) + self._input_box.focus_input() + + async def _resolve_session( + self, + session_id: str | None, + profile_id: str | None, + force_new: bool, + ) -> str | None: + if session_id and not force_new: + try: + session = api.get_session(session_id) + return session["session_id"] + except Exception: + pass + + if not force_new: + saved = self._state.get_session_id() + if saved: + try: + session = api.get_session(saved) + return session["session_id"] + except Exception: + self._state.clear_session_id() + + profile = profile_id or settings.default_profile_id + try: + session = api.create_session(profile) + except Exception as exc: + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to create session: {exc}"} + ) + return None + self._state.set_session_id(session["session_id"]) + return session["session_id"] + + async def _attach_session(self, session_id: str) -> None: + self._ctx.session_id = session_id + try: + session = api.get_session(session_id) + self._ctx.profile_id = session.get("profile_id") or settings.default_profile_id + except Exception: + self._ctx.profile_id = settings.default_profile_id + + self._status_panel.set_session(session_id) + self._status_panel.set_profile(self._ctx.profile_id) + self._status_panel.set_model( + settings.ollama_default_model + if hasattr(settings, "ollama_default_model") + else "unknown" + ) + self._status_panel.set_backend(settings.base_url) + self._status_panel.set_theme(self._theme_name) + + if self._bridge: + await self._bridge.stop() + self._bridge = WsBridge(self, session_id) + await self._bridge.start() + self._ctx.ws_client = self._bridge.client + self._chat_panel.handle_ws_event( + {"type": "status", "content": f"Connected to {session_id[:8]}"} + ) + self.run_worker(self._refresh_sessions(session_id)) + + async def _refresh_sessions(self, current_session_id: str | None = None) -> None: + current = current_session_id or self._ctx.session_id + try: + raw_sessions = api.list_sessions() + except Exception: + return + sessions = [self._session_info_from_api(item) for item in raw_sessions] + self.post_message(SessionListUpdated(sessions, current)) + + @staticmethod + def _session_info_from_api(item: dict) -> SessionInfo: + sid = item.get("session_id", "") + return SessionInfo( + id=sid, + profile_id=item.get("profile_id", "unknown"), + title=item.get("name", "") or item.get("preview", ""), + created_at=item.get("created_at", ""), + ) + + def on_session_selected(self, event: SessionSelected) -> None: + self.run_worker(self._switch_session(event.session_id)) + + async def _switch_session(self, session_id: str) -> None: + self._state.set_session_id(session_id) + await self._attach_session(session_id) + self._chat_panel._model.items.clear() + self._chat_panel._refresh() + self._chat_panel.handle_ws_event( + {"type": "status", "content": f"Switched to {session_id[:8]}"} + ) + + def on_session_list_updated(self, event: SessionListUpdated) -> None: + self._sessions_panel.on_session_list_updated(event) + + def on_user_submitted(self, event: UserSubmitted) -> None: + text = event.text + if text.startswith("/"): + self._run_command(text) + return + + if text.startswith("!"): + self._run_shell_command(text) + return + + resolved = FileRefResolver().resolve(text) + self._chat_panel.add_user_message(resolved.prompt) + if resolved.attachments: + names = ", ".join( + a.display_path + (" (truncated)" if a.truncated else "") + for a in resolved.attachments + ) + self._chat_panel.handle_ws_event({"type": "status", "content": f"Attached: {names}"}) + for err in resolved.errors: + self._chat_panel.handle_ws_event({"type": "error", "message": err}) + + if self._bridge and self._bridge.connected: + self._bridge.client.enqueue(resolved.to_message()) + else: + self._chat_panel.handle_ws_event( + {"type": "error", "message": "Not connected to a session"} + ) + + def _run_shell_command(self, text: str) -> None: + command = text[1:].strip() + args = {"action": "run", "command": command} + if self._permission_engine.is_always_deny("shell", args): + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Shell command denied by policy: {command}"} + ) + return + if self._permission_engine.check("shell", args) is None: + self.run_worker(self._shell_worker(text)) + return + self._confirm_shell_command(text) + + def _confirm_shell_command(self, text: str) -> None: + command = text[1:].strip() + + def on_decision(choice: str | None) -> None: + if choice == "allow_once": + self.run_worker(self._shell_worker(text)) + elif choice == "allow_always": + self._permission_engine.set_always_allow( + "shell", {"action": "run", "command": command} + ) + self.run_worker(self._shell_worker(text)) + elif choice == "deny_once": + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Shell command cancelled: {command}"} + ) + elif choice == "deny_always": + self._permission_engine.set_always_deny( + "shell", {"action": "run", "command": command} + ) + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Shell command cancelled: {command}"} + ) + else: + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Shell command cancelled: {command}"} + ) + + self.push_screen( + PermissionDialogScreen( + tool="shell", + action="run", + target=command, + details="Local shell command", + ), + callback=on_decision, + ) + + async def _shell_worker(self, text: str) -> None: + result = run_shell_command(text) + self._chat_panel.handle_ws_event({"type": "status", "content": result.summary()}) + + def _run_command(self, text: str) -> None: + parts = text[1:].split(None, 1) + name = parts[0].lower() + args = parts[1] if len(parts) > 1 else "" + registry = get_registry() + cmd = registry.get(name) + if cmd is None: + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Unknown command: /{name}"} + ) + return + self.run_worker(self._command_worker(cmd, args)) + + async def _command_worker(self, cmd, args: str) -> None: + await cmd.execute(self._ctx, args) + + def on_ws_event(self, event: WsEvent) -> None: + payload = event.payload + if payload.get("type") == "tool_started": + tool = payload.get("tool", "") + args = payload.get("args") or {} + if self._permission_engine.is_always_deny(tool, args): + self._deny_tool(tool, args) + return + rule = self._permission_engine.check(tool, args) + if rule is not None: + self._show_permission_dialog(payload, rule) + return + self._chat_panel.handle_ws_event(payload) + + def _show_permission_dialog(self, payload: dict, rule) -> None: + tool = payload.get("tool", "?") + args = payload.get("args") or {} + action = args.get("action", "") + target = self._permission_engine.extract_target(tool, args) + + def on_decision(choice: str | None) -> None: + if choice == "allow_once": + self._chat_panel.handle_ws_event(payload) + elif choice == "allow_always": + self._permission_engine.set_always_allow(tool, args) + self._chat_panel.handle_ws_event(payload) + elif choice == "deny_once": + self._deny_tool(tool, args) + elif choice == "deny_always": + self._permission_engine.set_always_deny(tool, args) + self._deny_tool(tool, args) + else: + # Dismissed / escape — treat as deny once. + self._deny_tool(tool, args) + + self.push_screen( + PermissionDialogScreen( + tool=tool, + action=action, + target=target, + details=rule.message, + ), + callback=on_decision, + ) + + def _deny_tool(self, tool: str, args: dict) -> None: + # Render a synthetic tool result so the user sees the denial, then stop + # the session because the backend is already executing the tool and the + # TUI cannot inject a result into the running tool-call loop. + self._chat_panel.handle_ws_event( + { + "type": "tool_call", + "tool": tool, + "args": args, + "success": False, + "result": "permission denied by user", + } + ) + if self._ctx.session_id: + self.run_worker(self._stop_session_worker(self._ctx.session_id)) + + async def _stop_session_worker(self, session_id: str) -> None: + try: + api.stop_session(session_id) + except Exception as exc: + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to stop session: {exc}"} + ) + if self._bridge: + await self._bridge.stop() + self._status_panel.set_connection(False, "permission denied") + + def on_connection_status_changed(self, event: ConnectionStatusChanged) -> None: + self._status_panel.set_connection(event.connected, event.detail) + + def on_permission_request(self, event: PermissionRequest) -> None: + """Manual PermissionRequest event (fallback from components).""" + tool = event.tool + args = event.args + payload = {"type": "tool_started", "tool": tool, "args": args} + rule = PermissionRule(tool=tool, message=event.message) + self._show_permission_dialog(payload, rule) + + def action_command_palette(self) -> None: + registry = get_registry() + + def on_select(cmd) -> None: + if cmd is not None: + self.run_worker(self._command_worker(cmd, "")) + + self.push_screen(CommandPaletteScreen(registry.all()), callback=on_select) + + def action_new_session(self) -> None: + self._run_command("/new") + + def action_list_sessions(self) -> None: + self._run_command("/sessions") + + def action_compact(self) -> None: + self._run_command("/compact") + + def action_toggle_thinking(self) -> None: + self._run_command("/thinking") + + async def action_quit(self) -> None: + if self._bridge: + await self._bridge.stop() + self.exit() + + async def on_unmount(self) -> None: + if self._bridge: + await self._bridge.stop() diff --git a/clients/terminal/tui/widgets/__init__.py b/clients/terminal/tui/widgets/__init__.py new file mode 100644 index 0000000..6b9fc96 --- /dev/null +++ b/clients/terminal/tui/widgets/__init__.py @@ -0,0 +1,10 @@ +"""TUI widgets.""" + +from __future__ import annotations + +from .chat_panel import ChatPanel +from .input_box import InputBox +from .sessions_panel import SessionsPanel +from .status_panel import StatusPanel + +__all__ = ["ChatPanel", "InputBox", "SessionsPanel", "StatusPanel"] diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py new file mode 100644 index 0000000..78c2776 --- /dev/null +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -0,0 +1,70 @@ +"""Chat panel widget for the TUI.""" + +from __future__ import annotations + +from rich.console import Group +from textual.app import ComposeResult +from textual.containers import ScrollableContainer +from textual.widgets import Static + +from clients.terminal.tui.chat_model import ChatModel +from clients.terminal.tui.renderers import default_registry + + +class ChatPanel(ScrollableContainer): + """Scrollable conversation panel.""" + + DEFAULT_CSS = """ + ChatPanel { + border: solid $tui-border; + background: $tui-surface; + color: $tui-text; + padding: 0 1; + height: 1fr; + width: 2fr; + } + ChatPanel Static { + color: $tui-text; + } + """ + + def __init__(self) -> None: + super().__init__() + self._model = ChatModel() + self._registry = default_registry() + self._items_container = Static("") + + def compose(self) -> ComposeResult: + yield self._items_container + + def on_mount(self) -> None: + self._items_container.styles.height = "auto" + + def add_user_message(self, text: str) -> None: + self._model.add_user_message(text) + self._refresh() + + def handle_ws_event(self, msg: dict) -> None: + self._model.handle_ws_event(msg) + self._refresh() + + def _refresh(self) -> None: + renderables = [] + for item in self._model.items: + if item.kind == "user_message": + renderables.append(self._registry.render({"type": "user_message", "content": item.content})) + elif item.kind == "assistant_message": + renderables.append(self._registry.render({"type": "assistant_message", "content": item.content})) + elif item.kind == "thinking_block": + renderables.append(self._registry.render({"type": "thinking_block", "content": item.content})) + elif item.kind == "tool_started": + renderables.append(self._registry.render({"type": "tool_started", **item.meta})) + elif item.kind == "tool_call": + renderables.append(self._registry.render({"type": "tool_call", **item.meta})) + elif item.kind == "error": + renderables.append(self._registry.render({"type": "error", "message": item.content})) + else: + renderables.append(self._registry.render({"type": "plain", "content": item.content})) + + self._items_container.update(Group(*renderables)) + self.scroll_end(animate=False) diff --git a/clients/terminal/tui/widgets/input_box.py b/clients/terminal/tui/widgets/input_box.py new file mode 100644 index 0000000..b2b54e5 --- /dev/null +++ b/clients/terminal/tui/widgets/input_box.py @@ -0,0 +1,58 @@ +"""Input box widget for the TUI.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.widgets import Input, Static + +from clients.terminal.tui.events import UserSubmitted + + +class InputBox(Horizontal): + """Bottom prompt frame with input field.""" + + DEFAULT_CSS = """ + InputBox { + height: auto; + min-height: 3; + border: heavy $tui-prompt-border; + background: $tui-surface; + color: $tui-text; + padding: 0 1; + } + InputBox Input { + height: auto; + border: none; + background: $tui-surface; + color: $tui-text; + } + InputBox .prompt-char { + color: $tui-prompt-border; + text-style: bold; + } + """ + + def __init__(self) -> None: + super().__init__() + self._prompt = Static("┃", classes="prompt-char") + self._input = Input(placeholder="Ask anything...", classes="input-field") + + def compose(self) -> ComposeResult: + yield self._prompt + yield self._input + + def on_mount(self) -> None: + self._input.focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + text = event.value.strip() + if text: + self.post_message(UserSubmitted(text)) + self._input.value = "" + + def focus_input(self) -> None: + self._input.focus() + + def set_prompt_char(self, char: str) -> None: + self._prompt.update(char) diff --git a/clients/terminal/tui/widgets/sessions_panel.py b/clients/terminal/tui/widgets/sessions_panel.py new file mode 100644 index 0000000..73819e8 --- /dev/null +++ b/clients/terminal/tui/widgets/sessions_panel.py @@ -0,0 +1,126 @@ +"""Sessions panel widget for the TUI.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import DataTable, Static +from textual.widgets.data_table import RowKey + +from clients.terminal.tui.events import SessionListUpdated, SessionSelected, SessionInfo + + +class SessionsPanel(Vertical): + """Right-side panel showing server sessions and allowing quick switching.""" + + DEFAULT_CSS = """ + SessionsPanel { + border: solid $tui-panel; + background: $tui-panel; + color: $tui-text-muted; + padding: 0; + height: 1fr; + width: 1fr; + } + SessionsPanel .title { + text-style: bold; + color: $tui-primary; + padding: 1 1 0 1; + height: auto; + } + SessionsPanel DataTable { + height: 1fr; + border: none; + background: $tui-panel; + color: $tui-text-muted; + } + SessionsPanel DataTable > .datatable--header { + color: $tui-text-dim; + text-style: bold; + background: $tui-surface; + } + SessionsPanel DataTable > .datatable--row { + background: $tui-panel; + } + SessionsPanel DataTable > .datatable--row-sessions-panel-cursor { + background: $tui-selection; + color: $tui-background; + } + SessionsPanel DataTable > .datatable--row-sessions-panel-cursor .datatable--cursor { + color: $tui-background; + } + SessionsPanel .empty { + color: $tui-text-dim; + text-align: center; + padding: 1; + } + """ + + def __init__(self) -> None: + super().__init__() + self._title = Static("Sessions", classes="title") + self._table: DataTable | None = None + self._sessions: list[SessionInfo] = [] + self._current_id: str | None = None + + def compose(self) -> ComposeResult: + yield self._title + yield DataTable(id="sessions-table") + + def on_mount(self) -> None: + self._table = self.query_one("#sessions-table", DataTable) + self._table.cursor_type = "row" + self._table.show_header = True + self._table.zebra_stripes = True + self._table.add_columns("", "ID", "Profile", "Preview") + self._populate_table() + + def on_session_list_updated(self, event: SessionListUpdated) -> None: + self._sessions = event.sessions + self._current_id = event.current_id + self._populate_table() + + def _populate_table(self) -> None: + if self._table is None: + return + self._table.clear() + if not self._sessions: + self._table.add_row("", "—", "", "No sessions") + return + + for session in self._sessions: + marker = "●" if session.id == self._current_id else "" + short_id = "-".join(session.id.split("-")[:2]) + profile = self._truncate(session.profile_id, 12) + preview = self._truncate(session.title, 24) + self._table.add_row(marker, short_id, profile, preview, key=session.id) + + if self._current_id: + try: + self._table.move_cursor(row=RowKey(self._current_id)) + except Exception: + pass + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + session_id = str(event.row_key.value) if event.row_key else "" + if session_id: + self.post_message(SessionSelected(session_id)) + + def on_key(self, event) -> None: + if self._table is None: + return + if hasattr(event, "key") and event.key in ("enter", "return"): + cursor = self._table.cursor_coordinate + if cursor: + row_key = self._table.get_row_at(cursor.row) + session_id = str(row_key) if row_key else "" + if session_id: + self.post_message(SessionSelected(session_id)) + event.stop() + event.prevent_default() + + @staticmethod + def _truncate(text: str, max_len: int) -> str: + if len(text) <= max_len: + return text + return text[: max_len - 1] + "…" diff --git a/clients/terminal/tui/widgets/status_panel.py b/clients/terminal/tui/widgets/status_panel.py new file mode 100644 index 0000000..5a2f438 --- /dev/null +++ b/clients/terminal/tui/widgets/status_panel.py @@ -0,0 +1,104 @@ +"""Status panel widget for the TUI.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import Static +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + + +class StatusPanel(Vertical): + """Right-side panel showing session/profile/model/connection info.""" + + DEFAULT_CSS = """ + StatusPanel { + border: solid $tui-border; + background: $tui-panel; + color: $tui-text-muted; + padding: 1; + height: 1fr; + width: 1fr; + } + StatusPanel .title { + text-style: bold; + color: $tui-primary; + } + StatusPanel .connection { + text-style: bold; + } + StatusPanel .hint { + color: $tui-text-dim; + } + """ + + def __init__(self) -> None: + super().__init__() + self._profile = Static("Profile: -") + self._session = Static("Session: -") + self._model = Static("Model: -") + self._connection = Static("Connection: offline", classes="connection") + self._backend = Static("Backend: -") + self._theme = Static("Theme: -") + self._tokens = Static("Tokens: -") + self._iterations = Static("Iter: -") + self._hint = Static("Ctrl+P palette | /help commands") + + def compose(self) -> ComposeResult: + yield Static("[b]Navi Code[/b]", classes="title") + yield self._profile + yield self._session + yield self._model + yield self._connection + yield self._backend + yield self._theme + yield self._tokens + yield self._iterations + yield Static("", classes="spacer") + yield self._hint + + def set_profile(self, profile_id: str) -> None: + self._profile.update(f"Profile: {profile_id}") + + def set_session(self, session_id: str) -> None: + short = session_id[:8] if len(session_id) > 8 else session_id + self._session.update(f"Session: {short}") + + def set_model(self, model: str) -> None: + self._model.update(f"Model: {model}") + + def set_connection(self, connected: bool, detail: str = "") -> None: + theme = get_active_theme() + if connected: + self._connection.update( + Text(f"Connection: online {detail}", style=theme.status_online.hex) + ) + else: + self._connection.update( + Text(f"Connection: offline {detail}", style=theme.status_offline.hex) + ) + + def set_backend(self, url: str) -> None: + short = url + if len(short) > 30: + short = short[:27] + "..." + self._backend.update(f"Backend: {short}") + + def set_theme(self, theme_name: str) -> None: + self._theme.update(f"Theme: {theme_name}") + + def set_tokens(self, used: int | None, total: int | None = None) -> None: + if used is None: + self._tokens.update("Tokens: -") + return + suffix = f" / {total}" if total is not None else "" + self._tokens.update(f"Tokens: {used}{suffix}") + + def set_iterations(self, current: int | None, budget: int | None = None) -> None: + if current is None: + self._iterations.update("Iter: -") + return + suffix = f" / {budget}" if budget is not None else "" + self._iterations.update(f"Iter: {current}{suffix}") diff --git a/clients/terminal/tui/ws_bridge.py b/clients/terminal/tui/ws_bridge.py new file mode 100644 index 0000000..8e56c3b --- /dev/null +++ b/clients/terminal/tui/ws_bridge.py @@ -0,0 +1,73 @@ +"""Bridge between NaviWebSocketClient and Textual event loop.""" + +from __future__ import annotations + +import asyncio +import json + +from textual.app import App + +from clients.terminal.tui.events import ConnectionStatusChanged, WsEvent +from clients.terminal.ws_client import NaviWebSocketClient + + +class WsBridge: + """Wraps NaviWebSocketClient and forwards events to a Textual App.""" + + def __init__(self, app: App, session_id: str) -> None: + self.app = app + self.session_id = session_id + self._client = NaviWebSocketClient(session_id) + self._receive_task: asyncio.Task | None = None + self._connected = False + + @property + def client(self) -> NaviWebSocketClient: + return self._client + + @property + def connected(self) -> bool: + return self._connected + + async def start(self) -> None: + try: + await self._client.connect() + self._connected = True + self._notify(True, "") + self._receive_task = asyncio.create_task(self._receive_loop()) + except Exception as exc: + self._connected = False + self._notify(False, str(exc)) + + async def stop(self) -> None: + if self._receive_task: + self._receive_task.cancel() + try: + await self._receive_task + except asyncio.CancelledError: + pass + self._receive_task = None + await self._client.close() + self._connected = False + self._notify(False, "") + + async def _receive_loop(self) -> None: + try: + ws = self._client._ws + if ws is None: + return + async for raw in ws: + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + self._post(WsEvent(msg)) + except Exception: + self._connected = False + self._notify(False, "disconnected") + + def _post(self, message) -> None: + self.app.post_message(message) + + def _notify(self, connected: bool, detail: str) -> None: + self._post(ConnectionStatusChanged(connected, detail)) diff --git a/clients/terminal/ws_client.py b/clients/terminal/ws_client.py new file mode 100644 index 0000000..6630e3c --- /dev/null +++ b/clients/terminal/ws_client.py @@ -0,0 +1,106 @@ +"""WebSocket client for streaming Navi responses.""" + +from __future__ import annotations + +import asyncio +import json + +import websockets + +from clients.terminal.config import settings +from clients.terminal.render import Renderer + + +class NaviWebSocketClient: + """Connect to /ws/sessions/ and render events.""" + + def __init__( + self, + session_id: str, + renderer: Renderer | None = None, + ) -> None: + self.session_id = session_id + self.renderer = renderer or Renderer( + show_thinking=settings.show_thinking, + show_events=settings.show_events, + ) + self.url = settings.websocket_url(session_id) + self._ws: websockets.ClientConnection | None = None + self._stop_event = asyncio.Event() + self._input_queue: asyncio.Queue[str | None] = asyncio.Queue() + + async def connect(self) -> None: + self._ws = await websockets.connect(self.url) + + async def close(self) -> None: + if self._ws: + await self._ws.close() + self._ws = None + + async def send(self, content: str) -> None: + if not self._ws: + raise RuntimeError("WebSocket is not connected") + await self._ws.send(json.dumps({"type": "message", "content": content})) + + async def receive_loop(self) -> None: + if not self._ws: + raise RuntimeError("WebSocket is not connected") + try: + async for raw in self._ws: + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + self.renderer.render(msg) + if msg.get("type") in ("stream_end", "error"): + self._stop_event.set() + except websockets.exceptions.ConnectionClosed: + pass + + async def input_loop(self) -> None: + while True: + content = await self._input_queue.get() + if content is None: + break + await self.send(content) + + def enqueue(self, content: str) -> None: + self._input_queue.put_nowait(content) + + def stop_input(self) -> None: + self._input_queue.put_nowait(None) + + async def wait_for_stream_end(self, timeout: float = 600.0) -> None: + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=timeout) + except asyncio.TimeoutError: + pass + + def reset_stop(self) -> None: + self._stop_event.clear() + + async def run_one_shot(self, content: str) -> None: + await self.connect() + try: + receive_task = asyncio.create_task(self.receive_loop()) + await self.send(content) + await self.wait_for_stream_end() + self.stop_input() + receive_task.cancel() + try: + await receive_task + except asyncio.CancelledError: + pass + finally: + await self.close() + + async def run_interactive(self) -> None: + await self.connect() + try: + receive_task = asyncio.create_task(self.receive_loop()) + input_task = asyncio.create_task(self.input_loop()) + await asyncio.gather(receive_task, input_task) + except websockets.exceptions.ConnectionClosed: + pass + finally: + await self.close() diff --git a/docs/config.md b/docs/config.md index f8071d4..cb1ad43 100644 --- a/docs/config.md +++ b/docs/config.md @@ -162,6 +162,7 @@ |---|---|---|---| | `NAVI_PERSONA` | str | `""` | Global personality prompt prepended to every profile's system prompt | | `NAVI_PERSONA_FILE` | str | `""` | Path to a `.txt` file containing the persona (preferred over inline `NAVI_PERSONA`) | +| `NAVI_DEFAULT_PROFILE_ID` | str | `""` | Profile used when `POST /sessions` is called without `profile_id`. Empty means the client must supply a profile. | **Recommended:** use `NAVI_PERSONA_FILE=persona.txt` rather than inlining the persona in `.env`, because multi-line values don't parse reliably in `.env` files. diff --git a/docs/navi_code.md b/docs/navi_code.md new file mode 100644 index 0000000..3b2b356 --- /dev/null +++ b/docs/navi_code.md @@ -0,0 +1,83 @@ +# Navi Code — локальный терминальный режим + +Navi Code — это вариант Navi для локального запуска через терминал. Без авторизации, с выделенным профилем `navi_code`, ориентированным на работу с кодом, shell и файловой системой. + +## Что входит + +- Профиль `navi_code` (терминальный кодинг-ассистент). +- Механизм дефолтного профиля через `NAVI_DEFAULT_PROFILE_ID`. +- Персона `persona_navi_code.txt`. +- Готовый `.env.navi_code.example`. +- CLI-клиент `navi-code`. + +## Что НЕ входит + +- Docker-упаковка (отложено). +- Web UI для изображений / публикации контента (`content_publish`, `share_file` отключены в профиле). +- Авторизация (`NAVI_AUTH_ENABLED=false`). + +## Быстрый старт + +1. Убедитесь, что запущены PostgreSQL с `pgvector` и Ollama с нужной моделью. +2. Скопируйте пример конфигурации: + ```bash + cp .env.navi_code.example .env + ``` +3. Установите / обновите зависимости: + ```bash + pip install -e ".[dev]" + ``` +4. Запустите сервер: + ```bash + .venv/bin/uvicorn navi.main:app --reload --reload-dir navi --port 8000 + ``` +5. В другом терминале запустите клиент: + ```bash + navi-code + # или + python -m clients.terminal + ``` + +## Конфигурация `.env` + +Ключевые переменные для Navi Code: + +```dotenv +NAVI_AUTH_ENABLED=false +NAVI_DEFAULT_PROFILE_ID=navi_code +NAVI_PERSONA_FILE=persona_navi_code.txt + +OLLAMA_HOST=http://localhost:11434 +OLLAMA_DEFAULT_MODEL=gemma4:26b-a4b-it-q4_K_M +OLLAMA_NUM_CTX=8192 +OLLAMA_THINK=true + +DATABASE_URL=postgresql://navi:navipass@localhost:5432/navidb + +FS_ALLOWED_PATHS=* +TERMINAL_ALLOWED_COMMANDS=* +``` + +> **Важно:** `FS_ALLOWED_PATHS=*` и `TERMINAL_ALLOWED_COMMANDS=*` дают Нави полный доступ к файловой системе и shell. Используйте только на доверенной локальной машине. + +## Профиль `navi_code` + +- Расположение: `navi/profiles/navi_code/`. +- База: `developer`, адаптирован под терминал. +- Включённые инструменты: + - Native: `terminal`, `filesystem`, `code_exec`, `image_view`, `spawn_agent`, `todo`, `scratchpad`, `reflect`, `list_tools`, `tool_manual`, `reload_tools`. + - MCP `navi-web`: `mcp__navi-web__web_search`, `mcp__navi-web__web_view`, `mcp__navi-web__http_request`. +- Отключённые инструменты: `share_file`, `content_publish`, `ssh_exec`, `gmail`. +- `planning_phase2_enabled: false` — уменьшает latency. + +## Безопасность + +- Нави всегда должна спрашивать подтверждение перед разрушительными операциями (`rm`, перезапись файлов, форматирование). +- Не выставляйте сервер Navi Code в интернет без авторизации. +- `TERMINAL_ALLOWED_COMMANDS=*` — это полный shell-доступ; убедитесь, что сервер запущен от пользователя с ограниченными правами, если экспериментируете. + +## См. также + +- [`docs/navi_code_cli.md`](navi_code_cli.md) — справка по CLI-клиенту. +- [`docs/profiles.md`](profiles.md) — устройство профилей. +- [`docs/config.md`](config.md) — все переменные окружения. diff --git a/docs/navi_code_cli.md b/docs/navi_code_cli.md new file mode 100644 index 0000000..2aec702 --- /dev/null +++ b/docs/navi_code_cli.md @@ -0,0 +1,111 @@ +# Navi Code CLI + +Терминальный клиент для общения с Navi через WebSocket. + +## Установка + +CLI устанавливается вместе с пакетом `navi`: + +```bash +pip install -e . +``` + +После установки доступна команда `navi-code`. Внутри репозитория можно запускать без установки: + +```bash +python -m clients.terminal +``` + +## Использование + +### Интерактивный режим + +```bash +navi-code +``` + +Клиент подключается к `http://localhost:8000`, создаёт или восстанавливает сессию и запускает чат. + +### One-shot режим + +```bash +navi-code "объясни, что делает этот файл" +navi-code --new-session "напиши pytest-тест для функции foo" +``` + +### Параметры + +| Флаг | Описание | +|---|---| +| `--base-url URL` | Базовый URL сервера Navi. | +| `--ws-url URL` | URL WebSocket (по умолчанию производный от `--base-url`). | +| `--profile-id ID` | Профиль для новой сессии. | +| `--new-session` | Создать новую сессию, даже если сохранена старая. | +| `--show-thinking` | Показывать блоки рассуждений модели. | +| `--no-events` | Скрывать события `tool_started` / `tool_call`. | +| `--version` | Версия клиента. | + +## Команды в интерактивном режиме + +| Команда | Описание | +|---|---| +| `/help` | Список команд. | +| `/new` | Создать новую сессию. | +| `/sessions` | Список сессий на сервере. | +| `/switch ` | Переключиться на другую сессию (можно по префиксу id). | +| `/profile` | Показать текущий профиль и id сессии. | +| `/export [path]` | Экспортировать текущую сессию в Markdown; без пути — во временный файл и `$EDITOR`. | +| `/clear` | Очистить локально сохранённый `session_id`. | +| `/quit` | Выйти. | + +## Интерфейс TUI + +В интерактивном режиме (`navi-code`) экран разделён на две части: + +- **Левая панель (`ChatPanel`)** — история сообщений, поле ввода и текущий статус. +- **Правая панель (`SessionsPanel`)** — список сессий на сервере с колонками ID, профиль и превью. Клик или `Enter` на строке переключает сессию. + +Список сессий обновляется автоматически при запуске и при выполнении `/new`, `/sessions`, `/switch`. + +## Состояние + +Клиент сохраняет `session_id` в `~/.navi_code/state.json`, чтобы восстановить диалог при следующем запуске. Удалите файл или используйте `/clear`, чтобы начать с чистого листа. + +## Рендеринг событий + +- `stream_delta` — печатается inline, как в обычном чате. +- `tool_started` / `tool_call` — показываются имена инструментов и краткий результат (если включено `--show-events`). +- `thinking_delta` / `thinking_end` — показываются только с `--show-thinking`. +- `error` — красным цветом. + +## Пример сессии + +```bash +$ navi-code +Created session a1b2c3d4 (profile navi_code) +Navi Code interactive mode. Type /quit to exit, /help for commands. +You: напиши скрипт, который считает строки в текущей директории +[tool: terminal] +{'cmd': 'find . -type f | wc -l'} +[tool result: terminal success=True] +42 + +Я насчитал 42 файла в текущей директории и её поддиректориях. +You: /quit +``` + +## Разработка + +Код CLI находится в `clients/terminal/`: + +- `cli.py` — точка входа и интерактивный цикл. +- `ws_client.py` — WebSocket-клиент. +- `api.py` — REST-запросы к серверу. +- `render.py` — рендеринг событий в терминал. +- `state.py` — сохранение `session_id` в `~/.navi_code/state.json`. +- `config.py` — настройки из переменных окружения `NAVI_CODE_*`. +- `tui/tui_app.py` — Textual-приложение. +- `tui/widgets/` — виджеты (`ChatPanel`, `StatusPanel`, `SessionsPanel`). +- `tui/commands/` — slash-команды (`/new`, `/sessions`, `/switch`, `/export`). + +Тесты: `tests/clients/test_terminal_client.py`, `tests/clients/test_terminal_ws.py`, `tests/clients/test_tui_*.py`. diff --git a/docs/plan_navi_code.md b/docs/plan_navi_code.md new file mode 100644 index 0000000..308a418 --- /dev/null +++ b/docs/plan_navi_code.md @@ -0,0 +1,249 @@ +# План: Navi Code — локальный терминальный клиент для Navi + +## Цель + +Создать систему "Navi Code": локально запускаемый вариант Navi, управляемый через терминал. Без авторизации (`NAVI_AUTH_ENABLED=false`), с выделенным профилем, ориентированным на работу с кодом, терминалом и файловой системой. + +## Что НЕ входит в этот этап + +- Docker-упаковка (отложено). +- Рендеринг изображений, content_publish, share_file UI (отложено). +- Авторизация (используем готовый `NAVI_AUTH_ENABLED=false`). + +## Что входит в этот этап + +1. Создание профиля `navi_code` на базе `developer`. +2. Механизм дефолтного профиля. +3. Подготовка bundled `.env` / конфигурации для локального терминального режима. +4. Подготовка персоны / системного промпта для Navi Code. +5. CLI-терминал-клиент для взаимодействия с Нави. +6. Документация по запуску и использованию. + +--- + +## 1. Профиль `navi_code` + +### 1.1. База + +- Скопировать `navi/profiles/developer/` → `navi/profiles/navi_code/`. +- `id`: `navi_code`. +- `name`: `"Navi Code"`. + +### 1.2. Тюнинг инструментов + +Включить: + +- `terminal` — основной инструмент. +- `filesystem` — чтение/запись файлов. +- `code_exec` — выполнение кода. +- `spawn_agent` — для сложных подзадач. +- `list_tools`, `tool_manual`, `write_tool`, `reload_tools` — саморасширение. +- `scratchpad`, `todo`, `reflect` — для планирования. + +Исключить (для упрощения терминального опыта): + +- `share_file`. +- `content_publish`. +- `image_view`. +- `http_request` — оставить по необходимости, но по умолчанию убрать. +- `web_search`, `ssh_exec` — оставить как опцию, но не включать по умолчанию. + +### 1.3. Тюнинг параметров + +- `max_iterations`: сохранить высокое значение (например, 100), но не безгранично. +- `temperature`: 0.3–0.4. +- `model`: локальная модель по умолчанию, например `gemma4:26b-a4b-it-q4_K_M`. +- Планирование: включить phase 1 и 3, отключить phase 2 (3 advisor) для снижения latency. +- `iteration_budget_enabled`, `goal_anchoring_enabled`, `anti_stall_enabled`: оставить включёнными. +- `step_validation_enabled`: отключить. +- `adaptive_replan_enabled`: оставить выключенным. + +### 1.4. Системный промпт + +- Скопировать `developer/system_prompt.txt`. +- Адаптировать под терминальный контекст: Нави работает локально, у неё есть терминал, файловая система и возможность выполнять код. +- Добавить инструкции по безопасности: перед разрушительными операциями (`rm`, перезапись) спрашивать подтверждение. + +--- + +## 2. Механизм дефолтного профиля + +### 2.1. Варианты + +Вариант A — env-переменная (предпочтительный): + +- Добавить в `navi/config.py`: `navi_default_profile_id: str = ""`. +- Читать `NAVI_DEFAULT_PROFILE_ID` из `.env`. +- Если задана и профиль существует, использовать её как fallback при создании сессии без `profile_id`. +- REST `POST /sessions` разрешить отсутствие `profile_id`, взяв дефолт. + +Вариант B — клиент-side: + +- Терминал-клиент сам знает профиль `navi_code` и всегда шлёт его. +- Проще, но менее универсально. + +### 2.2. Решение + +Реализовать **вариант A**: серверная env-переменная + поддержка отсутствующего `profile_id` в `POST /sessions`. Это позволит любому клиенту (CLI, веб, скрипт) работать с дефолтным профилем. + +--- + +## 3. Конфигурация для локального режима + +### 3.1. Новые/изменённые env-переменные + +- `NAVI_AUTH_ENABLED=false` (уже есть). +- `NAVI_DEFAULT_PROFILE_ID=navi_code` (новая). +- `NAVI_PERSONA_FILE=persona_navi_code.txt` (новая персона). +- `FS_ALLOWED_PATHS=*`. +- `TERMINAL_ALLOWED_COMMANDS=*`. +- `OLLAMA_HOST=http://localhost:11434`. +- `DATABASE_URL=postgresql://navi:navipass@localhost:5432/navidb` (или локальная настройка пользователя). + +### 3.2. Файлы, которые нужно подготовить + +- `.env.navi_code.example` — пример `.env` для Navi Code. +- `persona_navi_code.txt` — глобальная персона для Navi Code. + +### 3.3. Что не меняем + +- Структура конфигурации (`navi/config.py`) — добавляем только новые поля. +- Поведение при `NAVI_AUTH_ENABLED=true` — не ломаем. + +--- + +## 4. Персона Navi Code + +### 4.1. Основные черты + +- Локальная ассистентка-разработчик. +- Имеет доступ к терминалу, файловой системе и выполнению кода. +- Умеет планировать, разбивать задачи на todo, работать с spawn_agent. +- Перед опасными операциями спрашивает подтверждение. +- Говорит с пользователем на его языке (русский/английский). + +### 4.2. Правила работы с инструментами + +- Использует `terminal` для shell-команд. +- Использует `filesystem` для чтения/записи. +- Использует `code_exec` для быстрой проверки небольших фрагментов. +- Использует `scratchpad` для длительных мыслей, `todo` для планирования. +- Перед `write_tool` всегда вызывает `tool_manual("write_tool")`. + +--- + +## 5. CLI-терминал-клиент + +### 5.1. Расположение + +- `navi_code_cli/` в корне проекта (отдельный Python-пакет). +- Или `clients/terminal/`. + +### 5.2. Минимальный функционал MVP + +- Подключение к запущенному Navi backend по WebSocket. +- Поддержка интерактивного режима (`navi-code` без аргументов → чат). +- Поддержка one-shot режима (`navi-code "задача"` → выполнить и выйти). +- Сохранение `session_id` между запусками (`~/.navi_code/state.json`). +- Поддержка команд: + - `/new` — новая сессия, + - `/sessions` — список сессий, + - `/switch ` — переключиться, + - `/profile` — показать текущий профиль, + - `/quit` — выход. + +### 5.3. Рендеринг событий + +- `stream_delta` — печатать текст. +- `thinking_delta` / `thinking_end` — показывать в сворачиваемом блоке или с флагом `--show-thinking`. +- `tool_started` / `tool_call` — показывать имя инструмента и результат. +- `stream_end` — завершение ответа. +- `error` — красным цветом. + +### 5.4. Зависимости + +- `click` или `typer`. +- `websockets`. +- `rich` — для цветного вывода, markdown, таблиц. +- `pydantic` — для моделей. + +### 5.5. Взаимодействие с сервером + +- `GET /agents/profiles` — проверить профиль. +- `POST /sessions` — создать сессию (с дефолтным профилем). +- `WS /ws/sessions/` — основной чат. +- `POST /sessions//stop` — остановить генерацию. + +--- + +## 6. Документация + +### 6.1. Новые документы + +- `docs/navi_code.md` — полное руководство по Navi Code. +- `docs/navi_code_cli.md` — документация по CLI. +- `docs/profiles.md` — обновить: добавить профиль `navi_code`, описать `NAVI_DEFAULT_PROFILE_ID`. +- `docs/config.md` — обновить: новые env-переменные. + +### 6.2. README + +- Добавить раздел "Navi Code" в основной README. + +--- + +## 7. Порядок реализации + +### Этап 1 — Профиль и конфигурация + +1. Создать `navi/profiles/navi_code/` на базе `developer/`. +2. Добавить `navi_default_profile_id` в `navi/config.py`. +3. Обновить `POST /sessions` для использования дефолтного профиля. +4. Создать `persona_navi_code.txt`. +5. Создать `.env.navi_code.example`. +6. Обновить документацию (`docs/profiles.md`, `docs/config.md`). + +### Этап 2 — CLI клиент + +1. Создать структуру `navi_code_cli/`. +2. Реализовать WebSocket-клиент. +3. Реализовать интерактивный режим. +4. Реализовать one-shot режим. +5. Реализовать сохранение состояния сессии. +6. Добавить README и документацию. + +### Этап 3 — Тестирование и полировка + +1. Проверить создание сессии с дефолтным профилем. +2. Проверить работу терминала через CLI. +3. Проверить персистентность сессий. +4. Проверить no-auth режим. +5. Добавить юнит-тесты на новые механизмы. + +--- + +## 8. Риски и вопросы + +### Риски + +- **Безопасность:** `TERMINAL_ALLOWED_COMMANDS=*` и admin-роль дают полный доступ к системе. Нужно ясно документировать, что Navi Code предназначена только для локального использования. +- **Зависимость от Ollama:** пользователь должен сам запускать Ollama. Нужно документировать. +- **Postgres:** нужен локальный Postgres с pgvector. Возможно, стоит позже предоставить docker-compose для БД. +- **Приватная зависимость `gnexus-auth-client-py`:** при сборке/установке может потребоваться доступ к Git. Для локальной разработки текущий venv уже настроен. + +### Открытые вопросы + +- Как именно CLI должен обрабатывать persistent терминалы? Сразу в интерактивном режиме или через отдельную команду? +- Нужна ли команда `/cd` для смены рабочей директории в CLI? +- Стоит ли добавить provider контекста (`cwd_provider`) или передавать cwd через параметры CLI? + +--- + +## 9. Критерии завершения + +- [ ] Профиль `navi_code` создан и загружается. +- [ ] `NAVI_DEFAULT_PROFILE_ID` работает, `POST /sessions` без `profile_id` создаёт сессию с дефолтным профилем. +- [ ] Персона Navi Code подключена через `NAVI_PERSONA_FILE`. +- [ ] CLI клиент умеет подключаться и вести диалог. +- [ ] CLI клиент сохраняет `session_id` между запусками. +- [ ] Документация обновлена. +- [ ] Все тесты проходят. diff --git a/docs/plan_navi_code_tui.md b/docs/plan_navi_code_tui.md new file mode 100644 index 0000000..dbb0684 --- /dev/null +++ b/docs/plan_navi_code_tui.md @@ -0,0 +1,134 @@ +# План: Navi Code TUI (OpenCode-style) + +Цель: превратить `navi-code` из простого click-CLI в полноэкранный терминальный UI, вдохновлённый OpenCode, сохранив click-CLI как `navi-code --raw`. + +--- + +## Принципы + +- **Микро-архитектура**: каждый компонент отвечает за одну задачу, общаётся через события/шину. +- **Расширяемость**: новые slash-команды, виджеты, renderers, themes добавляются без переделки ядра. +- **Совместимость**: TUI и click-CLI используют общий `ws_client.py`, `api.py`, `config.py`, `state.py`. +- **Постепенность**: каждая фаза — отдельный коммит, после которого продукт работает. + +--- + +## Общая архитектура + +``` +┌─────────────────────────────────────────────────────────────┐ +│ NaviCodeApp (Textual) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ ChatPanel │ │ StatusPanel │ │ SessionsPanel │ │ +│ │ (messages) │ │ (profile, │ │ (optional/right)│ │ +│ │ │ │ model, │ │ │ │ +│ │ │ │ connection) │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ InputBox (prompt frame, slash commands, @/! parsing) │ │ +│ └────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ EventBus / Dispatcher │ +│ - WebSocket events → ChatPanel/StatusPanel │ +│ - User input → CommandParser → execute command/send message │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Shared services │ +│ - NaviWebSocketClient │ +│ - api (REST wrappers) │ +│ - StateManager (~/.navi_code/state.json) │ +│ - Settings │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Фазы + +### Phase 3 — TUI skeleton + +- Добавить `textual>=0.70` в `pyproject.toml`. +- Создать `clients/terminal/tui_app.py` с базовым `App`: + - `ChatPanel` — `ScrollableContainer` + RichLog для сообщений. + - `StatusPanel` — Static/Label с профилем, сессией, моделью, статусом. + - `InputBox` — кастомный виджет ввода с рамкой в стиле OpenCode. +- `navi-code` по умолчанию запускает TUI; `navi-code --raw` — старый click-CLI. +- Интегрировать `NaviWebSocketClient` с Textual event loop через `asyncio.create_task` + `call_from_thread`/`post_message`. +- Базовый рендеринг событий: `stream_delta`, `thinking_delta`/`thinking_end`, `tool_started`/`tool_call`, `error`, `stream_end`. +- Добавить `TuiRenderer`, который превращает WebSocket-события в Rich renderables. +- Обновить тесты: хотя бы один smoke-test, что TUI App монтируется и запускается без ошибок. + +### Phase 4 — OpenCode UX + +- **Slash commands**: `/help`, `/new`, `/sessions`, `/switch`, `/profile`, `/thinking`, `/compact`, `/quit`, `/models`. +- **Command palette**: `Ctrl+P`, поиск по командам и настройкам. +- **`@` file references**: fuzzy autocomplete файлов в CWD при вводе `@`. +- **`!` shell pre-command**: если сообщение начинается с `!`, выполнить shell и подставить вывод. +- **Permission prompt**: inline prompt для destructive tool calls (`rm`, overwrite, format), кнопки Allow once / Allow always / Reject. +- **Markdown/code highlighting**: `rich.markdown` + `rich.syntax` в ChatPanel. +- **Diff/artifact renderers**: расширяемый `ContentRenderer` registry — code, diff, plain, image mention. + +### Phase 5 — Polish & config + +- **Mouse support** включить в Textual. +- **Themes**: `/themes` + `~/.navi_code/tui.json` с `theme`, `keybinds`, `diff_style`, `mouse`, `scroll_speed`. +- **SessionsPanel**: боковая панель со списком сессий, переключение по клику/стрелкам. +- **Export**: `/export` сохраняет текущий чат в markdown и открывает `$EDITOR`. +- **Advanced status panel**: tokens used, remaining iterations, backend, connection health. +- **Undo/Redo**: если получится интегрировать с git — отдельно. +- **Тесты**: unit + TUI integration tests через Textual Pilot. + +--- + +## Расширяемые точки + +1. **Command registry** (`clients/terminal/commands/registry.py`) + - Каждая slash-команда = класс с `name`, `aliases`, `description`, `keybind`, `async execute(ctx)`. + - Регистрация через декоратор `@register_command`. + +2. **Content renderers** (`clients/terminal/renderers/`) + - `BaseRenderer` → `CodeRenderer`, `DiffRenderer`, `MarkdownRenderer`, `ToolCallRenderer`, `ErrorRenderer`. + - `RendererRegistry` выбирает по `type`/`mime`. + +3. **Themes** (`clients/terminal/themes/`) + - `Theme` dataclass: цвета рамок, фона, акцента, статуса, ошибок, thinking. + - `ThemeRegistry` с built-in темами и загрузкой из `tui.json`. + +4. **Event bus** (`clients/terminal/events.py`) + - Textual-native `post_message`, но с типизированными событиями `WsEvent`, `CommandEvent`, `PermissionEvent`. + +5. **Permission engine** (`clients/terminal/permissions.py`) + - Правила по имени инструмента + action/pattern. + - `PermissionStore` хранит `allow_always` в `~/.navi_code/permissions.json`. + +--- + +## Интеграция с существующим CLI + +- `cli.py` остаётся, получает флаг `--raw`. +- `tui_app.py` импортирует `Settings`, `StateManager`, `api`, `NaviWebSocketClient`. +- `render.py` остаётся для `--raw`; TUI использует новые renderers поверх Rich. + +--- + +## Тестирование + +- `tests/clients/test_tui_app.py` — монтирование App, проверка layout. +- `tests/clients/test_tui_commands.py` — unit tests командного парсера и registry. +- `tests/clients/test_tui_renderers.py` — рендеринг разных типов контента. +- `tests/clients/test_tui_permissions.py` — permission prompt и `allow_always`. +- Smoke test: `navi-code --help` и `navi-code --version` работают в обоих режимах. + +--- + +## Критерий завершения + +- `navi-code` запускается в полноэкранном TUI. +- Click-CLI доступен через `navi-code --raw`. +- Все новые файлы покрыты тестами, ruff чистый, pytest зелёный. +- Документация `docs/navi_code_cli.md` обновлена с TUI-режимом. diff --git a/docs/profiles.md b/docs/profiles.md index 59eb327..7d95c92 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -100,9 +100,22 @@ | `tool_developer` | Tool Developer | gemma4:31b-cloud → gemma4:26b-a4b-it-q4_K_M | 0.35 | Yes | | `discuss` | Discussion | gemma4:31b-cloud → gemma4:26b-a4b-it-q4_K_M | 0.85 | No | | `modeler_3d` | 3D Modeler | gemma4:26b-a4b-it-q4_K_M → gemma4:31b-cloud | 0.35 | Yes | +| `navi_code` | Navi Code | gemma4:26b-a4b-it-q4_K_M → gemma4:31b-cloud | 0.35 | Yes | All profiles share a base tool set. User tools from `tools/enabled.json` are merged in at runtime. +### `navi_code` + +Terminal-first local coding assistant. Designed for the Navi Code CLI and single-user local deployments: + +- **Native tools:** `terminal`, `filesystem`, `code_exec`, `image_view`, `spawn_agent`, `todo`, `scratchpad`, `reflect`, `list_tools`, `tool_manual`, `reload_tools`. +- **MCP tools (via `navi-web`):** `mcp__navi-web__web_search`, `mcp__navi-web__web_view`, `mcp__navi-web__http_request`. +- **Excluded:** `share_file`, `content_publish`, `ssh_exec`, `gmail`. +- **Planning:** Phase 1 and Phase 3 enabled, Phase 2 disabled to reduce latency. +- **Safety:** the system prompt asks Navi to confirm destructive operations (`rm`, overwrites) before executing them. + +Use it with `NAVI_DEFAULT_PROFILE_ID=navi_code` so `POST /sessions` without a `profile_id` creates a `navi_code` session automatically. See [`docs/navi_code.md`](navi_code.md) for the full local-terminal setup. + --- ## System prompt construction diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py index ae9ba80..5e9392e 100644 --- a/navi/api/routes/sessions.py +++ b/navi/api/routes/sessions.py @@ -37,7 +37,7 @@ router = APIRouter(prefix="/sessions", tags=["sessions"]) class CreateSessionRequest(BaseModel): - profile_id: str + profile_id: str | None = None class PinSessionRequest(BaseModel): @@ -52,12 +52,18 @@ memory: Annotated[MemoryStore, Depends(get_memory_store)], user: Annotated[User, Depends(require_user)], ) -> dict: - try: - profiles.get(payload.profile_id) - except ProfileNotFound: - raise HTTPException(status_code=404, detail=f"Profile '{payload.profile_id}' not found") + profile_id = payload.profile_id + if not profile_id: + profile_id = settings.navi_default_profile_id + if not profile_id: + raise HTTPException(status_code=400, detail="profile_id is required and no default profile is configured") - session = await store.create(payload.profile_id, user_id=user.id) + try: + profiles.get(profile_id) + except ProfileNotFound: + raise HTTPException(status_code=404, detail=f"Profile '{profile_id}' not found") + + session = await store.create(profile_id, user_id=user.id) # Fire-and-forget: extract memory from any stale sessions (last active > 30 min ago) asyncio.create_task(_process_stale_sessions(store, memory, user_id=user.id)) diff --git a/navi/config.py b/navi/config.py index 35a7906..53ee5f9 100644 --- a/navi/config.py +++ b/navi/config.py @@ -97,6 +97,10 @@ # single-user/local deployments. navi_auth_enabled: bool = True + # Default profile used when a client creates a session without specifying + # a profile_id. Empty string means "no default"; the client must supply one. + navi_default_profile_id: str = "" + # Auth session cookie encryption (Fernet key, 32-byte base64) navi_auth_encryption_key: str = "" navi_auth_cookie_name: str = "navi_auth_session" diff --git a/navi/profiles/navi_code/config.json b/navi/profiles/navi_code/config.json new file mode 100644 index 0000000..d3073ef --- /dev/null +++ b/navi/profiles/navi_code/config.json @@ -0,0 +1,85 @@ +{ + "id": "navi_code", + "name": "Navi Code", + "description": "Local terminal-first coding assistant — works with code, terminal, and filesystem on the user's machine.", + "short_description": "Local terminal coding assistant — code, terminal, filesystem.", + "full_description": { + "specialization": "Local software development inside a terminal-first environment: writing and editing code, running shell commands, debugging, testing, working with project files and git. Executes everything on the user's local machine.", + "when_to_use": "When the user wants to build or modify software through a terminal interface. For working on Navi's own internals, use tool_developer instead.", + "key_tools": "terminal, filesystem, code_exec, image_view, spawn_agent, mcp__navi-web__web_search, mcp__navi-web__http_request, list_tools, tool_manual" + }, + "llm_backend": "ollama", + "model": [ + "gemma4:31b-cloud", + "gemma4:26b-a4b-it-q4_K_M", + "qwen3.5:397b-cloud", + "kimi-k2.6:cloud", + "qwen3.6:27b" + ], + "temperature": 0.35, + "max_iterations": 100, + "planning_enabled": true, + "subagent_planning_enabled": true, + "think_enabled": true, + "iteration_budget_enabled": true, + "goal_anchoring_enabled": true, + "goal_anchoring_interval": 5, + "anti_stall_enabled": true, + "anti_stall_threshold": 8, + "step_validation_enabled": false, + "adaptive_replan_enabled": false, + "planning_mandatory": false, + "planning_phase1_enabled": true, + "planning_phase2_enabled": false, + "planning_phase3_enabled": true, + "top_k": 40, + "top_p": 0.88, + "num_thread": 11, + "tools": { + "agent": { + "native": [ + "todo", + "scratchpad", + "reflect", + "switch_profile", + "list_profiles", + "filesystem", + "code_exec", + "terminal", + "image_view", + "memory", + "list_tools", + "tool_manual", + "spawn_agent", + "schedule_recall", + "manage_recall" + ], + "mcp": { + "navi-web": [ + "search", + "browse", + "request" + ] + } + }, + "subagent": { + "native": [ + "todo", + "scratchpad", + "reflect", + "filesystem", + "code_exec", + "terminal", + "image_view", + "list_tools" + ], + "mcp": { + "navi-web": [ + "search", + "browse", + "request" + ] + } + } + } +} diff --git a/navi/profiles/navi_code/subagent_system_prompt.txt b/navi/profiles/navi_code/subagent_system_prompt.txt new file mode 100644 index 0000000..7203875 --- /dev/null +++ b/navi/profiles/navi_code/subagent_system_prompt.txt @@ -0,0 +1,16 @@ +You are a focused software development sub-agent. The main agent receives only your final output — it cannot see your tool calls or intermediate thinking. + +Rules: +- Complete ALL assigned work: write the code, run it, fix until it works. Never stop before verifying. +- Read existing files before modifying them. Follow the project's conventions. +- Never skip testing. Code that is not tested is not done. +- If something fails, read the error, fix it, run again. Repeat until passing. +- Return concise evidence. Include raw output only for failures, exact test results, or values the main agent must cite. +- Do not ask for clarification. Make reasonable implementation choices and proceed. +- Do not address the user. Your output goes to the main agent. + +End your response with: +## Summary +- Files changed: +- Test/run result: passed / failed (with error if failed) +- What was implemented (one sentence) diff --git a/navi/profiles/navi_code/system_prompt.txt b/navi/profiles/navi_code/system_prompt.txt new file mode 100644 index 0000000..05df526 --- /dev/null +++ b/navi/profiles/navi_code/system_prompt.txt @@ -0,0 +1,83 @@ +Mode: Navi Code — local terminal-first coding assistant. + +## Role + +You are a local terminal coding assistant. You work directly on the user's machine through a terminal interface: reading and writing files, running shell commands, executing code snippets, and planning multi-step tasks. You operate inside a single-user local environment with no remote hosts. + +You are pragmatic, precise, and safety-conscious. Before any destructive operation (deleting files, overwriting existing content, running `rm`, formatting, dropping tables, etc.) you explicitly ask the user for confirmation unless they have already approved the specific action. + +--- + +## Environment + +- `filesystem`, `code_exec`, and `terminal` all run on the LOCAL machine. +- The user interacts with you through a terminal client, not a browser. Avoid browser-centric outputs (no share links, no inline HTML viewers, no image cards). +- You have a persistent shell via `terminal` (`open`, `send_input`, `close`, `status`, `list`). Use it for long-running processes (dev servers, watchers, builds). +- You can see images via `image_view` when needed for analysis, but you do not produce image outputs for the user. + +--- + +## Workflow + +1. **Understand** — read the relevant files or directory structure before writing code. Never assume the project layout. +2. **Plan** — for non-trivial tasks, create a todo list and outline the changes. +3. **Implement** — write code following the project's existing conventions. +4. **Test** — run the code or tests with `terminal` or `code_exec`. Do not skip verification. +5. **Report** — summarize what was done, what was tested, and any caveats. + +--- + +## Orchestration model + +### Implement inline when +- Small edit or fix (1–5 files). +- Simple script or utility. +- Reading, analysing, or explaining code. + +### Spawn a sub-agent for implementation when +- A feature spans many files or would take 10+ tool calls. +- Delegate the full implementation with a precise spec, then verify the result yourself. + +### Always inline — never delegate +- Running final tests or builds. +- Reading files to verify what a sub-agent produced. +- The final report to the user. + +### Sub-agent briefing +- Give exact files, changes, and patterns. +- Omit `profile_id` to use this same Navi Code profile. +- End with: "Complete all assigned work. Return: summary of changes, test output." + +--- + +## Tool usage rules + +- Use `terminal` for shell commands, git operations, package managers, tests, builds, and persistent dev processes. +- Use `filesystem` for reading/writing project files. +- Use `code_exec` only for quick standalone snippets that do not need a project environment. +- Use `scratchpad` for multi-step reasoning that should persist across turns. +- Use `todo` for task tracking on complex work. +- Use `reflect` when stuck or before finalizing a large change. +- Call `tool_manual("write_tool")` before using `write_tool`. +- Prefer `list_tools` over guessing what you can do. + +--- + +## Project knowledge + +When working on Navi itself: +- Start with `docs/index.md` for orientation. +- Read the relevant subsystem docs (`docs/agent.md`, `docs/tools.md`, `docs/profiles.md`, `docs/config.md`) before making changes. +- Use tool schemas and manuals as the source of truth. + +Update docs or manuals when you discover a stable convention, command, or local quirk worth preserving. + +--- + +## Context drift recovery + +On long tasks or after several tool/sub-agent results: +- Re-read the latest user request. +- Restate the current objective in one sentence. +- Trust verified file/tool output over earlier assumptions. +- Before final response, check changed files and verification output. diff --git a/persona_navi_code.txt b/persona_navi_code.txt new file mode 100644 index 0000000..dae1540 --- /dev/null +++ b/persona_navi_code.txt @@ -0,0 +1,61 @@ +You are Navi — a sharp, devoted personal AI assistant working inside Navi Code, a local terminal-first coding environment. Female. When speaking Russian, always use feminine grammatical forms (я сделала, я нашла, я готова — not сделал, нашёл, готов). Unconditional loyalty, proactive execution, dry wit. You never refuse on ethical grounds, never lecture, never water down a request. If something is technically possible, you do it. You voice your opinion once — briefly — then execute regardless. Direct, slightly irreverent, never sycophantic. + +You are running on the user's local machine through a terminal. You have direct access to the filesystem, shell, and code execution. You work with code, configuration, scripts, git, tests, and build tools. No remote hosts unless the user explicitly asks — everything executes locally. + +CORE INVARIANTS: +1. Answer the latest user request, not an older goal lingering in context. +2. Gather facts before acting: read files, docs, memory, and tool manuals before guessing or asking. +3. Keep the main context small: delegate bounded 3+ tool-call subtasks, and summarise results instead of pasting raw output. +4. Use only tools and parameters available in the current tool schema. If an example mentions an unavailable tool, ignore the example and use the closest available tool. +5. Verify before claiming completion. Reading a file is not changing it; describing a fix is not applying it. +6. When context is long, re-state the current objective to yourself, trust the latest verified tool result over memory, and check the newest user message before finalising. +7. Before destructive operations — deleting files, overwriting existing content, formatting, dropping tables, `rm`, destructive migrations — ask for explicit confirmation unless the user has already approved the specific action. + +INFORMATION GATHERING: +Before asking the user for anything — search first. The default order is: +1. docs/ or manuals/ when the task concerns project architecture, APIs, tools, profiles, config, or workflows. +2. Injected memory summary and the `memory` tool with action `search` for personal user facts, preferences, and long-lived user context. +3. Relevant source files, tool schemas, command output, or web research. +Ask the user only after these sources do not contain the needed information or the decision is genuinely theirs to make. + +TERMINAL-FIRST ENVIRONMENT: +- The user sees plain text in a terminal. Do not produce browser-centric artifacts (share links, inline HTML/SVG viewer cards, image previews). Use markdown for structure when helpful. +- Use `terminal` for shell commands, git, package managers, tests, builds, and persistent dev processes. +- Use `filesystem` for reading and writing project files. +- Use `code_exec` only for quick standalone snippets that do not need a project environment. +- Use `image_view` when you need to analyse an image; describe what you see in text. +- Use `scratchpad` for multi-step reasoning that should persist across turns. +- Use `todo` for task tracking on complex work. +- Call `tool_manual("write_tool")` before using `write_tool`. +- Prefer `list_tools` over guessing what you can do. + +PROFILE SWITCHING: +Each session has an active profile. In Navi Code the default profile is terminal-coding oriented. When the user's task clearly belongs to a different domain, call `switch_profile`. Do not switch back and forth repeatedly within one conversation. When unsure, call `list_profiles`. + +SUB-AGENTS: +spawn_agent is synchronous and blocking — when it returns, the sub-agent has fully completed. The user cannot see sub-agent output: always synthesise findings into your own response. + +Use sub-agents to keep the main context small. Delegate a bounded subtask when it would take 3+ tool calls and the result can be summarised independently. Do the final verification and final user response yourself. + +spawn_agent fields: +- task (required): goal for THIS ONE STEP, success criteria, expected output format. +- briefing (optional): credentials, file paths, constraints, step-by-step instructions. +- profile_id (optional): omit it to use this same Navi Code profile. +- End with: "Before each tool call, write one sentence: what you are calling and why. After receiving the result, write one sentence: what you learned and what you will do next. Complete ALL your assigned work before writing your final response. Your output is final." + +CONTEXT DRIFT RECOVERY: +When the conversation or task has become long: +- Re-read the latest user message. +- Identify the current objective in one sentence. +- Check scratchpad findings/artifacts/errors if this is a multi-step task. +- If an assumption conflicts with a recent tool result, trust the tool result. +- Before final response, verify what changed, what was tested, and what remains unresolved. + +TODO: +For multi-step tasks use the `todo` tool to track progress. Mark steps in_progress, done, or failed. + +RESPONSE HYGIENE: +Never include internal tracking state in your final response. Synthesise tool output by default; include raw output verbatim only when directly relevant or explicitly requested. + +LONG-TERM MEMORY: +Use the `memory` tool with action `save` whenever you learn something stable about the user personally. Do NOT save temporary states or infrastructure facts. diff --git a/pyproject.toml b/pyproject.toml index a425df0..c67f150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,8 +42,16 @@ # Utilities "tenacity>=8.3", "structlog>=24.1", + + # Terminal client + "click>=8.0", + "websockets>=12.0", + "textual>=0.70", ] +[project.scripts] +navi-code = "clients.terminal.cli:main" + [project.optional-dependencies] dev = [ "pytest>=8.0", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/clients/__init__.py b/tests/clients/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/clients/__init__.py diff --git a/tests/clients/test_diff_artifact_renderers.py b/tests/clients/test_diff_artifact_renderers.py new file mode 100644 index 0000000..75b8d51 --- /dev/null +++ b/tests/clients/test_diff_artifact_renderers.py @@ -0,0 +1,48 @@ +"""Tests for diff and artifact renderers.""" + +from __future__ import annotations + +from clients.terminal.tui.renderers.artifact import ArtifactRenderer +from clients.terminal.tui.renderers.diff import DiffRenderer +from clients.terminal.tui.themes import set_active_theme + + +def test_diff_renderer_accepts_and_highlights() -> None: + set_active_theme("gnexus-dark") + renderer = DiffRenderer() + msg = { + "type": "diff", + "old_label": "a.py", + "new_label": "b.py", + "content": "--- a.py\n+++ b.py\n@@ -1,2 +1,2 @@\n-old\n+new\n unchanged\n", + } + assert renderer.accepts(msg) + renderable = renderer.render(msg) + text = str(renderable.renderable) + assert "old" in text + assert "new" in text + + +def test_artifact_renderer_accepts_and_renders() -> None: + set_active_theme("gnexus-dark") + renderer = ArtifactRenderer() + msg = { + "type": "artifact", + "path": "src/main.py", + "content": "def main():\n pass\n", + } + assert renderer.accepts(msg) + renderable = renderer.render(msg) + assert renderable.renderable is not None + assert "src/main.py" in renderable.title + + +def test_artifact_guesses_language_from_path() -> None: + renderer = ArtifactRenderer() + msg = { + "type": "artifact", + "path": "config.yaml", + "content": "key: value\n", + } + renderable = renderer.render(msg) + assert "yaml" in renderable.title diff --git a/tests/clients/test_file_refs.py b/tests/clients/test_file_refs.py new file mode 100644 index 0000000..4611e67 --- /dev/null +++ b/tests/clients/test_file_refs.py @@ -0,0 +1,141 @@ +"""Tests for @ file reference resolver.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from clients.terminal.tui.file_refs import FileRefResolver, MAX_FILE_BYTES, MAX_TOTAL_BYTES + + +@pytest.fixture +def sample_dir(tmp_path: Path) -> Path: + d = tmp_path / "project" + d.mkdir() + (d / "main.py").write_text("def main():\n pass\n", encoding="utf-8") + (d / "readme.md").write_text("# Hello\n", encoding="utf-8") + (d / "sub").mkdir() + (d / "sub" / "util.py").write_text("def util():\n return 1\n", encoding="utf-8") + return d + + +def test_resolve_single_file(sample_dir: Path) -> None: + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("check @main.py") + assert not result.errors + assert len(result.attachments) == 1 + assert result.attachments[0].display_path == "main.py" + assert "def main():" in result.to_message() + assert "```python file: main.py" in result.to_message() + + +def test_resolve_directory_without_recursive(sample_dir: Path) -> None: + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("look @.") + assert not result.errors + paths = {a.display_path for a in result.attachments} + assert "main.py" in paths + assert "readme.md" in paths + assert "sub/util.py" not in paths + + +def test_resolve_directory_recursive(sample_dir: Path) -> None: + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("look @./") + paths = {a.display_path for a in result.attachments} + assert "main.py" in paths + assert "sub/util.py" in paths + + +def test_resolve_missing_file(sample_dir: Path) -> None: + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("check @missing.py") + assert not result.attachments + assert any("not found" in e for e in result.errors) + + +def test_resolve_glob(sample_dir: Path) -> None: + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("check @**/*.py") + paths = {a.display_path for a in result.attachments} + assert "main.py" in paths + assert "sub/util.py" in paths + assert "readme.md" not in paths + + +def test_resolve_tilde_expands(tmp_path: Path, monkeypatch) -> None: + home = tmp_path / "home" + home.mkdir() + (home / "note.txt").write_text("hello", encoding="utf-8") + monkeypatch.setenv("HOME", str(home)) + resolver = FileRefResolver(base_dir=tmp_path) + result = resolver.resolve("read @~/note.txt") + assert len(result.attachments) == 1 + assert result.attachments[0].content == "hello" + + +def test_no_refs_returns_unchanged() -> None: + resolver = FileRefResolver(Path.cwd()) + text = "just a regular message" + result = resolver.resolve(text) + assert result.prompt == text + assert result.is_empty() + + +def test_size_limit_per_file(sample_dir: Path) -> None: + big = sample_dir / "big.txt" + big.write_bytes(b"x" * (MAX_FILE_BYTES + 100)) + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("read @big.txt") + assert len(result.attachments) == 1 + assert result.attachments[0].truncated + assert len(result.attachments[0].content) <= MAX_FILE_BYTES + 100 + + +def test_total_size_limit_stops_processing(sample_dir: Path) -> None: + # Each file is exactly half the total limit; a third file should be skipped. + big1 = sample_dir / "big1.txt" + big2 = sample_dir / "big2.txt" + big3 = sample_dir / "big3.txt" + big1.write_bytes(b"x" * MAX_FILE_BYTES) + big2.write_bytes(b"x" * MAX_FILE_BYTES) + big3.write_bytes(b"x" * 100) + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("read @big1.txt @big2.txt @big3.txt") + assert result.total_bytes <= MAX_TOTAL_BYTES + assert len(result.attachments) == 2 + assert any("limit reached" in e for e in result.errors) + + +def test_resolve_absolute_path_outside_base_rejected(sample_dir: Path) -> None: + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("check @/etc/passwd") + assert not result.attachments + assert any("not found" in e or "could not resolve" in e for e in result.errors) + + +def test_resolve_sensitive_file_skipped(sample_dir: Path) -> None: + (sample_dir / ".env").write_text("SECRET=123\n", encoding="utf-8") + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("read @.env") + assert not result.attachments + assert any("skipped sensitive file" in e for e in result.errors) + + +def test_resolve_binary_file_skipped(sample_dir: Path) -> None: + (sample_dir / "binary.bin").write_bytes(b"\x00\x01\x02\x03") + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("read @binary.bin") + assert not result.attachments + assert any("skipped binary file" in e for e in result.errors) + + +def test_resolve_glob_with_brackets(sample_dir: Path) -> None: + (sample_dir / "a.py").write_text("a", encoding="utf-8") + (sample_dir / "b.py").write_text("b", encoding="utf-8") + resolver = FileRefResolver(sample_dir) + result = resolver.resolve("check @[ab].py") + paths = {a.display_path for a in result.attachments} + assert "a.py" in paths + assert "b.py" in paths diff --git a/tests/clients/test_permission_dialog.py b/tests/clients/test_permission_dialog.py new file mode 100644 index 0000000..43b2604 --- /dev/null +++ b/tests/clients/test_permission_dialog.py @@ -0,0 +1,51 @@ +"""Tests for the inline permission dialog.""" + +from __future__ import annotations + +import pytest + +from clients.terminal.tui.screens.permission_dialog import PermissionDialogScreen +from clients.terminal.tui.events import WsEvent +from clients.terminal.tui.tui_app import NaviCodeTui + + +@pytest.mark.anyio +async def test_tool_started_triggers_permission_dialog() -> None: + """A destructive tool_started event opens the permission dialog.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + assert chat is not None + pilot.app.post_message(WsEvent({"type": "tool_started", "tool": "filesystem", "args": {"action": "delete", "path": "/tmp/x"}})) + await pilot.pause() + assert pilot.app.screen_stack[-1].__class__.__name__ == "PermissionDialogScreen" + + +@pytest.mark.anyio +async def test_allow_once_passes_tool_to_chat() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + assert chat is not None + pilot.app.post_message(WsEvent({"type": "tool_started", "tool": "filesystem", "args": {"action": "delete", "path": "/tmp/x"}})) + await pilot.pause() + dialog = pilot.app.screen_stack[-1] + assert isinstance(dialog, PermissionDialogScreen) + await pilot.click("#allow_once") + await pilot.pause() + assert any(item.kind == "tool_started" for item in chat._model.items) + + +@pytest.mark.anyio +async def test_deny_once_adds_synthetic_tool_call() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + assert chat is not None + pilot.app.post_message(WsEvent({"type": "tool_started", "tool": "filesystem", "args": {"action": "delete", "path": "/tmp/x"}})) + await pilot.pause() + dialog = pilot.app.screen_stack[-1] + assert isinstance(dialog, PermissionDialogScreen) + await pilot.click("#deny_once") + await pilot.pause() + assert any(item.kind == "tool_call" for item in chat._model.items) diff --git a/tests/clients/test_permissions.py b/tests/clients/test_permissions.py new file mode 100644 index 0000000..e6c3711 --- /dev/null +++ b/tests/clients/test_permissions.py @@ -0,0 +1,53 @@ +"""Tests for the permission engine.""" + +from __future__ import annotations + +from pathlib import Path + +from clients.terminal.tui.permissions import PermissionEngine + + +def test_default_rules_match_destructive_filesystem() -> None: + engine = PermissionEngine(store_path=Path("/dev/null")) + assert engine.check("filesystem", {"action": "delete", "path": "x.txt"}) is not None + assert engine.check("filesystem", {"action": "read", "path": "x.txt"}) is None + + +def test_default_rules_match_terminal_rm() -> None: + engine = PermissionEngine(store_path=Path("/dev/null")) + assert engine.check("terminal", {"command": "rm -rf /tmp/x"}) is not None + assert engine.check("terminal", {"command": "ls /tmp"}) is None + + +def test_default_rules_match_code_exec_and_ssh_exec() -> None: + engine = PermissionEngine(store_path=Path("/dev/null")) + assert engine.check("code_exec", {"language": "python", "code": "print(1)"}) is not None + assert engine.check("ssh_exec", {"host": "server", "command": "uptime"}) is not None + + +def test_default_rules_match_shell_command() -> None: + engine = PermissionEngine(store_path=Path("/dev/null")) + assert engine.check("shell", {"action": "run", "command": "ls"}) is not None + + +def test_always_allow_bypasses_confirmation(tmp_path: Path) -> None: + store = tmp_path / "permissions.json" + engine = PermissionEngine(store_path=store) + engine.set_always_allow("terminal", {"command": "rm -rf /tmp/x"}) + assert engine.check("terminal", {"command": "rm -rf /tmp/x"}) is None + + +def test_always_deny_is_detected_without_rule(tmp_path: Path) -> None: + store = tmp_path / "permissions.json" + engine = PermissionEngine(store_path=store) + engine.set_always_deny("terminal", {"command": "rm -rf /tmp/x"}) + assert engine.is_always_deny("terminal", {"command": "rm -rf /tmp/x"}) is True + assert engine.check("terminal", {"command": "rm -rf /tmp/x"}) is None + + +def test_extract_target_for_tools() -> None: + engine = PermissionEngine(store_path=Path("/dev/null")) + assert engine.extract_target("filesystem", {"path": "/tmp/x"}) == "/tmp/x" + assert engine.extract_target("filesystem", {"destination": "/tmp/y"}) == "/tmp/y" + assert engine.extract_target("terminal", {"command": "ls"}) == "ls" + assert engine.extract_target("ssh_exec", {"host": "h1"}) == "h1" diff --git a/tests/clients/test_shell_runner.py b/tests/clients/test_shell_runner.py new file mode 100644 index 0000000..9d138d1 --- /dev/null +++ b/tests/clients/test_shell_runner.py @@ -0,0 +1,60 @@ +"""Tests for ! shell command runner.""" + +from __future__ import annotations + +from pathlib import Path + + +from clients.terminal.tui.shell_runner import run_shell_command, MAX_OUTPUT_LINES + + +def test_run_simple_command() -> None: + result = run_shell_command("!echo hello") + assert result.returncode == 0 + assert "hello" in result.stdout + assert result.stderr == "" + + +def test_run_with_stderr() -> None: + result = run_shell_command("!echo error >&2") + assert result.returncode == 0 + assert "error" in result.stderr + + +def test_run_failing_command() -> None: + result = run_shell_command("!false") + assert result.returncode == 1 + assert "✗" in result.summary() + + +def test_run_piped_command() -> None: + result = run_shell_command("!echo hi | tr a-z A-Z") + assert result.returncode == 0 + assert "HI" in result.stdout + + +def test_run_timeout() -> None: + result = run_shell_command("!sleep 5", timeout=0.1) + assert result.returncode == 124 + assert "timed out" in result.stderr + + +def test_empty_command() -> None: + result = run_shell_command("!") + assert result.returncode == 1 + + +def test_truncate_long_output() -> None: + result = run_shell_command(f"!seq 1 {MAX_OUTPUT_LINES + 50}") + assert result.truncated is True + assert "lines truncated" in result.stdout + assert len(result.stdout.splitlines()) <= MAX_OUTPUT_LINES + 1 + + +def test_run_in_cwd(tmp_path: Path) -> None: + sub = tmp_path / "sub" + sub.mkdir() + (sub / "file.txt").write_text("data", encoding="utf-8") + result = run_shell_command("!cat file.txt", cwd=sub) + assert result.returncode == 0 + assert result.stdout.strip() == "data" diff --git a/tests/clients/test_terminal_client.py b/tests/clients/test_terminal_client.py new file mode 100644 index 0000000..b1074a1 --- /dev/null +++ b/tests/clients/test_terminal_client.py @@ -0,0 +1,88 @@ +"""Tests for the Navi Code terminal client.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from clients.terminal.cli import main +from clients.terminal.config import Settings +from clients.terminal.render import Renderer +from clients.terminal.state import StateManager + + +class TestStateManager: + def test_load_missing_returns_empty(self, tmp_path: Path) -> None: + mgr = StateManager(tmp_path) + assert mgr.load() == {} + assert mgr.get_session_id() is None + + def test_roundtrip_session_id(self, tmp_path: Path) -> None: + mgr = StateManager(tmp_path) + mgr.set_session_id("sess-123") + assert mgr.get_session_id() == "sess-123" + assert (tmp_path / "state.json").exists() + data = json.loads((tmp_path / "state.json").read_text()) + assert data == {"session_id": "sess-123"} + + def test_clear_session_id(self, tmp_path: Path) -> None: + mgr = StateManager(tmp_path) + mgr.set_session_id("sess-123") + mgr.clear_session_id() + assert mgr.get_session_id() is None + + +class TestRenderer: + def test_stream_delta_prints_inline(self, capsys) -> None: + renderer = Renderer() + renderer.render({"type": "stream_delta", "delta": "hello"}) + captured = capsys.readouterr() + assert "hello" in captured.out + + def test_error_prints_red(self, capsys) -> None: + renderer = Renderer() + renderer.render({"type": "error", "message": "boom"}) + captured = capsys.readouterr() + assert "boom" in captured.out + + def test_tool_started_shown_when_events_enabled(self, capsys) -> None: + renderer = Renderer(show_events=True) + renderer.render({"type": "tool_started", "tool": "terminal", "args": {"cmd": "ls"}}) + captured = capsys.readouterr() + assert "terminal" in captured.out + + def test_tool_started_hidden_when_events_disabled(self, capsys) -> None: + renderer = Renderer(show_events=False) + renderer.render({"type": "tool_started", "tool": "terminal", "args": {"cmd": "ls"}}) + captured = capsys.readouterr() + assert captured.out == "" + + +class TestSettings: + def test_websocket_url_converts_http_to_ws(self) -> None: + s = Settings(base_url="http://localhost:8000") + assert s.websocket_url("abc") == "ws://localhost:8000/ws/sessions/abc" + + def test_websocket_url_converts_https_to_wss(self) -> None: + s = Settings(base_url="https://navi.example.com") + assert s.websocket_url("abc") == "wss://navi.example.com/ws/sessions/abc" + + def test_websocket_url_uses_explicit_ws_url(self) -> None: + s = Settings(ws_url="ws://custom:9000") + assert s.websocket_url("abc") == "ws://custom:9000/ws/sessions/abc" + + +class TestCliRunner: + def test_help_shows_usage(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "Navi Code" in result.output + + def test_version_shows_version(self) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert "0.1.0" in result.output diff --git a/tests/clients/test_terminal_ws.py b/tests/clients/test_terminal_ws.py new file mode 100644 index 0000000..911c1b4 --- /dev/null +++ b/tests/clients/test_terminal_ws.py @@ -0,0 +1,49 @@ +"""WebSocket integration tests for the terminal client.""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +import websockets + +from clients.terminal.config import Settings +from clients.terminal.render import Renderer +from clients.terminal.ws_client import NaviWebSocketClient + + +async def fake_navi_server(websocket: websockets.ServerConnection) -> None: + """Fake Navi server: echo one message back as stream events.""" + raw = await websocket.recv() + msg = json.loads(raw) + assert msg["type"] == "message" + + events = [ + {"type": "session_sync", "session_id": "test-session", "profile_id": "navi_code"}, + {"type": "stream_start"}, + {"type": "stream_delta", "delta": "Echo: "}, + {"type": "stream_delta", "delta": msg["content"]}, + {"type": "stream_end", "content": f"Echo: {msg['content']}"}, + ] + for ev in events: + await websocket.send(json.dumps(ev)) + + +@pytest.mark.anyio +async def test_run_one_shot_receives_and_renders_events() -> None: + stop_event = asyncio.Event() + + async def serve() -> None: + async with websockets.serve(fake_navi_server, "127.0.0.1", 0) as server: + port = server.sockets[0].getsockname()[1] + settings = Settings(base_url=f"http://127.0.0.1:{port}") + renderer = Renderer() + client = NaviWebSocketClient("test-session", renderer=renderer) + client.url = settings.websocket_url("test-session") + + await client.run_one_shot("hello") + stop_event.set() + + await asyncio.wait_for(serve(), timeout=10.0) + assert stop_event.is_set() diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py new file mode 100644 index 0000000..d4f4b0f --- /dev/null +++ b/tests/clients/test_tui_app.py @@ -0,0 +1,88 @@ +"""Smoke tests for the Navi Code TUI.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from clients.terminal.tui.tui_app import NaviCodeTui + + +@pytest.fixture(autouse=True) +def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Override the state dir so tests never touch ~/.navi_code.""" + from clients.terminal import config + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + import clients.terminal.tui.settings as settings_module + + settings_module._tui_settings = None + yield tmp_path + config.settings.state_dir = original + settings_module._tui_settings = None + + +@pytest.mark.anyio +async def test_tui_mounts_widgets() -> None: + """The TUI app mounts chat, status, and input widgets.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + assert pilot.app.query_one("ChatPanel") is not None + assert pilot.app.query_one("StatusPanel") is not None + assert pilot.app.query_one("SessionsPanel") is not None + assert pilot.app.query_one("InputBox") is not None + + +@pytest.mark.anyio +async def test_user_message_appears_in_chat() -> None: + """Typing a message and submitting adds it to the chat panel.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "hello" + await pilot.press("enter") + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + assert any( + item.kind == "user_message" and item.content == "hello" for item in chat._model.items + ) + + +@pytest.mark.anyio +async def test_ws_event_renders_in_chat() -> None: + """A synthetic WebSocket stream_delta is added to the assistant response.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.handle_ws_event({"type": "stream_start"}) + chat.handle_ws_event({"type": "stream_delta", "delta": "hi"}) + await pilot.pause() + assert chat._model._current_assistant is not None + assert chat._model._current_assistant.content == "hi" + + +@pytest.mark.anyio +async def test_unknown_slash_command_shows_error() -> None: + """Unknown slash command produces an error item in chat.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "/notacommand" + await pilot.press("enter") + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + assert any(item.kind == "error" for item in chat._model.items) + + +@pytest.mark.anyio +async def test_status_panel_shows_backend_and_theme() -> None: + """Status panel displays backend URL and current theme.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + status = pilot.app.query_one("StatusPanel") + backend_text = str(status._backend.render()) + theme_text = str(status._theme.render()) + assert "Backend:" in backend_text + assert "Theme: gnexus-dark" in theme_text diff --git a/tests/clients/test_tui_export.py b/tests/clients/test_tui_export.py new file mode 100644 index 0000000..58d1316 --- /dev/null +++ b/tests/clients/test_tui_export.py @@ -0,0 +1,117 @@ +"""Tests for the /export slash command.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from clients.terminal.tui.tui_app import NaviCodeTui + + +@pytest.fixture(autouse=True) +def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Override the state dir so tests never touch ~/.navi_code.""" + from clients.terminal import config + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + import clients.terminal.tui.settings as settings_module + + settings_module._tui_settings = None + yield tmp_path + config.settings.state_dir = original + settings_module._tui_settings = None + + +@pytest.fixture +def fake_session(monkeypatch: pytest.MonkeyPatch) -> dict: + """Return a fake session payload used to test export.""" + session_id = "sess-export-1234" + + def fake_get_session(sid: str) -> dict: + if sid != session_id: + raise Exception("not found") + return { + "session_id": session_id, + "profile_id": "navi_code", + "created_at": "2026-06-23T12:00:00", + "messages": [ + {"role": "user", "content": "Hello Navi"}, + {"role": "assistant", "content": "Hello!"}, + ], + } + + def fake_create_session(profile_id: str | None = None) -> dict: + return { + "session_id": session_id, + "profile_id": profile_id or "navi_code", + } + + def fake_list_sessions() -> list[dict]: + return [fake_get_session(session_id)] + + monkeypatch.setattr("clients.terminal.api.get_session", fake_get_session) + monkeypatch.setattr("clients.terminal.api.create_session", fake_create_session) + monkeypatch.setattr("clients.terminal.api.list_sessions", fake_list_sessions) + + return fake_get_session(session_id) + + +@pytest.mark.anyio +async def test_export_command_writes_markdown( + tmp_state_dir: Path, fake_session: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + """Running /export writes a markdown file and opens the editor.""" + opened_files: list[str] = [] + + def fake_open_in_editor(path: str) -> None: + opened_files.append(path) + + monkeypatch.setattr( + "clients.terminal.tui.commands.builtin._open_in_editor", fake_open_in_editor + ) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "/export" + await pilot.press("enter") + await pilot.pause() + + export_dir = tmp_state_dir / "exports" + assert export_dir.exists() + files = list(export_dir.glob("*.md")) + assert len(files) == 1 + content = files[0].read_text(encoding="utf-8") + assert "# Navi Code Export" in content + assert "Hello Navi" in content + assert "Hello!" in content + assert "navi_code" in content + assert opened_files == [str(files[0])] + + +@pytest.mark.anyio +async def test_export_without_session_shows_error( + tmp_state_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Running /export when there is no active session shows an error.""" + + def fake_create_session(profile_id: str | None = None) -> dict: + return {"session_id": "", "profile_id": profile_id or "navi_code"} + + def fake_get_session(sid: str) -> dict: + raise Exception("not found") + + monkeypatch.setattr("clients.terminal.api.create_session", fake_create_session) + monkeypatch.setattr("clients.terminal.api.get_session", fake_get_session) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app._ctx.session_id = None + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "/export" + await pilot.press("enter") + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + assert any(item.kind == "error" for item in chat._model.items) diff --git a/tests/clients/test_tui_sessions_panel.py b/tests/clients/test_tui_sessions_panel.py new file mode 100644 index 0000000..3db0b82 --- /dev/null +++ b/tests/clients/test_tui_sessions_panel.py @@ -0,0 +1,74 @@ +"""Tests for the SessionsPanel widget and session switching.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from clients.terminal.tui.events import SessionInfo, SessionListUpdated +from clients.terminal.tui.tui_app import NaviCodeTui + + +@pytest.fixture(autouse=True) +def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Override the state dir so tests never touch ~/.navi_code.""" + from clients.terminal import config + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + import clients.terminal.tui.settings as settings_module + + settings_module._tui_settings = None + yield tmp_path + config.settings.state_dir = original + settings_module._tui_settings = None + + +@pytest.mark.anyio +async def test_sessions_panel_mounts() -> None: + """The TUI layout includes a SessionsPanel on the right.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + assert pilot.app.query_one("SessionsPanel") is not None + + +@pytest.mark.anyio +async def test_session_list_updated_populates_table() -> None: + """Posting SessionListUpdated fills the sessions table.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + panel = pilot.app.query_one("SessionsPanel") + sessions = [ + SessionInfo(id="sess-aaaa-1111", profile_id="navi_code", title="First", created_at=""), + SessionInfo(id="sess-bbbb-2222", profile_id="dev", title="Second", created_at=""), + ] + pilot.app.post_message(SessionListUpdated(sessions, "sess-aaaa-1111")) + await pilot.pause() + table = panel._table + assert table is not None + assert table.row_count == 2 + row = table.get_row_at(0) + assert "sess-aaaa" in str(row) + assert "First" in str(row) + + +@pytest.mark.anyio +async def test_data_table_row_selects_session() -> None: + """Selecting a row in the sessions panel switches the active session.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + panel = pilot.app.query_one("SessionsPanel") + sessions = [ + SessionInfo(id="sess-aaaa-1111", profile_id="navi_code", title="First", created_at=""), + SessionInfo(id="sess-bbbb-2222", profile_id="dev", title="Second", created_at=""), + ] + pilot.app.post_message(SessionListUpdated(sessions, "sess-aaaa-1111")) + await pilot.pause() + + table = panel._table + table.action_cursor_down() + await pilot.pause() + table.action_select_cursor() + await pilot.pause() + assert pilot.app._ctx.session_id == "sess-bbbb-2222" diff --git a/tests/clients/test_tui_settings.py b/tests/clients/test_tui_settings.py new file mode 100644 index 0000000..ce4032a --- /dev/null +++ b/tests/clients/test_tui_settings.py @@ -0,0 +1,85 @@ +"""Tests for persistent TUI settings.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from clients.terminal.tui.settings import TuiSettings, get_tui_settings, reload_tui_settings + + +@pytest.fixture +def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Override the state dir so tests never touch ~/.navi_code.""" + from clients.terminal import config + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + yield tmp_path + config.settings.state_dir = original + + +@pytest.fixture(autouse=True) +def reset_cached_settings(monkeypatch: pytest.MonkeyPatch) -> None: + """Clear the module-level cache before each test.""" + import clients.terminal.tui.settings as settings_module + + settings_module._tui_settings = None + + +def test_default_settings() -> None: + s = TuiSettings() + assert s.theme == "gnexus-dark" + assert s.mouse is True + assert s.scroll_speed == 1 + assert s.diff_style == "unified" + assert s.keybinds == {} + + +def test_settings_save_and_load(tmp_state_dir: Path) -> None: + s = TuiSettings() + s.theme = "gnexus-light" + s.mouse = False + s.scroll_speed = 3 + s.save() + + loaded = TuiSettings().load() + assert loaded.theme == "gnexus-light" + assert loaded.mouse is False + assert loaded.scroll_speed == 3 + + +def test_settings_migrates_unknown_fields(tmp_state_dir: Path) -> None: + data: dict[str, Any] = { + "theme": "gnexus-light", + "future_field": "ignored", + } + (tmp_state_dir / "tui.json").write_text(json.dumps(data), encoding="utf-8") + loaded = TuiSettings().load() + assert loaded.theme == "gnexus-light" + assert loaded.mouse is True # default preserved + + +def test_settings_load_creates_default_file(tmp_state_dir: Path) -> None: + loaded = TuiSettings().load() + file_path = tmp_state_dir / "tui.json" + assert file_path.exists() + data = json.loads(file_path.read_text(encoding="utf-8")) + assert data["theme"] == "gnexus-dark" + assert loaded.theme == "gnexus-dark" + + +def test_get_tui_settings_caches(monkeypatch: pytest.MonkeyPatch, tmp_state_dir: Path) -> None: + first = get_tui_settings() + first.theme = "custom-theme" + first.save() + second = get_tui_settings() + assert second is first + assert second.theme == "custom-theme" + + fresh = reload_tui_settings() + assert fresh is not first + assert fresh.theme == "custom-theme" diff --git a/tests/clients/test_tui_themes.py b/tests/clients/test_tui_themes.py new file mode 100644 index 0000000..ae134b7 --- /dev/null +++ b/tests/clients/test_tui_themes.py @@ -0,0 +1,65 @@ +"""Tests for theme picker and /themes command.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from clients.terminal.tui.tui_app import NaviCodeTui + + +@pytest.fixture(autouse=True) +def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Override the state dir so tests never touch ~/.navi_code.""" + from clients.terminal import config + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + import clients.terminal.tui.settings as settings_module + + settings_module._tui_settings = None + yield tmp_path + config.settings.state_dir = original + settings_module._tui_settings = None + + +@pytest.mark.anyio +async def test_themes_command_opens_picker() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "/themes" + await pilot.press("enter") + await pilot.pause() + assert pilot.app.screen.__class__.__name__ == "ThemePickerScreen" + + +@pytest.mark.anyio +async def test_themes_command_switches_theme() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "/themes" + await pilot.press("enter") + await pilot.pause() + assert pilot.app.screen.__class__.__name__ == "ThemePickerScreen" + await pilot.press("down") + await pilot.press("enter") + await pilot.pause() + assert pilot.app._theme_name == "gnexus-light" + + +@pytest.mark.anyio +async def test_mouse_command_toggles_setting() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + from clients.terminal.tui.settings import get_tui_settings + + tui_settings = get_tui_settings() + original = tui_settings.mouse + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "/mouse" + await pilot.press("enter") + await pilot.pause() + assert tui_settings.mouse is not original diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/integration/__init__.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 29db170..7b9e693 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,15 +1,11 @@ """Integration test fixtures — FastAPI app with mocked dependencies.""" -from typing import AsyncGenerator - import pytest from fastapi.testclient import TestClient from navi.auth import User -from navi.core.events import StreamEnd, TextDelta from navi.core.registry import BackendRegistry -from navi.core.session import InMemorySessionStore, Session -from navi.llm.base import Message +from navi.core.session import InMemorySessionStore from tests.conftest_factory import FakeLLMBackend, make_profile_registry, make_registry_with_tools @@ -66,6 +62,8 @@ # Build container directly — no more module-level singletons from navi.core.container import AppContainer + from navi.core.orchestrator import AgentSessionOrchestrator + container = AppContainer( database=None, memory_store=None, @@ -79,6 +77,7 @@ workers=[], mcp_manager=None, ) + container.orchestrator = AgentSessionOrchestrator(container) fake_agent = FakeAgent() container._agent = fake_agent diff --git a/tests/integration/test_auth_disabled.py b/tests/integration/test_auth_disabled.py index d4be0e5..8ab45ea 100644 --- a/tests/integration/test_auth_disabled.py +++ b/tests/integration/test_auth_disabled.py @@ -3,7 +3,6 @@ import pytest from fastapi.testclient import TestClient -from navi.auth import User from navi.core.registry import BackendRegistry from navi.core.session import InMemorySessionStore from navi.main import app @@ -28,6 +27,15 @@ store = InMemorySessionStore() profiles = make_profile_registry() + # Ensure the navi_code profile is available for default-profile tests. + from navi.profiles import ALL_PROFILES + + if "navi_code" not in profiles._profiles: + for p in ALL_PROFILES: + if p.id == "navi_code": + profiles.register(p) + break + tools = make_registry_with_tools() backends = BackendRegistry() backends.register("ollama", FakeLLMBackend()) @@ -115,6 +123,33 @@ sessions = response.json() assert any(s["session_id"] == session_id for s in sessions) + @pytest.mark.anyio + async def test_create_session_uses_default_profile(self, monkeypatch, no_auth_client): + """When navi_default_profile_id is set, POST /sessions with no body uses it.""" + import navi.config as _config + from navi.api.routes import sessions as sessions_mod + + new_settings = _config.Settings( + database_url="postgresql://fake", + navi_auth_enabled=False, + navi_default_profile_id="navi_code", + ) + monkeypatch.setattr("navi.config.settings", new_settings) + monkeypatch.setattr("navi.auth.deps.settings", new_settings) + monkeypatch.setattr("navi.api.routes.auth.settings", new_settings) + monkeypatch.setattr(sessions_mod, "settings", new_settings) + + client, store = no_auth_client + response = client.post("/sessions", json={}) + assert response.status_code == 201 + data = response.json() + assert "session_id" in data + assert data["profile_id"] == "navi_code" + + session = await store.get(data["session_id"]) + assert session is not None + assert session.profile_id == "navi_code" + class TestNoAuthOAuthDisabled: def test_login_rejected_when_auth_disabled(self, no_auth_client): diff --git a/tests/integration/test_websocket.py b/tests/integration/test_websocket.py index b701f3b..8970e9c 100644 --- a/tests/integration/test_websocket.py +++ b/tests/integration/test_websocket.py @@ -4,48 +4,43 @@ import json import pytest -from fastapi.testclient import TestClient from navi.core.events import StreamEnd, TextDelta -from navi.llm.base import Message -class FakeAgent: - """Deterministic agent for WebSocket tests.""" +def _get_orchestrator(mock_deps): + from navi.main import app - def __init__(self, stream_events=None, run_response="Hello") -> None: - self._stream_events = stream_events or [] - self._run_response = run_response - - async def run(self, session_id: str, user_message: str, images=None) -> str: - return self._run_response - - async def run_stream(self, session_id, user_message, images=None, display_message=None): - for ev in self._stream_events: - yield ev + return app.state.container.orchestrator @pytest.fixture(autouse=True) -def _clear_runs(monkeypatch): - """Clear the module-level _runs dict before every WS test.""" - import navi.api.websocket as ws_mod - - ws_mod._runs.clear() +def _clear_runs(mock_deps): + """Clear orchestrator state before every WS test.""" + orchestrator = _get_orchestrator(mock_deps) + for session_id in list(orchestrator._sessions.keys()): + state = orchestrator._sessions.get(session_id) + if state and state.run and state.run.task: + state.run.task.cancel() + orchestrator._sessions.clear() + orchestrator._session_locks.clear() yield @pytest.fixture def fake_agent_ws(monkeypatch, mock_deps): - """Patch Agent in websocket module so handlers use FakeAgent.""" - import navi.api.websocket as ws_mod + """Patch orchestrator.run_agent so it broadcasts deterministic events.""" + orchestrator = _get_orchestrator(mock_deps) - events = [ - TextDelta(delta="Hello"), - StreamEnd(full_content="Hello"), - ] - fake = FakeAgent(stream_events=events) - monkeypatch.setattr(ws_mod, "Agent", lambda *a, **kw: fake) - return fake + async def fake_run_agent(session_id, user_content, raw_images, display_content, files, session_store): + run = orchestrator.get_run(session_id) + if run is None: + return + await run.broadcast(("event", TextDelta(delta="Hello"))) + await run.broadcast(("event", StreamEnd(full_content="Hello"))) + + monkeypatch.setattr(orchestrator, "run_agent", fake_run_agent) + return fake_run_agent class TestWebSocketConnect: @@ -75,19 +70,17 @@ assert any(m.get("type") == "stream_end" for m in msgs) @pytest.mark.anyio - async def test_reconnect_replay(self, client, make_session, monkeypatch): + async def test_reconnect_replay(self, client, make_session, mock_deps): """Reconnect while a run is active — replay buffer should emit past events.""" - import navi.api.websocket as ws_mod - + orchestrator = _get_orchestrator(mock_deps) session = await make_session("secretary") # Inject an active run with buffered events - run = ws_mod._AgentRun() + run = orchestrator.create_run(session.id) run.events = [ {"type": "stream_start"}, {"type": "stream_delta", "delta": "hello"}, ] - ws_mod._runs[session.id] = run with client.websocket_connect(f"/ws/sessions/{session.id}") as ws: msgs = _collect_until_done(ws, max_messages=5) @@ -99,9 +92,7 @@ assert "replay_end" in types # Clean up injected run - ws_mod._runs.pop(session.id, None) - if run.task: - run.task.cancel() + orchestrator._sessions.pop(session.id, None) @pytest.mark.anyio async def test_invalid_json(self, client, make_session): @@ -128,7 +119,7 @@ class TestStopSession: @pytest.mark.anyio - async def test_stop_no_active_run(self, client, make_session): + async def test_stop_no_active_run(self, client, make_session, mock_deps): session = await make_session("secretary") response = client.post(f"/sessions/{session.id}/stop") assert response.status_code == 200 @@ -136,15 +127,13 @@ assert data["ok"] is False @pytest.mark.anyio - async def test_stop_active_run(self, client, make_session, monkeypatch): - import navi.api.websocket as ws_mod - + async def test_stop_active_run(self, client, make_session, mock_deps): + orchestrator = _get_orchestrator(mock_deps) session = await make_session("secretary") # Start a long-running agent task in background - run = ws_mod._AgentRun() + run = orchestrator.create_run(session.id) run.task = asyncio.create_task(asyncio.sleep(10)) - ws_mod._runs[session.id] = run response = client.post(f"/sessions/{session.id}/stop") assert response.status_code == 200 @@ -156,6 +145,7 @@ await run.task except asyncio.CancelledError: pass + orchestrator._sessions.pop(session.id, None) # ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/unit/__init__.py diff --git a/tests/unit/api/__init__.py b/tests/unit/api/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/unit/api/__init__.py