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

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

66 

67 

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

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

70 

71 

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

81 

82 

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

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

85 

86 CSS_PATH = "slash_command_catalog.tcss" 

87 

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

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

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

91 ] 

92 

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

99 

100 def on_mount(self) -> None: 

101 self._rebuild("") 

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

103 

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

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

106 return 

107 self._rebuild(event.value) 

108 

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

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

111 return 

112 self._select_first_match() 

113 

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) 

118 

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) 

131 

132 def action_cancel(self) -> None: 

133 self.dismiss(None) 

134 

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 

143 

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) 

147 

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 

158 

159 

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 

164 

165 

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 

179 

180 

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 

193 

194 

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

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

197 

198 

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)