from __future__ import annotations import webbrowser from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional from src import theme_2 from src.hot_reloader import HotReloader from src.result_types import ErrorInfo, ErrorKind, Result if TYPE_CHECKING: from src.gui_2 import App # -------------------------------------------------------------------------- # 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: 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 _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: _PENDING_REGISTRATIONS.append(command_or_callable) return command_or_callable def __getattr__(self, name: str) -> Any: return getattr(_get_real_registry(), name) registry = _EagerCommandRegistry() # -------------------------------------------------------------------------- # Helpers # -------------------------------------------------------------------------- def _toggle_window(app: "App", name: str) -> None: """Toggle a window in app.show_windows by name. Defensive no-op if missing.""" if hasattr(app, "show_windows") and isinstance(app.show_windows, dict): app.show_windows[name] = not app.show_windows.get(name, False) def _toggle_attr(app: "App", attr: str) -> None: """Toggle a boolean attribute on app. Defensive no-op if missing.""" if hasattr(app, attr): current = getattr(app, attr) if isinstance(current, bool): setattr(app, attr, not current) # -------------------------------------------------------------------------- # AI / Discussion # -------------------------------------------------------------------------- @registry.register def reset_session(app: "App") -> None: """Reset Session — Reset the AI session, clear comms and tool logs.""" from src import ai_client ai_client.reset_session() if hasattr(app, "_handle_reset_session"): app._handle_reset_session() if hasattr(app, "_comms_log"): app._comms_log.clear() if hasattr(app, "_tool_log"): app._tool_log.clear() if hasattr(app, "ai_response"): app.ai_response = "" @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 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 generate_md_only(app: "App") -> None: """Generate MD Only — Run the AI to produce a markdown file without sending to the chat.""" if hasattr(app, "_do_generate"): try: md, path, *_ = app._do_generate() app.last_md = md app.last_md_path = path if hasattr(app, "ai_status"): app.ai_status = f"md written: {path.name}" except (OSError, ValueError, TypeError) as e: _md_err = Result(data=None, errors=[ErrorInfo(kind=ErrorKind.INTERNAL, message=f"generate_md_only: {e}", source="commands.generate_md_only", original=e)]) if hasattr(app, "ai_status"): app.ai_status = f"error: {e}" # -------------------------------------------------------------------------- # Project # -------------------------------------------------------------------------- @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 save_all(app: "App") -> None: """Save All — Flush to project, flush to config, save global config.""" if hasattr(app, "_flush_to_project"): app._flush_to_project() if hasattr(app, "_flush_to_config"): app._flush_to_config() if hasattr(app, "config"): try: app.save_config() except (OSError, ValueError) as e: _save_err = Result(data=None, errors=[ErrorInfo(kind=ErrorKind.INTERNAL, message=f"save_config: {e}", source="commands.save_all", original=e)]) if hasattr(app, "ai_status"): app.ai_status = f"save error: {e}" # -------------------------------------------------------------------------- # View — Window toggles (from show_windows dict) # -------------------------------------------------------------------------- @registry.register def toggle_text_viewer(app: "App") -> None: """Toggle Text Viewer — Open or close the standalone text/code viewer.""" _toggle_window(app, "Text Viewer") @registry.register def toggle_diagnostics(app: "App") -> None: """Toggle Diagnostics — Show/hide the Diagnostics panel.""" _toggle_window(app, "Diagnostics") @registry.register def toggle_usage_analytics(app: "App") -> None: """Toggle Usage Analytics — Show/hide the Usage Analytics panel.""" _toggle_window(app, "Usage Analytics") @registry.register def toggle_context_preview(app: "App") -> None: """Toggle Context Preview — Show/hide the Context Preview panel.""" _toggle_window(app, "Context Preview") @registry.register def toggle_tier1_strategy(app: "App") -> None: """Toggle Tier 1 Strategy — Show/hide the Tier 1 strategy stream.""" _toggle_window(app, "Tier 1: Strategy") @registry.register def toggle_tier2_tech_lead(app: "App") -> None: """Toggle Tier 2 Tech Lead — Show/hide the Tier 2 tech-lead stream.""" _toggle_window(app, "Tier 2: Tech Lead") @registry.register def toggle_tier3_workers(app: "App") -> None: """Toggle Tier 3 Workers — Show/hide the Tier 3 worker streams.""" _toggle_window(app, "Tier 3: Workers") @registry.register def toggle_tier4_qa(app: "App") -> None: """Toggle Tier 4 QA — Show/hide the Tier 4 QA stream.""" _toggle_window(app, "Tier 4: QA") @registry.register def toggle_external_tools(app: "App") -> None: """Toggle External Tools — Show/hide the External MCP tools panel.""" _toggle_window(app, "External Tools") @registry.register def toggle_shader_editor(app: "App") -> None: """Toggle Shader Editor — Show/hide the live shader editor.""" _toggle_window(app, "Shader Editor") @registry.register def toggle_undo_redo_history(app: "App") -> None: """Toggle Undo/Redo History — Show/hide the undo/redo history panel.""" _toggle_window(app, "Undo/Redo History") @registry.register def toggle_command_palette(app: "App") -> None: """Toggle Command Palette — Open or close the command palette.""" _toggle_attr(app, "show_command_palette") @registry.register def show_all_panels(app: "App") -> None: """Show All Panels — Open every toggleable window.""" if hasattr(app, "show_windows") and isinstance(app.show_windows, dict): for name in app.show_windows.keys(): app.show_windows[name] = True @registry.register def hide_all_panels(app: "App") -> None: """Hide All Panels — Close every toggleable window.""" if hasattr(app, "show_windows") and isinstance(app.show_windows, dict): for name in app.show_windows.keys(): app.show_windows[name] = False @registry.register def reset_layout(app: "App") -> None: """Reset Layout — Restore the default window layout and clear the stale dock-state cache. Useful when a previously-saved manualslop_layout.ini references window names that no longer exist (e.g. after a refactor renames windows) and the dock space appears empty / non-recoverable via the Windows menu. Sets every show_windows entry to True and deletes manualslop_layout.ini so hello_imgui regenerates a fresh dock layout on the next frame (and the next process shutdown saves the new layout in place of the stale one). The user will need to restart sloppy.py for the dock layout to fully take effect; the show_windows toggles take effect immediately. """ if hasattr(app, "show_windows") and isinstance(app.show_windows, dict): for name in app.show_windows.keys(): app.show_windows[name] = True try: import os layout_paths = [ "manualslop_layout.ini", os.path.join("tests", "artifacts", "live_gui_workspace", "manualslop_layout.ini"), ] for p in layout_paths: if os.path.exists(p): os.remove(p) if hasattr(app, "ai_status"): app.ai_status = f"layout reset: removed {p}" except OSError as e: if hasattr(app, "ai_status"): app.ai_status = f"layout reset partial: {e}" # -------------------------------------------------------------------------- # Layout — Workspace Profiles # -------------------------------------------------------------------------- @registry.register def save_workspace_profile(app: "App") -> None: """Save Workspace Profile — Open the save-profile dialog.""" _toggle_attr(app, "_show_save_workspace_profile_modal") if hasattr(app, "_new_workspace_profile_name"): app._new_workspace_profile_name = "" @registry.register def show_workspace_manager(app: "App") -> None: """Show Workspace Manager — Open the workspace profile management UI.""" if hasattr(app, "show_windows") and isinstance(app.show_windows, dict): app.show_windows["Workspace Manager"] = True # -------------------------------------------------------------------------- # Tools # -------------------------------------------------------------------------- @registry.register def trigger_hot_reload(app: "App") -> None: """Hot Reload — Reload the GUI module to pick up code changes.""" HotReloader.reload("src.gui_2", app) @registry.register def undo(app: "App") -> None: """Undo — Revert the most recent UI mutation.""" if hasattr(app, "_handle_undo"): app._handle_undo() @registry.register def redo(app: "App") -> None: """Redo — Re-apply the most recently undone mutation.""" if hasattr(app, "_handle_redo"): app._handle_redo() # -------------------------------------------------------------------------- # Theme # -------------------------------------------------------------------------- @registry.register def switch_to_dark_theme(app: "App") -> None: """Switch to Dark Theme (10x Dark palette).""" theme_2.apply("10x Dark") @registry.register def switch_to_light_theme(app: "App") -> None: """Switch to Light Theme (ImGui Light palette).""" theme_2.apply("ImGui Light") @registry.register def switch_to_nerv_theme(app: "App") -> None: """Switch to NERV Theme (Tactical Console aesthetic).""" theme_2.apply("NERV") @registry.register def cycle_theme(app: "App") -> None: """Cycle Theme — Switch to the next theme in the cycle (Dark → Light → NERV → Dark).""" order = ["10x Dark", "ImGui Light", "NERV"] current = theme_2.get_current_palette() if current in order: next_idx = (order.index(current) + 1) % len(order) else: next_idx = 0 theme_2.apply(order[next_idx]) # -------------------------------------------------------------------------- # Help # -------------------------------------------------------------------------- @registry.register def show_documentation(app: "App") -> None: """Show Documentation — Open the project URL in the browser.""" webbrowser.open("https://git.cozyair.dev/ed/manual_slop/") @registry.register def show_command_palette_help(app: "App") -> None: """Show Command Palette Help — Open the docs/Readme.md in the Text Viewer.""" if hasattr(app, "readme_text"): docs_readme = Path("docs/Readme.md") if docs_readme.exists(): app.readme_text = docs_readme.read_text(encoding="utf-8") if hasattr(app, "show_windows") and isinstance(app.show_windows, dict): app.show_windows["Text Viewer"] = True