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-06-28 01:01 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +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", "/rebuild", "/export", "/import"),
47 ),
48 CatalogGroup(
49 "MEMORY",
50 ("/remember", "/memories"),
51 ),
52 CatalogGroup(
53 "SETTINGS & SYSTEM",
54 ("/settings", "/set", "/theme", "/reset", "/remove", "/login", "/version"),
55 ),
56)
59def _by_name() -> dict[str, SlashCommand]:
60 return {cmd.name: cmd for cmd in COMMANDS}
63def _matches(cmd: SlashCommand, query: str) -> bool:
64 if not query:
65 return True
66 needle = query.lower().lstrip("/")
67 if needle in cmd.name.lower():
68 return True
69 if any(needle in alias.lower() for alias in cmd.aliases):
70 return True
71 return needle in cmd.help_text.lower()
74class SlashCommandCatalog(ModalScreen[str | None]):
75 """Modal browser for every slash command; dismisses with the picked name or ``None``."""
77 CSS_PATH = "slash_command_catalog.tcss"
79 BINDINGS: ClassVar[list[BindingType]] = [
80 Binding("escape", "cancel", "Close", show=True),
81 Binding("enter", "select", "Run", show=True),
82 ]
84 def compose(self) -> ComposeResult:
85 with Vertical(id="catalog-root"):
86 yield Static(msg.SLASH_CATALOG_TITLE, id="catalog-title")
87 yield Input(placeholder=msg.SLASH_CATALOG_FILTER_PLACEHOLDER, id="catalog-filter")
88 yield OptionList(id="catalog-list")
89 yield Static(msg.SLASH_CATALOG_FOOTER_HINT, id="catalog-hint")
91 def on_mount(self) -> None:
92 self._rebuild("")
93 self.query_one("#catalog-filter", Input).focus()
95 def on_input_changed(self, event: Input.Changed) -> None:
96 if event.input.id != "catalog-filter":
97 return
98 self._rebuild(event.value)
100 def on_input_submitted(self, event: Input.Submitted) -> None:
101 if event.input.id != "catalog-filter":
102 return
103 self._select_first_match()
105 def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
106 option_id = event.option.id
107 if option_id and option_id.startswith("/"):
108 self.dismiss(option_id)
110 def action_select(self) -> None:
111 ol = self.query_one("#catalog-list", OptionList)
112 index = ol.highlighted
113 if index is None:
114 self._select_first_match()
115 return
116 try:
117 opt = ol.get_option_at_index(index)
118 except IndexError:
119 return
120 if opt.id and opt.id.startswith("/"):
121 self.dismiss(opt.id)
123 def action_cancel(self) -> None:
124 self.dismiss(None)
126 def _select_first_match(self) -> None:
127 """Dismiss with the first runnable command in the current filtered list."""
128 ol = self.query_one("#catalog-list", OptionList)
129 for i in range(ol.option_count):
130 opt = ol.get_option_at_index(i)
131 if opt.id and opt.id.startswith("/"):
132 self.dismiss(opt.id)
133 return
135 def _rebuild(self, query: str) -> None:
136 ol = self.query_one("#catalog-list", OptionList)
137 ol.clear_options()
138 groups = _filter_groups(query)
139 if not groups:
140 ol.add_option(Option(msg.SLASH_CATALOG_NO_MATCH, id=None, disabled=True))
141 return
142 first_runnable = _populate_options(ol, groups)
143 if first_runnable is not None:
144 ol.highlighted = first_runnable
147def _filter_groups(query: str) -> list[tuple[str, list[SlashCommand]]]:
148 """Each ``CatalogGroup`` paired with its filtered (non-empty) command list."""
149 registry = _by_name()
150 out: list[tuple[str, list[SlashCommand]]] = []
151 for group in CATALOG_GROUPS:
152 matching = [
153 cmd
154 for name in group.members
155 if (cmd := registry.get(name)) is not None and _matches(cmd, query)
156 ]
157 if matching:
158 out.append((group.title, matching))
159 return out
162def _populate_options(ol: OptionList, groups: list[tuple[str, list[SlashCommand]]]) -> int | None:
163 """Add header + command rows for each group, return the first runnable row index."""
164 first_runnable: int | None = None
165 for title, commands in groups:
166 ol.add_option(Option(_render_header(title), id=None, disabled=True))
167 for cmd in commands:
168 if first_runnable is None:
169 first_runnable = ol.option_count
170 ol.add_option(Option(_render_row(cmd), id=cmd.name))
171 return first_runnable
174def _render_header(title: str) -> Content:
175 return Content.styled(title, "bold $primary")
178def _render_row(cmd: SlashCommand) -> Content:
179 name_part = Content.styled(f" {cmd.name}", "$success bold")
180 args_part = Content.styled(f" {cmd.args_hint}", "$text-muted") if cmd.args_hint else Content("")
181 visible_len = len(f" {cmd.name}") + (len(f" {cmd.args_hint}") if cmd.args_hint else 0)
182 pad = " " * max(_ROW_HELP_GUTTER_MIN, _ROW_NAME_COLUMN_WIDTH - visible_len)
183 help_part = Content.styled(f"{pad}{cmd.help_text}", "$text-muted")
184 return Content.assemble(name_part, args_part, help_part)