Private
Public Access
0
0
Files
manual_slop/docs/guide_command_palette.md
ed 86d093e101 docs(command-palette): rewrite guide to match actual implementation
- Updated to reflect 13 tests (6 unit + 7 live_gui) instead of hypothetical async test
- Removed Everything mode and async context preview sections (not yet implemented; marked as future work)
- Updated Commands Registry section to reference actual src/commands.py file
- Added Implementation section with file layout and Command/CommandRegistry/CommandModal reference
- Added Built-in Commands table reflecting the actual 11 commands shipped
- Added Adding Custom Commands section with decorator and explicit-Command patterns
- Added Keyboard Reference table
- Updated Testing section with accurate coverage and test pattern
- Moved unimplemented features (Everything mode, user-defined commands, plugin system) to Future Work
2026-06-02 22:46:48 -04:00

365 lines
15 KiB
Markdown

# 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`, it presents a fuzzy-searchable list of every registered command. The user types a query, navigates with Up/Down arrows, and executes with Enter (or mouse click). Escape closes.
This guide covers:
1. **Architecture** — Where the Command Palette sits in the render pipeline
2. **Implementation** — File layout and the `Command` data model
3. **Commands Registry** — How commands are registered
4. **Fuzzy Search** — Matching algorithm and scoring
5. **Built-in Commands** — The 11+ commands shipped today
6. **Adding Custom Commands** — How to extend the library
7. **Testing** — Unit and integration coverage
8. **Limitations & Future Work**
---
## Architecture
The Command Palette is a modal rendered as part of the main render loop. The user types in a search field; `fuzzy_match` ranks the registered commands; Up/Down arrows navigate; Enter or click executes. The palette is **synchronous** — the command count is small enough that there's no need for async context preview.
```
┌─────────────────────────────────────────────────┐
│ Render Loop (_gui_func in src/gui_2.py) │
│ - Detects Ctrl+Shift+P → toggles palette │
│ - Calls render_palette_modal(self, commands) │
└─────────────────┬───────────────────────────────┘
│ calls
┌─────────────────────────────────────────────────┐
│ render_palette_modal(app, commands) │
│ - Sets window focus on first open │
│ - Captures arrow/enter keys BEFORE input_text │
│ - Renders centered 600x400 modal │
│ - Handles Up/Down/Enter/Escape │
└─────────────────┬───────────────────────────────┘
│ reads from
┌─────────────────────────────────────────────────┐
│ CommandRegistry (in src/command_palette.py) │
│ - registry.all() returns List[Command] │
│ - Commands are registered at module import time │
└─────────────────┬───────────────────────────────┘
│ populated by
┌─────────────────────────────────────────────────┐
│ src/commands.py — 11+ static commands │
│ - Decorated with @registry.register │
│ - Each has id, title, category, action │
│ - All actions take app: App as parameter │
└─────────────────────────────────────────────────┘
```
### Per-Open State
The palette tracks per-open state on the `App` instance:
- `show_command_palette: bool` — visibility flag
- `_command_palette_query: str` — current search text
- `_command_palette_selected: int` — currently selected index
- `_command_palette_focused: bool` — set on first open, for `set_next_window_focus`
- `_command_palette_input_focused: bool` — set on first open, for `set_keyboard_focus_here`
All four are reset to their initial values (`""`, `0`, `False`) when the palette closes.
---
## Implementation
### File Layout
| File | Role |
|---|---|
| `src/command_palette.py` | `Command` dataclass, `CommandRegistry`, `fuzzy_match`, `render_palette_modal`, `_close_palette`, `_execute` helpers |
| `src/commands.py` | Static command definitions (~11+ commands) |
| `src/gui_2.py` | `App.show_command_palette` flag, `_toggle_command_palette` method, Ctrl+Shift+P handler in `_gui_func`, modal render call |
| `tests/test_command_palette.py` | 6 unit tests |
| `tests/test_command_palette_sim.py` | 7 live_gui integration tests |
### The `Command` Data Model
```python
@dataclass
class Command:
id: str # Unique identifier
title: str # Display name (fuzzy-matched)
category: str # Category label (e.g., "File", "View")
shortcut: Optional[str] = None # Reserved for direct shortcuts (not yet rendered)
description: str = "" # Optional help text
enabled_when: Optional[str] = None # Reserved for future conditional enablement
action: Optional[Callable] = None # The function to call when selected
```
### The `CommandRegistry`
```python
class CommandRegistry:
def __init__(self) -> None:
self._commands: Dict[str, Command] = {}
def register(self, command_or_callable: Any) -> Any:
"""Decorator: @registry.register on a function. The function name becomes
the command id; the function's docstring or underscored_name becomes
the title; the action is the function itself."""
if isinstance(command_or_callable, Command):
cmd = command_or_callable
else:
cmd = Command(
id=command_or_callable.__name__,
title=command_or_callable.__name__.replace("_", " ").title(),
category="uncategorized",
action=command_or_callable,
)
if cmd.id in self._commands:
raise ValueError(f"Command {cmd.id} already registered")
self._commands[cmd.id] = cmd
return command_or_callable
def all(self) -> List[Command]:
return list(self._commands.values())
def get(self, command_id: str) -> Optional[Command]:
return self._commands.get(command_id)
```
### The `render_palette_modal` Function
Module-level function (per the project's UI delegation pattern — see [guide_architecture.md](guide_architecture.md)). Takes `app: App` as its first parameter; never reaches into App state directly.
```python
def render_palette_modal(app: Any, commands: List[Command]) -> None:
"""Renders the Command Palette as a centered modal. Called from _gui_func."""
if not getattr(app, "show_command_palette", False):
return
# ... sets up window focus on first open ...
# ... processes Up/Down/Enter keys BEFORE input_text ...
# ... calls input_text and draws result list ...
# ... returns early via _close_palette on Escape ...
```
### The Keyboard Handler
In `App._gui_func`, immediately after the existing Ctrl+Alt+R (hot reload) handler:
```python
if (io.key_ctrl and io.key_shift
and not io.key_alt and not io.key_super
and imgui.is_key_pressed(imgui.Key.p)):
self.show_command_palette = not self.show_command_palette
if self.show_command_palette:
# Reset state on open
...
```
---
## Fuzzy Search
The matching algorithm is a **fuzzy subsequence matcher with scoring**, implemented as a pure function in `src/command_palette.py`.
### Algorithm
Given the query string and a candidate command's title:
1. **Subsequence check**: All characters in the query must appear in the title, in order, case-insensitively. The "fuzzy" part — the user doesn't need to type the exact command name.
2. **Score calculation** (4 components, summed):
- **Exact prefix match** (+1.0): Query is a prefix of the title
- **Word boundary match** (+0.5): Query starts at a word boundary in the title
- **Contiguous match** (+0.3): All matched characters are contiguous
- **Gap penalty** (-0.1 per gap): Penalty for non-contiguous matches
3. **Sort by score descending**, return top N (default 20).
### Example Matches
Query: `"fld"` against titles:
| Title | Match | Score |
|---|---|---|
| "Fold All" | exact prefix | 1.0 (prefix) + 0.3 (contiguous) = 1.3 |
| "File List" | subsequence | 0 (gaps > 0) |
| "Find in Selection" | rejected | no subsequence match |
### Performance
~1ms for ~50 commands on a typical query. The palette re-runs the matcher on every keystroke; with the current command count this is imperceptible.
---
## Built-in Commands
The 11 commands currently shipped in `src/commands.py`:
| ID | Title | Category | Action |
|---|---|---|---|
| `reset_session` | Reset Session | AI | Calls `ai_client.reset_session()` and the App's reset handler |
| `clear_discussion` | Clear Discussion | AI | Empties `app.discussion_history` |
| `toggle_diagnostics` | Toggle Diagnostics | View | Toggles `app.show_diagnostics` |
| `add_all_files_to_context` | Add All Files To Context | AI | Calls `app._add_all_files_to_context()` |
| `open_project` | Open Project | Project | Calls `app._show_project_picker()` |
| `save_project` | Save Project | Project | Calls `app._save_project_state()` |
| `trigger_hot_reload` | Hot Reload | Tools | Calls `HotReloader.reload("src.gui_2", app)` |
| `show_documentation` | Show Documentation | Help | Opens the project URL in the browser |
| `switch_to_dark_theme` | Switch To Dark Theme | View | `theme_2.apply("10x Dark")` |
| `switch_to_light_theme` | Switch To Light Theme | View | `theme_2.apply("ImGui Light")` |
| `switch_to_nerv_theme` | Switch To Nerv Theme | View | `theme_2.apply("NERV")` |
### Defensive Action Calls
Every command action is wrapped in a defensive `hasattr` check:
```python
@registry.register
def clear_discussion(app: "App") -> None:
"""Clear Discussion — Clear all entries in the current discussion."""
if hasattr(app, "discussion_history"):
app.discussion_history = []
```
This makes commands safe to register even if the corresponding App state hasn't been initialized yet. The `_execute` helper in `render_palette_modal` additionally wraps the action call in a try/except so a buggy action doesn't break the modal's end_child/end pairing (which would cause the `IM_ASSERT: Must call EndChild() and not End()!` crash).
---
## Adding Custom Commands
To add a new command, edit `src/commands.py`:
```python
@registry.register
def my_new_command(app: "App") -> None:
"""My New Command — Does something useful."""
if hasattr(app, "my_attr"):
app.my_attr.do_something()
```
The decorator extracts the function's name as the command ID and converts the underscored name to a human-readable title (e.g., `my_new_command` → "My New Command"). If you want a custom title, register an explicit `Command` instance:
```python
registry.register(Command(
id="save_all",
title="Save All Open Files",
category="File",
action=lambda app: app._save_all(),
))
```
### Conventions
- **ID**: snake_case, unique within the registry
- **Title**: Human-readable, used for fuzzy matching
- **Category**: Short label for grouping (currently informational; not yet rendered in the palette)
- **Action**: A function taking `app: App`. Wrap in `try/except` if needed; the palette already wraps in try/except too.
---
## Keyboard Reference
| Key | Action |
|---|---|
| `Ctrl+Shift+P` | Toggle the palette |
| `Escape` | Close the palette |
| `Up Arrow` | Move selection up |
| `Down Arrow` | Move selection down |
| `Enter` / `Keypad Enter` | Execute the selected command |
| `Click` on a result | Execute that command |
| Typing in the input | Fuzzy-filter the result list |
The arrow keys are processed BEFORE the input field consumes them, ensuring Up/Down navigate the list and not just the text cursor.
---
## Testing
13 tests cover the Command Palette:
### Unit Tests (`tests/test_command_palette.py`)
```python
def test_fuzzy_match_prefix_ranks_first():
candidates = [
_cmd("find", "Find in Selection"),
_cmd("fold", "Fold All"),
_cmd("config", "Configure Settings"),
]
results = fuzzy_match("fin", candidates, top_n=10)
assert results[0].command.id == "find"
assert results[0].score > 0.5
```
5 unit tests cover:
- `fuzzy_match` prefix ranking
- `fuzzy_match` subsequence matching
- `fuzzy_match` no-match returns empty
- `fuzzy_match` top_n limits results
- `fuzzy_match` exact-prefix priority
- `CommandRegistry` has core commands registered
### Live GUI Tests (`tests/test_command_palette_sim.py`)
7 integration tests use the `live_gui` fixture and `ApiHookClient`:
- `test_palette_starts_hidden` — palette is closed at startup
- `test_palette_toggles_via_callback``_toggle_command_palette` opens and closes
- `test_palette_registers_core_commands` — registry has expected commands with actions
- `test_palette_query_state_resets_on_open` — state resets on close+reopen
- `test_palette_close_helper_resets_all_state``_close_palette` clears per-open state
- `test_execute_runs_command_and_closes``_execute` runs commands and catches exceptions
- `test_fuzzy_match_returns_top_n_for_navigation``top_n=20` is meaningful
### Test Pattern
```python
def test_my_command_via_palette(live_gui):
client = ApiHookClient()
# Open the palette
client.push_event("custom_callback", {
"callback": "_toggle_command_palette",
"args": [],
})
time.sleep(0.5)
assert client.get_value("show_command_palette") is True
# ... verify state, close, etc.
```
The `_toggle_command_palette` callback is registered specifically for tests, since the keyboard shortcut (Ctrl+Shift+P) cannot be simulated via the Hook API.
### Running the Tests
```bash
uv run pytest tests/test_command_palette.py tests/test_command_palette_sim.py -v
```
Expected: 13/13 pass.
---
## Limitations
1. **No Async Search**: All matching is synchronous. The ~1ms cost is fine for the current command count but won't scale to thousands of commands.
2. **No User-Defined Commands via Config**: Custom commands must be added in Python via `src/commands.py`. There is no `config.toml` integration yet.
3. **No Plugin System**: External packages cannot register their own commands.
4. **No Command Aliases**: A command has one ID and one title. Users who remember a different name for the same action must fuzzy-search for it.
5. **No Command History**: Recently-used commands aren't tracked across sessions.
6. **Categories Not Rendered**: The `category` field is stored but not displayed in the palette UI yet.
7. **No `shortcut` field display**: Commands can declare a shortcut but it's not currently shown or used to bind a key.
---
## Future Work
- **"Everything" mode** — search across commands, files, symbols, history, settings (requires async worker + indexer)
- **Config-defined commands** — `[[command_palette.custom_commands]]` in `config.toml` for shell/python actions
- **Plugin system** — allow third-party packages to register commands at import time
- **Command aliases** — multiple titles per command
- **Cross-session history** — track recently used commands
- **Render category column** — group results visually by category
- **Show shortcut hints** — display the `shortcut` field next to each result
- **Natural language queries** — "send a message to the AI about X" instead of explicit commands
See [guide_architecture.md](guide_architecture.md) for the overall architecture and [guide_simulations.md](guide_simulations.md) for the test infrastructure.