Private
Public Access
0
0

refactor(gui_2): merge command_palette; split registry->commands + render->gui_2; git rm src/command_palette.py

Per spec FR1 + Phase 1.3 + architecture feedback: src/command_palette.py
split by responsibility:
  - Command/ScoredCommand/CommandRegistry/fuzzy_match/_close_palette/_execute (data/ops)
    -> src/commands.py (which already owns _LazyCommandRegistry pattern)
  - render_palette_modal (view/ImGui) -> src/gui_2.py

GUI is a pure view; the registry/data classes are ops; commands.py owns
the registry because commands.py is where @registry.register decorators live.
gui_2.render_palette_modal imports Command from commands.py to type its
parameters.

Also fixes Phase 1.1 (bg_shader) per architecture feedback:
BackgroundShader no longer owns 'enabled' state - the GUI is pure view.
State is now owned by AppController.bg_shader_enabled (read on load from
config, written from gui_2 checkbox via app's __setattr__ delegation).

Tests:
- tests/test_command_palette.py: imports from src.commands (was src.command_palette)
- tests/test_commands_no_top_level_command_palette.py: rewritten for the
  new architecture (eager registry in commands.py; render in gui_2; no
  circular import between commands.py and gui_2)
This commit is contained in:
2026-06-26 06:54:59 -04:00
parent be5607dee8
commit 3dd153f718
6 changed files with 259 additions and 327 deletions
+2 -4
View File
@@ -2076,8 +2076,7 @@ class AppController:
self.ui_separate_tool_calls_panel = _uip.separate_tool_calls_panel self.ui_separate_tool_calls_panel = _uip.separate_tool_calls_panel
self.ui_auto_switch_layout = gui_cfg.get("auto_switch_layout", False) self.ui_auto_switch_layout = gui_cfg.get("auto_switch_layout", False)
self.ui_tier_layout_bindings = gui_cfg.get("tier_layout_bindings", {"Tier 1": "", "Tier 2": "", "Tier 3": "", "Tier 4": ""}) self.ui_tier_layout_bindings = gui_cfg.get("tier_layout_bindings", {"Tier 1": "", "Tier 2": "", "Tier 3": "", "Tier 4": ""})
from src.gui_2 import get_bg self.bg_shader_enabled = gui_cfg.get("bg_shader_enabled", False)
get_bg().enabled = gui_cfg.get("bg_shader_enabled", False)
_default_windows = { _default_windows = {
"Project Settings": True, "Project Settings": True,
@@ -3018,7 +3017,6 @@ class AppController:
self.config["rag"] = self.rag_config.to_dict() self.config["rag"] = self.rag_config.to_dict()
self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path} self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path}
from src.gui_2 import get_bg
# Update gui section while preserving other keys like bg_shader_enabled # Update gui section while preserving other keys like bg_shader_enabled
gui_cfg = self.config.get("gui", {}) gui_cfg = self.config.get("gui", {})
gui_cfg.update({ gui_cfg.update({
@@ -3033,7 +3031,7 @@ class AppController:
"separate_tier2": self.ui_separate_tier2, "separate_tier2": self.ui_separate_tier2,
"separate_tier3": self.ui_separate_tier3, "separate_tier3": self.ui_separate_tier3,
"separate_tier4": self.ui_separate_tier4, "separate_tier4": self.ui_separate_tier4,
"bg_shader_enabled": get_bg().enabled "bg_shader_enabled": getattr(self, "bg_shader_enabled", False)
}) })
self.config["gui"] = gui_cfg self.config["gui"] = gui_cfg
-208
View File
@@ -1,208 +0,0 @@
from __future__ import annotations
from imgui_bundle import imgui
from dataclasses import dataclass, field
from typing import Optional, Callable, List, Dict, Any
from src.result_types import ErrorInfo, ErrorKind, Result
@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) -> Command:
return self._commands.get(command_id) or Command(id="", title="", category="uncategorized", action=lambda: None)
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
def _close_palette(app: Any) -> None:
"""Close the palette and reset all per-open state."""
app.show_command_palette = False
app._command_palette_query = ""
app._command_palette_selected = 0
app._command_palette_focused = False
app._command_palette_input_focused = False
def _execute(app: Any, command: Command) -> None:
"""Run a command and close the palette. Catches exceptions to keep the modal clean."""
if not command.action:
return
try:
command.action(app)
except (AttributeError, TypeError, ValueError, OSError) as e:
_cmd_err = Result(data=None, errors=[ErrorInfo(kind=ErrorKind.INTERNAL, message=f"Action {command.id} raised: {e}", source="command_palette._execute", original=e)])
print(f"[CommandPalette] Action {command.id} raised: {e}")
_close_palette(app)
def render_palette_modal(app: Any, commands: List[Command]) -> None:
"""Renders the interactive Command Palette modal. Exposes a text input query bar
and lists matching commands with fuzzy matching, supporting keyboard navigation (Up/Down/Enter/Esc).
SSDL: `[I:query_input] -> [B:results_list] => [B:execute_command]`
ASCII Layout Map:
+==================== Command Palette ===================+
| |query_text| |
| +-----------------------------------------------------+ |
| | > [category-a] Command Title A (selected) | |
| | [category-b] Command Title B | |
| +-----------------------------------------------------+ |
+========================================================+
"""
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.Cond_.always)
imgui.set_next_window_size((600, 400), imgui.Cond_.always)
if not hasattr(app, "_command_palette_query"): app._command_palette_query = ""
if not hasattr(app, "_command_palette_selected"): app._command_palette_selected = 0
if not hasattr(app, "_command_palette_focused"): app._command_palette_focused = False
# Set focus on the window + input field ONCE per open.
if not app._command_palette_focused:
imgui.set_next_window_focus()
app._command_palette_focused = True
# Escape closes the palette.
if imgui.is_key_pressed(imgui.Key.escape):
_close_palette(app)
return
expanded, opened = imgui.begin("Command Palette##manual_slop", True, imgui.WindowFlags_.no_collapse)
if not expanded or not opened:
app.show_command_palette = False
app._command_palette_focused = False
imgui.end()
return
# After the window is drawn, the input gets focus.
if not getattr(app, '_command_palette_input_focused', False):
imgui.set_keyboard_focus_here()
app._command_palette_input_focused = True
# Process Up/Down/Enter BEFORE input_text so we see the keys before the
# input field consumes them for cursor movement / text editing.
results = fuzzy_match(app._command_palette_query, commands, top_n=20)
if results: app._command_palette_selected = max(0, min(app._command_palette_selected, len(results) - 1))
else: app._command_palette_selected = 0
if imgui.is_key_pressed(imgui.Key.down_arrow):
if results:
app._command_palette_selected = min(app._command_palette_selected + 1, len(results) - 1)
if imgui.is_key_pressed(imgui.Key.up_arrow):
if results:
app._command_palette_selected = max(app._command_palette_selected - 1, 0)
if imgui.is_key_pressed(imgui.Key.enter) or imgui.is_key_pressed(imgui.Key.keypad_enter):
if results and 0 <= app._command_palette_selected < len(results):
_execute(app, results[app._command_palette_selected].command)
imgui.set_next_item_width(-1)
_, app._command_palette_query = imgui.input_text("##query", app._command_palette_query)
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}"
clicked, _ = imgui.selectable(label, is_selected)
if clicked:
app._command_palette_selected = i
_execute(app, scored.command)
if not results:
imgui.text_disabled("No matching commands.")
imgui.end_child()
imgui.end()
+131 -28
View File
@@ -2,12 +2,12 @@ from __future__ import annotations
import webbrowser import webbrowser
from pathlib import Path from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
from src import models from src import models
from src import theme_2 from src import theme_2
from src.module_loader import _require_warmed
from src.hot_reloader import HotReloader from src.hot_reloader import HotReloader
from src.result_types import ErrorInfo, ErrorKind, Result from src.result_types import ErrorInfo, ErrorKind, Result
@@ -15,25 +15,138 @@ from src.result_types import ErrorInfo, ErrorKind, Result
if TYPE_CHECKING: if TYPE_CHECKING:
from src.gui_2 import App from src.gui_2 import App
# Lazy command registry (startup_speedup_20260606 Phase 5A)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# The @registry.register decorator runs at module import time, but we want # Command data classes + registry (moved from src/command_palette.py in
# to defer the actual CommandRegistry creation (and the underlying # module_taxonomy_refactor_20260627 Phase 1.3; the *rendering* function
# src.command_palette import, ~244ms) until the palette is actually used. # `render_palette_modal` lives in src/gui_2.py because it owns ImGui state)
# The proxy below makes @registry.register a no-op that just queues the
# function; the real CommandRegistry is built lazily on first access to
# any other registry attribute (.all, .get, etc.) by gui_2.py or tests.
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@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) -> Command:
return self._commands.get(command_id) or Command(id="", title="", category="uncategorized", action=lambda: None)
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
def _close_palette(app: Any) -> None:
app.show_command_palette = False
app._command_palette_query = ""
app._command_palette_selected = 0
app._command_palette_focused = False
app._command_palette_input_focused = False
def _execute(app: Any, command: Command) -> None:
if not command.action:
return
try:
command.action(app)
except (AttributeError, TypeError, ValueError, OSError) as e:
_cmd_err = Result(data=None, errors=[ErrorInfo(kind=ErrorKind.INTERNAL, message=f"Action {command.id} raised: {e}", source="command_palette._execute", original=e)])
print(f"[CommandPalette] Action {command.id} raised: {e}")
_close_palette(app)
# --------------------------------------------------------------------------
# Eager registry (was _LazyCommandRegistry; the lazy pattern is no longer
# needed since src/commands.py is a thin data module, not the heavy
# command_palette.py that previously pulled in imgui at module load time)
# --------------------------------------------------------------------------
_PENDING_REGISTRATIONS: list[Callable] = [] _PENDING_REGISTRATIONS: list[Callable] = []
_real_registry: Any = None _real_registry: CommandRegistry | None = None
def _get_real_registry() -> CommandRegistry:
global _real_registry
if _real_registry is None:
_real_registry = CommandRegistry()
for func in _PENDING_REGISTRATIONS:
_real_registry.register(func)
return _real_registry
class _LazyCommandRegistry: class _EagerCommandRegistry:
"""Proxy that defers CommandRegistry instantiation. """Eager registry proxy. @registry.register queues until first .all/.get,
then materializes the real CommandRegistry and replays the queue.
Behaves like a CommandRegistry from the caller's perspective:
- @registry.register decorates functions by queuing them
- .all, .get, etc. trigger real initialization on first access
""" """
def register(self, command_or_callable: Any) -> Any: def register(self, command_or_callable: Any) -> Any:
@@ -44,17 +157,7 @@ class _LazyCommandRegistry:
return getattr(_get_real_registry(), name) return getattr(_get_real_registry(), name)
def _get_real_registry() -> Any: registry = _EagerCommandRegistry()
global _real_registry
if _real_registry is None:
command_palette = _require_warmed("src.command_palette")
_real_registry = command_palette.CommandRegistry()
for func in _PENDING_REGISTRATIONS:
_real_registry.register(func)
return _real_registry
registry = _LazyCommandRegistry()
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
+69 -12
View File
@@ -24,7 +24,8 @@ if _thirdparty not in sys.path:
from contextlib import ExitStack, nullcontext from contextlib import ExitStack, nullcontext
from pathlib import Path from pathlib import Path
from typing import Optional, Any from typing import Optional, Any, Callable, Dict, List
from dataclasses import dataclass, field
from imgui_bundle import imgui, hello_imgui, immapp, imgui_node_editor as ed, imgui_color_text_edit as ced from imgui_bundle import imgui, hello_imgui, immapp, imgui_node_editor as ed, imgui_color_text_edit as ced
# Lazy proxies (startup_speedup_20260606 Phase 5D) # Lazy proxies (startup_speedup_20260606 Phase 5D)
@@ -1093,9 +1094,9 @@ class App:
pushed_prior_tint = False pushed_prior_tint = False
# Render background shader # Render background shader
bg = get_bg() if getattr(self, 'bg_shader_enabled', False):
ws = imgui.get_io().display_size ws = imgui.get_io().display_size
if bg.enabled: bg.render(ws.x, ws.y) get_bg().render(ws.x, ws.y)
theme.render_post_fx(ws.x, ws.y, self.ai_status, self.ui_crt_filter) theme.render_post_fx(ws.x, ws.y, self.ai_status, self.ui_crt_filter)
@@ -6265,13 +6266,14 @@ def render_theme_panel(app: App) -> None:
ch_ct, ctrans = imgui.slider_float("##ctrans", theme.get_child_transparency(), 0.1, 1.0, "%.2f") ch_ct, ctrans = imgui.slider_float("##ctrans", theme.get_child_transparency(), 0.1, 1.0, "%.2f")
if ch_ct: if ch_ct:
theme.set_child_transparency(ctrans) theme.set_child_transparency(ctrans)
bg = get_bg() bg_enabled = getattr(self, 'bg_shader_enabled', False)
ch_bg, bg.enabled = imgui.checkbox("Animated Background Shader", bg.enabled) ch_bg, new_bg = imgui.checkbox("Animated Background Shader", bg_enabled)
if ch_bg: if ch_bg and new_bg != bg_enabled:
self.bg_shader_enabled = new_bg
gui_cfg = app.config.setdefault("gui", {}) gui_cfg = app.config.setdefault("gui", {})
gui_cfg["bg_shader_enabled"] = bg.enabled gui_cfg["bg_shader_enabled"] = new_bg
app._flush_to_config() if hasattr(app, "_flush_to_config"): app._flush_to_config()
app.save_config() if hasattr(app, "save_config"): app.save_config()
ch_crt, app.ui_crt_filter = imgui.checkbox("CRT Filter", app.ui_crt_filter) ch_crt, app.ui_crt_filter = imgui.checkbox("CRT Filter", app.ui_crt_filter)
if ch_crt: if ch_crt:
@@ -8443,12 +8445,11 @@ _bg: _Optional["BackgroundShader"] = None
class BackgroundShader: class BackgroundShader:
def __init__(self): def __init__(self):
self.enabled = False
self.start_time = _bg_time.time() self.start_time = _bg_time.time()
self.ctx: _Optional[_Any] = None self.ctx: _Optional[_Any] = None
def render(self, width: float, height: float): def render(self, width: float, height: float):
if not self.enabled or width <= 0 or height <= 0: if width <= 0 or height <= 0:
return return
t = _bg_time.time() - self.start_time t = _bg_time.time() - self.start_time
dl = imgui.get_background_draw_list() dl = imgui.get_background_draw_list()
@@ -8506,3 +8507,59 @@ def draw_soft_shadow(draw_list: imgui.ImDrawList, p_min: imgui.ImVec2, p_max: im
thickness=1.0 thickness=1.0
) )
#endregion: Shaders #endregion: Shaders
#region: Command Palette Modal (rendering only; registry lives in src/commands.py)
from src.commands import Command as _CpCommand, fuzzy_match as _cp_fuzzy_match, _close_palette, _execute as _cp_execute
def render_palette_modal(app: Any, commands: List[Any]) -> 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.Cond_.always)
imgui.set_next_window_size((600, 400), imgui.Cond_.always)
if not hasattr(app, "_command_palette_query"): app._command_palette_query = ""
if not hasattr(app, "_command_palette_selected"): app._command_palette_selected = 0
if not hasattr(app, "_command_palette_focused"): app._command_palette_focused = False
if not app._command_palette_focused:
imgui.set_next_window_focus()
app._command_palette_focused = True
if imgui.is_key_pressed(imgui.Key.escape):
_close_palette(app)
return
expanded, opened = imgui.begin("Command Palette##manual_slop", True, imgui.WindowFlags_.no_collapse)
if not expanded or not opened:
app.show_command_palette = False
app._command_palette_focused = False
imgui.end()
return
if not getattr(app, '_command_palette_input_focused', False):
imgui.set_keyboard_focus_here()
app._command_palette_input_focused = True
results = _cp_fuzzy_match(app._command_palette_query, commands, top_n=20)
if results: app._command_palette_selected = max(0, min(app._command_palette_selected, len(results) - 1))
else: app._command_palette_selected = 0
if imgui.is_key_pressed(imgui.Key.down_arrow):
if results:
app._command_palette_selected = min(app._command_palette_selected + 1, len(results) - 1)
if imgui.is_key_pressed(imgui.Key.up_arrow):
if results:
app._command_palette_selected = max(app._command_palette_selected - 1, 0)
if imgui.is_key_pressed(imgui.Key.enter) or imgui.is_key_pressed(imgui.Key.keypad_enter):
if results and 0 <= app._command_palette_selected < len(results):
_cp_execute(app, results[app._command_palette_selected].command)
imgui.set_next_item_width(-1)
_, app._command_palette_query = imgui.input_text("##query", app._command_palette_query)
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}"
clicked, _ = imgui.selectable(label, is_selected)
if clicked:
app._command_palette_selected = i
_cp_execute(app, scored.command)
if not results:
imgui.text_disabled("No matching commands.")
imgui.end_child()
imgui.end()
#endregion: Command Palette Modal
+1 -1
View File
@@ -1,4 +1,4 @@
from src.command_palette import Command, ScoredCommand, fuzzy_match from src.commands import Command, ScoredCommand, fuzzy_match
def _cmd(id: str, title: str) -> Command: def _cmd(id: str, title: str) -> Command:
@@ -1,26 +1,22 @@
"""Tests that src/commands.py has NO top-level src.command_palette import. """Tests for the post-Phase 1.3 architecture.
Per spec.md:2.2 Layer 1, the main thread's import chain must not include Per module_taxonomy_refactor_20260627 Phase 1.3, src/command_palette.py was
heavy feature-gated modules. src.command_palette (~244ms) is warmed on deleted and its content split by responsibility:
AppController's _io_pool and accessed via _require_warmed at use sites. - Command / ScoredCommand / CommandRegistry / fuzzy_match (data/ops) -> src/commands.py
- render_palette_modal (view) -> src/gui_2.py
src/commands.py is a particularly tricky case: it has 32 `@registry.register` src/commands.py is a thin data module and can be imported eagerly (no
decorators on its command functions. The naive "drop the top-level import" require_warmed lazy load is needed; the original lazy-load pattern in
approach would break the decorators (they need a registry at module load time). startup_speedup_20260606 was specifically to defer the heavy src/command_palette
which pulled in imgui at module load).
Solution: a lazy registry proxy. The @registry.register decorator becomes a These tests run in fresh subprocesses to ensure no warmup state leaks from
no-op that queues the function; the real CommandRegistry is created on first the test runner. We assert:
attribute access to the proxy (e.g. registry.all, registry.get). The 32 - src/commands imports cleanly and exposes Command + CommandRegistry
decorated functions get registered at first use, which is the user's first - src/gui_2 exposes render_palette_modal
Ctrl+Shift+P press (or any other access to the palette). - src/commands does NOT import gui_2 at module level (avoids circular)
- The static audit detects no top-level command_palette import (since the
These tests run in a fresh subprocess to ensure no warmup state leaks module no longer exists)
from the test runner. We assert:
- `src.command_palette` is NOT in `sys.modules` after `import src.commands`
- The lazy registry proxy works: `from src.commands import registry` succeeds
- Accessing `registry.all()` triggers the real CommandRegistry and
returns all 32 registered commands
- The static audit script reports NO new violation from src/commands.py
""" """
import subprocess import subprocess
@@ -42,77 +38,63 @@ def _run_in_subprocess(snippet: str) -> subprocess.CompletedProcess:
) )
def test_commands_does_not_import_command_palette_at_module_level() -> None: def test_commands_exposes_command_and_registry() -> None:
res = _run_in_subprocess(""" res = _run_in_subprocess("""
import sys from src.commands import Command, CommandRegistry, fuzzy_match, ScoredCommand
import src.commands r = CommandRegistry()
print('src.command_palette' in sys.modules) def my_cmd(app): pass
""") r.register(my_cmd)
assert res.returncode == 0, f"stderr: {res.stderr}" print(len(r.all()))
assert res.stdout.strip() == "False", f"src.commands triggered src.command_palette import: {res.stdout}" print(r.all()[0].id)
def test_commands_lazy_registry_proxies_to_real_registry() -> None:
"""Accessing registry.all() should trigger real init and return registered commands."""
res = _run_in_subprocess("""
from src.commands import registry
# Access .all() triggers real CommandRegistry creation
all_cmds = registry.all()
print(len(list(all_cmds)))
# After access, src.command_palette SHOULD be in sys.modules
import sys
print('src.command_palette' in sys.modules)
""") """)
assert res.returncode == 0, f"stderr: {res.stderr}" assert res.returncode == 0, f"stderr: {res.stderr}"
lines = res.stdout.strip().splitlines() lines = res.stdout.strip().splitlines()
# Should have at least 32 commands registered (matches the 32 @registry.register) assert lines[0] == "1"
assert int(lines[0]) >= 32, f"Expected >=32 commands, got {lines[0]}" assert lines[1] == "my_cmd"
assert lines[1] == "True", f"src.command_palette should be loaded after registry access, got {lines[1]}"
def test_commands_register_decorator_is_lazy() -> None: def test_gui_2_exposes_render_palette_modal() -> None:
"""The @registry.register decorator should NOT trigger command_palette import at module load."""
res = _run_in_subprocess(""" res = _run_in_subprocess("""
# Fresh subprocess, just import commands from src.gui_2 import render_palette_modal
import sys print(callable(render_palette_modal))
import src.commands
# Verify decorator ran but did not trigger command_palette
# (the lazy proxy just queues; real init is deferred)
print('src.command_palette' in sys.modules)
# Verify the function references still exist
from src.commands import toggle_command_palette
print(callable(toggle_command_palette))
""") """)
assert res.returncode == 0, f"stderr: {res.stderr}" assert res.returncode == 0, f"stderr: {res.stderr}"
lines = res.stdout.strip().splitlines() assert res.stdout.strip() == "True"
assert lines[0] == "False", f"Decorator should not trigger command_palette, got {lines[0]}"
assert lines[1] == "True", f"toggle_command_palette should be a callable, got {lines[1]}"
def test_audit_main_thread_imports_sees_no_new_violation_from_commands() -> None: def test_commands_does_not_import_gui_2_at_module_level() -> None:
"""Run the static audit and check that src/commands.py contributes no """src/commands is imported by src/commands registration sites and by
new command_palette violations. gui_2.render_palette_modal. To avoid a circular import, commands.py must
NOT import gui_2 at the top of the file. (TYPE_CHECKING imports are
allowed because they don't execute at runtime.)
""" """
res = _run_in_subprocess(""" res = _run_in_subprocess("""
import ast import ast
from pathlib import Path from pathlib import Path
root = Path('.').resolve() commands_path = Path('src') / 'commands.py'
commands_path = root / 'src' / 'commands.py' source = commands_path.read_text(encoding='utf-8')
tree = ast.parse(commands_path.read_text(encoding='utf-8')) tree = ast.parse(source)
heavy = ['src.command_palette', 'command_palette']
for node in tree.body: for node in tree.body:
if isinstance(node, ast.Import): if isinstance(node, (ast.Import, ast.ImportFrom)):
for alias in node.names: mod = getattr(node, 'module', None) or (node.names[0].name if node.names else '')
for h in heavy: if mod and ('gui_2' in mod or mod.endswith('gui_2')):
if alias.name == h or alias.name.startswith(h + '.'): print('VIOLATION:', mod)
print('VIOLATION:', alias.name)
elif isinstance(node, ast.ImportFrom):
if node.module:
for h in heavy:
if node.module == h or node.module.startswith(h + '.'):
print('VIOLATION:', node.module)
print('OK') print('OK')
""") """)
assert res.returncode == 0, f"stderr: {res.stderr}" assert res.returncode == 0, f"stderr: {res.stderr}"
assert "OK" in res.stdout
assert "VIOLATION" not in res.stdout assert "VIOLATION" not in res.stdout
assert "OK" in res.stdout
def test_command_palette_module_no_longer_exists() -> None:
"""src/command_palette.py was deleted in Phase 1.3; this is a regression guard."""
res = _run_in_subprocess("""
import importlib
try:
importlib.import_module('src.command_palette')
print('EXISTS')
except ModuleNotFoundError:
print('NOT_FOUND')
""")
assert res.returncode == 0, f"stderr: {res.stderr}"
assert res.stdout.strip() == "NOT_FOUND"