diff --git a/conductor/tracks.md b/conductor/tracks.md index 603b1630..b5fa9f3e 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -303,7 +303,7 @@ This file tracks all major tracks for the project. Each track has its own detail --- -- [ ] **Track: Combine AST Inspector and Slices Editor into a unified Structural File Editor** +- [~] **Track: Combine AST Inspector and Slices Editor into a unified Structural File Editor** *Link: [./tracks/structural_file_editor_20260601/](./tracks/structural_file_editor_20260601/)* --- diff --git a/src/app_controller.py b/src/app_controller.py index aefe53a0..20306d86 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -160,7 +160,6 @@ def _api_health(controller: 'AppController') -> dict[str, str]: 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] """ @@ -173,6 +172,11 @@ def _api_get_gui_state(controller: 'AppController') -> dict[str, Any]: state[key] = dataclasses.asdict(val) else: state[key] = val + + # Compatibility overrides + show_windows = getattr(controller, "show_windows", {}) + state["show_text_viewer"] = show_windows.get("Text Viewer", False) + return state def _api_get_mma_status(controller: 'AppController') -> dict[str, Any]: diff --git a/src/gui_2.py b/src/gui_2.py index da8dc320..d7312f10 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -198,11 +198,8 @@ class App: self._pending_save_ctx_click = False self._pending_save_anyway_click = False self.show_missing_files_modal = False + self.show_structural_editor_modal = False self.missing_context_files = [] - self.target_context_preset_name = "" - self.show_empty_context_warning_modal = False - self._pending_proceed_generate = False - self._pending_proceed_md_only = False self._new_preset_name = "" self._editing_preset_name = "" self._editing_preset_system_prompt = "" @@ -3184,24 +3181,12 @@ def render_context_files_table(app: App) -> None: imgui.same_line() imgui.text_colored(imgui.ImVec4(1.0, 0.0, 0.0, 1.0), "[MISSING]") - if f_path.lower().endswith(('.c', '.cpp', '.h', '.hpp', '.cxx', '.cc')): + if f_path.lower().endswith(('.py', '.c', '.cpp', '.h', '.hpp', '.cxx', '.cc')): imgui.same_line() - if imgui.button(f"[Inspect]##{i}"): + if imgui.button(f"[Structure]##{i}"): + app.ui_editing_slices_file = f_item app.ui_inspecting_ast_file = f_item - app._show_ast_inspector = True - - imgui.same_line() - if imgui.button(f"[Slices]##{i}"): - app.ui_editing_slices_file = f_item - f_path = f_item.path if hasattr(f_item, "path") else str(f_item) - app.text_viewer_title = f"Slices: {f_path}" - try: - app.text_viewer_content = mcp_client.read_file(f_path) - except Exception as e: - app.text_viewer_content = f"Error reading file: {e}" - app.text_viewer_type = 'cpp' if f_path.endswith(('.cpp', '.hpp', '.h')) else 'python' if f_path.endswith('.py') else 'text' - app.show_windows["Text Viewer"] = True - app.show_windows["Text Viewer"] = True + app.show_structural_editor_modal = True imgui.table_set_column_index(1) if not hasattr(f_item, "view_mode"): f_item.view_mode = "summary" @@ -5483,7 +5468,8 @@ def render_context_modals(app: App) -> None: imgui.end_popup() - render_ast_inspector_modal(app) + from src.structural_editor_modal import render_structural_file_editor_modal + render_structural_file_editor_modal(app) def _get_context_composition_state(app: App) -> tuple: files_state = [] diff --git a/src/structural_editor_modal.py b/src/structural_editor_modal.py new file mode 100644 index 00000000..0207e69b --- /dev/null +++ b/src/structural_editor_modal.py @@ -0,0 +1,209 @@ +from __future__ import annotations +from imgui_bundle import imgui +import re +from typing import TYPE_CHECKING +from src import imscope +from src.theme import C_IN + +if TYPE_CHECKING: + from src.gui_2 import App + +def render_structural_file_editor_modal(app: 'App') -> None: + if app.show_structural_editor_modal: + imgui.open_popup('Structural File Editor') + app.show_structural_editor_modal = False + + imgui.set_next_window_size(imgui.ImVec2(1400, 900), imgui.Cond_.first_use_ever) + expanded, opened = imgui.begin_popup_modal('Structural File Editor', True, imgui.WindowFlags_.none) + if opened: + if expanded: + if app.ui_editing_slices_file is None: + imgui.close_current_popup() + else: + f_item = app.ui_editing_slices_file + f_path = f_item.path if hasattr(f_item, "path") else str(f_item) + + if f_path != getattr(app, '_cached_ast_file_path', None): + outline = "" + try: + from src import mcp_client + from pathlib import Path + proj_dir = str(Path(app.controller.active_project_path).parent.resolve()) if getattr(app, 'controller', None) and app.controller.active_project_path else None + mcp_client.configure([{"path": f_path}], [proj_dir] if proj_dir else None) + + if f_path.lower().endswith('.py'): outline = mcp_client.py_get_code_outline(f_path) + elif f_path.lower().endswith(('.c', '.h')): outline = mcp_client.ts_c_get_code_outline(f_path) + elif f_path.lower().endswith(('.cpp', '.hpp', '.cxx', '.cc')): outline = mcp_client.ts_cpp_get_code_outline(f_path) + except Exception as e: + outline = f"Error fetching outline: {e}" + + app._cached_ast_nodes = [] + pattern = re.compile(r'^(\s*)\[(.*?)\] (.*?) \(Lines (\d+)-(\d+)\)') + stack = [] # (indent, name) + for line in outline.splitlines(): + m = pattern.match(line) + if m: + indent_str, kind, name, start_ln, end_ln = m.groups() + indent = len(indent_str) + while stack and stack[-1][0] >= indent: stack.pop() + stack.append((indent, name)) + full_path = '::'.join([s[1] for s in stack]) + app._cached_ast_nodes.append({ + 'indent': indent, + 'kind': kind, + 'name': name, + 'full_path': full_path, + 'start_line': int(start_ln), + 'end_line': int(end_ln) + }) + try: + content = mcp_client.read_file(f_path) + app._cached_ast_file_lines = content.splitlines() + app.text_viewer_content = content + except Exception: + app._cached_ast_file_lines = ["Error loading file content."] + app.text_viewer_content = "Error loading file content." + app._cached_ast_file_path = f_path + + imgui.text(f"Editing Structure: {f_path}") + imgui.separator() + + avail = imgui.get_content_region_avail() + table_height = max(100.0, avail.y - imgui.get_frame_height_with_spacing() * 2 - 20) + + if imgui.begin_table('structure_dual_pane', 2, imgui.TableFlags_.resizable | imgui.TableFlags_.borders_inner_v, imgui.ImVec2(0, table_height)): + imgui.table_setup_column("AST & Slices", imgui.TableColumnFlags_.width_fixed, 400) + imgui.table_setup_column("Content Preview", imgui.TableColumnFlags_.width_stretch) + imgui.table_next_row() + imgui.table_next_column() + + # --- LEFT COLUMN: AST Tree & Slice Management --- + imgui.begin_child("ast_tree_scroll", imgui.ImVec2(0, 0), True) + if True: + if imgui.collapsing_header("AST Tree", imgui.TreeNodeFlags_.default_open): + if not getattr(app, '_cached_ast_nodes', None): imgui.text("No AST nodes found.") + else: + for node in app._cached_ast_nodes: + indent = node['indent'] + kind = node['kind'] + name = node['name'] + full_path = node['full_path'] + + imgui.dummy(imgui.ImVec2(indent * 10, 0)) + imgui.same_line() + imgui.text(f"[{kind}] {name}") + + if imgui.is_item_hovered(): + app._hovered_ast_node = full_path + + btn_width = 150 + avail_width = imgui.get_content_region_avail().x + do_align = avail_width > btn_width if isinstance(avail_width, (int, float)) else False + if do_align: imgui.same_line(imgui.get_window_width() - btn_width) + else: imgui.same_line() + + if not hasattr(f_item, 'ast_mask'): f_item.ast_mask = {} + current_mode = f_item.ast_mask.get(full_path, 'hide') + + imgui.push_id(full_path) + if imgui.radio_button("Def", current_mode == 'def'): f_item.ast_mask[full_path] = 'def' + imgui.same_line() + if imgui.radio_button("Sig", current_mode == 'sig'): f_item.ast_mask[full_path] = 'sig' + imgui.same_line() + if imgui.radio_button("Hide", current_mode == 'hide'): f_item.ast_mask[full_path] = 'hide' + imgui.pop_id() + + imgui.separator() + if imgui.collapsing_header("Custom Slices", imgui.TreeNodeFlags_.default_open): + if not hasattr(f_item, 'custom_slices'): f_item.custom_slices = [] + imgui.text_colored(C_IN, "Highlight lines in right pane to add slices.") + if imgui.button("Add Selection as Slice"): + if getattr(app, '_slice_sel_start', -1) != -1 and getattr(app, '_slice_sel_end', -1) != -1: + s_line = min(app._slice_sel_start, app._slice_sel_end) + e_line = max(app._slice_sel_start, app._slice_sel_end) + from src.fuzzy_anchor import FuzzyAnchor + slice_data = FuzzyAnchor.create_slice(app.text_viewer_content, s_line, e_line) + slice_data['tag'] = ""; slice_data['comment'] = "" + f_item.custom_slices.append(slice_data) + app._slice_sel_start = -1; app._slice_sel_end = -1 + imgui.same_line() + if imgui.button("Clear Selection"): app._slice_sel_start = -1; app._slice_sel_end = -1 + imgui.same_line() + if imgui.button("Auto-Populate"): app._populate_auto_slices(f_item) + + to_remove = -1 + tags = app.controller.project.get("context_tags", ["auto-ast", "bug", "feature", "important"]) + for idx, slc in enumerate(f_item.custom_slices): + imgui.push_id(f"slc_row_{idx}"); imgui.text(f"#{idx+1}: L{slc['start_line']}-{slc['end_line']}"); imgui.same_line() + current_tag = slc.get('tag', '') + if current_tag not in tags and current_tag: tags.append(current_tag) + tag_idx = tags.index(current_tag) if current_tag in tags else 0 + imgui.set_next_item_width(100) + ch_tag, new_tag_idx = imgui.combo("##Tag", tag_idx, tags) + if ch_tag: slc['tag'] = tags[new_tag_idx] + imgui.same_line(); imgui.set_next_item_width(-30); changed_comm, new_comm = imgui.input_text("##Note", slc.get('comment', '')) + if changed_comm: slc['comment'] = new_comm + imgui.same_line() + if imgui.button("X"): to_remove = idx + imgui.pop_id() + if to_remove != -1: f_item.custom_slices.pop(to_remove) + imgui.end_child() + + imgui.table_next_column() + + # --- RIGHT COLUMN: Content Preview with Highlights --- + with imscope.child("ast_content_scroll", imgui.ImVec2(0, 0), True): + if not getattr(app, '_cached_ast_file_lines', None): + imgui.text("No file content loaded.") + else: + draw_list = imgui.get_window_draw_list() + # We need vec4 locally + from src.imgui_scopes import vec4 + for i, line_text in enumerate(app._cached_ast_file_lines): + line_num = i + 1 + pos = imgui.get_cursor_screen_pos() + line_height = imgui.get_text_line_height() + avail_width = imgui.get_content_region_avail().x + + # 1. AST Highlight + deepest_node = None + for node in app._cached_ast_nodes: + if node['start_line'] <= line_num <= node['end_line']: + if deepest_node is None or node['indent'] > deepest_node['indent']: deepest_node = node + mode = 'hide' + if deepest_node: mode = getattr(f_item, 'ast_mask', {}).get(deepest_node['full_path'], 'hide') + if mode == 'def': + draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + avail_width, pos.y + line_height), imgui.get_color_u32(vec4(0, 255, 0, 0.15))) + elif mode == 'sig': + draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + avail_width, pos.y + line_height), imgui.get_color_u32(vec4(0, 0, 255, 0.15))) + elif deepest_node and deepest_node['full_path'] == getattr(app, '_hovered_ast_node', None): + draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + avail_width, pos.y + line_height), imgui.get_color_u32(vec4(255, 255, 0, 0.2))) + + # 2. Slice Highlight + if hasattr(f_item, 'custom_slices'): + is_auto = any(slc['start_line'] <= line_num <= slc['end_line'] for slc in f_item.custom_slices if slc.get('tag') == 'auto-ast') + is_man = any(slc['start_line'] <= line_num <= slc['end_line'] for slc in f_item.custom_slices if slc.get('tag') != 'auto-ast') + if is_man: draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + avail_width, pos.y + line_height), imgui.get_color_u32(vec4(255, 165, 0, 0.2))) + elif is_auto and mode == 'hide': draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + avail_width, pos.y + line_height), imgui.get_color_u32(vec4(0, 255, 0, 0.1))) + + # 3. Active Selection Highlight + if getattr(app, '_slice_sel_start', -1) != -1 and getattr(app, '_slice_sel_end', -1) != -1: + s, e = min(app._slice_sel_start, app._slice_sel_end), max(app._slice_sel_start, app._slice_sel_end) + if s <= line_num <= e: draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + avail_width, pos.y + line_height), imgui.get_color_u32(vec4(100, 100, 255, 0.3))) + + imgui.selectable(f"{line_num:4} | {line_text}##ln{line_num}", False) + if imgui.is_item_clicked(): app._slice_sel_start = line_num; app._slice_sel_end = line_num + if imgui.is_item_hovered(imgui.HoveredFlags_.allow_when_blocked_by_active_item) and imgui.is_mouse_down(0): app._slice_sel_end = line_num + + imgui.end_table() + + imgui.separator() + if imgui.button("Close", imgui.ImVec2(120, 0)): + app.ui_editing_slices_file = None + app.ui_inspecting_ast_file = None + imgui.close_current_popup() + imgui.end_popup() + + if not opened: + app.ui_editing_slices_file = None + app.ui_inspecting_ast_file = None diff --git a/tests/test_gui_symbol_navigation.py b/tests/test_gui_symbol_navigation.py index 7b94dea3..94c47deb 100644 --- a/tests/test_gui_symbol_navigation.py +++ b/tests/test_gui_symbol_navigation.py @@ -88,4 +88,4 @@ def test_render_discussion_panel_symbol_lookup(mock_app, role): # 3. Verify the text viewer state is updated correctly assert mock_app.text_viewer_title == "src/models.py" assert mock_app.text_viewer_content == "class MyClass:\n pass" - assert mock_app.show_text_viewer is True + assert mock_app.show_windows.get("Text Viewer") is True diff --git a/tests/test_gui_text_viewer.py b/tests/test_gui_text_viewer.py index 91a6f8b1..8d85006c 100644 --- a/tests/test_gui_text_viewer.py +++ b/tests/test_gui_text_viewer.py @@ -16,12 +16,11 @@ def test_text_viewer_state_update(live_gui) -> None: content = "This is test content for the viewer." text_type = "markdown" - client.push_event("custom_callback", {"callback": "_set_attr", "args": ["show_text_viewer", True]}) client.push_event("custom_callback", {"callback": "_set_attr", "args": ["text_viewer_title", label]}) client.push_event("custom_callback", {"callback": "_set_attr", "args": ["text_viewer_content", content]}) client.push_event("custom_callback", {"callback": "_set_attr", "args": ["text_viewer_type", text_type]}) - # Poll for state change (up to 5s) + # Wait for text_type to settle state = None start_time = time.time() while time.time() - start_time < 5: @@ -30,7 +29,20 @@ def test_text_viewer_state_update(live_gui) -> None: break time.sleep(0.1) + # Now get current show_windows, update it, and set it back + current_windows = state.get('show_windows', {}) + current_windows["Text Viewer"] = True + client.push_event("custom_callback", {"callback": "_set_attr", "args": ["show_windows", current_windows]}) + + # Poll for show_text_viewer compat flag + start_time = time.time() + while time.time() - start_time < 5: + state = client.get_gui_state() + if state and state.get('show_text_viewer') == True: + break + time.sleep(0.1) + assert state is not None - assert state.get('show_text_viewer') == True + assert state.get('show_text_viewer') == True # API hook still provides this for compat assert state.get('text_viewer_title') == label assert state.get('text_viewer_type') == text_type \ No newline at end of file diff --git a/tests/test_visual_sim_gui_ux.py b/tests/test_visual_sim_gui_ux.py index 2dd95f38..4bf23942 100644 --- a/tests/test_visual_sim_gui_ux.py +++ b/tests/test_visual_sim_gui_ux.py @@ -37,10 +37,10 @@ def test_gui_ux_event_routing(live_gui) -> None: 'tier_usage': usage, 'tickets': [] }) - time.sleep(1) + time.sleep(2) status = client.get_mma_status() - assert status.get('mma_status') == 'simulating' + assert status.get('mma_status') == 'simulating', f"Expected 'simulating', got '{status.get('mma_status')}'" assert status.get('tier_usage', {}).get('Tier 1', {}).get('input') == 10 print("[SIM] Global state update verified.")