diff --git a/src/app_controller.py b/src/app_controller.py index 2bfb394e..c4e6bc97 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -2076,8 +2076,7 @@ class AppController: 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_tier_layout_bindings = gui_cfg.get("tier_layout_bindings", {"Tier 1": "", "Tier 2": "", "Tier 3": "", "Tier 4": ""}) - from src.gui_2 import get_bg - get_bg().enabled = gui_cfg.get("bg_shader_enabled", False) + self.bg_shader_enabled = gui_cfg.get("bg_shader_enabled", False) _default_windows = { "Project Settings": True, @@ -3018,7 +3017,6 @@ class AppController: self.config["rag"] = self.rag_config.to_dict() 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 gui_cfg = self.config.get("gui", {}) gui_cfg.update({ @@ -3033,7 +3031,7 @@ class AppController: "separate_tier2": self.ui_separate_tier2, "separate_tier3": self.ui_separate_tier3, "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 diff --git a/src/command_palette.py b/src/command_palette.py deleted file mode 100644 index 1b124b50..00000000 --- a/src/command_palette.py +++ /dev/null @@ -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() diff --git a/src/commands.py b/src/commands.py index b2b7a7a6..0863cd49 100644 --- a/src/commands.py +++ b/src/commands.py @@ -2,12 +2,12 @@ from __future__ import annotations import webbrowser -from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional from src import models from src import theme_2 -from src.module_loader import _require_warmed from src.hot_reloader import HotReloader from src.result_types import ErrorInfo, ErrorKind, Result @@ -15,25 +15,138 @@ from src.result_types import ErrorInfo, ErrorKind, Result if TYPE_CHECKING: 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 -# to defer the actual CommandRegistry creation (and the underlying -# src.command_palette import, ~244ms) until the palette is actually used. -# 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. +# Command data classes + registry (moved from src/command_palette.py in +# module_taxonomy_refactor_20260627 Phase 1.3; the *rendering* function +# `render_palette_modal` lives in src/gui_2.py because it owns ImGui state) # -------------------------------------------------------------------------- + +@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] = [] -_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: - """Proxy that defers CommandRegistry instantiation. - - 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 +class _EagerCommandRegistry: + """Eager registry proxy. @registry.register queues until first .all/.get, + then materializes the real CommandRegistry and replays the queue. """ def register(self, command_or_callable: Any) -> Any: @@ -44,17 +157,7 @@ class _LazyCommandRegistry: return getattr(_get_real_registry(), name) -def _get_real_registry() -> Any: - 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() +registry = _EagerCommandRegistry() # -------------------------------------------------------------------------- diff --git a/src/gui_2.py b/src/gui_2.py index 0d125393..fb2d1290 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -24,7 +24,8 @@ if _thirdparty not in sys.path: from contextlib import ExitStack, nullcontext 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 # Lazy proxies (startup_speedup_20260606 Phase 5D) @@ -1093,9 +1094,9 @@ class App: pushed_prior_tint = False # Render background shader - bg = get_bg() - ws = imgui.get_io().display_size - if bg.enabled: bg.render(ws.x, ws.y) + if getattr(self, 'bg_shader_enabled', False): + ws = imgui.get_io().display_size + get_bg().render(ws.x, ws.y) 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") if ch_ct: theme.set_child_transparency(ctrans) - bg = get_bg() - ch_bg, bg.enabled = imgui.checkbox("Animated Background Shader", bg.enabled) - if ch_bg: + bg_enabled = getattr(self, 'bg_shader_enabled', False) + ch_bg, new_bg = imgui.checkbox("Animated Background Shader", bg_enabled) + if ch_bg and new_bg != bg_enabled: + self.bg_shader_enabled = new_bg gui_cfg = app.config.setdefault("gui", {}) - gui_cfg["bg_shader_enabled"] = bg.enabled - app._flush_to_config() - app.save_config() + gui_cfg["bg_shader_enabled"] = new_bg + if hasattr(app, "_flush_to_config"): app._flush_to_config() + if hasattr(app, "save_config"): app.save_config() ch_crt, app.ui_crt_filter = imgui.checkbox("CRT Filter", app.ui_crt_filter) if ch_crt: @@ -8443,12 +8445,11 @@ _bg: _Optional["BackgroundShader"] = None class BackgroundShader: def __init__(self): - self.enabled = False self.start_time = _bg_time.time() self.ctx: _Optional[_Any] = None def render(self, width: float, height: float): - if not self.enabled or width <= 0 or height <= 0: + if width <= 0 or height <= 0: return t = _bg_time.time() - self.start_time 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 ) #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 diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index bd83ceae..f0863704 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -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: diff --git a/tests/test_commands_no_top_level_command_palette.py b/tests/test_commands_no_top_level_command_palette.py index 6487d696..c4daa4cd 100644 --- a/tests/test_commands_no_top_level_command_palette.py +++ b/tests/test_commands_no_top_level_command_palette.py @@ -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 -heavy feature-gated modules. src.command_palette (~244ms) is warmed on -AppController's _io_pool and accessed via _require_warmed at use sites. +Per module_taxonomy_refactor_20260627 Phase 1.3, src/command_palette.py was +deleted and its content split by responsibility: + - 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` -decorators on its command functions. The naive "drop the top-level import" -approach would break the decorators (they need a registry at module load time). +src/commands.py is a thin data module and can be imported eagerly (no +require_warmed lazy load is needed; the original lazy-load pattern in +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 -no-op that queues the function; the real CommandRegistry is created on first -attribute access to the proxy (e.g. registry.all, registry.get). The 32 -decorated functions get registered at first use, which is the user's first -Ctrl+Shift+P press (or any other access to the palette). - -These tests run in a fresh subprocess to ensure no warmup state leaks -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 +These tests run in fresh subprocesses to ensure no warmup state leaks from +the test runner. We assert: + - src/commands imports cleanly and exposes Command + CommandRegistry + - src/gui_2 exposes render_palette_modal + - src/commands does NOT import gui_2 at module level (avoids circular) + - The static audit detects no top-level command_palette import (since the + module no longer exists) """ 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(""" - import sys - import src.commands - print('src.command_palette' in sys.modules) - """) - assert res.returncode == 0, f"stderr: {res.stderr}" - assert res.stdout.strip() == "False", f"src.commands triggered src.command_palette import: {res.stdout}" - - -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) + from src.commands import Command, CommandRegistry, fuzzy_match, ScoredCommand + r = CommandRegistry() + def my_cmd(app): pass + r.register(my_cmd) + print(len(r.all())) + print(r.all()[0].id) """) assert res.returncode == 0, f"stderr: {res.stderr}" lines = res.stdout.strip().splitlines() - # Should have at least 32 commands registered (matches the 32 @registry.register) - assert int(lines[0]) >= 32, f"Expected >=32 commands, got {lines[0]}" - assert lines[1] == "True", f"src.command_palette should be loaded after registry access, got {lines[1]}" + assert lines[0] == "1" + assert lines[1] == "my_cmd" -def test_commands_register_decorator_is_lazy() -> None: - """The @registry.register decorator should NOT trigger command_palette import at module load.""" +def test_gui_2_exposes_render_palette_modal() -> None: res = _run_in_subprocess(""" - # Fresh subprocess, just import commands - import sys - 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)) + from src.gui_2 import render_palette_modal + print(callable(render_palette_modal)) """) assert res.returncode == 0, f"stderr: {res.stderr}" - lines = res.stdout.strip().splitlines() - 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]}" + assert res.stdout.strip() == "True" -def test_audit_main_thread_imports_sees_no_new_violation_from_commands() -> None: - """Run the static audit and check that src/commands.py contributes no - new command_palette violations. +def test_commands_does_not_import_gui_2_at_module_level() -> None: + """src/commands is imported by src/commands registration sites and by + 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(""" import ast from pathlib import Path - root = Path('.').resolve() - commands_path = root / 'src' / 'commands.py' - tree = ast.parse(commands_path.read_text(encoding='utf-8')) - heavy = ['src.command_palette', 'command_palette'] + commands_path = Path('src') / 'commands.py' + source = commands_path.read_text(encoding='utf-8') + tree = ast.parse(source) for node in tree.body: - if isinstance(node, ast.Import): - for alias in node.names: - for h in heavy: - if alias.name == h or alias.name.startswith(h + '.'): - 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) + if isinstance(node, (ast.Import, ast.ImportFrom)): + mod = getattr(node, 'module', None) or (node.names[0].name if node.names else '') + if mod and ('gui_2' in mod or mod.endswith('gui_2')): + print('VIOLATION:', mod) print('OK') """) assert res.returncode == 0, f"stderr: {res.stderr}" - assert "OK" 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" \ No newline at end of file