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

1"""Modal listing every slash command, grouped and filterable; reads ``COMMANDS``.""" 

2 

3from __future__ import annotations 

4 

5import textwrap 

6from dataclasses import dataclass 

7from typing import ClassVar 

8 

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 

16 

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 

20 

21 

22@dataclass(frozen=True) 

23class CatalogGroup: 

24 """A named group of slash commands, ordered for display.""" 

25 

26 title: str 

27 members: tuple[str, ...] 

28 

29 

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 

42 

43 

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) 

75 

76 

77def _by_name() -> dict[str, SlashCommand]: 

78 return {cmd.name: cmd for cmd in COMMANDS} 

79 

80 

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() 

90 

91 

92class SlashCommandCatalog(ModalScreen[str | None]): 

93 """Modal browser for every slash command; dismisses with the picked name or ``None``.""" 

94 

95 CSS_PATH = "slash_command_catalog.tcss" 

96 

97 BINDINGS: ClassVar[list[BindingType]] = [ 

98 Binding("escape", "cancel", "Close", show=True), 

99 Binding("enter", "select", "Run", show=True), 

100 ] 

101 

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") 

108 

109 def on_mount(self) -> None: 

110 self._rebuild("") 

111 self.query_one("#catalog-filter", Input).focus() 

112 

113 def on_input_changed(self, event: Input.Changed) -> None: 

114 if event.input.id != "catalog-filter": 

115 return 

116 self._rebuild(event.value) 

117 

118 def on_input_submitted(self, event: Input.Submitted) -> None: 

119 if event.input.id != "catalog-filter": 

120 return 

121 self._select_first_match() 

122 

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) 

127 

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) 

140 

141 def action_cancel(self) -> None: 

142 self.dismiss(None) 

143 

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 

152 

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) 

156 

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 

167 

168 

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 

173 

174 

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 

188 

189 

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 

202 

203 

204def _render_header(title: str) -> Content: 

205 return Content.styled(title, "bold $primary") 

206 

207 

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)