diff --git a/check_indent.py b/check_indent.py new file mode 100644 index 0000000..4950438 --- /dev/null +++ b/check_indent.py @@ -0,0 +1,20 @@ + +import sys + +def check_indent(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + last_indent = 0 + for i, line in enumerate(lines): + stripped = line.lstrip() + if not stripped: + continue + + indent = len(line) - len(line.lstrip()) + if indent > last_indent + 1: + print(f"Jump at line {i+1}: '{line.rstrip()}' (Indent: {indent}, Last: {last_indent})") + last_indent = indent + +if __name__ == '__main__': + check_indent('src/app_controller.py') diff --git a/fix_indent_v3.py b/fix_indent_v3.py new file mode 100644 index 0000000..6198247 --- /dev/null +++ b/fix_indent_v3.py @@ -0,0 +1,38 @@ + +import sys + +def fix_indentation(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + fixed_lines = [] + indent_map = {0: 0} + + for i, line in enumerate(lines): + stripped = line.lstrip() + if not stripped: + fixed_lines.append('\n') + continue + + old_indent = len(line) - len(stripped) + + # Remove larger indents from map if we dedent below them + for k in list(indent_map.keys()): + if k > old_indent: + del indent_map[k] + + if old_indent not in indent_map: + # Find the closest smaller indent + known = sorted([k for k in indent_map.keys() if k < old_indent]) + parent_new = indent_map[max(known)] + indent_map[old_indent] = parent_new + 1 + + new_indent = indent_map[old_indent] + fixed_lines.append(' ' * new_indent + stripped) + + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(fixed_lines) + +if __name__ == '__main__': + fix_indentation('src/app_controller.py') + print("Indentation fixed v3.") diff --git a/src/ai_client.py b/src/ai_client.py index 9d1ea30..aad96c2 100644 --- a/src/ai_client.py +++ b/src/ai_client.py @@ -523,11 +523,6 @@ def list_models(provider: str) -> list[str]: """ [C: src/app_controller.py:AppController.do_fetch, tests/test_agent_capabilities.py:test_agent_capabilities_listing, tests/test_ai_client_list_models.py:test_list_models_gemini_cli, tests/test_deepseek_infra.py:test_deepseek_model_listing, tests/test_minimax_provider.py:test_minimax_list_models] """ - proxy = _get_proxy() - if proxy and proxy.status == "ready": - result = proxy.send_command("list_models", {"provider": provider}) - if "result" in result: - return result["result"].get("models", []) creds = _load_credentials() if provider == "gemini": return _list_gemini_models(creds["gemini"]["api_key"]) diff --git a/src/app_controller.py b/src/app_controller.py index ad55dd3..7e87bc1 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -15,15 +15,15 @@ from datetime import datetime from fastapi import FastAPI, Depends, HTTPException from fastapi.security.api_key import APIKeyHeader from pathlib import Path -from pydantic import BaseModel from typing import Any, List, Dict, Optional, Callable from src import aggregate +from src import models +from src.models import GenerateRequest, ConfirmRequest from src import ai_client from src import conductor_tech_lead from src import events from src import mcp_client from src import multi_agent_conductor -from src import models from src import orchestrator_pm from src import paths from src import performance_monitor @@ -37,18 +37,17 @@ from src import thinking_parser from src import tool_presets from src.file_cache import ASTParser - def parse_symbols(text: str) -> list[str]: """ - Finds all occurrences of '@SymbolName' in text and returns SymbolName. - SymbolName can be a function, class, or method (e.g. @MyClass, @my_func, @MyClass.my_method). - [C: tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_basic, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_edge_cases, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_methods, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_mixed, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_no_symbols] + Finds all occurrences of '@SymbolName' in text and returns SymbolName. + SymbolName can be a function, class, or method (e.g. @MyClass, @my_func, @MyClass.my_method). + [C: tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_basic, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_edge_cases, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_methods, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_mixed, tests/test_symbol_lookup.py:TestSymbolLookup.test_parse_symbols_no_symbols] """ return re.findall(r"@([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)", text) def get_symbol_definition(symbol: str, files: list[str]) -> tuple[str, str, int] | None: """ - [C: tests/test_symbol_lookup.py:TestSymbolLookup.test_get_symbol_definition_found, tests/test_symbol_lookup.py:TestSymbolLookup.test_get_symbol_definition_not_found] + [C: tests/test_symbol_lookup.py:TestSymbolLookup.test_get_symbol_definition_found, tests/test_symbol_lookup.py:TestSymbolLookup.test_get_symbol_definition_not_found] """ for file_path in files: result = mcp_client.py_get_symbol_info(file_path, symbol) @@ -57,21 +56,10 @@ def get_symbol_definition(symbol: str, files: list[str]) -> tuple[str, str, int] return (file_path, source, line) return None -class GenerateRequest(BaseModel): - prompt: str - auto_add_history: bool = True - temperature: float | None = None - top_p: float | None = None - max_tokens: int | None = None - -class ConfirmRequest(BaseModel): - approved: bool - script: Optional[str] = None - class ConfirmDialog: def __init__(self, script: str, base_dir: str) -> None: """ - [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] + [C: src/app_controller.py:ConfirmDialog.__init__] """ self._uid = str(uuid.uuid4()) self._script = str(script) if script is not None else "" @@ -82,7 +70,7 @@ class ConfirmDialog: 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_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] + [C: src/app_controller.py:ConfirmDialog.wait] """ start_time = time.time() with self._condition: @@ -95,7 +83,7 @@ class ConfirmDialog: class MMAApprovalDialog: def __init__(self, ticket_id: str, payload: str) -> None: """ - [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] + [C: src/app_controller.py:MMAApprovalDialog.__init__] """ self._payload = payload self._condition = threading.Condition() @@ -104,7 +92,7 @@ class MMAApprovalDialog: 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_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] + [C: src/app_controller.py:MMAApprovalDialog.wait] """ start_time = time.time() with self._condition: @@ -117,7 +105,7 @@ class MMAApprovalDialog: 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__] + [C: src/app_controller.py:MMASpawnApprovalDialog.__init__] """ self._prompt = prompt self._context_md = context_md @@ -128,7 +116,7 @@ class MMASpawnApprovalDialog: 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_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] + [C: src/app_controller.py:MMASpawnApprovalDialog.wait] """ start_time = time.time() with self._condition: @@ -143,6 +131,580 @@ class MMASpawnApprovalDialog: 'context_md': self._context_md } +#region: API Handlers +async def _api_get_key(controller: 'AppController', header_key: str) -> str: + """ + Validates the API key from the request header against configuration. + [SDM: src/app_controller.py:_api_get_key] + """ + headless_cfg = controller.config.get("headless", {}) + config_key = headless_cfg.get("api_key", "").strip() + env_key = os.environ.get("SLOP_API_KEY", "").strip() + target_key = env_key or config_key + if not target_key: + raise HTTPException(status_code=403, detail="API Key not configured on server") + if header_key == target_key: + return header_key + raise HTTPException(status_code=403, detail="Could not validate API Key") + +def _api_health(controller: 'AppController') -> dict[str, str]: + """ + Returns the health status of the API. + [SDM: src/app_controller.py:_api_health] + """ + return {"status": "ok"} + +def _api_get_gui_state(controller: 'AppController') -> dict[str, Any]: + """ + Returns the current GUI state for specific fields. + [SDM: src/app_controller.py:_api_get_gui_state] + """ + gettable = getattr(controller, "_gettable_fields", {}) + state = {} + import dataclasses + for key, attr in gettable.items(): + val = getattr(controller, attr, None) + if dataclasses.is_dataclass(val): + state[key] = dataclasses.asdict(val) + else: + state[key] = val + return state + +def _api_get_mma_status(controller: 'AppController') -> dict[str, Any]: + """ + Dedicated endpoint for MMA-related status. + [SDM: src/app_controller.py:_api_get_mma_status] + """ + return { + "mma_status": controller.mma_status, + "ai_status": controller.ai_status, + "mma_streams": controller.mma_streams, + "worker_status": controller._worker_status, + "tool_stats": controller._tool_stats, + "active_tier": controller.active_tier, + "active_tickets": controller.active_tickets, + "proposed_tracks": controller.proposed_tracks, + "tracks": controller.tracks, + "tier_usage": controller.mma_tier_usage + } + +def _api_post_gui(controller: 'AppController', req: dict) -> dict[str, str]: + """ + Pushes a GUI task to the event queue. + [SDM: src/app_controller.py:_api_post_gui] + """ + controller.event_queue.put("gui_task", req) + return {"status": "queued"} + +def _api_get_api_session(controller: 'AppController') -> dict[str, Any]: + """ + Returns current discussion session entries. + [SDM: src/app_controller.py:_api_get_api_session] + """ + with controller._disc_entries_lock: + return {"session": {"entries": controller.disc_entries}} + +def _api_post_api_session(controller: 'AppController', req: dict) -> dict[str, str]: + """ + Updates session entries. + [SDM: src/app_controller.py:_api_post_api_session] + """ + entries = req.get("entries", []) + with controller._disc_entries_lock: + controller.disc_entries = entries + return {"status": "updated"} + +def _api_get_api_project(controller: 'AppController') -> dict[str, Any]: + """ + Returns current project data. + [SDM: src/app_controller.py:_api_get_api_project] + """ + return {"project": controller.project} + +def _api_get_performance(controller: 'AppController') -> dict[str, Any]: + """ + Returns performance monitor metrics. + [SDM: src/app_controller.py:_api_get_performance] + """ + return {"performance": controller.perf_monitor.get_metrics()} + +def _api_get_diagnostics(controller: 'AppController') -> dict[str, Any]: + """ + Alias for performance metrics. + [SDM: src/app_controller.py:_api_get_diagnostics] + """ + return controller.perf_monitor.get_metrics() + +def _api_status(controller: 'AppController') -> dict[str, Any]: + """ + Returns the current status of the application. + [SDM: src/app_controller.py:_api_status] + """ + return { + "provider": controller.current_provider, + "model": controller.current_model, + "status": controller.ai_status, + "usage": controller.session_usage + } + +def _api_generate(controller: 'AppController', req: GenerateRequest) -> dict[str, Any]: + """ + Triggers an AI generation request using the current project context. + [SDM: src/app_controller.py:_api_generate] + """ + if not req.prompt.strip(): + raise HTTPException(status_code=400, detail="Prompt cannot be empty") + with controller._send_thread_lock: + start_time = time.time() + try: + md, path, file_items, stable_md, disc_text = controller._do_generate() + controller._last_stable_md = stable_md + controller.last_md = md + controller.last_md_path = path + controller.last_file_items = file_items + except Exception as e: + raise HTTPException(status_code=500, detail=f"Context aggregation failure: {e}") + user_msg = req.prompt + base_dir = controller.active_project_root + csp = filter(bool, [controller.ui_global_system_prompt.strip(), controller.ui_project_system_prompt.strip()]) + ai_client.set_custom_system_prompt("\n\n".join(csp)) + ai_client.set_base_system_prompt(controller.ui_base_system_prompt) + ai_client.set_use_default_base_prompt(controller.ui_use_default_base_prompt) + ai_client.set_project_context_marker(controller.ui_project_context_marker) + temp = req.temperature if req.temperature is not None else controller.temperature + top_p = req.top_p if req.top_p is not None else controller.top_p + tokens = req.max_tokens if req.max_tokens is not None else controller.max_tokens + ai_client.set_model_params(temp, tokens, controller.history_trunc_limit, top_p) + ai_client.set_agent_tools(controller.ui_agent_tools) + if req.auto_add_history: + with controller._pending_history_adds_lock: + controller._pending_history_adds.append({ + "role": "User", + "content": user_msg, + "collapsed": True, + "ts": project_manager.now_ts() + }) + try: + resp = ai_client.send(stable_md, user_msg, base_dir, controller.last_file_items, disc_text, rag_engine=controller.rag_engine) + if req.auto_add_history: + with controller._pending_history_adds_lock: + controller._pending_history_adds.append({ + "role": "AI", + "content": resp, + "collapsed": True, + "ts": project_manager.now_ts() + }) + controller._recalculate_session_usage() + duration = time.time() - start_time + return { + "text": resp, + "metadata": { + "provider": controller.current_provider, + "model": controller.current_model, + "duration_sec": round(duration, 3), + "timestamp": project_manager.now_ts() + }, + "usage": controller.session_usage + } + except ai_client.ProviderError as e: + raise HTTPException(status_code=502, detail=f"AI Provider Error: {e.ui_message()}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"In-flight AI request failure: {e}") + +async def _api_stream(controller: 'AppController', req: GenerateRequest) -> Any: + """ + Placeholder for streaming AI generation responses (Not yet implemented). + [SDM: src/app_controller.py:_api_stream] + """ + raise HTTPException(status_code=501, detail="Streaming endpoint (/api/v1/stream) is not yet supported in this version.") + +def _api_pending_actions(controller: 'AppController') -> list[dict[str, Any]]: + """ + Lists all pending PowerShell scripts awaiting confirmation. + [SDM: src/app_controller.py:_api_pending_actions] + """ + with controller._pending_dialog_lock: + return [ + {"action_id": uid, "script": diag._script, "base_dir": diag._base_dir} + for uid, diag in controller._pending_actions.items() + ] + +def _api_confirm_action(controller: 'AppController', action_id: str, req: ConfirmRequest) -> dict[str, str]: + """ + Approves or rejects a pending action. + [SDM: src/app_controller.py:_api_confirm_action] + """ + with controller._pending_dialog_lock: + if action_id not in controller._pending_actions: + raise HTTPException(status_code=404, detail="Action not found") + dialog = controller._pending_actions.pop(action_id) + if req.script is not None: + dialog._script = req.script + with dialog._condition: + dialog._approved = req.approved + dialog._done = True + dialog._condition.notify_all() + return {"status": "confirmed" if req.approved else "rejected"} + +def _api_list_sessions(controller: 'AppController') -> list[str]: + """ + Lists all session IDs. + [SDM: src/app_controller.py:_api_list_sessions] + """ + log_dir = paths.get_logs_dir() + if not log_dir.exists(): + return [] + return [d.name for d in log_dir.iterdir() if d.is_dir()] + +def _api_get_session(controller: 'AppController', session_id: str) -> dict[str, Any]: + """ + Returns the content of the comms.log for a specific session. + [SDM: src/app_controller.py:_api_get_session] + """ + log_path = paths.get_logs_dir() / session_id / "comms.log" + if not log_path.exists(): + raise HTTPException(status_code=404, detail="Session log not found") + return {"id": session_id, "content": log_path.read_text(encoding="utf-8", errors="replace")} + +def _api_delete_session(controller: 'AppController', session_id: str) -> dict[str, str]: + """ + Deletes a specific session directory. + [SDM: src/app_controller.py:_api_delete_session] + """ + log_path = paths.get_logs_dir() / session_id + if not log_path.exists() or not log_path.is_dir(): + raise HTTPException(status_code=404, detail="Session directory not found") + import shutil + shutil.rmtree(log_path) + return {"status": "deleted"} + +def _api_get_context(controller: 'AppController') -> dict[str, Any]: + """ + Returns the current aggregated project context. + [SDM: src/app_controller.py:_api_get_context] + """ + try: + md, path, file_items, stable_md, disc_text = controller._do_generate() + screenshots = controller.project.get("screenshots", {}).get("paths", []) + return { + "files": [f.get("path") if isinstance(f, dict) else str(f) for f in file_items], + "screenshots": screenshots, + "files_base_dir": controller.active_project_root, + "markdown": md, + "discussion": disc_text + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Context aggregation failure: {e}") + +def _api_token_stats(controller: 'AppController') -> dict[str, Any]: + """ + Returns current token usage and budget statistics. + [SDM: src/app_controller.py:_api_token_stats] + """ + return controller._token_stats +#endregion +#region: GUI Task Handlers + +def _handle_ai_response(controller: 'AppController', task: dict): + """[SDM: AppController._handle_ai_response]""" + payload = task.get("payload", {}) + text = payload.get("text", "") + stream_id = payload.get("stream_id") + is_streaming = payload.get("status") == "streaming..." + if stream_id: + if is_streaming: + if stream_id not in controller.mma_streams: controller.mma_streams[stream_id] = "" + if stream_id not in controller._worker_status: controller._worker_status[stream_id] = "running" + controller.mma_streams[stream_id] += text + if len(controller.mma_streams[stream_id]) > controller.MAX_STREAM_SIZE: + controller.mma_streams[stream_id] = controller.mma_streams[stream_id][-controller.MAX_STREAM_SIZE:] + else: + controller.mma_streams[stream_id] = text + if stream_id in controller._worker_status and controller._worker_status[stream_id] == "running": + controller._worker_status[stream_id] = "completed" + if stream_id == "Tier 1": + if "status" in payload: + controller._ai_status = payload["status"] + else: + if is_streaming: + controller.ai_response += text + else: + controller.ai_response = text + controller._ai_status = payload.get("status", "done") + controller._trigger_blink = True + if not stream_id: + controller._token_stats_dirty = True + if not is_streaming: + controller._autofocus_response_tab = True + +def _handle_mma_state_update(controller: 'AppController', task: dict): + """[SDM: AppController._handle_mma_state_update]""" + p = task.get("payload") + if not isinstance(p, dict): + p = task + + track_data = p.get("track") + is_active_track = False + if track_data and controller.active_track and track_data.get("id") == controller.active_track.id: + is_active_track = True + + if is_active_track or not controller.active_track: + controller._mma_status = p.get("status", controller._mma_status) + + old_tier = controller.active_tier + controller.active_tier = p.get("active_tier", controller.active_tier) + + if getattr(controller, "ui_auto_switch_layout", False) and controller.active_tier and controller.active_tier != old_tier: + for tier_prefix in ["Tier 1", "Tier 2", "Tier 3", "Tier 4"]: + if controller.active_tier.startswith(tier_prefix): + bound_profile = getattr(controller, "ui_tier_layout_bindings", {}).get(tier_prefix) + if bound_profile: + controller._cb_load_workspace_profile(bound_profile) + break + + new_usage = p.get("tier_usage", {}) + for tier, data in new_usage.items(): + if tier in controller.mma_tier_usage: + input_val = data.get("input") + if input_val is not None: + controller.mma_tier_usage[tier]["input"] = input_val + + output_val = data.get("output") + if output_val is not None: + controller.mma_tier_usage[tier]["output"] = output_val + if "model" in data: controller.mma_tier_usage[tier]["model"] = data["model"] + if "provider" in data: controller.mma_tier_usage[tier]["provider"] = data["provider"] + else: + controller.mma_tier_usage[tier] = data + + if is_active_track or not controller.active_track: + controller.active_tickets = p.get("tickets", []) + if track_data: + tickets = [] + for t_data in controller.active_tickets: + if isinstance(t_data, models.Ticket): + tickets.append(t_data) + else: + if "goal" in t_data and "description" not in t_data: + t_data["description"] = t_data["goal"] + tickets.append(models.Ticket.from_dict(t_data)) + controller.active_track = models.Track( + id=track_data.get("id"), + description=track_data.get("title", ""), + tickets=tickets + ) + +def _handle_refresh_api_metrics(controller: 'AppController', task: dict): + """[SDM: AppController._handle_refresh_api_metrics]""" + controller._refresh_api_metrics(task.get("payload", {}), md_content=controller.last_md or None) + +def _handle_set_ai_status(controller: 'AppController', task: dict): + """[SDM: AppController._handle_set_ai_status]""" + controller._ai_status = task.get("value", task.get("payload", "")) + +def _handle_set_mma_status(controller: 'AppController', task: dict): + """[SDM: AppController._handle_set_mma_status]""" + controller._mma_status = task.get("value", task.get("payload", "")) + +def _handle_mma_stream(controller: 'AppController', task: dict): + """[SDM: AppController._handle_mma_stream]""" + stream_id = task.get("stream_id") or task.get("payload", {}).get("stream_id") + text = task.get("text") or task.get("payload", {}).get("text", "") + if stream_id: + if stream_id not in controller.mma_streams: + controller.mma_streams[stream_id] = "" + controller.mma_streams[stream_id] += text + +def _handle_show_track_proposal(controller: 'AppController', task: dict): + """[SDM: AppController._handle_show_track_proposal]""" + controller.proposed_tracks = task.get("payload", []) + controller._show_track_proposal_modal = True + +def _handle_custom_callback(controller: 'AppController', task: dict): + """[SDM: AppController._handle_custom_callback]""" + cb = task.get("callback") + args = task.get("args", []) + if callable(cb): + try: + cb(*args) + except Exception as e: + print(f"Error in direct custom callback: {e}") + elif cb in controller._predefined_callbacks: + controller._predefined_callbacks[cb](*args) + +def _handle_set_value(controller: 'AppController', task: dict): + """[SDM: AppController._handle_set_value]""" + item = task.get("item") + value = task.get("value") + if item in controller._settable_fields: + attr_name = controller._settable_fields[item] + setattr(controller, attr_name, value) + if item == "gcli_path": + controller._update_gcli_adapter(str(value)) + +def _handle_click(controller: 'AppController', task: dict): + """[SDM: AppController._handle_click]""" + item = task.get("item") + user_data = task.get("user_data") + if item == "btn_project_new_automated": + controller._cb_new_project_automated(user_data) + elif item == "btn_mma_load_track": + controller._cb_load_track(str(user_data or "")) + elif item in controller._clickable_actions: + func = controller._clickable_actions[item] + try: + sig = inspect.signature(func) + if 'user_data' in sig.parameters: + func(user_data=user_data) + else: + func() + except Exception: + func() + +def _handle_drag(controller: 'AppController', task: dict): + """[SDM: AppController._handle_drag]""" + src_item = task.get("src_item") + dst_item = task.get("dst_item") + if src_item in controller._drag_actions: + func = controller._drag_actions[src_item] + func(dst_item=dst_item) + +def _handle_right_click(controller: 'AppController', task: dict): + """[SDM: AppController._handle_right_click]""" + item = task.get("item") + if item in controller._right_clickable_actions: + func = controller._right_clickable_actions[item] + func() + +def _handle_select_list_item(controller: 'AppController', task: dict): + """[SDM: AppController._handle_select_list_item]""" + item = task.get("listbox", task.get("item")) + value = task.get("item_value", task.get("value")) + if item == "disc_listbox": + controller._switch_discussion(str(value or "")) + +def _handle_ask(controller: 'AppController', task: dict): + """[SDM: AppController._handle_ask]""" + controller._pending_ask_dialog = True + controller._ask_request_id = task.get("request_id") + controller._ask_tool_data = task.get("data", {}) + +def _handle_clear_ask(controller: 'AppController', task: dict): + """[SDM: AppController._handle_clear_ask]""" + if controller._ask_request_id == task.get("request_id"): + controller._pending_ask_dialog = False + controller._ask_request_id = None + controller._ask_tool_data = None + +def _handle_mma_step_approval(controller: 'AppController', task: dict): + """[SDM: AppController._handle_mma_step_approval]""" + if controller.test_hooks_enabled and not getattr(controller, "ui_manual_approve", False): + if "dialog_container" in task: + class AutoStepDialog: + def __init__(self, t): self.t = t + def wait(self): return True, self.t.get("payload", "") + task["dialog_container"][0] = AutoStepDialog(task) + return + dlg = MMAApprovalDialog(str(task.get("ticket_id") or ""), str(task.get("payload") or "")) + controller._pending_mma_approvals.append(task) + if "dialog_container" in task: + task["dialog_container"][0] = dlg + +def _handle_mma_spawn_approval(controller: 'AppController', task: dict): + """[SDM: AppController._handle_mma_spawn_approval]""" + if controller.test_hooks_enabled and not getattr(controller, "ui_manual_approve", False): + if "dialog_container" in task: + class AutoSpawnDialog: + def __init__(self, t): self.t = t + def wait(self): + return {'approved': True, 'abort': False, 'prompt': self.t.get("prompt"), 'context_md': self.t.get("context_md")} + task["dialog_container"][0] = AutoSpawnDialog(task) + return + spawn_dlg = MMASpawnApprovalDialog( + str(task.get("ticket_id") or ""), + str(task.get("role") or ""), + str(task.get("prompt") or ""), + str(task.get("context_md") or "") + ) + controller._pending_mma_spawns.append(task) + controller._mma_spawn_prompt = task.get("prompt", "") + controller._mma_spawn_context = task.get("context_md", "") + controller._mma_spawn_open = True + controller._mma_spawn_edit_mode = False + if "dialog_container" in task: + task["dialog_container"][0] = spawn_dlg + +def _handle_ticket_started(controller: 'AppController', task: dict): + """[SDM: AppController._handle_ticket_started]""" + payload = task.get("payload", {}) + ticket_id = payload.get("ticket_id") + start_time = payload.get("timestamp") + persona_id = payload.get("persona_id") + model = payload.get("model") + if ticket_id and start_time: + controller._ticket_start_times[ticket_id] = start_time + if ticket_id and (persona_id or model): + stream_id = f"Tier 3 (Worker): {ticket_id}" + meta_info = f"[STARTED] Ticket: {ticket_id}" + if model: + meta_info += f" | Model: {model}" + if persona_id: + meta_info += f" | Persona: {persona_id}" + meta_info += "\n" + "="*50 + "\n" + if stream_id not in controller.mma_streams: + controller.mma_streams[stream_id] = "" + controller.mma_streams[stream_id] = meta_info + controller.mma_streams[stream_id] + +def _handle_ticket_completed(controller: 'AppController', task: dict): + """[SDM: AppController._handle_ticket_completed]""" + payload = task.get("payload", {}) + ticket_id = payload.get("ticket_id") + end_time = payload.get("timestamp") + if ticket_id and end_time and ticket_id in controller._ticket_start_times: + start_time = controller._ticket_start_times.pop(ticket_id) + elapsed = end_time - start_time + controller._completed_ticket_count += 1 + controller._avg_ticket_time = ((controller._avg_ticket_time * (controller._completed_ticket_count - 1)) + elapsed) / controller._completed_ticket_count + +def _handle_bead_updated(controller: 'AppController', task: dict): + """[SDM: AppController._handle_bead_updated]""" + payload = task.get("payload", {}) + bead_id = payload.get("bead_id") or payload.get("bid") + status = payload.get("status") + if bead_id and status: + stream_id = "Tier 2 (Tech Lead)" + msg = f"\n[BEAD UPDATE] {bead_id} -> status: {status}\n" + if stream_id not in controller.mma_streams: + controller.mma_streams[stream_id] = "" + controller.mma_streams[stream_id] += msg + +def _handle_set_comms_dirty(controller: 'AppController', task: dict): + """[SDM: AppController._handle_set_comms_dirty]""" + controller._comms_log_dirty = True + +def _handle_set_tool_log_dirty(controller: 'AppController', task: dict): + """[SDM: AppController._handle_set_tool_log_dirty]""" + controller._tool_log_dirty = True + +def _handle_refresh_from_project(controller: 'AppController', task: dict): + """[SDM: AppController._handle_refresh_from_project]""" + controller._refresh_from_project() + +def _handle_show_patch_modal(controller: 'AppController', task: dict): + """[SDM: AppController._handle_show_patch_modal]""" + controller._pending_patch_text = task.get("patch_text", "") + controller._pending_patch_files = task.get("file_paths", []) + controller._show_patch_modal = True + +def _handle_hide_patch_modal(controller: 'AppController', task: dict): + """[SDM: AppController._handle_hide_patch_modal]""" + controller._show_patch_modal = False + controller._pending_patch_text = None + controller._pending_patch_files = [] + +#endregion +#endregion + class AppController: """ The headless controller for the Manual Slop application. @@ -150,54 +712,117 @@ class AppController: """ def __init__(self): - # Initialize locks first to avoid initialization order issues """ - [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] + [C: src/app_controller.py:AppController.__init__] """ + # --- Locks --- 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_gui_tasks: List[Dict[str, Any]] = [] - self._ai_status: str = "idle" - self._mma_status: str = "idle" self._pending_dialog_lock: threading.Lock = threading.Lock() self._api_event_queue_lock: threading.Lock = threading.Lock() + + # --- Internal State --- + self._ai_status: str = "idle" + self._mma_status: str = "idle" + self._pending_gui_tasks: List[Dict[str, Any]] = [] + self._api_event_queue: List[Dict[str, Any]] = [] + self._pending_dialog: Optional[ConfirmDialog] = None + self._pending_dialog_open: bool = False + self._pending_actions: Dict[str, ConfirmDialog] = {} + self._pending_ask_dialog: bool = False + self._loop_thread: Optional[threading.Thread] = None + self._worker_status: Dict[str, str] = {} + self._pending_patch_text: Optional[str] = None + self._pending_patch_files: List[str] = [] + self._show_patch_modal: bool = False + self._patch_error_message: Optional[str] = None + self._tool_log: List[Dict[str, Any]] = [] + self._tool_stats: Dict[str, Dict[str, Any]] = {} + self._cached_cache_stats: Dict[str, Any] = {} + self._cached_files: List[str] = [] + self._token_history: List[Dict[str, Any]] = [] + self._session_start_time: float = time.time() + self._ticket_start_times: dict[str, float] = {} + self._avg_ticket_time: float = 0.0 + self._completed_ticket_count: int = 0 + self._comms_log: List[Dict[str, Any]] = [] + self._last_telemetry_time: float = 0.0 + self._current_provider: str = "gemini" + self._current_model: str = "gemini-2.5-flash-lite" + self._autofocus_response_tab = False + self._show_track_proposal_modal: bool = False + 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_last_update: float = 0.0 + self._last_autosave: float = time.time() + self._ask_dialog_open: bool = False + self._ask_request_id: Optional[str] = None + self._ask_tool_data: Optional[Dict[str, Any]] = None + self._pending_mma_approvals: List[Dict[str, Any]] = [] + self._mma_approval_open: bool = False + self._mma_approval_edit_mode: bool = False + self._mma_approval_payload: str = "" + self._pending_mma_spawns: List[Dict[str, Any]] = [] + 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._comms_log_dirty: bool = True + self._tool_log_dirty: bool = True + self._token_stats_dirty: bool = True + self._tier_stream_last_len: Dict[str, int] = {} + self._current_session_usage = None + self._current_mma_tier_usage = None + self._current_token_history = None + self._current_session_start_time = None + self._inject_file_path: str = "" + self._inject_mode: str = "skeleton" + self._inject_preview: str = "" + self._show_inject_modal: bool = False + self._editing_preset_name: str = "" + self._editing_preset_content: str = "" + self._editing_preset_temperature: float = 0.0 + self._editing_preset_top_p: float = 0.0 + self._editing_preset_max_output_tokens: int = 4096 + self._editing_preset_scope: str = "project" + self._editing_tool_preset_name: str = "" + self._editing_tool_preset_categories: Dict[str, Dict[str, Any]] = {} + self._editing_tool_preset_scope: str = "project" + self._show_base_prompt_diff_modal: bool = False + self._autosave_interval: float = 60.0 + self._show_add_ticket_form: bool = False + self._track_discussion_active: bool = False + + # --- Core State --- 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.ui_active_persona: str = "" self.disc_roles: List[str] = [] - self.files: List[models.FileItem] = [] - self.context_files: List[models.FileItem] = [] - self.screenshots: List[str] = [] - self.event_queue: events.AsyncEventQueue = events.AsyncEventQueue() - self._loop_thread: Optional[threading.Thread] = None self.tracks: List[Dict[str, Any]] = [] self.active_track: Optional[models.Track] = None self.engines: Dict[str, multi_agent_conductor.ConductorEngine] = {} self.mma_streams: Dict[str, str] = {} - self._worker_status: Dict[str, str] = {} # stream_id -> "running" | "completed" | "failed" | "killed" - self.MAX_STREAM_SIZE: int = 10 * 1024 # 10KB max per stream - self._pending_patch_text: Optional[str] = None - self._pending_patch_files: List[str] = [] - self._show_patch_modal: bool = False - self._patch_error_message: Optional[str] = None - self._tool_log: List[Dict[str, Any]] = [] - self._tool_stats: Dict[str, Dict[str, Any]] = {} # {tool_name: {"count": 0, "total_time_ms": 0.0, "failures": 0}} - self._cached_cache_stats: Dict[str, Any] = {} # Pre-computed cache stats for GUI - self._cached_files: List[str] = [] - self._token_history: List[Dict[str, Any]] = [] # Token usage over time [{"time": t, "input": n, "output": n, "model": s}, ...] - self._session_start_time: float = time.time() # For calculating burn rate - self._ticket_start_times: dict[str, float] = {} - self._avg_ticket_time: float = 0.0 - self._completed_ticket_count: int = 0 - self._comms_log: List[Dict[str, Any]] = [] + self.MAX_STREAM_SIZE: int = 10 * 1024 self.session_usage: Dict[str, Any] = { "input_tokens": 0, "output_tokens": 0, @@ -213,27 +838,47 @@ class AppController: "Tier 3": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-2.5-flash-lite", "tool_preset": None}, "Tier 4": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-2.5-flash-lite", "tool_preset": None}, } - self._last_telemetry_time: float = 0.0 - self._pending_gui_tasks: List[Dict[str, Any]] = [] - self._api_event_queue: List[Dict[str, Any]] = [] - # Pending dialogs state moved from App - self._pending_dialog: Optional[ConfirmDialog] = None - self._pending_dialog_open: bool = False - self._pending_actions: Dict[str, ConfirmDialog] = {} - self._pending_ask_dialog: bool = False self.mcp_config: models.MCPConfiguration = models.MCPConfiguration() self.view_presets: list[models.NamedViewPreset] = [] self.rag_config: Optional[models.RAGConfig] = None - self.rag_engine: Optional[Any] = None self.rag_status: str = 'idle' - # AI settings state - self._current_provider: str = "gemini" - self._current_model: str = "gemini-2.5-flash-lite" self.temperature: float = 0.0 self.top_p: float = 1.0 self.max_tokens: int = 8192 self.history_trunc_limit: int = 8000 - # UI-related state moved to controller + self.ai_response: str = '' + self.last_md: str = '' + self.last_aggregate_markdown: str = '' + self.last_resolved_system_prompt: 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.text_viewer_type: str = 'text' + 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.mma_step_mode: bool = False + self.active_tier: Optional[str] = None + self.test_hooks_enabled: bool = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1") + self.is_viewing_prior_session: bool = False + self.prior_session_entries: List[Dict[str, Any]] = [] + self.prior_tool_calls: List[Dict[str, Any]] = [] + self.prior_disc_entries: List[Dict[str, Any]] = [] + self.prior_mma_dashboard_state = {} + self.diagnostic_log: List[Dict[str, Any]] = [] + self.ui_active_tool_preset: str | None = None + self.available_models: List[str] = [] + self.all_available_models: Dict[str, List[str]] = {} + self.proposed_tracks: List[Dict[str, Any]] = [] + self.show_preset_manager_window: bool = False + self.show_tool_preset_manager_window: bool = False + self.show_persona_editor_window: bool = False + + # --- UI State --- self.ui_ai_input: str = "" self.ui_disc_new_name_input: str = "" self.ui_disc_new_role_input: str = "" @@ -260,105 +905,28 @@ class AppController: self.ui_separate_message_panel: bool = False self.ui_separate_response_panel: bool = False self.ui_separate_tool_calls_panel: bool = False - self.ui_active_tool_preset: str | None = None self.ui_global_system_prompt: str = "" self.ui_base_system_prompt: str = "" self.ui_use_default_base_prompt: bool = True - self._show_base_prompt_diff_modal: bool = False self.ui_project_context_marker: str = "" self.ui_agent_tools: Dict[str, bool] = {} - self.available_models: List[str] = [] - self.all_available_models: Dict[str, List[str]] = {} # provider -> list of models - self._autofocus_response_tab = False - self.proposed_tracks: List[Dict[str, Any]] = [] - self._show_track_proposal_modal: bool = False - self.ai_response: str = '' - self.last_md: str = '' - self.last_aggregate_markdown: str = '' - self.last_resolved_system_prompt: 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.text_viewer_type: str = 'text' - 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_approvals: List[Dict[str, Any]] = [] - self._mma_approval_open: bool = False - self._mma_approval_edit_mode: bool = False - self._mma_approval_payload: str = "" - self._pending_mma_spawns: List[Dict[str, Any]] = [] - 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._comms_log_dirty: bool = True - self._tool_log_dirty: bool = True - self._token_stats_dirty: bool = True + self.ui_manual_approve: 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._current_session_usage = None - self._current_mma_tier_usage = None - self.prior_session_entries: List[Dict[str, Any]] = [] - self.prior_tool_calls: List[Dict[str, Any]] = [] - self.prior_disc_entries: List[Dict[str, Any]] = [] - self.prior_mma_dashboard_state = {} - self._current_token_history = None - self._current_session_start_time = None - 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 - # Injection state - self._inject_file_path: str = "" - self._inject_mode: str = "skeleton" - self._inject_preview: str = "" - self._show_inject_modal: bool = False - self.show_preset_manager_window: bool = False - self.show_tool_preset_manager_window: bool = False - self.show_persona_editor_window: bool = False - self._editing_preset_name: str = "" - self._editing_preset_content: str = "" - self._editing_preset_temperature: float = 0.0 - self._editing_preset_top_p: float = 0.0 - self._editing_preset_max_output_tokens: int = 4096 - self._editing_preset_scope: str = "project" - self._editing_tool_preset_name: str = "" - self._editing_tool_preset_categories: Dict[str, Dict[str, Any]] = {} - self._editing_tool_preset_scope: str = "project" - self.diagnostic_log: List[Dict[str, Any]] = [] + self.ui_focus_agent: Optional[str] = None + self.ui_active_persona: str = "" + + # --- Media/Context --- + self.files: List[models.FileItem] = [] + self.context_files: List[models.FileItem] = [] + self.screenshots: List[str] = [] + + # --- Services --- + self.event_queue: events.AsyncEventQueue = events.AsyncEventQueue() + self.rag_engine: Optional[Any] = None + + # --- Configuration Maps --- self._settable_fields: Dict[str, str] = { 'ai_input': 'ui_ai_input', 'project_git_dir': 'ui_project_git_dir', @@ -367,7 +935,8 @@ class AppController: 'gcli_path': 'ui_gemini_cli_path', 'output_dir': 'ui_output_dir', 'files_base_dir': 'ui_files_base_dir', - 'files': 'ui_file_paths', 'screenshots': 'screenshots', + 'files': 'ui_file_paths', + 'screenshots': 'screenshots', 'ai_status': 'ai_status', 'ai_response': 'ai_response', 'active_discussion': 'active_discussion', @@ -486,6 +1055,34 @@ class AppController: }) self.perf_monitor = performance_monitor.get_monitor() self._perf_profiling_enabled = False + self._gui_task_handlers: Dict[str, Callable] = { + "refresh_api_metrics": _handle_refresh_api_metrics, + "set_ai_status": _handle_set_ai_status, + "set_mma_status": _handle_set_mma_status, + "handle_ai_response": _handle_ai_response, + "mma_stream": _handle_mma_stream, + "mma_stream_append": _handle_mma_stream, + "show_track_proposal": _handle_show_track_proposal, + "mma_state_update": _handle_mma_state_update, + "custom_callback": _handle_custom_callback, + "set_value": _handle_set_value, + "click": _handle_click, + "drag": _handle_drag, + "right_click": _handle_right_click, + "select_list_item": _handle_select_list_item, + "ask": _handle_ask, + "clear_ask": _handle_clear_ask, + "mma_step_approval": _handle_mma_step_approval, + "mma_spawn_approval": _handle_mma_spawn_approval, + "ticket_started": _handle_ticket_started, + "ticket_completed": _handle_ticket_completed, + "bead_updated": _handle_bead_updated, + "set_comms_dirty": _handle_set_comms_dirty, + "set_tool_log_dirty": _handle_set_tool_log_dirty, + "refresh_from_project": _handle_refresh_from_project, + "show_patch_modal": _handle_show_patch_modal, + "hide_patch_modal": _handle_hide_patch_modal, + } self._init_actions() @property @@ -506,9 +1103,8 @@ class AppController: def _update_inject_preview(self) -> None: """ - - Updates the preview content based on the selected file and injection mode. - [C: src/gui_2.py:App._gui_func, tests/test_skeleton_injection.py:test_update_inject_preview_full, tests/test_skeleton_injection.py:test_update_inject_preview_skeleton, tests/test_skeleton_injection.py:test_update_inject_preview_truncation] + Updates the preview content based on the selected file and injection mode. + [C: src/gui_2.py:App._gui_func, tests/test_skeleton_injection.py:test_update_inject_preview_full, tests/test_skeleton_injection.py:test_update_inject_preview_skeleton, tests/test_skeleton_injection.py:test_update_inject_preview_truncation] """ if not self._inject_file_path: self._inject_preview = "" @@ -659,7 +1255,7 @@ class AppController: return self.is_viewing_prior_session def _init_actions(self) -> None: - # Set up state-related action maps + # Set up state-related action maps self._clickable_actions: dict[str, Callable[..., Any]] = { 'btn_reset': self._handle_reset_session, 'btn_gen_send': self._handle_generate_send, @@ -706,15 +1302,13 @@ class AppController: else: ai_client._gemini_cli_adapter.binary_path = str(path) - - def _set_rag_status(self, status: str) -> None: """Thread-safe update of rag_status via the GUI task queue.""" with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ - "action": "set_value", - "item": "rag_status", - "value": status + "action": "set_value", + "item": "rag_status", + "value": status }) def _rebuild_rag_index(self) -> None: @@ -726,7 +1320,7 @@ class AppController: try: self._set_rag_status("indexing...") import concurrent.futures - + # 1. Incremental indexing of current files in parallel with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: futures = [] @@ -734,7 +1328,7 @@ class AppController: path = f.path if hasattr(f, "path") else str(f) futures.append(executor.submit(self.rag_engine.index_file, path)) concurrent.futures.wait(futures) - + # 2. Cleanup stale entries (files no longer tracked) indexed_paths = self.rag_engine.get_all_indexed_paths() current_paths = {f.path if hasattr(f, "path") else str(f) for f in self.files} @@ -754,9 +1348,9 @@ class AppController: self._pending_gui_tasks.append({'action': 'set_tool_log_dirty'}) def _process_pending_gui_tasks(self) -> None: - # Periodic telemetry broadcast """ - [C: src/gui_2.py:App._gui_func, tests/test_api_hook_extensions.py:test_app_processes_new_actions, tests/test_gui_updates.py:test_gui_updates_on_event, tests/test_live_gui_integration_v2.py:test_user_request_error_handling, tests/test_live_gui_integration_v2.py:test_user_request_integration_flow, tests/test_mma_orchestration_gui.py:test_handle_ai_response_fallback, tests/test_mma_orchestration_gui.py:test_handle_ai_response_with_stream_id, tests/test_mma_orchestration_gui.py:test_process_pending_gui_tasks_mma_spawn_approval, tests/test_mma_orchestration_gui.py:test_process_pending_gui_tasks_show_track_proposal, tests/test_process_pending_gui_tasks.py:test_gcli_path_updates_adapter, tests/test_process_pending_gui_tasks.py:test_redundant_calls_in_process_pending_gui_tasks] + Processes pending GUI tasks from the queue on the main render thread. + [C: src/gui_2.py:App._gui_func, tests/test_api_hook_extensions.py:test_app_processes_new_actions, tests/test_gui_updates.py:test_gui_updates_on_event, tests/test_live_gui_integration_v2.py:test_user_request_error_handling, tests/test_live_gui_integration_v2.py:test_user_request_integration_flow, tests/test_mma_orchestration_gui.py:test_handle_ai_response_fallback, tests/test_mma_orchestration_gui.py:test_handle_ai_response_with_stream_id, tests/test_mma_orchestration_gui.py:test_process_pending_gui_tasks_mma_spawn_approval, tests/test_mma_orchestration_gui.py:test_process_pending_gui_tasks_show_track_proposal, tests/test_process_pending_gui_tasks.py:test_gcli_path_updates_adapter, tests/test_process_pending_gui_tasks.py:test_redundant_calls_in_process_pending_gui_tasks] """ now = time.time() if hasattr(self, 'event_queue') and hasattr(self.event_queue, 'websocket_server') and self.event_queue.websocket_server: @@ -772,280 +1366,19 @@ class AppController: self._pending_gui_tasks.clear() for task in tasks: try: - action = task.get("action") + action = task.get("action") or task.get("type") if action: session_logger.log_api_hook("PROCESS_TASK", action, str(task)) - # ... - if action == "refresh_api_metrics": - self._refresh_api_metrics(task.get("payload", {}), md_content=self.last_md or None) - elif action == 'set_comms_dirty': - self._comms_log_dirty = True - elif action == 'set_tool_log_dirty': - self._tool_log_dirty = True - elif action == "set_ai_status": - self._ai_status = task.get("value", task.get("payload", "")) - elif action == "set_mma_status": - self._mma_status = task.get("value", task.get("payload", "")) - elif action == "handle_ai_response": - payload = task.get("payload", {}) - text = payload.get("text", "") - stream_id = payload.get("stream_id") - is_streaming = payload.get("status") == "streaming..." - if stream_id: - if is_streaming: - if stream_id not in self.mma_streams: self.mma_streams[stream_id] = "" - if stream_id not in self._worker_status: self._worker_status[stream_id] = "running" - self.mma_streams[stream_id] += text - if len(self.mma_streams[stream_id]) > self.MAX_STREAM_SIZE: - self.mma_streams[stream_id] = self.mma_streams[stream_id][-self.MAX_STREAM_SIZE:] - else: - self.mma_streams[stream_id] = text - if stream_id in self._worker_status and self._worker_status[stream_id] == "running": - self._worker_status[stream_id] = "completed" - if stream_id == "Tier 1": - if "status" in payload: - self._ai_status = payload["status"] - else: - if is_streaming: - self.ai_response += text - else: - self.ai_response = text - self._ai_status = payload.get("status", "done") - self._trigger_blink = True - if not stream_id: - self._token_stats_dirty = True - if not is_streaming: - self._autofocus_response_tab = True - elif action in ("mma_stream", "mma_stream_append"): - # Some events might have these at top level, some in a 'payload' dict - stream_id = task.get("stream_id") or task.get("payload", {}).get("stream_id") - text = task.get("text") or task.get("payload", {}).get("text", "") - if stream_id: - if stream_id not in self.mma_streams: - self.mma_streams[stream_id] = "" - self.mma_streams[stream_id] += text - elif action == "show_track_proposal": - self.proposed_tracks = task.get("payload", []) - self._show_track_proposal_modal = True - elif action == "mma_state_update": - # Handle both internal (nested) and hook-server (flattened) payloads - p = task.get("payload") - if not isinstance(p, dict): - p = task # Fallback to task itself if payload is missing or wrong type - - track_data = p.get("track") - is_active_track = False - if track_data and self.active_track and track_data.get("id") == self.active_track.id: - is_active_track = True - - if is_active_track or not self.active_track: - self._mma_status = p.get("status", self._mma_status) - - old_tier = self.active_tier - self.active_tier = p.get("active_tier", self.active_tier) - - if getattr(self, "ui_auto_switch_layout", False) and self.active_tier and self.active_tier != old_tier: - for tier_prefix in ["Tier 1", "Tier 2", "Tier 3", "Tier 4"]: - if self.active_tier.startswith(tier_prefix): - bound_profile = getattr(self, "ui_tier_layout_bindings", {}).get(tier_prefix) - if bound_profile: - self._cb_load_workspace_profile(bound_profile) - break - - # Preserve existing model/provider config if not explicitly in payload - new_usage = p.get("tier_usage", {}) - for tier, data in new_usage.items(): - if tier in self.mma_tier_usage: - # Update usage counts but keep selected model/provider if not in update - input_val = data.get("input") - if input_val is not None: - self.mma_tier_usage[tier]["input"] = input_val - - output_val = data.get("output") - if output_val is not None: - self.mma_tier_usage[tier]["output"] = output_val - if "model" in data: self.mma_tier_usage[tier]["model"] = data["model"] - if "provider" in data: self.mma_tier_usage[tier]["provider"] = data["provider"] - else: - self.mma_tier_usage[tier] = data - - if is_active_track or not self.active_track: - self.active_tickets = p.get("tickets", []) - if track_data: - tickets = [] - for t_data in self.active_tickets: - if isinstance(t_data, models.Ticket): - tickets.append(t_data) - else: - # Map 'goal' from Godot format to 'description' if needed - if "goal" in t_data and "description" not in t_data: - t_data["description"] = t_data["goal"] - tickets.append(models.Ticket.from_dict(t_data)) - self.active_track = models.Track( - id=track_data.get("id"), - description=track_data.get("title", ""), - tickets=tickets - ) - elif action == "set_value": - item = task.get("item") - value = task.get("value") - if item in self._settable_fields: - attr_name = self._settable_fields[item] - setattr(self, attr_name, value) - if item == "gcli_path": - self._update_gcli_adapter(str(value)) - elif action == "click": - item = task.get("item") - user_data = task.get("user_data") - if item == "btn_project_new_automated": - self._cb_new_project_automated(user_data) - elif item == "btn_mma_load_track": - self._cb_load_track(str(user_data or "")) - elif item in self._clickable_actions: - func = self._clickable_actions[item] - try: - sig = inspect.signature(func) - if 'user_data' in sig.parameters: - func(user_data=user_data) - else: - func() - except Exception: - func() - elif action == "drag": - src_item = task.get("src_item") - dst_item = task.get("dst_item") - if src_item in self._drag_actions: - func = self._drag_actions[src_item] - func(dst_item=dst_item) - elif action == "right_click": - item = task.get("item") - if item in self._right_clickable_actions: - func = self._right_clickable_actions[item] - func() - elif action == "select_list_item": - item = task.get("listbox", task.get("item")) - value = task.get("item_value", task.get("value")) - if item == "disc_listbox": - self._switch_discussion(str(value or "")) - elif task.get("type") == "ask": - self._pending_ask_dialog = True - self._ask_request_id = task.get("request_id") - self._ask_tool_data = task.get("data", {}) - elif action == "clear_ask": - if self._ask_request_id == task.get("request_id"): - self._pending_ask_dialog = False - self._ask_request_id = None - self._ask_tool_data = None - elif action == "custom_callback": - cb = task.get("callback") - args = task.get("args", []) - if callable(cb): - try: cb(*args) - except Exception as e: print(f"Error in direct custom callback: {e}") - elif cb in self._predefined_callbacks: - self._predefined_callbacks[cb](*args) - elif action == "mma_step_approval": - if self.test_hooks_enabled and not getattr(self, "ui_manual_approve", False): - if "dialog_container" in task: - class AutoStepDialog: - def __init__(self, t): self.t = t - def wait(self): return True, self.t.get("payload", "") - task["dialog_container"][0] = AutoStepDialog(task) - continue - dlg = MMAApprovalDialog(str(task.get("ticket_id") or ""), str(task.get("payload") or "")) - self._pending_mma_approvals.append(task) - if "dialog_container" in task: - task["dialog_container"][0] = dlg - elif action == 'refresh_from_project': - self._refresh_from_project() - elif action == "show_patch_modal": - self._pending_patch_text = task.get("patch_text", "") - self._pending_patch_files = task.get("file_paths", []) - self._show_patch_modal = True - elif action == "hide_patch_modal": - self._show_patch_modal = False - self._pending_patch_text = None - self._pending_patch_files = [] - elif action == "mma_spawn_approval": - if self.test_hooks_enabled and not getattr(self, "ui_manual_approve", False): - if "dialog_container" in task: - class AutoSpawnDialog: - def __init__(self, t): self.t = t - def wait(self): - """ - [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_conductor_engine_abort.py:worker, tests/test_parallel_execution.py:test_worker_pool_limit] - """ - return {'approved': True, 'abort': False, 'prompt': self.t.get("prompt"), 'context_md': self.t.get("context_md")} - task["dialog_container"][0] = AutoSpawnDialog(task) - continue - spawn_dlg = MMASpawnApprovalDialog( - str(task.get("ticket_id") or ""), - str(task.get("role") or ""), - str(task.get("prompt") or ""), - str(task.get("context_md") or "") - ) - self._pending_mma_spawns.append(task) - self._mma_spawn_prompt = task.get("prompt", "") - self._mma_spawn_context = task.get("context_md", "") - self._mma_spawn_open = True - self._mma_spawn_edit_mode = False - if "dialog_container" in task: - task["dialog_container"][0] = spawn_dlg - elif action == "ticket_started": - payload = task.get("payload", {}) - ticket_id = payload.get("ticket_id") - start_time = payload.get("timestamp") - persona_id = payload.get("persona_id") - model = payload.get("model") - if ticket_id and start_time: - self._ticket_start_times[ticket_id] = start_time - if ticket_id and (persona_id or model): - stream_id = f"Tier 3 (Worker): {ticket_id}" - meta_info = f"[STARTED] Ticket: {ticket_id}" - if model: - meta_info += f" | Model: {model}" - if persona_id: - meta_info += f" | Persona: {persona_id}" - meta_info += "\n" + "="*50 + "\n" - if stream_id not in self.mma_streams: - self.mma_streams[stream_id] = "" - self.mma_streams[stream_id] = meta_info + self.mma_streams[stream_id] - elif action == "ticket_completed": - payload = task.get("payload", {}) - ticket_id = payload.get("ticket_id") - end_time = payload.get("timestamp") - if ticket_id and end_time and ticket_id in self._ticket_start_times: - start_time = self._ticket_start_times.pop(ticket_id) - elapsed = end_time - start_time - self._completed_ticket_count += 1 - self._avg_ticket_time = ((self._avg_ticket_time * (self._completed_ticket_count - 1)) + elapsed) / self._completed_ticket_count - elif action == "bead_updated": - payload = task.get("payload", {}) - bid = payload.get("bead_id") - status = payload.get("status") - if bid and status: - stream_id = "Tier 2" - msg = f"\n[BEAD UPDATE] {bid} -> status: {status}\n" - if stream_id not in self.mma_streams: - self.mma_streams[stream_id] = "" - self.mma_streams[stream_id] += msg - - elif action == "bead_updated": - payload = task.get("payload", {}) - bead_id = payload.get("bead_id") - status = payload.get("status") - stream_id = "Tier 2 (Tech Lead)" - if stream_id not in self.mma_streams: - self.mma_streams[stream_id] = "" - self.mma_streams[stream_id] += f"[BEAD UPDATE] {bead_id} -> status: {status}\n" + if action in self._gui_task_handlers: + self._gui_task_handlers[action](self, task) except Exception as e: - print(f"Error executing GUI task: {e}") + print(f"Error executing GUI task ({task.get('action') or task.get('type')}): {e}") + traceback.print_exc() def _process_pending_history_adds(self) -> None: """ - - Synchronizes pending history entries to the active discussion and project state. - [C: src/gui_2.py:App._gui_func] + Synchronizes pending history entries to the active discussion and project state. + [C: src/gui_2.py:App._gui_func] """ with self._pending_history_adds_lock: items = self._pending_history_adds[:] @@ -1072,9 +1405,8 @@ class AppController: def _process_pending_tool_calls(self) -> bool: """ - - Drains pending tool calls into the tool log. Returns True if any were processed. - [C: src/gui_2.py:App._gui_func] + Drains pending tool calls into the tool log. Returns True if any were processed. + [C: src/gui_2.py:App._gui_func] """ with self._pending_tool_calls_lock: items = self._pending_tool_calls[:] @@ -1120,9 +1452,8 @@ class AppController: def init_state(self): """ - - Initializes the application state from configurations. - [C: src/gui_2.py:App.__init__, src/gui_2.py:App._render_paths_panel, src/gui_2.py:App._save_paths, tests/test_app_controller_mcp.py:test_app_controller_mcp_loading, tests/test_app_controller_mcp.py:test_app_controller_mcp_project_override, tests/test_external_mcp_e2e.py:test_external_mcp_e2e_refresh_and_call, tests/test_system_prompt_exposure.py:TestSystemPromptExposure.test_app_controller_init_state_loads_prompts] + Initializes the application state from configurations. + [C: src/gui_2.py:App.__init__, src/gui_2.py:App._render_paths_panel, src/gui_2.py:App._save_paths, tests/test_app_controller_mcp.py:test_app_controller_mcp_loading, tests/test_app_controller_mcp.py:test_app_controller_mcp_project_override, tests/test_external_mcp_e2e.py:test_external_mcp_e2e_refresh_and_call, tests/test_system_prompt_exposure.py:TestSystemPromptExposure.test_app_controller_init_state_loads_prompts] """ self.active_tickets = [] self.ui_separate_task_dag = False @@ -1148,7 +1479,7 @@ class AppController: self.project_paths = list(projects_cfg.get("paths", [])) self.active_project_path = projects_cfg.get("active", "") self._load_active_project() - + # Track 5: Path isolation overrides if self.project and 'paths' in self.project: project_root = Path(self.active_project_path).parent if self.active_project_path else Path.cwd() @@ -1164,7 +1495,7 @@ class AppController: spath = project_root / spath os.environ['SLOP_SCRIPTS_DIR'] = str(spath) paths.reset_resolved() - + path_info = paths.get_full_path_info() self.ui_logs_dir = str(path_info['logs_dir']['path']) self.ui_scripts_dir = str(path_info['scripts_dir']['path']) @@ -1191,7 +1522,7 @@ class AppController: disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) with self._disc_entries_lock: self.disc_entries = models.parse_history_entries(disc_data.get("history", []), self.disc_roles) - # UI state + # 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", ".") @@ -1207,7 +1538,7 @@ class AppController: self.ui_base_system_prompt = self.config.get("ai", {}).get("base_system_prompt", "") self.ui_use_default_base_prompt = self.config.get("ai", {}).get("use_default_base_prompt", True) self.ui_project_context_marker = proj_meta.get("context_marker", "") - + self.preset_manager = presets.PresetManager(Path(self.active_project_path).parent if self.active_project_path else None) self.presets = self.preset_manager.load_all() self.tool_preset_manager = tool_presets.ToolPresetManager(Path(self.active_project_path).parent if self.active_project_path else None) @@ -1253,7 +1584,7 @@ class AppController: ai_client.set_bias_profile(self.ui_active_bias_profile) self.ui_global_preset_name = ai_cfg.get("active_preset") self.ui_project_preset_name = proj_meta.get("active_preset") - + gui_cfg = self.config.get("gui", {}) self.ui_separate_message_panel = gui_cfg.get('separate_message_panel', False) self.ui_separate_response_panel = gui_cfg.get('separate_response_panel', False) @@ -1262,7 +1593,7 @@ class AppController: self.ui_tier_layout_bindings = gui_cfg.get("tier_layout_bindings", {"Tier 1": "", "Tier 2": "", "Tier 3": "", "Tier 4": ""}) from src import bg_shader bg_shader.get_bg().enabled = gui_cfg.get("bg_shader_enabled", False) - + _default_windows = { "Project Settings": True, "Files & Media": True, @@ -1297,7 +1628,7 @@ class AppController: async def refresh_external_mcps(self): """ - [C: tests/test_external_mcp_e2e.py:test_external_mcp_e2e_refresh_and_call] + [C: tests/test_external_mcp_e2e.py:test_external_mcp_e2e_refresh_and_call] """ await mcp_client.get_external_mcp_manager().stop_all() # Start servers with auto_start=True @@ -1307,11 +1638,11 @@ class AppController: def cb_load_prior_log(self, path: Optional[str] = None) -> None: """ - [C: src/gui_2.py:App._render_log_management] + [C: src/gui_2.py:App._render_log_management] """ if not path: return - + if not self.is_viewing_prior_session: self._current_session_usage = copy.deepcopy(self.session_usage) self._current_mma_tier_usage = copy.deepcopy(self.mma_tier_usage) @@ -1325,7 +1656,7 @@ class AppController: else: log_file = log_path session_dir = log_path.parent - + if not log_file.exists(): self.ai_status = f"log file not found: {log_file}" return @@ -1413,24 +1744,24 @@ class AppController: 'output': u.get('output_tokens', 0) or 0, 'model': entry.get('model', 'unknown') }) - + if kind == "history_add": content = payload.get("content", payload.get("text", payload.get("message", ""))) content = _resolve_log_ref(content, session_dir) disc_entries.append({ - "role": payload.get("role", "AI"), - "content": content, - "collapsed": payload.get("collapsed", False), - "ts": ts + "role": payload.get("role", "AI"), + "content": content, + "collapsed": payload.get("collapsed", False), + "ts": ts }) elif kind == "request": content = payload.get("message", payload.get("content", payload.get("text", ""))) content = _resolve_log_ref(content, session_dir) disc_entries.append({ - "role": "User", - "content": content, - "collapsed": False, - "ts": ts + "role": "User", + "content": content, + "collapsed": False, + "ts": ts }) elif kind == "response": text = payload.get("text", payload.get("content", payload.get("message", ""))) @@ -1450,26 +1781,26 @@ class AppController: else: content = "[TOOL CALLS PRESENT]" disc_entries.append({ - "role": "AI", - "content": content, - "collapsed": False, - "ts": ts + "role": "AI", + "content": content, + "collapsed": False, + "ts": ts }) elif kind == "tool_result": output = payload.get("output", payload.get("content", "")) output = _resolve_log_ref(output, session_dir) disc_entries.append({ - "role": "Tool", - "content": f"[TOOL RESULT]\n{output}", - "collapsed": True, - "ts": ts + "role": "Tool", + "content": f"[TOOL RESULT]\n{output}", + "collapsed": True, + "ts": ts }) except json.JSONDecodeError: continue except Exception as e: self.ai_status = f"log load error: {e}" return - + self.session_usage = new_usage self.mma_tier_usage = new_mma_usage self._token_history = new_token_history @@ -1491,7 +1822,7 @@ class AppController: def cb_exit_prior_session(self): """ - [C: src/gui_2.py:App._render_comms_history_panel, src/gui_2.py:App._render_discussion_panel] + [C: src/gui_2.py:App._render_comms_history_panel, src/gui_2.py:App._render_discussion_panel] """ self.is_viewing_prior_session = False if self._current_session_usage: @@ -1517,7 +1848,7 @@ class AppController: def cb_prune_logs(self) -> None: """Manually triggers the log pruning process with aggressive thresholds.""" self.ai_status = "Manual prune started (Age > 0d, Size < 100KB)..." - + def run_manual_prune() -> None: try: from src import log_registry @@ -1531,7 +1862,7 @@ class AppController: except Exception as e: self.ai_status = f"Manual prune error: {e}" print(f"Error during manual log pruning: {e}") - + thread = threading.Thread(target=run_manual_prune, daemon=True) thread.start() @@ -1585,7 +1916,7 @@ class AppController: def _fetch_models(self, provider: str) -> None: """ - [C: src/gui_2.py:App.run] + [C: src/gui_2.py:App.run] """ self.ai_status = "fetching models..." @@ -1596,7 +1927,7 @@ class AppController: self.all_available_models[p] = ai_client.list_models(p) except Exception as e: self.all_available_models[p] = [] - + models_list = self.all_available_models.get(provider, []) self.available_models = models_list if self.current_model not in models_list and models_list: @@ -1610,9 +1941,9 @@ 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) @@ -1621,9 +1952,9 @@ class AppController: def shutdown(self) -> None: """ - - Stops background threads and cleans up resources. - [C: src/gui_2.py:App.run, src/gui_2.py:App.shutdown, tests/conftest.py:app_instance, tests/conftest.py:mock_app] + + Stops background threads and cleans up resources. + [C: src/gui_2.py:App.run, src/gui_2.py:App.shutdown, tests/conftest.py:app_instance, tests/conftest.py:mock_app] """ from src import ai_client ai_client.cleanup() @@ -1684,14 +2015,14 @@ class AppController: elif event_name == "mma_state_update": with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ - "action": "mma_state_update", - "payload": payload + "action": "mma_state_update", + "payload": payload }) elif event_name == "mma_stream": with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ - "action": "mma_stream_append", - "payload": payload + "action": "mma_stream_append", + "payload": payload }) elif event_name in ("mma_spawn_approval", "mma_step_approval"): with self._pending_gui_tasks_lock: @@ -1700,8 +2031,8 @@ class AppController: elif event_name == "response": with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ - "action": "handle_ai_response", - "payload": payload + "action": "handle_ai_response", + "payload": payload }) if self.test_hooks_enabled: with self._api_event_queue_lock: @@ -1709,14 +2040,14 @@ class AppController: elif event_name == "ticket_started": with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ - "action": "ticket_started", - "payload": payload + "action": "ticket_started", + "payload": payload }) elif event_name == "ticket_completed": with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ - "action": "ticket_completed", - "payload": payload + "action": "ticket_completed", + "payload": payload }) elif event_name == "refresh_external_mcps": import asyncio @@ -1724,9 +2055,9 @@ class AppController: def _handle_request_event(self, event: events.UserRequestEvent) -> None: """ - - Processes a UserRequestEvent by calling the AI client. - [C: tests/test_live_gui_integration_v2.py:test_user_request_error_handling, tests/test_live_gui_integration_v2.py:test_user_request_integration_flow, tests/test_rag_integration.py:test_rag_integration] + + Processes a UserRequestEvent by calling the AI client. + [C: tests/test_live_gui_integration_v2.py:test_user_request_error_handling, tests/test_live_gui_integration_v2.py:test_user_request_integration_flow, tests/test_rag_integration.py:test_rag_integration] """ self.ai_status = 'sending...' ai_client.set_current_tier(None) # Ensure main discussion is untagged @@ -1786,14 +2117,14 @@ class AppController: def _on_comms_entry(self, entry: Dict[str, Any]) -> None: """ - [C: tests/test_app_controller_offloading.py:test_on_comms_entry_tool_result_offloading] + [C: tests/test_app_controller_offloading.py:test_on_comms_entry_tool_result_offloading] """ optimized_entry = self._offload_entry_payload(entry) session_logger.log_comms(optimized_entry) entry["local_ts"] = time.time() kind = entry.get("kind") payload = entry.get("payload", {}) - + if kind == "response" and "usage" in payload: u = payload["usage"] inp = u.get("input_tokens") or u.get("prompt_tokens") or 0 @@ -1846,7 +2177,7 @@ class AppController: } if segments: entry_obj["thinking_segments"] = [{"content": s.content, "marker": s.marker} for s in segments] - + if entry_obj["content"] or segments: with self._pending_history_adds_lock: self._pending_history_adds.append(entry_obj) @@ -1863,19 +2194,19 @@ class AppController: content = json.dumps(content, indent=1) with self._pending_history_adds_lock: self._pending_history_adds.append({ - "role": role, - "content": f"[{kind.upper().replace('_', ' ')}]\n{content}", - "collapsed": True, - "ts": entry.get("ts", project_manager.now_ts()) + "role": role, + "content": f"[{kind.upper().replace('_', ' ')}]\n{content}", + "collapsed": True, + "ts": entry.get("ts", project_manager.now_ts()) }) if kind == "history_add": payload = entry.get("payload", {}) with self._pending_history_adds_lock: self._pending_history_adds.append({ - "role": payload.get("role", "AI"), - "content": payload.get("content", ""), - "collapsed": payload.get("collapsed", False), - "ts": entry.get("ts", project_manager.now_ts()) + "role": payload.get("role", "AI"), + "content": payload.get("content", ""), + "collapsed": payload.get("collapsed", False), + "ts": entry.get("ts", project_manager.now_ts()) }) return with self._pending_comms_lock: @@ -1883,7 +2214,7 @@ class AppController: def _on_tool_log(self, script: str, result: str) -> None: """ - [C: tests/test_app_controller_offloading.py:test_on_tool_log_offloading] + [C: tests/test_app_controller_offloading.py:test_on_tool_log_offloading] """ session_logger.log_tool_call(script, result, None) session_logger.log_tool_output(result) @@ -1893,7 +2224,7 @@ class AppController: def _on_api_event(self, event_name: str = "generic_event", **kwargs: Any) -> None: """ - [C: tests/test_gui_updates.py:test_gui_updates_on_event] + [C: tests/test_gui_updates.py:test_gui_updates_on_event] """ payload = kwargs.get("payload", {}) # Push to background event queue, NOT GUI queue @@ -1904,14 +2235,14 @@ class AppController: def _on_performance_alert(self, message: str) -> None: self.diagnostic_log.append({ - "ts": project_manager.now_ts(), - "message": message, - "type": "performance" + "ts": project_manager.now_ts(), + "message": message, + "type": "performance" }) def _confirm_and_run(self, script: str, base_dir: str, qa_callback: Optional[Callable[[str], str]] = None, patch_callback: Optional[Callable[[str, str], Optional[str]]] = None) -> Optional[str]: """ - [C: tests/test_arch_boundary_phase2.py:TestArchBoundaryPhase2.test_mutating_tool_triggers_callback, tests/test_arch_boundary_phase2.py:TestArchBoundaryPhase2.test_rejection_prevents_dispatch] + [C: tests/test_arch_boundary_phase2.py:TestArchBoundaryPhase2.test_mutating_tool_triggers_callback, tests/test_arch_boundary_phase2.py:TestArchBoundaryPhase2.test_rejection_prevents_dispatch] """ if self.test_hooks_enabled and not getattr(self, "ui_manual_approve", False): self.ai_status = "running powershell..." @@ -1929,11 +2260,11 @@ class AppController: if self.test_hooks_enabled and hasattr(self, '_api_event_queue'): with self._api_event_queue_lock: self._api_event_queue.append({ - "type": "script_confirmation_required", - "action_id": dialog._uid, - "script": str(script), - "base_dir": str(base_dir), - "ts": time.time() + "type": "script_confirmation_required", + "action_id": dialog._uid, + "script": str(script), + "base_dir": str(base_dir), + "ts": time.time() }) approved, final_script = dialog.wait() if is_headless: @@ -1951,7 +2282,7 @@ class AppController: def _append_tool_log(self, script: str, result: str, source_tier: str | None = None, elapsed_ms: float = 0.0) -> None: """ - [C: tests/test_mma_agent_focus_phase1.py:test_append_tool_log_dict_has_source_tier, tests/test_mma_agent_focus_phase1.py:test_append_tool_log_dict_keys, tests/test_mma_agent_focus_phase1.py:test_append_tool_log_stores_dict] + [C: tests/test_mma_agent_focus_phase1.py:test_append_tool_log_dict_has_source_tier, tests/test_mma_agent_focus_phase1.py:test_append_tool_log_dict_keys, tests/test_mma_agent_focus_phase1.py:test_append_tool_log_stores_dict] """ self._tool_log.append({"script": script, "result": result, "ts": time.time(), "source_tier": source_tier}) tool_name = self._extract_tool_name(script) @@ -2049,264 +2380,71 @@ class AppController: def create_api(self) -> FastAPI: """ - - Creates and configures the FastAPI application for headless mode. - [C: src/gui_2.py:App.run, tests/test_headless_service.py:TestHeadlessAPI.setUp] + Creates and configures the FastAPI application for headless mode. + [SDM: src/app_controller.py:AppController.create_api] """ api = FastAPI(title="Manual Slop Headless API") API_KEY_NAME = "X-API-KEY" api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) - async def get_api_key(header_key: str = Depends(api_key_header)) -> str: - """Validates the API key from the request header against configuration.""" - headless_cfg = self.config.get("headless", {}) - config_key = headless_cfg.get("api_key", "").strip() - env_key = os.environ.get("SLOP_API_KEY", "").strip() - target_key = env_key or config_key - if not target_key: - raise HTTPException(status_code=403, detail="API Key not configured on server") - if header_key == target_key: - return header_key - raise HTTPException(status_code=403, detail="Could not validate API Key") - + return await _api_get_key(self, header_key) @api.get("/health") def health() -> dict[str, str]: - """Returns the health status of the API.""" - return {"status": "ok"} - + return _api_health(self) @api.get("/api/gui/state", dependencies=[Depends(get_api_key)]) def get_gui_state() -> dict[str, Any]: - """ - - Returns the current GUI state for specific fields. - [C: tests/test_ai_settings_layout.py:test_change_provider_via_hook, tests/test_ai_settings_layout.py:test_set_params_via_custom_callback, tests/test_conductor_api_hook_integration.py:simulate_conductor_phase_completion, tests/test_external_editor_gui.py:test_button_click_is_received, tests/test_external_editor_gui.py:test_patch_modal_shows_with_configured_editor, tests/test_external_editor_gui.py:test_vscode_launches_with_diff_view, tests/test_gui_text_viewer.py:test_text_viewer_state_update, tests/test_hooks.py:test_live_hook_server_responses, tests/test_live_gui_integration_v2.py:test_api_gui_state_live, tests/test_live_workflow.py:test_full_live_workflow, tests/test_live_workflow.py:wait_for_value, tests/test_patch_modal_gui.py:test_patch_apply_modal_workflow, tests/test_patch_modal_gui.py:test_patch_modal_appears_on_trigger, tests/test_rag_phase4_final_verify.py:test_phase4_final_verify, tests/test_rag_phase4_stress.py:test_rag_large_codebase_verification_sim, tests/test_saved_presets_sim.py:test_preset_manager_modal, tests/test_saved_presets_sim.py:test_preset_switching, tests/test_task_dag_popout_sim.py:test_task_dag_popout, tests/test_tool_management_layout.py:test_tool_management_gettable_fields, tests/test_tool_management_layout.py:test_tool_management_state_updates, tests/test_tool_presets_sim.py:test_tool_preset_switching, tests/test_usage_analytics_popout_sim.py:test_usage_analytics_popout] - """ - gettable = getattr(self, "_gettable_fields", {}) - state = {} - import dataclasses - for key, attr in gettable.items(): - val = getattr(self, attr, None) - if dataclasses.is_dataclass(val): - state[key] = dataclasses.asdict(val) - else: - state[key] = val - return state - + return _api_get_gui_state(self) @api.get("/api/gui/mma_status", dependencies=[Depends(get_api_key)]) def get_mma_status() -> dict[str, Any]: - """ - - Dedicated endpoint for MMA-related status. - [C: tests/test_headless_simulation.py:test_mma_track_lifecycle_simulation, tests/test_live_workflow.py:test_full_live_workflow, tests/test_mma_concurrent_tracks_sim.py:_poll_mma_status, tests/test_mma_concurrent_tracks_sim.py:test_mma_concurrent_tracks_execution, tests/test_mma_concurrent_tracks_stress_sim.py:test_mma_concurrent_tracks_stress, tests/test_mma_step_mode_sim.py:_poll_mma_status, tests/test_mma_step_mode_sim.py:test_mma_step_mode_approval_flow, tests/test_visual_orchestration.py:test_mma_epic_lifecycle, tests/test_visual_sim_gui_ux.py:test_gui_ux_event_routing, tests/test_visual_sim_mma_v2.py:_poll] - """ - return { - "mma_status": self.mma_status, - "ai_status": self.ai_status, - "mma_streams": self.mma_streams, - "worker_status": self._worker_status, - "tool_stats": self._tool_stats, - "active_tier": self.active_tier, - "active_tickets": self.active_tickets, - "proposed_tracks": self.proposed_tracks, - "tracks": self.tracks, - "tier_usage": self.mma_tier_usage - } - + return _api_get_mma_status(self) @api.post("/api/gui", dependencies=[Depends(get_api_key)]) def post_gui(req: dict) -> dict[str, str]: - """ - - Pushes a GUI task to the event queue. - [C: tests/test_ai_settings_layout.py:test_set_params_via_custom_callback, tests/test_api_hook_client.py:test_post_gui_success, tests/test_gui2_parity.py:test_gui2_custom_callback_hook_works, tests/test_gui2_parity.py:test_gui2_set_value_hook_works, tests/test_visual_mma.py:test_visual_mma_components] - """ - self.event_queue.put("gui_task", req) - return {"status": "queued"} - + return _api_post_gui(self, req) @api.get("/api/session", dependencies=[Depends(get_api_key)]) def get_api_session() -> dict[str, Any]: - """Returns current discussion session entries.""" - with self._disc_entries_lock: - return {"session": {"entries": self.disc_entries}} - + return _api_get_api_session(self) @api.post("/api/session", dependencies=[Depends(get_api_key)]) def post_api_session(req: dict) -> dict[str, str]: - """Updates session entries.""" - entries = req.get("entries", []) - with self._disc_entries_lock: - self.disc_entries = entries - return {"status": "updated"} - + return _api_post_api_session(self, req) @api.get("/api/project", dependencies=[Depends(get_api_key)]) def get_api_project() -> dict[str, Any]: - """Returns current project data.""" - return {"project": self.project} - + return _api_get_api_project(self) @api.get("/api/performance", dependencies=[Depends(get_api_key)]) def get_performance() -> dict[str, Any]: - """ - - Returns performance monitor metrics. - [C: tests/test_gui2_performance.py:test_performance_benchmarking, tests/test_gui_performance_requirements.py:test_idle_performance_requirements, tests/test_gui_stress_performance.py:test_comms_volume_stress_performance, tests/test_selectable_ui.py:test_selectable_label_stability, tests/test_visual_sim_gui_ux.py:test_gui_ux_event_routing] - """ - return {"performance": self.perf_monitor.get_metrics()} - + return _api_get_performance(self) @api.get("/api/gui/diagnostics", dependencies=[Depends(get_api_key)]) def get_diagnostics() -> dict[str, Any]: - """Alias for performance metrics.""" - return self.perf_monitor.get_metrics() - + return _api_get_diagnostics(self) @api.get("/status", dependencies=[Depends(get_api_key)]) def status() -> dict[str, Any]: - """Returns the current status of the application.""" - return { - "provider": self.current_provider, - "model": self.current_model, - "status": self.ai_status, - "usage": self.session_usage - } - + return _api_status(self) @api.post("/api/v1/generate", dependencies=[Depends(get_api_key)]) def generate(req: GenerateRequest) -> dict[str, Any]: - """Triggers an AI generation request using the current project context.""" - if not req.prompt.strip(): - raise HTTPException(status_code=400, detail="Prompt cannot be empty") - with self._send_thread_lock: - start_time = time.time() - try: - md, path, file_items, stable_md, disc_text = self._do_generate() - self._last_stable_md = stable_md - self.last_md = md - self.last_md_path = path - self.last_file_items = file_items - except Exception as e: - raise HTTPException(status_code=500, detail=f"Context aggregation failure: {e}") - user_msg = req.prompt - base_dir = self.active_project_root - csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()]) - ai_client.set_custom_system_prompt("\n\n".join(csp)) - ai_client.set_base_system_prompt(self.ui_base_system_prompt) - ai_client.set_use_default_base_prompt(self.ui_use_default_base_prompt) - ai_client.set_project_context_marker(self.ui_project_context_marker) - temp = req.temperature if req.temperature is not None else self.temperature - top_p = req.top_p if req.top_p is not None else self.top_p - tokens = req.max_tokens if req.max_tokens is not None else self.max_tokens - ai_client.set_model_params(temp, tokens, self.history_trunc_limit, top_p) - ai_client.set_agent_tools(self.ui_agent_tools) - if req.auto_add_history: - with self._pending_history_adds_lock: - self._pending_history_adds.append({ - "role": "User", - "content": user_msg, - "collapsed": True, - "ts": project_manager.now_ts() - }) - try: - resp = ai_client.send(stable_md, user_msg, base_dir, self.last_file_items, disc_text, rag_engine=self.rag_engine) - if req.auto_add_history: - with self._pending_history_adds_lock: - self._pending_history_adds.append({ - "role": "AI", - "content": resp, - "collapsed": True, - "ts": project_manager.now_ts() - }) - self._recalculate_session_usage() - duration = time.time() - start_time - return { - "text": resp, - "metadata": { - "provider": self.current_provider, - "model": self.current_model, - "duration_sec": round(duration, 3), - "timestamp": project_manager.now_ts() - }, - "usage": self.session_usage - } - except ai_client.ProviderError as e: - raise HTTPException(status_code=502, detail=f"AI Provider Error: {e.ui_message()}") - except Exception as e: - raise HTTPException(status_code=500, detail=f"In-flight AI request failure: {e}") - + return _api_generate(self, req) @api.post("/api/v1/stream", dependencies=[Depends(get_api_key)]) async def stream(req: GenerateRequest) -> Any: - """Placeholder for streaming AI generation responses (Not yet implemented).""" - raise HTTPException(status_code=501, detail="Streaming endpoint (/api/v1/stream) is not yet supported in this version.") - + return await _api_stream(self, req) @api.get("/api/v1/pending_actions", dependencies=[Depends(get_api_key)]) def pending_actions() -> list[dict[str, Any]]: - """Lists all pending PowerShell scripts awaiting confirmation.""" - with self._pending_dialog_lock: - return [ - {"action_id": uid, "script": diag._script, "base_dir": diag._base_dir} - for uid, diag in self._pending_actions.items() - ] - + return _api_pending_actions(self) @api.post("/api/v1/confirm/{action_id}", dependencies=[Depends(get_api_key)]) def confirm_action(action_id: str, req: ConfirmRequest) -> dict[str, str]: - """Approves or rejects a pending action.""" - with self._pending_dialog_lock: - if action_id not in self._pending_actions: - raise HTTPException(status_code=404, detail="Action not found") - dialog = self._pending_actions.pop(action_id) - if req.script is not None: - dialog._script = req.script - with dialog._condition: - dialog._approved = req.approved - dialog._done = True - dialog._condition.notify_all() - return {"status": "confirmed" if req.approved else "rejected"} - + return _api_confirm_action(self, action_id, req) @api.get("/api/v1/sessions", dependencies=[Depends(get_api_key)]) def list_sessions() -> list[str]: - """Lists all session IDs.""" - log_dir = paths.get_logs_dir() - if not log_dir.exists(): - return [] - return [d.name for d in log_dir.iterdir() if d.is_dir()] - + return _api_list_sessions(self) @api.get("/api/v1/sessions/{session_id}", dependencies=[Depends(get_api_key)]) def get_session(session_id: str) -> dict[str, Any]: - """ - - Returns the content of the comms.log for a specific session. - [C: simulation/ping_pong.py:main, simulation/sim_context.py:ContextSimulation.run, simulation/sim_execution.py:ExecutionSimulation.run, simulation/sim_tools.py:ToolsSimulation.run, simulation/workflow_sim.py:WorkflowSimulator.run_discussion_turn_async, simulation/workflow_sim.py:WorkflowSimulator.wait_for_ai_response, tests/test_api_hook_client.py:test_get_session_success, tests/test_gui_stress_performance.py:test_comms_volume_stress_performance, tests/test_live_workflow.py:test_full_live_workflow, tests/test_rag_phase4_final_verify.py:test_phase4_final_verify, tests/test_rag_phase4_stress.py:test_rag_large_codebase_verification_sim] - """ - log_path = paths.get_logs_dir() / session_id / "comms.log" - if not log_path.exists(): - raise HTTPException(status_code=404, detail="Session log not found") - return {"id": session_id, "content": log_path.read_text(encoding="utf-8", errors="replace")} - + return _api_get_session(self, session_id) @api.delete("/api/v1/sessions/{session_id}", dependencies=[Depends(get_api_key)]) def delete_session(session_id: str) -> dict[str, str]: - """Deletes a specific session directory.""" - log_path = paths.get_logs_dir() / session_id - if not log_path.exists() or not log_path.is_dir(): - raise HTTPException(status_code=404, detail="Session directory not found") - import shutil - shutil.rmtree(log_path) - return {"status": "deleted"} - + return _api_delete_session(self, session_id) @api.get("/api/v1/context", dependencies=[Depends(get_api_key)]) def get_context() -> dict[str, Any]: - """Returns the current aggregated project context.""" - try: - md, path, file_items, stable_md, disc_text = self._do_generate() - # Pull current screenshots if available in project - screenshots = self.project.get("screenshots", {}).get("paths", []) - return { - "files": [f.get("path") if isinstance(f, dict) else str(f) for f in file_items], - "screenshots": screenshots, - "files_base_dir": self.active_project_root, - "markdown": md, - "discussion": disc_text - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"Context aggregation failure: {e}") - + return _api_get_context(self) @api.get("/api/v1/token_stats", dependencies=[Depends(get_api_key)]) def token_stats() -> dict[str, Any]: - """Returns current token usage and budget statistics.""" - return self._token_stats + return _api_token_stats(self) return api def _cb_new_project_automated(self, user_data: Any) -> None: @@ -2326,14 +2464,14 @@ class AppController: def _cb_reset_base_prompt(self, user_data=None) -> None: """ - [C: src/gui_2.py:App._render_system_prompts_panel] + [C: src/gui_2.py:App._render_system_prompts_panel] """ self.ui_base_system_prompt = ai_client._SYSTEM_PROMPT self.ui_use_default_base_prompt = False def _cb_clear_summary_cache(self, user_data=None) -> None: """ - [C: src/gui_2.py:App._render_files_panel] + [C: src/gui_2.py:App._render_files_panel] """ from src import summarize summarize._summary_cache.clear() @@ -2345,7 +2483,7 @@ class AppController: def _cb_show_base_prompt_diff(self, user_data=None) -> None: """ - [C: src/gui_2.py:App._render_system_prompts_panel] + [C: src/gui_2.py:App._render_system_prompts_panel] """ self._show_base_prompt_diff_modal = True @@ -2364,7 +2502,7 @@ class AppController: def _switch_project(self, path: str) -> None: """ - [C: src/gui_2.py:App._render_projects_panel] + [C: src/gui_2.py:App._render_projects_panel] """ if path == self.active_project_path: return @@ -2391,7 +2529,7 @@ class AppController: def _refresh_from_project(self) -> None: # Deserialize FileItems in files.paths """ - [C: tests/test_mma_dashboard_refresh.py:test_mma_dashboard_initialization_refresh, tests/test_mma_dashboard_refresh.py:test_mma_dashboard_refresh] + [C: tests/test_mma_dashboard_refresh.py:test_mma_dashboard_initialization_refresh, tests/test_mma_dashboard_refresh.py:test_mma_dashboard_refresh] """ raw_paths = self.project.get("files", {}).get("paths", []) self.files = [] @@ -2402,7 +2540,6 @@ class AppController: self.files.append(models.FileItem.from_dict(p)) else: self.files.append(models.FileItem(path=str(p))) - self.screenshots = list(self.project.get("screenshots", {}).get("paths", [])) disc_sec = self.project.get("discussion", {}) self.disc_roles = list(disc_sec.get("roles", ["User", "AI", "Vendor API", "System"])) @@ -2454,31 +2591,28 @@ class AppController: else: self.active_track = None self.active_tickets = [] - # Load track-scoped history if track is active + # Load track-scoped history if track is active if self.active_track: track_history = project_manager.load_track_history(self.active_track.id, self.active_project_root) if track_history: with self._disc_entries_lock: self.disc_entries = models.parse_history_entries(track_history, self.disc_roles) - self.preset_manager.project_root = Path(self.active_project_root) self.presets = self.preset_manager.load_all() self.tool_preset_manager.project_root = Path(self.active_project_root) self.tool_presets = self.tool_preset_manager.load_all_presets() self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles() - raw_presets = proj.get("view_presets", []) if isinstance(raw_presets, dict): self.view_presets = [models.NamedViewPreset.from_dict({"name": name, **data}) for name, data in raw_presets.items()] else: self.view_presets = [models.NamedViewPreset.from_dict(p) for p in raw_presets if isinstance(p, dict)] - 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] + [C: src/gui_2.py:App._render_save_workspace_profile_modal] """ if not hasattr(self, '_app') or not self._app: return @@ -2489,7 +2623,7 @@ class AppController: def _cb_delete_workspace_profile(self, name: str, scope: str = 'project') -> None: """ - [C: src/gui_2.py:App._show_menus] + [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() @@ -2498,7 +2632,7 @@ class AppController: def _cb_load_workspace_profile(self, name: str) -> None: """ - [C: src/gui_2.py:App._show_menus] + [C: src/gui_2.py:App._show_menus] """ if name in self.workspace_profiles: profile = self.workspace_profiles[name] @@ -2507,7 +2641,7 @@ class AppController: def _apply_preset(self, name: str, scope: str) -> None: """ - [C: src/gui_2.py:App._render_system_prompts_panel] + [C: src/gui_2.py:App._render_system_prompts_panel] """ if name == "None": if scope == "global": @@ -2527,7 +2661,7 @@ class AppController: def _cb_save_preset(self, name, content, scope): """ - [C: src/gui_2.py:App._render_preset_manager_content] + [C: src/gui_2.py:App._render_preset_manager_content] """ if not name or not name.strip(): raise ValueError("Preset name cannot be empty or whitespace.") @@ -2540,14 +2674,14 @@ class AppController: def _cb_delete_preset(self, name, scope): """ - [C: src/gui_2.py:App._render_preset_manager_content] + [C: src/gui_2.py:App._render_preset_manager_content] """ self.preset_manager.delete_preset(name, scope) self.presets = self.preset_manager.load_all() def _cb_save_tool_preset(self, name, categories, scope): """ - [C: src/gui_2.py:App._render_tool_preset_manager_content] + [C: src/gui_2.py:App._render_tool_preset_manager_content] """ preset = models.ToolPreset(name=name, categories=categories) self.tool_preset_manager.save_preset(preset, scope) @@ -2555,14 +2689,14 @@ class AppController: def _cb_delete_tool_preset(self, name, scope): """ - [C: src/gui_2.py:App._render_tool_preset_manager_content] + [C: src/gui_2.py:App._render_tool_preset_manager_content] """ self.tool_preset_manager.delete_preset(name, scope) self.tool_presets = self.tool_preset_manager.load_all_presets() def _cb_save_bias_profile(self, profile: models.BiasProfile, scope: str = "project"): """ - [C: src/gui_2.py:App._render_tool_preset_manager_content] + [C: src/gui_2.py:App._render_tool_preset_manager_content] """ self.tool_preset_manager.save_bias_profile(profile, scope) self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles() @@ -2573,21 +2707,21 @@ class AppController: def _cb_save_persona(self, persona: models.Persona, scope: str = "project") -> None: """ - [C: src/gui_2.py:App._render_persona_editor_window] + [C: src/gui_2.py:App._render_persona_editor_window] """ self.persona_manager.save_persona(persona, scope) self.personas = self.persona_manager.load_all() def _cb_delete_persona(self, name: str, scope: str = "project") -> None: """ - [C: src/gui_2.py:App._render_persona_editor_window] + [C: src/gui_2.py:App._render_persona_editor_window] """ self.persona_manager.delete_persona(name, scope) self.personas = self.persona_manager.load_all() def _cb_save_view_preset(self, name: str, f_item: models.FileItem) -> None: """ - [C: src/gui_2.py:App._render_files_panel] + [C: src/gui_2.py:App._render_files_panel] """ preset = models.NamedViewPreset( name=name, @@ -2605,7 +2739,7 @@ class AppController: def _cb_apply_view_preset(self, name: str, f_item: models.FileItem) -> None: """ - [C: src/gui_2.py:App._render_files_panel] + [C: src/gui_2.py:App._render_files_panel] """ preset = next((vp for vp in self.view_presets if vp.name == name), None) if preset: @@ -2615,7 +2749,7 @@ class AppController: def _cb_delete_view_preset(self, name: str) -> None: """ - [C: src/gui_2.py:App._render_files_panel] + [C: src/gui_2.py:App._render_files_panel] """ self.view_presets = [vp for vp in self.view_presets if vp.name != name] self._flush_to_project() @@ -2623,7 +2757,7 @@ class AppController: def _cb_load_track(self, track_id: str) -> None: """ - [C: src/gui_2.py:App._render_mma_dashboard] + [C: src/gui_2.py:App._render_mma_dashboard] """ state = project_manager.load_track_state(track_id, self.active_project_root) if state: @@ -2657,7 +2791,7 @@ class AppController: def _save_active_project(self) -> None: """ - [C: src/gui_2.py:App.delete_context_preset, src/gui_2.py:App.save_context_preset] + [C: src/gui_2.py:App.delete_context_preset, src/gui_2.py:App.save_context_preset] """ if self.active_project_path: try: @@ -2668,7 +2802,7 @@ class AppController: def _get_discussion_names(self) -> list[str]: """ - [C: src/gui_2.py:App._render_discussion_panel] + [C: src/gui_2.py:App._render_discussion_panel] """ disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) @@ -2676,7 +2810,7 @@ class AppController: def _switch_discussion(self, name: str) -> None: """ - [C: src/gui_2.py:App._render_discussion_panel, src/gui_2.py:App._render_takes_panel] + [C: src/gui_2.py:App._render_discussion_panel, src/gui_2.py:App._render_takes_panel] """ self._flush_disc_entries_to_project() disc_sec = self.project.get("discussion", {}) @@ -2697,7 +2831,7 @@ class AppController: def _flush_disc_entries_to_project(self) -> None: """ - [C: src/gui_2.py:App._render_discussion_panel] + [C: src/gui_2.py:App._render_discussion_panel] """ history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries] if self.active_track and self._track_discussion_active: @@ -2712,7 +2846,7 @@ class AppController: def _create_discussion(self, name: str) -> None: """ - [C: src/gui_2.py:App._render_discussion_panel, src/gui_2.py:App._render_synthesis_panel, src/gui_2.py:App._render_takes_panel] + [C: src/gui_2.py:App._render_discussion_panel, 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", {}) @@ -2726,7 +2860,7 @@ class AppController: def _branch_discussion(self, index: int) -> None: """ - [C: src/gui_2.py:App._render_discussion_panel] + [C: src/gui_2.py:App._render_discussion_panel] """ self._flush_disc_entries_to_project() # Generate a unique branch name @@ -2743,7 +2877,7 @@ class AppController: self._switch_discussion(new_name) def _rename_discussion(self, old_name: str, new_name: str) -> None: """ - [C: src/gui_2.py:App._render_discussion_panel] + [C: src/gui_2.py:App._render_discussion_panel] """ disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) @@ -2759,7 +2893,7 @@ class AppController: def _delete_discussion(self, name: str) -> None: """ - [C: src/gui_2.py:App._render_discussion_panel] + [C: src/gui_2.py:App._render_discussion_panel] """ disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) @@ -2775,7 +2909,7 @@ class AppController: 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._gui_func, src/gui_2.py:App._handle_approve_mma_step, src/gui_2.py:App._handle_approve_spawn] + [C: src/gui_2.py:App._gui_func, src/gui_2.py:App._handle_approve_mma_step, src/gui_2.py:App._handle_approve_spawn] """ if self._pending_mma_approvals: task = self._pending_mma_approvals.pop(0) @@ -2803,9 +2937,9 @@ class AppController: def _handle_approve_ask(self) -> None: """ - - Responds with approval for a pending /api/ask request. - [C: src/gui_2.py:App.__init__, src/gui_2.py:App._gui_func] + + Responds with approval for a pending /api/ask request. + [C: src/gui_2.py:App.__init__, src/gui_2.py:App._gui_func] """ if not self._ask_request_id: return request_id = self._ask_request_id @@ -2825,9 +2959,9 @@ class AppController: def _handle_reject_ask(self) -> None: """ - - Responds with rejection for a pending /api/ask request. - [C: src/gui_2.py:App._gui_func] + + Responds with rejection for a pending /api/ask request. + [C: src/gui_2.py:App._gui_func] """ if not self._ask_request_id: return request_id = self._ask_request_id @@ -2847,9 +2981,9 @@ 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] + + Logic for resetting the AI session and GUI state. + [C: src/gui_2.py:App._render_message_panel] """ ai_client.reset_session() ai_client.clear_comms_log() @@ -2900,14 +3034,14 @@ class AppController: def _handle_md_only(self) -> None: """ - - Logic for the 'MD Only' action. - [C: src/gui_2.py:App._render_message_panel] + + Logic for the 'MD Only' action. + [C: src/gui_2.py:App._render_message_panel] """ 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] + [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() @@ -2922,14 +3056,14 @@ 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] + + 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] """ 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] + [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, file_items, stable_md, disc_text = self._do_generate() @@ -2939,7 +3073,7 @@ class AppController: self.last_file_items = file_items self.ai_status = "sending..." user_msg = self.ui_ai_input - + # RAG Retrieval if self.rag_engine and self.rag_config and self.rag_config.enabled: chunks = self.rag_engine.search(user_msg) @@ -2988,7 +3122,7 @@ class AppController: 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] + [C: tests/test_gui_updates.py:test_telemetry_data_updates_correctly] """ if "latency" in payload: self.session_usage["last_latency"] = payload["latency"] @@ -3015,7 +3149,7 @@ class AppController: def clear_cache(self) -> None: """ - [C: src/gui_2.py:App._render_cache_panel] + [C: src/gui_2.py:App._render_cache_panel] """ from src import ai_client ai_client.cleanup() @@ -3023,7 +3157,7 @@ class AppController: def get_session_insights(self) -> Dict[str, Any]: """ - [C: src/gui_2.py:App._render_session_insights_panel] + [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) @@ -3048,7 +3182,7 @@ class AppController: def _flush_to_project(self) -> None: """ - [C: src/gui_2.py:App._gui_func, src/gui_2.py:App._render_discussion_panel, src/gui_2.py:App._render_projects_panel, src/gui_2.py:App._show_menus] + [C: src/gui_2.py:App._gui_func, src/gui_2.py:App._render_discussion_panel, src/gui_2.py:App._render_projects_panel, src/gui_2.py:App._show_menus] """ proj = self.project proj.setdefault("output", {})["output_dir"] = self.ui_output_dir @@ -3088,7 +3222,7 @@ class AppController: def _flush_to_config(self) -> None: """ - [C: src/gui_2.py:App._gui_func, src/gui_2.py:App._render_discussion_panel, 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] + [C: src/gui_2.py:App._gui_func, src/gui_2.py:App._render_discussion_panel, 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, @@ -3102,10 +3236,10 @@ class AppController: 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 @@ -3125,15 +3259,15 @@ class AppController: "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_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_tiered_aggregation.py:test_app_controller_do_generate_uses_persona_strategy] """ self._flush_to_project() self._flush_to_config() @@ -3141,10 +3275,10 @@ class AppController: track_id = self.active_track.id if self.active_track else None flat = project_manager.flat_config(self.project, self.active_discussion, track_id=track_id) flat.setdefault("files", {})["paths"] = self.context_files - + persona = self.personas.get(self.ui_active_persona) strategy = persona.aggregation_strategy if persona else "auto" - + full_md, path, file_items = aggregate.run(flat, aggregation_strategy=strategy) # Build stable markdown (no history) for Gemini caching screenshot_base_dir = Path(flat.get("screenshots", {}).get("base_dir", ".")) @@ -3154,7 +3288,7 @@ class AppController: # Build discussion history text separately history = flat.get("discussion", {}).get("history", []) discussion_text = aggregate.build_discussion_text(history) - + csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()]) ai_client.set_custom_system_prompt("\n\n".join(csp)) ai_client.set_base_system_prompt(self.ui_base_system_prompt) @@ -3162,12 +3296,12 @@ class AppController: ai_client.set_project_context_marker(self.ui_project_context_marker) self.last_resolved_system_prompt = ai_client.get_combined_system_prompt() self.last_aggregate_markdown = full_md - + return full_md, path, file_items, stable_md, discussion_text def _cb_plan_epic(self) -> None: """ - [C: src/gui_2.py:App._render_mma_dashboard, tests/test_mma_orchestration_gui.py:test_cb_plan_epic_launches_thread] + [C: src/gui_2.py:App._render_mma_dashboard, tests/test_mma_orchestration_gui.py:test_cb_plan_epic_launches_thread] """ def _bg_task() -> None: try: @@ -3190,21 +3324,21 @@ class AppController: self.mma_tier_usage["Tier 1"]["output"] += o with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ - "action": "custom_callback", - "callback": _push_t1_usage, - "args": [_t1_in, _t1_out] + "action": "custom_callback", + "callback": _push_t1_usage, + "args": [_t1_in, _t1_out] }) self._pending_gui_tasks.append({ - "action": "handle_ai_response", - "payload": { - "text": json.dumps(tracks, indent=2), - "stream_id": "Tier 1", - "status": "Epic tracks generated." - } + "action": "handle_ai_response", + "payload": { + "text": json.dumps(tracks, indent=2), + "stream_id": "Tier 1", + "status": "Epic tracks generated." + } }) self._pending_gui_tasks.append({ - "action": "show_track_proposal", - "payload": tracks + "action": "show_track_proposal", + "payload": tracks }) except Exception as e: self.ai_status = f"Epic plan error: {e}" @@ -3213,7 +3347,7 @@ class AppController: def _cb_accept_tracks(self) -> None: """ - [C: src/gui_2.py:App._render_track_proposal_modal] + [C: src/gui_2.py:App._render_track_proposal_modal] """ self._show_track_proposal_modal = False @@ -3254,7 +3388,7 @@ class AppController: def _cb_start_track(self, user_data: Any = None) -> None: """ - [C: src/gui_2.py:App._render_track_proposal_modal] + [C: src/gui_2.py:App._render_track_proposal_modal] """ if isinstance(user_data, str): # If track_id is provided directly @@ -3313,9 +3447,9 @@ class AppController: self.mma_tier_usage["Tier 2"]["output"] += o with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ - "action": "custom_callback", - "callback": _push_t2_usage, - "args": [_t2_in, _t2_out] + "action": "custom_callback", + "callback": _push_t2_usage, + "args": [_t2_in, _t2_out] }) if not raw_tickets: self.ai_status = f"Error: No tickets generated for track: {title}" @@ -3373,7 +3507,7 @@ class AppController: def _cb_ticket_retry(self, ticket_id: str) -> None: """ - [C: tests/test_mma_ticket_actions.py:test_cb_ticket_retry] + [C: tests/test_mma_ticket_actions.py:test_cb_ticket_retry] """ for t in self.active_tickets: if t.get('id') == ticket_id: @@ -3383,7 +3517,7 @@ class AppController: def _cb_ticket_skip(self, ticket_id: str) -> None: """ - [C: tests/test_mma_ticket_actions.py:test_cb_ticket_skip] + [C: tests/test_mma_ticket_actions.py:test_cb_ticket_skip] """ for t in self.active_tickets: if t.get('id') == ticket_id: @@ -3405,9 +3539,9 @@ 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: @@ -3429,9 +3563,9 @@ class AppController: def inject_context(self, data: dict) -> None: """ - - Programmatic context injection. - [C: tests/test_headless_simulation.py:test_mma_track_lifecycle_simulation] + + Programmatic context injection. + [C: tests/test_headless_simulation.py:test_mma_track_lifecycle_simulation] """ file_path = data.get("file_path") if file_path: @@ -3479,7 +3613,7 @@ class AppController: def _cb_run_conductor_setup(self) -> None: """ - [C: src/gui_2.py:App._render_mma_dashboard, tests/test_gui_phase3.py:test_conductor_setup_scan] + [C: src/gui_2.py:App._render_mma_dashboard, tests/test_gui_phase3.py:test_conductor_setup_scan] """ base = paths.get_conductor_dir(project_path=self.active_project_root) if not base.exists(): @@ -3509,7 +3643,7 @@ class AppController: def _cb_create_track(self, name: str, desc: str, track_type: str) -> None: """ - [C: src/gui_2.py:App._render_mma_dashboard, tests/test_gui_phase3.py:test_create_track] + [C: src/gui_2.py:App._render_mma_dashboard, tests/test_gui_phase3.py:test_create_track] """ if not name: return date_suffix = datetime.now().strftime("%Y%m%d") @@ -3525,19 +3659,19 @@ class AppController: meta_file = track_dir / "metadata.json" with open(meta_file, "w", encoding="utf-8") as f: json.dump({ - "id": track_id, - "title": name, - "description": desc, - "type": track_type, - "status": "new", - "progress": 0.0 + "id": track_id, + "title": name, + "description": desc, + "type": track_type, + "status": "new", + "progress": 0.0 }, f, indent=1) # Refresh tracks from disk self.tracks = project_manager.get_all_tracks(self.active_project_root) def _push_mma_state_update(self) -> 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_dashboard, 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._cb_block_ticket, src/gui_2.py:App._cb_unblock_ticket, src/gui_2.py:App._render_mma_dashboard, 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] """ if not self.active_track: return @@ -3561,9 +3695,9 @@ class AppController: def _load_active_tickets(self) -> None: """ - - Populates self.active_tickets based on the current execution mode. - [C: tests/test_gui_dag_beads.py:test_load_active_tickets_from_beads] + + Populates self.active_tickets based on the current execution mode. + [C: tests/test_gui_dag_beads.py:test_load_active_tickets_from_beads] """ if getattr(self, "ui_project_execution_mode", "native") == "beads": from src import beads_client diff --git a/src/models.py b/src/models.py index 1ca1ef6..4897723 100644 --- a/src/models.py +++ b/src/models.py @@ -43,11 +43,23 @@ import tomllib import datetime from dataclasses import dataclass, field from typing import List, Optional, Dict, Any, Union +from pydantic import BaseModel from pathlib import Path from src.paths import get_config_path PROVIDERS: List[str] = ["gemini", "anthropic", "gemini_cli", "deepseek", "minimax"] +class GenerateRequest(BaseModel): + prompt: str + auto_add_history: bool = True + temperature: float | None = None + top_p: float | None = None + max_tokens: int | None = None + +class ConfirmRequest(BaseModel): + approved: bool + script: Optional[str] = None + CONFIG_PATH = get_config_path() def _clean_nones(data: Any) -> Any: