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