Private
Public Access
0
0
Files
manual_slop/src/commands.py
T
ed 3dd153f718 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)
2026-06-26 06:54:59 -04:00

477 lines
16 KiB
Python

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 models
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