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

81 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-17 10:02 +0000

1"""Command palette provider for lilbee TUI.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from typing import TYPE_CHECKING, Any, cast 

7 

8from textual.command import Hit, Hits, Provider 

9 

10from lilbee.catalog import display_label_for_ref 

11from lilbee.cli.tui import messages as msg 

12from lilbee.cli.tui.command_registry import COMMANDS, SlashCommand, get_command 

13from lilbee.core.config import cfg 

14 

15log = logging.getLogger(__name__) 

16 

17if TYPE_CHECKING: 

18 from lilbee.cli.tui.app import LilbeeApp 

19 

20 

21class LilbeeCommandProvider(Provider): 

22 """Provides searchable commands for the Textual command palette (Ctrl+P).""" 

23 

24 @property 

25 def _app(self) -> LilbeeApp: 

26 return cast("LilbeeApp", self.screen.app) 

27 

28 async def search(self, query: str) -> Hits: 

29 matcher = self.matcher(query) 

30 for cmd_text, help_text, action in self._get_commands(): 

31 score = matcher.match(cmd_text) 

32 if score > 0: 

33 yield Hit(score, matcher.highlight(cmd_text), action, help=help_text) 

34 

35 async def discover(self) -> Hits: 

36 for cmd_text, help_text, action in self._get_commands(): 

37 yield Hit(1.0, cmd_text, action, help=help_text) 

38 

39 def _get_commands(self) -> list[tuple[str, str, Any]]: 

40 app = self._app 

41 commands: list[tuple[str, str, Any]] = [ 

42 ("Open chat", "Ask questions about your knowledge base", app.action_open_chat), 

43 ("Open catalog", "Browse and install models", app.action_open_catalog), 

44 ("Open status", "Knowledge base status", lambda: app.switch_view("Status")), 

45 ("Open settings", "View and change settings", lambda: app.switch_view("Settings")), 

46 ("Open task center", "Monitor background tasks", lambda: app.switch_view("Tasks")), 

47 ("Help", "Show keybinding reference", app.action_push_help), 

48 ("Cycle theme", "Switch to next color theme", app.action_cycle_theme), 

49 ("Sync documents", "Sync knowledge base", self._action_sync), 

50 ( 

51 "Retry skipped documents", 

52 "Re-attempt files that failed a previous sync", 

53 self._action_retry_skipped, 

54 ), 

55 ( 

56 "Delete document", 

57 "Remove a file from the index (Tab completes names)", 

58 self._action_delete_document, 

59 ), 

60 ( 

61 "Prune ignored documents", 

62 "Drop indexed documents a .lilbeeignore now excludes", 

63 self._action_prune_ignored, 

64 ), 

65 ("Open wiki", "Browse and generate wiki pages", self._action_open_wiki), 

66 ( 

67 "Wikify", 

68 "Generate wiki pages from indexed documents (GPU-heavy)", 

69 self._action_wikify, 

70 ), 

71 ( 

72 "Delete wiki", 

73 "Remove every generated wiki page and its indexed rows", 

74 self._action_wipe_wiki, 

75 ), 

76 ("Show version", "Display lilbee version", self._action_version), 

77 ( 

78 "Reset knowledge base", 

79 "Delete all data (asks for confirmation)", 

80 self._action_reset, 

81 ), 

82 ("Quit", "Exit lilbee", app.action_quit), 

83 ] 

84 

85 commands.extend(self._slash_commands()) 

86 commands.extend(self._model_commands()) 

87 return commands 

88 

89 def _slash_commands(self) -> list[tuple[str, str, Any]]: 

90 """One palette entry per slash command, mirroring the chat surface.""" 

91 return [ 

92 (cmd.name, cmd.help_text, lambda c=cmd: self._run_slash_command(c)) for cmd in COMMANDS 

93 ] 

94 

95 def _run_slash_command(self, cmd: SlashCommand) -> None: 

96 """Run *cmd* through Chat: dispatch it, or prefill it when it needs arguments.""" 

97 app = self._app 

98 chat = app.chat_screen() 

99 if chat is None: 

100 app.notify(f"Open Chat to run {cmd.name}") 

101 return 

102 if cmd.args_hint.startswith("<"): 

103 # Needs an argument: land in the chat prompt for Tab completion. 

104 app.switch_view(msg.DEFAULT_VIEW) 

105 chat.insert_slash_command(cmd.name) 

106 else: 

107 # Complete as-is: dispatch like a submitted prompt. Handlers that 

108 # navigate call switch_view themselves, and switch_view no-ops 

109 # while another switch is in flight, so don't pre-switch to Chat. 

110 chat.run_command(cmd.name) 

111 

112 def _model_commands(self) -> list[tuple[str, str, Any]]: 

113 """Generate commands for installed models.""" 

114 commands: list[tuple[str, str, Any]] = [] 

115 try: 

116 from lilbee.modelhub.models import list_installed_models 

117 

118 for name in list_installed_models(): 

119 commands.append( 

120 ( 

121 f"Set chat model → {name}", 

122 "Switch chat model", 

123 lambda n=name: self._set_model("chat_model", n), 

124 ) 

125 ) 

126 except Exception: 

127 log.debug("Failed to list installed models", exc_info=True) 

128 

129 return commands 

130 

131 def _set_model(self, attr: str, value: str) -> None: 

132 # Route through LilbeeApp.set_active_model so model-bar / scope chip 

133 # / status bar subscribers (settings_changed_signal) refresh. 

134 app = self._app 

135 app.set_active_model(attr, value) 

136 display = display_label_for_ref(value) or "off" 

137 app.notify(f"{attr}: {display}") 

138 if attr == "chat_model": 

139 app.title = msg.app_title(value) 

140 

141 def _action_delete_document(self) -> None: 

142 """Jump to Chat with /delete prefilled; Tab there completes file names.""" 

143 self._run_slash_command(get_command("/delete")) 

144 

145 def _action_sync(self) -> None: 

146 self._app.action_run_sync() 

147 

148 def _action_retry_skipped(self) -> None: 

149 """Clear the failed-file markers and kick off a sync to retry them. 

