Coverage for src / lilbee / cli / tui / widgets / slash_command_catalog.py: 100%
111 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""Modal listing every slash command, grouped and filterable; reads ``COMMANDS``."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import ClassVar
8from textual.app import ComposeResult
9from textual.binding import Binding, BindingType
10from textual.containers import Vertical
11from textual.content import Content
12from textual.screen import ModalScreen
13from textual.widgets import Input, OptionList, Static
14from textual.widgets.option_list import Option
16from lilbee.cli.tui import messages as msg
17from lilbee.cli.tui.command_registry import COMMANDS, SlashCommand
20@dataclass(frozen=True)
21class CatalogGroup:
22 """A named group of slash commands, ordered for display."""
24 title: str
25 members: tuple[str, ...]
28# Visual layout constants for ``_render_row``: align the command name +
29# args column at this width, with at least this much gutter before the
30# help text starts. Picked to fit the longest /set <key> <value> entry.
31_ROW_NAME_COLUMN_WIDTH = 28
32_ROW_HELP_GUTTER_MIN = 2
35CATALOG_GROUPS: tuple[CatalogGroup, ...] = (
36 CatalogGroup(
37 "CHAT & SESSION",
38 ("/clear", "/cancel", "/quit", "/help", "/status"),
39 ),
40 CatalogGroup(
41 "MODELS",
42 ("/model", "/models", "/setup"),
43 ),
44 CatalogGroup(
45 "KNOWLEDGE",
46 ("/add", "/crawl", "/wiki", "/delete"),
47 ),
48 CatalogGroup(
49 "SETTINGS & SYSTEM",
50 ("/settings", "/set", "/theme", "/reset", "/remove", "/login", "/version"),
51 ),
52)
55def _by_name() -> dict[str, SlashCommand]:
56 return {cmd.name: cmd for cmd in COMMANDS}
59def _matches(cmd: SlashCommand, query: str) -> bool:
60 if not query:
61 return True
62 needle = query.lower().lstrip("/")
63 if needle in cmd.name.lower():
64 return True
65 if any(needle in alias.lower() for alias in cmd.aliases):
66 return True
67 return needle in cmd.help_text.lower()
70class SlashCommandCatalog(ModalScreen[str | None]):
71 """Modal browser for every slash command; dismisses with the picked name or ``None``."""
73 CSS_PATH = "slash_command_catalog.tcss"
75 BINDINGS: ClassVar[list[BindingType]] = [
76 Binding("escape", "cancel", "Close", show=True),
77 Binding("enter", "select", "Run", show=True),
78 ]
80 def compose(self) -> ComposeResult:
81 with Vertical(id="catalog-root"):
82 yield Static(msg.SLASH_CATALOG_TITLE, id="catalog-title")
83 yield Input(placeholder=msg.SLASH_CATALOG_FILTER_PLACEHOLDER, id="catalog-filter")
84 yield OptionList(id="catalog-list")
85 yield Static(msg.SLASH_CATALOG_FOOTER_HINT, id="catalog-hint")
87 def on_mount(self) -> None:
88 self._rebuild("")
89 self.query_one("#catalog-filter", Input).focus()
91 def on_input_changed(self, event: Input.Changed) -> None:
92 if event.input.id != "catalog-filter":
93 return
94 self._rebuild(event.value)
96 def on_input_submitted(self, event: Input.Submitted) -> None:
97 if event.input.id != "catalog-filter":
98 return
99 self._select_first_match()
101 def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
102 option_id = event.option.id
103 if option_id and option_id.startswith("/"):
104 self.dismiss(option_id)
106 def action_select(self) -> None:
107 ol = self.query_one("#catalog-list", OptionList)
108 index = ol.highlighted
109 if index is None:
110 self._select_first_match()
111 return
112 try:
113 opt = ol.get_option_at_index(index)
114 except IndexError:
115 return
116 if opt.id and opt.id.startswith("/"):
117 self.dismiss(opt.id)
119 def action_cancel(self) -> None:
120 self.dismiss(None)
122 def _select_first_match(self) -> None:
123 """Dismiss with the first runnable command in the current filtered list."""
124 ol = self.query_one("#catalog-list", OptionList)
125 for i in range(ol.option_count):
126 opt = ol.get_option_at_index(i)
127 if opt.id and opt.id.startswith("/"):
128 self.dismiss(opt.id)
129 return
131 def _rebuild(self, query: str) -> None:
132 ol = self.query_one("#catalog-list", OptionList)
133 ol.clear_options()
134 groups = _filter_groups(query)
135 if not groups:
136 ol.add_option(Option(msg.SLASH_CATALOG_NO_MATCH, id=None, disabled=True))
137 return
138 first_runnable = _populate_options(ol, groups)
139 if first_runnable is not None:
140 ol.highlighted = first_runnable
143def _filter_groups(query: str) -> list[tuple[str, list[SlashCommand]]]:
144 """Each ``CatalogGroup`` paired with its filtered (non-empty) command list."""
145 registry = _by_name()
146 out: list[tuple[str, list[SlashCommand]]] = []
147 for group in CATALOG_GROUPS:
148 matching = [
149 cmd
150 for name in group.members
151 if (cmd := registry.get(name)) is not None and _matches(cmd, query)
152 ]
153 if matching:
154 out.append((group.title, matching))
155 return out
158def _populate_options(ol: OptionList, groups: list[tuple[str, list[SlashCommand]]]) -> int | None:
159 """Add header + command rows for each group, return the first runnable row index."""
160 first_runnable: int | None = None
161 for title, commands in groups:
162 ol.add_option(Option(_render_header(title), id=None, disabled=True))
163 for cmd in commands:
164 if first_runnable is None:
165 first_runnable = ol.option_count
166 ol.add_option(Option(_render_row(cmd), id=cmd.name))
167 return first_runnable
170def _render_header(title: str) -> Content:
171 return Content.styled(title, "bold $primary")
174def _render_row(cmd: SlashCommand) -> Content:
175 name_part = Content.styled(f" {cmd.name}", "$success bold")
176 args_part = Content.styled(f" {cmd.args_hint}", "$text-muted") if cmd.args_hint else Content("")
177 visible_len = len(f" {cmd.name}") + (len(f" {cmd.args_hint}") if cmd.args_hint else 0)
178 pad = " " * max(_ROW_HELP_GUTTER_MIN, _ROW_NAME_COLUMN_WIDTH - visible_len)
179 help_part = Content.styled(f"{pad}{cmd.help_text}", "$text-muted")
180 return Content.assemble(name_part, args_part, help_part)