161ebb0da6
Gitea (and any case-sensitive filesystem) was rendering the [Top]
nav links in /docs as broken because of two bugs:
1. Case-sensitivity: 22 links used '../README.md' (all-uppercase)
but the actual file is 'docs/Readme.md' (capital R, lowercase
rest). 21 guide_*.md nav bars were affected, plus 1 internal
cross-link in Readme.md itself. Works on Windows (case-
insensitive) but broken on Linux/Gitea.
Fix: 22 occurrences across 22 files changed
'../README.md' -> '../Readme.md'
2. Wrong relative-path level: 16 links used '../../conductor/...'
from 'docs/guide_*.md' to reach 'conductor/'. This goes up 2
levels to 'projects/', which doesn't exist. The correct path
from 'docs/guide_*.md' to 'conductor/' is 1 level up
('../conductor/...'). 12 unique patterns across 10 files
affected.
Fix: 16 occurrences across 10 files changed
'../../conductor/' -> '../conductor/'
3. Bonus: 1 planned-guide link in guide_context_curation.md
referenced a never-written 'guide_context_presets.md'. The
ContextPreset schema is now fully covered in the new
'guide_context_aggregation.md' (per the 2026-06-08 docs
refresh). Fix: link target updated.
No content was changed, only link paths. 24 files, 37 link
replacements, 37 deletions.
Verification:
- All .md links in docs/ now resolve to existing files
(validated by path-resolution check from each file's directory)
- The 3 new guides from the previous docs refresh commit
(guide_discussions.md, guide_state_lifecycle.md,
guide_context_aggregation.md) had the case bug inherited from
guide_architecture.md's existing nav pattern; their top-of-file
nav bars are now correct
- The 21 pre-existing guide nav bars that had the same bug
(all 21 of them, except the 3 that used the correct case:
guide_mma.md, guide_simulations.md, guide_tools.md) are now
also fixed
- Inter-guide links (e.g. [Discussions](guide_discussions.md))
were not affected; they were always correct because both the
link text and the actual filename are lowercase
This is a docs-only fix. No code modified.
365 lines
15 KiB
Markdown
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.
|