Newer
Older
navi-1 / clients / terminal / tui / tui_app.py
"""Textual TUI application for Navi Code."""

from __future__ import annotations

from pathlib import Path

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical

from clients.terminal import api
from clients.terminal.config import settings
from clients.terminal.state import StateManager
from clients.terminal.tui.context import TuiContext
from clients.terminal.tui.events import (
    ConnectionStatusChanged,
    UserSubmitted,
    WsEvent,
)
from clients.terminal.tui.file_refs import FileRefResolver
from clients.terminal.tui.shell_runner import run_shell_command
from clients.terminal.tui.settings import get_tui_settings
from clients.terminal.tui.themes import ThemeRegistry, set_active_theme
from clients.terminal.tui.commands.registry import get_registry
from clients.terminal.tui.screens.command_palette import CommandPaletteScreen
from clients.terminal.tui.widgets import ChatPanel, InputBox, StatusBar, StatusPanel, TodoPanel
from clients.terminal.tui.ws_bridge import WsBridge


class NaviCodeTui(App):
    """OpenCode-inspired terminal UI for Navi."""

    # Reading mode: hide every piece of chrome around the conversation (right
    # status/todo column, input prompt, bottom status bar, and the chat panel's
    # own outer border) so the terminal shows only the message column. The
    # reason this exists is that GNOME Console (and other terminals without
    # OSC 52 support) can't copy Textual's selection to the *system* clipboard —
    # only the raw terminal selection (Shift+drag) reaches the OS via
    # Ctrl+Shift+C. Hiding the chrome lets Shift+drag grab just the conversation
    # instead of the status bar / prompt / side panels. (Message bubble borders
    # still leak into the copy in this first cut; a borderless plain render
    # follows in a second step.)
    DEFAULT_CSS = """
    NaviCodeTui.reading-mode #tui-right-column {
        display: none;
    }
    NaviCodeTui.reading-mode InputBox {
        display: none;
    }
    NaviCodeTui.reading-mode StatusBar {
        display: none;
    }
    NaviCodeTui.reading-mode ChatPanel {
        border: none;
        padding: 0;
    }
    /* A blank line between borderless messages so they don't run together when
       the bubble borders are gone. The empty margin rows are part of the raw
       terminal selection, which is fine — they read as message separators. */
    NaviCodeTui.reading-mode ChatPanel _ChatItemView {
        margin: 0 0 1 0;
    }
    """

    BINDINGS = [
        ("ctrl+p", "command_palette", "Palette"),
        ("ctrl+x q", "quit", "Quit"),
        ("ctrl+x c", "compact", "Compact"),
        ("ctrl+x t", "toggle_thinking", "Thinking"),
        ("escape", "stop_stream", "Stop"),
        ("ctrl+r", "toggle_reading_mode", "Reading"),
        # Scroll the chat panel from the keyboard while focus stays on the
        # input box. These keys are not claimed by TextArea (it uses shift+up/
        # down for select and ctrl+e/end for line end), so they bubble to the
        # app. show=False keeps the footer bindings uncluttered.
        Binding("ctrl+shift+up", "scroll_chat_up", "Scroll up", show=False),
        Binding("ctrl+shift+down", "scroll_chat_down", "Scroll down", show=False),
        Binding("ctrl+end", "scroll_chat_end", "Bottom", show=False),
    ]

    def __init__(
        self,
        session_id: str | None = None,
        profile_id: str | None = None,
        new_session: bool = False,
        theme_name: str | None = None,
        cwd: Path | None = None,
    ) -> None:
        self._tui_settings = get_tui_settings()
        self._theme_name = theme_name or self._tui_settings.theme
        self._mouse_enabled = self._tui_settings.mouse
        super().__init__()
        self._register_textual_themes()
        self.theme = self._theme_name
        set_active_theme(self._theme_name)
        self._chat_panel = ChatPanel()
        self._status_panel = StatusPanel()
        self._todo_panel = TodoPanel()
        self._input_box = InputBox()
        self._status_bar = StatusBar()
        self._state = StateManager()
        self._ctx = TuiContext(
            state=self._state,
            chat_panel=self._chat_panel,
            status_panel=self._status_panel,
            settings=self._tui_settings,
            cwd=cwd or Path.cwd().resolve(),
        )
        self._bridge: WsBridge | None = None
        self._requested_session_id = session_id
        self._requested_profile_id = profile_id
        self._force_new_session = new_session
        self._streaming = False

    def compose(self) -> ComposeResult:
        with Horizontal():
            yield self._chat_panel
            with Vertical(id="tui-right-column"):
                yield self._status_panel
                yield self._todo_panel
        yield self._input_box
        yield self._status_bar

    def on_mount(self) -> None:
        self.apply_theme()
        self.run_worker(self._startup)
        self._input_box.focus_input()
        self._update_footer_bindings()

    def _update_footer_bindings(self) -> None:
        """Refresh footer so the Stop binding visibility tracks streaming state."""
        self.refresh_bindings()

    def _register_textual_themes(self) -> None:
        """Register every Navi theme as a Textual theme so $tui-* variables resolve."""
        for name in ThemeRegistry.all():
            self.register_theme(ThemeRegistry.get(name).to_textual_theme())

    def apply_theme(self) -> None:
        """Activate the selected theme and update global active theme state."""
        set_active_theme(self._theme_name)
        self.theme = self._theme_name
        if self._status_panel:
            self._status_panel.set_theme(self._theme_name)
        if self._todo_panel:
            self._todo_panel.refresh_content()

    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:
            # An explicitly requested id (the --resume flag) must not silently
            # fall through to creating a new session on a typo — surface the
            # error and let the user correct the id. Saved-state resume below
            # is the lenient path.
            try:
                session = await api.get_session(session_id)
                return session["session_id"]
            except Exception as exc:
                self._chat_panel.handle_ws_event(
                    {"type": "error", "message": f"Failed to resume session {session_id[:8]}: {exc}"}
                )
                return None

        if not force_new:
            saved = self._state.get_session_id()
            if saved:
                try:
                    session = await 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 = await 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
        history: list[dict] = []
        session: dict = {}
        try:
            session = await api.get_session(session_id)
            self._ctx.profile_id = session.get("profile_id") or settings.default_profile_id
            history = session.get("messages") or []
        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)
        # Show the profile's configured model (first of its priority list) so the
        # panel has a value before the first request resolves the actually-served
        # model via a model_info event. Falls back to the global default, then "-".
        configured = None
        try:
            configured = await api.get_profile_model(self._ctx.profile_id)
        except Exception:
            configured = None
        self._status_panel.set_model(
            configured
            or (settings.ollama_default_model if hasattr(settings, "ollama_default_model") else None)
            or "-"
        )
        self._status_panel.set_backend(settings.base_url)
        self._status_panel.set_theme(self._theme_name)
        # Seed the side-panel todo from the server so a resumed/switched session
        # shows its existing plan before the first todo_updated WS event arrives.
        try:
            todos = await api.get_todos(session_id)
            self._todo_panel.set_tasks(todos.get("tasks") or [])
        except Exception:
            self._todo_panel.clear()
        # Replay the session's past conversation before the connection banner
        # so a resumed/switched session shows its history instead of a blank chat.
        self._chat_panel.load_history(history)
        # Seed the context gauge immediately on resume/switch so the bar isn't
        # blank until the first stream_end of the new turn reports tokens.
        self._status_bar.set_context(
            session.get("context_token_count"),
            session.get("max_context_tokens"),
        )
        if cwd := self._ctx.cwd:
            self._chat_panel.handle_ws_event({"type": "status", "content": f"cwd: {cwd}"})

        if self._bridge:
            await self._bridge.stop()
        self._bridge = WsBridge(self, session_id, cwd=self._ctx.cwd)
        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

        if text.startswith("!"):
            self._run_shell_command(text)
            return

        resolved = FileRefResolver().resolve(text)
        self._chat_panel.add_user_message(resolved.prompt)
        if resolved.attachments:
            names = ", ".join(
                a.display_path + (" (truncated)" if a.truncated else "")
                for a in resolved.attachments
            )
            self._chat_panel.handle_ws_event({"type": "status", "content": f"Attached: {names}"})
        for err in resolved.errors:
            self._chat_panel.handle_ws_event({"type": "error", "message": err})

        # Always enqueue when a bridge exists: the WsBridge input loop buffers
        # the message in its queue and waits on ``_connected_event`` before
        # sending, so a submit during a brief reconnect (``connected`` is False)
        # is delivered after the socket comes back — not dropped. Only show the
        # "not connected" error when there is no bridge at all.
        if self._bridge:
            self._bridge.client.enqueue(resolved.to_message())
        else:
            self._chat_panel.handle_ws_event(
                {"type": "error", "message": "Not connected to a session"}
            )

    def _run_shell_command(self, text: str) -> None:
        # User-typed `!cmd` — the user explicitly invoked this, so no
        # confirmation gate. (Agent-initiated tool calls are gated on the
        # backend; see docs/permissions.md — not yet implemented.)
        self.run_worker(self._shell_worker(text))

    async def _shell_worker(self, text: str) -> None:
        result = run_shell_command(text)
        self._chat_panel.handle_ws_event({"type": "status", "content": result.summary()})

    async def _switch_session_worker(self, session_id: str) -> None:
        """Fully switch to an existing session: persist, reattach (replays
        history, restarts the WS bridge), and announce it.

        No explicit ``chat_panel.clear()`` — ``attach_session`` calls
        ``load_history`` which replaces the chat with the new session's past
        conversation. Clearing afterwards would erase that history.
        """
        self._state.set_session_id(session_id)
        await self.attach_session(session_id)
        self._chat_panel.handle_ws_event(
            {"type": "status", "content": f"Switched to {session_id[:8]}"}
        )

    async def _create_new_session_worker(self) -> None:
        """Create a fresh navi_code session and switch to it."""
        try:
            session = await api.create_session(settings.default_profile_id)
        except Exception as exc:
            self._chat_panel.handle_ws_event(
                {"type": "error", "message": f"Failed to create session: {exc}"}
            )
            return
        session_id = session["session_id"]
        self._state.set_session_id(session_id)
        await self.attach_session(session_id)
        self._chat_panel.handle_ws_event(
            {"type": "status", "content": f"Created session {session_id[:8]}"}
        )

    def _open_sessions_picker(self, initial_query: str = "") -> None:
        """Push the sessions picker; on select, switch/create as appropriate."""
        from clients.terminal.tui.screens.sessions_picker import (
            NEW_SESSION,
            SessionsPickerScreen,
        )

        def on_select(session_id: str | None) -> None:
            if session_id is None:
                return
            if session_id == NEW_SESSION:
                self.run_worker(self._create_new_session_worker())
                return
            self.run_worker(self._switch_session_worker(session_id))

        self.push_screen(
            SessionsPickerScreen(self._ctx.session_id, initial_query=initial_query),
            callback=on_select,
        )

    def _run_command(self, text: str) -> None:
        parts = text[1:].split(None, 1)
        name = parts[0].lower()
        args = parts[1] if len(parts) > 1 else ""
        registry = get_registry()
        cmd = registry.get(name)
        if cmd is None:
            self._chat_panel.handle_ws_event(
                {"type": "error", "message": f"Unknown command: /{name}"}
            )
            return
        self.run_worker(self._command_worker(cmd, args))

    async def _command_worker(self, cmd, args: str) -> None:
        await cmd.execute(self._ctx, args)

    def on_ws_event(self, event: WsEvent) -> None:
        payload = event.payload
        msg_type = payload.get("type")
        if msg_type == "stream_start":
            self._streaming = True
            self._input_box.set_placeholder("Esc to stop")
            self._status_bar.activity_start("thinking")
            self._status_bar.elapsed_start()
            self._update_footer_bindings()
        elif msg_type in ("stream_end", "stream_stopped", "error"):
            self._streaming = False
            self._input_box.set_placeholder("Ask anything...")
            self._input_box.focus_input()
            self._status_bar.activity_stop()
            # stream_end reports the prompt-token size of the last LLM call —
            # how full the context window was for this turn. Update the gauge.
            # It also carries elapsed_seconds — the authoritative whole-turn
            # duration — so sync the live timer to it before freezing.
            if msg_type == "stream_end":
                self._status_bar.set_context(
                    payload.get("context_tokens"), payload.get("max_context_tokens")
                )
                self._status_bar.elapsed_stop(payload.get("elapsed_seconds"))
            else:
                # stream_stopped / error: no authoritative elapsed; freeze the
                # locally-measured value.
                self._status_bar.elapsed_stop()
            self._update_footer_bindings()
        elif msg_type == "model_info":
            # The model that actually served this turn (resolved by the backend,
            # may differ from the configured priority list when a model was down).
            # Update the status panel; not a chat item, so don't forward it.
            model = payload.get("model")
            if model:
                self._status_panel.set_model(model)
            return
        elif msg_type == "todo_updated":
            # Live todo plan update from the agent (after planning auto-populate
            # or a todo tool call). Renders in the side panel; not a chat item.
            self._todo_panel.set_tasks(payload.get("tasks") or [])
            return
        elif msg_type in ("compression_started", "context_compressed"):
            # Compression carries a fresh context-token count (post-compress on
            # context_compressed, pre-compress on compression_started). Update
            # the gauge.
            self._status_bar.set_context(
                payload.get("context_tokens"), payload.get("max_context_tokens")
            )
            # Auto-compression happens mid-turn, while _streaming is True (a
            # stream_start already kicked off the activity spinner) — just
            # relabel it. A forced /compact has NO streaming turn, so _streaming
            # is False: start the spinner on compression_started and stop it on
            # context_compressed.
            if msg_type == "compression_started":
                if self._streaming:
                    self._status_bar.activity_label("compressing")
                else:
                    self._status_bar.activity_start("compressing")
            else:  # context_compressed
                # Render the summary block in the chat for both forced /compact
                # and mid-turn auto-compress — the summary text is what the
                # compressor kept, useful to see what was discarded. The block's
                # header carries the before/after counts, so the old one-line
                # "Context compacted: N → M" status is no longer emitted.
                self._chat_panel.handle_ws_event(payload)
                if not self._streaming:
                    self._status_bar.activity_stop()
            return
        elif msg_type == "stream_delta" and self._streaming:
            # First real output: the agent is now responding, not just thinking.
            self._status_bar.activity_label("responding")
        elif msg_type == "thinking_delta" and self._streaming:
            self._status_bar.activity_label("thinking")
        elif msg_type == "planning_status":
            self._status_bar.activity_label("planning")
        elif msg_type == "plan_ready":
            self._status_bar.activity_label("planning")
        elif msg_type == "tool_started":
            tool = payload.get("tool", "")
            self._status_bar.activity_label(f"running {tool}" if tool else "running tool")

        self._chat_panel.handle_ws_event(payload)

    def on_connection_status_changed(self, event: ConnectionStatusChanged) -> None:
        self._status_panel.set_connection(event.connected, event.detail)
        # A dropped connection mid-turn will never deliver stream_end, so stop
        # the spinner here rather than leave it spinning forever.
        if not event.connected:
            self._status_bar.activity_stop()
            self._status_bar.elapsed_stop()

    def action_command_palette(self) -> None:
        registry = get_registry()

        def on_select(cmd) -> None:
            if cmd is not None:
                self.run_worker(self._command_worker(cmd, ""))

        self.push_screen(CommandPaletteScreen(registry.all()), callback=on_select)

    def action_compact(self) -> None:
        self._run_command("/compact")

    def action_toggle_thinking(self) -> None:
        self._run_command("/thinking")

    def action_stop_stream(self) -> None:
        if not self._streaming:
            return
        session_id = self._ctx.session_id
        if not session_id:
            return
        self.run_worker(self._stop_stream_worker(session_id))

    async def _stop_stream_worker(self, session_id: str) -> None:
        try:
            await api.stop_session(session_id)
        except Exception as exc:
            self._chat_panel.handle_ws_event(
                {"type": "error", "message": f"Failed to stop generation: {exc}"}
            )

    def action_scroll_chat_up(self) -> None:
        """Scroll the chat one line up without leaving the input box."""
        self._chat_panel.scroll_up(animate=False)

    def action_scroll_chat_down(self) -> None:
        """Scroll the chat one line down without leaving the input box."""
        self._chat_panel.scroll_down(animate=False)

    def action_scroll_chat_end(self) -> None:
        """Jump the chat to the bottom (resumes auto-follow on the next sync)."""
        self._chat_panel.scroll_end(animate=False)

    def action_toggle_reading_mode(self) -> None:
        """Hide the chrome around the chat so Shift+drag + Ctrl+Shift+C copies it.

        Toggles the ``reading-mode`` CSS class on the app, which hides the right
        status/todo column, the input prompt, and the bottom status bar, and
        drops the chat panel's outer border — leaving only the message column on
        screen. Use ``Shift+drag`` to select the conversation and ``Ctrl+Shift+C``
        to copy it to the system clipboard (works in terminals without OSC 52,
        e.g. GNOME Console). Press ``Ctrl+R`` again to restore the full UI and
        return focus to the input box.
        """
        self.toggle_class("reading-mode")
        reading = self.has_class("reading-mode")
        # Re-render the chat without/with bubble borders. Done after the class
        # toggle so the new widgets paint in the chosen style.
        self._chat_panel.set_reading_mode(reading)
        if not reading:
            self._input_box.focus_input()
        self.refresh_bindings()

    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()