feat(gui): Implement Visual DAG with imgui_node_editor
- Add node editor context and config in App.__init__ - Replace tree-based DAG with imgui_node_editor visualization - Add selection detection for interactive ticket editing - Add edit panel for selected ticket (view status, target, deps, mark complete, delete) - Add ui_selected_ticket_id state variable
This commit is contained in:
@@ -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)**
|
2. [x] **Track: Deep AST-Driven Context Pruning (RAG for Code)**
|
||||||
*Link: [./tracks/deep_ast_context_pruning_20260306/](./tracks/deep_ast_context_pruning_20260306/)*
|
*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/)*
|
*Link: [./tracks/visual_dag_ticket_editing_20260306/](./tracks/visual_dag_ticket_editing_20260306/)*
|
||||||
|
|
||||||
4. [ ] **Track: Advanced Tier 4 QA Auto-Patching**
|
4. [ ] **Track: Advanced Tier 4 QA Auto-Patching**
|
||||||
|
|||||||
153
src/gui_2.py
153
src/gui_2.py
@@ -23,7 +23,7 @@ from src import models
|
|||||||
from src import app_controller
|
from src import app_controller
|
||||||
|
|
||||||
from pydantic import BaseModel
|
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"]
|
PROVIDERS: list[str] = ["gemini", "anthropic", "gemini_cli", "deepseek"]
|
||||||
COMMS_CLAMP_CHARS: int = 300
|
COMMS_CLAMP_CHARS: int = 300
|
||||||
@@ -103,6 +103,10 @@ class App:
|
|||||||
self._api_event_queue_lock = self.controller._api_event_queue_lock
|
self._api_event_queue_lock = self.controller._api_event_queue_lock
|
||||||
self._discussion_names_cache: list[str] = []
|
self._discussion_names_cache: list[str] = []
|
||||||
self._discussion_names_dirty: bool = True
|
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:
|
def _handle_approve_tool(self, user_data=None) -> None:
|
||||||
"""UI-level wrapper for approving a pending tool execution ask."""
|
"""UI-level wrapper for approving a pending tool execution ask."""
|
||||||
@@ -1542,25 +1546,46 @@ class App:
|
|||||||
# 4. Task DAG Visualizer
|
# 4. Task DAG Visualizer
|
||||||
imgui.text("Task DAG")
|
imgui.text("Task DAG")
|
||||||
if self.active_track:
|
if self.active_track:
|
||||||
tickets_by_id = {str(t.get('id') or ''): t for t in self.active_tickets}
|
ed.set_current_editor(self.node_editor_ctx)
|
||||||
all_ids = set(tickets_by_id.keys())
|
ed.begin('Visual DAG')
|
||||||
# Build children map
|
# Selection detection
|
||||||
children_map: dict[str, list[str]] = {}
|
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:
|
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', []):
|
for dep in t.get('depends_on', []):
|
||||||
if dep not in children_map: children_map[dep] = []
|
ed.link(abs(hash(dep + "_" + tid)), abs(hash(dep + "_out")), abs(hash(tid + "_in")))
|
||||||
children_map[dep].append(str(t.get('id') or ''))
|
ed.end()
|
||||||
# Roots are those whose depends_on elements are NOT in all_ids
|
ed.set_current_editor(None)
|
||||||
roots = []
|
# 5. Add Ticket Form
|
||||||
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
|
|
||||||
imgui.separator()
|
imgui.separator()
|
||||||
if imgui.button("Add Ticket"):
|
if imgui.button("Add Ticket"):
|
||||||
self._show_add_ticket_form = not self._show_add_ticket_form
|
self._show_add_ticket_form = not self._show_add_ticket_form
|
||||||
@@ -1602,6 +1627,25 @@ class App:
|
|||||||
else:
|
else:
|
||||||
imgui.text_disabled("No active MMA track.")
|
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:
|
def _render_tier_stream_panel(self, tier_key: str, stream_key: str | None) -> None:
|
||||||
if stream_key is not None:
|
if stream_key is not None:
|
||||||
content = self.mma_streams.get(stream_key, "")
|
content = self.mma_streams.get(stream_key, "")
|
||||||
@@ -1632,79 +1676,6 @@ class App:
|
|||||||
pass
|
pass
|
||||||
imgui.end_child()
|
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.text_colored(vec4(200, 220, 160), f"Status: {self.ai_status}")
|
||||||
imgui.same_line()
|
imgui.same_line()
|
||||||
if imgui.button("Clear##comms"):
|
if imgui.button("Clear##comms"):
|
||||||
|
|||||||
17
tests/test_mma_node_editor.py
Normal file
17
tests/test_mma_node_editor.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user