diff --git a/bin/muse-tui.py b/bin/muse-tui.py index b933d0a..bb4c3aa 100755 --- a/bin/muse-tui.py +++ b/bin/muse-tui.py @@ -2641,12 +2641,52 @@ class MuseTUI: else: 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_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) - transcript_y = y + 2 - transcript_h = h - 2 + 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_h = h - 2 if not msgs: is_loading_hist = self.data.is_loading_history(active_node, active_tid) @@ -3072,7 +3112,7 @@ class MuseTUI: # Footer hints 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")) 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") if is_context_popup: 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)) top_y = max(1, min(anchor_y, screen_h - modal_h - 2)) left_x = max(1, min(anchor_x, screen_w - modal_w - 2)) @@ -4133,11 +4173,17 @@ class MuseTUI: actions = [ ("open", "[1] ▶ Open Chat with Agent", "[Enter/o]"), - ("approvals", "[2] ⚡ Resolve Approvals", "[a]"), - ("tmux", "[3] 📋 Copy Tmux Command", "[t]"), - ("wo", "[4] 📝 Issue Work Order", "[w]"), - ("sync", "[5] 🔄 Sync Threads", "[s]"), - ("read_all", "[6] ✔ Mark All Read", "[m]"), + ("dispatch", "[2] 🚀 Dispatch New Case / Build Ticket", "[w]"), + ("thoughts", "[3] 🧠 Inspect Live Cognition & Screen", "[t]"), + ("menu", "[4] 👤 Navigate Agent Profile Menu", "[p]"), + ("refocus", "[5] 🎯 Refocus Agent to Main Chat", "[f]"), + ("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_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 + 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": m = getattr(self, "context_message", None) or {} @@ -4194,8 +4240,16 @@ class MuseTUI: elif self.modal == "work_dispatch": 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")) - self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, "Enter Build Ticket Title & Goal:", self._attr("dim")) + cog = (self.data.cognitive_cache or {}).get(target) or {} + 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_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 + 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 - 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 - 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] Dispatch Case [Tab] Cycle Target [F1-F4] Load Preset [Esc] Cancel", self._attr("dim")) try: 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() 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": 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}" 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 @@ -4871,6 +4925,10 @@ class MuseTUI: nodes = list(self.data.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] + 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: target_node = getattr(self, "dispatch_target_node", self.data.active_node) @@ -4895,6 +4953,12 @@ class MuseTUI: elif ch in (ord('h'), ord('H')): self._async_heal_agent(target_node) 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')): threading.Thread(target=self.data._fetch_cognitive_and_work, daemon=True).start() 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") 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 if ch in (ord('f'), ord('F')) or (self.focus_pane in ("fleet", "sidechats", "sidebar") and ch == ord('/')): self.modal = "agent_search" @@ -5177,7 +5268,8 @@ class MuseTUI: # Work Order modal 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_cursor = 0 return True @@ -5203,6 +5295,24 @@ class MuseTUI: elif ch == ord('3'): threading.Thread(target=self._async_resolve_approval, args=("deny", cur_node), daemon=True).start() 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')): threading.Thread(target=self._async_resolve_approval, args=("reply", cur_node, "Proceed with standard execution."), daemon=True).start() return True @@ -5229,13 +5339,37 @@ class MuseTUI: # Box mode Fast Actions: Tab 4 (Tmux) if self.mode == "box" and self.box_tab == 4: + with self.data.lock: + 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')): - with self.data.lock: - sessions = list(self.data.tmux_cache) if sessions and 0 <= self.tmux_sel_idx < len(sessions): sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip() threading.Thread(target=self._async_kill_tmux, args=(sess_name,), daemon=True).start() 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')): threading.Thread(target=self.data._fetch_tmux_sessions, daemon=True).start() 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") if is_context_popup: 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)) top_y = max(1, min(anchor_y, h - modal_h - 2)) left_x = max(1, min(anchor_x, w - modal_w - 2)) @@ -6326,11 +6460,35 @@ class MuseTUI: "index": 0, } self.context_popup_pos = (mx, my) - self.chat_context_sel = 0 self.modal = "chat_context" self._modal_open_time = time.time() 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 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: @@ -7215,6 +7373,68 @@ class MuseTUI: self.data._fetch_cognitive_and_work() 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): """Open modal streaming live thought paragraphs and screen state.""" self.selected_thought_node = node @@ -7414,6 +7634,24 @@ class MuseTUI: self.focus_pane = "sidebar" self.set_toast(f"Focused sidebar on {node.upper()}", "info") 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): """Execute selected contextual action on the targeted message.""" @@ -7542,7 +7780,9 @@ class MuseTUI: elif self.modal == "agent_context": actions = getattr(self, "_agent_context_actions", [ - ("open", "", ""), ("approvals", "", ""), ("tmux", "", ""), + ("open", "", ""), ("dispatch", "", ""), ("thoughts", "", ""), + ("menu", "", ""), ("refocus", "", ""), ("approvals", "", ""), + ("heal", "", ""), ("loopback", "", ""), ("tmux", "", ""), ("wo", "", ""), ("sync", "", ""), ("read_all", "", "") ]) if ch in (27, ord('q'), ord('Q')): @@ -7563,11 +7803,17 @@ class MuseTUI: key_map = { ord('1'): "open", ord('o'): "open", ord('O'): "open", - ord('2'): "approvals", ord('a'): "approvals", ord('A'): "approvals", - ord('3'): "tmux", ord('t'): "tmux", ord('T'): "tmux", - ord('4'): "wo", ord('w'): "wo", ord('W'): "wo", - ord('5'): "sync", ord('s'): "sync", ord('S'): "sync", - ord('6'): "read_all", ord('m'): "read_all", ord('M'): "read_all", + ord('2'): "dispatch", ord('w'): "dispatch", + ord('3'): "thoughts", ord('t'): "thoughts", ord('T'): "thoughts", + ord('4'): "menu", ord('p'): "menu", ord('P'): "menu", + ord('5'): "refocus", ord('f'): "refocus", ord('F'): "refocus", + 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: 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() self.modal = None 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 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:] @@ -7817,6 +8079,9 @@ class MuseTUI: self.modal_input_buf = "" self.modal_input_cursor = 0 return True + elif ch in (ord('p'), ord('P')): + self._open_agent_menu(node) + return True elif ch in (ord('r'), ord('R')): self._open_agent_thoughts(node) return True @@ -7840,6 +8105,23 @@ class MuseTUI: elif ch in (ord('4'), ord('i'), ord('I')): self._async_load_menu(node, "identity") 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'): c_idx = tabs.index(cur_tab) if cur_tab in tabs else 0 next_tab = tabs[(c_idx + 1) % len(tabs)] diff --git a/tests/test_box_tui_render.py b/tests/test_box_tui_render.py index d5da781..89af607 100644 --- a/tests/test_box_tui_render.py +++ b/tests/test_box_tui_render.py @@ -4,6 +4,7 @@ import unittest from unittest.mock import MagicMock, patch import os import sys +import curses from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent @@ -138,6 +139,160 @@ class TestBoxTUIHeadless(unittest.TestCase): app._handle_key(ord('\t')) 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): dimensions = [(8, 20), (12, 40), (24, 80), (50, 160)]