Newer
Older
navi-1 / clients / terminal / tui / screens / command_palette.py
"""Command palette modal screen for Navi Code TUI."""

from __future__ import annotations

from textual import events
from textual.app import ComposeResult
from textual.containers import Container
from textual.screen import ModalScreen
from textual.widgets import Input, Label, ListItem, ListView, Static

from clients.terminal.tui.commands.base import BaseCommand, CommandMeta


class CommandPaletteScreen(ModalScreen[BaseCommand | None]):
    """A centered command palette with fuzzy filtering.

    Dismisses with the selected command (or None if cancelled). The caller is
    responsible for executing the selected command with the TUI context.
    """

    DEFAULT_CSS = """
    CommandPaletteScreen {
        align: center middle;
    }
    CommandPaletteScreen > Container {
        width: 70;
        height: auto;
        max-height: 24;
        border: thick $tui-primary;
        background: $tui-surface;
        padding: 0 0 1 0;
    }
    CommandPaletteScreen .title {
        text-style: bold;
        color: $tui-primary;
        background: $tui-panel;
        padding: 1;
        height: auto;
        text-align: center;
    }
    CommandPaletteScreen Input {
        height: auto;
        border: none;
        border-bottom: solid $tui-border;
        background: $tui-background;
        color: $tui-text;
        padding: 0 1;
        margin: 0;
    }
    CommandPaletteScreen ListView {
        height: auto;
        max-height: 16;
        border: none;
        background: $tui-surface;
        padding: 0;
        margin: 0;
    }
    CommandPaletteScreen ListItem {
        color: $tui-text;
        background: transparent;
        height: auto;
        padding: 0 1;
    }
    CommandPaletteScreen ListItem.--highlight {
        background: $tui-selection;
        color: $tui-background;
    }
    CommandPaletteScreen ListItem.--highlight .cmd-description {
        color: $tui-background;
    }
    CommandPaletteScreen ListItem.--highlight .cmd-keybind {
        color: $tui-background;
    }
    CommandPaletteScreen .cmd-line {
        height: auto;
        width: 100%;
    }
    CommandPaletteScreen .cmd-name {
        text-style: bold;
        width: auto;
    }
    CommandPaletteScreen .cmd-description {
        color: $tui-text-muted;
    }
    CommandPaletteScreen .cmd-keybind {
        color: $tui-accent;
        text-align: right;
        dock: right;
        width: auto;
    }
    CommandPaletteScreen .empty {
        color: $tui-text-dim;
        text-align: center;
        padding: 1;
    }
    """

    BINDINGS = [
        ("escape", "dismiss_cancel", "Cancel"),
        ("ctrl+p", "dismiss_cancel", "Cancel"),
    ]

    def __init__(self, commands: list[BaseCommand]) -> None:
        super().__init__()
        self._commands = commands
        self._filtered = list(commands)
        self._list_items: list[ListItem] = []

    def compose(self) -> ComposeResult:
        with Container():
            yield Static("Command palette", classes="title")
            yield Input(placeholder="Type to filter commands...", id="palette-input")
            yield ListView(id="palette-list")

    def on_mount(self) -> None:
        self._render_list()
        self.query_one("#palette-input", Input).focus()

    def _render_list(self) -> None:
        list_view = self.query_one("#palette-list", ListView)
        list_view.clear()
        self._list_items = []
        if not self._filtered:
            list_view.append(ListItem(Static("No matching commands", classes="empty")))
            return
        for index, cmd in enumerate(self._filtered):
            item = self._build_item(index, cmd.meta)
            self._list_items.append(item)
            list_view.append(item)

    def _build_item(self, index: int, meta: CommandMeta) -> ListItem:
        keybind_text = f"  {meta.keybind}" if meta.keybind else ""
        aliases_text = f" ({', '.join(meta.aliases)})" if meta.aliases else ""
        name_line = f"/{meta.name}{aliases_text}"
        line = Label(f"{name_line}{keybind_text}", classes="cmd-line")
        line.renderable = self._render_rich_line(name_line, meta.description, keybind_text)
        return ListItem(line, id=f"palette-item-{index}-{meta.name}")

    def _render_rich_line(self, name: str, description: str, keybind: str):
        from rich.text import Text

        parts: list[Text] = []
        parts.append(Text(name, style="bold"))
        if keybind:
            parts.append(Text(keybind, style="dim"))
        parts.append(Text(f"  — {description}", style="dim"))
        return Text.assemble(*parts)

    def _filter(self, query: str) -> None:
        query = query.strip().lower()
        if not query:
            self._filtered = list(self._commands)
        else:
            self._filtered = [
                cmd
                for cmd in self._commands
                if self._matches(cmd, query)
            ]
        self._render_list()

    def _matches(self, cmd: BaseCommand, query: str) -> bool:
        meta = cmd.meta
        haystack = " ".join(
            [meta.name, " ".join(meta.aliases), meta.description, meta.keybind or ""]
        ).lower()
        return all(part in haystack for part in query.split())

    def on_input_changed(self, event: Input.Changed) -> None:
        self._filter(event.value)

    def on_input_submitted(self, event: Input.Submitted) -> None:
        if self._filtered:
            self.dismiss(self._filtered[0])

    def on_list_view_selected(self, event: ListView.Selected) -> None:
        index = self._list_items.index(event.item) if event.item in self._list_items else None
        if index is not None:
            self.dismiss(self._filtered[index])

    def on_key(self, event: events.Key) -> None:
        list_view = self.query_one("#palette-list", ListView)
        if event.key == "down":
            list_view.action_cursor_down()
            event.stop()
            event.prevent_default()
        elif event.key == "up":
            list_view.action_cursor_up()
            event.stop()
            event.prevent_default()
        elif event.key in ("enter", "return"):
            highlighted = list_view.index
            if 0 <= highlighted < len(self._filtered):
                self.dismiss(self._filtered[highlighted])
            elif self._filtered:
                self.dismiss(self._filtered[0])
            event.stop()
            event.prevent_default()
        elif event.key in ("escape", "ctrl+p"):
            self.dismiss(None)
            event.stop()
            event.prevent_default()

    def action_dismiss_cancel(self) -> None:
        self.dismiss(None)