277 lines
11 KiB
Python
277 lines
11 KiB
Python
import asyncio
|
|
import threading
|
|
import time
|
|
import sys
|
|
import os
|
|
from typing import Any, List, Dict, Optional, Tuple
|
|
from pathlib import Path
|
|
|
|
from src import events
|
|
from src import session_logger
|
|
from src import project_manager
|
|
from src.performance_monitor import PerformanceMonitor
|
|
from src.models import Track, Ticket, load_config, parse_history_entries, DISC_ROLES, AGENT_TOOL_NAMES
|
|
|
|
class AppController:
|
|
"""
|
|
The headless controller for the Manual Slop application.
|
|
Owns the application state and manages background services.
|
|
"""
|
|
def __init__(self):
|
|
# Initialize locks first to avoid initialization order issues
|
|
self._send_thread_lock: threading.Lock = threading.Lock()
|
|
self._disc_entries_lock: threading.Lock = threading.Lock()
|
|
self._pending_comms_lock: threading.Lock = threading.Lock()
|
|
self._pending_tool_calls_lock: threading.Lock = threading.Lock()
|
|
self._pending_history_adds_lock: threading.Lock = threading.Lock()
|
|
self._pending_gui_tasks_lock: threading.Lock = threading.Lock()
|
|
self._pending_dialog_lock: threading.Lock = threading.Lock()
|
|
self._api_event_queue_lock: threading.Lock = threading.Lock()
|
|
|
|
self.config: Dict[str, Any] = {}
|
|
self.project: Dict[str, Any] = {}
|
|
self.active_project_path: str = ""
|
|
self.project_paths: List[str] = []
|
|
self.active_discussion: str = "main"
|
|
self.disc_entries: List[Dict[str, Any]] = []
|
|
self.disc_roles: List[str] = []
|
|
self.files: List[str] = []
|
|
self.screenshots: List[str] = []
|
|
|
|
self.event_queue: events.AsyncEventQueue = events.AsyncEventQueue()
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
self._loop_thread: Optional[threading.Thread] = None
|
|
|
|
self.tracks: List[Dict[str, Any]] = []
|
|
self.active_track: Optional[Track] = None
|
|
self.active_tickets: List[Dict[str, Any]] = []
|
|
self.mma_streams: Dict[str, str] = {}
|
|
self.mma_status: str = "idle"
|
|
|
|
self._tool_log: List[Dict[str, Any]] = []
|
|
self._comms_log: List[Dict[str, Any]] = []
|
|
|
|
self.session_usage: Dict[str, Any] = {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"cache_creation_input_tokens": 0,
|
|
"last_latency": 0.0
|
|
}
|
|
|
|
self.mma_tier_usage: Dict[str, Dict[str, Any]] = {
|
|
"Tier 1": {"input": 0, "output": 0, "model": "gemini-3.1-pro-preview"},
|
|
"Tier 2": {"input": 0, "output": 0, "model": "gemini-3-flash-preview"},
|
|
"Tier 3": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"},
|
|
"Tier 4": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"},
|
|
}
|
|
|
|
self.perf_monitor: PerformanceMonitor = PerformanceMonitor()
|
|
self._pending_gui_tasks: List[Dict[str, Any]] = []
|
|
|
|
# AI settings state
|
|
self._current_provider: str = "gemini"
|
|
self._current_model: str = "gemini-2.5-flash-lite"
|
|
self.temperature: float = 0.0
|
|
self.max_tokens: int = 8192
|
|
self.history_trunc_limit: int = 8000
|
|
|
|
# UI-related state moved to controller
|
|
self.ui_ai_input: str = ""
|
|
self.ui_disc_new_name_input: str = ""
|
|
self.ui_disc_new_role_input: str = ""
|
|
self.ui_epic_input: str = ""
|
|
self.ui_new_track_name: str = ""
|
|
self.ui_new_track_desc: str = ""
|
|
self.ui_new_track_type: str = "feature"
|
|
self.ui_conductor_setup_summary: str = ""
|
|
self.ui_last_script_text: str = ""
|
|
self.ui_last_script_output: str = ""
|
|
self.ui_new_ticket_id: str = ""
|
|
self.ui_new_ticket_desc: str = ""
|
|
self.ui_new_ticket_target: str = ""
|
|
self.ui_new_ticket_deps: str = ""
|
|
|
|
self.ui_output_dir: str = ""
|
|
self.ui_files_base_dir: str = ""
|
|
self.ui_shots_base_dir: str = ""
|
|
self.ui_project_git_dir: str = ""
|
|
self.ui_project_main_context: str = ""
|
|
self.ui_project_system_prompt: str = ""
|
|
self.ui_gemini_cli_path: str = "gemini"
|
|
self.ui_word_wrap: bool = True
|
|
self.ui_summary_only: bool = False
|
|
self.ui_auto_add_history: bool = False
|
|
self.ui_global_system_prompt: str = ""
|
|
self.ui_agent_tools: Dict[str, bool] = {}
|
|
|
|
self.available_models: List[str] = []
|
|
self.proposed_tracks: List[Dict[str, Any]] = []
|
|
self._show_track_proposal_modal: bool = False
|
|
self.ai_status: str = 'idle'
|
|
self.ai_response: str = ''
|
|
self.last_md: str = ''
|
|
self.last_md_path: Optional[Path] = None
|
|
self.last_file_items: List[Any] = []
|
|
self.send_thread: Optional[threading.Thread] = None
|
|
self.models_thread: Optional[threading.Thread] = None
|
|
self.show_windows: Dict[str, bool] = {}
|
|
self.show_script_output: bool = False
|
|
self.show_text_viewer: bool = False
|
|
self.text_viewer_title: str = ''
|
|
self.text_viewer_content: str = ''
|
|
self._pending_comms: List[Dict[str, Any]] = []
|
|
self._pending_tool_calls: List[Dict[str, Any]] = []
|
|
self._pending_history_adds: List[Dict[str, Any]] = []
|
|
self.perf_history: Dict[str, List[float]] = {'frame_time': [0.0]*100, 'fps': [0.0]*100, 'cpu': [0.0]*100, 'input_lag': [0.0]*100}
|
|
self._perf_last_update: float = 0.0
|
|
self._autosave_interval: float = 60.0
|
|
self._last_autosave: float = time.time()
|
|
|
|
# More state moved from App
|
|
self._ask_dialog_open: bool = False
|
|
self._ask_request_id: Optional[str] = None
|
|
self._ask_tool_data: Optional[Dict[str, Any]] = None
|
|
self.mma_step_mode: bool = False
|
|
self.active_tier: Optional[str] = None
|
|
self.ui_focus_agent: Optional[str] = None
|
|
self._pending_mma_approval: Optional[Dict[str, Any]] = None
|
|
self._mma_approval_open: bool = False
|
|
self._mma_approval_edit_mode: bool = False
|
|
self._mma_approval_payload: str = ""
|
|
self._pending_mma_spawn: Optional[Dict[str, Any]] = None
|
|
self._mma_spawn_open: bool = False
|
|
self._mma_spawn_edit_mode: bool = False
|
|
self._mma_spawn_prompt: str = ''
|
|
self._mma_spawn_context: str = ''
|
|
|
|
self._trigger_blink: bool = False
|
|
self._is_blinking: bool = False
|
|
self._blink_start_time: float = 0.0
|
|
self._trigger_script_blink: bool = False
|
|
self._is_script_blinking: bool = False
|
|
self._script_blink_start_time: float = 0.0
|
|
self._scroll_disc_to_bottom: bool = False
|
|
self._scroll_comms_to_bottom: bool = False
|
|
self._scroll_tool_calls_to_bottom: bool = False
|
|
self._gemini_cache_text: str = ""
|
|
self._last_stable_md: str = ''
|
|
self._token_stats: Dict[str, Any] = {}
|
|
self._token_stats_dirty: bool = False
|
|
self.ui_disc_truncate_pairs: int = 2
|
|
self.ui_auto_scroll_comms: bool = True
|
|
self.ui_auto_scroll_tool_calls: bool = True
|
|
self._show_add_ticket_form: bool = False
|
|
self._track_discussion_active: bool = False
|
|
self._tier_stream_last_len: Dict[str, int] = {}
|
|
self.is_viewing_prior_session: bool = False
|
|
self.prior_session_entries: List[Dict[str, Any]] = []
|
|
self.test_hooks_enabled: bool = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1")
|
|
self.ui_manual_approve: bool = False
|
|
|
|
def init_state(self):
|
|
"""Initializes the application state from configurations."""
|
|
self.config = load_config()
|
|
ai_cfg = self.config.get("ai", {})
|
|
self._current_provider = ai_cfg.get("provider", "gemini")
|
|
self._current_model = ai_cfg.get("model", "gemini-2.5-flash-lite")
|
|
self.temperature = ai_cfg.get("temperature", 0.0)
|
|
self.max_tokens = ai_cfg.get("max_tokens", 8192)
|
|
self.history_trunc_limit = ai_cfg.get("history_trunc_limit", 8000)
|
|
|
|
projects_cfg = self.config.get("projects", {})
|
|
self.project_paths = list(projects_cfg.get("paths", []))
|
|
self.active_project_path = projects_cfg.get("active", "")
|
|
|
|
self._load_active_project()
|
|
|
|
self.files = list(self.project.get("files", {}).get("paths", []))
|
|
self.screenshots = list(self.project.get("screenshots", {}).get("paths", []))
|
|
|
|
disc_sec = self.project.get("discussion", {})
|
|
self.disc_roles = list(disc_sec.get("roles", list(DISC_ROLES)))
|
|
self.active_discussion = disc_sec.get("active", "main")
|
|
disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
|
|
with self._disc_entries_lock:
|
|
self.disc_entries = parse_history_entries(disc_data.get("history", []), self.disc_roles)
|
|
|
|
# UI state
|
|
self.ui_output_dir = self.project.get("output", {}).get("output_dir", "./md_gen")
|
|
self.ui_files_base_dir = self.project.get("files", {}).get("base_dir", ".")
|
|
self.ui_shots_base_dir = self.project.get("screenshots", {}).get("base_dir", ".")
|
|
proj_meta = self.project.get("project", {})
|
|
self.ui_project_git_dir = proj_meta.get("git_dir", "")
|
|
self.ui_project_main_context = proj_meta.get("main_context", "")
|
|
self.ui_project_system_prompt = proj_meta.get("system_prompt", "")
|
|
self.ui_gemini_cli_path = self.project.get("gemini_cli", {}).get("binary_path", "gemini")
|
|
self.ui_word_wrap = proj_meta.get("word_wrap", True)
|
|
self.ui_summary_only = proj_meta.get("summary_only", False)
|
|
self.ui_auto_add_history = disc_sec.get("auto_add", False)
|
|
self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "")
|
|
|
|
_default_windows = {
|
|
"Context Hub": True,
|
|
"Files & Media": True,
|
|
"AI Settings": True,
|
|
"MMA Dashboard": True,
|
|
"Tier 1: Strategy": True,
|
|
"Tier 2: Tech Lead": True,
|
|
"Tier 3: Workers": True,
|
|
"Tier 4: QA": True,
|
|
"Discussion Hub": True,
|
|
"Operations Hub": True,
|
|
"Theme": True,
|
|
"Log Management": False,
|
|
"Diagnostics": False,
|
|
}
|
|
saved = self.config.get("gui", {}).get("show_windows", {})
|
|
self.show_windows = {k: saved.get(k, v) for k, v in _default_windows.items()}
|
|
|
|
agent_tools_cfg = self.project.get("agent", {}).get("tools", {})
|
|
self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES}
|
|
|
|
label = self.project.get("project", {}).get("name", "")
|
|
session_logger.open_session(label=label)
|
|
|
|
def _load_active_project(self) -> None:
|
|
"""Loads the active project configuration, with fallbacks."""
|
|
if self.active_project_path and Path(self.active_project_path).exists():
|
|
try:
|
|
self.project = project_manager.load_project(self.active_project_path)
|
|
return
|
|
except Exception as e:
|
|
print(f"Failed to load project {self.active_project_path}: {e}")
|
|
for pp in self.project_paths:
|
|
if Path(pp).exists():
|
|
try:
|
|
self.project = project_manager.load_project(pp)
|
|
self.active_project_path = pp
|
|
return
|
|
except Exception:
|
|
continue
|
|
self.project = project_manager.migrate_from_legacy_config(self.config)
|
|
name = self.project.get("project", {}).get("name", "project")
|
|
fallback_path = f"{name}.toml"
|
|
project_manager.save_project(self.project, fallback_path)
|
|
self.active_project_path = fallback_path
|
|
if fallback_path not in self.project_paths:
|
|
self.project_paths.append(fallback_path)
|
|
|
|
def start_services(self):
|
|
"""Starts background threads and async event loop."""
|
|
self._loop = asyncio.new_event_loop()
|
|
self._loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
|
|
self._loop_thread.start()
|
|
|
|
def _run_event_loop(self):
|
|
"""Internal loop runner."""
|
|
asyncio.set_event_loop(self._loop)
|
|
self._loop.run_forever()
|
|
|
|
def stop_services(self):
|
|
"""Stops background threads and async event loop."""
|
|
if self._loop:
|
|
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
if self._loop_thread:
|
|
self._loop_thread.join(timeout=2.0)
|