diff --git a/docs/guide_command_palette.md b/docs/guide_command_palette.md new file mode 100644 index 00000000..b713e352 --- /dev/null +++ b/docs/guide_command_palette.md @@ -0,0 +1,427 @@ +# Command Palette + +[Top](../README.md) | [Architecture](guide_architecture.md) | [Simulations](guide_simulations.md) | [Workspace Profiles](guide_workspace_profiles.md) + +--- + +## Overview + +The **Command Palette** is a global, keyboard-driven launcher for actions across Manual Slop. Triggered by `Ctrl+Shift+P` (or the View menu), it presents a fuzzy-searchable list of every command available in the current context. Selecting a command executes it; the palette closes. + +The Command Palette also includes an **"Everything" mode** (Ctrl+Shift+E) that searches across all available actions, including file content, symbols, commands, and history. This is the primary discovery mechanism for new users and the fastest navigation tool for power users. + +This guide covers: + +1. **Architecture** — Where the Command Palette sits in the input pipeline +2. **Commands Registry** — How commands are registered +3. **Fuzzy Search** — Matching algorithm and ranking +4. **Async Context Preview** — Why context preview is async and what it does +5. **"Everything" Mode** — Cross-domain search +6. **Testing** — Test patterns + +--- + +## Architecture + +The Command Palette is a modal that sits in the main render loop. It is **synchronous from the user's perspective** (the user types, sees results, picks one) but uses **async I/O** for the underlying context preview to avoid blocking the GUI on long-running file reads. + +``` +┌─────────────────────────────────────────────────┐ +│ Render Loop │ +│ - Detects Ctrl+Shift+P → opens palette modal │ +│ - Modal is rendered at the top of the stack │ +│ - User types in the search box │ +│ - Fuzzy matcher ranks results in real time │ +│ - "Everything" mode triggers async context │ +│ preview worker │ +└─────────────────┬───────────────────────────────┘ + │ reads commands from + ▼ +┌─────────────────────────────────────────────────┐ +│ Commands Registry │ +│ - Built-in commands (file ops, MMA, view, etc.) │ +│ - User-defined commands (from config) │ +│ - Plugin commands (future) │ +└─────────────────┬───────────────────────────────┘ + │ fuzzy-matched against + ▼ +┌─────────────────────────────────────────────────┐ +│ Fuzzy Matcher (synchronous) │ +│ - Returns ranked list of (command, score) │ +│ - Updates as user types │ +│ - Supports fuzzy subsequence matching │ +└─────────────────┬───────────────────────────────┘ + │ for "Everything" mode, queries + ▼ +┌─────────────────────────────────────────────────┐ +│ Async Context Preview Worker │ +│ - Reads file content, symbols, history │ +│ - Runs in background thread to avoid frame │ +│ stutter │ +│ - Results stream into the palette incrementally │ +└─────────────────────────────────────────────────┘ +``` + +**Why async context preview**: For the "Everything" mode, the palette may need to read file content, search symbols, or query history. These operations can be slow (especially over a large codebase). Running them synchronously in the render thread would cause visible UI hangs. The async worker streams results as they become available. + +--- + +## Commands Registry + +### Built-in Commands + +Manual Slop ships with ~50 built-in commands, organized by category: + +| Category | Examples | +|---|---| +| **File** | Open File, Save File, Close File, Revert File | +| **Edit** | Find, Replace, Go to Line, Go to Symbol | +| **View** | Toggle Panel, Switch Theme, Switch Profile | +| **AI** | Send to AI, Compress Discussion, Show Comms Log | +| **MMA** | Start Track, Pause Engine, Resume Engine, Kill Worker | +| **Tools** | Run PowerShell, Open External Editor, Trigger Hot Reload | +| **Project** | Switch Project, Reload Project, Save Project TOML | +| **Window** | Minimize, Maximize, Close, Reset Layout | +| **Help** | Open Documentation, About, Show Diagnostics | + +### Command Schema + +```python +@dataclass +class Command: + id: str # Unique identifier + title: str # Display name + category: str # Category for grouping + shortcut: Optional[str] # Keyboard shortcut (e.g., "Ctrl+S") + description: str = "" # Optional help text + enabled_when: Optional[str] = None # Condition for enablement (e.g., "has_selection") + action: Callable # Function to execute when selected +``` + +Commands are registered in `src/commands.py` (or similar) at module import time. The registry exposes: + +```python +def get_all_commands() -> List[Command]: + """Returns all registered commands.""" + +def get_command_by_id(command_id: str) -> Optional[Command]: + """Returns a command by its ID, or None if not found.""" +``` + +### User-Defined Commands + +Users can add custom commands via `config.toml`: + +```toml +[command_palette.custom_commands.my_format] +title = "Format Selected File" +category = "Custom" +action_type = "shell" +action = "powershell -File format_script.ps1 {current_file}" +shortcut = "Ctrl+Shift+F" +``` + +`action_type` is one of: +- `"shell"` — Run a shell command (with `{current_file}`, `{current_selection}`, etc. as placeholders) +- `"python"` — Call a Python function (function must be importable) +- `"palette"` — Open another palette with filtered commands + +--- + +## Fuzzy Search + +The matching algorithm is a **fuzzy subsequence matcher with scoring**. + +### Algorithm + +Given the query string (what the user typed) and a candidate command's title, the matcher: + +1. **Subsequence check**: Verifies that all characters in the query appear in the title, in order (case-insensitive). This is the "fuzzy" part — the user doesn't need to type the exact command name. + +2. **Score calculation**: If subsequence matches, calculates a score based on: + - **Exact prefix match** (highest score): Query is a prefix of the title + - **Word boundary match**: Query starts at a word boundary in the title + - **Contiguous match**: All matched characters are contiguous in the title + - **Character distance**: Smaller gaps between matched characters score higher + +3. **Result sorting**: Results are sorted by score (descending). The top N (configurable, default 20) are displayed. + +### Example Matches + +Query: `"fld"` (user wants "Find in Selection") + +| Title | Match | Score | +|---|---|---| +| "Find in Selection" | subsequence `f-i-n-d in S-e-L-ection` → no | 0 (rejected) | +| "Fold All" | subsequence `F-o-L-D` → yes | high (exact prefix) | +| "File List" | subsequence `F-i-L-e` → yes | medium (gaps) | + +The result list shows "Fold All" first, then "File List". + +### Performance + +The matcher is **synchronous** and **fast**: ~1ms for 100 commands on a typical query. The palette re-runs the matcher on every keystroke, so performance is bounded by the number of registered commands (~50) and the query length (typically <20 chars). + +--- + +## Async Context Preview + +When the palette is in "Everything" mode, the underlying search may need to perform I/O: + +- **File content search** (text content within tracked files) +- **Symbol search** (definitions, references via `py_find_usages` etc.) +- **History search** (past AI responses, discussion entries) + +### Why Async + +A synchronous search over a large codebase (1000+ files) could take seconds. Blocking the render thread for that duration would cause visible UI freezes. The async worker pattern decouples the search from the render: + +``` +User types "foo" in Everything mode + │ + ├─ Palette starts async search worker + │ + ├─ Render loop continues; palette shows "Searching..." spinner + │ + ├─ Worker thread: searches file content, symbols, history + │ + ├─ Worker emits results as they come (incremental streaming) + │ + ├─ Render loop: on each frame, drain pending results and update palette + │ + └─ When worker completes, palette shows final results +``` + +### Streaming + +Results are streamed via a thread-safe queue: + +```python +class AsyncSearchWorker: + def __init__(self): + self._results_queue: queue.Queue = queue.Queue() + self._stop_event: threading.Event = threading.Event() + + def search(self, query: str) -> None: + """Starts the search in a background thread.""" + threading.Thread(target=self._search_impl, args=(query,), daemon=True).start() + + def drain_results(self) -> List[SearchResult]: + """Called by the render loop to drain pending results.""" + results = [] + while True: + try: + results.append(self._results_queue.get_nowait()) + except queue.Empty: + break + return results +``` + +The render loop calls `drain_results()` once per frame, adding any new results to the palette's display list. The user sees results appear incrementally as the search progresses. + +### Cancellation + +If the user types another character before the previous search completes, the previous search is cancelled: + +```python +def on_query_changed(self, new_query: str) -> None: + if self.current_worker: + self.current_worker.cancel() # Sets _stop_event + self.current_worker = AsyncSearchWorker() + self.current_worker.search(new_query) +``` + +The cancelled worker checks `_stop_event` periodically and exits early. Partial results are discarded. + +--- + +## "Everything" Mode + +The "Everything" mode is a special palette mode that searches across multiple domains: + +| Domain | Source | Filter | +|---|---|---| +| **Commands** | Built-in + user-defined | All | +| **Files** | Tracked files in current project | `f:` or auto-detected | +| **Symbols** | Definitions across all source files | `@` or auto-detected | +| **History** | Past AI responses, discussion entries | Recent first | +| **Settings** | Configurable settings keys | `setting:` | + +### Mode Detection + +The palette uses a **prefix-based mode detector**: + +- `@` → Symbols mode +- `f:` or `file:` → Files mode +- `setting:` → Settings mode +- No prefix → Commands mode (default) + +For Everything mode (Ctrl+Shift+E), all domains are searched and results are merged by relevance score. + +### Result Categories + +In Everything mode, results are grouped by category: + +``` +[COMMANDS] + Find in Selection + Fold All + +[FILES] + src/gui_2.py + src/aggregate.py + +[SYMBOLS] + RAGEngine.search (src/rag_engine.py:242) + BeadsClient.list_beads (src/beads_client.py:73) + +[HISTORY] + Last response to "How does X work?" + Discussion entry from 2 hours ago +``` + +The user can navigate within or across categories using arrow keys. + +--- + +## Usage Patterns + +### Quick File Access + +1. `Ctrl+Shift+P` to open the palette. +2. Type `f:gui_2` to filter to files matching "gui_2". +3. Press Enter to open the file. + +### Quick Symbol Lookup + +1. `Ctrl+Shift+P`. +2. Type `@RAGEngine.search`. +3. Press Enter to navigate to the symbol. + +### Cross-Domain Search + +1. `Ctrl+Shift+E` to open Everything mode. +2. Type `compression`. +3. See: Run Discussion Compression command, `run_discussion_compression` symbol, history entries mentioning "compression", settings for compression strategy. + +### Triggering Built-in Commands + +1. `Ctrl+Shift+P`. +2. Type a few letters of the command name. +3. Press Enter to execute. + +### Keyboard Shortcuts + +Some commands have direct keyboard shortcuts (e.g., `Ctrl+S` for Save). These bypass the palette entirely. The palette is for commands without a direct shortcut, or for discovering commands. + +--- + +## Configuration + +```toml +[command_palette] +max_results = 20 # Max results shown in palette +history_depth = 100 # How many history entries to search +search_threads = 2 # Parallelism for async searches +fuzzy_match_min_score = 0.3 # Min score to include a result +``` + +| Key | Default | Description | +|---|---|---| +| `max_results` | 20 | Max results shown. Increase for power users with many commands. | +| `history_depth` | 100 | Number of recent history entries to search. | +| `search_threads` | 2 | Parallel workers for async search. Higher = faster on multi-core, more memory. | +| `fuzzy_match_min_score` | 0.3 | Minimum score to include a result. Lower = more permissive. | + +--- + +## Performance + +| Operation | Cost | Notes | +|---|---|---| +| Fuzzy match (commands only) | ~1ms | O(N) over command list. | +| File content search | 50-500ms | Scales with codebase size and query. | +| Symbol search | 100ms-2s | Requires `py_find_usages` etc. | +| History search | ~10ms | In-memory deque. | +| Everything mode (all domains) | 200ms-3s | Parallel across threads. | + +The async worker ensures that even the slowest searches don't block the render thread. Results stream in as they become available. + +--- + +## Testing + +### Unit Tests + +- `tests/test_command_palette.py` — Fuzzy matcher, command registry, mode detection +- `tests/test_command_palette_sim.py` — End-to-end via `live_gui`: open palette, type, select command + +### Test Pattern + +```python +def test_fuzzy_match_ranks_prefix_first(): + from src.command_palette import fuzzy_match + + results = fuzzy_match("fin", ["Find in Selection", "Fold All", "Configure Settings"]) + assert results[0].command.title == "Find in Selection" + assert results[0].score > 0.5 + +def test_palette_executes_command(live_gui): + client.open_command_palette() + client.type_in_palette("find") + client.select_first_result() + + # Verify the Find panel is now open + state = client.get_window_state("find_panel") + assert state["visible"] == True +``` + +### Async Testing + +Async search is harder to test deterministically. The pattern is: + +```python +def test_async_search_streams_results(live_gui): + client.open_command_palette(mode="everything") + client.type_in_palette("compression") + + # Wait for streaming to complete (with timeout) + for _ in range(30): # 3 seconds at 100ms intervals + results = client.get_palette_results() + if len(results) > 0 and all(r.complete for r in results): + break + time.sleep(0.1) + + # Verify expected results appeared + titles = [r.title for r in client.get_palette_results()] + assert any("Compression" in t for t in titles) +``` + +--- + +## Limitations + +1. **No Plugin System Yet**: User-defined commands via TOML are limited to shell, python, or palette-chaining. A full plugin API is roadmap. + +2. **No Command Aliases**: A command can have one title but not multiple aliases. Users who remember different names for the same command are out of luck. + +3. **Fuzzy Match Is Synchronous**: For a small command list (~50) this is fine, but if the registry grows to thousands (via plugins), the per-keystroke match could become slow. + +4. **No Cross-Session Command History**: "Recently used" commands aren't tracked across sessions. Each session starts with the full list. + +5. **Async Search Has No Result Caching**: Repeated identical searches re-run the worker. A small LRU cache could improve perceived performance. + +6. **No Command Descriptions in Match Score**: A command's description doesn't affect fuzzy match score, only the title does. Users who know the description but not the title may have to guess. + +--- + +## Future Work + +- **Plugin System** — Allow third-party commands to be registered via plugins. +- **Command Aliases** — Multiple names per command. +- **Cross-Session History** — Track recently used commands. +- **Result Caching** — LRU cache for async search results. +- **Natural Language Queries** — "send a message to the AI about X" instead of explicit commands. +- **Command Macros** — Combine multiple commands into one palette entry. + +See [guide_architecture.md](guide_architecture.md) for the async pattern and [guide_simulations.md](guide_simulations.md) for the testing infrastructure.