223 lines
7.7 KiB
Markdown
223 lines
7.7 KiB
Markdown
# Command Palette Implementation & Tests
|
|
|
|
**Date:** 2026-06-02
|
|
**Status:** Draft (pending review)
|
|
**Parent Track:** `command_palette_and_performance_20260602` (continuing Phase 2 + adding Phase 3)
|
|
**Spec:** `conductor/tracks/command_palette_and_performance_20260602/spec.md` (existing)
|
|
|
|
---
|
|
|
|
## Context & Motivation
|
|
|
|
A `command_palette_and_performance_20260602` track was started in early June 2026. **Phase 1 (Async Context Preview)** is complete. **Phase 2 (Command Palette)** is unstarted — no `src/command_palette.py`, no `src/commands.py`, no test files. The user reports the palette doesn't pop up on `Ctrl+Shift+P`.
|
|
|
|
The existing spec says `Ctrl+P`; this design uses `Ctrl+Shift+P` (per the user's expectation and VSCode convention documented in `docs/guide_command_palette.md`).
|
|
|
|
This design finishes Phase 2 of the existing track and adds Phase 3 (tests).
|
|
|
|
---
|
|
|
|
## Scope
|
|
|
|
### In Scope
|
|
|
|
- `src/command_palette.py` — Module-level: `Command` dataclass, `CommandRegistry`, `fuzzy_match()`, `render_palette_modal(app)`, `render_everything_modal(app)`
|
|
- `src/commands.py` — Static command definitions (~30-50 commands across categories)
|
|
- `src/gui_2.py` — `self.show_command_palette: bool` in `App.__init__`, `_render_command_palette(self)` thin wrapper, `Ctrl+Shift+P` keyboard handler
|
|
- `tests/test_command_palette.py` — Unit tests for fuzzy matcher, command registry, mode detection
|
|
- `tests/test_command_palette_sim.py` — Integration tests via `live_gui`
|
|
|
|
### Out of Scope
|
|
|
|
- Async context preview (Phase 1; already complete)
|
|
- The "Everything" mode async search worker (mentioned in the existing spec; defer to a follow-up track)
|
|
- Visual theming of the palette (use existing ImGui style)
|
|
|
|
---
|
|
|
|
## Design
|
|
|
|
### Data Model: `Command`
|
|
|
|
```python
|
|
@dataclass
|
|
class Command:
|
|
id: str # Unique identifier
|
|
title: str # Display name
|
|
category: str # Category for grouping
|
|
shortcut: Optional[str] # Optional default shortcut (e.g., "Ctrl+S")
|
|
description: str = "" # Optional help text
|
|
enabled_when: Optional[str] = None # Optional condition expression
|
|
action: Callable = None # Function to execute when selected
|
|
```
|
|
|
|
### Command Registry
|
|
|
|
```python
|
|
# src/commands.py
|
|
from src.command_palette import Command, CommandRegistry
|
|
|
|
registry = CommandRegistry()
|
|
|
|
@registry.register
|
|
def save_file(app: App) -> None:
|
|
"""Save File — File category, Ctrl+S"""
|
|
# ... call app's save logic
|
|
```
|
|
|
|
**Registration patterns:**
|
|
- Decorator: `@registry.register` for top-level functions
|
|
- Explicit: `registry.register(Command(id=..., title=..., action=...))` for closures or classes
|
|
|
|
### Fuzzy Matcher
|
|
|
|
Implemented in `src/command_palette.py` as a pure function:
|
|
|
|
```python
|
|
def fuzzy_match(query: str, candidates: List[Command], top_n: int = 20) -> List[ScoredCommand]:
|
|
"""
|
|
Returns the top_n candidates matching query, ranked by score.
|
|
|
|
Algorithm:
|
|
1. Subsequence check: query chars must appear in title, in order
|
|
2. Score calculation:
|
|
- Exact prefix match: +1.0
|
|
- Word boundary match: +0.5
|
|
- Contiguous match: +0.3
|
|
- Character distance penalty: -0.1 per gap
|
|
3. Sort by score descending
|
|
4. Return top_n
|
|
"""
|
|
```
|
|
|
|
### Modal Rendering
|
|
|
|
The palette is a centered ImGui modal. Module-level function (per delegation pattern):
|
|
|
|
```python
|
|
# src/command_palette.py
|
|
def render_palette_modal(app: App) -> None:
|
|
"""Render the Command Palette modal. Called from gui_2.py when app.show_command_palette is True."""
|
|
if not app.show_command_palette:
|
|
return
|
|
imgui.set_next_window_position(...) # Centered
|
|
imgui.set_next_window_size(...)
|
|
if imgui.begin("Command Palette##palette", closable=True):
|
|
# Search input
|
|
# Fuzzy-matched results list
|
|
# Keyboard navigation
|
|
imgui.end()
|
|
```
|
|
|
|
### Keyboard Handler
|
|
|
|
In `gui_2.py`'s main event loop:
|
|
|
|
```python
|
|
io = imgui.get_io()
|
|
if io.key_ctrl and io.key_shift and imgui.is_key_pressed(imgui.Key.p):
|
|
app.show_command_palette = not app.show_command_palette
|
|
```
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- `src/command_palette.py` — NEW: Command, CommandRegistry, fuzzy_match, render_palette_modal
|
|
- `src/commands.py` — NEW: Static command definitions and registry
|
|
- `src/gui_2.py` — MODIFY: add `self.show_command_palette`, add `_render_command_palette` wrapper, add Ctrl+Shift+P handler, register the palette module
|
|
- `tests/test_command_palette.py` — NEW: Unit tests
|
|
- `tests/test_command_palette_sim.py` — NEW: Integration tests via live_gui
|
|
|
|
---
|
|
|
|
## Tests
|
|
|
|
### Unit Tests (`tests/test_command_palette.py`)
|
|
|
|
```python
|
|
def test_fuzzy_match_prefix_ranks_first():
|
|
from src.command_palette import fuzzy_match
|
|
candidates = [
|
|
Command(id="find", title="Find in Selection"),
|
|
Command(id="fold", title="Fold All"),
|
|
Command(id="config", title="Configure Settings"),
|
|
]
|
|
results = fuzzy_match("fin", candidates)
|
|
assert results[0].command.id == "find"
|
|
assert results[0].score > 0.5
|
|
|
|
def test_fuzzy_match_rejects_no_match():
|
|
from src.command_palette import fuzzy_match
|
|
candidates = [Command(id="x", title="foo bar")]
|
|
results = fuzzy_match("xyz", candidates)
|
|
assert len(results) == 0
|
|
|
|
def test_command_registry_register_and_list():
|
|
from src.command_palette import CommandRegistry
|
|
from src.commands import registry
|
|
assert "save_file" in registry.all()
|
|
# All commands have id, title, category
|
|
for cmd in registry.all():
|
|
assert cmd.id and cmd.title and cmd.category
|
|
|
|
def test_command_registry_duplicate_raises():
|
|
from src.command_palette import CommandRegistry, Command
|
|
reg = CommandRegistry()
|
|
reg.register(Command(id="x", title="X", category="test"))
|
|
with pytest.raises(ValueError):
|
|
reg.register(Command(id="x", title="X", category="test"))
|
|
```
|
|
|
|
### Integration Tests (`tests/test_command_palette_sim.py`)
|
|
|
|
```python
|
|
def test_ctrl_shift_p_opens_palette(live_gui):
|
|
client = live_gui[1]
|
|
# Press Ctrl+Shift+P
|
|
client.press_key_combo("Ctrl+Shift+P")
|
|
# Verify the palette is visible
|
|
state = client.get_window_state("command_palette")
|
|
assert state["visible"] == True
|
|
|
|
def test_palette_filters_as_user_types(live_gui):
|
|
client = live_gui[1]
|
|
client.press_key_combo("Ctrl+Shift+P")
|
|
client.type_in_palette("save")
|
|
results = client.get_palette_results()
|
|
assert any("Save" in r.title for r in results)
|
|
# Other commands not shown
|
|
assert not any("Compress" in r.title for r in results)
|
|
|
|
def test_palette_executes_command_on_enter(live_gui):
|
|
client = live_gui[1]
|
|
client.press_key_combo("Ctrl+Shift+P")
|
|
client.type_in_palette("Reset")
|
|
client.press_key("Down")
|
|
client.press_key("Enter")
|
|
# Verify the reset command was executed (check via Hook API)
|
|
state = client.get_session_state()
|
|
assert state.get("discussion_history", []) == []
|
|
```
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
- `Ctrl+Shift+P` opens the palette (verified via `live_gui` test)
|
|
- Typing in the palette filters results via fuzzy match
|
|
- Selecting a command (Enter key) executes it and closes the palette
|
|
- Escape closes the palette without executing
|
|
- All unit tests pass
|
|
- All integration tests pass
|
|
- The palette respects the existing theme (dark/light/nerv)
|
|
- No new lint errors
|
|
|
|
---
|
|
|
|
## Risks
|
|
|
|
1. **Keyboard handler conflicts:** The Ctrl+Shift+P combo might be intercepted by other subsystems. Mitigation: check for other handlers in the codebase first; if conflicts, document them.
|
|
2. **Pyodide build dependencies:** The image_bundle web backend (for Track 4) has a different architecture than this track. The two are independent but should be aware of each other.
|
|
3. **Test flakiness:** `live_gui` tests can be flaky if the GUI doesn't initialize in time. Mitigation: the standard 15-second readiness polling is sufficient.
|