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
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-17 10:02 +0000
1"""Command palette provider for lilbee TUI."""
3from __future__ import annotations
5import logging
6from typing import TYPE_CHECKING, Any, cast
8from textual.command import Hit, Hits, Provider
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
15log = logging.getLogger(__name__)
17if TYPE_CHECKING:
18 from lilbee.cli.tui.app import LilbeeApp
21class LilbeeCommandProvider(Provider):
22 """Provides searchable commands for the Textual command palette (Ctrl+P)."""
24 @property
25 def _app(self) -> LilbeeApp:
26 return cast("LilbeeApp", self.screen.app)
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)
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)
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 ]
85 commands.extend(self._slash_commands())
86 commands.extend(self._model_commands())
87 return commands
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 ]
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)
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
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)
129 return commands
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)
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"))
145 def _action_sync(self) -> None:
146 self._app.action_run_sync()
148 def _action_retry_skipped(self) -> None:
149 """Clear the failed-file markers and kick off a sync to retry them.
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
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()
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"))
166 def _action_version(self) -> None:
167 from lilbee.app.version import get_version
169 self.screen.app.notify(f"lilbee {get_version()}")
171 def _action_open_wiki(self) -> None:
172 self._app.switch_view("Wiki")
174 def _action_wikify(self) -> None:
175 from lilbee.cli.tui.screens.wiki import start_wikify
177 start_wikify(self._app)
179 def _action_wipe_wiki(self) -> None:
180 """Delete the generated wiki.
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
188 confirm_wiki_wipe(self._app)
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"))