150 

151 Clearing the marker cache and then running a normal sync is 

152 equivalent to ``lilbee sync --retry-skipped`` / ``POST /api/sync`` 

153 with ``retry_skipped=true``. 

154 """ 

155 from lilbee.data.ingest.skip_marker import clear_skip_markers, load_skip_markers 

156 

157 cleared = len(load_skip_markers(cfg.data_root)) 

158 clear_skip_markers(cfg.data_root) 

159 self.screen.app.notify(msg.retry_skipped_message(cleared)) 

160 self._app.action_run_sync() 

161 

162 def _action_prune_ignored(self) -> None: 

163 """Sync with pruning on, the TUI equivalent of ``lilbee sync --prune-ignored``.""" 

164 self._run_slash_command(get_command("/prune-ignored")) 

165 

166 def _action_version(self) -> None: 

167 from lilbee.app.version import get_version 

168 

169 self.screen.app.notify(f"lilbee {get_version()}") 

170 

171 def _action_open_wiki(self) -> None: 

172 self._app.switch_view("Wiki") 

173 

174 def _action_wikify(self) -> None: 

175 from lilbee.cli.tui.screens.wiki import start_wikify 

176 

177 start_wikify(self._app) 

178 

179 def _action_wipe_wiki(self) -> None: 

180 """Delete the generated wiki. 

181 

182 The palette is the only TUI route to this while the wiki is off, since 

183 the wiki view (and its ``W`` binding) is dropped from the nav in that 

184 state, which is exactly when the leftover pages need removing. 

185 """ 

186 from lilbee.cli.tui.screens.wiki import confirm_wiki_wipe 

187 

188 confirm_wiki_wipe(self._app) 

189 

190 def _action_reset(self) -> None: 

191 """Trigger /reset from the palette so the ConfirmDialog flow fires.""" 

192 self._run_slash_command(get_command("/reset"))