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-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17: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 (
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 ("/settings", "/set", "/theme", "/reset", "/remove", "/login", "/version"),
82 ),
83)
86def _by_name() -> dict[str, SlashCommand]:
87 return {cmd.name: cmd for cmd in COMMANDS}
90def _matches(cmd: SlashCommand, query: str) -> bool:
91 if not query:
92 return True
93 needle = query.lower().lstrip("/")
94 if needle in cmd.name.lower():
95 return True
96 if any(needle in alias.lower() for alias in cmd.aliases):
97 return True
98 return needle in cmd.help_text.lower()
101class SlashCommandCatalog(ModalScreen[str | None]):
102 """Modal browser for every slash command; dismisses with the picked name or ``None``."""
104 CSS_PATH = "slash_command_catalog.tcss"
106 BINDINGS: ClassVar[list[BindingType]] = [
107 Binding("escape", "cancel", "Close", show=True),
108 Binding("enter", "select", "Run", show=True),
109 ]
111 def compose(self) -> ComposeResult:
112 with Vertical(id="catalog-root"):
113 yield Static(msg.SLASH_CATALOG_TITLE, id="catalog-title")
114 yield Input(placeholder=msg.SLASH_CATALOG_FILTER_PLACEHOLDER, id="catalog-filter")
115 yield ClampedOptionList(id="catalog-list")
116 yield Static(msg.SLASH_CATALOG_FOOTER_HINT, id="catalog-hint")
118 def on_mount(self) -> None:
119 self._rebuild("")
120 self.query_one("#catalog-filter", Input).focus()
122 def on_input_changed(self, event: Input.Changed) -> None:
123 if event.input.id != "catalog-filter":
124 return
125 self._rebuild(event.value)
127 def on_input_submitted(self, event: Input.Submitted) -> None:
128 if event.input.id != "catalog-filter":
129 return
130 self._select_first_match()
132 def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
133 option_id = event.option.id
134 if option_id and option_id.startswith("/"):
135 self.dismiss(option_id)
137 def action_select(self) -> None:
138 ol = self.query_one("#catalog-list", OptionList)
139 index = ol.highlighted
140 if index is None:
141 self._select_first_match()
142 return
143 try:
144 opt = ol.get_option_at_index(index)
145 except IndexError:
146 return
147 if opt.id and opt.id.startswith("/"):
148 self.dismiss(opt.id)
150 def action_cancel(self) -> None:
151 self.dismiss(None)
153 def _select_first_match(self) -> None:
154 """Dismiss with the first runnable command in the current filtered list."""
155 ol = self.query_one("#catalog-list", OptionList)
156 for i in range(ol.option_count):
157 opt = ol.get_option_at_index(i)
158 if opt.id and opt.id.startswith("/"):
159 self.dismiss(opt.id)
160 return
162 def on_resize(self) -> None:
163 """Re-render rows so help wrapping tracks the option list's width."""
164 self._rebuild(self.query_one("#catalog-filter", Input).value)
166 def _rebuild(self, query: str) -> None:
167 ol = self.query_one("#catalog-list", OptionList)
168 ol.clear_options()
169 groups = _filter_groups(query)
170 if not groups:
171 ol.add_option(Option(msg.SLASH_CATALOG_NO_MATCH, id=None, disabled=True))
172 return
173 first_runnable = _populate_options(ol, groups, _row_width(ol))
174 if first_runnable is not None:
175 ol.highlighted = first_runnable
178def _row_width(ol: OptionList) -> int:
179 """Usable text width of one option row, before layout the fallback width."""
180 width = ol.scrollable_content_region.width - _ROW_OPTION_PADDING
181 return width if width > 0 else _ROW_FALLBACK_WIDTH
184def _filter_groups(query: str) -> list[tuple[str, list[SlashCommand]]]:
185 """Each ``CatalogGroup`` paired with its filtered (non-empty) command list."""
186 registry = _by_name()
187 out: list[tuple[str, list[SlashCommand]]] = []
188 for group in CATALOG_GROUPS:
189 matching = [
190 cmd
191 for name in group.members
192 if (cmd := registry.get(name)) is not None and _matches(cmd, query)
193 ]
194 if matching:
195 out.append((group.title, matching))
196 return out
199def _populate_options(
200 ol: OptionList, groups: list[tuple[str, list[SlashCommand]]], width: int
201) -> int | None:
202 """Add header + command rows for each group, return the first runnable row index."""
203 first_runnable: int | None = None
204 for title, commands in groups:
205 ol.add_option(Option(_render_header(title), id=None, disabled=True))
206 for cmd in commands:
207 if first_runnable is None:
208 first_runnable = ol.option_count
209 ol.add_option(Option(_render_row(cmd, width), id=cmd.name))
210 return first_runnable
213def _render_header(title: str) -> Content:
214 return Content.styled(title, "bold $primary")
217def _render_row(cmd: SlashCommand, width: int) -> Content:
218 """One row at *width* cols: name + args column, help with a hanging indent."""
219 lead = f" {cmd.name}"
220 args = f" {cmd.args_hint}" if cmd.args_hint else ""
221 help_col = max(_ROW_NAME_COLUMN_WIDTH, len(lead) + len(args) + _ROW_HELP_GUTTER_MIN)
222 help_width = max(_ROW_HELP_MIN_WIDTH, width - help_col)
223 wrapped = textwrap.wrap(cmd.help_text, help_width) or [""]
224 first_pad = " " * (help_col - len(lead) - len(args))
225 help_block = first_pad + ("\n" + " " * help_col).join(wrapped)
226 name_part = Content.styled(lead, "$success bold")
227 args_part = Content.styled(args, "$text-muted") if args else Content("")
228 help_part = Content.styled(help_block, "$text-muted")
229 return Content.assemble(name_part, args_part, help_part)