# 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.