refactor(gui): Migrate application state to AppController

This commit is contained in:
2026-03-04 10:36:41 -05:00
parent 5cc8f76bf8
commit d0009bb23a
4 changed files with 355 additions and 211 deletions

View File

@@ -11,7 +11,7 @@ This file tracks all major tracks for the project. Each track has its own detail
1. [x] **Track: Codebase Migration to `src` & Cleanup** 1. [x] **Track: Codebase Migration to `src` & Cleanup**
*Link: [./tracks/codebase_migration_20260302/](./tracks/codebase_migration_20260302/)* *Link: [./tracks/codebase_migration_20260302/](./tracks/codebase_migration_20260302/)*
2. [ ] **Track: GUI Decoupling & Controller Architecture** 2. [~] **Track: GUI Decoupling & Controller Architecture**
*Link: [./tracks/gui_decoupling_controller_20260302/](./tracks/gui_decoupling_controller_20260302/)* *Link: [./tracks/gui_decoupling_controller_20260302/](./tracks/gui_decoupling_controller_20260302/)*
3. [ ] **Track: Hook API UI State Verification** 3. [ ] **Track: Hook API UI State Verification**

276
src/app_controller.py Normal file
View File

@@ -0,0 +1,276 @@
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)

View File

@@ -32,7 +32,8 @@ from log_registry import LogRegistry
from log_pruner import LogPruner from log_pruner import LogPruner
import conductor_tech_lead import conductor_tech_lead
import multi_agent_conductor import multi_agent_conductor
from models import Track, Ticket from models import Track, Ticket, DISC_ROLES, AGENT_TOOL_NAMES, CONFIG_PATH, load_config, parse_history_entries
from app_controller import AppController
from file_cache import ASTParser from file_cache import ASTParser
from fastapi import FastAPI, Depends, HTTPException from fastapi import FastAPI, Depends, HTTPException
@@ -40,14 +41,9 @@ from fastapi.security.api_key import APIKeyHeader
from pydantic import BaseModel from pydantic import BaseModel
from imgui_bundle import imgui, hello_imgui, immapp from imgui_bundle import imgui, hello_imgui, immapp
CONFIG_PATH: Path = Path("config.toml")
PROVIDERS: list[str] = ["gemini", "anthropic", "gemini_cli", "deepseek"] PROVIDERS: list[str] = ["gemini", "anthropic", "gemini_cli", "deepseek"]
COMMS_CLAMP_CHARS: int = 300 COMMS_CLAMP_CHARS: int = 300
def load_config() -> dict[str, Any]:
with open(CONFIG_PATH, "rb") as f:
return tomllib.load(f)
def save_config(config: dict[str, Any]) -> None: def save_config(config: dict[str, Any]) -> None:
with open(CONFIG_PATH, "wb") as f: with open(CONFIG_PATH, "wb") as f:
tomli_w.dump(config, f) tomli_w.dump(config, f)
@@ -78,17 +74,6 @@ DIR_COLORS: dict[str, imgui.ImVec4] = {"OUT": C_OUT, "IN": C_IN}
KIND_COLORS: dict[str, imgui.ImVec4] = {"request": C_REQ, "response": C_RES, "tool_call": C_TC, "tool_result": C_TR, "tool_result_send": C_TRS} KIND_COLORS: dict[str, imgui.ImVec4] = {"request": C_REQ, "response": C_RES, "tool_call": C_TC, "tool_result": C_TR, "tool_result_send": C_TRS}
HEAVY_KEYS: set[str] = {"message", "text", "script", "output", "content"} HEAVY_KEYS: set[str] = {"message", "text", "script", "output", "content"}
DISC_ROLES: list[str] = ["User", "AI", "Vendor API", "System"]
AGENT_TOOL_NAMES: list[str] = [
"run_powershell", "read_file", "list_directory", "search_files", "get_file_summary",
"web_search", "fetch_url", "py_get_skeleton", "py_get_code_outline", "get_file_slice",
"py_get_definition", "py_get_signature", "py_get_class_summary", "py_get_var_declaration",
"get_git_diff", "py_find_usages", "py_get_imports", "py_check_syntax", "py_get_hierarchy",
"py_get_docstring", "get_tree", "get_ui_performance",
# Mutating tools — disabled by default
"set_file_slice", "py_update_definition", "py_set_signature", "py_set_var_declaration",
]
def truncate_entries(entries: list[dict[str, Any]], max_pairs: int) -> list[dict[str, Any]]: def truncate_entries(entries: list[dict[str, Any]], max_pairs: int) -> list[dict[str, Any]]:
if max_pairs <= 0: if max_pairs <= 0:
return [] return []
@@ -102,14 +87,6 @@ def truncate_entries(entries: list[dict[str, Any]], max_pairs: int) -> list[dict
return entries[i:] return entries[i:]
return entries return entries
def _parse_history_entries(history: list[str], roles: list[str] | None = None) -> list[dict[str, Any]]:
known = roles if roles is not None else DISC_ROLES
entries = []
for raw in history:
entry = project_manager.str_to_entry(raw, known)
entries.append(entry)
return entries
class ConfirmDialog: class ConfirmDialog:
def __init__(self, script: str, base_dir: str) -> None: def __init__(self, script: str, base_dir: str) -> None:
self._uid = str(uuid.uuid4()) self._uid = str(uuid.uuid4())
@@ -172,170 +149,41 @@ class App:
"""The main ImGui interface orchestrator for Manual Slop.""" """The main ImGui interface orchestrator for Manual Slop."""
def __init__(self) -> None: def __init__(self) -> None:
# Initialize locks first to avoid initialization order issues # Initialize controller and delegate state
self._send_thread_lock: threading.Lock = threading.Lock() self.controller = AppController()
self._disc_entries_lock: threading.Lock = threading.Lock() self.controller.init_state()
self._pending_comms_lock: threading.Lock = threading.Lock()
self._pending_tool_calls_lock: threading.Lock = threading.Lock() # Aliases for controller-owned locks
self._pending_history_adds_lock: threading.Lock = threading.Lock() self._send_thread_lock = self.controller._send_thread_lock
self._pending_gui_tasks_lock: threading.Lock = threading.Lock() self._disc_entries_lock = self.controller._disc_entries_lock
self._pending_dialog_lock: threading.Lock = threading.Lock() self._pending_comms_lock = self.controller._pending_comms_lock
self._api_event_queue_lock: threading.Lock = threading.Lock() self._pending_tool_calls_lock = self.controller._pending_tool_calls_lock
self._pending_history_adds_lock = self.controller._pending_history_adds_lock
self._pending_gui_tasks_lock = self.controller._pending_gui_tasks_lock
self._pending_dialog_lock = self.controller._pending_dialog_lock
self._api_event_queue_lock = self.controller._api_event_queue_lock
self.config: dict[str, Any] = load_config()
self.event_queue: events.AsyncEventQueue = events.AsyncEventQueue()
self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
self._loop_thread: threading.Thread = threading.Thread(target=self._run_event_loop, daemon=True)
self._loop_thread.start()
ai_cfg = self.config.get("ai", {})
self._current_provider: str = ai_cfg.get("provider", "gemini")
self._current_model: str = ai_cfg.get("model", "gemini-2.5-flash-lite")
self.available_models: list[str] = []
self.temperature: float = ai_cfg.get("temperature", 0.0)
self.max_tokens: int = ai_cfg.get("max_tokens", 8192)
self.history_trunc_limit: int = ai_cfg.get("history_trunc_limit", 8000)
projects_cfg = self.config.get("projects", {})
self.project_paths: list[str] = list(projects_cfg.get("paths", []))
self.active_project_path: str = projects_cfg.get("active", "")
self.project: dict[str, Any] = {}
self.active_discussion: str = "main"
self._load_active_project()
self.files: list[str] = list(self.project.get("files", {}).get("paths", []))
self.screenshots: list[str] = list(self.project.get("screenshots", {}).get("paths", []))
disc_sec = self.project.get("discussion", {})
self.disc_roles: list[str] = 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: list[dict[str, Any]] = _parse_history_entries(disc_data.get("history", []), self.disc_roles)
self.ui_output_dir: str = self.project.get("output", {}).get("output_dir", "./md_gen")
self.ui_files_base_dir: str = self.project.get("files", {}).get("base_dir", ".")
self.ui_shots_base_dir: str = self.project.get("screenshots", {}).get("base_dir", ".")
proj_meta = self.project.get("project", {})
self.ui_project_git_dir: str = proj_meta.get("git_dir", "")
self.ui_project_main_context: str = proj_meta.get("main_context", "")
self.ui_project_system_prompt: str = proj_meta.get("system_prompt", "")
self.ui_gemini_cli_path: str = self.project.get("gemini_cli", {}).get("binary_path", "gemini")
self.ui_word_wrap: bool = proj_meta.get("word_wrap", True)
self.ui_summary_only: bool = proj_meta.get("summary_only", False)
self.ui_auto_add_history: bool = disc_sec.get("auto_add", False)
self.ui_global_system_prompt: str = self.config.get("ai", {}).get("system_prompt", "")
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.proposed_tracks: list[dict[str, Any]] = []
self._show_track_proposal_modal: bool = False
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.ai_status: str = "idle"
self.ai_response: str = ""
self.last_md: str = ""
self.last_md_path: Path | None = None
self.last_file_items: list[Any] = []
self.send_thread: threading.Thread | None = None
self.models_thread: threading.Thread | None = None
_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: dict[str, bool] = {k: saved.get(k, v) for k, v in _default_windows.items()}
self.show_script_output: bool = False
self.show_text_viewer: bool = False
self.text_viewer_title: str = ""
self.text_viewer_content: str = ""
self._pending_dialog: ConfirmDialog | None = None self._pending_dialog: ConfirmDialog | None = None
self._pending_dialog_open: bool = False self._pending_dialog_open: bool = False
self._pending_actions: dict[str, ConfirmDialog] = {} self._pending_actions: dict[str, ConfirmDialog] = {}
self._pending_ask_dialog: bool = False self._pending_ask_dialog: bool = False
self._ask_dialog_open: bool = False
self._ask_request_id: str | None = None
self._ask_tool_data: dict[str, Any] | None = None
self.mma_step_mode: bool = False
self.active_track: Track | None = None
self.active_tickets: list[dict[str, Any]] = []
self.active_tier: str | None = None
self.ui_focus_agent: str | None = None
self.mma_status: str = "idle"
self._pending_mma_approval: dict[str, Any] | None = None
self._mma_approval_open: bool = False
self._mma_approval_edit_mode: bool = False
self._mma_approval_payload: str = ""
self._pending_mma_spawn: dict[str, Any] | None = 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.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._tool_log: list[dict[str, Any]] = []
self._comms_log: list[dict[str, Any]] = []
self._pending_comms: list[dict[str, Any]] = []
self._pending_tool_calls: list[dict[str, Any]] = []
self._pending_history_adds: list[dict[str, Any]] = []
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._pending_gui_tasks: 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._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
agent_tools_cfg = self.project.get("agent", {}).get("tools", {})
self.ui_agent_tools: dict[str, bool] = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES}
self.tracks: list[dict[str, Any]] = []
self._show_add_ticket_form: bool = False
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._track_discussion_active: bool = False
self.mma_streams: dict[str, str] = {}
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
self.perf_monitor: PerformanceMonitor = PerformanceMonitor()
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()
label = self.project.get("project", {}).get("name", "")
session_logger.open_session(label=label)
self._prune_old_logs() self._prune_old_logs()
self._init_ai_and_hooks() self._init_ai_and_hooks()
def __getattr__(self, name: str) -> Any:
if name != 'controller' and hasattr(self, 'controller') and hasattr(self.controller, name):
return getattr(self.controller, name)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
def __setattr__(self, name: str, value: Any) -> None:
if name == 'controller':
super().__setattr__(name, value)
elif hasattr(self, 'controller') and hasattr(self.controller, name):
setattr(self.controller, name, value)
else:
super().__setattr__(name, value)
def _prune_old_logs(self) -> None: def _prune_old_logs(self) -> None:
"""Asynchronously prunes old insignificant logs on startup.""" """Asynchronously prunes old insignificant logs on startup."""
@@ -642,29 +490,6 @@ class App:
self._create_discussion(nm) self._create_discussion(nm)
self.ui_disc_new_name_input = "" self.ui_disc_new_name_input = ""
def _load_active_project(self) -> None:
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 _switch_project(self, path: str) -> None: def _switch_project(self, path: str) -> None:
if not Path(path).exists(): if not Path(path).exists():
self.ai_status = f"project file not found: {path}" self.ai_status = f"project file not found: {path}"
@@ -690,7 +515,7 @@ class App:
self.active_discussion = disc_sec.get("active", "main") self.active_discussion = disc_sec.get("active", "main")
disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
with self._disc_entries_lock: with self._disc_entries_lock:
self.disc_entries = _parse_history_entries(disc_data.get("history", []), self.disc_roles) self.disc_entries = parse_history_entries(disc_data.get("history", []), self.disc_roles)
proj = self.project proj = self.project
self.ui_output_dir = proj.get("output", {}).get("output_dir", "./md_gen") self.ui_output_dir = proj.get("output", {}).get("output_dir", "./md_gen")
self.ui_files_base_dir = proj.get("files", {}).get("base_dir", ".") self.ui_files_base_dir = proj.get("files", {}).get("base_dir", ".")
@@ -734,7 +559,7 @@ class App:
track_history = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir) track_history = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir)
if track_history: if track_history:
with self._disc_entries_lock: with self._disc_entries_lock:
self.disc_entries = _parse_history_entries(track_history, self.disc_roles) self.disc_entries = parse_history_entries(track_history, self.disc_roles)
def _cb_load_track(self, track_id: str) -> None: def _cb_load_track(self, track_id: str) -> None:
state = project_manager.load_track_state(track_id, self.ui_files_base_dir) state = project_manager.load_track_state(track_id, self.ui_files_base_dir)
@@ -759,7 +584,7 @@ class App:
history = project_manager.load_track_history(track_id, self.ui_files_base_dir) history = project_manager.load_track_history(track_id, self.ui_files_base_dir)
with self._disc_entries_lock: with self._disc_entries_lock:
if history: if history:
self.disc_entries = _parse_history_entries(history, self.disc_roles) self.disc_entries = parse_history_entries(history, self.disc_roles)
else: else:
self.disc_entries = [] self.disc_entries = []
self._recalculate_session_usage() self._recalculate_session_usage()
@@ -803,7 +628,7 @@ class App:
self._discussion_names_dirty = True self._discussion_names_dirty = True
disc_data = discussions[name] disc_data = discussions[name]
with self._disc_entries_lock: with self._disc_entries_lock:
self.disc_entries = _parse_history_entries(disc_data.get("history", []), self.disc_roles) self.disc_entries = parse_history_entries(disc_data.get("history", []), self.disc_roles)
self.ai_status = f"discussion: {name}" self.ai_status = f"discussion: {name}"
def _flush_disc_entries_to_project(self) -> None: def _flush_disc_entries_to_project(self) -> None:
@@ -2568,7 +2393,7 @@ class App:
self._flush_disc_entries_to_project() self._flush_disc_entries_to_project()
history_strings = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir) history_strings = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir)
with self._disc_entries_lock: with self._disc_entries_lock:
self.disc_entries = _parse_history_entries(history_strings, self.disc_roles) self.disc_entries = parse_history_entries(history_strings, self.disc_roles)
self.ai_status = f"track discussion: {self.active_track.id}" self.ai_status = f"track discussion: {self.active_track.id}"
else: else:
self._flush_disc_entries_to_project() self._flush_disc_entries_to_project()
@@ -3617,6 +3442,22 @@ class App:
def run(self) -> None: def run(self) -> None:
"""Initializes the ImGui runner and starts the main application loop.""" """Initializes the ImGui runner and starts the main application loop."""
self.controller.start_services()
self._loop = self.controller._loop
self._loop_thread = self.controller._loop_thread
if self._loop:
self._loop.call_soon_threadsafe(lambda: self._loop.create_task(self._process_event_queue()))
async def queue_fallback() -> None:
while True:
try:
self._process_pending_gui_tasks()
self._process_pending_history_adds()
except: pass
await asyncio.sleep(0.1)
self._loop.call_soon_threadsafe(lambda: self._loop.create_task(queue_fallback()))
if "--headless" in sys.argv: if "--headless" in sys.argv:
print("Headless mode active") print("Headless mode active")
self._fetch_models(self.current_provider) self._fetch_models(self.current_provider)

