diff --git a/clients/terminal/cli.py b/clients/terminal/cli.py index 54dd80f..fb481f0 100644 --- a/clients/terminal/cli.py +++ b/clients/terminal/cli.py @@ -21,6 +21,7 @@ @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("--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, @@ -30,11 +31,12 @@ new_session: bool, show_thinking: bool, show_events: bool, + raw: bool, ) -> None: """Navi Code — terminal client for Navi. - Without PROMPT, runs interactive chat mode. With PROMPT, sends one message - and exits after the response completes. + 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 @@ -44,8 +46,15 @@ settings.show_thinking = True settings.show_events = show_events - state = StateManager() + if raw or prompt: + _run_raw(prompt, new_session, profile_id) + return + _run_tui(profile_id, new_session) + + +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.") @@ -60,6 +69,13 @@ asyncio.run(_run_interactive(client, state)) +def _run_tui(profile_id: str | None, new_session: bool) -> None: + from clients.terminal.tui.tui_app import NaviCodeTui + + app = NaviCodeTui(profile_id=profile_id, new_session=new_session) + app.run() + + def _resolve_session_id(state: StateManager, force_new: bool, profile_id: str | None) -> str | None: if not force_new: saved = state.get_session_id() 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..fd6b776 --- /dev/null +++ b/clients/terminal/tui/commands/builtin.py @@ -0,0 +1,192 @@ +"""Built-in slash commands for Navi Code TUI.""" + +from __future__ import annotations + +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 + + +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["id"] + ctx.profile_id = session.get("profile_id") + ctx.state.set_session_id(session["id"]) + ctx.status_panel.set_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['id'][:8]}"}) + 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: + marker = "● " if s["id"] == ctx.session_id else " " + lines.append(f"{marker}{s['id'][:8]} {s.get('profile_id', 'unknown')} {s.get('title', '')}") + ctx.chat_panel.handle_ws_event({"type": "status", "content": "\n".join(lines)}) + + +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["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["id"] + ctx.profile_id = session.get("profile_id") + ctx.state.set_session_id(session["id"]) + ctx.status_panel.set_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['id'][:8]}"}) + 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['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.") + + +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..93f7dd8 --- /dev/null +++ b/clients/terminal/tui/commands/registry.py @@ -0,0 +1,59 @@ +"""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()) + return registry diff --git a/clients/terminal/tui/context.py b/clients/terminal/tui/context.py new file mode 100644 index 0000000..e149cad --- /dev/null +++ b/clients/terminal/tui/context.py @@ -0,0 +1,33 @@ +"""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.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 + chat_panel: "ChatPanel | None" = None + status_panel: "StatusPanel | None" = None + chat_model: "ChatModel | None" = None + + def app(self): + """Return the running TuiApp instance.""" + from textual.app import App + + # This is a convenience accessor used by commands that need App-level actions. + return App.get_active_app() diff --git a/clients/terminal/tui/events.py b/clients/terminal/tui/events.py new file mode 100644 index 0000000..7800458 --- /dev/null +++ b/clients/terminal/tui/events.py @@ -0,0 +1,69 @@ +"""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.""" + + def __init__(self, tool: str, action: str, details: str, callback) -> None: + self.tool = tool + self.action = action + self.details = details + self.callback = callback + 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__() diff --git a/clients/terminal/tui/permissions.py b/clients/terminal/tui/permissions.py new file mode 100644 index 0000000..8f2a809 --- /dev/null +++ b/clients/terminal/tui/permissions.py @@ -0,0 +1,108 @@ +"""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"), +] + + +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.""" + rule_key = self._rule_key(tool, args) + if rule_key in self._always_allow: + return None + if rule_key in self._always_deny: + # Always denied — treat as matched, the caller will reject. + return PermissionRule(tool=tool, message="always denied by user policy") + + 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 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() + + @staticmethod + def _rule_key(tool: str, args: dict) -> str: + action = args.get("action", "") + target = PermissionEngine._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", "") + return "" diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py new file mode 100644 index 0000000..7cabd58 --- /dev/null +++ b/clients/terminal/tui/renderers/__init__.py @@ -0,0 +1,28 @@ +"""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 + + +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(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..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/error.py b/clients/terminal/tui/renderers/error.py new file mode 100644 index 0000000..86774b3 --- /dev/null +++ b/clients/terminal/tui/renderers/error.py @@ -0,0 +1,27 @@ +"""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 .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: + text = msg.get("message", "unknown error") + return Panel( + Text(text, style="bold"), + title="error", + title_align="left", + border_style="red", + box=ROUNDED, + ) diff --git a/clients/terminal/tui/renderers/markdown_content.py b/clients/terminal/tui/renderers/markdown_content.py new file mode 100644 index 0000000..7c37fad --- /dev/null +++ b/clients/terminal/tui/renderers/markdown_content.py @@ -0,0 +1,19 @@ +"""Renderer for markdown content.""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.markdown import Markdown + +from .base import ContentRenderer + + +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: + text = msg.get("content", "") + return Markdown(text, code_theme="monokai") diff --git a/clients/terminal/tui/renderers/message.py b/clients/terminal/tui/renderers/message.py new file mode 100644 index 0000000..63520d0 --- /dev/null +++ b/clients/terminal/tui/renderers/message.py @@ -0,0 +1,44 @@ +"""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 .base import ContentRenderer + + +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: + text = msg.get("content", "") + return Panel( + Text(text, style="bright_white"), + title="You", + title_align="left", + border_style="blue", + 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: + text = msg.get("content", "") + return Panel( + Text(text, style="bright_white"), + title="Navi", + title_align="left", + border_style="green", + box=ROUNDED, + ) diff --git a/clients/terminal/tui/renderers/plain.py b/clients/terminal/tui/renderers/plain.py new file mode 100644 index 0000000..9b0f099 --- /dev/null +++ b/clients/terminal/tui/renderers/plain.py @@ -0,0 +1,19 @@ +"""Fallback plain text renderer.""" + +from __future__ import annotations + +from rich.console import RenderableType +from rich.text import Text + +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: + text = msg.get("content", "") or str(msg) + return Text(text, style="bright_white") 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..5629f70 --- /dev/null +++ b/clients/terminal/tui/renderers/thinking.py @@ -0,0 +1,27 @@ +"""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 .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: + text = msg.get("content", "") + return Panel( + Text(text, style="dim"), + title="thinking", + title_align="left", + border_style="bright_black", + box=ROUNDED, + ) diff --git a/clients/terminal/tui/renderers/tool.py b/clients/terminal/tui/renderers/tool.py new file mode 100644 index 0000000..2c1d51c --- /dev/null +++ b/clients/terminal/tui/renderers/tool.py @@ -0,0 +1,61 @@ +"""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 .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: + 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="bright_black") + else: + body = Text("") + return Panel( + body, + title=title, + title_align="left", + border_style="yellow", + 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: + tool = msg.get("tool", "?") + success = msg.get("success", True) + result = msg.get("result") + color = "green" if success else "red" + title = f"← {tool} {'✓' if success else '✗'}" + body = Text(str(result) if result is not None else "", style="bright_black") + return Panel( + body, + title=title, + title_align="left", + border_style=color, + box=ROUNDED, + ) diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py new file mode 100644 index 0000000..cf9c5ed --- /dev/null +++ b/clients/terminal/tui/tui_app.py @@ -0,0 +1,203 @@ +"""Textual TUI application for Navi Code.""" + +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.containers import Horizontal +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.commands.registry import get_registry +from clients.terminal.tui.context import TuiContext +from clients.terminal.tui.events import ( + ConnectionStatusChanged, + PermissionRequest, + UserSubmitted, + WsEvent, +) +from clients.terminal.tui.permissions import PermissionEngine +from clients.terminal.tui.widgets import ChatPanel, InputBox, StatusPanel +from clients.terminal.tui.ws_bridge import WsBridge + + +class NaviCodeTui(App): + """OpenCode-inspired terminal UI for Navi.""" + + CSS = """ + Screen { align: center middle; } + NaviCodeTui { padding: 0; } + Header { height: 1; } + Footer { height: 1; } + """ + + 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, + ) -> None: + super().__init__() + self._chat_panel = ChatPanel() + self._status_panel = StatusPanel() + self._input_box = InputBox() + self._state = StateManager() + self._ctx = TuiContext( + state=self._state, + chat_panel=self._chat_panel, + status_panel=self._status_panel, + ) + 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 + yield self._status_panel + yield self._input_box + yield Footer() + + def on_mount(self) -> None: + self.run_worker(self._startup) + + 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") + + 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]}"}) + + def on_user_submitted(self, event: UserSubmitted) -> None: + text = event.text + if text.startswith("/"): + self._run_command(text) + return + + # @ file references and ! shell commands can be parsed here in Phase 4. + self._chat_panel.add_user_message(text) + if self._bridge and self._bridge.connected: + self._bridge.client.enqueue(text) + else: + self._chat_panel.handle_ws_event({"type": "error", "message": "Not connected to a session"}) + + 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: + self._chat_panel.handle_ws_event(event.payload) + + 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: + self._pending_permission = event + self._chat_panel.handle_ws_event( + { + "type": "status", + "content": f"Permission required: {event.details}\nAllow once (y) / always (a) / reject (n)", + } + ) + + def action_command_palette(self) -> None: + # Phase 4: implement command palette screen. + self._chat_panel.handle_ws_event({"type": "status", "content": "Command palette: /help, /new, /sessions, /switch, /profile, /thinking, /compact, /quit"}) + + 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..9e46815 --- /dev/null +++ b/clients/terminal/tui/widgets/__init__.py @@ -0,0 +1,9 @@ +"""TUI widgets.""" + +from __future__ import annotations + +from .chat_panel import ChatPanel +from .input_box import InputBox +from .status_panel import StatusPanel + +__all__ = ["ChatPanel", "InputBox", "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..dbe34f6 --- /dev/null +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -0,0 +1,65 @@ +"""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 $primary; + padding: 0 1; + height: 1fr; + width: 2fr; + } + """ + + 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..d3de254 --- /dev/null +++ b/clients/terminal/tui/widgets/input_box.py @@ -0,0 +1,51 @@ +"""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 $primary; + padding: 0 1; + } + InputBox Input { + height: auto; + border: none; + background: $surface; + } + """ + + 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/status_panel.py b/clients/terminal/tui/widgets/status_panel.py new file mode 100644 index 0000000..d41e79e --- /dev/null +++ b/clients/terminal/tui/widgets/status_panel.py @@ -0,0 +1,55 @@ +"""Status panel widget for the TUI.""" + +from __future__ import annotations + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import Static + + +class StatusPanel(Vertical): + """Right-side panel showing session/profile/model/connection info.""" + + DEFAULT_CSS = """ + StatusPanel { + border: solid $primary-darken-2; + padding: 1; + height: 1fr; + width: 1fr; + } + """ + + 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._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 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: + if connected: + self._connection.update(f"[green]Connection: online[/green] {detail}") + else: + self._connection.update(f"[red]Connection: offline[/red] {detail}") 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/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/pyproject.toml b/pyproject.toml index 6c009b3..c67f150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ # Terminal client "click>=8.0", "websockets>=12.0", + "textual>=0.70", ] [project.scripts] diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py new file mode 100644 index 0000000..b613e23 --- /dev/null +++ b/tests/clients/test_tui_app.py @@ -0,0 +1,56 @@ +"""Smoke tests for the Navi Code TUI.""" + +from __future__ import annotations + +import pytest + +from clients.terminal.tui.tui_app import NaviCodeTui + + +@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("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)