From e9ff6efe20c21048cfa226ca816981040095cfff Mon Sep 17 00:00:00 2001 From: Ed_ Date: Tue, 2 Jun 2026 02:58:33 -0400 Subject: [PATCH] UX UX UX UX UX --- conductor/tracks.md | 6 +- .../structural_file_editor_20260601/plan.md | 24 +- src/ai_client.py | 2 +- src/app_controller.py | 4 +- src/gui_2.py | 338 ++---------------- src/structural_editor_modal.py | 8 +- 6 files changed, 57 insertions(+), 325 deletions(-) diff --git a/conductor/tracks.md b/conductor/tracks.md index b5fa9f3e..c9562f87 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -288,7 +288,7 @@ This file tracks all major tracks for the project. Each track has its own detail --- -- [~] **Track: Preserve context selection on discussion switch and add empty context warning** +- [x] **Track: Preserve context selection on discussion switch and add empty context warning** *Link: [./tracks/context_preservation_and_warnings_20260601/](./tracks/context_preservation_and_warnings_20260601/)* --- @@ -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** +- [x] **Track: Combine AST Inspector and Slices Editor into a unified Structural File Editor** *Link: [./tracks/structural_file_editor_20260601/](./tracks/structural_file_editor_20260601/)* --- @@ -313,5 +313,5 @@ This file tracks all major tracks for the project. Each track has its own detail --- -- [~] **Track: Fix Approve Modal sizing and inline full preview** +- [x] **Track: Fix Approve Modal sizing and inline full preview** *Link: [./tracks/approve_modal_ux_20260601/](./tracks/approve_modal_ux_20260601/)* diff --git a/conductor/tracks/structural_file_editor_20260601/plan.md b/conductor/tracks/structural_file_editor_20260601/plan.md index 23b873ce..223c6f96 100644 --- a/conductor/tracks/structural_file_editor_20260601/plan.md +++ b/conductor/tracks/structural_file_editor_20260601/plan.md @@ -1,17 +1,17 @@ # Implementation Plan: Structural File Editor ## Phase 1: Unification -- [ ] Task: Create Structural File Editor Modal - - [ ] Add `app.show_structural_editor_modal = False` to `App.__init__`. - - [ ] Create `render_structural_file_editor_modal(app: App)` in `src/gui_2.py`. - - [ ] Port the tree rendering logic from `render_ast_inspector_modal` into this new modal. - - [ ] Port the custom slice management UI from `render_slices_editor_modal` into this new modal, positioning it logically alongside or above the AST tree. -- [ ] Task: Update File Row UI - - [ ] In `render_context_files_table`, replace the separate "AST" and "Slices" buttons with a single "Structure" button that sets `app.show_structural_editor_modal = True` and sets the target file. +- [x] Task: Create Structural File Editor Modal + - [x] Add `app.show_structural_editor_modal = False` to `App.__init__`. + - [x] Create `render_structural_file_editor_modal(app: App)` in `src/gui_2.py` (implemented in `src/structural_editor_modal.py`). + - [x] Port the tree rendering logic from `render_ast_inspector_modal` into this new modal. + - [x] Port the custom slice management UI from `render_slices_editor_modal` into this new modal, positioning it logically alongside or above the AST tree. +- [x] Task: Update File Row UI + - [x] In `render_context_files_table`, replace the separate "AST" and "Slices" buttons with a single "Structure" button that sets `app.show_structural_editor_modal = True` and sets the target file. ## Phase 2: Verification -- [ ] Task: Verification - - [ ] Open the Structural File Editor for a complex Python file. - - [ ] Verify both AST nodes and custom slices are visible and interactive. - - [ ] Verify that adding a custom slice works correctly within the unified interface. -- [ ] Task: Conductor - User Manual Verification 'Phase 2: Verification' (Protocol in workflow.md) \ No newline at end of file +- [x] Task: Verification + - [x] Open the Structural File Editor for a complex Python file. + - [x] Verify both AST nodes and custom slices are visible and interactive. + - [x] Verify that adding a custom slice works correctly within the unified interface. +- [x] Task: Conductor - User Manual Verification 'Phase 2: Verification' (Protocol in workflow.md) \ No newline at end of file diff --git a/src/ai_client.py b/src/ai_client.py index f73d1047..4ee29df6 100644 --- a/src/ai_client.py +++ b/src/ai_client.py @@ -2631,7 +2631,7 @@ def run_subagent_summarization(file_path: str, content: str, is_code: bool, outl return "ERROR: Unsupported provider for sub-agent summarization" def run_discussion_compression(discussion_text: str) -> str: - prompt = f"The following is a long conversation history.\\nPlease provide a highly compact, dense summary of the key facts, decisions, bugs encountered, and outcomes that should be retained for context going forward. Categorize into User intent, Tool outputs, and AI reasoning. Omit pleasantries and redundant thoughts.\\n\\n[HISTORY]\\n{discussion_text}" + prompt = f"The following is a long conversation history.\n\nPlease provide a highly compact, dense summary of the key facts, decisions, bugs encountered, and outcomes that should be retained for context going forward. Categorize into User intent, Tool outputs, and AI reasoning. Omit pleasantries and redundant thoughts.\n\n[HISTORY]\n{discussion_text}" if _provider == "gemini": _ensure_gemini_client() if _gemini_client: diff --git a/src/app_controller.py b/src/app_controller.py index 20306d86..3c9c61bf 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -3277,7 +3277,9 @@ class AppController: response_text = ai_client.run_discussion_compression(disc_text) if response_text and not response_text.startswith("ERROR:"): - self.disc_entries = [{"role": "System", "content": f"[COMPRESSED HISTORY]\n{response_text}", "collapsed": False, "ts": project_manager.now_ts()}] + with self._disc_entries_lock: + self.disc_entries.clear() + self.disc_entries.append({"role": "System", "content": f"[COMPRESSED HISTORY]\n{response_text}", "collapsed": False, "ts": project_manager.now_ts()}) self.ai_status = "compression complete" else: self.ai_status = f"compression failed: {response_text}" diff --git a/src/gui_2.py b/src/gui_2.py index d7312f10..05087445 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -2632,24 +2632,22 @@ def render_files_and_media(app: App) -> None: """ [C: tests/test_gui_fast_render.py:test_render_files_and_media_fast] """ - avail = imgui.get_content_region_avail().y - if not hasattr(app, 'files_screenshots_split'): app.files_screenshots_split = 0.65 - split_y = int(avail * app.files_screenshots_split) if imgui.collapsing_header("Files", imgui.TreeNodeFlags_.default_open): - with imscope.child("Files_child", -1, split_y, True): - if not hasattr(app, 'files_last_selected'): app.files_last_selected = -1 - - with imscope.table("files_table", 3, imgui.TableFlags_.resizable | imgui.TableFlags_.borders): - imgui.table_setup_column("", imgui.TableColumnFlags_.width_fixed, 25) + with imscope.group(): + if imgui.begin_table("files_table", 3, imgui.TableFlags_.resizable | imgui.TableFlags_.borders | imgui.TableFlags_.row_bg): + imgui.table_setup_column("Act", imgui.TableColumnFlags_.width_fixed, 60) imgui.table_setup_column("Path", imgui.TableColumnFlags_.width_stretch) - imgui.table_setup_column("Status", imgui.TableColumnFlags_.width_fixed, 60) + imgui.table_setup_column("Status", imgui.TableColumnFlags_.width_fixed, 70) imgui.table_headers_row() + to_remove_idx = -1 app.files.sort(key=lambda f: f.path.lower() if hasattr(f, 'path') else str(f).lower()) for i, f_item in enumerate(app.files): - imgui.table_next_row(); imgui.table_set_column_index(0) + imgui.table_next_row() + imgui.table_set_column_index(0) fpath = f_item.path if hasattr(f_item, 'path') else str(f_item) in_context = any((cf.path if hasattr(cf, 'path') else str(cf)) == fpath for cf in app.context_files) + is_cached = any(fpath in c for c in getattr(app, '_cached_files', [])) if imgui.button(f"+##add_f_{i}"): if not in_context: @@ -2657,157 +2655,48 @@ def render_files_and_media(app: App) -> None: new_item = models.FileItem(path=fpath) app.context_files.append(new_item) app._populate_auto_slices(new_item) + + imgui.same_line() + if imgui.button(f"x##rem_f_{i}"): + to_remove_idx = i - imgui.table_set_column_index(1); imgui.text(fpath) + imgui.table_set_column_index(1) + imgui.text(fpath) + if imgui.is_item_hovered(): imgui.set_tooltip(fpath) + imgui.table_set_column_index(2) if in_context: imgui.text_colored(imgui.ImVec4(0.3, 0.8, 0.3, 1), "Active") + elif is_cached: + imgui.text_colored(imgui.ImVec4(0.3, 0.8, 1, 1), "Cached") else: imgui.text_disabled(" - ") - if is_cached: imgui.text_colored(imgui.ImVec4(0, 1, 0, 1), "Y") - else: imgui.text_colored(imgui.ImVec4(0.5, 0.5, 0.5, 1), "-") - - if imgui.button("Add Files to Inventory"): - r = hide_tk_root(); paths = filedialog.askopenfilenames(); r.destroy() - for p in paths: - if p not in [f.path if hasattr(f, 'path') else f for f in app.files]: app.files.append(models.FileItem(path=p)) - - imgui.same_line() - if imgui.button("Clear Selection##inv"): - for f in app.files: f.selected = False - - imgui.same_line() - if imgui.button("Remove from Inventory"): - app.files = [f for f in app.files if not f.selected] + + imgui.end_table() + if to_remove_idx != -1: app.files.pop(to_remove_idx) + + imgui.dummy(imgui.ImVec2(0, 5)) + if imgui.button("Add Files to Inventory"): + r = hide_tk_root(); paths = filedialog.askopenfilenames(); r.destroy() + for p in paths: + if p not in [f.path if hasattr(f, 'path') else f for f in app.files]: app.files.append(models.FileItem(path=p)) imgui.separator() - if imgui.collapsing_header("Screenshots", imgui.TreeNodeFlags_.default_open): - with imscope.child("Shots_child", -1, -1, True): + with imscope.child("Shots_child", -1, 150, True): + to_rem_shot = -1 for i, s in enumerate(app.screenshots): - if imgui.button(f"x##s{i}"): - app.screenshots.pop(i) - break + if imgui.button(f"x##s{i}"): to_rem_shot = i imgui.same_line(); imgui.text(s) + if to_rem_shot != -1: app.screenshots.pop(to_rem_shot) + if imgui.button("Add Screenshots##adds"): r = hide_tk_root(); paths = filedialog.askopenfilenames(filetypes=[("Images", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"), ("All", "*.*")]); r.destroy() for p in paths: if p not in app.screenshots: app.screenshots.append(p) return -def render_files_panel(app: App, height_override: float = 0) -> None: - if app.perf_profiling_enabled: app.perf_monitor.start_component("_render_files_panel") - imgui.text("Paths") - imgui.same_line() - imgui.text("| Base Dir:") - imgui.same_line() - imgui.set_next_item_width(-100) - ch, app.ui_files_base_dir = imgui.input_text("##f_base", app.ui_files_base_dir) - imgui.same_line() - if imgui.button("Browse##fb"): - r = hide_tk_root() - d = filedialog.askdirectory() - r.destroy() - if d: app.ui_files_base_dir = d - imgui.separator() - # Calculate content-based height: use override if provided, else content-based - if height_override > 0: - child_h = height_override - else: - row_count = max(len(app.files), 1) - child_h = min(row_count * 28 + 40, 300) - # BEGIN f_paths child window - imgui.begin_child("f_paths", imgui.ImVec2(0, child_h), True) - if imgui.begin_table("files_table", 4, imgui.TableFlags_.resizable | imgui.TableFlags_.borders): - imgui.table_setup_column("Actions", imgui.TableColumnFlags_.width_fixed, 40) - imgui.table_setup_column("File Path", imgui.TableColumnFlags_.width_stretch) - imgui.table_setup_column("Flags", imgui.TableColumnFlags_.width_fixed, 150) - imgui.table_setup_column("Cache", imgui.TableColumnFlags_.width_fixed, 40) - imgui.table_headers_row() - - for i, f_item in enumerate(app.files): - imgui.table_next_row() - # Actions - imgui.table_set_column_index(0) - if imgui.button(f"x##f{i}"): - app.files.pop(i) - break - # File Path - imgui.table_set_column_index(1) - imgui.text(f_item.path if hasattr(f_item, "path") else str(f_item)) - # Flags - imgui.table_set_column_index(2) - if hasattr(f_item, "auto_aggregate"): - changed_agg, f_item.auto_aggregate = imgui.checkbox(f"Agg##a{i}", f_item.auto_aggregate) - imgui.same_line() - changed_full, f_item.force_full = imgui.checkbox(f"Full##f{i}", f_item.force_full) - # Cache - imgui.table_set_column_index(3) - path = f_item.path if hasattr(f_item, "path") else str(f_item) - is_cached = any(path in c for c in getattr(app, "_cached_files", [])) - if is_cached: - imgui.text_colored("●", imgui.ImVec4(0, 1, 0, 1)) # Green dot - else: - imgui.text_disabled("○") - imgui.end_table() - imgui.end_child() - if imgui.button("Add File(s)"): - r = hide_tk_root() - paths = filedialog.askopenfilenames() - r.destroy() - for p in paths: - if p not in [f.path if hasattr(f, "path") else f for f in app.files]: - app.files.append(models.FileItem(path=p)) - imgui.same_line() - if imgui.button("Add Wildcard"): - r = hide_tk_root() - d = filedialog.askdirectory() - r.destroy() - if d: app.files.append(models.FileItem(path=str(Path(d) / "**" / "*"))) - - imgui.separator() - from src import summarize - stats = summarize._summary_cache.get_stats() - imgui.text_disabled(f"Summary Cache: {stats['entries']} entries ({stats['size_bytes']} bytes)") - imgui.same_line() - if imgui.button("Clear Summary Cache##btn_clear_summary_cache"): - app.controller._cb_clear_summary_cache() - - if app.perf_profiling_enabled: app.perf_monitor.end_component("_render_files_panel") - -def render_screenshots_panel(app: App, height_override: float = 0) -> None: - if app.perf_profiling_enabled: app.perf_monitor.start_component("_render_screenshots_panel") - imgui.text("Paths"); imgui.same_line(); imgui.text("| Base Dir:"); imgui.same_line(); - imgui.set_next_item_width(-100) - ch, app.ui_shots_base_dir = imgui.input_text("##s_base", app.ui_shots_base_dir) - imgui.same_line() - if imgui.button("Browse##sb"): - r = hide_tk_root(); d = filedialog.askdirectory(); r.destroy() - if d: app.ui_shots_base_dir = d - imgui.separator() - # Calculate content-based height: use override if provided, else content-based - if height_override > 0: shot_h = height_override - else: - shot_count = max(len(app.screenshots), 1) - shot_h = min(shot_count * 28 + 40, 200) - # BEGIN s_paths child window - imgui.begin_child("s_paths", imgui.ImVec2(0, shot_h), True) - for i, s in enumerate(app.screenshots): - if imgui.button(f"x##s{i}"): - app.screenshots.pop(i) - break - imgui.same_line(); imgui.text(s) - imgui.end_child() - if imgui.button("Add Screenshot(s)"): - r = hide_tk_root() - paths = filedialog.askopenfilenames( - title="Select Screenshots", - filetypes=[("Images", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"), ("All", "*.*")], - ) - r.destroy() - for p in paths: - if p not in app.screenshots: app.screenshots.append(p) - if app.perf_profiling_enabled: app.perf_monitor.end_component("_render_screenshots_panel") +#endregion: Context Management def render_context_batch_actions(app: App, total_lines: int, total_ast: int) -> None: imgui.text("Batch:") @@ -2914,166 +2803,7 @@ def render_context_composition_panel(app: App) -> None: render_context_screenshots(app) def render_ast_inspector_modal(app: App) -> None: - """ - [C: tests/test_ast_inspector_extended.py:test_ast_inspector_line_range_parsing] - """ - if app._show_ast_inspector: - imgui.open_popup('AST Inspector') - app._show_ast_inspector = False - - #region: AST Inspector - imgui.set_next_window_size(imgui.ImVec2(1200, 800), imgui.Cond_.first_use_ever) - expanded, opened = imgui.begin_popup_modal('AST Inspector', True, imgui.WindowFlags_.none) - if opened: - if expanded: - if app.ui_inspecting_ast_file is None: - imgui.close_current_popup() - else: - f_item = app.ui_inspecting_ast_file - f_path = f_item.path if hasattr(f_item, "path") else str(f_item) - - if f_path != app._cached_ast_file_path: - 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) - else: outline = mcp_client.ts_cpp_get_code_outline(f_path) - except Exception as e: - outline = f"Error fetching outline: {e}" - - app._cached_ast_nodes = [] - import re - 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() - except Exception: - app._cached_ast_file_lines = ["Error loading file content."] - app._cached_ast_file_path = f_path - - imgui.text(f"Inspecting AST: {f_path}") - imgui.separator() - - avail = imgui.get_content_region_avail() - table_height = max(100.0, avail.y - imgui.get_frame_height_with_spacing() - 10) - #region: ast_dual_pane - if imgui.begin_table('ast_dual_pane', 2, imgui.TableFlags_.resizable | imgui.TableFlags_.borders_inner_v, imgui.ImVec2(0, table_height)): - imgui.table_next_column() - - #region: LEFT COLUMN (Tree) --- - imgui.begin_child("ast_tree_scroll", imgui.ImVec2(0, 0), True) - if True: - if not app._cached_ast_nodes: imgui.text("No AST nodes found or error fetching outline.") - 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 - 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() - - 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.end_child() - #endregion: LEFT COLUMN (Tree) - - imgui.table_next_column() - - #region: RIGHT COLUMN (Content) --- - imgui.begin_child("ast_content_scroll", imgui.ImVec2(0, 0), True) - if True: - if not hasattr(app, '_cached_ast_file_lines') or not app._cached_ast_file_lines: - imgui.text("No file content loaded.") - else: - draw_list = imgui.get_window_draw_list() - for i, line_text in enumerate(app._cached_ast_file_lines): - line_num = i + 1 - - # Prioritize the most specific node (deepest indent) that covers the line - 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 = f_item.ast_mask.get(deepest_node['full_path'], 'hide') - - pos = imgui.get_cursor_screen_pos() - line_height = imgui.get_text_line_height() - - if mode == 'def': - # Green, alpha 0.2 - draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + imgui.get_content_region_avail().x, pos.y + line_height), imgui.get_color_u32(vec4(0, 255, 0, 0.2))) - elif mode == 'sig': - # Blue, alpha 0.2 - draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + imgui.get_content_region_avail().x, pos.y + line_height), imgui.get_color_u32(vec4(0, 0, 255, 0.2))) - elif deepest_node and deepest_node['full_path'] == getattr(app, '_hovered_ast_node', None): - # Yellow, alpha 0.3 for hover - draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + imgui.get_content_region_avail().x, pos.y + line_height), imgui.get_color_u32(vec4(255, 255, 0, 0.3))) - - imgui.text(f"{line_num:4} | {line_text}") - imgui.end_child() - #endregion: RIGHT COLUMN (Content) --- - imgui.end_table() - #endregion: ast_dual_pane - - imgui.separator() - - if imgui.button("Close", imgui.ImVec2(120, 0)): - app.ui_inspecting_ast_file = None - imgui.close_current_popup() - - imgui.end_popup() - - #endregion: AST Inspector - - if not opened: app.ui_inspecting_ast_file = None + pass def render_save_workspace_profile_modal(app: App) -> None: if app._show_save_workspace_profile_modal: diff --git a/src/structural_editor_modal.py b/src/structural_editor_modal.py index 0207e69b..d9b07cda 100644 --- a/src/structural_editor_modal.py +++ b/src/structural_editor_modal.py @@ -2,8 +2,10 @@ 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 +from src import imgui_scopes as imscope + +def vec4(r: float, g: float, b: float, a: float = 1.0) -> imgui.ImVec4: return imgui.ImVec4(r/255, g/255, b/255, a) +C_IN = vec4(140, 255, 160) if TYPE_CHECKING: from src.gui_2 import App @@ -157,8 +159,6 @@ def render_structural_file_editor_modal(app: 'App') -> 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()