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**
*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**

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
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."""
@@ -642,29 +490,6 @@ class App:
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)

View File

@@ -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: