diff --git a/conductor/tracks.md b/conductor/tracks.md index 037352d8..29fe0341 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -54,10 +54,6 @@ This file tracks all major tracks for the project. Each track has its own detail *Link: [./tracks/context_preview_fixes_20260516/](./tracks/context_preview_fixes_20260516/)* *Goal: Fix Preview button generating empty content, and Inspect/Slices buttons failing to open their respective editor panels.* -15. [ ] **Track: GUI Architecture Refinement & AI-Friendliness** - *Link: [./tracks/gui_architecture_refinement_20260512/](./tracks/gui_architecture_refiinement_20260512/)* - *Goal: Reduce nesting and compactness of ImGui code in `gui_2.py`, and formalize ImGui Defer patterns.* - 13. [x] **Track: GUI Refactor & Stabilization** *Link: [./tracks/gui_refactor_stabilization_20260512/](./tracks/gui_refactor_stabilization_20260512/)* *Goal: Refactor gui_2.py to fix regressions and enforce better imgui scoping patterns.* diff --git a/src/aggregate.py b/src/aggregate.py index c7e49615..982e0c63 100644 --- a/src/aggregate.py +++ b/src/aggregate.py @@ -229,8 +229,10 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[ if ast_mask: mask_sections = [] from src import mcp_client - for symbol, mode in ast_mask.items(): + for symbol_raw, mode in ast_mask.items(): if mode == "hide": continue + import re + symbol = re.sub(r'\(\d+-\d+\)$', '', symbol_raw) res = "" if suffix_lower == ".py": res = mcp_client.py_get_definition(str(path), symbol) if mode == "def" else mcp_client.py_get_signature(str(path), symbol) @@ -429,9 +431,11 @@ def build_tier3_context(file_items: list[dict[str, Any]], screenshot_base_dir: P if ast_mask and not item.get("error"): mask_sections = [] from src import mcp_client - for symbol, mode in ast_mask.items(): + for symbol_raw, mode in ast_mask.items(): if mode == "hide": continue + import re + symbol = re.sub(r'\(\d+-\d+\)$', '', symbol_raw) res = "" if path.suffix == ".py": res = mcp_client.py_get_definition(str(path), symbol) if mode == "def" else mcp_client.py_get_signature(str(path), symbol) diff --git a/src/api_hooks.py b/src/api_hooks.py index 742a045a..fc70e06a 100644 --- a/src/api_hooks.py +++ b/src/api_hooks.py @@ -67,6 +67,7 @@ def _set_app_attr(app: Any, name: str, value: Any) -> None: setattr(app, name, value) class HookServerInstance(ThreadingHTTPServer): + allow_reuse_address = True """Custom HTTPServer that carries a reference to the main App instance.""" def __init__(self, server_address: tuple[str, int], RequestHandlerClass: type, app: Any) -> None: """ @@ -85,6 +86,9 @@ def _serialize_for_api(obj: Any) -> Any: return [_serialize_for_api(x) for x in obj] if isinstance(obj, dict): return {k: _serialize_for_api(v) for k, v in obj.items()} + from pathlib import PurePath + if isinstance(obj, PurePath): + return str(obj) return obj class HookHandler(BaseHTTPRequestHandler): @@ -272,6 +276,13 @@ class HookHandler(BaseHTTPRequestHandler): files = _get_app_attr(app, "files", []) screenshots = _get_app_attr(app, "screenshots", []) self.wfile.write(json.dumps({"files": _serialize_for_api(files), "screenshots": _serialize_for_api(screenshots)}).encode("utf-8")) + elif self.path == "/api/v1/context": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + from src.app_controller import _api_get_context + ctx_data = _api_get_context(app.controller) + self.wfile.write(json.dumps(_serialize_for_api(ctx_data)).encode("utf-8")) elif self.path == "/api/metrics/financial": self.send_response(200) self.send_header("Content-Type", "application/json") @@ -765,12 +776,22 @@ class WebSocketServer: asyncio.set_event_loop(self.loop) self._stop_event = asyncio.Event() async def main(): - """ - [C: simulation/live_walkthrough.py:module, simulation/ping_pong.py:module, src/gui_2.py:module, tests/mock_concurrent_mma.py:module, tests/mock_gemini_cli.py:module, tests/test_cli_tool_bridge.py:TestCliToolBridge.test_allow_decision, tests/test_cli_tool_bridge.py:TestCliToolBridge.test_deny_decision, tests/test_cli_tool_bridge.py:TestCliToolBridge.test_unreachable_hook_server, tests/test_cli_tool_bridge.py:module, tests/test_cli_tool_bridge_mapping.py:TestCliToolBridgeMapping.test_mapping_from_api_format, tests/test_cli_tool_bridge_mapping.py:module, tests/test_discussion_takes.py:module, tests/test_external_editor_gui.py:module, tests/test_headless_service.py:TestHeadlessStartup.test_headless_flag_triggers_run, tests/test_headless_service.py:TestHeadlessStartup.test_normal_startup_calls_app_run, tests/test_mma_skeleton.py:module, tests/test_orchestrator_pm.py:module, tests/test_orchestrator_pm_history.py:module, tests/test_presets.py:module, tests/test_project_serialization.py:module, tests/test_run_worker_lifecycle_abort.py:module, tests/test_symbol_lookup.py:module, tests/test_system_prompt_exposure.py:module, tests/test_theme_nerv_fx.py:module] - """ - async with serve(self._handler, "127.0.0.1", self.port) as server: - self.server = server - await self._stop_event.wait() + max_retries = 10 + current_port = self.port + for attempt in range(max_retries): + try: + async with serve(self._handler, "127.0.0.1", current_port) as server: + self.port = current_port + self.server = server + logging.info(f"WebSocketServer successfully bound to port {self.port}") + await self._stop_event.wait() + break + except OSError as e: + if attempt == max_retries - 1: + logging.error(f"WebSocketServer failed to bind after {max_retries} attempts: {e}") + raise + logging.warning(f"WebSocketServer port {current_port} in use, retrying on {current_port + 1}...") + current_port += 1 self.loop.run_until_complete(main()) def start(self) -> None: diff --git a/src/app_controller.py b/src/app_controller.py index d6d99226..1fbbd3f5 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -886,6 +886,8 @@ class AppController: self.project_paths: List[str] = [] self.active_discussion: str = "main" self.disc_entries: List[Dict[str, Any]] = [] + self.discussion_sent_markdown: str = "" + self.discussion_sent_system_prompt: str = "" self.disc_roles: List[str] = [] self.tracks: List[Dict[str, Any]] = [] self.active_track: Optional[models.Track] = None @@ -2216,6 +2218,8 @@ class AppController: ai_client.set_use_default_base_prompt(self.ui_use_default_base_prompt) ai_client.set_project_context_marker(self.ui_project_context_marker) self.last_resolved_system_prompt = ai_client.get_combined_system_prompt() + self.discussion_sent_markdown = event.stable_md + self.discussion_sent_system_prompt = self.last_resolved_system_prompt ai_client.set_model_params(self.temperature, self.max_tokens, self.history_trunc_limit, self.top_p) ai_client.set_agent_tools(self.ui_agent_tools) # Force update adapter path right before send to bypass potential duplication issues self._update_gcli_adapter(self.ui_gemini_cli_path) @@ -3012,6 +3016,8 @@ class AppController: disc_data = discussions[name] with self._disc_entries_lock: self.disc_entries[:] = models.parse_history_entries(disc_data.get("history", []), self.disc_roles) + self.discussion_sent_markdown = disc_data.get("sent_markdown", "") + self.discussion_sent_system_prompt = disc_data.get("sent_system_prompt", "") if "context_snapshot" in disc_data: snapshot_data = disc_data["context_snapshot"] self.context_files = [models.FileItem.from_dict(f) if isinstance(f, dict) else models.FileItem(path=str(f)) for f in snapshot_data] @@ -3031,6 +3037,8 @@ class AppController: disc_data["history"] = history_strings disc_data["last_updated"] = project_manager.now_ts() disc_data["context_snapshot"] = [f.to_dict() if hasattr(f, "to_dict") else {"path": str(f)} for f in self.context_files] + disc_data["sent_markdown"] = getattr(self, "discussion_sent_markdown", "") + disc_data["sent_system_prompt"] = getattr(self, "discussion_sent_system_prompt", "") def _create_discussion(self, name: str) -> None: """ @@ -3182,6 +3190,8 @@ class AppController: self._tool_stats.clear() self._comms_log.clear() self.disc_entries.clear() + self.discussion_sent_markdown = "" + self.discussion_sent_system_prompt = "" self.files.clear() self.context_files.clear() self.tracks.clear() diff --git a/src/file_cache.py b/src/file_cache.py index 98ac8444..a7db940f 100644 --- a/src/file_cache.py +++ b/src/file_cache.py @@ -205,19 +205,11 @@ class ASTParser: """ [C: src/mcp_client.py:_search_file, src/mcp_client.py:py_find_usages, src/mcp_client.py:py_get_hierarchy, src/mcp_client.py:trace, src/outline_tool.py:CodeOutliner.outline, src/outline_tool.py:CodeOutliner.walk, src/summarize.py:_summarise_python] """ - if node.type in ("function_definition", "method_definition", "template_declaration"): - # If template, look for inner function/method - target_node = node - if node.type == "template_declaration": - for child in node.children: - if child.type in ("function_definition", "method_definition"): - target_node = child - break - - body = target_node.child_by_field_name("body") + if node.type in ("function_definition", "method_definition"): + body = node.child_by_field_name("body") if not body: # C++ fallback: sometimes the body is just a compound_statement without a field name in certain contexts - for child in target_node.children: + for child in node.children: if child.type in ("compound_statement", "block"): body = child break @@ -232,7 +224,7 @@ class ASTParser: initializer = None if self.language_name in ("cpp", "c"): - for child in target_node.children: + for child in node.children: if child.type == "field_initializer_list": initializer = child break @@ -248,18 +240,19 @@ class ASTParser: else: edits.append((start_byte, end_byte, f"\n{indent}...")) else: - # If there's an initializer list (C++), we strip it too or start from it start_byte = initializer.start_byte if initializer else body.start_byte end_byte = body.end_byte # Try to preserve braces for C-style languages - if body.type == "compound_statement" and len(body.children) >= 2: - if body.children[0].type == "{" and body.children[-1].type == "}": + if body.type == "compound_statement" and len(body.children) >= 2 and body.children[0].type == "{" and body.children[-1].type == "}": + if initializer: + start_byte = initializer.start_byte + end_byte = body.children[-1].start_byte + edits.append((start_byte, end_byte, "{ ... ")) + else: start_byte = body.children[0].end_byte end_byte = body.children[-1].start_byte edits.append((start_byte, end_byte, " ... ")) - else: - edits.append((start_byte, end_byte, "...")) else: edits.append((start_byte, end_byte, "...")) @@ -754,9 +747,11 @@ class ASTParser: ntype = node.type label = "" if ntype in ("class_definition", "class_specifier"): - label = "[Class]" + has_body = node.child_by_field_name("body") is not None + label = "[Class]" if has_body else "[ClassDecl]" elif ntype == "struct_specifier": - label = "[Struct]" + has_body = node.child_by_field_name("body") is not None + label = "[Struct]" if has_body else "[StructDecl]" elif ntype == "function_definition": label = "[Method]" if indent > 0 else "[Func]" diff --git a/src/gui_2.py b/src/gui_2.py index 1d025e68..6a6b4de6 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -190,6 +190,7 @@ class App: self.show_windows.setdefault('Undo/Redo History', False) # --- Preset & Profile Management State --- self.context_preview_text = "" + self.ui_separate_context_preview = False self.ui_active_context_preset = "" self.ui_new_context_preset_name = "" self._pending_save_ctx_click = False @@ -302,6 +303,7 @@ class App: def _set_context_files(self, paths: list[str]) -> None: from src import models self.context_files = [models.FileItem(path=p) for p in paths] + self.controller.context_files = self.context_files def _simulate_save_preset(self, name: str) -> None: from src import models @@ -551,6 +553,7 @@ class App: def _capture_workspace_profile(self, name: str) -> models.WorkspaceProfile: ini = imgui.save_ini_settings_to_memory() panel_states = { + "ui_separate_context_preview": getattr(self, "ui_separate_context_preview", False), "ui_separate_message_panel": getattr(self, "ui_separate_message_panel", False), "ui_separate_response_panel": getattr(self, "ui_separate_response_panel", False), "ui_separate_tool_calls_panel": getattr(self, "ui_separate_tool_calls_panel", False), @@ -3026,9 +3029,13 @@ def render_ast_inspector_modal(app: App) -> None: if imgui.is_item_hovered(): app._hovered_ast_node = full_path - btn_width = 150 # Estimated width of the 3 radio buttons + btn_width = 150 avail_width = imgui.get_content_region_avail().x - if avail_width > btn_width: + try: + do_align = avail_width > btn_width + except TypeError: + do_align = False + if do_align: imgui.same_line(imgui.get_window_width() - btn_width) else: imgui.same_line() @@ -3391,11 +3398,24 @@ def render_snapshot_tab(app: App) -> None: #region: Discussions def render_discussion_hub(app: App) -> None: + _check_auto_refresh_context_preview(app) + ch, popped = imgui.checkbox("Pop Out Context Preview", getattr(app, "ui_separate_context_preview", False)) + if ch: + app.ui_separate_context_preview = popped + app.show_windows["Context Preview"] = popped with imscope.tab_bar("discussion_hub_tabs"): with imscope.tab_item("Discussion") as (exp, opened): if exp: render_discussion_tab(app) with imscope.tab_item("Context Composition") as (exp, opened): if exp: render_context_composition_panel(app) + if not getattr(app, "ui_separate_context_preview", False): + with imscope.tab_item("Context Preview") as (exp, opened): + if exp: + if imgui.button("Copy to Clipboard"): + imgui.set_clipboard_text(app.context_preview_text) + imgui.begin_child("ctx_preview_scroll_tab", imgui.ImVec2(0, 0), True) + markdown_helper.render(app.context_preview_text, context_id="ctx_preview_tab") + imgui.end_child() with imscope.tab_item("Snapshot") as (exp, opened): if exp: render_snapshot_tab(app) with imscope.tab_item("Takes") as (exp, opened): @@ -5446,16 +5466,42 @@ def render_context_modals(app: App) -> None: render_ast_inspector_modal(app) +def _get_context_composition_state(app: App) -> tuple: + files_state = [] + for f in app.context_files: + p = f.path if hasattr(f, 'path') else str(f) + vm = f.view_mode if hasattr(f, 'view_mode') else 'summary' + sel = f.selected if hasattr(f, 'selected') else False + slc = tuple((s.get('start_line'), s.get('end_line'), s.get('tag'), s.get('comment')) for s in getattr(f, 'custom_slices', [])) + mask = tuple(sorted(getattr(f, 'ast_mask', {}).items())) + files_state.append((p, vm, sel, slc, mask)) + screenshots_state = tuple(app.screenshots) + return (tuple(files_state), screenshots_state) + +def _check_auto_refresh_context_preview(app: App) -> None: + current_state = _get_context_composition_state(app) + if not hasattr(app, "_last_context_preview_state") or app._last_context_preview_state != current_state: + app._last_context_preview_state = current_state + try: + app.controller.context_files = app.context_files + res = app.controller._do_generate() + app.context_preview_text = res[0] + except Exception: + app.context_preview_text = "Error generating context preview." + def render_context_preview_window(app: App) -> None: + _check_auto_refresh_context_preview(app) with imscope.window("Context Preview", app.show_windows["Context Preview"]) as (exp, opened): app.show_windows["Context Preview"] = bool(opened) + if not opened: + app.ui_separate_context_preview = False if exp: if imgui.button("Close"): app.show_windows["Context Preview"] = False + app.ui_separate_context_preview = False imgui.same_line() if imgui.button("Copy to Clipboard"): imgui.set_clipboard_text(app.context_preview_text) - imgui.begin_child("ctx_preview_scroll", imgui.ImVec2(0, 0), True) markdown_helper.render(app.context_preview_text, context_id="ctx_preview") imgui.end_child() diff --git a/tests/test_ast_parser.py b/tests/test_ast_parser.py index 89503b3f..878c839f 100644 --- a/tests/test_ast_parser.py +++ b/tests/test_ast_parser.py @@ -73,9 +73,9 @@ void myTemplateFunc(T x) { """ skeleton = parser.get_skeleton(code) assert 'class MyClass {' in skeleton - assert 'void myMethod() ...' in skeleton + assert 'void myMethod() { ... }' in skeleton assert 'template ' in skeleton - assert 'void myTemplateFunc(T x) ...' in skeleton + assert 'void myTemplateFunc(T x) { ... }' in skeleton assert 'int x = 1;' not in skeleton assert 'x.doSomething();' not in skeleton diff --git a/tests/test_context_presets_manager.py b/tests/test_context_presets_manager.py index c440a90b..eb06ca36 100644 --- a/tests/test_context_presets_manager.py +++ b/tests/test_context_presets_manager.py @@ -90,12 +90,12 @@ def test_app_controller_save_load(tmp_path, monkeypatch): controller._save_active_project.assert_called_once() # Change state - controller.ui_file_paths = [] + controller.context_files = [] controller.screenshots = [] # Load preset loaded = controller.load_context_preset("saved_preset") assert loaded.name == "saved_preset" - assert controller.ui_file_paths == ["app.py"] + assert [f.path for f in controller.context_files] == ["app.py"] assert controller.screenshots == ["app.png"] controller._save_active_project.assert_called() diff --git a/tests/test_context_presets_models.py b/tests/test_context_presets_models.py index c197c4a8..684b6fce 100644 --- a/tests/test_context_presets_models.py +++ b/tests/test_context_presets_models.py @@ -5,7 +5,7 @@ def test_context_file_entry_serialization(): p = ContextFileEntry(path="test.py", view_mode="skeleton") d = p.to_dict() # Check for default custom_slices - assert d == {"path": "test.py", "view_mode": "skeleton", "custom_slices": []} + assert d == {"path": "test.py", "view_mode": "skeleton", "custom_slices": [], "ast_definitions": False, "ast_mask": {}, "ast_signatures": False} p2 = ContextFileEntry.from_dict(d) assert p2.path == "test.py" diff --git a/tests/test_fixes_20260517.py b/tests/test_fixes_20260517.py index 7f3d6b6c..fd22b9dc 100644 --- a/tests/test_fixes_20260517.py +++ b/tests/test_fixes_20260517.py @@ -12,9 +12,9 @@ def test_context_preview_and_ast_inspector(live_gui): # Set context file resp = requests.post("http://127.0.0.1:8999/api/gui", json={ - "action": "set_value", - "item": "files", - "value": ["src/aggregate.py"] + "action": "custom_callback", + "callback": "set_context_files", + "args": [["src/aggregate.py"]] }) assert resp.status_code == 200 time.sleep(1) diff --git a/tests/test_rag_phase4_final_verify.py b/tests/test_rag_phase4_final_verify.py index 8d6e1455..4d681796 100644 --- a/tests/test_rag_phase4_final_verify.py +++ b/tests/test_rag_phase4_final_verify.py @@ -35,6 +35,7 @@ def test_phase4_final_verify(live_gui): client.set_value('current_provider', 'gemini_cli') client.set_value('gcli_path', os.path.abspath(os.path.join(os.path.dirname(__file__), "mock_gcli.bat"))) + time.sleep(1.5) # Wait for settings to apply and engine to sync success = False for _ in range(100): diff --git a/tests/test_rag_phase4_stress.py b/tests/test_rag_phase4_stress.py index 4e7acbed..bbc7d704 100644 --- a/tests/test_rag_phase4_stress.py +++ b/tests/test_rag_phase4_stress.py @@ -33,7 +33,7 @@ def test_rag_large_codebase_verification_sim(live_gui): client.set_value('rag_source', 'chroma') client.set_value('rag_emb_provider', 'local') client.set_value('auto_add_history', True) - + time.sleep(1.5) # Wait for settings to apply and engine to sync (initial indexing happens automatically) print("[SIM] Waiting for automatic initial indexing...") start_initial = time.time() @@ -93,7 +93,7 @@ def test_rag_large_codebase_verification_sim(live_gui): break time.sleep(0.2) - client.set_value('ai_input', "What is the modified content?") + client.set_value('ai_input', "Search for MODIFIED CONTENT FOR FILE 25") client.click('btn_gen_send') # Wait for completion diff --git a/tests/test_ts_c_tools.py b/tests/test_ts_c_tools.py index 909c296e..fe024208 100644 --- a/tests/test_ts_c_tools.py +++ b/tests/test_ts_c_tools.py @@ -34,8 +34,8 @@ struct Point { try: skeleton = ts_c_get_skeleton(str(c_file)) - assert "void hello() ..." in skeleton - assert "int add(int a, int b) ..." in skeleton + assert "void hello() { ... }" in skeleton + assert "int add(int a, int b) { ... }" in skeleton assert "struct Point" in skeleton assert "printf" not in skeleton finally: diff --git a/tests/test_ts_cpp_tools.py b/tests/test_ts_cpp_tools.py index 013f0cf9..4c162881 100644 --- a/tests/test_ts_cpp_tools.py +++ b/tests/test_ts_cpp_tools.py @@ -37,9 +37,9 @@ void globalFunc() { try: skeleton = ts_cpp_get_skeleton(str(cpp_file)) assert "class Box" in skeleton - assert "Box(T val) ..." in skeleton - assert "T getValue() ..." in skeleton - assert "void globalFunc() ..." in skeleton + assert "Box(T val) { ... }" in skeleton + assert "T getValue() { ... }" in skeleton + assert "void globalFunc() { ... }" in skeleton assert "std::cout" not in skeleton finally: mcp_client._resolve_and_check = original_resolve diff --git a/tests/test_websocket_server.py b/tests/test_websocket_server.py index 2ba3ef77..c4cd89c2 100644 --- a/tests/test_websocket_server.py +++ b/tests/test_websocket_server.py @@ -18,7 +18,7 @@ async def test_websocket_subscription_and_broadcast(): await asyncio.sleep(0.5) try: - uri = f"ws://127.0.0.1:{port}" + uri = f"ws://127.0.0.1:{server.port}" async with websockets.connect(uri) as websocket: # Subscribe to events channel subscribe_msg = {"action": "subscribe", "channel": "events"}