diff --git a/conductor/tracks.md b/conductor/tracks.md index 55eda18..d1c778f 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -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** *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/)* 3. [ ] **Track: Hook API UI State Verification** diff --git a/src/app_controller.py b/src/app_controller.py new file mode 100644 index 0000000..932a01f --- /dev/null +++ b/src/app_controller.py @@ -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) diff --git a/src/gui_2.py b/src/gui_2.py index 68f51d4..522f33e 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -32,7 +32,8 @@ from log_registry import LogRegistry from log_pruner import LogPruner import conductor_tech_lead 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 fastapi import FastAPI, Depends, HTTPException @@ -40,14 +41,9 @@ from fastapi.security.api_key import APIKeyHeader from pydantic import BaseModel from imgui_bundle import imgui, hello_imgui, immapp -CONFIG_PATH: Path = Path("config.toml") PROVIDERS: list[str] = ["gemini", "anthropic", "gemini_cli", "deepseek"] 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: with open(CONFIG_PATH, "wb") as 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} 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]]: if max_pairs <= 0: return [] @@ -102,14 +87,6 @@ def truncate_entries(entries: list[dict[str, Any]], max_pairs: int) -> list[dict return entries[i:] 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: def __init__(self, script: str, base_dir: str) -> None: self._uid = str(uuid.uuid4()) @@ -172,170 +149,41 @@ class App: """The main ImGui interface orchestrator for Manual Slop.""" def __init__(self) -> None: - # 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() + # Initialize controller and delegate state + self.controller = AppController() + self.controller.init_state() + + # Aliases for controller-owned locks + self._send_thread_lock = self.controller._send_thread_lock + self._disc_entries_lock = self.controller._disc_entries_lock + self._pending_comms_lock = self.controller._pending_comms_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_open: bool = False self._pending_actions: dict[str, ConfirmDialog] = {} 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._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: """Asynchronously prunes old insignificant logs on startup.""" @@ -638,33 +486,10 @@ class App: def _cb_disc_create(self) -> None: nm = self.ui_disc_new_name_input.strip() - if nm: + if nm: self._create_discussion(nm) 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: if not Path(path).exists(): self.ai_status = f"project file not found: {path}" @@ -690,7 +515,7 @@ class App: 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) + self.disc_entries = parse_history_entries(disc_data.get("history", []), self.disc_roles) proj = self.project self.ui_output_dir = proj.get("output", {}).get("output_dir", "./md_gen") 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) if track_history: 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: 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) with self._disc_entries_lock: if history: - self.disc_entries = _parse_history_entries(history, self.disc_roles) + self.disc_entries = parse_history_entries(history, self.disc_roles) else: self.disc_entries = [] self._recalculate_session_usage() @@ -803,7 +628,7 @@ class App: self._discussion_names_dirty = True disc_data = discussions[name] 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}" def _flush_disc_entries_to_project(self) -> None: @@ -2568,7 +2393,7 @@ class App: self._flush_disc_entries_to_project() history_strings = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir) 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}" else: self._flush_disc_entries_to_project() @@ -3617,6 +3442,22 @@ class App: def run(self) -> None: """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: print("Headless mode active") self._fetch_models(self.current_provider) diff --git a/src/models.py b/src/models.py index d9638f0..7240e05 100644 --- a/src/models.py +++ b/src/models.py @@ -1,6 +1,33 @@ from dataclasses import dataclass, field from typing import List, Optional, Dict, Any 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 class Ticket: