diff --git a/conductor/tracks.md b/conductor/tracks.md index 3a0820a..27a6372 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -23,7 +23,7 @@ This file tracks all major tracks for the project. Each track has its own detail 2. [x] **Track: Deep AST-Driven Context Pruning (RAG for Code)** *Link: [./tracks/deep_ast_context_pruning_20260306/](./tracks/deep_ast_context_pruning_20260306/)* -3. [ ] **Track: Visual DAG & Interactive Ticket Editing** +3. [~] **Track: Visual DAG & Interactive Ticket Editing** *Link: [./tracks/visual_dag_ticket_editing_20260306/](./tracks/visual_dag_ticket_editing_20260306/)* 4. [ ] **Track: Advanced Tier 4 QA Auto-Patching** diff --git a/src/gui_2.py b/src/gui_2.py index f6be4ba..c586895 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -23,7 +23,7 @@ from src import models from src import app_controller from pydantic import BaseModel -from imgui_bundle import imgui, hello_imgui, immapp +from imgui_bundle import imgui, hello_imgui, immapp, imgui_node_editor as ed PROVIDERS: list[str] = ["gemini", "anthropic", "gemini_cli", "deepseek"] COMMS_CLAMP_CHARS: int = 300 @@ -103,6 +103,10 @@ class App: self._api_event_queue_lock = self.controller._api_event_queue_lock self._discussion_names_cache: list[str] = [] self._discussion_names_dirty: bool = True + # Initialize node editor context + self.node_editor_config = ed.Config() + self.node_editor_ctx = ed.create_editor(self.node_editor_config) + self.ui_selected_ticket_id: Optional[str] = None def _handle_approve_tool(self, user_data=None) -> None: """UI-level wrapper for approving a pending tool execution ask.""" @@ -1542,25 +1546,46 @@ class App: # 4. Task DAG Visualizer imgui.text("Task DAG") if self.active_track: - tickets_by_id = {str(t.get('id') or ''): t for t in self.active_tickets} - all_ids = set(tickets_by_id.keys()) - # Build children map - children_map: dict[str, list[str]] = {} + ed.set_current_editor(self.node_editor_ctx) + ed.begin('Visual DAG') + # Selection detection + selected = ed.get_selected_nodes() + if selected: + for node_id in selected: + for t in self.active_tickets: + if abs(hash(str(t.get('id', '')))) == node_id: + self.ui_selected_ticket_id = str(t.get('id', '')) + break + break for t in self.active_tickets: + tid = str(t.get('id', '??')) + int_id = abs(hash(tid)) + ed.begin_node(int_id) + imgui.text_colored(C_KEY, f"Ticket: {tid}") + status = t.get('status', 'todo') + s_col = C_VAL + if status == 'done' or status == 'complete': s_col = C_IN + elif status == 'in_progress' or status == 'running': s_col = C_OUT + elif status == 'error': s_col = imgui.ImVec4(1, 0.4, 0.4, 1) + imgui.text("Status: ") + imgui.same_line() + imgui.text_colored(s_col, status) + imgui.text(f"Target: {t.get('target_file','')}") + ed.begin_pin(abs(hash(tid + "_in")), ed.PinKind.input) + imgui.text("->") + ed.end_pin() + imgui.same_line() + ed.begin_pin(abs(hash(tid + "_out")), ed.PinKind.output) + imgui.text("->") + ed.end_pin() + ed.end_node() + for t in self.active_tickets: + tid = str(t.get('id', '??')) for dep in t.get('depends_on', []): - if dep not in children_map: children_map[dep] = [] - children_map[dep].append(str(t.get('id') or '')) - # Roots are those whose depends_on elements are NOT in all_ids - roots = [] - for t in self.active_tickets: - deps = t.get('depends_on', []) - has_local_dep = any(d in all_ids for d in deps) - if not has_local_dep: - roots.append(t) - rendered: set[str] = set() - for root in roots: - self._render_ticket_dag_node(root, tickets_by_id, children_map, rendered) - # 5. Add Ticket Form + ed.link(abs(hash(dep + "_" + tid)), abs(hash(dep + "_out")), abs(hash(tid + "_in"))) + ed.end() + ed.set_current_editor(None) + # 5. Add Ticket Form imgui.separator() if imgui.button("Add Ticket"): self._show_add_ticket_form = not self._show_add_ticket_form @@ -1602,6 +1627,25 @@ class App: else: imgui.text_disabled("No active MMA track.") + # 6. Edit Selected Ticket + if self.ui_selected_ticket_id: + imgui.separator() + imgui.text_colored(C_VAL, f"Editing: {self.ui_selected_ticket_id}") + ticket = next((t for t in self.active_tickets if str(t.get('id', '')) == self.ui_selected_ticket_id), None) + if ticket: + imgui.text(f"Status: {ticket.get('status', 'todo')}") + imgui.text(f"Target: {ticket.get('target_file', '')}") + deps = ticket.get('depends_on', []) + imgui.text(f"Depends on: {', '.join(deps)}") + if imgui.button(f"Mark Complete##{self.ui_selected_ticket_id}"): + ticket['status'] = 'done' + self._push_mma_state_update() + imgui.same_line() + if imgui.button(f"Delete##{self.ui_selected_ticket_id}"): + self.active_tickets = [t for t in self.active_tickets if str(t.get('id', '')) != self.ui_selected_ticket_id] + self.ui_selected_ticket_id = None + self._push_mma_state_update() + def _render_tier_stream_panel(self, tier_key: str, stream_key: str | None) -> None: if stream_key is not None: content = self.mma_streams.get(stream_key, "") @@ -1632,79 +1676,6 @@ class App: pass imgui.end_child() - def _render_ticket_dag_node(self, ticket: dict[str, Any], tickets_by_id: dict[str, Any], children_map: dict[str, list[str]], rendered: set[str]) -> None: - tid = ticket.get('id', '??') - is_duplicate = tid in rendered - if not is_duplicate: - rendered.add(tid) - target = ticket.get('target_file', 'general') - status = ticket.get('status', 'pending').upper() - status_color = vec4(178, 178, 178) - if status == 'RUNNING': - status_color = vec4(255, 255, 0) - elif status == 'COMPLETE': - status_color = vec4(0, 255, 0) - elif status in ['BLOCKED', 'ERROR']: - status_color = vec4(255, 0, 0) - elif status == 'PAUSED': - status_color = vec4(255, 165, 0) - p_min = imgui.get_cursor_screen_pos() - p_max = imgui.ImVec2(p_min.x + 4, p_min.y + imgui.get_text_line_height()) - imgui.get_window_draw_list().add_rect_filled(p_min, p_max, imgui.get_color_u32(status_color)) - imgui.set_cursor_screen_pos(imgui.ImVec2(p_min.x + 8, p_min.y)) - flags = imgui.TreeNodeFlags_.open_on_arrow | imgui.TreeNodeFlags_.open_on_double_click | imgui.TreeNodeFlags_.default_open - children = children_map.get(tid, []) - if not children or is_duplicate: - flags |= imgui.TreeNodeFlags_.leaf - node_open = imgui.tree_node_ex(f"##{tid}", flags) - if imgui.is_item_hovered(): - imgui.begin_tooltip() - imgui.text_colored(C_KEY, f"ID: {tid}") - imgui.text_colored(C_LBL, f"Target: {target}") - imgui.text_colored(C_LBL, "Description:") - imgui.same_line() - imgui.text_wrapped(ticket.get('description', 'N/A')) - deps = ticket.get('depends_on', []) - if deps: - imgui.text_colored(C_LBL, f"Depends on: {', '.join(deps)}") - stream_key = f"Tier 3: {tid}" - if stream_key in self.mma_streams: - imgui.separator() - imgui.text_colored(C_KEY, "Worker Stream:") - imgui.text_wrapped(self.mma_streams[stream_key]) - imgui.end_tooltip() - imgui.same_line() - imgui.text_colored(C_KEY, tid) - imgui.same_line(150) - imgui.text_disabled(str(target)) - imgui.same_line(400) - imgui.text_colored(status_color, status) - imgui.same_line(500) - if imgui.button(f"Retry##{tid}"): - self._cb_ticket_retry(tid) - imgui.same_line() - if imgui.button(f"Skip##{tid}"): - self._cb_ticket_skip(tid) - if status in ['TODO', 'BLOCKED']: - imgui.same_line() - if imgui.button(f"Delete##{tid}"): - self.active_tickets = [t for t in self.active_tickets if t.get('id') != tid] - for t in self.active_tickets: - deps = t.get('depends_on', []) - if tid in deps: - t['depends_on'] = [d for d in deps if d != tid] - self._push_mma_state_update() - if is_duplicate: - imgui.same_line() - imgui.text_disabled("(shown above)") - if node_open and not is_duplicate: - for child_id in children: - child = tickets_by_id.get(child_id) - if child: - self._render_ticket_dag_node(child, tickets_by_id, children_map, rendered) - imgui.tree_pop() - - def _render_comms_history_panel(self) -> None: imgui.text_colored(vec4(200, 220, 160), f"Status: {self.ai_status}") imgui.same_line() if imgui.button("Clear##comms"): diff --git a/tests/test_mma_node_editor.py b/tests/test_mma_node_editor.py new file mode 100644 index 0000000..dcdb008 --- /dev/null +++ b/tests/test_mma_node_editor.py @@ -0,0 +1,17 @@ +import pytest +from unittest.mock import MagicMock, patch +import sys + +def test_imgui_node_editor_import(): + from imgui_bundle import imgui_node_editor as ed + assert ed is not None + assert hasattr(ed, "begin_node") + assert hasattr(ed, "end_node") + +def test_app_has_node_editor_attrs(): + from src.gui_2 import App + import inspect + source = inspect.getsource(App.__init__) + assert 'node_editor_config' in source + assert 'node_editor_ctx' in source + assert 'ui_selected_ticket_id' in source