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