# 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 33 commands currently shipped in `src/commands.py` (the source file is the source of truth; this table is regenerated from `@registry.register` decorators and the leading docstring of each function):

| ID | Title | Category | Action |
|---|---|---|---|
| `reset_session`               | Reset Session              | AI      | Calls `ai_client.reset_session()` + clears comms/tool logs + `app._handle_reset_session()` |
| `clear_discussion`            | Clear Discussion           | AI      | Empties `app.discussion_history` |
| `add_all_files_to_context`    | Add All Files To Context   | AI      | Calls `app._add_all_files_to_context()` |
| `generate_md_only`            | Generate MD Only           | AI      | Runs `_do_generate()` and stores `app.last_md` / `app.last_md_path` (no chat send) |
| `open_project`                | Open Project               | Project | Calls `app._show_project_picker()` |
| `save_project`                | Save Project               | Project | Calls `app._save_project_state()` |
| `save_all`                    | Save All                   | Project | Flushes project + config + calls `app.save_config()` |
| `toggle_text_viewer`          | Toggle Text Viewer         | View    | `_toggle_window(app, "Text Viewer")` |
| `toggle_diagnostics`          | Toggle Diagnostics         | View    | `_toggle_window(app, "Diagnostics")` |
| `toggle_usage_analytics`      | Toggle Usage Analytics     | View    | `_toggle_window(app, "Usage Analytics")` |
| `toggle_context_preview`      | Toggle Context Preview     | View    | `_toggle_window(app, "Context Preview")` |
| `toggle_tier1_strategy`       | Toggle Tier 1 Strategy     | View    | `_toggle_window(app, "Tier 1: Strategy")` |
| `toggle_tier2_tech_lead`      | Toggle Tier 2 Tech Lead    | View    | `_toggle_window(app, "Tier 2: Tech Lead")` |
| `toggle_tier3_workers`        | Toggle Tier 3 Workers      | View    | `_toggle_window(app, "Tier 3: Workers")` |
| `toggle_tier4_qa`             | Toggle Tier 4 QA           | View    | `_toggle_window(app, "Tier 4: QA")` |
| `toggle_external_tools`       | Toggle External Tools      | View    | `_toggle_window(app, "External Tools")` |
| `toggle_shader_editor`        | Toggle Shader Editor       | View    | `_toggle_window(app, "Shader Editor")` |
| `toggle_undo_redo_history`    | Toggle Undo/Redo History   | View    | `_toggle_window(app, "Undo/Redo History")` |
| `toggle_command_palette`      | Toggle Command Palette     | View    | `_toggle_attr(app, "show_command_palette")` |
| `show_all_panels`             | Show All Panels            | View    | Sets every key of `app.show_windows` to `True` |
| `hide_all_panels`             | Hide All Panels            | View    | Sets every key of `app.show_windows` to `False` |
| `reset_layout`                | Reset Layout               | View    | Forces all `show_windows` to `True` and deletes `manualslop_layout.ini` (and the test-artifact copy) so hello_imgui regenerates the dock layout on the next process startup |
| `save_workspace_profile`      | Save Workspace Profile     | Layout  | Opens the save-profile modal |
| `show_workspace_manager`      | Show Workspace Manager     | Layout  | Sets `app.show_windows["Workspace Manager"] = True` |
| `trigger_hot_reload`          | Hot Reload                 | Tools   | Calls `HotReloader.reload("src.gui_2", app)` |
| `undo`                        | Undo                       | Edit    | Calls `app._handle_undo()` |
| `redo`                        | Redo                       | Edit    | Calls `app._handle_redo()` |
| `switch_to_dark_theme`        | Switch To Dark Theme       | Theme   | `theme_2.apply("10x Dark")` |
| `switch_to_light_theme`       | Switch To Light Theme      | Theme   | `theme_2.apply("ImGui Light")` |
| `switch_to_nerv_theme`        | Switch To NERV Theme       | Theme   | `theme_2.apply("NERV")` |
| `cycle_theme`                 | Cycle Theme                | Theme   | Cycles through `["10x Dark", "ImGui Light", "NERV"]` based on `theme_2.get_current_palette()` |
| `show_documentation`          | Show Documentation         | Help    | Opens `https://git.cozyair.dev/ed/manual_slop/` in the default browser |
| `show_command_palette_help`   | Show Command Palette Help  | Help    | Loads `docs/Readme.md` into `app.readme_text` and opens the Text Viewer |

Notes:
- All command bodies are wrapped in defensive `hasattr` / `try/except` so a missing App attribute or a buggy action never breaks the modal's end_child/end pairing (which would surface as the `IM_ASSERT: Must call EndChild() and not End()!` crash).
- The `cycle_theme` order is hard-coded; a new theme added to `themes/*.toml` will not appear in the cycle without an edit to `cycle_theme`.
- The `_toggle_window(app, "<window name>")` calls are case-sensitive against the keys of `app.show_windows` (the per-window visibility dict on the App).
- The category column is editorial — the registry stores no category; the title is what's actually searched in the palette.

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