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:
2026-03-06 17:35:41 -05:00
parent 2e44d0ea2e
commit 5956b4b9de
3 changed files with 80 additions and 92 deletions

View File

@@ -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"):