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

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 ("/settings", "/set", "/theme", "/reset", "/remove", "/login", "/version"), 

82 ), 

83) 

84 

85 

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

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

88 

89 

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

99 

100 

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

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

103 

104 CSS_PATH = "slash_command_catalog.tcss" 

105 

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

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

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

109 ] 

110 

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

117 

118 def on_mount(self) -> None: 

119 self._rebuild("") 

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

121 

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

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

124 return 

125 self._rebuild(event.value) 

126 

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

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

129 return 

130 self._select_first_match() 

131 

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) 

136 

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) 

149 

150 def action_cancel(self) -> None: 

151 self.dismiss(None) 

152 

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 

161 

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) 

165 

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 

176 

177 

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 

182 

183 

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 

197 

198 

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 

211 

212 

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

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

215 

216 

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)