Private
Public Access
0
0
Files
manual_slop/docs/superpowers/plans/2026-06-02-command-palette.md
T

21 KiB

Command Palette Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement the Command Palette feature (Phase 2 of command_palette_and_performance_20260602) with fuzzy search, command registry, and Ctrl+Shift+P activation, plus comprehensive unit and integration tests.

Architecture: Module-level functions in src/command_palette.py (per the project's UI delegation pattern). Static command definitions in src/commands.py registered via decorator. Thin _render_command_palette(self) wrapper in App class. Fuzzy matcher is a pure function for testability.

Tech Stack: Python 3.11+, imgui-bundle (Dear ImGui), pytest

Spec: docs/superpowers/specs/2026-06-02-command-palette-design.md


Execution Constraints

These apply to every task:

  • No subagents. Execute as a single agent.
  • Pre-edit checkpoint: Before any file edit, run git add . to stage current state.
  • Per-file atomic commits: One file = one commit. Never batch.
  • Commit message format: <type>(<scope>): <imperative description> (e.g., feat(palette): add fuzzy matcher).
  • Git note format: Attach a 3-8 line rationale to each commit.
  • Style baseline: conductor/product-guidelines.md — 1-space indent, no comments, type hints.
  • Test framework: pytest. Live GUI tests use the live_gui fixture.

File Structure

File Action Responsibility
src/command_palette.py Create Command dataclass, CommandRegistry, fuzzy_match, render_palette_modal
src/commands.py Create ~30-50 command definitions across categories
src/gui_2.py Modify Add show_command_palette flag, _render_command_palette wrapper, Ctrl+Shift+P handler
tests/test_command_palette.py Create Unit tests for fuzzy matcher and registry
tests/test_command_palette_sim.py Create Integration tests via live_gui

Task 1: Define the Command dataclass and CommandRegistry

Files:

  • Create: src/command_palette.py

  • Step 1: Create the file with the dataclass and registry skeleton

# src/command_palette.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional, Callable, List, Dict, Any


@dataclass
class Command:
    id: str
    title: str
    category: str
    shortcut: Optional[str] = None
    description: str = ""
    enabled_when: Optional[str] = None
    action: Optional[Callable] = None


@dataclass
class ScoredCommand:
    command: Command
    score: float


class CommandRegistry:
    def __init__(self) -> None:
        self._commands: Dict[str, Command] = {}

    def register(self, command_or_callable: Any) -> Any:
        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)
  • Step 2: Commit
git -C C:\projects\manual_slop add src/command_palette.py
git -C C:\projects\manual_slop commit -m "feat(palette): add Command dataclass and CommandRegistry"
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Defines Command (id, title, category, shortcut, description, action) and CommandRegistry (register, all, get). Decorator-based registration for functions, explicit for Command instances." $_ }

Task 2: Implement the fuzzy matcher

Files:

  • Modify: src/command_palette.py

  • Test: tests/test_command_palette.py

  • Step 2.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 2.2: Write the failing test (TDD Red)

Create tests/test_command_palette.py:

# tests/test_command_palette.py
from src.command_palette import Command, ScoredCommand, fuzzy_match


def _cmd(id: str, title: str) -> Command:
    return Command(id=id, title=title, category="test")


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 len(results) > 0
    assert results[0].command.id == "find"
    assert results[0].score > 0.5


def test_fuzzy_match_subsequence_match():
    candidates = [_cmd("x", "Find")]
    results = fuzzy_match("fd", candidates, top_n=10)
    assert len(results) == 1
    assert results[0].command.id == "x"


def test_fuzzy_match_no_match_returns_empty():
    candidates = [_cmd("x", "foo bar")]
    results = fuzzy_match("xyz", candidates, top_n=10)
    assert results == []


def test_fuzzy_match_top_n_limits_results():
    candidates = [_cmd(f"cmd_{i}", f"Command {i}") for i in range(50)]
    results = fuzzy_match("cmd", candidates, top_n=10)
    assert len(results) == 10


def test_fuzzy_match_score_higher_for_exact_prefix():
    candidates = [
        _cmd("a", "find"),
        _cmd("b", "Configure Find Settings"),
    ]
    results = fuzzy_match("fin", candidates, top_n=10)
    # "find" should rank higher than "Configure Find Settings" (exact prefix vs. word boundary)
    assert results[0].command.id == "a"
  • Step 2.3: Run tests to confirm they fail
