Coverage for src/lilbee/cli/tui/widgets/slash_command_catalog.py: 100%
126 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
1"""Modal listing every slash command, grouped and filterable; reads ``COMMANDS``."""
3from __future__ import annotations
5import textwrap
6from dataclasses import dataclass
7from typing import ClassVar
9from textual.app import ComposeResult
10from textual.binding import Binding, BindingType
11from textual.containers import Vertical
12from textual.content import Content
13from textual.screen import ModalScreen
14from textual.widgets import Input, OptionList, Static
15from textual.widgets.option_list import Option
17from lilbee.cli.tui import messages as msg
18from lilbee.cli.tui.command_registry import COMMANDS, SlashCommand
19from lilbee.cli.tui.widgets.clamped_option_list import ClampedOptionList
22@dataclass(frozen=True)
23class CatalogGroup:
24 """A named group of slash commands, ordered for display."""
26 title: str
27 members: tuple[str, ...]
30# Visual layout constants for ``_render_row``: align the command name +
31# args column at this width, with at least this much gutter before the
32# help text starts. Picked to fit the longest /set <key> <value> entry.
33# Help text wraps at the row width with a hanging indent so wrapped
34# lines stay in the description column; the fallback width matches the
35# option area of the default 70-col modal before layout has run.
36_ROW_NAME_COLUMN_WIDTH = 28
37_ROW_HELP_GUTTER_MIN = 2
38_ROW_HELP_MIN_WIDTH = 16
39_ROW_FALLBACK_WIDTH = 64
40# Horizontal padding the .option-list--option rule adds around each row.
41_ROW_OPTION_PADDING = 2
44CATALOG_GROUPS: tuple[CatalogGroup, ...] = (
45 CatalogGroup(
46 "CHAT & SESSION",
47 ("/sessions", "/clear", "/cancel", "/quit", "/help", "/status"),
48 ),
49 CatalogGroup(
50 "MODELS",
51 ("/model", "/models", "/setup"),
52 ),
53 CatalogGroup(
54 "KNOWLEDGE",
55 (
56 "/add",
57 "/crawl",
58 "/wiki",
59 "/delete",
60 "/prune-ignored",
61 "/rebuild",
62 "/export",
63 "/import",
64 ),
65 ),
66 CatalogGroup(
67 "MEMORY",
68 ("/remember", "/memories"),
69 ),
70 CatalogGroup(
71 "SETTINGS & SYSTEM",
72 ("/settings", "/set", "/theme", "/reset", "/remove", "/login", "/version"),
73 ),
74)
77def _by_name() -> dict[str, SlashCommand]:
78 return {cmd.name: cmd for cmd in COMMANDS}
81def _matches(cmd: SlashCommand, query: str) -> bool:
82 if not query:
83 return True
84 needle = query.lower().lstrip("/")
85 if needle in cmd.name.lower():
86 return True
87 if any(needle in alias.lower() for alias in cmd.aliases):
88 return True
89 return needle in cmd.help_text.lower()
92class SlashCommandCatalog(ModalScreen[str | None]):
93 """Modal browser for every slash command; dismisses with the picked name or ``None``."""
95 CSS_PATH = "slash_command_catalog.tcss"
97 BINDINGS: ClassVar[list[BindingType]] = [
98 Binding("escape", "cancel", "Close", show=True),
99 Binding("enter", "select", "Run", show=True),
100 ]
102 def compose(self) -> ComposeResult:
103 with Vertical(id="catalog-root"):
104 yield Static(msg.SLASH_CATALOG_TITLE, id="catalog-title")
105 yield Input(placeholder=msg.SLASH_CATALOG_FILTER_PLACEHOLDER, id="catalog-filter")
106 yield ClampedOptionList(id="catalog-list")
107 yield Static(msg.SLASH_CATALOG_FOOTER_HINT, id="catalog-hint")
109 def on_mount(self) -> None:
110 self._rebuild("")
111 self.query_one("#catalog-filter", Input).focus()
113 def on_input_changed(self, event: Input.Changed) -> None:
114 if event.input.id != "catalog-filter":
115 return
116 self._rebuild(event.value)
118 def on_input_submitted(self, event: Input.Submitted) -> None:
119 if event.input.id != "catalog-filter":
120 return
121 self._select_first_match()
123 def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
124 option_id = event.option.id
125 if option_id and option_id.startswith("/"):
126 self.dismiss(option_id)
128 def action_select(self) -> None:
129 ol = self.query_one("#catalog-list", OptionList)
130 index = ol.highlighted
131 if index is None:
132 self._select_first_match()
133 return
134 try:
135 opt = ol.get_option_at_index(index)
136 except IndexError:
137 return
138 if opt.id and opt.id.startswith("/"):
139 self.dismiss(opt.id)
141 def action_cancel(self) -> None:
142 self.dismiss(None)
144 def _select_first_match(self) -> None:
145 """Dismiss with the first runnable command in the current filtered list."""
146 ol = self.query_one("#catalog-list", OptionList)
147 for i in range(ol.option_count):
148 opt = ol.get_option_at_index(i)
149 if opt.id and opt.id.startswith("/"):
150 self.dismiss(opt.id)
151 return
153 def on_resize(self) -> None:
154 """Re-render rows so help wrapping tracks the option list's width."""
155 self._rebuild(self.query_one("#catalog-filter", Input).value)
157 def _rebuild(self, query: str) -> None:
158 ol = self.query_one("#catalog-list", OptionList)
159 ol.clear_options()
160 groups = _filter_groups(query)
161 if not groups:
162 ol.add_option(Option(msg.SLASH_CATALOG_NO_MATCH, id=None, disabled=True))
163 return
164 first_runnable = _populate_options(ol, groups, _row_width(ol))
165 if first_runnable is not None:
166 ol.highlighted = first_runnable
169def _row_width(ol: OptionList) -> int:
170 """Usable text width of one option row, before layout the fallback width."""
171 width = ol.scrollable_content_region.width - _ROW_OPTION_PADDING
172 return width if width > 0 else _ROW_FALLBACK_WIDTH
175def _filter_groups(query: str) -> list[tuple[str, list[SlashCommand]]]:
176 """Each ``CatalogGroup`` paired with its filtered (non-empty) command list."""
177 registry = _by_name()
178 out: list[tuple[str, list[SlashCommand]]] = []
179 for group in CATALOG_GROUPS:
180 matching = [
181 cmd
182 for name in group.members
183 if (cmd := registry.get(name)) is not None and _matches(cmd, query)
184 ]
185 if matching:
186 out.append((group.title, matching))
187 return out
190def _populate_options(
191 ol: OptionList, groups: list[tuple[str, list[SlashCommand]]], width: int
192) -> int | None:
193 """Add header + command rows for each group, return the first runnable row index."""
194 first_runnable: int | None = None
195 for title, commands in groups:
196 ol.add_option(Option(_render_header(title), id=None, disabled=True))
197 for cmd in commands:
198 if first_runnable is None:
199 first_runnable = ol.option_count
200 ol.add_option(Option(_render_row(cmd, width), id=cmd.name))
201 return first_runnable
204def _render_header(title: str) -> Content:
205 return Content.styled(title, "bold $primary")
208def _render_row(cmd: SlashCommand, width: int) -> Content:
209 """One row at *width* cols: name + args column, help with a hanging indent."""
210 lead = f" {cmd.name}"
211 args = f" {cmd.args_hint}" if cmd.args_hint else ""
212 help_col = max(_ROW_NAME_COLUMN_WIDTH, len(lead) + len(args) + _ROW_HELP_GUTTER_MIN)
213 help_width = max(_ROW_HELP_MIN_WIDTH, width - help_col)
214 wrapped = textwrap.wrap(cmd.help_text, help_width) or [""]
215 first_pad = " " * (help_col - len(lead) - len(args))
216 help_block = first_pad + ("\n" + " " * help_col).join(wrapped)
217 name_part = Content.styled(lead, "$success bold")
218 args_part = Content.styled(args, "$text-muted") if args else Content("")
219 help_part = Content.styled(help_block, "$text-muted")
220 return Content.assemble(name_part, args_part, help_part)