refactor(gui): Migrate application state to AppController
This commit is contained in:
@@ -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
276
src/app_controller.py
Normal 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)
|
||||||
259
src/gui_2.py
259
src/gui_2.py
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user