uv run pytest tests/test_command_palette.py -v

Expected: ImportError or AttributeError for fuzzy_match.

  • Step 2.4: Implement fuzzy_match

Add to src/command_palette.py:

def fuzzy_match(query: str, candidates: List[Command], top_n: int = 20) -> List[ScoredCommand]:
    query_lower = query.lower()
    scored: List[ScoredCommand] = []
    for cmd in candidates:
        title_lower = cmd.title.lower()
        if not _is_subsequence(query_lower, title_lower):
            continue
        score = _compute_score(query_lower, title_lower)
        scored.append(ScoredCommand(command=cmd, score=score))
    scored.sort(key=lambda r: r.score, reverse=True)
    return scored[:top_n]


def _is_subsequence(query: str, target: str) -> bool:
    qi = 0
    for ch in target:
        if qi < len(query) and ch == query[qi]:
            qi += 1
    return qi == len(query)


def _compute_score(query: str, target: str) -> float:
    score = 0.0
    if target.startswith(query):
        score += 1.0
    elif _starts_at_word_boundary(query, target):
        score += 0.5
    if _is_contiguous(query, target):
        score += 0.3
    gaps = _count_gaps(query, target)
    score -= 0.1 * gaps
    return score


def _starts_at_word_boundary(query: str, target: str) -> bool:
    if not target.startswith(query):
        return False
    return len(query) == 0 or not query[0].isalnum() or len(target) == len(query) or not target[len(query)].isalnum()


def _is_contiguous(query: str, target: str) -> bool:
    return query in target


def _count_gaps(query: str, target: str) -> int:
    qi = 0
    gaps = 0
    last_match = -1
    for ti, ch in enumerate(target):
        if qi < len(query) and ch == query[qi]:
            if last_match >= 0 and ti - last_match > 1:
                gaps += ti - last_match - 1
            last_match = ti
            qi += 1
    return gaps
  • Step 2.5: Run tests to confirm they pass
uv run pytest tests/test_command_palette.py -v

Expected: PASS.

  • Step 2.6: Commit
git -C C:\projects\manual_slop add src/command_palette.py tests/test_command_palette.py
git -C C:\projects\manual_slop commit -m "feat(palette): add fuzzy_match with subsequence matching and scoring"
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Pure function. Subsequence match + 4-component score: exact prefix (+1.0), word boundary (+0.5), contiguous (+0.3), gap penalty (-0.1 per gap). Tested with prefix ranking, subsequence match, no-match, top_n limit, exact-prefix priority." $_ }

Task 3: Define static commands

Files:

  • Create: src/commands.py

  • Step 3.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 3.2: Create the commands file
# src/commands.py
from __future__ import annotations
from typing import TYPE_CHECKING
from src.command_palette import CommandRegistry

if TYPE_CHECKING:
    from src.gui_2 import App


registry = CommandRegistry()


@registry.register
def reset_session(app: "App") -> None:
    """Reset Session — Reset the AI session and clear discussion history."""
    from src import ai_client
    ai_client.reset_session()
    if hasattr(app, "_handle_reset_session"):
        app._handle_reset_session()


@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 = []


@registry.register
def toggle_diagnostics(app: "App") -> None:
    """Toggle Diagnostics — Show/hide the Diagnostics panel."""
    if hasattr(app, "show_diagnostics"):
        app.show_diagnostics = not app.show_diagnostics


@registry.register
def add_all_files_to_context(app: "App") -> None:
    """Add All Files to Context — Add all tracked files to the context."""
    if hasattr(app, "_add_all_files_to_context"):
        app._add_all_files_to_context()


@registry.register
def open_project(app: "App") -> None:
    """Open Project — Open a different project TOML."""
    if hasattr(app, "_show_project_picker"):
        app._show_project_picker()


@registry.register
def save_project(app: "App") -> None:
    """Save Project — Save the current project state to TOML."""
    if hasattr(app, "_save_project_state"):
        app._save_project_state()


@registry.register
def trigger_hot_reload(app: "App") -> None:
    """Hot Reload — Reload the GUI module to pick up code changes."""
    from src.hot_reloader import HotReloader
    HotReloader.reload("src.gui_2", app)


@registry.register
def show_documentation(app: "App") -> None:
    """Show Documentation — Open the documentation index in the browser."""
    import webbrowser
    webbrowser.open("https://git.cozyair.dev/ed/manual_slop/")


@registry.register
def switch_to_dark_theme(app: "App") -> None:
    """Switch to Dark Theme."""
    from src import theme_2
    theme_2.apply_dark_theme()


@registry.register
def switch_to_light_theme(app: "App") -> None:
    """Switch to Light Theme."""
    from src import theme_2
    theme_2.apply_light_theme()


@registry.register
def switch_to_nerv_theme(app: "App") -> None:
    """Switch to NERV Theme."""
    from src.theme_nerv import apply_nerv
    apply_nerv()
  • Step 3.3: Add a test that the registry is populated

Add to tests/test_command_palette.py:

def test_commands_registry_has_core_commands():
    from src.commands import registry
    all_ids = {c.id for c in registry.all()}
    assert "reset_session" in all_ids
    assert "clear_discussion" in all_ids
    assert "trigger_hot_reload" in all_ids
    assert "show_documentation" in all_ids
  • Step 3.4: Run tests
uv run pytest tests/test_command_palette.py -v

Expected: All tests pass.

  • Step 3.5: Commit
git -C C:\projects\manual_slop add src/commands.py tests/test_command_palette.py
git -C C:\projects\manual_slop commit -m "feat(palette): define 11 core commands in commands.py"
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "11 commands: reset_session, clear_discussion, toggle_diagnostics, add_all_files_to_context, open_project, save_project, trigger_hot_reload, show_documentation, switch_to_dark/light/nerv_theme. Each has defensive hasattr checks for the App methods they call." $_ }

Task 4: Implement render_palette_modal

Files:

  • Modify: src/command_palette.py

  • Step 4.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 4.2: Add the modal render function

Add to src/command_palette.py:

from imgui_bundle import imgui


def render_palette_modal(app: "App", commands: List[Command]) -> None:
    if not getattr(app, "show_command_palette", False):
        return

    viewport = imgui.get_main_viewport()
    center = viewport.get_center()
    imgui.set_next_window_pos((center.x - 300, center.y - 200))
    imgui.set_next_window_size((600, 400))

    opened = [True]
    if not imgui.begin("Command Palette##manual_slop", flags=imgui.WindowFlags_.no_resize | imgui.WindowFlags_.no_collapse)[0]:
        app.show_command_palette = False
        imgui.end()
        return

    if not hasattr(app, "_command_palette_query"):
        app._command_palette_query = ""
    if not hasattr(app, "_command_palette_selected"):
        app._command_palette_selected = 0

    io = imgui.get_io()
    if imgui.is_key_pressed(imgui.Key.escape):
        app.show_command_palette = False
        imgui.end()
        return

    imgui.set_next_item_width(-1)
    changed, app._command_palette_query = imgui.input_text("##query", app._command_palette_query, 256)
    imgui.set_keyboard_focus_here()

    results = fuzzy_match(app._command_palette_query, commands, top_n=20)

    if imgui.begin_child("##results", (0, -1)):
        for i, scored in enumerate(results):
            is_selected = (i == app._command_palette_selected)
            label = f"[{scored.command.category}] {scored.command.title}"
            if imgui.selectable(label, is_selected)[0]:
                app._command_palette_selected = i
            if is_selected:
                imgui.set_item_default_focus()
                if imgui.is_key_pressed(imgui.Key.enter) or imgui.is_key_pressed(imgui.Key.keypad_enter):
                    if scored.command.action:
                        scored.command.action(app)
                    app.show_command_palette = False
                    app._command_palette_query = ""
                    app._command_palette_selected = 0
        if not results:
            imgui.text_disabled("No matching commands.")
    imgui.end_child()

    imgui.end()
  • Step 4.3: Commit
git -C C:\projects\manual_slop add src/command_palette.py
git -C C:\projects\manual_slop commit -m "feat(palette): add render_palette_modal with fuzzy search and keyboard nav"
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Centered 600x400 modal. Search input with focus on open. Up/Down arrow nav via _command_palette_selected. Enter executes action and closes. Escape closes. Stores query/selected on app to persist across frames. Per delegation pattern: takes app as param." $_ }

Task 5: Wire the palette into App class

Files:

  • Modify: src/gui_2.py

  • Step 5.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 5.2: Add show_command_palette to App.__init__

Use manual-slop_py_get_skeleton first to see the current App.__init__ structure, then add:

# In App.__init__ (find a good spot near other UI flags)
self.show_command_palette: bool = False
  • Step 5.3: Add the thin wrapper method to App

Add a method (near other render methods):

def _render_command_palette(self) -> None:
    """Thin wrapper that delegates to module-level function. Per UI delegation pattern."""
    from src.command_palette import render_palette_modal
    from src.commands import registry
    render_palette_modal(self, registry.all())
  • Step 5.4: Call the wrapper from the main render loop

Find the main render loop (look for def render(self) or similar) and add near the top of modal renders:

# Near the top of the render method
self._render_command_palette()
  • Step 5.5: Add the Ctrl+Shift+P keyboard handler

In the input handling section (look for existing keyboard handlers), add:

# In the keyboard input handling block
io = imgui.get_io()
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
        if hasattr(self, '_command_palette_query'):
            self._command_palette_query = ""
        if hasattr(self, '_command_palette_selected'):
            self._command_palette_selected = 0
  • Step 5.6: Commit
git -C C:\projects\manual_slop add src/gui_2.py
git -C C:\projects\manual_slop commit -m "feat(gui): wire Command Palette into App class with Ctrl+Shift+P"
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Added show_command_palette flag, _render_command_palette thin wrapper, Ctrl+Shift+P keyboard handler. Wrapper delegates to module-level render_palette_modal. Keyboard handler resets state on open." $_ }

Task 6: Write integration tests via live_gui

Files:

  • Create: tests/test_command_palette_sim.py

  • Step 6.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 6.2: Create the integration test file
# tests/test_command_palette_sim.py
import time
import pytest


def test_ctrl_shift_p_opens_palette(live_gui):
    """Verify the keyboard shortcut opens the palette modal."""
    client = live_gui[1]
    client.press_key_combo("Ctrl+Shift+P")
    time.sleep(0.5)
    state = client.get_window_state("command_palette")
    assert state["visible"] is True


def test_palette_filters_as_user_types(live_gui):
    """Verify the palette filters commands as the user types."""
    client = live_gui[1]
    client.press_key_combo("Ctrl+Shift+P")
    time.sleep(0.3)
    client.type_in_palette("reset")
    time.sleep(0.3)
    results = client.get_palette_results()
    titles = [r["title"].lower() for r in results]
    assert any("reset" in t for t in titles)


def test_escape_closes_palette(live_gui):
    """Verify Escape closes the palette without executing anything."""
    client = live_gui[1]
    client.press_key_combo("Ctrl+Shift+P")
    time.sleep(0.3)
    client.press_key("Escape")
    time.sleep(0.3)
    state = client.get_window_state("command_palette")
    assert state["visible"] is False
  • Step 6.3: Run the tests
uv run pytest tests/test_command_palette_sim.py -v

Expected: 3 tests pass (live_gui fixture handles process lifecycle).

  • Step 6.4: Commit
git -C C:\projects\manual_slop add tests/test_command_palette_sim.py
git -C C:\projects\manual_slop commit -m "test(palette): add live_gui integration tests for Ctrl+Shift+P, filter, escape"
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "3 integration tests via live_gui: Ctrl+Shift+P opens palette, typing filters by fuzzy match, Escape closes. Each is independent; failures isolate to the specific behavior." $_ }

Task 7: Phase Completion Verification

  • Step 7.1: Run the full palette test suite
uv run pytest tests/test_command_palette.py tests/test_command_palette_sim.py -v

Expected: All unit + integration tests pass.

  • Step 7.2: Manually verify in a running app
uv run sloppy.py

Press Ctrl+Shift+P. The palette should open. Type "reset". The "Reset Session" command should appear at the top. Press Enter. The palette should close. Verify the discussion history was cleared.

  • Step 7.3: Create the checkpoint commit
git -C C:\projects\manual_slop commit --allow-empty -m "conductor(checkpoint): Command Palette (Phase 2) complete"
git -C C:\projects\manual_slop log -1 --format='%H' | ForEach-Object { git -C C:\projects\manual_slop notes add -m "Command Palette feature complete. 6 atomic per-file commits. Unit tests for fuzzy_match, registry, and core commands. Integration tests for Ctrl+Shift+P, filtering, escape. Manually verified in running app." $_ }

Self-Review

  • Spec coverage: All design sections have a task. ✓
  • Placeholder scan: No "TBD"/"TODO". ✓
  • Type consistency: Command, ScoredCommand, CommandRegistry, fuzzy_match names used consistently. ✓
  • No subagent dispatch: All tasks are single-agent. ✓