Coverage for src / lilbee / cli / tui / widgets / slash_command_catalog.py: 100%

111 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-28 01:01 +0000

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

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import ClassVar 

7 

8from textual.app import ComposeResult 

9from textual.binding import Binding, BindingType 

10from textual.containers import Vertical 

11from textual.content import Content 

12from textual.screen import ModalScreen 

13from textual.widgets import Input, OptionList, Static 

14from textual.widgets.option_list import Option 

15 

16from lilbee.cli.tui import messages as msg 

17from lilbee.cli.tui.command_registry import COMMANDS, SlashCommand 

18 

19 

20@dataclass(frozen=True) 

21class CatalogGroup: 

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

23 

24 title: str 

25 members: tuple[str, ...] 

26 

27 

28# Visual layout constants for ``_render_row``: align the command name + 

29# args column at this width, with at least this much gutter before the 

30# help text starts. Picked to fit the longest /set <key> <value> entry. 

31_ROW_NAME_COLUMN_WIDTH = 28 

32_ROW_HELP_GUTTER_MIN = 2 

33 

34 

35CATALOG_GROUPS: tuple[CatalogGroup, ...] = ( 

36 CatalogGroup( 

37 "CHAT & SESSION", 

38 ("/clear", "/cancel", "/quit", "/help", "/status"), 

39 ), 

40 CatalogGroup( 

41 "MODELS", 

42 ("/model", "/models", "/setup"), 

43 ), 

44 CatalogGroup( 

45 "KNOWLEDGE", 

46 ("/add", "/crawl", "/wiki", "/delete", "/rebuild", "/export", "/import"), 

47 ), 

48 CatalogGroup( 

49 "MEMORY", 

50 ("/remember", "/memories"), 

51 ), 

52 CatalogGroup( 

53 "SETTINGS & SYSTEM", 

54 ("/settings", "/set", "/theme", "/reset", "/remove", "/login", "/version"), 

55 ), 

56) 

57 

58 

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

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

61 

62 

63def _matches(cmd: SlashCommand, query: str) -> bool: 

64 if not query: 

65 return True 

66 needle = query.lower().lstrip("/") 

67 if needle in cmd.name.lower(): 

68 return True 

69 if any(needle in alias.lower() for alias in cmd.aliases): 

70 return True 

71 return needle in cmd.help_text.lower() 

72 

73 

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

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

76 

77 CSS_PATH = "slash_command_catalog.tcss" 

78 

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

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

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

82 ] 

83 

84 def compose(self) -> ComposeResult: 

85 with Vertical(id="catalog-root"): 

86 yield Static(msg.SLASH_CATALOG_TITLE, id="catalog-title") 

87 yield Input(placeholder=msg.SLASH_CATALOG_FILTER_PLACEHOLDER, id="catalog-filter") 

88 yield OptionList(id="catalog-list") 

89 yield Static(msg.SLASH_CATALOG_FOOTER_HINT, id="catalog-hint") 

90 

91 def on_mount(self) -> None: 

92 self._rebuild("") 

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

94 

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

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

97 return 

98 self._rebuild(event.value) 

99 

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

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

102 return 

103 self._select_first_match() 

104 

105 def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: 

106 option_id = event.option.id 

107 if option_id and option_id.startswith("/"): 

108 self.dismiss(option_id) 

109 

110 def action_select(self) -> None: 

111 ol = self.query_one("#catalog-list", OptionList) 

112 index = ol.highlighted 

113 if index is None: 

114 self._select_first_match() 

115 return 

116 try: 

117 opt = ol.get_option_at_index(index) 

118 except IndexError: 

119 return 

120 if opt.id and opt.id.startswith("/"): 

121 self.dismiss(opt.id) 

122 

123 def action_cancel(self) -> None: 

124 self.dismiss(None) 

125 

126 def _select_first_match(self) -> None: 

127 """Dismiss with the first runnable command in the current filtered list.""" 

128 ol = self.query_one("#catalog-list", OptionList) 

129 for i in range(ol.option_count): 

130 opt = ol.get_option_at_index(i) 

131 if opt.id and opt.id.startswith("/"): 

132 self.dismiss(opt.id) 

133 return 

134 

135 def _rebuild(self, query: str) -> None: 

136 ol = self.query_one("#catalog-list", OptionList) 

137 ol.clear_options() 

138 groups = _filter_groups(query) 

139 if not groups: 

140 ol.add_option(Option(msg.SLASH_CATALOG_NO_MATCH, id=None, disabled=True)) 

141 return 

142 first_runnable = _populate_options(ol, groups) 

143 if first_runnable is not None: 

144 ol.highlighted = first_runnable 

145 

146 

147def _filter_groups(query: str) -> list[tuple[str, list[SlashCommand]]]: 

148 """Each ``CatalogGroup`` paired with its filtered (non-empty) command list.""" 

149 registry = _by_name() 

150 out: list[tuple[str, list[SlashCommand]]] = [] 

151 for group in CATALOG_GROUPS: 

152 matching = [ 

153 cmd 

154 for name in group.members 

155 if (cmd := registry.get(name)) is not None and _matches(cmd, query) 

156 ] 

157 if matching: 

158 out.append((group.title, matching)) 

159 return out 

160 

161 

162def _populate_options(ol: OptionList, groups: list[tuple[str, list[SlashCommand]]]) -> int | None: 

163 """Add header + command rows for each group, return the first runnable row index.""" 

164 first_runnable: int | None = None 

165 for title, commands in groups: 

166 ol.add_option(Option(_render_header(title), id=None, disabled=True)) 

167 for cmd in commands: 

168 if first_runnable is None: 

169 first_runnable = ol.option_count 

170 ol.add_option(Option(_render_row(cmd), id=cmd.name)) 

171 return first_runnable 

172 

173 

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

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

176 

177 

178def _render_row(cmd: SlashCommand) -> Content: 

179 name_part = Content.styled(f" {cmd.name}", "$success bold") 

180 args_part = Content.styled(f" {cmd.args_hint}", "$text-muted") if cmd.args_hint else Content("") 

181 visible_len = len(f" {cmd.name}") + (len(f" {cmd.args_hint}") if cmd.args_hint else 0) 

182 pad = " " * max(_ROW_HELP_GUTTER_MIN, _ROW_NAME_COLUMN_WIDTH - visible_len) 

183 help_part = Content.styled(f"{pad}{cmd.help_text}", "$text-muted") 

184 return Content.assemble(name_part, args_part, help_part)