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-05-15 20:55 +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"), 

47 ), 

48 CatalogGroup( 

49 "SETTINGS & SYSTEM", 

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

51 ), 

52) 

53 

54 

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

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

57 

58 

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

60 if not query: 

61 return True 

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

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

64 return True 

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

66 return True 

67 return needle in cmd.help_text.lower() 

68 

69 

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

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

72 

73 CSS_PATH = "slash_command_catalog.tcss" 

74 

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

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

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

78 ] 

79 

80 def compose(self) -> ComposeResult: 

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

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

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

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

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

86 

87 def on_mount(self) -> None: 

88 self._rebuild("") 

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

90 

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

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

93 return 

94 self._rebuild(event.value) 

95 

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

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

98 return 

99 self._select_first_match() 

100 

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

102 option_id = event.option.id 

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

104 self.dismiss(option_id) 

105 

106 def action_select(self) -> None: 

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

108 index = ol.highlighted 

109 if index is None: 

110 self._select_first_match() 

111 return 

112 try: 

113 opt = ol.get_option_at_index(index) 

114 except IndexError: 

115 return 

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

117 self.dismiss(opt.id) 

118 

119 def action_cancel(self) -> None: 

120 self.dismiss(None) 

121 

122 def _select_first_match(self) -> None: 

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

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

125 for i in range(ol.option_count): 

126 opt = ol.get_option_at_index(i) 

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

128 self.dismiss(opt.id) 

129 return 

130 

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

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

133 ol.clear_options() 

134 groups = _filter_groups(query) 

135 if not groups: 

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

137 return 

138 first_runnable = _populate_options(ol, groups) 

139 if first_runnable is not None: 

140 ol.highlighted = first_runnable 

141 

142 

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

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

145 registry = _by_name() 

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

147 for group in CATALOG_GROUPS: 

148 matching = [ 

149 cmd 

150 for name in group.members 

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

152 ] 

153 if matching: 

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

155 return out 

156 

157 

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

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

160 first_runnable: int | None = None 

161 for title, commands in groups: 

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

163 for cmd in commands: 

164 if first_runnable is None: 

165 first_runnable = ol.option_count 

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

167 return first_runnable 

168 

169 

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

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

172 

173 

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

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

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

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

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

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

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