Private
Public Access
0
0
Files
manual_slop/src/commands.py
T

472 lines
15 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 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 the cwd's manualslop_layout.ini so hello_imgui regenerates
a fresh dock layout on the next process restart. 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: list[str] = ["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