feat(ux): complete multi-panel case orchestration, presets, and main panel cognitive status

This commit is contained in:
operator
2026-10-10 12:32:45 -04:00
parent 340cc9e85b
commit f1252423a8
2 changed files with 465 additions and 28 deletions
+307 -25
View File
@@ -2641,10 +2641,50 @@ class MuseTUI:
else: else:
self._btn_menu_bounds = None self._btn_menu_bounds = None
# Agent Work & Cognitive Status Sub-header (visible without clicks)
cog = (self.data.cognitive_cache or {}).get(active_node) or {}
c_st = cog.get("status") or "IDLE"
c_locked = cog.get("cognitive_lock", False)
screen = cog.get("screen") or {}
active_scr_title = screen.get("title") or screen.get("url") or ""
assigned_ticket = None
for iss in (getattr(self.data, "work_issues_cache", []) or []):
if not isinstance(iss, dict):
continue
if (iss.get("assignee") or {}).get("username") == active_node and iss.get("state") == "open":
assigned_ticket = f"#{iss.get('number')} {(iss.get('title') or '')[:28]}"
break
div_rule_char = "━" if is_focused else "─" div_rule_char = "━" if is_focused else "─"
div_rule_attr = self._attr("user") if is_focused else self._attr("dim") div_rule_attr = self._attr("user") if is_focused else self._attr("dim")
self.safe_addstr(self.stdscr, y + 1, x, div_rule_char * w, div_rule_attr)
has_sub_bar = (h >= 14 and w >= 36)
if has_sub_bar:
# Cognitive indicator & active task
cog_str = f"● @{active_node.upper()} [{c_st}]"
cog_attr = self._attr("warn_banner") if c_st == "THINKING" else (self._attr("danger") if c_locked else self._attr("success"))
task_str = f"| Work: {assigned_ticket}" if assigned_ticket else (f"| Context: {active_scr_title[:24]}" if active_scr_title else f"| State: {c_st}")
actions_str = "[w:Case] [f:Refocus] [t:Thoughts] [p:Menu] [h:Heal]"
self.safe_addstr(self.stdscr, y + 1, x, " " * w, self._attr("dim"))
self.safe_addstr(self.stdscr, y + 1, x + 1, cog_str, cog_attr)
pos_x = x + 1 + len(cog_str) + 1
self.safe_addstr(self.stdscr, y + 1, pos_x, task_str[:max(0, w - pos_x - len(actions_str) - 2)], self._attr("dim"))
act_x = max(pos_x + len(task_str) + 2, x + w - len(actions_str) - 1)
if act_x + len(actions_str) <= x + w:
self.safe_addstr(self.stdscr, y + 1, act_x, actions_str, self._attr("wo_badge"))
self._btn_case_actions_bounds = (y + 1, act_x, act_x + len(actions_str))
else:
self._btn_case_actions_bounds = None
self.safe_addstr(self.stdscr, y + 2, x, div_rule_char * w, div_rule_attr)
transcript_y = y + 3
transcript_h = h - 3
else:
self._btn_case_actions_bounds = None
self.safe_addstr(self.stdscr, y + 1, x, div_rule_char * w, div_rule_attr)
transcript_y = y + 2 transcript_y = y + 2
transcript_h = h - 2 transcript_h = h - 2
@@ -3072,7 +3112,7 @@ class MuseTUI:
# Footer hints # Footer hints
hint_y = y + h - 1 hint_y = y + h - 1
hints = "[W]: Dispatch Work Ticket [F]: Refocus Main Chat [T]: Thoughts [P]: Menu [L]: Loopback Sweep [H]: Heal [r]: Refresh" hints = "[W]: Dispatch [a]: Assign Ticket [m]: Merge PR [F]: Refocus [T]: Thoughts [H]: Heal [L]: Loopback [r]: Refresh"
self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("header")) self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("header"))
def _render_approvals_view(self, y: int, x: int, h: int, w: int): def _render_approvals_view(self, y: int, x: int, h: int, w: int):
@@ -3660,7 +3700,7 @@ class MuseTUI:
is_context_popup = self.modal in ("chat_context", "agent_context", "message_context") is_context_popup = self.modal in ("chat_context", "agent_context", "message_context")
if is_context_popup: if is_context_popup:
modal_w = min(56, screen_w - 4) modal_w = min(56, screen_w - 4)
modal_h = min(17 if self.modal == "chat_context" else (14 if self.modal == "agent_context" else 11), screen_h - 2) modal_h = min(17 if self.modal == "chat_context" else (19 if self.modal == "agent_context" else 11), screen_h - 2)
anchor_x, anchor_y = getattr(self, "context_popup_pos", (screen_w // 4, screen_h // 3)) anchor_x, anchor_y = getattr(self, "context_popup_pos", (screen_w // 4, screen_h // 3))
top_y = max(1, min(anchor_y, screen_h - modal_h - 2)) top_y = max(1, min(anchor_y, screen_h - modal_h - 2))
left_x = max(1, min(anchor_x, screen_w - modal_w - 2)) left_x = max(1, min(anchor_x, screen_w - modal_w - 2))
@@ -4133,11 +4173,17 @@ class MuseTUI:
actions = [ actions = [
("open", "[1] ▶ Open Chat with Agent", "[Enter/o]"), ("open", "[1] ▶ Open Chat with Agent", "[Enter/o]"),
("approvals", "[2] ⚡ Resolve Approvals", "[a]"), ("dispatch", "[2] 🚀 Dispatch New Case / Build Ticket", "[w]"),
("tmux", "[3] 📋 Copy Tmux Command", "[t]"), ("thoughts", "[3] 🧠 Inspect Live Cognition & Screen", "[t]"),
("wo", "[4] 📝 Issue Work Order", "[w]"), ("menu", "[4] 👤 Navigate Agent Profile Menu", "[p]"),
("sync", "[5] 🔄 Sync Threads", "[s]"), ("refocus", "[5] 🎯 Refocus Agent to Main Chat", "[f]"),
("read_all", "[6] ✔ Mark All Read", "[m]"), ("approvals", "[6] ⚡ Resolve Approvals", "[a]"),
("heal", "[7] 🩺 Heal & Self-Repair Worker", "[h]"),
("loopback", "[8] ⚡ Sweep Loopback Escalation", "[l]"),
("tmux", "[9] 📋 Copy Tmux Command", "[c]"),
("wo", "[0] 📝 Issue Work Order", "[W]"),
("sync", "[s] 🔄 Sync Threads", "[s]"),
("read_all", "[m] ✔ Mark All Read", "[m]"),
] ]
self._agent_context_actions = actions self._agent_context_actions = actions
self.agent_context_sel = max(0, min(getattr(self, "agent_context_sel", 0), len(actions) - 1)) self.agent_context_sel = max(0, min(getattr(self, "agent_context_sel", 0), len(actions) - 1))
@@ -4154,7 +4200,7 @@ class MuseTUI:
self.safe_addstr(self.stdscr, cur_y, left_x + 3, f"{pointer}{label}", row_attr) self.safe_addstr(self.stdscr, cur_y, left_x + 3, f"{pointer}{label}", row_attr)
self.safe_addstr(self.stdscr, cur_y, left_x + modal_w - 3 - len(hotkey), hotkey, self._attr("dim") if not is_sel else self._attr("selected")) self.safe_addstr(self.stdscr, cur_y, left_x + modal_w - 3 - len(hotkey), hotkey, self._attr("dim") if not is_sel else self._attr("selected"))
self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Esc/q] Close [j/k/↑/↓] Navigate [1-8/Click] Run Action", self._attr("dim")) self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Esc/q] Close [j/k/↑/↓] Navigate [1-9,0,Hotkeys] Run Action", self._attr("dim"))
elif self.modal == "message_context": elif self.modal == "message_context":
m = getattr(self, "context_message", None) or {} m = getattr(self, "context_message", None) or {}
@@ -4194,8 +4240,16 @@ class MuseTUI:
elif self.modal == "work_dispatch": elif self.modal == "work_dispatch":
target = getattr(self, "dispatch_target_node", "dev") target = getattr(self, "dispatch_target_node", "dev")
self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Target Worker: @{target.upper()} [Tab: Cycle Worker]", self._attr("bold")) cog = (self.data.cognitive_cache or {}).get(target) or {}
self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, "Enter Build Ticket Title & Goal:", self._attr("dim")) c_st = cog.get("status") or "IDLE"
c_locked = cog.get("cognitive_lock", False)
lock_label = "LOCKED" if c_locked else "OPEN"
lock_attr = self._attr("danger") if c_locked else self._attr("success")
self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, "CASE ORCHESTRATION & BUILD DISPATCH", self._attr("bold"))
self.safe_addstr(self.stdscr, top_y + 3, left_x + 3, f"Target: @{target.upper():<6} [{c_st:<12}] Lock: {lock_label}", lock_attr)
preset_bar = "Presets: [F1: Build] [F2: Bugfix] [F3: Review] [F4: Audit] [Tab: Cycle Worker]"
self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, preset_bar[:modal_w - 6], self._attr("header"))
field_y = top_y + 5 field_y = top_y + 5
field_w = modal_w - 6 field_w = modal_w - 6
@@ -4203,8 +4257,8 @@ class MuseTUI:
self.safe_addstr(self.stdscr, field_y + 1, left_x + 3, "│ " + self.modal_input_buf.ljust(field_w - 2) + " │", self._attr("normal")) self.safe_addstr(self.stdscr, field_y + 1, left_x + 3, "│ " + self.modal_input_buf.ljust(field_w - 2) + " │", self._attr("normal"))
self.safe_addstr(self.stdscr, field_y + 2, left_x + 3, "└" + "─" * field_w + "┘", self._attr("dim")) self.safe_addstr(self.stdscr, field_y + 2, left_x + 3, "└" + "─" * field_w + "┘", self._attr("dim"))
self.safe_addstr(self.stdscr, top_y + modal_h - 4, left_x + 3, "⚡ Dispatches with 45s Synchronous Readback Gate", self._attr("header")) self.safe_addstr(self.stdscr, top_y + modal_h - 4, left_x + 3, "⚡ 45s Synchronous Readback Gate | Autonomous post-dispatch loopback", self._attr("dim"))
self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Enter] Start & Dispatch Ticket [Tab] Cycle Target [Esc] Cancel", self._attr("dim")) self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Enter] Dispatch Case [Tab] Cycle Target [F1-F4] Load Preset [Esc] Cancel", self._attr("dim"))
try: try:
self.stdscr.move(field_y + 1, left_x + 5 + self.modal_input_cursor) self.stdscr.move(field_y + 1, left_x + 5 + self.modal_input_cursor)
@@ -4234,7 +4288,7 @@ class MuseTUI:
clean_p = p.replace("\n", " ").strip() clean_p = p.replace("\n", " ").strip()
self.safe_addstr(self.stdscr, p_start_y + idx, left_x + 3, f"• {clean_p[:modal_w - 8]}", self._attr("normal")) self.safe_addstr(self.stdscr, p_start_y + idx, left_x + 3, f"• {clean_p[:modal_w - 8]}", self._attr("normal"))
self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[F] Refocus Main Chat [W] Dispatch Work [Esc/q] Close", self._attr("header")) self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[F] Refocus Main Chat [W] Dispatch Work [P] Profile Menu [Esc/q] Close", self._attr("header"))
elif self.modal == "agent_menu": elif self.modal == "agent_menu":
node = getattr(self, "selected_menu_node", self.data.active_node) node = getattr(self, "selected_menu_node", self.data.active_node)
@@ -4262,7 +4316,7 @@ class MuseTUI:
line_str = f"• {it['title']}: {it.get('detail', '')}{t_str}" line_str = f"• {it['title']}: {it.get('detail', '')}{t_str}"
self.safe_addstr(self.stdscr, m_start_y + idx, left_x + 3, line_str[:modal_w - 8], self._attr("normal")) self.safe_addstr(self.stdscr, m_start_y + idx, left_x + 3, line_str[:modal_w - 8], self._attr("normal"))
self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[1-4 / Tab] Switch Tab [Esc/q] Close", self._attr("dim")) self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[1-4/Tab] Tab [w] Dispatch [f] Refocus [h] Heal [t] Thoughts [Esc/q] Close", self._attr("header"))
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Keypress & Event Handling # Keypress & Event Handling
@@ -4871,6 +4925,10 @@ class MuseTUI:
nodes = list(self.data.nodes) nodes = list(self.data.nodes)
if self.box_tab == 1 and nodes and 0 <= getattr(self, "fleet_sel_idx", 0) < len(nodes): if self.box_tab == 1 and nodes and 0 <= getattr(self, "fleet_sel_idx", 0) < len(nodes):
target_node = nodes[self.fleet_sel_idx] target_node = nodes[self.fleet_sel_idx]
elif self.box_tab == 7:
all_workers = ["opm", "646", "dev", "pip", "def", "muse", "muse-main"]
s_idx = getattr(self, "work_sel_idx", 0)
target_node = all_workers[s_idx] if 0 <= s_idx < len(all_workers) else getattr(self, "dispatch_target_node", self.data.active_node)
else: else:
target_node = getattr(self, "dispatch_target_node", self.data.active_node) target_node = getattr(self, "dispatch_target_node", self.data.active_node)
@@ -4895,6 +4953,12 @@ class MuseTUI:
elif ch in (ord('h'), ord('H')): elif ch in (ord('h'), ord('H')):
self._async_heal_agent(target_node) self._async_heal_agent(target_node)
return True return True
elif self.box_tab == 7 and ch in (ord('m'), ord('M')):
self._async_merge_pr()
return True
elif self.box_tab == 7 and ch in (ord('a'), ord('A')):
self._async_assign_ticket(target_node)
return True
elif ch in (ord('r'), ord('R')): elif ch in (ord('r'), ord('R')):
threading.Thread(target=self.data._fetch_cognitive_and_work, daemon=True).start() threading.Thread(target=self.data._fetch_cognitive_and_work, daemon=True).start()
self.set_toast("Refreshing work pipeline & cognitive state...", "info") self.set_toast("Refreshing work pipeline & cognitive state...", "info")
@@ -4957,6 +5021,33 @@ class MuseTUI:
self.set_toast(f"Message Context Menu ({r_role})", "info") self.set_toast(f"Message Context Menu ({r_role})", "info")
return True return True
# Main Chat / Transcript Fast Orchestration Actions (NORMAL mode):
# 'w': Dispatch Case, 'W': Work Order, 'f': Refocus Main Chat, 't': Thoughts, 'p': Menu, 'h'/'H': Heal
if self.focus_pane == "transcript":
if ch == ord('w'):
self.dispatch_target_node = self.data.active_node
self.modal = "work_dispatch"
self.modal_input_buf = ""
self.modal_input_cursor = 0
return True
elif ch == ord('W'):
self.modal = "work_order"
self.modal_input_buf = ""
self.modal_input_cursor = 0
return True
elif ch == ord('f'):
self._async_refocus_main(self.data.active_node)
return True
elif ch in (ord('t'), ord('T')):
self._open_agent_thoughts(self.data.active_node)
return True
elif ch == ord('p'):
self._open_agent_menu(self.data.active_node)
return True
elif ch in (ord('h'), ord('H')):
self._async_heal_agent(self.data.active_node)
return True
# Fast Agent Search & Select Modal: 'f' or 'F', or '/' when focused on fleet or sidechats # Fast Agent Search & Select Modal: 'f' or 'F', or '/' when focused on fleet or sidechats
if ch in (ord('f'), ord('F')) or (self.focus_pane in ("fleet", "sidechats", "sidebar") and ch == ord('/')): if ch in (ord('f'), ord('F')) or (self.focus_pane in ("fleet", "sidechats", "sidebar") and ch == ord('/')):
self.modal = "agent_search" self.modal = "agent_search"
@@ -5177,7 +5268,8 @@ class MuseTUI:
# Work Order modal # Work Order modal
if ch in (ord('w'), ord('W')): if ch in (ord('w'), ord('W')):
self.modal = "work_order" self.modal = "work_dispatch" if ch == ord('w') else "work_order"
self.dispatch_target_node = self.data.active_node
self.modal_input_buf = "" self.modal_input_buf = ""
self.modal_input_cursor = 0 self.modal_input_cursor = 0
return True return True
@@ -5203,6 +5295,24 @@ class MuseTUI:
elif ch == ord('3'): elif ch == ord('3'):
threading.Thread(target=self._async_resolve_approval, args=("deny", cur_node), daemon=True).start() threading.Thread(target=self._async_resolve_approval, args=("deny", cur_node), daemon=True).start()
return True return True
elif ch in (ord('w'), ord('W')):
self.modal = "work_dispatch"
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.dispatch_target_node = cur_node
return True
elif ch in (ord('f'), ord('F')):
self._async_refocus_main(cur_node)
return True
elif ch in (ord('t'), ord('T')):
self._open_agent_thoughts(cur_node)
return True
elif ch in (ord('p'), ord('P')):
self._open_agent_menu(cur_node)
return True
elif ch in (ord('h'), ord('H')):
self._async_heal_agent(cur_node)
return True
elif ch in (ord('r'), ord('R')): elif ch in (ord('r'), ord('R')):
threading.Thread(target=self._async_resolve_approval, args=("reply", cur_node, "Proceed with standard execution."), daemon=True).start() threading.Thread(target=self._async_resolve_approval, args=("reply", cur_node, "Proceed with standard execution."), daemon=True).start()
return True return True
@@ -5229,13 +5339,37 @@ class MuseTUI:
# Box mode Fast Actions: Tab 4 (Tmux) # Box mode Fast Actions: Tab 4 (Tmux)
if self.mode == "box" and self.box_tab == 4: if self.mode == "box" and self.box_tab == 4:
if ch in (ord('x'), ord('X'), ord('d'), ord('D'), ord('K')):
with self.data.lock: with self.data.lock:
sessions = list(self.data.tmux_cache) sessions = list(self.data.tmux_cache)
t_node = self.data.active_node
if sessions and 0 <= self.tmux_sel_idx < len(sessions):
s_name = sessions[self.tmux_sel_idx].split(":")[0].strip()
if s_name.startswith("agent-"):
t_node = s_name.replace("agent-", "")
if ch in (ord('x'), ord('X'), ord('d'), ord('D'), ord('K')):
if sessions and 0 <= self.tmux_sel_idx < len(sessions): if sessions and 0 <= self.tmux_sel_idx < len(sessions):
sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip() sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip()
threading.Thread(target=self._async_kill_tmux, args=(sess_name,), daemon=True).start() threading.Thread(target=self._async_kill_tmux, args=(sess_name,), daemon=True).start()
return True return True
elif ch in (ord('w'), ord('W')):
self.modal = "work_dispatch"
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.dispatch_target_node = t_node
return True
elif ch in (ord('f'), ord('F')):
self._async_refocus_main(t_node)
return True
elif ch in (ord('t'), ord('T')):
self._open_agent_thoughts(t_node)
return True
elif ch in (ord('p'), ord('P')):
self._open_agent_menu(t_node)
return True
elif ch in (ord('h'), ord('H')):
self._async_heal_agent(t_node)
return True
elif ch in (ord('r'), ord('R')): elif ch in (ord('r'), ord('R')):
threading.Thread(target=self.data._fetch_tmux_sessions, daemon=True).start() threading.Thread(target=self.data._fetch_tmux_sessions, daemon=True).start()
self.set_toast("Refreshed tmux background sessions.", "info") self.set_toast("Refreshed tmux background sessions.", "info")
@@ -5769,7 +5903,7 @@ class MuseTUI:
is_context_popup = self.modal in ("chat_context", "agent_context", "message_context") is_context_popup = self.modal in ("chat_context", "agent_context", "message_context")
if is_context_popup: if is_context_popup:
modal_w = min(56, w - 4) modal_w = min(56, w - 4)
modal_h = min(17 if self.modal == "chat_context" else (14 if self.modal == "agent_context" else 11), h - 2) modal_h = min(17 if self.modal == "chat_context" else (19 if self.modal == "agent_context" else 11), h - 2)
anchor_x, anchor_y = getattr(self, "context_popup_pos", (w // 4, h // 3)) anchor_x, anchor_y = getattr(self, "context_popup_pos", (w // 4, h // 3))
top_y = max(1, min(anchor_y, h - modal_h - 2)) top_y = max(1, min(anchor_y, h - modal_h - 2))
left_x = max(1, min(anchor_x, w - modal_w - 2)) left_x = max(1, min(anchor_x, w - modal_w - 2))
@@ -6326,11 +6460,35 @@ class MuseTUI:
"index": 0, "index": 0,
} }
self.context_popup_pos = (mx, my) self.context_popup_pos = (mx, my)
self.chat_context_sel = 0
self.modal = "chat_context" self.modal = "chat_context"
self._modal_open_time = time.time() self._modal_open_time = time.time()
return True return True
# Check if clicked on sub-header case actions / agent profile
if getattr(self, "_btn_case_actions_bounds", None):
btn_y, btn_x_start, btn_x_end = self._btn_case_actions_bounds
if my == btn_y:
if mx < btn_x_start:
# Clicked on agent pill / context in sub-header: open profile menu!
self._open_agent_menu(self.data.active_node)
return True
elif btn_x_start <= mx <= btn_x_end:
rel = mx - btn_x_start
if rel < 9: # "[w:Case]"
self.dispatch_target_node = self.data.active_node
self.modal = "work_dispatch"
self.modal_input_buf = ""
self.modal_input_cursor = 0
elif rel < 21: # "[f:Refocus]"
self._async_refocus_main(self.data.active_node)
elif rel < 34: # "[t:Thoughts]"
self._open_agent_thoughts(self.data.active_node)
elif rel < 43: # "[p:Menu]"
self._open_agent_menu(self.data.active_node)
else: # "[h:Heal]"
self._async_heal_agent(self.data.active_node)
return True
# Check if clicked on any visible [↩ Reply] button on message headers # Check if clicked on any visible [↩ Reply] button on message headers
for (msg_y, btn_x_start, btn_x_end, msg_obj) in getattr(self, "_msg_reply_buttons", []): for (msg_y, btn_x_start, btn_x_end, msg_obj) in getattr(self, "_msg_reply_buttons", []):
if my == msg_y and btn_x_start <= mx <= btn_x_end: if my == msg_y and btn_x_start <= mx <= btn_x_end:
@@ -7215,6 +7373,68 @@ class MuseTUI:
self.data._fetch_cognitive_and_work() self.data._fetch_cognitive_and_work()
threading.Thread(target=_run, daemon=True).start() threading.Thread(target=_run, daemon=True).start()
def _async_merge_pr(self, pr_num: Optional[int] = None):
"""Verify and merge an open PR into master."""
if pr_num is None:
with self.data.lock:
prs = list(getattr(self.data, "work_prs_cache", []))
open_prs = [p for p in prs if isinstance(p, dict) and not p.get("merged") and (p.get("state") or "").lower() == "open"]
if not open_prs:
self.set_toast("⚠️ No open PRs found to merge.", "warn_banner")
return
target_pr = open_prs[0].get("number")
else:
target_pr = pr_num
self.set_toast(f"⚡ Verifying and merging PR #{target_pr} into master...", "info")
def _run():
try:
cmd = [sys.executable, str(BIN_DIR / "box-work.py"), "merge", str(target_pr)]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
if res.returncode == 0:
self.set_toast(f"✔ Successfully merged PR #{target_pr} into master.", "success")
else:
err = (res.stderr or res.stdout or "Merge failed").strip().splitlines()[-1]
self.set_toast(f"❌ PR #{target_pr} merge error: {err[:45]}", "error")
except Exception as e:
self.set_toast(f"❌ PR #{target_pr} merge exception: {str(e)[:40]}", "error")
self.data._fetch_cognitive_and_work()
threading.Thread(target=_run, daemon=True).start()
def _async_assign_ticket(self, target_node: str, issue_num: Optional[int] = None):
"""Assign an issue ticket to a target worker node."""
if issue_num is None:
with self.data.lock:
issues = list(getattr(self.data, "work_issues_cache", []))
unassigned = [i for i in issues if isinstance(i, dict) and (i.get("state") or "").lower() == "open"]
free_tickets = [i for i in unassigned if not (i.get("assignee") or {}).get("username")]
candidate = free_tickets[0] if free_tickets else (unassigned[0] if unassigned else None)
if not candidate:
self.dispatch_target_node = target_node
self.modal = "work_dispatch"
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.set_toast(f"No open tickets. Enter goal to dispatch new ticket to @{target_node.upper()}.", "info")
return
target_issue = candidate.get("number")
else:
target_issue = issue_num
self.set_toast(f"⚡ Assigning Issue #{target_issue} to @{target_node.upper()}...", "info")
def _run():
try:
cmd = [sys.executable, str(BIN_DIR / "box-work.py"), "assign", str(target_issue), "--to", target_node]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
if res.returncode == 0:
self.set_toast(f"✔ Issue #{target_issue} assigned to @{target_node.upper()}", "success")
else:
err = (res.stderr or res.stdout or "Assignment failed").strip().splitlines()[-1]
self.set_toast(f"⚠️ Issue #{target_issue} warning: {err[:45]}", "warn_banner")
except Exception as e:
self.set_toast(f"❌ Issue assignment error: {str(e)[:40]}", "error")
self.data._fetch_cognitive_and_work()
threading.Thread(target=_run, daemon=True).start()
def _open_agent_thoughts(self, node: str): def _open_agent_thoughts(self, node: str):
"""Open modal streaming live thought paragraphs and screen state.""" """Open modal streaming live thought paragraphs and screen state."""
self.selected_thought_node = node self.selected_thought_node = node
@@ -7414,6 +7634,24 @@ class MuseTUI:
self.focus_pane = "sidebar" self.focus_pane = "sidebar"
self.set_toast(f"Focused sidebar on {node.upper()}", "info") self.set_toast(f"Focused sidebar on {node.upper()}", "info")
self.modal = None self.modal = None
elif action_id == "dispatch":
self.dispatch_target_node = node
self.modal = "work_dispatch"
self.modal_input_buf = ""
self.modal_input_cursor = 0
elif action_id == "thoughts":
self._open_agent_thoughts(node)
elif action_id == "menu":
self._open_agent_menu(node)
elif action_id == "refocus":
self._async_refocus_main(node)
self.modal = None
elif action_id == "heal":
self._async_heal_agent(node)
self.modal = None
elif action_id == "loopback":
self._async_run_loopback()
self.modal = None
def _execute_message_action(self, action_id: str): def _execute_message_action(self, action_id: str):
"""Execute selected contextual action on the targeted message.""" """Execute selected contextual action on the targeted message."""
@@ -7542,7 +7780,9 @@ class MuseTUI:
elif self.modal == "agent_context": elif self.modal == "agent_context":
actions = getattr(self, "_agent_context_actions", [ actions = getattr(self, "_agent_context_actions", [
("open", "", ""), ("approvals", "", ""), ("tmux", "", ""), ("open", "", ""), ("dispatch", "", ""), ("thoughts", "", ""),
("menu", "", ""), ("refocus", "", ""), ("approvals", "", ""),
("heal", "", ""), ("loopback", "", ""), ("tmux", "", ""),
("wo", "", ""), ("sync", "", ""), ("read_all", "", "") ("wo", "", ""), ("sync", "", ""), ("read_all", "", "")
]) ])
if ch in (27, ord('q'), ord('Q')): if ch in (27, ord('q'), ord('Q')):
@@ -7563,11 +7803,17 @@ class MuseTUI:
key_map = { key_map = {
ord('1'): "open", ord('o'): "open", ord('O'): "open", ord('1'): "open", ord('o'): "open", ord('O'): "open",
ord('2'): "approvals", ord('a'): "approvals", ord('A'): "approvals", ord('2'): "dispatch", ord('w'): "dispatch",
ord('3'): "tmux", ord('t'): "tmux", ord('T'): "tmux", ord('3'): "thoughts", ord('t'): "thoughts", ord('T'): "thoughts",
ord('4'): "wo", ord('w'): "wo", ord('W'): "wo", ord('4'): "menu", ord('p'): "menu", ord('P'): "menu",
ord('5'): "sync", ord('s'): "sync", ord('S'): "sync", ord('5'): "refocus", ord('f'): "refocus", ord('F'): "refocus",
ord('6'): "read_all", ord('m'): "read_all", ord('M'): "read_all", ord('6'): "approvals", ord('a'): "approvals", ord('A'): "approvals",
ord('7'): "heal", ord('h'): "heal", ord('H'): "heal",
ord('8'): "loopback", ord('l'): "loopback", ord('L'): "loopback",
ord('9'): "tmux", ord('c'): "tmux", ord('C'): "tmux",
ord('0'): "wo", ord('W'): "wo",
ord('s'): "sync", ord('S'): "sync",
ord('m'): "read_all", ord('M'): "read_all",
} }
if ch in key_map: if ch in key_map:
self._execute_agent_action(key_map[ch]) self._execute_agent_action(key_map[ch])
@@ -7779,6 +8025,22 @@ class MuseTUI:
threading.Thread(target=self._async_dispatch_work, args=(val, target), daemon=True).start() threading.Thread(target=self._async_dispatch_work, args=(val, target), daemon=True).start()
self.modal = None self.modal = None
return True return True
if ch == curses.KEY_F1:
self.modal_input_buf = "Build: Implement feature specification and tests"
self.modal_input_cursor = len(self.modal_input_buf)
return True
elif ch == curses.KEY_F2:
self.modal_input_buf = "Bugfix: Investigate and resolve error trace"
self.modal_input_cursor = len(self.modal_input_buf)
return True
elif ch == curses.KEY_F3:
self.modal_input_buf = "Review: Code review and verify pull request changes"
self.modal_input_cursor = len(self.modal_input_buf)
return True
elif ch == curses.KEY_F4:
self.modal_input_buf = "Audit: Pre-flight health and security audit"
self.modal_input_cursor = len(self.modal_input_buf)
return True
if ch in (curses.KEY_BACKSPACE, 127, 8): if ch in (curses.KEY_BACKSPACE, 127, 8):
if self.modal_input_cursor > 0: if self.modal_input_cursor > 0:
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:] self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:]
@@ -7817,6 +8079,9 @@ class MuseTUI:
self.modal_input_buf = "" self.modal_input_buf = ""
self.modal_input_cursor = 0 self.modal_input_cursor = 0
return True return True
elif ch in (ord('p'), ord('P')):
self._open_agent_menu(node)
return True
elif ch in (ord('r'), ord('R')): elif ch in (ord('r'), ord('R')):
self._open_agent_thoughts(node) self._open_agent_thoughts(node)
return True return True
@@ -7840,6 +8105,23 @@ class MuseTUI:
elif ch in (ord('4'), ord('i'), ord('I')): elif ch in (ord('4'), ord('i'), ord('I')):
self._async_load_menu(node, "identity") self._async_load_menu(node, "identity")
return True return True
elif ch in (ord('w'), ord('W')):
self.dispatch_target_node = node
self.modal = "work_dispatch"
self.modal_input_buf = ""
self.modal_input_cursor = 0
return True
elif ch in (ord('f'), ord('F')):
self._async_refocus_main(node)
self.modal = None
return True
elif ch in (ord('h'), ord('H')):
self._async_heal_agent(node)
self.modal = None
return True
elif ch in (ord('t'), ord('T')):
self._open_agent_thoughts(node)
return True
elif ch == ord('\t'): elif ch == ord('\t'):
c_idx = tabs.index(cur_tab) if cur_tab in tabs else 0 c_idx = tabs.index(cur_tab) if cur_tab in tabs else 0
next_tab = tabs[(c_idx + 1) % len(tabs)] next_tab = tabs[(c_idx + 1) % len(tabs)]
+155
View File
@@ -4,6 +4,7 @@ import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import os import os
import sys import sys
import curses
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -138,6 +139,160 @@ class TestBoxTUIHeadless(unittest.TestCase):
app._handle_key(ord('\t')) app._handle_key(ord('\t'))
mock_load.assert_called_with("pip", "upcoming") mock_load.assert_called_with("pip", "upcoming")
def test_transcript_sub_header_render_and_mouse_click(self):
app = muse_tui.MuseTUI(self.mock_stdscr, initial_mode="muse")
app.data.active_node = "dev"
app.focus_pane = "transcript"
app._render_transcript(0, 0, 30, 100)
self.assertIsNotNone(app._btn_case_actions_bounds)
btn_y, b_start, b_end = app._btn_case_actions_bounds
# Click [w:Case] (offset +2)
with patch.object(app, "_open_agent_menu") as mock_menu:
handled = app._handle_mouse(b_start + 2, btn_y, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
self.assertEqual(app.modal, "work_dispatch")
app.modal = None
# Click [f:Refocus] (offset +12)
with patch.object(app, "_async_refocus_main") as mock_refocus:
handled = app._handle_mouse(b_start + 12, btn_y, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
mock_refocus.assert_called_with("dev")
# Click [t:Thoughts] (offset +25)
with patch.object(app, "_open_agent_thoughts") as mock_thoughts:
handled = app._handle_mouse(b_start + 25, btn_y, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
mock_thoughts.assert_called_with("dev")
# Click [p:Menu] (offset +38)
with patch.object(app, "_open_agent_menu") as mock_menu:
handled = app._handle_mouse(b_start + 38, btn_y, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
mock_menu.assert_called_with("dev")
# Click [h:Heal] (offset +46)
with patch.object(app, "_async_heal_agent") as mock_heal:
handled = app._handle_mouse(b_start + 46, btn_y, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
mock_heal.assert_called_with("dev")
def test_agent_context_twelve_actions_and_hotkeys(self):
app = muse_tui.MuseTUI(self.mock_stdscr, initial_mode="muse")
app.data.active_node = "646"
app.context_agent = {"node": "646", "status": {}, "index": 0}
app.modal = "agent_context"
app._render_modal(40, 120)
self.assertEqual(len(app._agent_context_actions), 12)
# Hotkey 2 / w: Dispatch
app.modal = "agent_context"
app._handle_key(ord('2'))
self.assertEqual(app.modal, "work_dispatch")
self.assertEqual(app.dispatch_target_node, "646")
# Hotkey 3 / t: Thoughts
app.modal = "agent_context"
with patch.object(app, "_open_agent_thoughts") as mock_th:
app._handle_key(ord('3'))
mock_th.assert_called_with("646")
# Hotkey 4 / p: Menu
app.modal = "agent_context"
with patch.object(app, "_open_agent_menu") as mock_menu:
app._handle_key(ord('4'))
mock_menu.assert_called_with("646")
# Hotkey 5 / f: Refocus
app.modal = "agent_context"
with patch.object(app, "_async_refocus_main") as mock_ref:
app._handle_key(ord('5'))
mock_ref.assert_called_with("646")
# Hotkey 7 / h: Heal
app.modal = "agent_context"
with patch.object(app, "_async_heal_agent") as mock_heal:
app._handle_key(ord('7'))
mock_heal.assert_called_with("646")
# Hotkey 8 / l: Loopback
app.modal = "agent_context"
with patch.object(app, "_async_run_loopback") as mock_loop:
app._handle_key(ord('8'))
mock_loop.assert_called_once()
def test_work_dispatch_presets_f1_to_f4(self):
app = muse_tui.MuseTUI(self.mock_stdscr, initial_mode="muse")
app.modal = "work_dispatch"
app.modal_input_buf = ""
app.modal_input_cursor = 0
app.dispatch_target_node = "dev"
app._handle_key(curses.KEY_F1)
self.assertEqual(app.modal_input_buf, "Build: Implement feature specification and tests")
app._handle_key(curses.KEY_F2)
self.assertEqual(app.modal_input_buf, "Bugfix: Investigate and resolve error trace")
app._handle_key(curses.KEY_F3)
self.assertEqual(app.modal_input_buf, "Review: Code review and verify pull request changes")
app._handle_key(curses.KEY_F4)
self.assertEqual(app.modal_input_buf, "Audit: Pre-flight health and security audit")
# Enter triggers dispatch
with patch("threading.Thread") as mock_thread:
app._handle_key(10)
mock_thread.assert_called()
self.assertIsNone(app.modal)
def test_main_panel_normal_mode_orchestration_hotkeys(self):
app = muse_tui.MuseTUI(self.mock_stdscr, initial_mode="muse")
app.data.active_node = "opm"
app.focus_pane = "transcript"
app.editor_mode = "NORMAL"
# 'w' -> work_dispatch
app._handle_key(ord('w'))
self.assertEqual(app.modal, "work_dispatch")
self.assertEqual(app.dispatch_target_node, "opm")
app.modal = None
# 'f' -> refocus
with patch.object(app, "_async_refocus_main") as mock_ref:
app._handle_key(ord('f'))
mock_ref.assert_called_with("opm")
# 't' -> thoughts
with patch.object(app, "_open_agent_thoughts") as mock_th:
app._handle_key(ord('t'))
mock_th.assert_called_with("opm")
# 'p' -> menu
with patch.object(app, "_open_agent_menu") as mock_menu:
app._handle_key(ord('p'))
mock_menu.assert_called_with("opm")
# 'h' / 'H' -> heal
with patch.object(app, "_async_heal_agent") as mock_heal:
app._handle_key(ord('h'))
mock_heal.assert_called_with("opm")
def test_work_pipeline_assign_and_merge_hotkeys(self):
app = muse_tui.MuseTUI(self.mock_stdscr, initial_mode="box", initial_tab="work")
app.box_tab = 7
app.work_sel_idx = 1 # 646 in all_workers
with patch.object(app, "_async_merge_pr") as mock_merge:
app._handle_key(ord('m'))
mock_merge.assert_called_once()
with patch.object(app, "_async_assign_ticket") as mock_assign:
app._handle_key(ord('a'))
mock_assign.assert_called_with("646")
def test_muse_tui_all_tabs_fuzz_dimensions_and_corrupt_data(self): def test_muse_tui_all_tabs_fuzz_dimensions_and_corrupt_data(self):
dimensions = [(8, 20), (12, 40), (24, 80), (50, 160)] dimensions = [(8, 20), (12, 40), (24, 80), (50, 160)]