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

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 ( 

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) 

94 

95 

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

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

98 

99 

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

109 

110 

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

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

113 

114 CSS_PATH = "slash_command_catalog.tcss" 

115 

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

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

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

119 ] 

120 

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

127 

128 def on_mount(self) -> None: 

129 self._rebuild("") 

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

131 

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

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

134 return 

135 self._rebuild(event.value) 

136 

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

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

139 return 

140 self._select_first_match() 

141 

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) 

146 

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) 

159 

160 def action_cancel(self) -> None: 

161 self.dismiss(None) 

162 

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 

171 

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) 

175 

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 

186 

187 

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 

192 

193 

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 

207 

208 

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 

221 

222 

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

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

225 

226 

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)