View File

@@ -1,6 +1,33 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from datetime import datetime from datetime import datetime
from pathlib import Path
import tomllib
from src import project_manager
CONFIG_PATH: Path = Path('config.toml')
DISC_ROLES: list[str] = ['User', 'AI', 'Vendor API', 'System']
AGENT_TOOL_NAMES: list[str] = [
"run_powershell", "read_file", "list_directory", "search_files", "get_file_summary",
"web_search", "fetch_url", "py_get_skeleton", "py_get_code_outline", "get_file_slice",
"py_get_definition", "py_get_signature", "py_get_class_summary", "py_get_var_declaration",
"get_git_diff", "py_find_usages", "py_get_imports", "py_check_syntax", "py_get_hierarchy",
"py_get_docstring", "get_tree", "get_ui_performance",
# Mutating tools — disabled by default
"set_file_slice", "py_update_definition", "py_set_signature", "py_set_var_declaration",
]
def load_config() -> dict[str, Any]:
with open(CONFIG_PATH, "rb") as f:
return tomllib.load(f)
def parse_history_entries(history: list[str], roles: list[str] | None = None) -> list[dict[str, Any]]:
known = roles if roles is not None else DISC_ROLES
entries = []
for raw in history:
entry = project_manager.str_to_entry(raw, known)
entries.append(entry)
return entries
@dataclass @dataclass
class Ticket: class Ticket: