From cbb1c1ed79f84857ca51542bc17bcb6aa4483c5c Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sun, 7 Jun 2026 02:03:19 -0400 Subject: [PATCH] first pass on cleaning up app controller --- src/app_controller.py | 1030 ++++++++++++++++++++--------------------- 1 file changed, 498 insertions(+), 532 deletions(-) diff --git a/src/app_controller.py b/src/app_controller.py index d2a5080f..7a455b8f 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -89,57 +89,6 @@ class ConfirmDialog: self._condition.wait(timeout=0.1) return self._approved, self._script -class MMAApprovalDialog: - def __init__(self, ticket_id: str, payload: str) -> None: - """ - [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] - """ - self._payload = payload - self._condition = threading.Condition() - self._done = False - self._approved = False - - def wait(self) -> tuple[bool, str]: - """ - [C: src/mcp_client.py:StdioMCPServer.stop, src/multi_agent_conductor.py:confirm_execution, src/multi_agent_conductor.py:confirm_spawn, tests/conftest.py:live_gui, tests/test_ai_client_concurrency.py:run_t1, tests/test_ai_client_concurrency.py:run_t2, tests/test_ai_server.py:test_server_handles_list_models, tests/test_ai_server.py:test_server_handles_unknown_method, tests/test_ai_server.py:test_server_loads_google_genai_quickly, tests/test_ai_server.py:test_server_outputs_ready_marker, tests/test_ai_server.py:test_server_starts_and_exits_cleanly, tests/test_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] - """ - start_time = time.time() - with self._condition: - while not self._done: - if time.time() - start_time > 120: - return False, self._payload - self._condition.wait(timeout=0.1) - return self._approved, self._payload - -class MMASpawnApprovalDialog: - def __init__(self, ticket_id: str, role: str, prompt: str, context_md: str) -> None: - """ - [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] - """ - self._prompt = prompt - self._context_md = context_md - self._condition = threading.Condition() - self._done = False - self._approved = False - self._abort = False - - def wait(self) -> dict[str, Any]: - """ - [C: src/mcp_client.py:StdioMCPServer.stop, src/multi_agent_conductor.py:confirm_execution, src/multi_agent_conductor.py:confirm_spawn, tests/conftest.py:live_gui, tests/test_ai_client_concurrency.py:run_t1, tests/test_ai_client_concurrency.py:run_t2, tests/test_ai_server.py:test_server_handles_list_models, tests/test_ai_server.py:test_server_handles_unknown_method, tests/test_ai_server.py:test_server_loads_google_genai_quickly, tests/test_ai_server.py:test_server_outputs_ready_marker, tests/test_ai_server.py:test_server_starts_and_exits_cleanly, tests/test_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] - """ - start_time = time.time() - with self._condition: - while not self._done: - if time.time() - start_time > 120: - return {'approved': False, 'abort': True, 'prompt': self._prompt, 'context_md': self._context_md} - self._condition.wait(timeout=0.1) - return { - 'approved': self._approved, - 'abort': self._abort, - 'prompt': self._prompt, - 'context_md': self._context_md - } - #region: API Handlers async def _api_get_key(controller: 'AppController', header_key: str) -> str: """ @@ -2141,7 +2090,6 @@ class AppController: self._trigger_gui_refresh() self.ai_status = f"viewing prior session: {session_dir.name} ({len(entries)} entries)" - def cb_exit_prior_session(self): """ [C: src/gui_2.py:App._render_comms_history_panel, src/gui_2.py:App._render_prior_session_view] @@ -2222,6 +2170,21 @@ class AppController: self._refresh_from_project() self._configure_mcp_for_project() + def inject_context(self, data: dict) -> None: + """ + Programmatic context injection. + [C: tests/test_headless_simulation.py:test_mma_track_lifecycle_simulation] + """ + file_path = data.get("file_path") + if file_path: + if not os.path.isabs(file_path): + file_path = os.path.relpath(file_path, self.active_project_root) + existing = next((f for f in self.files if (f.path if hasattr(f, "path") else str(f)) == file_path), None) + if not existing: + item = models.FileItem(path=file_path) + self.files.append(item) + self._refresh_from_project() + def _prune_old_logs(self) -> None: """Asynchronously prunes old insignificant logs on startup.""" @@ -2238,7 +2201,7 @@ class AppController: def _fetch_models(self, provider: str) -> None: """ - [C: src/gui_2.py:App.run] + [C: src/gui_2.py:App.run] """ # In the desktop GUI, model listing imports the provider SDKs (the same # ~2s C-extension load warmup pays for). Defer it until the first frame is @@ -2271,10 +2234,8 @@ class AppController: def start_services(self, app: Any = None): """ - - - Starts background threads. - [C: src/gui_2.py:App.__init__] + Starts background threads. + [C: src/gui_2.py:App.__init__] """ self._prune_old_logs() self._init_ai_and_hooks(app) @@ -2283,9 +2244,8 @@ class AppController: def _compute_warmup_list(self) -> list[str]: """ - - Returns the list of modules to warm on the _io_pool at startup. - [SDM: src/app_controller.py:_compute_warmup_list] + Returns the list of modules to warm on the _io_pool at startup. + [SDM: src/app_controller.py:_compute_warmup_list] """ modules: list[str] = [ "google.genai", @@ -3006,17 +2966,6 @@ class AppController: file_items_as_dicts = [{"path": f.path if hasattr(f, "path") else str(f)} for f in self.files] mcp_client.configure(file_items_as_dicts, [str(project_root)]) - def is_project_stale(self) -> bool: - """True when a project switch is queued or running; UI should tint - to signal the controller state lags the user's last click.""" - with self._project_switch_lock: - if self._project_switch_in_progress: - return True - pending = self._project_switch_pending_path - if pending and pending != self.active_project_path: - return True - return False - def _switch_project(self, path: str) -> None: """ [C: src/gui_2.py:App._render_projects_panel] @@ -3074,6 +3023,250 @@ class AppController: if pending and pending != self.active_project_path and Path(pending).exists(): self._switch_project(pending) + def save_context_preset(self, preset: models.ContextPreset) -> None: + self.context_preset_manager.save_preset(self.project, preset) + self._save_active_project() + + def load_context_preset(self, name: str) -> models.ContextPreset: + presets = self.context_preset_manager.load_all(self.project) + if name not in presets: + raise KeyError(f"Context preset '{name}' not found.") + preset = presets[name] + + # Update only temporary context state, not project files + import copy + self.context_files = [] + for f in preset.files: + fi = models.FileItem(path=f.path, view_mode=f.view_mode) + fi.custom_slices = copy.deepcopy(f.custom_slices) if hasattr(f, 'custom_slices') else [] + fi.ast_mask = copy.deepcopy(f.ast_mask) if hasattr(f, 'ast_mask') else {} + fi.ast_signatures = getattr(f, 'ast_signatures', False) + fi.ast_definitions = getattr(f, 'ast_definitions', False) + self.context_files.append(fi) + + self.screenshots = list(preset.screenshots) + return preset + + def clear_last_error(self) -> None: + """Reset last_error after a successful response cycle. + [C: src/vendor_state.py:get_vendor_state] + """ + self.last_error = None + + #region: Layout + + def _cb_save_workspace_profile(self, name: str, scope: str = 'project') -> None: + """ + [C: src/gui_2.py:App._render_save_workspace_profile_modal] + """ + if not hasattr(self, '_app') or not self._app: + return + profile = self._app._capture_workspace_profile(name) + self.workspace_manager.save_profile(profile, scope=scope) + self.workspace_profiles = self.workspace_manager.load_all_profiles() + self._app.workspace_profiles = self.workspace_profiles + + def _cb_delete_workspace_profile(self, name: str, scope: str = 'project') -> None: + """ + [C: src/gui_2.py:App._show_menus] + """ + self.workspace_manager.delete_profile(name, scope=scope) + self.workspace_profiles = self.workspace_manager.load_all_profiles() + if hasattr(self, '_app') and self._app: + self._app.workspace_profiles = self.workspace_profiles + + def _cb_load_workspace_profile(self, name: str) -> None: + """ + [C: src/gui_2.py:App._show_menus] + """ + if name in self.workspace_profiles: + profile = self.workspace_profiles[name] + if hasattr(self, '_app') and self._app: + self._app._apply_workspace_profile(profile) + + #endregion: Layout + + #region: Serialization + + def _flush_to_project(self) -> None: + """ + [C: src/gui_2.py:App._render_discussion_entry_controls, src/gui_2.py:App._render_main_interface, src/gui_2.py:App._render_projects_panel, src/gui_2.py:App._show_menus, tests/test_view_presets.py:test_save_view_preset] + """ + proj = self.project + proj.setdefault("output", {})["output_dir"] = self.ui_output_dir + proj.setdefault("files", {})["base_dir"] = self.ui_files_base_dir + proj["files"]["paths"] = self.files + proj.setdefault("screenshots", {})["base_dir"] = self.ui_shots_base_dir + proj["screenshots"]["paths"] = self.screenshots + proj.setdefault("project", {}) + proj["project"]["git_dir"] = self.ui_project_git_dir + proj.setdefault("conductor", {})["dir"] = self.ui_project_conductor_dir + proj["project"]["system_prompt"] = self.ui_project_system_prompt + proj["project"]["active_preset"] = self.ui_project_preset_name + proj["project"]["word_wrap"] = self.ui_word_wrap + proj["project"]["auto_scroll_comms"] = self.ui_auto_scroll_comms + proj["project"]["auto_scroll_tool_calls"] = self.ui_auto_scroll_tool_calls + proj.setdefault("gemini_cli", {})["binary_path"] = self.ui_gemini_cli_path + proj.setdefault("agent", {}).setdefault("tools", {}) + for t_name in models.AGENT_TOOL_NAMES: + proj["agent"]["tools"][t_name] = self.ui_agent_tools.get(t_name, True) + self._flush_disc_entries_to_project() + disc_sec = proj.setdefault("discussion", {}) + disc_sec["roles"] = self.disc_roles + disc_sec["active"] = self.active_discussion + disc_sec["auto_add"] = self.ui_auto_add_history + proj["view_presets"] = [vp.to_dict() for vp in self.view_presets] + # Save MMA State + mma_sec = proj.setdefault("mma", {}) + mma_sec["epic"] = self.ui_epic_input + mma_sec["tier_models"] = {t: {"model": d["model"], "provider": d.get("provider", "gemini"), "tool_preset": d.get("tool_preset")} for t, d in self.mma_tier_usage.items()} + if self.active_track: + mma_sec["active_track"] = asdict(self.active_track) + else: + mma_sec["active_track"] = None + + cleaned_proj = project_manager.clean_nones(proj) + project_manager.save_project(cleaned_proj, self.active_project_path) + + def _flush_to_config(self) -> None: + """ + [C: src/gui_2.py:App._render_discussion_entry_controls, src/gui_2.py:App._render_main_interface, src/gui_2.py:App._render_projects_panel, src/gui_2.py:App._render_theme_panel, src/gui_2.py:App._show_menus, tests/test_system_prompt_exposure.py:TestSystemPromptExposure.test_app_controller_flush_saves_prompts] + """ + self.config["ai"] = { + "provider": self.current_provider, + "model": self.current_model, + "temperature": self.temperature, + "top_p": self.top_p, + "max_tokens": self.max_tokens, + "history_trunc_limit": self.history_trunc_limit, + "active_preset": self.ui_global_preset_name, + } + self.config["ai"]["system_prompt"] = self.ui_global_system_prompt + self.config["ai"]["base_system_prompt"] = self.ui_base_system_prompt + self.config["ai"]["use_default_base_prompt"] = self.ui_use_default_base_prompt + + if self.rag_config: + self.config["rag"] = self.rag_config.to_dict() + + self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path} + from src import bg_shader + # Update gui section while preserving other keys like bg_shader_enabled + gui_cfg = self.config.get("gui", {}) + gui_cfg.update({ + "show_windows": self.show_windows, + "separate_message_panel": getattr(self, "ui_separate_message_panel", False), + "separate_response_panel": getattr(self, "ui_separate_response_panel", False), + "separate_tool_calls_panel": getattr(self, "ui_separate_tool_calls_panel", False), + "separate_external_tools": getattr(self, "ui_separate_external_tools", False), + "separate_task_dag": self.ui_separate_task_dag, + "separate_usage_analytics": self.ui_separate_usage_analytics, + "separate_tier1": self.ui_separate_tier1, + "separate_tier2": self.ui_separate_tier2, + "separate_tier3": self.ui_separate_tier3, + "separate_tier4": self.ui_separate_tier4, + "bg_shader_enabled": bg_shader.get_bg().enabled + }) + self.config["gui"] = gui_cfg + + # Explicitly save theme state into the config dict + theme.save_to_config(self.config) + + #endregion: Serialization + + #region: Usage Analytics + + def get_session_insights(self) -> Dict[str, Any]: + """ + [C: src/gui_2.py:App._render_session_insights_panel] + """ + from src import cost_tracker + total_input = sum(e["input"] for e in self._token_history) + total_output = sum(e["output"] for e in self._token_history) + total_tokens = total_input + total_output + elapsed_min = (time.time() - self._session_start_time) / 60.0 if self._token_history else 0 + burn_rate = total_tokens / elapsed_min if elapsed_min > 0 else 0 + session_cost = cost_tracker.estimate_cost("gemini-2.5-flash", total_input, total_output) + completed = sum(1 for t in self.active_tickets if t.get("status") == "complete") + efficiency = total_tokens / completed if completed > 0 else 0 + return { + "total_tokens": total_tokens, + "total_input": total_input, + "total_output": total_output, + "elapsed_min": elapsed_min, + "burn_rate": burn_rate, + "session_cost": session_cost, + "completed_tickets": completed, + "efficiency": efficiency, + "call_count": len(self._token_history) + } + + def _refresh_api_metrics(self, payload: dict[str, Any], md_content: str | None = None) -> None: + """ + [C: tests/test_gui_updates.py:test_telemetry_data_updates_correctly] + """ + if "latency" in payload: + self.session_usage["last_latency"] = payload["latency"] + if "usage" in payload and "percentage" in payload["usage"]: + self.session_usage["percentage"] = payload["usage"]["percentage"] + self._recalculate_session_usage() + if md_content is not None: + stats = ai_client.get_token_stats(md_content) + # Ensure compatibility if keys are named differently + if "total_tokens" in stats and "estimated_prompt_tokens" not in stats: + stats["estimated_prompt_tokens"] = stats["total_tokens"] + self._token_stats = stats + cache_stats = payload.get("cache_stats") + if cache_stats: + count = cache_stats.get("cache_count", 0) + size_bytes = cache_stats.get("total_size_bytes", 0) + self._gemini_cache_text = f"Gemini Caches: {count} ({size_bytes / 1024:.1f} KB)" + quota = payload.get("vendor_quota") + if isinstance(quota, dict) and quota: + self.vendor_quota = quota + if "error" in payload and isinstance(payload["error"], dict): + self.last_error = payload["error"] + self._update_cached_stats() + + def set_vendor_quota(self, provider: str, remaining_pct: float, reset_at: str = "") -> None: + """Update vendor quota state from a quota-bearing API response. + [C: src/vendor_state.py:get_vendor_state] + """ + self.vendor_quota = {"provider": provider, "remaining_pct": remaining_pct, "reset_at": reset_at} + + def _recalculate_session_usage(self) -> None: + usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "total_tokens": 0, "last_latency": 0.0, "percentage": self.session_usage.get("percentage", 0.0)} + for entry in ai_client.get_comms_log(): + if entry.get("kind") == "response" and "usage" in entry.get("payload", {}): + u = entry["payload"]["usage"] + for k in ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "total_tokens"]: + if k in usage: + usage[k] += u.get(k, 0) or 0 + self.session_usage = usage + # Update cached files list + stats = ai_client.get_gemini_cache_stats() + self._cached_files = stats.get("cached_files", []) + + #endregion: Usage Analytics + + #region: Context + + def _update_cached_stats(self) -> None: + from src import ai_client + self._cached_cache_stats = ai_client.get_gemini_cache_stats() + self._cached_tool_stats = dict(self._tool_stats) + + def clear_cache(self) -> None: + """ + [C: src/gui_2.py:App._render_cache_panel] + """ + from src import ai_client + ai_client.cleanup() + self._update_cached_stats() + + #endregion: Context + +#region: Project + def _refresh_from_project(self) -> None: # Deserialize FileItems in files.paths """ @@ -3179,34 +3372,31 @@ class AppController: if self.rag_config and self.rag_config.enabled: self._rebuild_rag_index() - def _cb_save_workspace_profile(self, name: str, scope: str = 'project') -> None: - """ - [C: src/gui_2.py:App._render_save_workspace_profile_modal] - """ - if not hasattr(self, '_app') or not self._app: - return - profile = self._app._capture_workspace_profile(name) - self.workspace_manager.save_profile(profile, scope=scope) - self.workspace_profiles = self.workspace_manager.load_all_profiles() - self._app.workspace_profiles = self.workspace_profiles + def is_project_stale(self) -> bool: + """True when a project switch is queued or running; UI should tint + to signal the controller state lags the user's last click.""" + with self._project_switch_lock: + if self._project_switch_in_progress: + return True + pending = self._project_switch_pending_path + if pending and pending != self.active_project_path: + return True + return False - def _cb_delete_workspace_profile(self, name: str, scope: str = 'project') -> None: + def _save_active_project(self) -> None: """ - [C: src/gui_2.py:App._show_menus] + [C: src/gui_2.py:App.delete_context_preset, src/gui_2.py:App.save_context_preset] """ - self.workspace_manager.delete_profile(name, scope=scope) - self.workspace_profiles = self.workspace_manager.load_all_profiles() - if hasattr(self, '_app') and self._app: - self._app.workspace_profiles = self.workspace_profiles + if self.active_project_path: + try: + cleaned = project_manager.clean_nones(self.project) + project_manager.save_project(cleaned, self.active_project_path) + except Exception as e: + self.ai_status = f"save error: {e}" - def _cb_load_workspace_profile(self, name: str) -> None: - """ - [C: src/gui_2.py:App._show_menus] - """ - if name in self.workspace_profiles: - profile = self.workspace_profiles[name] - if hasattr(self, '_app') and self._app: - self._app._apply_workspace_profile(profile) +#endregion: Project + +#region: AI Settings def _apply_preset(self, name: str, scope: str) -> None: """ @@ -3323,73 +3513,9 @@ class AppController: self.view_presets = [vp for vp in self.view_presets if vp.name != name] self._flush_to_project() - def save_context_preset(self, preset: models.ContextPreset) -> None: - self.context_preset_manager.save_preset(self.project, preset) - self._save_active_project() +#endregion: AI Settings - def load_context_preset(self, name: str) -> models.ContextPreset: - presets = self.context_preset_manager.load_all(self.project) - if name not in presets: - raise KeyError(f"Context preset '{name}' not found.") - preset = presets[name] - - # Update only temporary context state, not project files - import copy - self.context_files = [] - for f in preset.files: - fi = models.FileItem(path=f.path, view_mode=f.view_mode) - fi.custom_slices = copy.deepcopy(f.custom_slices) if hasattr(f, 'custom_slices') else [] - fi.ast_mask = copy.deepcopy(f.ast_mask) if hasattr(f, 'ast_mask') else {} - fi.ast_signatures = getattr(f, 'ast_signatures', False) - fi.ast_definitions = getattr(f, 'ast_definitions', False) - self.context_files.append(fi) - - self.screenshots = list(preset.screenshots) - return preset - def _cb_load_track(self, track_id: str) -> None: - """ - [C: src/gui_2.py:App._render_mma_track_browser] - """ - state = project_manager.load_track_state(track_id, self.active_project_root) - if state: - try: - # Convert list[Ticket] or list[dict] to list[Ticket] for Track object - tickets = [] - for t in state.tasks: - if isinstance(t, dict): - tickets.append(models.Ticket(**t)) - else: - tickets.append(t) - self.active_track = models.Track( - id=state.metadata.id, - description=state.metadata.name, - tickets=tickets - ) - # Keep dicts for UI table - self._load_active_tickets() - # Load track-scoped history - history = project_manager.load_track_history(track_id, self.active_project_root) - with self._disc_entries_lock: - if history: - self.disc_entries[:] = models.parse_history_entries(history, self.disc_roles) - else: - self.disc_entries.clear() - self._recalculate_session_usage() - self.ai_status = f"Loaded track: {state.metadata.name}" - except Exception as e: - self.ai_status = f"Load track error: {e}" - print(f"Error loading track {track_id}: {e}") - - def _save_active_project(self) -> None: - """ - [C: src/gui_2.py:App.delete_context_preset, src/gui_2.py:App.save_context_preset] - """ - if self.active_project_path: - try: - cleaned = project_manager.clean_nones(self.project) - project_manager.save_project(cleaned, self.active_project_path) - except Exception as e: - self.ai_status = f"save error: {e}" + #region: Discusssion def _get_discussion_names(self) -> list[str]: """ @@ -3442,103 +3568,8 @@ class AppController: disc_data["sent_markdown"] = getattr(self, "discussion_sent_markdown", "") disc_data["sent_system_prompt"] = getattr(self, "discussion_sent_system_prompt", "") - def _create_discussion(self, name: str) -> None: - """ - [C: src/gui_2.py:App._render_discussion_metadata, src/gui_2.py:App._render_synthesis_panel, src/gui_2.py:App._render_takes_panel] - """ - disc_sec = self.project.setdefault("discussion", {}) - discussions = disc_sec.setdefault("discussions", {}) - if name in discussions: - self.ai_status = f"discussion '{name}' already exists" - return - new_disc = project_manager.default_discussion() - # Inherit context from current session if available - if self.context_files: - new_disc["context_snapshot"] = [f.to_dict() if hasattr(f, 'to_dict') else f for f in self.context_files] - discussions[name] = new_disc - self._switch_discussion(name) - - def _branch_discussion(self, index: int) -> None: - """ - [C: src/gui_2.py:App._render_discussion_entry] - """ - self._flush_disc_entries_to_project() - # Generate a unique branch name - base_name = self.active_discussion.split("_take_")[0] - counter = 1 - new_name = f"{base_name}_take_{counter}" - disc_sec = self.project.get("discussion", {}) - discussions = disc_sec.get("discussions", {}) - while new_name in discussions: - counter += 1 - new_name = f"{base_name}_take_{counter}" - - project_manager.branch_discussion(self.project, self.active_discussion, new_name, index) - self._switch_discussion(new_name) - def _rename_discussion(self, old_name: str, new_name: str) -> None: - """ - [C: src/gui_2.py:App._render_discussion_metadata] - """ - disc_sec = self.project.get("discussion", {}) - discussions = disc_sec.get("discussions", {}) - if old_name not in discussions: - return - if new_name in discussions: - self.ai_status = f"discussion '{new_name}' already exists" - return - discussions[new_name] = discussions.pop(old_name) - if self.active_discussion == old_name: - self.active_discussion = new_name - disc_sec["active"] = new_name - - def _delete_discussion(self, name: str) -> None: - """ - [C: src/gui_2.py:App._render_discussion_metadata] - """ - disc_sec = self.project.get("discussion", {}) - discussions = disc_sec.get("discussions", {}) - if len(discussions) <= 1: - self.ai_status = "cannot delete the last discussion" - return - if name not in discussions: - return - del discussions[name] - if self.active_discussion == name: - remaining = sorted(discussions.keys()) - self._switch_discussion(remaining[0]) - - def _handle_mma_respond(self, approved: bool, payload: str | None = None, abort: bool = False, prompt: str | None = None, context_md: str | None = None) -> None: - """ - [C: src/gui_2.py:App._handle_approve_mma_step, src/gui_2.py:App._handle_approve_spawn, src/gui_2.py:App._render_mma_modals] - """ - if self._pending_mma_approvals: - task = self._pending_mma_approvals.pop(0) - dlg = task.get("dialog_container", [None])[0] - if dlg: - with dlg._condition: - dlg._approved = approved - if payload is not None: - dlg._payload = payload - dlg._done = True - dlg._condition.notify_all() - elif self._pending_mma_spawns: - task = self._pending_mma_spawns.pop(0) - spawn_dlg = task.get("dialog_container", [None])[0] - if spawn_dlg: - with spawn_dlg._condition: - spawn_dlg._approved = approved - spawn_dlg._abort = abort - if prompt is not None: - spawn_dlg._prompt = prompt - if context_md is not None: - spawn_dlg._context_md = context_md - spawn_dlg._done = True - spawn_dlg._condition.notify_all() - def _handle_approve_ask(self) -> None: """ - - Responds with approval for a pending /api/ask request. [C: src/gui_2.py:App._handle_approve_ask, src/gui_2.py:App._render_mma_modals] """ @@ -3561,8 +3592,6 @@ class AppController: def _handle_reject_ask(self) -> None: """ - - Responds with rejection for a pending /api/ask request. [C: src/gui_2.py:App._render_mma_modals] """ @@ -3585,8 +3614,6 @@ class AppController: def _handle_reset_session(self) -> None: """ - - Logic for resetting the AI session and GUI state. [C: src/gui_2.py:App._render_message_panel] """ @@ -3639,32 +3666,6 @@ class AppController: self.top_p = 1.0 self.max_tokens = 8192 - def _handle_md_only(self) -> None: - """ - - - Logic for the 'MD Only' action. - [C: src/gui_2.py:App._render_message_panel] - """ - if self.is_project_stale(): - self.ai_status = "project switch in progress; MD generation disabled" - return - - def worker(): - """ - [C: tests/test_symbol_parsing.py:test_handle_generate_send_appends_definitions, tests/test_symbol_parsing.py:test_handle_generate_send_no_symbols] - """ - try: - md, path, *_ = self._do_generate() - self.last_md = md - self.last_md_path = path - self.ai_status = f"md written: {path.name}" - # Refresh token budget metrics with CURRENT md - self._refresh_api_metrics({}, md_content=md) - except Exception as e: - self.ai_status = f"error: {e}" - self.submit_io(worker) - def _handle_compress_discussion(self) -> None: def worker(): try: @@ -3689,8 +3690,6 @@ class AppController: def _handle_generate_send(self) -> None: """ - - Logic for the 'Gen + Send' action. [C: src/gui_2.py:App._render_message_panel, src/gui_2.py:App._render_synthesis_panel, src/gui_2.py:App._render_takes_panel, tests/test_gui_events_v2.py:test_handle_generate_send_pushes_event, tests/test_symbol_parsing.py:test_handle_generate_send_appends_definitions, tests/test_symbol_parsing.py:test_handle_generate_send_no_symbols] """ @@ -3726,185 +3725,10 @@ class AppController: self.ai_status = f"generate error: {e}" self.submit_io(worker) - def _recalculate_session_usage(self) -> None: - usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "total_tokens": 0, "last_latency": 0.0, "percentage": self.session_usage.get("percentage", 0.0)} - for entry in ai_client.get_comms_log(): - if entry.get("kind") == "response" and "usage" in entry.get("payload", {}): - u = entry["payload"]["usage"] - for k in ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "total_tokens"]: - if k in usage: - usage[k] += u.get(k, 0) or 0 - self.session_usage = usage - # Update cached files list - stats = ai_client.get_gemini_cache_stats() - self._cached_files = stats.get("cached_files", []) - - def _refresh_api_metrics(self, payload: dict[str, Any], md_content: str | None = None) -> None: - """ - [C: tests/test_gui_updates.py:test_telemetry_data_updates_correctly] - """ - if "latency" in payload: - self.session_usage["last_latency"] = payload["latency"] - if "usage" in payload and "percentage" in payload["usage"]: - self.session_usage["percentage"] = payload["usage"]["percentage"] - self._recalculate_session_usage() - if md_content is not None: - stats = ai_client.get_token_stats(md_content) - # Ensure compatibility if keys are named differently - if "total_tokens" in stats and "estimated_prompt_tokens" not in stats: - stats["estimated_prompt_tokens"] = stats["total_tokens"] - self._token_stats = stats - cache_stats = payload.get("cache_stats") - if cache_stats: - count = cache_stats.get("cache_count", 0) - size_bytes = cache_stats.get("total_size_bytes", 0) - self._gemini_cache_text = f"Gemini Caches: {count} ({size_bytes / 1024:.1f} KB)" - quota = payload.get("vendor_quota") - if isinstance(quota, dict) and quota: - self.vendor_quota = quota - if "error" in payload and isinstance(payload["error"], dict): - self.last_error = payload["error"] - self._update_cached_stats() - - def set_vendor_quota(self, provider: str, remaining_pct: float, reset_at: str = "") -> None: - """Update vendor quota state from a quota-bearing API response. - [C: src/vendor_state.py:get_vendor_state] - """ - self.vendor_quota = {"provider": provider, "remaining_pct": remaining_pct, "reset_at": reset_at} - - def clear_last_error(self) -> None: - """Reset last_error after a successful response cycle. - [C: src/vendor_state.py:get_vendor_state] - """ - self.last_error = None - - def _update_cached_stats(self) -> None: - from src import ai_client - self._cached_cache_stats = ai_client.get_gemini_cache_stats() - self._cached_tool_stats = dict(self._tool_stats) - - def clear_cache(self) -> None: - """ - [C: src/gui_2.py:App._render_cache_panel] - """ - from src import ai_client - ai_client.cleanup() - self._update_cached_stats() - - def get_session_insights(self) -> Dict[str, Any]: - """ - [C: src/gui_2.py:App._render_session_insights_panel] - """ - from src import cost_tracker - total_input = sum(e["input"] for e in self._token_history) - total_output = sum(e["output"] for e in self._token_history) - total_tokens = total_input + total_output - elapsed_min = (time.time() - self._session_start_time) / 60.0 if self._token_history else 0 - burn_rate = total_tokens / elapsed_min if elapsed_min > 0 else 0 - session_cost = cost_tracker.estimate_cost("gemini-2.5-flash", total_input, total_output) - completed = sum(1 for t in self.active_tickets if t.get("status") == "complete") - efficiency = total_tokens / completed if completed > 0 else 0 - return { - "total_tokens": total_tokens, - "total_input": total_input, - "total_output": total_output, - "elapsed_min": elapsed_min, - "burn_rate": burn_rate, - "session_cost": session_cost, - "completed_tickets": completed, - "efficiency": efficiency, - "call_count": len(self._token_history) - } - - def _flush_to_project(self) -> None: - """ - [C: src/gui_2.py:App._render_discussion_entry_controls, src/gui_2.py:App._render_main_interface, src/gui_2.py:App._render_projects_panel, src/gui_2.py:App._show_menus, tests/test_view_presets.py:test_save_view_preset] - """ - proj = self.project - proj.setdefault("output", {})["output_dir"] = self.ui_output_dir - proj.setdefault("files", {})["base_dir"] = self.ui_files_base_dir - proj["files"]["paths"] = self.files - proj.setdefault("screenshots", {})["base_dir"] = self.ui_shots_base_dir - proj["screenshots"]["paths"] = self.screenshots - proj.setdefault("project", {}) - proj["project"]["git_dir"] = self.ui_project_git_dir - proj.setdefault("conductor", {})["dir"] = self.ui_project_conductor_dir - proj["project"]["system_prompt"] = self.ui_project_system_prompt - proj["project"]["active_preset"] = self.ui_project_preset_name - proj["project"]["word_wrap"] = self.ui_word_wrap - proj["project"]["auto_scroll_comms"] = self.ui_auto_scroll_comms - proj["project"]["auto_scroll_tool_calls"] = self.ui_auto_scroll_tool_calls - proj.setdefault("gemini_cli", {})["binary_path"] = self.ui_gemini_cli_path - proj.setdefault("agent", {}).setdefault("tools", {}) - for t_name in models.AGENT_TOOL_NAMES: - proj["agent"]["tools"][t_name] = self.ui_agent_tools.get(t_name, True) - self._flush_disc_entries_to_project() - disc_sec = proj.setdefault("discussion", {}) - disc_sec["roles"] = self.disc_roles - disc_sec["active"] = self.active_discussion - disc_sec["auto_add"] = self.ui_auto_add_history - proj["view_presets"] = [vp.to_dict() for vp in self.view_presets] - # Save MMA State - mma_sec = proj.setdefault("mma", {}) - mma_sec["epic"] = self.ui_epic_input - mma_sec["tier_models"] = {t: {"model": d["model"], "provider": d.get("provider", "gemini"), "tool_preset": d.get("tool_preset")} for t, d in self.mma_tier_usage.items()} - if self.active_track: - mma_sec["active_track"] = asdict(self.active_track) - else: - mma_sec["active_track"] = None - - cleaned_proj = project_manager.clean_nones(proj) - project_manager.save_project(cleaned_proj, self.active_project_path) - - def _flush_to_config(self) -> None: - """ - [C: src/gui_2.py:App._render_discussion_entry_controls, src/gui_2.py:App._render_main_interface, src/gui_2.py:App._render_projects_panel, src/gui_2.py:App._render_theme_panel, src/gui_2.py:App._show_menus, tests/test_system_prompt_exposure.py:TestSystemPromptExposure.test_app_controller_flush_saves_prompts] - """ - self.config["ai"] = { - "provider": self.current_provider, - "model": self.current_model, - "temperature": self.temperature, - "top_p": self.top_p, - "max_tokens": self.max_tokens, - "history_trunc_limit": self.history_trunc_limit, - "active_preset": self.ui_global_preset_name, - } - self.config["ai"]["system_prompt"] = self.ui_global_system_prompt - self.config["ai"]["base_system_prompt"] = self.ui_base_system_prompt - self.config["ai"]["use_default_base_prompt"] = self.ui_use_default_base_prompt - - if self.rag_config: - self.config["rag"] = self.rag_config.to_dict() - - self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path} - from src import bg_shader - # Update gui section while preserving other keys like bg_shader_enabled - gui_cfg = self.config.get("gui", {}) - gui_cfg.update({ - "show_windows": self.show_windows, - "separate_message_panel": getattr(self, "ui_separate_message_panel", False), - "separate_response_panel": getattr(self, "ui_separate_response_panel", False), - "separate_tool_calls_panel": getattr(self, "ui_separate_tool_calls_panel", False), - "separate_external_tools": getattr(self, "ui_separate_external_tools", False), - "separate_task_dag": self.ui_separate_task_dag, - "separate_usage_analytics": self.ui_separate_usage_analytics, - "separate_tier1": self.ui_separate_tier1, - "separate_tier2": self.ui_separate_tier2, - "separate_tier3": self.ui_separate_tier3, - "separate_tier4": self.ui_separate_tier4, - "bg_shader_enabled": bg_shader.get_bg().enabled - }) - self.config["gui"] = gui_cfg - - # Explicitly save theme state into the config dict - theme.save_to_config(self.config) - def _do_generate(self) -> tuple[str, Path, list[dict[str, Any]], str, str]: """ - - - Returns (full_md, output_path, file_items, stable_md, discussion_text). - [C: src/gui_2.py:App._show_menus, tests/test_context_composition_decoupled.py:test_do_generate_uses_context_files, tests/test_tiered_aggregation.py:test_app_controller_do_generate_uses_persona_strategy] + Returns (full_md, output_path, file_items, stable_md, discussion_text). + [C: src/gui_2.py:App._show_menus, tests/test_context_composition_decoupled.py:test_do_generate_uses_context_files, tests/test_tiered_aggregation.py:test_app_controller_do_generate_uses_persona_strategy] """ self._flush_to_project() self._flush_to_config() @@ -3948,6 +3772,100 @@ class AppController: return full_md, path, file_items, stable_md, discussion_text + def _handle_md_only(self) -> None: + """ + Logic for the 'MD Only' action. + [C: src/gui_2.py:App._render_message_panel] + """ + if self.is_project_stale(): + self.ai_status = "project switch in progress; MD generation disabled" + return + + def worker(): + """ + [C: tests/test_symbol_parsing.py:test_handle_generate_send_appends_definitions, tests/test_symbol_parsing.py:test_handle_generate_send_no_symbols] + """ + try: + md, path, *_ = self._do_generate() + self.last_md = md + self.last_md_path = path + self.ai_status = f"md written: {path.name}" + # Refresh token budget metrics with CURRENT md + self._refresh_api_metrics({}, md_content=md) + except Exception as e: + self.ai_status = f"error: {e}" + self.submit_io(worker) + + def _create_discussion(self, name: str) -> None: + """ + [C: src/gui_2.py:App._render_discussion_metadata, src/gui_2.py:App._render_synthesis_panel, src/gui_2.py:App._render_takes_panel] + """ + disc_sec = self.project.setdefault("discussion", {}) + discussions = disc_sec.setdefault("discussions", {}) + if name in discussions: + self.ai_status = f"discussion '{name}' already exists" + return + new_disc = project_manager.default_discussion() + # Inherit context from current session if available + if self.context_files: + new_disc["context_snapshot"] = [f.to_dict() if hasattr(f, 'to_dict') else f for f in self.context_files] + discussions[name] = new_disc + self._switch_discussion(name) + + def _branch_discussion(self, index: int) -> None: + """ + [C: src/gui_2.py:App._render_discussion_entry] + """ + self._flush_disc_entries_to_project() + # Generate a unique branch name + base_name = self.active_discussion.split("_take_")[0] + counter = 1 + new_name = f"{base_name}_take_{counter}" + disc_sec = self.project.get("discussion", {}) + discussions = disc_sec.get("discussions", {}) + while new_name in discussions: + counter += 1 + new_name = f"{base_name}_take_{counter}" + + project_manager.branch_discussion(self.project, self.active_discussion, new_name, index) + self._switch_discussion(new_name) + + def _rename_discussion(self, old_name: str, new_name: str) -> None: + """ + [C: src/gui_2.py:App._render_discussion_metadata] + """ + disc_sec = self.project.get("discussion", {}) + discussions = disc_sec.get("discussions", {}) + if old_name not in discussions: + return + if new_name in discussions: + self.ai_status = f"discussion '{new_name}' already exists" + return + discussions[new_name] = discussions.pop(old_name) + if self.active_discussion == old_name: + self.active_discussion = new_name + disc_sec["active"] = new_name + + def _delete_discussion(self, name: str) -> None: + """ + [C: src/gui_2.py:App._render_discussion_metadata] + """ + disc_sec = self.project.get("discussion", {}) + discussions = disc_sec.get("discussions", {}) + if len(discussions) <= 1: + self.ai_status = "cannot delete the last discussion" + return + if name not in discussions: + return + del discussions[name] + if self.active_discussion == name: + remaining = sorted(discussions.keys()) + self._switch_discussion(remaining[0]) + + #endregion: Discusssion + + #region MMA (Controller) + def _cb_plan_epic(self) -> None: """ [C: src/gui_2.py:App._render_mma_epic_planner, tests/test_mma_orchestration_gui.py:test_cb_plan_epic_launches_thread] @@ -4188,10 +4106,8 @@ class AppController: def kill_worker(self, worker_id: str) -> None: """ - - - Aborts a running worker. - [C: src/gui_2.py:App._cb_kill_ticket, tests/test_conductor_engine_abort.py:test_kill_worker_sets_abort_and_joins_thread] + Aborts a running worker. + [C: src/gui_2.py:App._cb_kill_ticket, tests/test_conductor_engine_abort.py:test_kill_worker_sets_abort_and_joins_thread] """ engine = self.engines.get(self.active_track.id if self.active_track else None) if engine: @@ -4211,23 +4127,6 @@ class AppController: if engine: engine.resume() - def inject_context(self, data: dict) -> None: - """ - - - Programmatic context injection. - [C: tests/test_headless_simulation.py:test_mma_track_lifecycle_simulation] - """ - file_path = data.get("file_path") - if file_path: - if not os.path.isabs(file_path): - file_path = os.path.relpath(file_path, self.active_project_root) - existing = next((f for f in self.files if (f.path if hasattr(f, "path") else str(f)) == file_path), None) - if not existing: - item = models.FileItem(path=file_path) - self.files.append(item) - self._refresh_from_project() - def approve_ticket(self, ticket_id: str) -> None: """Manually approves a ticket for execution.""" engine = self.engines.get(self.active_track.id if self.active_track else None) @@ -4320,54 +4219,121 @@ class AppController: # Refresh tracks from disk self.tracks = project_manager.get_all_tracks(self.active_project_root) - def _push_mma_state_update(self) -> None: + def _handle_mma_respond(self, approved: bool, payload: str | None = None, abort: bool = False, prompt: str | None = None, context_md: str | None = None) -> None: """ - [C: src/gui_2.py:App._cb_block_ticket, src/gui_2.py:App._cb_unblock_ticket, src/gui_2.py:App._render_mma_ticket_editor, src/gui_2.py:App._render_task_dag_panel, src/gui_2.py:App._render_ticket_queue, src/gui_2.py:App._reorder_ticket, src/gui_2.py:App.bulk_block, src/gui_2.py:App.bulk_execute, src/gui_2.py:App.bulk_skip, tests/test_gui_phase4.py:test_push_mma_state_update] + [C: src/gui_2.py:App._handle_approve_mma_step, src/gui_2.py:App._handle_approve_spawn, src/gui_2.py:App._render_mma_modals] """ - if not self.active_track: - return - # Sync active_tickets (list of dicts) back to active_track.tickets (list of models.Ticket objects) - self.active_track.tickets = [models.Ticket.from_dict(t) for t in self.active_tickets] - # Save the state to disk - existing = project_manager.load_track_state(self.active_track.id, self.active_project_root) - meta = models.Metadata( - id=self.active_track.id, - name=self.active_track.description, - status=self.mma_status, - created_at=existing.metadata.created_at if existing else datetime.now(), - updated_at=datetime.now() - ) - state = models.TrackState( - metadata=meta, - discussion=existing.discussion if existing else [], - tasks=self.active_track.tickets - ) - project_manager.save_track_state(self.active_track.id, state, self.active_project_root) + if self._pending_mma_approvals: + task = self._pending_mma_approvals.pop(0) + dlg = task.get("dialog_container", [None])[0] + if dlg: + with dlg._condition: + dlg._approved = approved + if payload is not None: + dlg._payload = payload + dlg._done = True + dlg._condition.notify_all() + elif self._pending_mma_spawns: + task = self._pending_mma_spawns.pop(0) + spawn_dlg = task.get("dialog_container", [None])[0] + if spawn_dlg: + with spawn_dlg._condition: + spawn_dlg._approved = approved + spawn_dlg._abort = abort + if prompt is not None: + spawn_dlg._prompt = prompt + if context_md is not None: + spawn_dlg._context_md = context_md + spawn_dlg._done = True + spawn_dlg._condition.notify_all() - def _load_active_tickets(self) -> None: + def _cb_load_track(self, track_id: str) -> None: """ - - - Populates self.active_tickets based on the current execution mode. - [C: tests/test_gui_dag_beads.py:test_load_active_tickets_from_beads] + [C: src/gui_2.py:App._render_mma_track_browser] """ - if getattr(self, "ui_project_execution_mode", "native") == "beads": - from src import beads_client - bclient = beads_client.BeadsClient(Path(self.active_project_root)) - beads = bclient.list_beads() - self.active_tickets = [] - for b in beads: - self.active_tickets.append({ - "id": b.id, - "title": b.title, - "description": b.description, - "status": b.status, - "assigned_to": "tier3-worker", - "target_file": "", - "depends_on": [] - }) - else: - if self.active_track: - self.active_tickets = [asdict(t) if not isinstance(t, dict) else t for t in self.active_track.tickets] - else: - self.active_tickets = [] \ No newline at end of file + state = project_manager.load_track_state(track_id, self.active_project_root) + if state: + try: + # Convert list[Ticket] or list[dict] to list[Ticket] for Track object + tickets = [] + for t in state.tasks: + if isinstance(t, dict): + tickets.append(models.Ticket(**t)) + else: + tickets.append(t) + self.active_track = models.Track( + id=state.metadata.id, + description=state.metadata.name, + tickets=tickets + ) + # Keep dicts for UI table + self._load_active_tickets() + # Load track-scoped history + history = project_manager.load_track_history(track_id, self.active_project_root) + with self._disc_entries_lock: + if history: + self.disc_entries[:] = models.parse_history_entries(history, self.disc_roles) + else: + self.disc_entries.clear() + self._recalculate_session_usage() + self.ai_status = f"Loaded track: {state.metadata.name}" + except Exception as e: + self.ai_status = f"Load track error: {e}" + print(f"Error loading track {track_id}: {e}") + + #endregion: MMA (Controller) + +#region: MMA + +class MMAApprovalDialog: + def __init__(self, ticket_id: str, payload: str) -> None: + """ + [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] + """ + self._payload = payload + self._condition = threading.Condition() + self._done = False + self._approved = False + + def wait(self) -> tuple[bool, str]: + """ + [C: src/mcp_client.py:StdioMCPServer.stop, src/multi_agent_conductor.py:confirm_execution, src/multi_agent_conductor.py:confirm_spawn, tests/conftest.py:live_gui, tests/test_ai_client_concurrency.py:run_t1, tests/test_ai_client_concurrency.py:run_t2, tests/test_ai_server.py:test_server_handles_list_models, tests/test_ai_server.py:test_server_handles_unknown_method, tests/test_ai_server.py:test_server_loads_google_genai_quickly, tests/test_ai_server.py:test_server_outputs_ready_marker, tests/test_ai_server.py:test_server_starts_and_exits_cleanly, tests/test_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] + """ + start_time = time.time() + with self._condition: + while not self._done: + if time.time() - start_time > 120: + return False, self._payload + self._condition.wait(timeout=0.1) + return self._approved, self._payload + +class MMASpawnApprovalDialog: + def __init__(self, ticket_id: str, role: str, prompt: str, context_md: str) -> None: + """ + [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] + """ + self._prompt = prompt + self._context_md = context_md + self._condition = threading.Condition() + self._done = False + self._approved = False + self._abort = False + + def wait(self) -> dict[str, Any]: + """ + [C: src/mcp_client.py:StdioMCPServer.stop, src/multi_agent_conductor.py:confirm_execution, src/multi_agent_conductor.py:confirm_spawn, tests/conftest.py:live_gui, tests/test_ai_client_concurrency.py:run_t1, tests/test_ai_client_concurrency.py:run_t2, tests/test_ai_server.py:test_server_handles_list_models, tests/test_ai_server.py:test_server_handles_unknown_method, tests/test_ai_server.py:test_server_loads_google_genai_quickly, tests/test_ai_server.py:test_server_outputs_ready_marker, tests/test_ai_server.py:test_server_starts_and_exits_cleanly, tests/test_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] + """ + start_time = time.time() + with self._condition: + while not self._done: + if time.time() - start_time > 120: + return {'approved': False, 'abort': True, 'prompt': self._prompt, 'context_md': self._context_md} + self._condition.wait(timeout=0.1) + return { + 'approved': self._approved, + 'abort': self._abort, + 'prompt': self._prompt, + 'context_md': self._context_md + } + +#endregion: MMA