diff --git a/.env.navi_code.example b/.env.navi_code.example new file mode 100644 index 0000000..7f11841 --- /dev/null +++ b/.env.navi_code.example @@ -0,0 +1,80 @@ +# ── LLM: Ollama (primary) ──────────────────────────────────────────────────── +OLLAMA_HOST=http://localhost:11434 +OLLAMA_API_KEY= +OLLAMA_DEFAULT_MODEL=gemma4:26b-a4b-it-q4_K_M +OLLAMA_NUM_CTX=65536 +OLLAMA_THINK=true +OLLAMA_REQUEST_TIMEOUT=30 + +# Multi-server fallback: path to JSON file [{host, api_key?}, ...] +# When set, overrides OLLAMA_HOST / OLLAMA_API_KEY. +# OLLAMA_BACKENDS_FILE=ollama_backends.json + +# ── LLM: OpenAI (optional) ─────────────────────────────────────────────────── +OPENAI_API_KEY= +# OPENAI_MODEL=gpt-4 +# OPENAI_BASE_URL=https://api.openai.com/v1 + +ANTHROPIC_API_KEY= + +# ── Database (PostgreSQL 15+ with pgvector) ────────────────────────────────── +DATABASE_URL=postgresql://navi:navipass@localhost:5432/navidb + +# ── Embedding (Ollama) ───────────────────────────────────────────────────────── +# When empty, falls back to OLLAMA_HOST. +EMBEDDING_OLLAMA_HOST= +EMBEDDING_OLLAMA_API_KEY= +EMBEDDING_MODEL=nomic-embed-text:latest + +# ── Filesystem / Terminal / SSH ────────────────────────────────────────────── +FS_ALLOWED_PATHS=* +TERMINAL_ALLOWED_COMMANDS=* +SSH_HOSTS_FILE=ssh_hosts.json + +# ── Session files ────────────────────────────────────────────────────────────── +SESSION_FILES_DIR=session_files +SESSION_FILES_MAX_SIZE_MB=200 + +# ── Context compression ──────────────────────────────────────────────────────── +CONTEXT_COMPRESSION_ENABLED=true +CONTEXT_COMPRESSION_THRESHOLD=0.70 +CONTEXT_KEEP_RECENT=8 +CONTEXT_SUMMARY_TEMPERATURE=0.3 +CONTEXT_SUMMARY_MAX_TOKENS=3000 + +# ── Logging ──────────────────────────────────────────────────────────────────── +LOG_LEVEL=INFO + +# ── Public URL (used by share_file for download links) ─────────────────────── +PUBLIC_URL=http://localhost:8000 + +# ── Persona ───────────────────────────────────────────────────────────────────── +# For Navi Code, we use the specialized persona file. +NAVI_PERSONA_FILE=persona_navi_code.txt +# NAVI_PERSONA="You are Navi, a loyal assistant..." + +# ── User tools ───────────────────────────────────────────────────────────────── +TOOLS_DIR=tools + +# ── Eval system (optional) ───────────────────────────────────────────────────── +# EVAL_DATA_DIR=debug/eval + +# ── Auth switch ────────────────────────────────────────────────────────────────── +# Set to false for local terminal mode. Every request is then +# treated as the anonymous admin user. +NAVI_AUTH_ENABLED=false + +# ── Default Profile ────────────────────────────────────────────────────────────── +# The profile used when no profile_id is specified during session creation. +NAVI_DEFAULT_PROFILE_ID=navi_code + +# ── gnexus-auth OAuth ──────────────────────────────────────────────────────────── +GNAUTH_BASE_URL=https://auth.your-domain.com +GNAUTH_CLIENT_ID= +GNAUTH_CLIENT_SECRET= +GNAUTH_REDIRECT_URI=https://navi.your-domain.com/auth/callback +# GNAUTH_PROFILE_PATH=/account/profile # path appended to base URL for profile links + +# ── Auth encryption ────────────────────────────────────────────────────────────── +# Fernet key for encrypting tokens in DB. +NAVI_AUTH_ENCRYPTION_KEY= diff --git a/.gitignore b/.gitignore index 7466564..96d6a21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,22 @@ -ssh_hosts.json -ollama_backends.json -.tmp -task_manager.json -tools/*_data.json -session_files/ -workspace/* -!workspace/.keep +CLAUDE.md +.env .venv/ __pycache__/ *.pyc *.pyo -.env -.mypy_cache/ -.ruff_cache/ -dist/ -!webclient/dist/ +*.pyd +.Python +*.so +*.egg *.egg-info/ -webclient/node_modules/ - -# Local runtime artifacts -.codex +dist/ +build/ *.db +navi.db +webclient/navi.db +debug/eval/navi.db +session_files/ +workspace/ +.tmp +.codex +ollama_backends.json diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 5f9a806..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,169 +0,0 @@ -# Navi — Project Context for Claude - -## Working rules - -- **No unapproved steps.** Before implementing a change, propose the approach and wait for user confirmation. Do not make architectural decisions, add dependencies, or modify external interfaces without explicit approval. -- **Check external libraries first.** When a task involves a third-party library (e.g. `gnexus-auth-client-py`), read the library source/docs before assuming behavior or inventing functionality. - -## What this is -Personal modular AI agent system. FastAPI backend + vanilla JS client. The agent is named Navi — female, loyal, uncensored personal assistant. Runs locally via Ollama. - -## Stack -- **Backend**: FastAPI, uvicorn, asyncpg, structlog, pydantic-settings -- **LLM**: Ollama (primary, cloud + local fallback), OpenAI-compatible backend also wired -- **Current models**: `gemma4:31b-cloud` (primary, Ollama Cloud), `gemma4:26b-a4b-it-q4_K_M` (local fallback) -- **Multi-server fallback**: `OLLAMA_BACKENDS_FILE=ollama_backends.json` — ordered list of servers; profiles use a model priority list; dead servers/models are blacklisted in-memory until restart -- **Thinking**: `ollama_think: bool = True` — model reasoning is enabled and streamed to client -- **Client**: Vanilla JS ES modules, marked.js + highlight.js via esm.sh CDN -- **DB**: PostgreSQL (asyncpg) for persistent sessions, memory, and scheduler -- **Run**: `.venv/bin/uvicorn navi.main:app --reload --reload-dir navi --port 8000` - -## Key architecture - -### Agent loop (`navi/core/agent.py`) -Tool-calling loop with `llm.complete()` for tool turns, `llm.stream()` for final response. -Events yielded: `ToolEvent`, `ThinkingDelta`, `ThinkingEnd`, `TextDelta`, `StreamEnd`. -Tool schemas built fresh on every `run_stream()` call from registry + `tools/enabled.json`. - -### Tool system -Two tiers: - -**Built-in tools** (`navi/tools/`): -- `web_search`, `filesystem`, `http_request`, `code_exec`, `terminal`, `ssh_exec`, `image_view` -- `write_tool` — Navi's primary self-extension mechanism (writes + reloads in one call) -- `reload_tools` — hot-reload all user tools without server restart -- `list_tools` — returns actual live tool list from registry (Navi calls this when asked what she can do) -- `tool_manual` — returns `manuals/.md` if exists, else auto-generates from schema - -**User tools** (`tools/*.py`): -- Written by Navi via `write_tool`, or manually -- Module-level format: `name`, `description`, `parameters`, `async def execute(params) -> str` -- No classes, no module-level print(), execute must return plain string or raise -- Auto-discovered at startup and on reload -- `tools/enabled.json` — list of user tool names to auto-include in all profiles -- `tools/_template.py` — canonical format reference (starts with `_`, not auto-loaded) - -Currently created by Navi: `get_current_datetime.py`, `user_notes.py` (working, correct format). - -### Profiles (`navi/profiles/`) -`secretary`, `server_admin`, `developer`. Each has `enabled_tools`, `system_prompt`, `model`, `temperature`, `max_iterations`. -All profiles have the same built-in tool set: `[..., reload_tools, write_tool, list_tools, tool_manual]`. -User tools from `enabled.json` are merged in by `Agent._tool_list()`. - -#### Thinking mechanics (per-profile flags in `config.json`) -Every autonomous-reasoning feature is gated by a flag on `AgentProfile`. New mechanics always add a flag first. -Full reference: `docs/profiles.md`. - -| Flag | Default | Purpose | -|------|---------|---------| -| `think_enabled` | `true` | Extended LLM reasoning on every call | -| `planning_enabled` | `false` | Run planning pipeline on every message (first-message always runs it) | -| `planning_mandatory` | `false` | `true` = DIRECT shortcut disabled, all phases always run | -| `planning_phase1_enabled` | `true` | Phase 1: task analysis | -| `planning_phase2_enabled` | `false` | Phase 2: 3-advisor review (adds ~3 LLM calls) | -| `planning_phase3_enabled` | `true` | Phase 3: structured execution plan | -| `iteration_budget_enabled` | `true` | Injects remaining iterations into context | -| `goal_anchoring_enabled` | `true` | Goal reminder every N iterations | -| `goal_anchoring_interval` | `5` | N for goal anchoring | -| `anti_stall_enabled` | `true` | Stall detector (N iterations without todo progress) | -| `anti_stall_threshold` | `8` | Stall threshold in iterations | -| `step_validation_enabled` | `false` | LLM check after each todo step (adds latency) | -| `adaptive_replan_enabled` | `false` | Re-planning on step failure | - -Low-latency profiles (e.g. future `smart_home`) can disable expensive flags; deep-reasoning profiles keep everything on. - -### Global persona (`navi/config.py` → `.env`) -`NAVI_PERSONA` env var — prepended to every profile's system prompt separated by `---`. -Contains: personality, self-extension instructions, `write_tool` usage rules, `tool_manual` usage. - -### Registry (`navi/core/registry.py`) -`ToolRegistry` tracks `_builtin_names` to distinguish builtins from user tools on reload. -`reload_user_tools()` drops all non-builtins and reloads from disk. -Built-in tools with registry injection: `ReloadToolsTool`, `WriteToolTool`, `ListToolsTool`, `ToolManualTool`. - -### Sessions (`navi/core/pg_session_store.py`) -Persistent PostgreSQL sessions. `model_dump(mode='json')` required for datetime serialization. -Session ID in URL hash for bookmarking. - -### WebSocket protocol (`navi/api/websocket.py`) -``` -client → server: {type: "message", content: "...", images: [...]} -server → client: stream_start - thinking_delta {delta} ← reasoning chunks (collapsible in UI) - thinking_end - tool_call {tool, args, result, success} - stream_delta {delta} - stream_end {content} - error {message} -``` - -### Client (`client/`) -ES modules: `app.js` (state/routing), `chat.js` (DOM helpers), `ws.js` (WebSocket), `api.js` (REST), `sidebar.js`. -Thinking blocks: open during reasoning, auto-collapse on `thinking_end`, re-openable (like tool cards). -Tool cards: accordion, collapsed by default, click to expand. -Images: paste/attach, base64 via FileReader, rendered in bubbles. -No localStorage — session from URL hash or most recent server session. - -### Dynamic tool loading (`navi/tools/loader.py`) -Tries module-level format first (preferred for user tools), falls back to class-based. -Errors isolated per file — one broken file doesn't affect others. -Detailed error messages: lists exactly which required definitions are missing. - -### Context providers (`navi/context_providers/`, `context_providers/`) -Inject dynamic runtime data as `role="system"` messages on every LLM call (before conversation history). -Module format: `name`, `description`, `global_provider: bool`, `async def get_context() -> str | None`. -`global_provider=True` → injected in all profiles. `False` → opt-in via `context_providers: [...]` in profile config. -Built-in: `public_url` (always injects `PUBLIC_URL` so Navi knows her own address). -Hot-reloaded by `reload_tools`. Navi uses `tool_manual("write_context_provider")` before writing one. -Full reference: `docs/context_providers.md`. - -## Config (`.env`) -``` -NAVI_PERSONA="..." # global personality + tool writing rules -OLLAMA_HOST=... -OLLAMA_DEFAULT_MODEL=gemma4:e2b-it-q8_0 -OLLAMA_NUM_CTX=8192 -OLLAMA_THINK=true -``` - -## Manuals (`manuals/`) -Markdown files, one per tool. `tool_manual` serves them on demand. -Currently: `manuals/write_tool.md` (full format reference + working example). -Auto-generation fallback from tool schema if no .md exists. - -## Important patterns -- `write_tool` validates code before writing (checks for 4 required definitions) -- `write_tool` adds tool to `tools/enabled.json` on success → available in all profiles -- New tool available from the **next** user message (tool schemas built at `run_stream()` entry) -- Navi should call `tool_manual("write_tool")` before writing a tool -- Navi should call `list_tools` when asked about her capabilities (not generate from memory) -- `no-store` cache middleware on `/static/` — safe to hard-refresh during development - -## Documentation - -Detailed reference is in `docs/`. Read specific files when you need depth on a subsystem: - -| File | Covers | -|---|---| -| `docs/agent.md` | Agent loop, 3-phase planning, all thinking mechanics flags | -| `docs/profiles.md` | Profile fields, config flags, how to add a profile | -| `docs/tools.md` | Built-in tools, user tool format, hot-reload | -| `docs/sessions.md` | Session model, dual-buffer, context compression, debug endpoints | -| `docs/websocket.md` | Full WS protocol — all events, reconnect/replay, stop mechanism | -| `docs/memory.md` | Long-term memory — facts, extraction, search | -| `docs/api.md` | All REST + WS endpoints with request/response schemas | -| `docs/config.md` | All `.env` variables with types and defaults | -| `docs/context_providers.md` | Context providers — dynamic system message injection | -| `docs/android-client.md` | Android app — architecture, build/deploy, WebView config, platform detection | -| `docs/architecture.md` | Component diagram, data flow, registry wiring | - -## What works well -- Hot-reload without server restart -- Thinking display in client -- Self-extension via `write_tool` (improving — model still sometimes struggles with format) -- Session persistence, URL-based navigation - -## Known friction -- Small model (e2b) sometimes writes tools in wrong format despite detailed instructions -- `tool_manual` + explicit format feedback in `write_tool` errors is the current mitigation -- Navi tends to hallucinate tool lists — `list_tools` fixes this if she uses it diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..848fc7a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Eugene Sukhodolskiy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5bd5f04..2395a0d 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Модульная агентная система с REST API и WebSocket. FastAPI бэкенд, Ollama LLM, Vue 3 + Pinia клиент. +Лицензия: [MIT](LICENSE) + ## Запуск ```bash @@ -71,6 +73,48 @@ Полная спецификация: [`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`. +- **Navi работает с проектом в текущей директории:** клиент автоматически передаёт серверу рабочую папку, из которой запущен `navi-code`. Относительные пути в `filesystem`, `terminal` и `code_exec` разрешаются относительно неё. + +### Установка для запуска из любой директории + +После `pip install -e .` консольная команда `navi-code` появляется в PATH внутри активированного venv. Если хочешь вызывать `navi-code` из любой папки без активации venv, есть два варианта: + +**Вариант 1 — symlink на wrapper (рекомендуется для постоянного использования):** +```bash +ln -s /path/to/navi-1/bin/navi-code ~/.local/bin/navi-code +# или любая другая директория из $PATH +``` + +**Вариант 2 — editable install даёт entry point:** +```bash +.venv/bin/pip install -e . +# затем можно symlink и сам entry point: +ln -s /path/to/navi-1/.venv/bin/navi-code ~/.local/bin/navi-code +``` + +Wrapper в `bin/navi-code` сам находит `.venv` рядом с репозиторием и запускает клиент, передавая текущую директорию серверу. + +Подробнее: [`docs/navi_code.md`](docs/navi_code.md) и [`docs/navi_code_cli.md`](docs/navi_code_cli.md). + ## Структура ``` @@ -80,7 +124,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 +137,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 +156,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/bin/navi-code b/bin/navi-code new file mode 100755 index 0000000..23f2d08 --- /dev/null +++ b/bin/navi-code @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Wrapper script for launching the Navi Code terminal client from anywhere. +# +# Usage: +# 1. Clone the navi repository. +# 2. Create a venv and install the package: +# python -m venv .venv +# .venv/bin/pip install -e ".[dev]" +# 3. Symlink this script onto your PATH: +# ln -s /path/to/navi/bin/navi-code ~/.local/bin/navi-code +# 4. cd into any project and run: +# navi-code +# +# The wrapper locates the project root from the script's own location (resolving +# symlinks so it works when called via a PATH symlink), runs the client with the +# repo's venv python, and puts the repo root on PYTHONPATH so the `clients` +# package is importable from any cwd WITHOUT cd-ing (cd would change the cwd the +# client captures and sends to the backend, breaking per-project path resolution). + +# Resolve symlinks so the wrapper finds PROJECT_ROOT correctly even when +# invoked via a symlink on PATH (e.g. ~/.local/bin/navi-code -> .../bin/navi-code). +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +VENV_DIR="${NAVI_CODE_VENV:-${PROJECT_ROOT}/.venv}" + +if [[ -f "${VENV_DIR}/bin/python" ]]; then + export PYTHONPATH="${PROJECT_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" + exec "${VENV_DIR}/bin/python" -m clients.terminal.cli "$@" +fi + +# Fallback: run the entry point directly if the package is installed elsewhere. +exec python -m clients.terminal.cli "$@" \ No newline at end of file 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..311bd5c --- /dev/null +++ b/clients/terminal/api.py @@ -0,0 +1,101 @@ +"""REST API helpers for the terminal client. + +All helpers are ``async`` and share one ``httpx.AsyncClient`` (connection +pooling) so callers running inside the Textual event loop never block the UI on +a network round-trip — the await yields the loop while the request is in flight. +The shared client is created lazily on first use (inside a running loop) and +lives for the process; tests monkeypatch these functions wholesale, so the +client is never constructed in unit tests. +""" + +from __future__ import annotations + +import httpx + +from clients.terminal.config import settings + +# Lazily created on first use (in an async context). Reused across calls so +# HTTP connections are pooled rather than re-established per request. +_async_client: httpx.AsyncClient | None = None + + +def _client() -> httpx.AsyncClient: + global _async_client + if _async_client is None: + _async_client = httpx.AsyncClient(base_url=settings.base_url, timeout=30.0) + return _async_client + + +async def get_profiles() -> list[dict]: + client = _client() + resp = await client.get("/agents/profiles") + resp.raise_for_status() + return resp.json() + + +async def get_profile(profile_id: str) -> dict | None: + """Return the profile dict for ``profile_id`` from /agents/profiles, or None.""" + for p in await get_profiles(): + if p.get("id") == profile_id: + return p + return None + + +async def get_profile_model(profile_id: str) -> str | None: + """Configured model for a profile (first item of its priority list), or None. + + Used to show a model in the status panel before the first request resolves + the actually-served model. ``profile.model`` is a priority list; the first + entry is the intended model. + """ + profile = await get_profile(profile_id) + if not profile: + return None + model = profile.get("model") + if isinstance(model, list): + return model[0] if model else None + return model or None + + +async def list_sessions() -> list[dict]: + client = _client() + resp = await client.get("/sessions") + resp.raise_for_status() + return resp.json() + + +async def create_session(profile_id: str | None = None) -> dict: + body: dict = {} + if profile_id: + body["profile_id"] = profile_id + client = _client() + resp = await client.post("/sessions", json=body) + resp.raise_for_status() + return resp.json() + + +async def get_session(session_id: str) -> dict: + client = _client() + resp = await client.get(f"/sessions/{session_id}") + resp.raise_for_status() + return resp.json() + + +async def get_todos(session_id: str) -> dict: + """Fetch the session's current todo list (GET /sessions/{id}/todos). + + Returns ``{"session_id": ..., "tasks": [{"index", "text", "status", "validation"}, ...]}``. + Used to seed the side-panel todo on attach/switch before the first + ``todo_updated`` WS event arrives. + """ + client = _client() + resp = await client.get(f"/sessions/{session_id}/todos") + resp.raise_for_status() + return resp.json() + + +async def stop_session(session_id: str) -> dict: + client = _client() + resp = await client.post(f"/sessions/{session_id}/stop") + resp.raise_for_status() + return resp.json() \ No newline at end of file diff --git a/clients/terminal/cli.py b/clients/terminal/cli.py new file mode 100644 index 0000000..bdb10bc --- /dev/null +++ b/clients/terminal/cli.py @@ -0,0 +1,324 @@ +"""Click CLI for the Navi Code terminal client.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import TYPE_CHECKING + +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 + +if TYPE_CHECKING: + from clients.terminal.tui.tui_app import NaviCodeTui + + +@click.command( + context_settings={ + "ignore_unknown_options": True, + "allow_extra_args": True, + "help_option_names": ["-h", "--help"], + } +) +@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( + "--resume", + "resume_session_id", + default=None, + help="Resume a specific session by id (prints the id on close so you can re-run with it).", +) +@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("--mouse/--no-mouse", default=None, help="Override mouse support in the TUI.") +@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, + mouse: bool | None, + raw: bool, + resume_session_id: str | None, +) -> 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 resume_session_id and new_session: + raise click.ClickException("--resume and --new-session are mutually exclusive.") + + cwd = Path.cwd().resolve() + + if raw or prompt: + _run_raw(prompt, new_session, profile_id, cwd, resume_session_id) + return + + _run_tui(profile_id, new_session, theme, mouse, cwd, resume_session_id) + + +def _run_raw( + prompt: str | None, + new_session: bool, + profile_id: str | None, + cwd: Path, + resume_session_id: str | None, +) -> None: + asyncio.run( + _run_raw_async(prompt, new_session, profile_id, cwd, resume_session_id) + ) + + +async def _run_raw_async( + prompt: str | None, + new_session: bool, + profile_id: str | None, + cwd: Path, + resume_session_id: str | None, +) -> None: + state = StateManager() + session_id = await _resolve_session_id(state, new_session, profile_id, resume_session_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, cwd=cwd) + + if prompt: + await client.run_one_shot(prompt) + return + + await _run_interactive(client, state) + + +def _run_tui( + profile_id: str | None, + new_session: bool, + theme: str | None, + mouse: bool | None, + cwd: Path, + resume_session_id: str | None, +) -> None: + from clients.terminal.tui.tui_app import NaviCodeTui + + app = NaviCodeTui( + profile_id=profile_id, + new_session=new_session, + session_id=resume_session_id, + theme_name=theme, + cwd=cwd, + ) + if mouse is not None: + app._mouse_enabled = mouse + app.run(mouse=app._mouse_enabled) + _print_resume_hint(app) + + +def _resume_hint(session_id: str) -> str: + """The exact command a user can re-run to continue this session.""" + return f"navi-code --resume {session_id}" + + +def _print_resume_hint(app: NaviCodeTui) -> None: + """After the TUI exits, tell the user how to resume the session they had open. + + Printed here (not from on_unmount) because Textual has already restored the + terminal by the time app.run() returns, so the hint lands on clean stdout. + """ + session_id = getattr(app._ctx, "session_id", None) + if not session_id: + return + click.secho(f"Session {session_id}", fg="bright_black") + click.secho(f"Resume with: {_resume_hint(session_id)}", fg="cyan") + + +async def _resolve_session_id( + state: StateManager, + force_new: bool, + profile_id: str | None, + resume_session_id: str | None = None, +) -> str | None: + if resume_session_id: + try: + session = await api.get_session(resume_session_id) + except Exception as exc: + raise click.ClickException(f"Failed to resume session {resume_session_id[:8]}: {exc}") + state.set_session_id(session["session_id"]) + click.secho( + f"Resumed session {session['session_id'][:8]} (profile {session['profile_id']})", + fg="bright_black", + ) + return session["session_id"] + + if not force_new: + saved = state.get_session_id() + if saved: + try: + session = await api.get_session(saved) + click.secho( + f"Resumed session {session['session_id'][:8]} (profile {session['profile_id']})", + fg="bright_black", + ) + return session["session_id"] + except Exception: + state.clear_session_id() + + profile = profile_id or settings.default_profile_id + try: + session = await 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["session_id"]) + click.secho( + f"Created session {session['session_id'][:8]} (profile {session['profile_id']})", + fg="green", + ) + return session["session_id"] + + +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 = await 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["session_id"]) + click.secho( + f"Switched to new session {session['session_id'][:8]} (profile {session['profile_id']})", + fg="green", + ) + return True + + if head == "/sessions": + try: + sessions = await 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: + title = s.get("name", "") or s.get("preview", "") + click.echo(f" {s['session_id'][:8]} {s.get('profile_id', 'unknown')} {title}") + return True + + if head == "/switch" and len(parts) == 2: + target = parts[1] + try: + session = await api.get_session(target) + except Exception as exc: + # Exact id miss — try a prefix match. If there is not exactly one, + # surface the original error too (404 vs network vs server) rather + # than swallowing it into a generic "not found". + try: + sessions = await api.list_sessions() + matches = [s for s in sessions if s["session_id"].startswith(target)] + except Exception: + matches = [] + if len(matches) == 1: + session = matches[0] + else: + click.secho(f"Session not found: {target} ({exc})", fg="red", err=True) + return True + state.set_session_id(session["session_id"]) + click.secho( + f"Switched to session {session['session_id'][:8]} (profile {session['profile_id']})", + fg="green", + ) + return True + + if head == "/profile": + try: + current = await api.get_session(state.get_session_id() or "") + click.echo(f"Profile: {current.get('profile_id', 'unknown')}") + click.echo(f"Session: {current.get('session_id', 'unknown')}") + 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..46520f8 --- /dev/null +++ b/clients/terminal/render.py @@ -0,0 +1,124 @@ +"""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: + sid = msg.get("session_id") + profile_id = msg.get("profile_id") or "unknown" + sid_str = f"{sid[:8]}" if sid else "?" + self._print(f"[session {sid_str} | 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 == "turn_thinking": + if self.show_thinking: + thinking = msg.get("thinking", "") or "" + prefix = "(subagent) " if msg.get("is_subagent") else "" + self._print(f"\n{prefix}[thinking]", color="bright_black") + self._print(thinking, color="bright_black") + self._print(f"{prefix}[/thinking]", color="bright_black") + 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 msg_type == "planning_status": + if self.show_events: + label = msg.get("label", "") or "planning…" + prefix = "(subagent) " if msg.get("is_subagent") else "" + self._print(f"{prefix}[planning] {label}", color="bright_black") + return + + if msg_type == "plan_ready": + if self.show_events: + plan = msg.get("plan", "") or "" + title = "subagent plan" if msg.get("is_subagent") else "plan" + self._print(f"\n[{title}]", color="cyan") + if plan: + self._print(plan, color="bright_black") + self._print(f"[/{title}]", color="cyan") + return + + if msg_type == "model_info": + if self.show_events: + model = msg.get("model", "") or "" + if model: + self._print(f"[model] {model}", 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..984153a --- /dev/null +++ b/clients/terminal/tui/chat_model.py @@ -0,0 +1,314 @@ +"""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", + "planning_status", + "plan_ready", + "turn_meta", + "context_summary", + "recall", + ] + content: str = "" + meta: dict = field(default_factory=dict) + id: str = "" + + +class ChatModel: + """Accumulate stream deltas and tool events into ChatItems.""" + + def __init__(self, cap: int | None = 600) -> None: + self.items: list[ChatItem] = [] + # Bound on retained history so a very long autonomous session does not + # grow ``items`` without limit. The visible window (ChatPanel) is much + # smaller than this cap, so trimming the front only drops items that + # were already off-screen — the truncation hint in the panel still + # reads correctly (``hidden = len(items) - visible_limit``). None = no + # trimming (tests / small sessions). + self._cap = cap + self._current_assistant: ChatItem | None = None + self._current_thinking: ChatItem | None = None + + def _trim(self) -> None: + """Drop the oldest items past the retention cap (front of the list).""" + cap = self._cap + if cap is None: + return + excess = len(self.items) - cap + if excess > 0: + del self.items[:excess] + + def _append(self, item: ChatItem) -> ChatItem: + """Append an item and trim the front past the retention cap. + + Central append point so every grow path (live events, history replay, + user messages) keeps ``items`` bounded without each caller repeating + the trim. Returns the item for the ``return self._append(item)`` pattern. + """ + self.items.append(item) + self._trim() + return item + + def add_user_message(self, text: str) -> None: + self._append(ChatItem(kind="user_message", content=text)) + self._current_assistant = None + + def load_history(self, messages: list[dict]) -> None: + """Replace the item list with the session's stored messages. + + Maps persisted Message dicts (as returned by GET /sessions/{id}) back + into the same ChatItems the live stream produces, so a resumed session + shows its past conversation. Display-only context markers + (is_display=False: the context-only user message, compressor summaries, + compression events) are skipped — only what the user would have seen + live is rebuilt. + """ + self.items.clear() + self._current_assistant = None + self._current_thinking = None + + # tool_call_id -> tool arguments, so a later ``role=="tool"`` message can + # recover the args the assistant sent (the persisted tool message only + # carries the result, not the call args). Mirrors the live path where the + # tool_call WS event's meta already contains ``args``. + args_by_id: dict[str, dict] = {} + + for m in messages: + if not m.get("is_display", True): + continue + role = m.get("role") + if role == "user": + self._append(ChatItem(kind="user_message", content=m.get("content") or "")) + elif role == "assistant": + thinking = m.get("thinking") + if thinking: + self._append(ChatItem(kind="thinking_block", content=thinking)) + content = m.get("content") or "" + if m.get("is_plan"): + self._append(ChatItem(kind="plan_ready", content=content, meta={"is_subagent": False})) + elif content: + # Match the live stream: stream_delta (assistant text) arrives + # BEFORE tool_started, so the text bubble sits above the tool + # cards, not below them. Emitting tool_started first would strand + # the answer under its own tool calls on resume. + self._append(ChatItem(kind="assistant_message", content=content)) + for tc in m.get("tool_calls") or []: + tcid = tc.get("id") + if tcid: + args_by_id[tcid] = tc.get("arguments") or {} + self._append( + ChatItem( + kind="tool_started", + meta={"tool": tc.get("name", ""), "args": tc.get("arguments") or {}}, + ) + ) + # The whole-turn duration is stamped only on the assistant message + # that closed the run (intermediate tool-tour assistant messages get + # no elapsed_seconds), so this reproduces the single turn_meta line + # the live stream_end appends at the end of a turn. + elapsed = m.get("elapsed_seconds") + if elapsed is not None: + self._append(ChatItem(kind="turn_meta", meta={"elapsed_seconds": elapsed})) + elif role == "tool": + tcid = m.get("tool_call_id") + args = args_by_id.pop(tcid, {}) if tcid else {} + self._append( + ChatItem( + kind="tool_call", + meta={ + "tool": m.get("name") or "", + "args": args, + "result": m.get("content") or "", + "success": True, + "metadata": m.get("metadata") or {}, + }, + ) + ) + + def handle_ws_event(self, msg: dict) -> ChatItem | None: + msg_type = msg.get("type") + + if msg_type == "stream_start": + # A new turn starts. Do NOT eagerly create an assistant bubble here — + # that would place the (possibly empty) answer at the TOP, above the + # tools/thinking that follow, where scroll_end hides it. Instead reset + # the current-assistant pointer and let the first stream_delta create + # the bubble lazily, at the position where text actually arrives. + # Reset the thinking pointer too: if the previous turn broke without a + # ``thinking_end`` (dropped socket, stop without the event), a dangling + # ``_current_thinking`` would make the new turn's first ``thinking_delta`` + # append to the previous turn's reasoning block, gluing two turns' + # reasoning together. + self._current_assistant = None + self._current_thinking = None + return None + + if msg_type == "thinking_delta": + if self._current_thinking is None: + self._current_thinking = ChatItem(kind="thinking_block", content="") + self._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 == "turn_thinking": + # A complete thinking block from a (sub-agent) non-streaming turn. + # Stored as a thinking_block with an is_subagent flag so the renderer + # can style/indent it as nested sub-agent reasoning. + item = ChatItem( + kind="thinking_block", + content=msg.get("thinking", "") or "", + meta={"is_subagent": bool(msg.get("is_subagent", False))}, + ) + self._append(item) + return item + + if msg_type == "stream_delta": + if self._current_assistant is None: + self._current_assistant = ChatItem(kind="assistant_message", content="") + self._append(self._current_assistant) + self._current_assistant.content += msg.get("delta", "") + return None + + if msg_type == "tool_started": + # Drop the current assistant pointer so the next stream_delta opens a + # fresh bubble BELOW the tool cards (the final answer ends up at the + # bottom, visible after scroll_end — not stranded at the top). + self._current_assistant = None + item = ChatItem(kind="tool_started", meta=msg) + self._append(item) + return item + + if msg_type == "tool_call": + self._current_assistant = None + item = ChatItem(kind="tool_call", meta=msg) + self._append(item) + return item + + if msg_type == "error": + item = ChatItem(kind="error", content=msg.get("message", "")) + self._append(item) + return item + + if msg_type == "status": + item = ChatItem(kind="status", content=msg.get("content", "")) + self._append(item) + return item + + if msg_type == "planning_status": + label = msg.get("label", "") or "" + is_subagent = bool(msg.get("is_subagent", False)) + phase = msg.get("phase") + # Non-subagent planning is a transient rolling indicator: update the + # last pending planning line in place so the phases (Analysis → + # Execution plan → Plan review) share one line instead of stacking. + # Subagent planning is appended as its own dim line. + if not is_subagent and self.items and self.items[-1].kind == "planning_status" and not bool( + self.items[-1].meta.get("is_subagent", False) + ): + self.items[-1].content = label + self.items[-1].meta = {"phase": phase, "is_subagent": False} + return self.items[-1] + item = ChatItem( + kind="planning_status", + content=label, + meta={"phase": phase, "is_subagent": is_subagent}, + ) + self._append(item) + return item + + if msg_type == "plan_ready": + plan = msg.get("plan", "") or "" + is_subagent = bool(msg.get("is_subagent", False)) + # The transient non-subagent planning indicator is consumed by the + # finalized plan card. + if not is_subagent and self.items and self.items[-1].kind == "planning_status" and not bool( + self.items[-1].meta.get("is_subagent", False) + ): + self.items.pop() + item = ChatItem( + kind="plan_ready", + content=plan, + meta={"is_subagent": is_subagent}, + ) + self._append(item) + return item + + if msg_type == "stream_stopped": + self._current_assistant = None + self._current_thinking = None + item = ChatItem(kind="status", content="Generation stopped by user") + self._append(item) + return item + + if msg_type == "stream_end": + # Purge assistant/thinking bubbles that never received any text so the + # conversation does not show empty "Navi" panels. Empty bubbles are + # created at the END of the turn (the current assistant/thinking that + # got no delta), so a tail-trim is sufficient — no full-copy scan of + # the whole history (O(tail) instead of O(n)). + while self.items and self.items[-1].kind in ( + "assistant_message", + "thinking_block", + ) and not self.items[-1].content: + self.items.pop() + # Record the total time the agent spent on this request (all steps) + # as a dim metadata line below the answer. elapsed_seconds is the + # authoritative value measured by the backend over the whole turn. + self._append( + ChatItem(kind="turn_meta", meta={"elapsed_seconds": msg.get("elapsed_seconds")}) + ) + self._current_assistant = None + self._current_thinking = None + return None + + if msg_type == "context_compressed": + # The compressor replaced the discarded turns with a summary. Surface + # that text in the chat as a distinct block so the user can see what + # was kept — for both forced /compact and mid-turn auto-compress. + item = ChatItem( + kind="context_summary", + content=msg.get("summary", "") or "", + meta={ + "messages_before": msg.get("messages_before"), + "messages_after": msg.get("messages_after"), + }, + ) + self._append(item) + return item + + if msg_type in ("heartbeat", "session_sync"): + return None + + if msg_type == "recall_update": + # schedule_recall lifecycle (scheduled/fired/cancelled/skipped/ + # rescheduled), pushed out-of-band by the scheduler/orchestrator. + item = ChatItem(kind="recall", meta=msg) + self._append(item) + return item + + # Unknown event — store as status for debugging. + item = ChatItem(kind="status", content=f"{msg_type}: {msg}") + self._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..b15ab39 --- /dev/null +++ b/clients/terminal/tui/commands/builtin.py @@ -0,0 +1,377 @@ +"""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.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() + # Plain section headers (no rich markup): this content is delivered as a + # ``status`` event, and ``StatusRenderer`` builds a literal ``Text(...)`` + # which does not parse markup — ``[b]…[/b]`` would show as literal square + # brackets instead of bold. + lines = ["Slash commands"] + 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}") + + # Keys (not slash commands) so they're discoverable from /help too. + lines.append("") + lines.append("Keys") + lines.append(" Ctrl+P — command palette") + lines.append(" Ctrl+R — reading mode (toggle)") + lines.append(" Ctrl+X Q — quit") + lines.append(" Ctrl+X C — compact context") + lines.append(" Ctrl+X T — toggle thinking blocks") + lines.append(" Esc — stop generation") + lines.append(" Ctrl+Shift+Up/Down, Ctrl+End — scroll chat") + + # Copying text out of the TUI — the non-obvious bit worth surfacing. + lines.append("") + lines.append("Copying text") + lines.append( + " Drag (no Shift) + Ctrl+C — copy a message/output (clean, no borders)." + ) + lines.append( + " Works via OSC 52; in terminals without it (e.g. GNOME Console) use:" + ) + lines.append( + " Ctrl+R → Shift+drag → Ctrl+Shift+C — copy raw selection to the system" + ) + lines.append( + " clipboard. Reading mode hides the chrome and the bubble borders so the" + ) + lines.append(" selection is just the conversation text.") + 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=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + app = ctx.app() + if app is None: + return + app.run_worker(app._create_new_session_worker()) + + +class SessionsCommand(BaseCommand): + meta = CommandMeta( + name="sessions", + aliases=("resume", "continue"), + description="Pick a navi_code session to switch to.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + app = ctx.app() + if app is None: + return + app._open_sessions_picker() + + +class SwitchCommand(BaseCommand): + meta = CommandMeta( + name="switch", + aliases=(), + description="Switch to a navi_code session by id/prefix, or open the picker.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + app = ctx.app() + if app is None: + return + + target = args.strip() + # No argument (or none/ambiguous match) -> open the picker, pre-filtered + # to whatever the user typed so they can finish the choice visually. + if not target: + app._open_sessions_picker() + return + + session = await self._resolve_session(target) + if session is None: + app._open_sessions_picker(initial_query=target) + return + + app.run_worker(app._switch_session_worker(session["session_id"])) + + @staticmethod + async def _resolve_session(target: str) -> dict | None: + """Resolve ``target`` to a single navi_code session, or None. + + Tries an exact id first (GET /sessions/{id}); if that fails or lands on a + different profile, falls back to prefix-matching the navi_code session + list. Returns the session only when exactly one navi_code match exists. + """ + # Exact id hit. + try: + session = await api.get_session(target) + except Exception: + session = None + if session is not None and session.get("profile_id") == settings.default_profile_id: + return session + + # Prefix match among navi_code sessions. + try: + sessions = await api.list_sessions() + except Exception: + return None + matches = [ + s + for s in sessions + if s.get("profile_id") == settings.default_profile_id + and s.get("session_id", "").startswith(target) + ] + if len(matches) == 1: + return matches[0] + return None + + +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 = await 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 (force context compression now).", + keybind="ctrl+x c", + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + # Send a typed control message, not a chat message: the backend runs the + # real context compressor (bypassing the token threshold) instead of a + # full agent turn that merely produces summary text. + if ctx.ws_client: + ctx.ws_client.enqueue({"type": "compact"}) + + +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 = await 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 + + try: + _open_in_editor(str(file_path)) + except OSError as exc: + ctx.chat_panel.handle_ws_event( + { + "type": "error", + "message": f"Failed to open editor for {file_path}: {exc}", + } + ) + return + 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. + try: + subprocess.Popen([editor, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as exc: + raise OSError(f"cannot start editor '{editor}': {exc}") from exc diff --git a/clients/terminal/tui/commands/registry.py b/clients/terminal/tui/commands/registry.py new file mode 100644 index 0000000..ae3c6fb --- /dev/null +++ b/clients/terminal/tui/commands/registry.py @@ -0,0 +1,86 @@ +"""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 + + def match(self, prefix: str) -> list["BaseCommand"]: + """Return commands whose name or alias matches ``prefix``. + + Case-insensitive prefix match against the canonical name and aliases. + An exact name/alias match sorts first, then remaining prefix matches in + registration order. An empty prefix returns every command (used when the + user has typed only ``/``). + """ + prefix = prefix.strip().lower() + if not prefix: + return self.all() + + def rank(cmd: "BaseCommand") -> int: + names = [cmd.meta.name.lower(), *(a.lower() for a in cmd.meta.aliases)] + if prefix in names: + return 0 + if any(n.startswith(prefix) for n in names): + return 1 + return 2 + + matched = [cmd for cmd in self.all() if rank(cmd) < 2] + matched.sort(key=rank) + return matched + + +_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..f54f503 --- /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, field +from pathlib import Path +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.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 + chat_model: "ChatModel | None" = None + cwd: Path = field(default_factory=lambda: Path.cwd().resolve()) + + 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/duration.py b/clients/terminal/tui/duration.py new file mode 100644 index 0000000..e4a3bab --- /dev/null +++ b/clients/terminal/tui/duration.py @@ -0,0 +1,23 @@ +"""Compact human-readable duration formatting for the TUI. + +Shared by the live elapsed-timer widget (next to the activity indicator) and +the turn-metadata renderer (the dim ``⏱ 2m 16s`` line under an answer), so both +show the same compact form: ``16s``, ``2m 16s``, ``1h 2m``. +""" + +from __future__ import annotations + + +def format_duration(seconds: float | None) -> str: + """Format a duration as ``Ns`` / ``Mm Ss`` / ``Hh Mm``. + + ``None`` (the backend did not report a value) renders as ``—``. + """ + if seconds is None: + return "—" + total = int(round(seconds)) + if total < 60: + return f"{total}s" + if total < 3600: + return f"{total // 60}m {total % 60}s" + return f"{total // 3600}h {(total % 3600) // 60}m" \ No newline at end of file diff --git a/clients/terminal/tui/events.py b/clients/terminal/tui/events.py new file mode 100644 index 0000000..bff7729 --- /dev/null +++ b/clients/terminal/tui/events.py @@ -0,0 +1,39 @@ +"""Typed events used inside the TUI app and between components.""" + +from __future__ import annotations + +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__() diff --git a/clients/terminal/tui/file_refs.py b/clients/terminal/tui/file_refs.py new file mode 100644 index 0000000..7e58886 --- /dev/null +++ b/clients/terminal/tui/file_refs.py @@ -0,0 +1,320 @@ +"""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() + # Filter sensitive entries in the generator BEFORE sorted(), so a huge + # directory (deep project tree) does not get fully materialized into a list + # only to have most of it discarded — sort only the kept files. + kept = (p for p in iterator if p.is_file() and not _is_inside_sensitive_dir(p)) + for p in sorted(kept): + 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/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py new file mode 100644 index 0000000..be43074 --- /dev/null +++ b/clients/terminal/tui/renderers/__init__.py @@ -0,0 +1,45 @@ +"""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, status, planning, subagent, todo, turn_meta, summary, filesystem, recall + + +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()) + # spawn_agent and todo cards must be checked before the generic tool renderers. + reg.register(subagent.SpawnAgentStartedRenderer()) + reg.register(subagent.SpawnAgentResultRenderer()) + reg.register(todo.TodoStartedRenderer()) + reg.register(todo.TodoResultRenderer()) + # filesystem-specific renderers must be checked before the generic tool + # renderers (first accepting renderer wins). + reg.register(filesystem.FilesystemToolStartedRenderer()) + reg.register(tool.ToolStartedRenderer()) + reg.register(filesystem.FilesystemToolResultRenderer()) + reg.register(tool.ToolResultRenderer()) + reg.register(error.ErrorRenderer()) + reg.register(status.StatusRenderer()) + reg.register(planning.PlanningStatusRenderer()) + reg.register(planning.PlanReadyRenderer()) + reg.register(turn_meta.TurnMetaRenderer()) + reg.register(summary.ContextSummaryRenderer()) + # recall_update lifecycle cards (scheduled/fired/cancelled/…). + reg.register(recall.RecallRenderer()) + reg.register(markdown_content.MarkdownRenderer()) + reg.register(diff.DiffRenderer()) + reg.register(plain.PlainRenderer()) + return reg + + +__all__ = [ + "ContentRenderer", + "RendererRegistry", + "default_registry", +] diff --git a/clients/terminal/tui/renderers/base.py b/clients/terminal/tui/renderers/base.py new file mode 100644 index 0000000..c54a792 --- /dev/null +++ b/clients/terminal/tui/renderers/base.py @@ -0,0 +1,58 @@ +"""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 + + +def _strip_panel(renderable: "RenderableType") -> "RenderableType": + """Drop the bubble chrome for reading-mode (plain) rendering. + + Most chat items render a rich ``Panel`` (rounded border + title) or a + ``Padding(Panel, ...)`` for sub-agent indent. In reading mode the goal is to + show just the content so a raw terminal ``Shift+drag`` + ``Ctrl+Shift+C`` + copies clean text without the ``│``/``╭─╮`` borders — so unwrap the Panel (or + padded Panel) to its body. Renderables that are already borderless (plain + ``Text``/``Group``/status lines) pass through unchanged. + """ + from rich.panel import Panel + from rich.padding import Padding + + if isinstance(renderable, Panel): + return renderable.renderable + if isinstance(renderable, Padding): + inner = renderable.renderable + if isinstance(inner, Panel): + return inner.renderable + return inner + return renderable + + +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 + + def render_plain(self, msg: dict) -> "RenderableType": + """Return a borderless renderable for reading mode. + + The default unwraps the Panel/Padding that :meth:`render` produces so the + body shows without bubble chrome. Renderers whose body is itself a styled + markdown renderable (assistant answers, finalized plans) override this to + return the raw markdown text instead — otherwise rich ``Markdown`` would + re-wrap fenced code blocks in their own Panels and the borders would leak + back into a raw-terminal copy. + """ + return _strip_panel(self.render(msg)) \ No newline at end of file diff --git a/clients/terminal/tui/renderers/diff.py b/clients/terminal/tui/renderers/diff.py new file mode 100644 index 0000000..b8db8b1 --- /dev/null +++ b/clients/terminal/tui/renderers/diff.py @@ -0,0 +1,97 @@ +"""Renderer for unified diff messages.""" + +from __future__ import annotations + +import re + +from rich.console import RenderableType +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.themes import Theme, get_active_theme + +from .base import ContentRenderer + +# ``{marker} {num}│ {content}`` — the line-number prefix the server +# (``navi/tools/filesystem.py:_number_diff``) prepends to each diff content line. +# A fixed space follows the marker, then the right-aligned number (with its own +# leading spaces, one per digit of width) and ``│ ``. We capture the number's +# leading spaces separately so the column stays right-aligned when we render +# the number first (``{num} {marker}``) instead of the server's marker-first +# order — and so files with more than 9 lines (width > 1) still match, which the +# old single-space regex silently dropped, falling back to whole-line colour. +_DIFF_NUM_RE = re.compile(r"^( +)(\d+)│ ?(.*)$", re.DOTALL) + + +def _diff_marker_style(marker: str, theme: Theme) -> str: + if marker == "+": + return theme.success.hex + if marker == "-": + return theme.error.hex + return theme.text.hex + + +def highlight_unified_diff(text: str, theme: Theme) -> Text: + """Color a unified-diff string: ``+`` green, ``-`` red, ``@@`` dim, rest text. + + When a content line carries a server-prefixed line number + (``{marker} {num}│ {content}``), the number and marker are shown in the + marker color (green/red) and the content is rendered neutral (``theme.text``) + — the number and marker act as a coloured anchor, the code itself reads + plainly. The display order is ``{num} {marker} {content}`` (number first); + the server still emits marker-first in the model-facing text, so the agent's + contract is unchanged. Lines without the prefix (e.g. the standalone + ``diff`` event, or older callers) are colored whole, as before. + + Shared by the standalone ``diff`` event renderer and the ``filesystem`` + tool-call renderer (``edit_lines`` / ``smart_edit`` / ``diff`` actions embed + a unified diff as plain text in ``ToolResult.output``). + """ + out = Text() + for idx, line in enumerate(text.splitlines()): + if idx: + out.append("\n") + if line.startswith("@@"): + out.append(line, style=theme.text_dim.hex) + continue + if line.startswith("+++") or line.startswith("---"): + out.append(line, style=theme.text.hex) + continue + marker = line[0] if line else "" + rest = line[1:] + m = _DIFF_NUM_RE.match(rest) + if m: + # {leading}{num} keeps the server's right-alignment (leading spaces + # come from {num:>width}); we then show the marker and two spaces + # before the content. Number+marker in the marker colour, content + # neutral. + out.append( + f"{m.group(1)}{m.group(2)} {marker} ", + style=_diff_marker_style(marker, theme), + ) + out.append(m.group(3), style=theme.text.hex) + else: + out.append(line, style=_diff_marker_style(marker, theme)) + return out + + +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", "+++") + + highlighted = highlight_unified_diff(content, theme) + + return Panel( + highlighted, + title=f"diff: {old_label} → {new_label}", + title_align="left", + border_style=theme.border.hex, + ) \ No newline at end of file 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/filesystem.py b/clients/terminal/tui/renderers/filesystem.py new file mode 100644 index 0000000..d51247c --- /dev/null +++ b/clients/terminal/tui/renderers/filesystem.py @@ -0,0 +1,517 @@ +"""Styled renderers for ``filesystem`` tool events. + +Two renderers: + +* :class:`FilesystemToolStartedRenderer` — restyles the ``tool_started`` card: + the action and primary path go into the title (``→ filesystem read + src/main.py``), and the body shows the key arguments compactly instead of a + full JSON dump, so large ``content`` / ``old`` / ``new`` values no longer + flood the card. +* :class:`FilesystemToolResultRenderer` — restyles the ``tool_call`` result + (diff highlighting, listing layout, grep match highlighting, read/info/find + formatting). + +Neither touches the filesystem tool, the WebSocket protocol, or events. """ + +from __future__ import annotations + +import re + +from rich.box import ROUNDED +from rich.console import RenderableType, Group +from rich.padding import Padding +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.themes import Theme, get_active_theme + +from .base import ContentRenderer +from .diff import highlight_unified_diff +from .language import guess_language +from .syntax import highlight_code + +# Actions whose output embeds a unified diff (edit_lines / smart_edit prepend an +# "Applied …" summary line; diff is the raw unified diff). +_DIFF_ACTIONS = {"diff", "edit_lines", "smart_edit"} + +# List entry prefixes emitted by filesystem._list. +_LIST_DIR_RE = re.compile(r"^d\s+(?P.+?)/\s*(?:\((?P\d+) items\))?\s*$") +_LIST_FILE_RE = re.compile(r"^\s{3}(?P.+?)\s{2,}(?P\S+)\s{2,}(?P