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)