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-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +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 (
48 "/sessions",
49 "/fork",
50 "/export-chat",
51 "/clear",
52 "/cancel",
53 "/quit",
54 "/help",
55 "/status",
56 ),
57 ),
58 CatalogGroup(
59 "MODELS",
60 ("/model", "/models", "/setup"),
61 ),
62 CatalogGroup(
63 "KNOWLEDGE",
64 (
65 "/add",
66 "/crawl",
67 "/wiki",
68 "/delete",
69 "/prune-ignored",
70 "/rebuild",
71 "/export",
72 "/import",
73 ),
74 ),
75 CatalogGroup(
76 "MEMORY",
77 ("/remember", "/memories"),
78 ),
79 CatalogGroup(
80 "SETTINGS & SYSTEM",
81 (
82 "/settings",
83 "/set",
84 "/profile",
85 "/analyze",
86 "/theme",
87 "/reset",
88 "/remove",
89 "/login",
90 "/version",
91 ),
92 ),
93)
96def _by_name() -> dict[str, SlashCommand]:
97 return {cmd.name: cmd for cmd in COMMANDS}
100def _matches(cmd: SlashCommand, query: str) -> bool:
101 if not query:
102 return True
103 needle = query.lower().lstrip("/")
104 if needle in cmd.name.lower():
105 return True
106 if any(needle in alias.lower() for alias in cmd.aliases):
107 return True
108 return needle in cmd.help_text.lower()
111class SlashCommandCatalog(ModalScreen[str | None]):
112 """Modal browser for every slash command; dismisses with the picked name or ``None``."""
114 CSS_PATH = "slash_command_catalog.tcss"
116 BINDINGS: ClassVar[list[BindingType]] = [
117 Binding("escape", "cancel", "Close", show=True),
118 Binding("enter", "select", "Run", show=True),
119 ]
121 def compose(self) -> ComposeResult:
122 with Vertical(id="catalog-root"):
123 yield Static(msg.SLASH_CATALOG_TITLE, id="catalog-title")
124 yield Input(placeholder=msg.SLASH_CATALOG_FILTER_PLACEHOLDER, id="catalog-filter")
125 yield ClampedOptionList(id="catalog-list")
126 yield Static(msg.SLASH_CATALOG_FOOTER_HINT, id="catalog-hint")
128 def on_mount(self) -> None:
129 self._rebuild("")
130 self.query_one("#catalog-filter", Input).focus()
132 def on_input_changed(self, event: Input.Changed) -> None:
133 if event.input.id != "catalog-filter":
134 return
135 self._rebuild(event.value)
137 def on_input_submitted(self, event: Input.Submitted) -> None:
138 if event.input.id != "catalog-filter":
139 return
140 self._select_first_match()
142 def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
143 option_id = event.option.id
144 if option_id and option_id.startswith("/"):
145 self.dismiss(option_id)
147 def action_select(self) -> None:
148 ol = self.query_one("#catalog-list", OptionList)
149 index = ol.highlighted
150 if index is None:
151 self._select_first_match()
152 return
153 try:
154 opt = ol.get_option_at_index(index)
155 except IndexError:
156 return
157 if opt.id and opt.id.startswith("/"):
158 self.dismiss(opt.id)
160 def action_cancel(self) -> None:
161 self.dismiss(None)
163 def _select_first_match(self) -> None:
164 """Dismiss with the first runnable command in the current filtered list."""
165 ol = self.query_one("#catalog-list", OptionList)
166 for i in range(ol.option_count):
167 opt = ol.get_option_at_index(i)
168 if opt.id and opt.id.startswith("/"):
169 self.dismiss(opt.id)
170 return
172 def on_resize(self) -> None:
173 """Re-render rows so help wrapping tracks the option list's width."""
174 self._rebuild(self.query_one("#catalog-filter", Input).value)
176 def _rebuild(self, query: str) -> None:
177 ol = self.query_one("#catalog-list", OptionList)
178 ol.clear_options()
179 groups = _filter_groups(query)
180 if not groups:
181 ol.add_option(Option(msg.SLASH_CATALOG_NO_MATCH, id=None, disabled=True))
182 return
183 first_runnable = _populate_options(ol, groups, _row_width(ol))
184 if first_runnable is not None:
185 ol.highlighted = first_runnable
188def _row_width(ol: OptionList) -> int:
189 """Usable text width of one option row, before layout the fallback width."""
190 width = ol.scrollable_content_region.width - _ROW_OPTION_PADDING
191 return width if width > 0 else _ROW_FALLBACK_WIDTH
194def _filter_groups(query: str) -> list[tuple[str, list[SlashCommand]]]:
195 """Each ``CatalogGroup`` paired with its filtered (non-empty) command list."""
196 registry = _by_name()
197 out: list[tuple[str, list[SlashCommand]]] = []
198 for group in CATALOG_GROUPS:
199 matching = [
200 cmd
201 for name in group.members
202 if (cmd := registry.get(name)) is not None and _matches(cmd, query)
203 ]
204 if matching:
205 out.append((group.title, matching))
206 return out
209def _populate_options(
210 ol: OptionList, groups: list[tuple[str, list[SlashCommand]]], width: int
211) -> int | None:
212 """Add header + command rows for each group, return the first runnable row index."""
213 first_runnable: int | None = None
214 for title, commands in groups:
215 ol.add_option(Option(_render_header(title), id=None, disabled=True))
216 for cmd in commands:
217 if first_runnable is None:
218 first_runnable = ol.option_count
219 ol.add_option(Option(_render_row(cmd, width), id=cmd.name))
220 return first_runnable
223def _render_header(title: str) -> Content:
224 return Content.styled(title, "bold $primary")
227def _render_row(cmd: SlashCommand, width: int) -> Content:
228 """One row at *width* cols: name + args column, help with a hanging indent."""
229 lead = f" {cmd.name}"
230 args = f" {cmd.args_hint}" if cmd.args_hint else ""
231 help_col = max(_ROW_NAME_COLUMN_WIDTH, len(lead) + len(args) + _ROW_HELP_GUTTER_MIN)
232 help_width = max(_ROW_HELP_MIN_WIDTH, width - help_col)
233 wrapped = textwrap.wrap(cmd.help_text, help_width) or [""]
234 first_pad = " " * (help_col - len(lead) - len(args))
235 help_block = first_pad + ("\n" + " " * help_col).join(wrapped)
236 name_part = Content.styled(lead, "$success bold")
237 args_part = Content.styled(args, "$text-muted") if args else Content("")
238 help_part = Content.styled(help_block, "$text-muted")
239 return Content.assemble(name_part, args_part, help_part)