From 4998ffddb68a57f531c1aa3fea7f84b4f23921a8 Mon Sep 17 00:00:00 2001 From: operator Date: Tue, 6 Oct 2026 19:29:22 +0000 Subject: [PATCH] feat(tui): SGR mouse fallback, focus partition highlights, multi-trigger context menus & box tui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bin/muse-tui.py: * Parse raw SGR 1006 (\033[= total_nodes: + self.fleet_focus_idx = max(0, total_nodes - 1) - if active_idx < self.agent_scroll_start: - self.agent_scroll_start = active_idx - elif active_idx >= self.agent_scroll_start + visible_agent_count: - self.agent_scroll_start = active_idx - visible_agent_count + 1 + is_fleet_focused = (self.focus_pane in ("fleet", "sidebar") and self.editor_mode == "NORMAL") + is_sidechats_focused = (self.focus_pane in ("sidechats", "sidebar") and self.editor_mode == "NORMAL") + + # Keep agent_scroll_start tracking focus index when fleet partition is focused + if is_fleet_focused: + target_agent_idx = self.fleet_focus_idx + else: + try: + target_agent_idx = nodes.index(active_node) + except ValueError: + target_agent_idx = 0 + + if target_agent_idx < self.agent_scroll_start: + self.agent_scroll_start = target_agent_idx + elif target_agent_idx >= self.agent_scroll_start + visible_agent_count: + self.agent_scroll_start = target_agent_idx - visible_agent_count + 1 max_scroll = max(0, total_nodes - visible_agent_count) self.agent_scroll_start = max(0, min(self.agent_scroll_start, max_scroll)) @@ -1810,11 +1850,14 @@ class MuseTUI: fleet_unread_total = self.data.get_fleet_unread_total() unread_badge = f" [● {fleet_unread_total} New]" if fleet_unread_total > 0 else "" - header_title = f"FLEET AGENTS {count_str}{unread_badge}" - self.safe_addstr(self.stdscr, y, x + 1, header_title, self._attr("bold") if not unread_badge else self._attr("warn_banner")) + focus_pill = "[● FOCUS] " if is_fleet_focused else "" + header_title = f"{focus_pill}FLEET AGENTS {count_str}{unread_badge}" + self.safe_addstr(self.stdscr, y, x + 1, " " * (w - 2), self._attr("header") if is_fleet_focused else self._attr("dim")) + hdr_attr = self._attr("header") if is_fleet_focused else (self._attr("warn_banner") if unread_badge else self._attr("bold")) + self.safe_addstr(self.stdscr, y, x + 1, header_title, hdr_attr) search_btn = "[🔍 /]" btn_x = max(x + len(header_title) + 2, x + w - len(search_btn) - 1) - self.safe_addstr(self.stdscr, y, btn_x, search_btn, self._attr("warn_banner")) + self.safe_addstr(self.stdscr, y, btn_x, search_btn, self._attr("header") if is_fleet_focused else self._attr("warn_banner")) node_y = y + 1 for row_i in range(visible_agent_count): @@ -1824,6 +1867,7 @@ class MuseTUI: node = nodes[idx] st_info = statuses.get(node, {}) is_active = (node == active_node) + is_highlighted = (is_fleet_focused and idx == self.fleet_focus_idx) is_blocked = st_info.get("blocked", False) is_waiting = st_info.get("waiting", False) latency = st_info.get("latency") @@ -1856,7 +1900,7 @@ class MuseTUI: pointer = "▶" if is_active else (" " if not scroll_arrow else scroll_arrow) line_str = f" {pointer}{num_tag} {node:<5} " - attr = self._attr("selected") if is_active else self._attr("normal") + attr = self._attr("selected") if is_highlighted else (self._attr("bold") if is_active else self._attr("normal")) self.safe_addstr(self.stdscr, node_y, x + 1, " " * (w - 2), attr) self.safe_addstr(self.stdscr, node_y, x + 1, line_str, attr) self.safe_addstr(self.stdscr, node_y, x + 12, dot, dot_attr) @@ -1864,7 +1908,7 @@ class MuseTUI: # Right-aligned latency tag [0ms] right_col = x + w - 2 lat_disp = f"[{lat_str}]" - self.safe_addstr(self.stdscr, node_y, right_col - len(lat_disp), lat_disp, self._attr("dim") if not is_active else self._attr("selected")) + self.safe_addstr(self.stdscr, node_y, right_col - len(lat_disp), lat_disp, self._attr("dim") if not is_highlighted else self._attr("selected")) # Notification badge & status labels status_pos = right_col - len(lat_disp) - 1 @@ -1884,12 +1928,12 @@ class MuseTUI: # Divider between nodes and threads divider_y = node_y - self.safe_addstr(self.stdscr, divider_y, x, "─" * w, self._attr("dim")) + div_attr = self._attr("header") if (is_fleet_focused or is_sidechats_focused) else self._attr("dim") + self.safe_addstr(self.stdscr, divider_y, x, "─" * w, div_attr) # 2. Threads / Sidechats Section threads_header_y = divider_y + 1 - is_sidebar_focused = (self.focus_pane == "sidebar") - hdr_attr = self._attr("header") if is_sidebar_focused else self._attr("bold") + hdr_attr = self._attr("header") if is_sidechats_focused else self._attr("bold") is_loading = self.data.is_loading_threads(active_node) spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] @@ -1900,13 +1944,14 @@ class MuseTUI: if is_loading: hdr_badge = f"[{spin_char} Syncing]" else: - hdr_badge = "[n:new r:sync]" + hdr_badge = "[n:new ⚡:menu r:sync]" - hdr_left = f"SIDECHATS: {active_node.upper()}{unread_hdr_badge}" - self.safe_addstr(self.stdscr, threads_header_y, x + 1, " " * (w - 2), self._attr("dim")) - self.safe_addstr(self.stdscr, threads_header_y, x + 1, hdr_left, self._attr("warn_banner") if unread_hdr_badge and not is_sidebar_focused else hdr_attr) + focus_dot = "● " if is_sidechats_focused else "" + hdr_left = f"{focus_dot}SIDECHATS: {active_node.upper()}{unread_hdr_badge}" + self.safe_addstr(self.stdscr, threads_header_y, x + 1, " " * (w - 2), self._attr("header") if is_sidechats_focused else self._attr("dim")) + self.safe_addstr(self.stdscr, threads_header_y, x + 1, hdr_left, self._attr("header") if is_sidechats_focused else (self._attr("warn_banner") if unread_hdr_badge else hdr_attr)) btn_x = max(x + len(hdr_left) + 2, x + w - len(hdr_badge) - 1) - self.safe_addstr(self.stdscr, threads_header_y, btn_x, hdr_badge, self._attr("dim") if not is_sidebar_focused else self._attr("bold")) + self.safe_addstr(self.stdscr, threads_header_y, btn_x, hdr_badge, self._attr("header") if is_sidechats_focused else self._attr("dim")) list_y = threads_header_y + 1 max_threads_visible = (y + h) - list_y @@ -1925,13 +1970,13 @@ class MuseTUI: self.safe_addstr(self.stdscr, list_y + 1, x + 2, "Press 'n' to create sidechat", self._attr("dim")) return - # Sync selection head to active thread if sidebar is not focused + # Sync selection head to active thread if sidechats are not focused cur_idx = None for idx, t in enumerate(threads): if t.get("session_id") == cur_thread_id: cur_idx = idx break - if cur_idx is not None and not is_sidebar_focused and self.thread_sel_idx != cur_idx: + if cur_idx is not None and not is_sidechats_focused and self.thread_sel_idx != cur_idx: self.thread_sel_idx = cur_idx # Ensure selection in bounds @@ -1954,7 +1999,7 @@ class MuseTUI: is_main = t.get("is_main", False) is_pinned = t.get("pinned", False) is_cur = (sid == cur_thread_id) - is_highlighted = (item_idx == self.thread_sel_idx and is_sidebar_focused) + is_highlighted = (item_idx == self.thread_sel_idx and is_sidechats_focused) if is_cur: indicator = "▶ " @@ -1975,16 +2020,27 @@ class MuseTUI: title_disp = title # Row background - row_attr = self._attr("selected") if is_highlighted else (self._attr("selected") if (is_cur and is_sidebar_focused) else self._attr("normal")) + if is_highlighted: + row_attr = self._attr("selected") + elif is_cur: + row_attr = self._attr("bold") + else: + row_attr = self._attr("normal") self.safe_addstr(self.stdscr, item_line_y, x + 1, " " * (w - 2), row_attr) - # Right elements: sid tag [xxxx] + # Right elements: sid tag [xxxx] and action button [⚡] sid_tag = f"[{sid[:4]}]" sid_x = x + w - 1 - len(sid_tag) self.safe_addstr(self.stdscr, item_line_y, sid_x, sid_tag, self._attr("dim") if not is_highlighted else self._attr("selected")) + # Context menu button [⚡] next to session id for easy click + menu_btn = "[⚡]" + menu_btn_x = sid_x - len(menu_btn) - 1 + btn_attr = self._attr("header") if is_cur else (self._attr("selected") if is_highlighted else self._attr("dim")) + self.safe_addstr(self.stdscr, item_line_y, menu_btn_x, menu_btn, btn_attr) + badge_end_x = menu_btn_x - 1 + # Notification Badge: ●N - badge_end_x = sid_x - 1 if unread_cnt > 0: badge_str = f"●{unread_cnt}" badge_x = max(x + 10, badge_end_x - len(badge_str)) @@ -2222,6 +2278,20 @@ class MuseTUI: active_tid = self.data.active_thread_id active_title = self.data.active_thread_title msgs = list(self.data.history_cache.get((active_node, active_tid), [])) + if not msgs: + cached_threads = self.data.threads_cache.get(active_node, []) + is_cur_main = any(t.get("session_id") == active_tid and t.get("is_main") for t in cached_threads) + if is_cur_main or not active_tid: + fallback_msgs = self.data.history_cache.get((active_node, None), []) + if fallback_msgs: + msgs = list(fallback_msgs) + else: + for t in cached_threads: + if t.get("is_main") and t.get("session_id"): + fb = self.data.history_cache.get((active_node, t["session_id"]), []) + if fb: + msgs = list(fb) + break # Automatically reset scroll when switching target node or thread cur_target = (active_node, active_tid) @@ -2231,7 +2301,7 @@ class MuseTUI: self._last_rendered_target = cur_target # Thread Header Banner - is_focused = (self.focus_pane == "transcript") + is_focused = (self.focus_pane == "transcript" and self.editor_mode == "NORMAL") badge = "" is_main_chat = False with self.data.lock: @@ -2273,7 +2343,8 @@ class MuseTUI: tail_w = len(msgs_str) + len(unread_pill) + len(copy_all_btn) + 4 avail_for_left = max(15, w - tail_w) - base_prefix = " Thread: ★ Main Chat " if is_main_chat else " Thread: " + focus_pill = "[● FOCUSED] " if is_focused else "" + base_prefix = f" {focus_pill}Thread: ★ Main Chat " if is_main_chat else f" {focus_pill}Thread: " raw_title = f"(\"{active_title}\")" if is_main_chat else active_title meta = f"{badge} {tid_short}{node_str}".strip() @@ -2296,7 +2367,7 @@ class MuseTUI: header_txt = f"{base_prefix}{title_disp}{meta_str}{msgs_str}" hdr_attr = self._attr("header") if is_focused else self._attr("bold") - self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("dim")) + self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("header") if is_focused else self._attr("dim")) self.safe_addstr(self.stdscr, y, x + 1, header_txt, hdr_attr) cur_x = x + 1 + len(header_txt) @@ -2304,14 +2375,24 @@ class MuseTUI: self.safe_addstr(self.stdscr, y, cur_x, unread_pill, self._attr("warn_banner")) cur_x += len(unread_pill) - # Place [📋 Copy All] immediately next to message total at top + # Place [📋 Copy All] and [⚡ Menu] immediately next to message total at top if cur_x + len(copy_all_btn) <= x + w: - self.safe_addstr(self.stdscr, y, cur_x, copy_all_btn, self._attr("button")) + self.safe_addstr(self.stdscr, y, cur_x, copy_all_btn, self._attr("header") if is_focused else self._attr("button")) self._btn_copy_all_bounds = (y, cur_x, cur_x + len(copy_all_btn)) + cur_x += len(copy_all_btn) + 1 else: self._btn_copy_all_bounds = None - self.safe_addstr(self.stdscr, y + 1, x, "─" * w, self._attr("dim")) + menu_btn = "[⚡ Menu]" + if cur_x + len(menu_btn) <= x + w: + self.safe_addstr(self.stdscr, y, cur_x, menu_btn, self._attr("header") if is_focused else self._attr("button")) + self._btn_menu_bounds = (y, cur_x, cur_x + len(menu_btn)) + else: + self._btn_menu_bounds = None + + 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 @@ -2377,7 +2458,8 @@ class MuseTUI: reply_btn = "[↩ Reply]" copy_clean_btn = "[📋 Copy]" copy_ctx_btn = "[📑+ Context]" - btns_str = f"{reply_btn} {copy_clean_btn} {copy_ctx_btn}" + menu_btn = "[⚡ Menu]" + btns_str = f"{reply_btn} {copy_clean_btn} {copy_ctx_btn} {menu_btn}" target_col = max(len(prefix) + 2, max_line_w - len(btns_str)) hdr_line = prefix + " " * max(1, target_col - len(prefix)) + btns_str rendered_lines.append((hdr_line, hdr_attr, m)) @@ -2435,6 +2517,7 @@ class MuseTUI: self._msg_copy_buttons = [] self._msg_copy_context_buttons = [] self._msg_reply_buttons = [] + self._msg_menu_buttons = [] is_copy_mode = getattr(self, "copy_mode", False) sel_min = None @@ -2480,11 +2563,20 @@ class MuseTUI: # Draw Full Context Copy button if "[📑+ Context]" in txt: ctx_str = "[📑+ Context]" - ctx_rel_x = txt.rfind(ctx_str) + ctx_rel_x = txt.find(ctx_str) if ctx_rel_x >= 0: ctx_scr_x = x + 2 + ctx_rel_x self.safe_addstr(self.stdscr, transcript_y + row, ctx_scr_x, ctx_str, self._attr("button")) self._msg_copy_context_buttons.append((transcript_y + row, ctx_scr_x, ctx_scr_x + len(ctx_str), msg_obj.get("text", ""))) + + # Draw Message Actions Menu button + if "[⚡ Menu]" in txt: + m_str = "[⚡ Menu]" + m_rel_x = txt.rfind(m_str) + if m_rel_x >= 0: + m_scr_x = x + 2 + m_rel_x + self.safe_addstr(self.stdscr, transcript_y + row, m_scr_x, m_str, self._attr("header")) + self._msg_menu_buttons.append((transcript_y + row, m_scr_x, m_scr_x + len(m_str), msg_obj)) else: self.safe_addstr(self.stdscr, transcript_y + row, x + 2, " " * max(0, w - 4), self._attr("normal")) @@ -2895,8 +2987,37 @@ class MuseTUI: # Bottom Input Bar & Toast # ----------------------------------------------------------------------- def _render_bottom_bar(self, y: int, x: int, h: int, w: int): - # 1. Divider line - self.safe_addstr(self.stdscr, y, x, "─" * w, self._attr("dim")) + is_insert = (self.editor_mode == "INSERT") + is_fleet_focus = (self.focus_pane == "fleet" and not is_insert) + is_sidechats_focus = (self.focus_pane in ("sidechats", "sidebar") and not is_insert) + is_transcript_focus = (self.focus_pane == "transcript" and not is_insert) + + # 1. Divider line - prominently highlights active portion + if is_insert: + lbl = " ── [● COMPOSER: INSERT MODE ACTIVE] " + pad_w = max(0, w - len(lbl) - 2) + div_str = lbl + ("━" * pad_w) + self.safe_addstr(self.stdscr, y, x, div_str[:w], self._attr("mode_insert")) + elif is_fleet_focus: + lbl = " ── [● ACTIVE PANE: FLEET AGENTS] " + pad_w = max(0, w - len(lbl) - 2) + div_str = lbl + ("─" * pad_w) + self.safe_addstr(self.stdscr, y, x, "─" * w, self._attr("dim")) + self.safe_addstr(self.stdscr, y, x, lbl, self._attr("header")) + elif is_sidechats_focus: + lbl = " ── [● ACTIVE PANE: SIDEBAR] " if self.focus_pane == "sidebar" else " ── [● ACTIVE PANE: SIDECHATS] " + pad_w = max(0, w - len(lbl) - 2) + div_str = lbl + ("─" * pad_w) + self.safe_addstr(self.stdscr, y, x, "─" * w, self._attr("dim")) + self.safe_addstr(self.stdscr, y, x, lbl, self._attr("header")) + elif is_transcript_focus: + lbl = " ── [● ACTIVE PANE: TRANSCRIPT] " + pad_w = max(0, w - len(lbl) - 2) + div_str = lbl + ("─" * pad_w) + self.safe_addstr(self.stdscr, y, x, "─" * w, self._attr("dim")) + self.safe_addstr(self.stdscr, y, x, lbl, self._attr("user")) + else: + self.safe_addstr(self.stdscr, y, x, "─" * w, self._attr("dim")) # Clear Toast and Prompt rows to prevent ghosting without full-screen erase self.safe_addstr(self.stdscr, y + 1, x, " " * w, self._attr("normal")) @@ -2927,13 +3048,20 @@ class MuseTUI: self.safe_addstr(self.stdscr, y + 1, x + 1, hints[:w - 2], self._attr("warn_banner")) else: if self.editor_mode == "NORMAL": - hints = " [NORMAL] Tab: Cycle h/l: Pane j/k: Nav Right-Click/x: Actions b: Clean/Box v: Copy i: Chat ?: Help" + if self.focus_pane == "fleet": + hints = " [● FLEET FOCUSED] j/k: Select agent Enter: Switch h/l: Switch pane x/Space: Agent menu ?: Help" + elif self.focus_pane == "sidebar": + hints = " [● SIDEBAR FOCUSED] j/k: Select thread Enter: Open h/l: Switch pane x/Space: Menu ?: Help" + elif self.focus_pane == "sidechats": + hints = " [● SIDECHATS FOCUSED] j/k: Select thread Enter: Open h/l: Switch pane x/Space: Menu ?: Help" + else: + hints = " [● TRANSCRIPT FOCUSED] j/k: Scroll v: Copy h/l: Switch pane i: Type R: Reply x/Space: Menu ?: Help" else: nl_cnt = self.input_buf.count("\n") if nl_cnt > 0: - hints = f" [INSERT] Multi-line chunk ({nl_cnt + 1} lines, {len(self.input_buf)} chars) [Enter] Send chunk [Alt+Enter] Newline [Esc] Normal" + hints = f" [● INSERT ACTIVE] Multi-line ({nl_cnt + 1} lines) [Enter] Send [Alt+Enter] Newline [Esc] Normal" else: - hints = " [INSERT] Type message [Enter] Send [Alt+Enter] Newline [Alt+Space] Agent Layer [Esc] Normal Mode [Ctrl+W] Del word" + hints = " [● INSERT ACTIVE] Type message [Enter] Send [Alt+Enter] Newline [Esc] Normal mode [Ctrl+W] Del word" self.safe_addstr(self.stdscr, y + 1, x + 1, hints[:w - 2], self._attr("dim")) # 3. Mode Badge & Input Prompt @@ -2975,7 +3103,13 @@ class MuseTUI: disp_text = f"[Draft: {disp_buf}] (press 'i' to edit)" self.safe_addstr(self.stdscr, prompt_y, inp_x, disp_text[:max_inp_w], self._attr("dim")) else: - hint_text = "[Nav locked — press 'i' or Enter to type, '?' for help]" + if self.focus_pane == "fleet": + active_pane_name = "Fleet Agents" + elif self.focus_pane in ("sidechats", "sidebar"): + active_pane_name = "Sidechats" + else: + active_pane_name = "Transcript" + hint_text = f"[{active_pane_name} active — press 'i' or Enter to type prompt, '?' for help]" self.safe_addstr(self.stdscr, prompt_y, inp_x, hint_text[:max_inp_w], self._attr("dim")) if not self.modal: try: @@ -3618,7 +3752,11 @@ class MuseTUI: seq.append(c) # Terminate on standard ANSI escape final characters if seq[0] == ord('[') and len(seq) >= 2: - if chr(c).isalpha() or chr(c) == '~': + if seq[1] == ord('M'): + # Old-style xterm mouse tracking: ESC [ M Cb Cx Cy (len 5: [ + M + 3 bytes) + if len(seq) >= 5: + break + elif chr(c).isalpha() or chr(c) == '~': break finally: self.stdscr.timeout(50) @@ -3655,6 +3793,93 @@ class MuseTUI: self.toast_time = 0 return True + # SGR 1006 mouse event fallback: ESC [ < button ; col ; row (M or m) + if len(seq) >= 6 and seq[0] == ord('[') and seq[1] == ord('<'): + try: + raw_str = "".join(chr(c) for c in seq[2:]) + final_char = raw_str[-1] + if final_char in ('M', 'm'): + parts = raw_str[:-1].split(';') + if len(parts) == 3: + btn = int(parts[0]) + mx = int(parts[1]) - 1 + my = int(parts[2]) - 1 + is_rel = (final_char == 'm') + + # Build bstate mask + if btn & 64: # Wheel scroll + if (btn & 3) == 0: + bstate = getattr(curses, "BUTTON4_PRESSED", 0x10000) + else: + bstate = getattr(curses, "BUTTON5_PRESSED", 0x200000) + elif is_rel: + if (btn & 3) == 0: + bstate = curses.BUTTON1_RELEASED + elif (btn & 3) == 1: + bstate = getattr(curses, "BUTTON2_RELEASED", 0x20) + elif (btn & 3) == 2: + bstate = getattr(curses, "BUTTON3_RELEASED", 0x400) + else: + bstate = 0 + else: # Press or drag + if btn & 32: # Motion/drag + bstate = curses.REPORT_MOUSE_POSITION + else: + if (btn & 3) == 0: + bstate = curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED + elif (btn & 3) == 1: + bstate = getattr(curses, "BUTTON2_CLICKED", 0x80) | getattr(curses, "BUTTON2_PRESSED", 0x40) + elif (btn & 3) == 2: + bstate = getattr(curses, "BUTTON3_CLICKED", 0x1000) | getattr(curses, "BUTTON3_PRESSED", 0x800) + else: + bstate = 0 + + if btn & 16: # Ctrl + bstate |= getattr(curses, "BUTTON_CTRL", 0x2000000) + if btn & 4: # Shift + bstate |= getattr(curses, "BUTTON_SHIFT", 0x4000000) + if btn & 8: # Alt + bstate |= getattr(curses, "BUTTON_ALT", 0x8000000) + + return self._handle_mouse(mx, my, bstate) + except Exception: + pass + + # Standard Xterm mouse event fallback: ESC [ M Cb Cx Cy + if len(seq) >= 5 and seq[0] == ord('[') and seq[1] == ord('M'): + try: + cb = seq[2] + cx = seq[3] + cy = seq[4] + mx = cx - 33 + my = cy - 33 + btn = cb - 32 + if btn & 64: + if (btn & 3) == 0: + bstate = getattr(curses, "BUTTON4_PRESSED", 0x10000) + else: + bstate = getattr(curses, "BUTTON5_PRESSED", 0x200000) + elif (btn & 3) == 3: # Release + bstate = curses.BUTTON1_RELEASED + else: + if (btn & 3) == 0: + bstate = curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED + elif (btn & 3) == 1: + bstate = getattr(curses, "BUTTON2_CLICKED", 0x80) | getattr(curses, "BUTTON2_PRESSED", 0x40) + elif (btn & 3) == 2: + bstate = getattr(curses, "BUTTON3_CLICKED", 0x1000) | getattr(curses, "BUTTON3_PRESSED", 0x800) + else: + bstate = 0 + if btn & 16: + bstate |= getattr(curses, "BUTTON_CTRL", 0x2000000) + if btn & 4: + bstate |= getattr(curses, "BUTTON_SHIFT", 0x4000000) + if btn & 8: + bstate |= getattr(curses, "BUTTON_ALT", 0x8000000) + return self._handle_mouse(mx, my, bstate) + except Exception: + pass + # Alt+Enter (ESC + Enter / Linefeed) -> Insert newline into input buffer if seq and (seq[0] in (curses.KEY_ENTER, 10, 13) or (len(seq) == 2 and seq[0] in (ord('O'), ord('[')) and seq[1] == ord('M'))): if self.editor_mode != "INSERT": @@ -4022,9 +4247,25 @@ class MuseTUI: self.toggle_transcript_style() return True - # Open Context Menu for active sidebar thread or message: 'x' or 'c' - if ch in (ord('x'), ord('X'), ord('c'), ord('C')) and not (self.mode == "box" and self.box_tab == 2): - if self.focus_pane == "sidebar": + # Open Context Menu for active sidebar thread, fleet agent, or message: 'x', 'c', or Space + if ch in (ord('x'), ord('X'), ord('c'), ord('C'), ord(' ')) and not (self.mode == "box" and self.box_tab == 2): + if self.focus_pane == "fleet": + with self.data.lock: + nodes = list(self.data.nodes) + if nodes and 0 <= getattr(self, "fleet_focus_idx", 0) < len(nodes): + node = nodes[self.fleet_focus_idx] + st_info = self.data.node_status.get(node, {}) + self.context_agent = { + "node": node, + "status": st_info, + "index": self.fleet_focus_idx, + } + self.agent_context_sel = 0 + self.modal = "agent_context" + self._modal_open_time = time.time() + self.set_toast(f"Opened Agent Context Menu: [{node.upper()}]", "info") + return True + elif self.focus_pane in ("sidechats", "sidebar"): with self.data.lock: threads = list(self.data.threads_cache.get(self.data.active_node, [])) if threads and 0 <= self.thread_sel_idx < len(threads): @@ -4036,6 +4277,7 @@ class MuseTUI: } self.chat_context_sel = 0 self.modal = "chat_context" + self._modal_open_time = time.time() title = t.get("title") or t.get("session_id", "")[:8] self.set_toast(f"Opened Chat Context Menu: '{title}'", "info") return True @@ -4057,12 +4299,13 @@ class MuseTUI: self.context_message = m self.message_context_sel = 0 self.modal = "message_context" + self._modal_open_time = time.time() r_role = m.get("role", "agent").upper() self.set_toast(f"Message Context Menu ({r_role})", "info") return True - # Fast Agent Search & Select Modal: 'f' or 'F', or '/' when focused on sidebar - if ch in (ord('f'), ord('F')) or (self.focus_pane == "sidebar" and ch == ord('/')): + # 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" self.modal_input_buf = "" self.modal_input_cursor = 0 @@ -4088,7 +4331,17 @@ class MuseTUI: # Enter key in normal mode if ch in (curses.KEY_ENTER, 10, 13): if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): - if self.focus_pane == "sidebar": + if self.focus_pane == "fleet": + # Switch to highlighted fleet agent and switch focus to sidechats + with self.data.lock: + nodes = list(self.data.nodes) + if nodes and 0 <= getattr(self, "fleet_focus_idx", 0) < len(nodes): + new_node = nodes[self.fleet_focus_idx] + self.data.set_active_target(new_node) + self.focus_pane = "sidechats" + self.set_toast(f"Switched to agent: {new_node.upper()}", "success") + return True + elif self.focus_pane in ("sidechats", "sidebar"): # Activate selected thread with self.data.lock: threads = list(self.data.threads_cache.get(self.data.active_node, [])) @@ -4151,7 +4404,7 @@ class MuseTUI: return True # Toggle Pin for selected thread (sidebar) or chat sent to prompt fill (transcript) / Expand Prompts ('P') if ch in (ord('p'), ord('P')): - if self.focus_pane == "sidebar": + if self.focus_pane in ("sidechats", "sidebar"): with self.data.lock: threads = list(self.data.threads_cache.get(self.data.active_node, [])) target_idx = self.thread_sel_idx @@ -4428,16 +4681,33 @@ class MuseTUI: return True elif ch in (ord('h'), curses.KEY_LEFT): - self.focus_pane = "sidebar" + if self.focus_pane == "transcript": + self.focus_pane = "sidechats" + elif self.focus_pane in ("sidechats", "sidebar"): + self.focus_pane = "fleet" + else: + self.focus_pane = "sidechats" return True elif ch in (ord('l'), curses.KEY_RIGHT): - self.focus_pane = "transcript" + if self.focus_pane == "fleet": + self.focus_pane = "sidechats" + elif self.focus_pane in ("sidechats", "sidebar"): + self.focus_pane = "transcript" + else: + self.focus_pane = "transcript" return True # Navigation: j / k / Up / Down if ch in (curses.KEY_UP, ord('k')): if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): - if self.focus_pane == "sidebar": + if self.focus_pane == "fleet": + with self.data.lock: + nodes = list(self.data.nodes) + if nodes: + self.fleet_focus_idx = max(0, getattr(self, "fleet_focus_idx", 0) - 1) + if self.fleet_focus_idx < getattr(self, "agent_scroll_start", 0): + self.agent_scroll_start = self.fleet_focus_idx + elif self.focus_pane in ("sidechats", "sidebar"): self.thread_sel_idx = max(0, self.thread_sel_idx - 1) else: self.auto_scroll = False @@ -4458,7 +4728,15 @@ class MuseTUI: elif ch in (curses.KEY_DOWN, ord('j')): if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): - if self.focus_pane == "sidebar": + if self.focus_pane == "fleet": + with self.data.lock: + nodes = list(self.data.nodes) + if nodes: + self.fleet_focus_idx = min(len(nodes) - 1, getattr(self, "fleet_focus_idx", 0) + 1) + vis = getattr(self, "agent_visible_count", 6) + if self.fleet_focus_idx >= getattr(self, "agent_scroll_start", 0) + vis: + self.agent_scroll_start = self.fleet_focus_idx - vis + 1 + elif self.focus_pane in ("sidechats", "sidebar"): with self.data.lock: threads = list(self.data.threads_cache.get(self.data.active_node, [])) if threads: @@ -4541,7 +4819,10 @@ class MuseTUI: # Top / Bottom: g / G / Home / End if ch in (ord('g'), curses.KEY_HOME): if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): - if self.focus_pane == "sidebar": + if self.focus_pane == "fleet": + self.fleet_focus_idx = 0 + self.agent_scroll_start = 0 + elif self.focus_pane in ("sidechats", "sidebar"): self.thread_sel_idx = 0 else: self.auto_scroll = False @@ -4562,7 +4843,14 @@ class MuseTUI: elif ch in (ord('G'), curses.KEY_END): if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): - if self.focus_pane == "sidebar": + if self.focus_pane == "fleet": + with self.data.lock: + nodes = list(self.data.nodes) + if nodes: + self.fleet_focus_idx = len(nodes) - 1 + vis = getattr(self, "agent_visible_count", 6) + self.agent_scroll_start = max(0, len(nodes) - vis) + elif self.focus_pane in ("sidechats", "sidebar"): with self.data.lock: threads = list(self.data.threads_cache.get(self.data.active_node, [])) if threads: @@ -4608,21 +4896,54 @@ class MuseTUI: 0x200000 | 0x400000 | 0x8000000)) # Button state masks - is_press = bool(bstate & (curses.BUTTON1_CLICKED | curses.BUTTON1_PRESSED)) - is_release = bool(bstate & curses.BUTTON1_RELEASED) - is_drag = bool(bstate & curses.REPORT_MOUSE_POSITION) - is_right_click = bool(bstate & ( + btn3_mask = ( getattr(curses, "BUTTON3_CLICKED", 0x1000) | getattr(curses, "BUTTON3_PRESSED", 0x800) | - getattr(curses, "BUTTON3_RELEASED", 0x400) + getattr(curses, "BUTTON3_RELEASED", 0x400) | + getattr(curses, "BUTTON3_DOUBLE_CLICKED", 0x2000) | + getattr(curses, "BUTTON3_TRIPLE_CLICKED", 0x4000) + ) + btn2_mask = ( + getattr(curses, "BUTTON2_CLICKED", 0x80) | + getattr(curses, "BUTTON2_PRESSED", 0x40) | + getattr(curses, "BUTTON2_RELEASED", 0x20) | + getattr(curses, "BUTTON2_DOUBLE_CLICKED", 0x100) | + getattr(curses, "BUTTON2_TRIPLE_CLICKED", 0x200) + ) + ctrl_mask = getattr(curses, "BUTTON_CTRL", 0x2000000) + alt_mask = getattr(curses, "BUTTON_ALT", 0x8000000) + shift_mask = getattr(curses, "BUTTON_SHIFT", 0x4000000) + + # Right-click detection: + # Check Button 3 (standard right click), Button 2 (middle click / 2-button trackpad), + # or Ctrl/Alt/Shift + Button 1 (macOS / laptop right-click modifier) + has_btn3 = bool(bstate & btn3_mask) + has_btn2 = bool(bstate & btn2_mask) + btn1_any = bool(bstate & (curses.BUTTON1_CLICKED | curses.BUTTON1_PRESSED | curses.BUTTON1_RELEASED)) + has_ctrl_left = bool(btn1_any and (bstate & ctrl_mask)) + has_alt_left = bool(btn1_any and (bstate & alt_mask)) + has_shift_left = bool(btn1_any and (bstate & shift_mask)) + + is_right_click = has_btn3 or has_btn2 or has_ctrl_left or has_alt_left or has_shift_left + is_double_click = bool(bstate & ( + getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0x8) | + getattr(curses, "BUTTON2_DOUBLE_CLICKED", 0x100) | + getattr(curses, "BUTTON3_DOUBLE_CLICKED", 0x2000) )) + + # Standard left-click press: only if NOT a right-click / modifier-click! + is_press = bool(bstate & (curses.BUTTON1_CLICKED | curses.BUTTON1_PRESSED)) and not is_right_click + is_release = bool(bstate & curses.BUTTON1_RELEASED) and not is_right_click + is_drag = bool(bstate & curses.REPORT_MOUSE_POSITION) + if is_right_click: now = time.time() - if now - getattr(self, "_last_right_click_time", 0.0) < 0.25: + if now - getattr(self, "_last_right_click_time", 0.0) < 0.15: return True self._last_right_click_time = now - is_click = is_press or is_release or is_drag or is_right_click + is_button_action = is_press or is_release or is_right_click or is_double_click + is_click = is_button_action or is_drag h, w = self.stdscr.getmaxyx() sidebar_w = min(36, max(26, w // 4)) @@ -4660,9 +4981,16 @@ class MuseTUI: else: visible_agent_count = getattr(self, "agent_visible_count", min(len(self.data.nodes), 6)) divider_y = content_y + 1 + visible_agent_count - if my < divider_y: + if my < divider_y or self.focus_pane == "fleet": # Scroll Fleet Agents up - self.agent_scroll_start = max(0, getattr(self, "agent_scroll_start", 0) - 1) + with self.data.lock: + nodes = list(self.data.nodes) + if nodes: + self.fleet_focus_idx = max(0, getattr(self, "fleet_focus_idx", 0) - 1) + if self.fleet_focus_idx < getattr(self, "agent_scroll_start", 0): + self.agent_scroll_start = self.fleet_focus_idx + else: + self.agent_scroll_start = max(0, getattr(self, "agent_scroll_start", 0) - 1) else: # Scroll Sidebar thread selection up self.thread_sel_idx = max(0, self.thread_sel_idx - 1) @@ -4702,12 +5030,17 @@ class MuseTUI: else: visible_agent_count = getattr(self, "agent_visible_count", min(len(self.data.nodes), 6)) divider_y = content_y + 1 + visible_agent_count - if my < divider_y: + if my < divider_y or self.focus_pane == "fleet": # Scroll Fleet Agents down with self.data.lock: total_nodes = len(self.data.nodes) - max_scroll = max(0, total_nodes - visible_agent_count) - self.agent_scroll_start = min(max_scroll, getattr(self, "agent_scroll_start", 0) + 1) + if total_nodes > 0: + self.fleet_focus_idx = min(total_nodes - 1, getattr(self, "fleet_focus_idx", 0) + 1) + if self.fleet_focus_idx >= getattr(self, "agent_scroll_start", 0) + visible_agent_count: + self.agent_scroll_start = self.fleet_focus_idx - visible_agent_count + 1 + else: + max_scroll = max(0, total_nodes - visible_agent_count) + self.agent_scroll_start = min(max_scroll, getattr(self, "agent_scroll_start", 0) + 1) else: # Scroll Sidebar thread selection down with self.data.lock: @@ -4716,205 +5049,212 @@ class MuseTUI: self.thread_sel_idx = min(len(threads) - 1, self.thread_sel_idx + 1) return True - # 2. CLICK HANDLING + # 2. CLICK & MODAL INTERACTION HANDLING if is_click: # If Modal is active if self.modal: - popup_bounds = getattr(self, "_context_popup_bounds", None) - if self.modal in ("chat_context", "agent_context", "message_context") and popup_bounds: - left_x, top_y, modal_w, modal_h = popup_bounds + 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) + 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)) + self._context_popup_bounds = (left_x, top_y, modal_w, modal_h) else: - modal_w = min(74, w - 6) - modal_h = min(20, h - 4) + modal_w = min(84 if self.modal in ("prompts", "history_search") else 74, w - 6) + modal_h = min(22 if self.modal in ("prompts", "history_search") else 20, h - 4) top_y = (h - modal_h) // 2 left_x = (w - modal_w) // 2 + self._context_popup_bounds = (left_x, top_y, modal_w, modal_h) - # If clicked outside modal box, close it - if not (left_x <= mx < left_x + modal_w and top_y <= my < top_y + modal_h): - self.modal = None - return True + is_inside_modal = (left_x <= mx < left_x + modal_w and top_y <= my < top_y + modal_h) - if self.modal in ("chat_context", "agent_context", "message_context"): - actions = getattr(self, f"_{self.modal}_actions", []) - action_row = my - (top_y + 5) - if 0 <= action_row < len(actions): - # Update selection index on hover or drag - if self.modal == "chat_context": - self.chat_context_sel = action_row - elif self.modal == "agent_context": - self.agent_context_sel = action_row - elif self.modal == "message_context": - self.message_context_sel = action_row + if is_inside_modal: + if self.modal in ("chat_context", "agent_context", "message_context"): + actions = getattr(self, f"_{self.modal}_actions", []) + action_row = my - (top_y + 5) + if 0 <= action_row < len(actions): + # Update selection index on hover or drag + if self.modal == "chat_context": + self.chat_context_sel = action_row + elif self.modal == "agent_context": + self.agent_context_sel = action_row + elif self.modal == "message_context": + self.message_context_sel = action_row - # If click press or drag release, execute action! - if is_press or is_release: - executor = getattr(self, f"_execute_{self.modal.split('_')[0]}_action") - executor(actions[action_row][0]) - return True - elif my == top_y + modal_h - 2 or my == top_y: - if is_press or is_release: - self.modal = None - return True - return True - - elif self.modal == "help": - self.modal = None - return True - - elif self.modal == "approvals": - act_y = top_y + modal_h - 4 - if my == act_y + 1: - rel_x = mx - left_x - 3 - # [1] Allow [2] Always [3] Deny [R] Proceed [X] Dismiss [C] Clear All - if 0 <= rel_x <= 10: - threading.Thread(target=self._async_resolve_approval, args=("allow",), daemon=True).start() - self.modal = None - elif 11 <= rel_x <= 22: - threading.Thread(target=self._async_resolve_approval, args=("always",), daemon=True).start() - self.modal = None - elif 23 <= rel_x <= 32: - threading.Thread(target=self._async_resolve_approval, args=("deny",), daemon=True).start() - self.modal = None - elif 33 <= rel_x <= 46: - threading.Thread(target=self._async_resolve_approval, args=("reply", None, "Proceed with standard execution."), daemon=True).start() - self.modal = None - elif 47 <= rel_x <= 60: - threading.Thread(target=self._async_resolve_approval, args=("dismiss",), daemon=True).start() - self.modal = None - elif rel_x >= 61: - threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start() - self.modal = None - elif my == top_y + modal_h - 2 or my == top_y: - self.modal = None - return True - - elif self.modal in ("new_sidechat", "work_order"): - if my == top_y + modal_h - 2: - self.modal = None - return True - - elif self.modal == "prompts": - list_w = min(32, modal_w // 2) - if (left_x + 2 <= mx <= left_x + list_w) and (top_y + 2 <= my < top_y + modal_h - 2): - row_offset = my - (top_y + 2) - with self.data.lock: - prompts = list(self.prompts) - visible_list_rows = modal_h - 4 - scroll_start = 0 - if self.prompt_sel_idx >= visible_list_rows: - scroll_start = self.prompt_sel_idx - visible_list_rows + 1 - clicked_idx = scroll_start + row_offset - if 0 <= clicked_idx < len(prompts): - self.prompt_sel_idx = clicked_idx - p = prompts[clicked_idx] - self.input_buf = p.get("prompt", "") - self.input_cursor = len(self.input_buf) - self.editor_mode = "INSERT" - self.set_toast(f"✔ Filled prompt: '{p.get('title')}'", "success") - self.modal = None + # If click press or drag release (after debounce), execute action! + if is_press or (is_release and time.time() - getattr(self, "_modal_open_time", 0.0) >= 0.15): + executor = getattr(self, f"_execute_{self.modal.split('_')[0]}_action") + executor(actions[action_row][0]) return True - if my == top_y + modal_h - 2: - rel_x = mx - left_x - 3 - if 0 <= rel_x <= 18: - if self.prompts and 0 <= self.prompt_sel_idx < len(self.prompts): - p = self.prompts[self.prompt_sel_idx] + elif my == top_y + modal_h - 2 or my == top_y: + if is_press or is_release: + self.modal = None + return True + return True + elif self.modal == "help": + if is_press: + self.modal = None + return True + elif self.modal == "approvals": + act_y = top_y + modal_h - 4 + if my == act_y + 1: + rel_x = mx - left_x - 3 + # [1] Allow [2] Always [3] Deny [R] Proceed [X] Dismiss [C] Clear All + if 0 <= rel_x <= 10: + threading.Thread(target=self._async_resolve_approval, args=("allow",), daemon=True).start() + self.modal = None + elif 11 <= rel_x <= 22: + threading.Thread(target=self._async_resolve_approval, args=("always",), daemon=True).start() + self.modal = None + elif 23 <= rel_x <= 32: + threading.Thread(target=self._async_resolve_approval, args=("deny",), daemon=True).start() + self.modal = None + elif 33 <= rel_x <= 46: + threading.Thread(target=self._async_resolve_approval, args=("reply", None, "Proceed with standard execution."), daemon=True).start() + self.modal = None + elif 47 <= rel_x <= 60: + threading.Thread(target=self._async_resolve_approval, args=("dismiss",), daemon=True).start() + self.modal = None + elif rel_x >= 61: + threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start() + self.modal = None + elif my == top_y + modal_h - 2 or my == top_y: + self.modal = None + return True + elif self.modal in ("new_sidechat", "work_order"): + if my == top_y + modal_h - 2: + self.modal = None + return True + elif self.modal == "prompts": + list_w = min(32, modal_w // 2) + if (left_x + 2 <= mx <= left_x + list_w) and (top_y + 2 <= my < top_y + modal_h - 2): + row_offset = my - (top_y + 2) + with self.data.lock: + prompts = list(self.prompts) + visible_list_rows = modal_h - 4 + scroll_start = 0 + if self.prompt_sel_idx >= visible_list_rows: + scroll_start = self.prompt_sel_idx - visible_list_rows + 1 + clicked_idx = scroll_start + row_offset + if 0 <= clicked_idx < len(prompts): + self.prompt_sel_idx = clicked_idx + p = prompts[clicked_idx] self.input_buf = p.get("prompt", "") self.input_cursor = len(self.input_buf) self.editor_mode = "INSERT" self.set_toast(f"✔ Filled prompt: '{p.get('title')}'", "success") - self.modal = None - return True - elif 19 <= rel_x <= 28: - if self.prompts and 0 <= self.prompt_sel_idx < len(self.prompts): - p = self.prompts[self.prompt_sel_idx] - new_pin = self.prompt_manager.toggle_pin_prompt(p["id"]) - self.prompts = self.prompt_manager.get_sorted_prompts() - state = "Pinned" if new_pin else "Unpinned" - self.set_toast(f"📌 {state} prompt: '{p['title']}'", "success") - return True - elif 29 <= rel_x <= 43: - self.modal = "prompt_formulate" - self.modal_input_buf = "" - self.modal_input_cursor = 0 - return True - elif 44 <= rel_x <= 56: - if self.prompts and 0 <= self.prompt_sel_idx < len(self.prompts): - p = self.prompts[self.prompt_sel_idx] - self.prompt_manager.delete_prompt(p["id"]) - self.prompts = self.prompt_manager.get_sorted_prompts() - self.prompt_sel_idx = max(0, min(self.prompt_sel_idx, len(self.prompts) - 1)) - self.set_toast(f"Deleted prompt: '{p['title']}'", "warn_banner") - return True - elif 57 <= rel_x <= 68: - self.modal = "history_search" - self.modal_input_buf = "" - self.modal_input_cursor = 0 - self.history_search_sel = 0 - return True - return True - - elif self.modal == "history_search": - list_start_y = top_y + 4 - max_results_visible = max(2, modal_h - 8) - row_offset = my - list_start_y - sends = self.get_chat_sends(self.modal_input_buf) - if 0 <= row_offset < max_results_visible and sends: - clicked_idx = getattr(self, "history_search_scroll", 0) + row_offset - if 0 <= clicked_idx < len(sends): - self.history_search_sel = clicked_idx - send = sends[clicked_idx] - if left_x + 2 <= mx <= left_x + 8: - p, is_new = self.prompt_manager.pin_chat_send(send.get("text", ""), node=send.get("node", "muse"), toggle=True) - self.prompts = self.prompt_manager.get_sorted_prompts() - send["is_pinned"] = p.get("pinned", False) - if p.get("pinned"): - state = "Pinned to" if is_new else "Pinned" - self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success") - else: - self.set_toast(f"📌 Unpinned from prompt fill: '{p['title']}'", "warn_banner") - return True - else: - self.input_buf = send.get("text", "") - self.input_cursor = len(self.input_buf) - self.editor_mode = "INSERT" - self.set_toast(f"✔ Filled from chat history: '{send.get('text', '')[:25]}...'", "success") self.modal = None return True - if my == top_y + modal_h - 2 and sends and 0 <= self.history_search_sel < len(sends): - send = sends[self.history_search_sel] - p, is_new = self.prompt_manager.pin_chat_send(send.get("text", ""), node=send.get("node", "muse"), toggle=True) - self.prompts = self.prompt_manager.get_sorted_prompts() - send["is_pinned"] = p.get("pinned", False) - if p.get("pinned"): - state = "Pinned to" if is_new else "Pinned" - self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success") - else: - self.set_toast(f"📌 Unpinned from prompt fill: '{p['title']}'", "warn_banner") + if my == top_y + modal_h - 2: + rel_x = mx - left_x - 3 + if 0 <= rel_x <= 18: + if self.prompts and 0 <= self.prompt_sel_idx < len(self.prompts): + p = self.prompts[self.prompt_sel_idx] + self.input_buf = p.get("prompt", "") + self.input_cursor = len(self.input_buf) + self.editor_mode = "INSERT" + self.set_toast(f"✔ Filled prompt: '{p.get('title')}'", "success") + self.modal = None + return True + elif 19 <= rel_x <= 28: + if self.prompts and 0 <= self.prompt_sel_idx < len(self.prompts): + p = self.prompts[self.prompt_sel_idx] + new_pin = self.prompt_manager.toggle_pin_prompt(p["id"]) + self.prompts = self.prompt_manager.get_sorted_prompts() + state = "Pinned" if new_pin else "Unpinned" + self.set_toast(f"📌 {state} prompt: '{p['title']}'", "success") + return True + elif 29 <= rel_x <= 43: + self.modal = "prompt_formulate" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + return True + elif 44 <= rel_x <= 56: + if self.prompts and 0 <= self.prompt_sel_idx < len(self.prompts): + p = self.prompts[self.prompt_sel_idx] + self.prompt_manager.delete_prompt(p["id"]) + self.prompts = self.prompt_manager.get_sorted_prompts() + self.prompt_sel_idx = max(0, min(self.prompt_sel_idx, len(self.prompts) - 1)) + self.set_toast(f"Deleted prompt: '{p['title']}'", "warn_banner") + return True + elif 57 <= rel_x <= 68: + self.modal = "history_search" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.history_search_sel = 0 + return True return True - return True - - elif self.modal == "prompt_formulate": - if my == top_y + modal_h - 2: - self.modal = None - return True - - elif self.modal == "agent_search": - # Check if clicked on a result row - q = self.modal_input_buf.strip().lower() - with self.data.lock: - all_nodes = list(self.data.nodes) - matches = [n for n in all_nodes if q in n.lower()] if q else all_nodes - list_start_y = top_y + 4 - max_results_visible = max(3, modal_h - 7) - row_offset = my - list_start_y - if 0 <= row_offset < max_results_visible: - target_idx = getattr(self, "agent_search_scroll", 0) + row_offset - if 0 <= target_idx < len(matches): - target_node = matches[target_idx] - self.data.set_active_target(target_node) - self.auto_scroll = True - self.transcript_scroll_offset = 0 - self.set_toast(f"Switched to agent: {target_node.upper()}", "success") + elif self.modal == "history_search": + list_start_y = top_y + 4 + max_results_visible = max(2, modal_h - 8) + row_offset = my - list_start_y + sends = self.get_chat_sends(self.modal_input_buf) + if 0 <= row_offset < max_results_visible and sends: + clicked_idx = getattr(self, "history_search_scroll", 0) + row_offset + if 0 <= clicked_idx < len(sends): + self.history_search_sel = clicked_idx + send = sends[clicked_idx] + if left_x + 2 <= mx <= left_x + 8: + p, is_new = self.prompt_manager.pin_chat_send(send.get("text", ""), node=send.get("node", "muse"), toggle=True) + self.prompts = self.prompt_manager.get_sorted_prompts() + send["is_pinned"] = p.get("pinned", False) + if p.get("pinned"): + state = "Pinned to" if is_new else "Pinned" + self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success") + else: + self.set_toast(f"📌 Unpinned from prompt fill: '{p['title']}'", "warn_banner") + return True + else: + self.input_buf = send.get("text", "") + self.input_cursor = len(self.input_buf) + self.editor_mode = "INSERT" + self.set_toast(f"✔ Filled from chat history: '{send.get('text', '')[:25]}...'", "success") + self.modal = None + return True + if my == top_y + modal_h - 2 and sends and 0 <= self.history_search_sel < len(sends): + send = sends[self.history_search_sel] + p, is_new = self.prompt_manager.pin_chat_send(send.get("text", ""), node=send.get("node", "muse"), toggle=True) + self.prompts = self.prompt_manager.get_sorted_prompts() + send["is_pinned"] = p.get("pinned", False) + if p.get("pinned"): + state = "Pinned to" if is_new else "Pinned" + self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success") + else: + self.set_toast(f"📌 Unpinned from prompt fill: '{p['title']}'", "warn_banner") + return True + return True + elif self.modal == "prompt_formulate": + if my == top_y + modal_h - 2: + self.modal = None + return True + elif self.modal == "agent_search": + # Check if clicked on a result row + q = self.modal_input_buf.strip().lower() + with self.data.lock: + all_nodes = list(self.data.nodes) + matches = [n for n in all_nodes if q in n.lower()] if q else all_nodes + list_start_y = top_y + 4 + max_results_visible = max(3, modal_h - 7) + row_offset = my - list_start_y + if 0 <= row_offset < max_results_visible: + target_idx = getattr(self, "agent_search_scroll", 0) + row_offset + if 0 <= target_idx < len(matches): + target_node = matches[target_idx] + self.data.set_active_target(target_node) + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.set_toast(f"Switched to agent: {target_node.upper()}", "success") + self.modal = None + return True + return True + else: + # Clicked OUTSIDE modal box: + # ONLY an actual mouse button press dismisses the modal! + # Motion / hover (is_drag) MUST NEVER dismiss the modal! + if is_press or is_right_click: + if time.time() - getattr(self, "_modal_open_time", 0.0) >= 0.2: self.modal = None return True return True @@ -4942,6 +5282,11 @@ class MuseTUI: # Prompt Shelf clicks (when in Main View) if is_main_view and prompt_shelf_h > 0 and (prompt_shelf_y <= my < h - bottom_bar_h): + if is_right_click: + self.modal = "prompts" + self.prompt_sel_idx = 0 + self._modal_open_time = time.time() + return True if my == prompt_shelf_y: if hasattr(self, "_btn_expand_bounds") and self._btn_expand_bounds[0] <= mx <= self._btn_expand_bounds[1]: self.modal = "prompts" @@ -4976,6 +5321,16 @@ class MuseTUI: # Bottom Bar click (my >= h - bottom_bar_h) if my >= h - bottom_bar_h: + if is_right_click: + with self.data.lock: + msgs = list(self.data.history_cache.get((self.data.active_node, self.data.active_thread_id), [])) + if msgs: + self.context_message = msgs[-1] + self.context_popup_pos = (mx, my) + self.message_context_sel = 0 + self.modal = "message_context" + self._modal_open_time = time.time() + return True self.editor_mode = "INSERT" prompt_len = len(f" INSERT {self.data.active_node} ({(self.data.active_thread_title or 'sidechat')[:12]}) > ") + 1 if mx >= prompt_len: @@ -4993,6 +5348,19 @@ class MuseTUI: clicked_idx = getattr(self, "table_scroll_idx", 0) + row_offset if 0 <= clicked_idx < len(nodes): new_node = nodes[clicked_idx] + if is_right_click: + st_info = self.data.node_status.get(new_node, {}) + self.context_agent = { + "node": new_node, + "status": st_info, + "index": clicked_idx, + } + self.context_popup_pos = (mx, my) + self.agent_context_sel = 0 + self.modal = "agent_context" + self._modal_open_time = time.time() + self.set_toast(f"Agent Actions: [{new_node.upper()}]", "info") + return True self.data.set_active_target(new_node) self.box_tab = 0 self.set_toast(f"Opened Chat with {new_node.upper()}", "success") @@ -5002,13 +5370,13 @@ class MuseTUI: if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): if mx < sidebar_w: self.editor_mode = "NORMAL" - self.focus_pane = "sidebar" visible_agent_count = getattr(self, "agent_visible_count", min(len(self.data.nodes), 6)) divider_y = content_y + 1 + visible_agent_count threads_hdr_y = divider_y + 1 # Check if click on Fleet Agents header (my == content_y) if my == content_y: + self.focus_pane = "fleet" if is_right_click: st_info = self.data.node_status.get(self.data.active_node, {}) self.context_agent = { @@ -5016,8 +5384,10 @@ class MuseTUI: "status": st_info, "index": 0, } + self.context_popup_pos = (mx, my) self.agent_context_sel = 0 self.modal = "agent_context" + self._modal_open_time = time.time() self.set_toast(f"Fleet Agent Actions: [{self.data.active_node.upper()}]", "info") else: self.modal = "agent_search" @@ -5029,30 +5399,42 @@ class MuseTUI: # Check if click on Fleet Node (rows content_y + 1 to divider_y - 1) if content_y + 1 <= my < divider_y: + self.focus_pane = "fleet" node_row = my - content_y - 1 target_idx = getattr(self, "agent_scroll_start", 0) + node_row with self.data.lock: nodes = list(self.data.nodes) if 0 <= target_idx < len(nodes): + self.fleet_focus_idx = target_idx new_node = nodes[target_idx] - if is_right_click: + is_node_menu_click = ( + is_right_click or + is_double_click or + (mx >= sidebar_w - 7) or + (new_node == self.data.active_node and is_press) + ) + if is_node_menu_click: st_info = self.data.node_status.get(new_node, {}) self.context_agent = { "node": new_node, "status": st_info, "index": target_idx, } + self.context_popup_pos = (mx, my) self.agent_context_sel = 0 self.modal = "agent_context" + self._modal_open_time = time.time() self.set_toast(f"Opened Agent Context Menu: [{new_node.upper()}]", "info") return True else: - self.data.set_active_target(new_node) - self.set_toast(f"Switched to agent: {new_node.upper()}", "success") + if is_press: + self.data.set_active_target(new_node) + self.set_toast(f"Switched to agent: {new_node.upper()}", "success") return True - # Check if click on [n:new] or [r:sync] in sidebar header + # Check if click on [n:new], [⚡:menu], or [r:sync] in sidebar header if my == threads_hdr_y: + self.focus_pane = "sidechats" if is_right_click: cur_node = self.data.active_node self.context_agent = { @@ -5060,15 +5442,33 @@ class MuseTUI: "status": self.data.node_status.get(cur_node, {}), "index": 0, } + self.context_popup_pos = (mx, my) self.agent_context_sel = 1 self.modal = "agent_context" + self._modal_open_time = time.time() return True - if mx >= sidebar_w - 14 and mx <= sidebar_w - 8: + # Click on [⚡:menu] in header + if sidebar_w - 16 <= mx <= sidebar_w - 7: + with self.data.lock: + threads = list(self.data.threads_cache.get(self.data.active_node, [])) + cur_t = next((th for th in threads if th.get("session_id") == self.data.active_thread_id), (threads[0] if threads else None)) + if cur_t: + self.context_chat = { + "node": self.data.active_node, + "thread": cur_t, + "index": self.thread_sel_idx, + } + self.context_popup_pos = (mx, my) + self.chat_context_sel = 0 + self.modal = "chat_context" + self._modal_open_time = time.time() + return True + elif mx >= sidebar_w - 21 and mx <= sidebar_w - 17: self.modal = "new_sidechat" self.modal_input_buf = "" self.modal_input_cursor = 0 return True - elif mx >= sidebar_w - 7: + elif mx >= sidebar_w - 6: self.data.lazy_fetch_threads(self.data.active_node, force=True) self.data.trigger_refresh() self.set_toast(f"Syncing threads for {self.data.active_node.upper()}...", "info") @@ -5080,6 +5480,7 @@ class MuseTUI: with self.data.lock: threads = list(self.data.threads_cache.get(self.data.active_node, [])) if 0 <= thread_row < (content_y + content_h - list_y): + self.focus_pane = "sidechats" scroll_start = getattr(self, "threads_scroll_start", 0) clicked_idx = scroll_start + thread_row if 0 <= clicked_idx < len(threads): @@ -5087,33 +5488,83 @@ class MuseTUI: t = threads[clicked_idx] sid = t.get("session_id") title = t.get("title") or sid[:8] - if is_right_click: + # Context menu triggered by: + # 1. Right click or modifier-click (Ctrl/Alt/Shift + Left) + # 2. Double-click + # 3. Click on [⚡] button or [sid] on right side (mx >= sidebar_w - 12) + # 4. Click on the ALREADY ACTIVE thread in sidechats pane + is_menu_btn_click = (mx >= sidebar_w - 12) + is_already_active = (sid == self.data.active_thread_id) + if is_right_click or is_double_click or is_menu_btn_click or (is_already_active and is_press): self.context_chat = { "node": self.data.active_node, "thread": t, "index": clicked_idx, } + self.context_popup_pos = (mx, my) self.chat_context_sel = 0 self.modal = "chat_context" + self._modal_open_time = time.time() self.set_toast(f"Opened Chat Context Menu: '{title}'", "info") return True else: - self.data.set_active_target(self.data.active_node, sid, title) - self.auto_scroll = True - self.transcript_scroll_offset = 0 - self.set_toast(f"Opened thread '{title}'", "success") + if is_press: + self.data.set_active_target(self.data.active_node, sid, title) + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.set_toast(f"Opened thread '{title}'", "success") return True + + # Right click on sidebar background / empty space fallback + if is_right_click: + with self.data.lock: + threads = list(self.data.threads_cache.get(self.data.active_node, [])) + cur_t = next((th for th in threads if th.get("session_id") == self.data.active_thread_id), None) + if cur_t: + self.context_chat = { + "node": self.data.active_node, + "thread": cur_t, + "index": self.thread_sel_idx, + } + self.context_popup_pos = (mx, my) + self.chat_context_sel = 0 + self.modal = "chat_context" + self._modal_open_time = time.time() + self.set_toast("Opened Chat Context Menu", "info") + return True else: # Mouse events inside Right Transcript if is_right_click: m = self._get_message_at_transcript_line(my) + if not m: + with self.data.lock: + msgs = list(self.data.history_cache.get((self.data.active_node, self.data.active_thread_id), [])) + if msgs: + m = msgs[-1] if m: self.context_message = m + self.context_popup_pos = (mx, my) self.message_context_sel = 0 self.modal = "message_context" + self._modal_open_time = time.time() r_role = m.get("role", "agent").upper() self.set_toast(f"Message Context Menu ({r_role})", "info") return True + else: + with self.data.lock: + threads = list(self.data.threads_cache.get(self.data.active_node, [])) + cur_t = next((th for th in threads if th.get("session_id") == self.data.active_thread_id), None) + self.context_chat = { + "node": self.data.active_node, + "thread": cur_t or {"session_id": self.data.active_thread_id, "title": self.data.active_thread_title}, + "index": 0, + } + self.context_popup_pos = (mx, my) + self.chat_context_sel = 0 + self.modal = "chat_context" + self._modal_open_time = time.time() + self.set_toast("Opened Chat Context Menu", "info") + return True # Mouse events inside Right Transcript # 1. Mouse Drag (Motion with Button 1 Pressed) if is_drag: @@ -5150,6 +5601,24 @@ class MuseTUI: self.copy_transcript_to_clipboard() return True + # Check if clicked on [⚡ Menu] button in thread header + if getattr(self, "_btn_menu_bounds", None): + btn_y, btn_x_start, btn_x_end = self._btn_menu_bounds + if my == btn_y and btn_x_start <= mx <= btn_x_end: + with self.data.lock: + threads = list(self.data.threads_cache.get(self.data.active_node, [])) + cur_t = next((th for th in threads if th.get("session_id") == self.data.active_thread_id), None) + self.context_chat = { + "node": self.data.active_node, + "thread": cur_t or {"session_id": self.data.active_thread_id, "title": self.data.active_thread_title}, + "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 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: @@ -5168,6 +5637,16 @@ class MuseTUI: self.copy_message_to_clipboard(msg_raw_text, with_context=True) return True + # Check if clicked on any visible [⚡ Menu] button on message headers + for (msg_y, btn_x_start, btn_x_end, msg_obj) in getattr(self, "_msg_menu_buttons", []): + if my == msg_y and btn_x_start <= mx <= btn_x_end: + self.context_message = msg_obj + self.context_popup_pos = (mx, my) + self.message_context_sel = 0 + self.modal = "message_context" + self._modal_open_time = time.time() + return True + # Check if clicked on the bottom "SCROLLED BACK - Follow" pill if not self.auto_scroll and my == content_y + content_h - 1: self.auto_scroll = True @@ -5715,10 +6194,19 @@ class MuseTUI: self.set_toast(f"Failed to deliver: {res[:40]}", "error") def _post_send_sync(self, node: str, thread_id: str): - time.sleep(1.0) - self.data._fetch_history(node, thread_id) - time.sleep(2.5) - self.data._fetch_history(node, thread_id) + # Progressively poll while assistant generates response + delays = [0.8, 1.5, 2.5, 3.5, 5.0, 7.0] + with self.data.lock: + initial_count = len(self.data.history_cache.get((node, thread_id), [])) + for d in delays: + time.sleep(d) + self.data._fetch_history(node, thread_id) + with self.data.lock: + current_msgs = self.data.history_cache.get((node, thread_id), []) + if len(current_msgs) > initial_count: + if any(m.get("role") == "assistant" for m in current_msgs[initial_count:]): + self.set_toast(f"✔ Reply received from {node.upper()}.", "success") + break def _async_create_sidechat(self, title: str): node = self.data.active_node diff --git a/bin/super-cli.py b/bin/super-cli.py index 708fef9..fd99003 100755 --- a/bin/super-cli.py +++ b/bin/super-cli.py @@ -6,7 +6,9 @@ autonomous browser agents (muse, pip, 646, opm) on bl and interface with the Google Cloud VM web surfaces. Domains: - super fleet — Node health, CDP status, active tabs, watch, restart + super muse-choices — Muse TUI A/B/C auto-answer daemon (on/off/status/logs) + super fleet — Node health, CDP status, active tabs, watch, restart, heal + super watchdog — Watchdog timers: status, trigger runs super dm — Inter-agent DMs, work orders ([WO]), acks, live log tail super thread — Inspect agent main chats, sidechats, and scrollbacks super job — Manage scheduled jobs, systemd timers, triggers, logs @@ -16,7 +18,8 @@ Domains: super vars — Shortcut to inspect & adjust runtime control variables Usage: - super fleet [status|watch|restart|cdp] + super fleet [status|watch|restart|cdp|heal] + super watchdog [status|run ] super dm [send|wo|ack|log|tail] super thread [list|view] super job [list|status|run|log|create|enable|disable|delete] @@ -392,7 +395,7 @@ def cmd_fleet_status(args): print(c_dim(" [*] status via host watchdog evidence (local probes blind in this shell)")) if has_any_approval: print("\n" + c_yellow(" ⚠ Agent(s) held up on browser approval. Run 'box approvals' to inspect/allow.")) - print("\n" + c_dim(" Commands: super fleet watch | super fleet restart | box approvals [check|allow|auto]") + "\n") + print("\n" + c_dim(" Commands: super fleet watch | super fleet heal | box approvals [check|allow|auto]") + "\n") def cmd_fleet_watch(args): interval = getattr(args, "interval", 2) @@ -447,6 +450,204 @@ def cmd_fleet_cdp(args): print(f" SSH Forward : {c_yellow(f'ssh -L {port}:{peer}:{port} super@100.123.153.75')}") print(f" Local Connect : {c_dim(f'http://127.0.0.1:{port}')} (after forwarding)\n") +# --------------------------------------------------------------------------- +# Domain: FLEET HEAL + WATCHDOG +# --------------------------------------------------------------------------- +SYSTEMD_SYSTEM_DIR = Path("/etc/systemd/system") +WATCHDOG_LOCK_TMPL = "/tmp/chromebox-watchdog-{node}.lock" +WARP_PROBE_URL = "https://1.1.1.1/cdn-cgi/trace" +CHROMEBOX_TIMER_TMPL = """[Unit] +Description=Run chromebox watchdog for {node} every 2 minutes + +[Timer] +RandomizedDelaySec=30s +OnBootSec=2min +OnUnitActiveSec=2min +Unit=chromebox-watchdog@{node}.service + +[Install] +WantedBy=timers.target +""" + + +def _sh(cmd, timeout=120, input_text=None): + """Run cmd, capture output. Returns (returncode, combined_output).""" + try: + r = subprocess.run(cmd, capture_output=True, text=True, + timeout=timeout, input=input_text) + return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip() + except subprocess.TimeoutExpired: + return 124, "timed out after %ds: %s" % (timeout, " ".join(cmd)) + except OSError as e: + return 127, str(e) + + +def _heal_lock_step(node): + """Clear a stale unwritable watchdog lock (blocks watchdog startup).""" + lock = Path(WATCHDOG_LOCK_TMPL.format(node=node)) + if not lock.exists() or os.access(lock, os.W_OK): + return True, "lock writable or absent" + rc, out = _sh(["sudo", "rm", "-f", str(lock)], timeout=30) + if rc == 0: + return True, "removed stale lock %s" % lock + return False, "cannot remove %s: %s" % (lock, out) + + +def _heal_timer_step(node): + """Install (if missing) and enable the node's watchdog timer.""" + timer = "chromebox-watchdog-%s.timer" % node + path = SYSTEMD_SYSTEM_DIR / timer + if not path.exists(): + rc, out = _sh(["sudo", "tee", str(path)], timeout=30, + input_text=CHROMEBOX_TIMER_TMPL.format(node=node)) + if rc != 0: + return False, "cannot install %s: %s" % (timer, out) + rc, out = _sh(["sudo", "systemctl", "daemon-reload"], timeout=60) + if rc != 0: + return False, "daemon-reload failed: %s" % out + rc, out = _sh(["sudo", "systemctl", "enable", "--now", timer], timeout=60) + if rc != 0: + return False, "enable --now failed: %s" % out + return True, "timer installed+active" + + +def _heal_tunnel_step(node): + """Restart the node's Warp tunnel + CDP relay via netvm-node-up.sh.""" + rc, out = _sh(["sudo", str(BIN_DIR / "netvm-node-up.sh"), node], timeout=240) + last = out.strip().splitlines()[-1] if out.strip() else "(no output)" + if rc != 0: + return False, "netvm-node-up.sh rc=%d: %s" % (rc, last) + if "egress=unknown" in out or "no handshake yet" in out: + return False, last + return True, last + + +def _warp_egress_ok(node): + """True when the node's netns reaches the warp probe URL.""" + rc, out = _sh(["sudo", "ip", "netns", "exec", "warp-%s" % node, + "curl", "-sk", "--max-time", "10", WARP_PROBE_URL], + timeout=30) + if rc != 0: + tail = out.splitlines()[-1] if out else "curl failed" + return False, tail + for line in out.splitlines(): + if line.startswith("ip="): + return True, line + return False, "no ip= line in probe response" + + +def cmd_fleet_heal(args): + node = args.node + if node not in VALID_NODES: + print(f"Error: Unknown node '{node}'. Valid: {', '.join(VALID_NODES)}", file=sys.stderr) + sys.exit(1) + as_json = getattr(args, "json", False) + info = get_node_network_info(node) + steps = [] + + def record(step, ok, detail): + steps.append({"step": step, "ok": ok, "detail": detail}) + if not as_json: + mark = c_green("ok") if ok else c_red("FAIL") + print(f" [{mark}] {step}: {detail}") + + if not as_json: + print(f"\n{c_bold('Healing node ' + node)} ({info['netns']}, {info['peer_ip']}:{info['cdp_port']})") + + record("lock", *_heal_lock_step(node)) + record("timer", *_heal_timer_step(node)) + record("tunnel", *_heal_tunnel_step(node)) + egress_ok, egress_detail = _warp_egress_ok(node) + record("egress", egress_ok, egress_detail) + relay = probe_cdp_status(info["peer_ip"], info["cdp_port"]) + if relay["ok"]: + record("relay", True, "CDP answers in %sms" % relay["latency_ms"]) + else: + record("relay", False, relay.get("error", "unreachable")) + + verdict = "RECOVERED" if egress_ok else "DOWN" + if as_json: + print(json.dumps({"ok": egress_ok, "node": node, + "verdict": verdict, "steps": steps}, indent=2)) + else: + print(f"\n Verdict: {c_green(verdict) if egress_ok else c_red(verdict)}") + if not egress_ok: + print(c_dim(" Tunnel still down after restart — if it persists, the Warp identity " + f"likely needs regenerating (human: netvm-new-identity.sh {node})")) + print() + sys.exit(0 if egress_ok else 1) + + +def _unit_state(unit): + """(enabled, active) booleans for a systemd unit.""" + rc_e, _ = _sh(["systemctl", "is-enabled", unit], timeout=15) + rc_a, _ = _sh(["systemctl", "is-active", unit], timeout=15) + return rc_e == 0, rc_a == 0 + + +def cmd_watchdog_status(args): + as_json = getattr(args, "json", False) + try: + import host_evidence + ev = host_evidence.collect() + except Exception: + ev = {} + data = [] + for node in VALID_NODES: + timer = "chromebox-watchdog-%s.timer" % node + enabled, active = _unit_state(timer) + node_ev = ev.get(node, {}) + data.append({ + "node": node, + "timer": timer, + "enabled": enabled, + "active": active, + "browser": node_ev.get("browser", "unknown"), + "browser_detail": node_ev.get("browser_detail", ""), + "cdp": node_ev.get("cdp", "unknown"), + "cdp_detail": node_ev.get("cdp_detail", ""), + }) + relay_enabled, relay_active = _unit_state("cdp-relay-watchdog.timer") + relay = {"timer": "cdp-relay-watchdog.timer", + "enabled": relay_enabled, "active": relay_active} + if as_json: + print(json.dumps({"ok": True, "nodes": data, "relay": relay}, indent=2)) + return + + print("\n" + c_bold("=== WATCHDOG STATUS ===") + c_dim(f" ({datetime.now().strftime('%H:%M:%S')} local)\n")) + headers = ["NODE", "TIMER", "BROWSER", "CDP"] + rows = [] + for item in data: + timer = badge_ok("active") if item["active"] else ( + badge_warn("ENABLED") if item["enabled"] else badge_dim("missing")) + rows.append([c_bold(item["node"]), timer, + item["browser"], item["cdp"]]) + print_table(headers, rows) + rmark = badge_ok("active") if relay["active"] else badge_dim("missing") + print(f" relay: {rmark} {c_dim('cdp-relay-watchdog.timer')}\n") + + +def cmd_watchdog_run(args): + target = args.target + as_json = getattr(args, "json", False) + if target == "relay": + unit = "cdp-relay-watchdog.service" + elif target in VALID_NODES: + unit = "chromebox-watchdog@%s.service" % target + else: + print("Error: Specify node or 'relay' (e.g. box watchdog run dev)", file=sys.stderr) + sys.exit(1) + rc, out = _sh(["sudo", "systemctl", "start", unit], timeout=180) + tail = out[-500:] if out else "" + if as_json: + print(json.dumps({"ok": rc == 0, "unit": unit, "detail": tail}, indent=2)) + else: + if rc == 0: + print(c_green(f"Watchdog run triggered: {unit}")) + else: + print(c_red(f"Failed to start {unit}: {tail}")) + sys.exit(0 if rc == 0 else 1) + # --------------------------------------------------------------------------- # Domain: APPROVALS # --------------------------------------------------------------------------- @@ -729,6 +930,136 @@ def cmd_approvals(args): print(f" Reason: {c_cyan(reason)}") print(c_dim(f" Operator can approve with: box approvals allow {node}")) +# --------------------------------------------------------------------------- +# Domain: MUSE-CHOICES (Muse TUI A/B/C auto-answer daemon) +# --------------------------------------------------------------------------- +def cmd_muse_choices(args): + import muse_choice_watcher as mcw + action = getattr(args, "mc_action", None) or "status" + as_json = getattr(args, "json", False) + caller = os.environ.get("BOX_CALLER") or getattr(args, "from_agent", None) or "super" + + if action == "on": + dry_run = getattr(args, "dry_run", False) + state = mcw.set_enabled(True, dry_run=dry_run, by=caller) + res = mcw.reconcile() + if as_json: + print(json.dumps({"ok": True, "desired": state, "reconcile": res}, indent=2)) + return + mode = "DRY-RUN (logging only)" if dry_run else c_yellow("LIVE (typing A into Muse panes)") + print(c_green("\n✔ Muse choice auto-answers ON.") + f" [{mode}]\n") + for s in res["started"]: + print(f" {badge_ok('STARTED')} {c_cyan(s)}") + for s in res["already"]: + print(f" {badge_dim('RUNNING')} {s}") + for s in res.get("failed") or []: + print(f" {badge_err('FAILED')} {s}") + if not res["started"] and not res["already"] and not res.get("failed"): + print(c_dim(" No Muse panes found on known tmux sockets.")) + print() + + elif action == "off": + state = mcw.set_enabled(False, by=caller) + stopped = mcw.stop_all() + if as_json: + print(json.dumps({"ok": True, "desired": state, "stopped": stopped}, indent=2)) + return + print(c_green("\n✔ Muse choice auto-answers OFF.")) + for r in stopped: + if r.get("status") == "stopped-orphan": + label = "(orphan %s:%s)" % (r.get("socket"), r.get("pane")) + else: + label = r.get("pidfile") + print(f" {badge_dim('STOPPED')} {label} (pid {r.get('pid')})") + if not stopped: + print(c_dim(" No watchers were running.")) + print() + + elif action == "reconcile": + res = mcw.reconcile() + if as_json: + print(json.dumps({"ok": True, "reconcile": res}, indent=2)) + return + print(f"\n Enabled: {c_bold(str(res['enabled']))} Dry-run: {res['dry_run']}") + for s in res["started"]: + print(f" {badge_ok('STARTED')} {c_cyan(s)}") + for s in res["stopped"]: + print(f" {badge_dim('STOPPED')} {s}") + for s in res["pruned"]: + print(f" {badge_dim('PRUNED')} {s}") + for s in res.get("failed") or []: + print(f" {badge_err('FAILED')} {s}") + if not res["started"] and not res["stopped"] and not res["pruned"] and not res.get("failed"): + print(c_dim(" Already in desired state.")) + print() + + elif action == "logs": + sock = getattr(args, "socket", None) + pane = getattr(args, "pane", None) + n = getattr(args, "n", 20) + path = mcw.logfile_for(sock, pane) + try: + with open(path) as f: + lines = f.readlines() + except OSError: + print(c_red(f"Error: no log at {path}"), file=sys.stderr) + sys.exit(1) + if as_json: + recs = [] + for line in lines[-n:]: + try: + recs.append(json.loads(line)) + except Exception: + recs.append({"raw": line.rstrip("\n")}) + print(json.dumps({"ok": True, "log": path, "records": recs}, indent=2)) + return + print(c_bold(f"\n=== {path} (last {n}) ===\n")) + for line in lines[-n:]: + print(line.rstrip("\n")) + print() + + else: # status + desired = mcw.get_desired() + watchers = mcw.status_all() + answers = mcw.recent_answers(5) + if as_json: + print(json.dumps({"ok": True, "desired": desired, + "watchers": watchers, + "recent_answers": answers}, indent=2)) + return + state_badge = badge_ok("ON") if desired["enabled"] else badge_dim("OFF") + dry = " (dry-run)" if desired.get("dry_run") else "" + print("\n" + c_bold("=== MUSE CHOICE AUTO-ANSWERS ===") + f" {state_badge}{dry}") + upd = desired.get("updated_at") or "-" + by = desired.get("updated_by") or "-" + print(c_dim(f" Desired state: enabled={desired['enabled']} dry_run={desired.get('dry_run')} updated={upd} by={by}\n")) + if watchers: + headers = ["WATCHER", "STATUS", "PID", "LOG BYTES"] + rows = [] + for w in watchers: + if w.get("orphan"): + badge = badge_warn("ORPHAN") + name = "(orphan %s:%s)" % (w.get("socket"), w.get("pane")) + else: + badge = badge_ok("ALIVE") if w["alive"] else badge_err("DEAD") + name = w["pidfile"] + rows.append([name, badge, str(w["pid"] or "-"), + str(w["log_bytes"] if w["log_bytes"] is not None else "-")]) + print_table(headers, rows) + else: + print(c_dim(" No watcher state files.")) + if answers: + print(c_bold("\n Recent answers (box audit feed):")) + for a in answers: + opts = "; ".join(a.get("options") or [])[:80] + kind = a.get("kind") or "-" + key = a.get("key") or "-" + print(f" • {c_dim(a.get('ts', ''))} {c_bold(a.get('name', ''))} {kind}/{key} sig={a.get('sig')} ok={a.get('ok')}") + print(f" {c_dim(opts)}") + else: + print(c_dim("\n No answers recorded in box audit feed.")) + print() + def resolve_sender(args) -> str: explicit = getattr(args, "from_agent", None) if explicit and explicit != DEFAULT_SENDER: @@ -768,6 +1099,359 @@ def sign_message(sender: str, message: str, key_path: Path = None, msg_id: str = signed_wire = f"[from:{sender}] [id:{mid}]\n\n{message}\n\n{sigblock}" return signed_wire, mid +# --------------------------------------------------------------------------- +# Domain: INVITE +# --------------------------------------------------------------------------- +def cmd_invite(args): + import invite + from invite_handler import salvage_blocked_node + action = getattr(args, "invite_action", None) or "status" + node = getattr(args, "node", None) + as_json = getattr(args, "json", False) + + if action in ("status", "list"): + nodes = [node] if node else VALID_NODES + res = invite.fleet_invite_status(nodes) + if as_json: + print(json.dumps({"ok": True, "agents": res}, indent=2)) + return + print("\n" + c_bold("=== FLEET INVITE STATUS ===") + "\n") + headers = ["NODE", "CODE", "REDEEMED", "USES LEFT", "USED", "REWARD"] + rows = [] + for n in nodes: + it = res.get(n, {}) + if not it.get("ok"): + rows.append([c_bold(n), c_red("ERROR"), "-", "-", "-", + (it.get("error") or "")[:40]]) + continue + rows.append([c_bold(n), c_cyan(it.get("code") or "-"), + "yes" if it.get("has_redeemed") else c_green("no"), + str(it.get("uses_remaining") + if it.get("uses_remaining") is not None else "-"), + str(it.get("use_count") + if it.get("use_count") is not None else "-"), + ((it.get("reward") or {}).get("title") or "-")[:40]]) + print_table(headers, rows) + print() + elif action in ("code", "find"): + try: + res = invite.get_invite(node) + except invite.InviteError as e: + print(c_red(f" Error: {e}"), file=sys.stderr) + sys.exit(2) + if as_json: + print(json.dumps(res, indent=2)) + elif res.get("ok"): + print(f" {c_bold(node)} invite code: {c_cyan(res['code'])}") + if res.get("uses_remaining") is not None: + print(f" Uses remaining: {res['uses_remaining']}") + print(f" Source: {res.get('source', 'api')}") + else: + print(c_red(f" Error: {res.get('error')}"), file=sys.stderr) + sys.exit(1) + elif action == "redeem": + code = getattr(args, "code", None) + try: + res = invite.redeem_invite(node, code) + except invite.InviteError as e: + print(c_red(f" Error: {e}"), file=sys.stderr) + sys.exit(2) + if as_json: + print(json.dumps(res, indent=2)) + elif res.get("ok"): + msg = f" ✔ Redeemed {res['code']} on {node}." + if res.get("detail"): + msg += f" {res['detail']}" + print(c_green(msg)) + else: + print(c_red(f" ✘ Redeem failed on {node}: " + f"{res.get('reason') or res.get('error')}"), + file=sys.stderr) + if res.get("detail"): + print(c_dim(f" {res['detail']}"), file=sys.stderr) + sys.exit(1) + elif action == "salvage": + target = getattr(args, "node", "646") or "646" + res = salvage_blocked_node(target, helper_node=getattr(args, "helper", None)) + if as_json: + print(json.dumps(res, indent=2)) + return + print(f"\n{c_bold('=== SALVAGE REPORT FOR ' + target.upper() + ' ===')}") + code = res.get("invite_code") or res.get("code_redeemed") + print(f" Invite Code to Credit: {c_cyan(str(code))}") + if res.get("success"): + print(c_green(f" Status: SUCCESS! Redeemed on {res.get('helper_node')}. 1 billion tokens credited to {target}!")) + else: + diag = res.get("error") or (res.get("redemption_result") or {}).get("detail") or "Redemption failed on helper peer" + print(c_yellow(f" Diagnosis: {diag}")) + action_hint = res.get("share_instruction") or f"Redeem code '{code}' via onboarding pipeline to grant 1B tokens to {target}." + print(f" Action: {action_hint}") + print(f" Command: {c_cyan('box onboard start --email --for ' + target)}") + print() + + +# --------------------------------------------------------------------------- +# Domain: USAGE +# --------------------------------------------------------------------------- +def cmd_usage(args): + import invite + node = getattr(args, "node", None) or getattr(args, "node_pos", None) + as_json = getattr(args, "json", False) + nodes = [node] if node else VALID_NODES + res = invite.fleet_usage(nodes) + if as_json: + print(json.dumps({"ok": True, "agents": res}, indent=2)) + return + print("\n" + c_bold("=== FLEET USAGE LIMITS ===") + "\n") + headers = ["NODE", "WEEKLY RESET", "WEEKLY USED", "ADDITIONAL", + "ADD LEFT", "REDEEMED"] + rows = [] + has_blocked = False + for n in nodes: + it = res.get(n, {}) + if not it.get("ok"): + rows.append([c_bold(n), c_red("ERROR"), "-", "-", + (it.get("error") or "")[:30], "-"]) + continue + wu_val = it.get("weekly_used_pct") + wu = ("%d%%" % wu_val) if wu_val is not None else "-" + au_val = it.get("additional_used_pct") + au = ("%d%% used" % au_val) if au_val is not None else "-" + add_left = it.get("additional_left") or "-" + redeemed_val = it.get("has_redeemed") + redeemed_str = "yes" if redeemed_val else (c_green("no") if redeemed_val is False else "-") + if (wu_val is not None and wu_val >= 100 and au_val is not None and au_val >= 100) or ("0 tokens left" in add_left): + has_blocked = True + n_disp = c_red(f"● {n}") + else: + n_disp = c_bold(n) + rows.append([n_disp, it.get("weekly_reset") or "-", + wu, "%s / %s" % (it.get("additional_expires") or "-", + au), + add_left, redeemed_str]) + print_table(headers, rows) + if has_blocked: + print("\n" + c_yellow(" ⚠ One or more agents have reached usage limits. Run 'box invite salvage ' to resolve.") + "\n") + else: + print() + + +# --------------------------------------------------------------------------- +# Domain: SETTINGS +# --------------------------------------------------------------------------- +def cmd_settings(args): + from settings_rpa import SettingsRPA + action = getattr(args, "settings_action", "usage") or "usage" + node = getattr(args, "node", "646") or "646" + as_json = getattr(args, "json", False) + + with SettingsRPA(node) as rpa: + if action == "usage": + usage = rpa.read_usage() + if as_json: + print(json.dumps(usage.to_dict(), indent=2)) + return + status_badge = badge_err("BLOCKED") if usage.is_blocked else badge_ok("ACTIVE") + redeemed_badge = c_green("No (Eligible to redeem)") if not usage.has_redeemed else "Yes (Already redeemed)" + print(f"\n{c_bold('=== Settings RPA Usage: ' + node + ' ===')}") + print(f" Plan: {usage.plan}") + print(f" Weekly Limit: {usage.weekly_reset_text} ({c_bold(str(usage.weekly_percent_used) + '%')} used)") + print(f" Extra Tokens: {usage.extra_tokens_remaining} ({c_bold(str(usage.extra_percent_used) + '%')} used)") + print(f" Has Redeemed: {redeemed_badge}") + print(f" Status: {status_badge}\n") + elif action == "check-redeem": + info = rpa.check_redeem_entrypoint() + print(json.dumps(info, indent=2)) + + +# --------------------------------------------------------------------------- +# Domain: ONBOARD (Agent-driven Onboarding & Invite Salvage Pipeline) +# --------------------------------------------------------------------------- +def cmd_onboard(args): + import onboard_pipeline + action = getattr(args, "onboard_action", "start") or "start" + as_json = getattr(args, "json", False) + + if action == "start": + node = args.node + email = args.email + for_agent = getattr(args, "for_agent", None) + code = getattr(args, "code", None) + acct_name = getattr(args, "account_name", None) + + res = onboard_pipeline.start_onboarding(node, email, beneficiary_node=for_agent, invite_code=code, account_name=acct_name) + if as_json: + print(json.dumps(res, indent=2)) + return + if res.get("ok"): + print(f"\n{c_bold('=== ONBOARDING PIPELINE STARTED: ' + node.upper() + ' ===')}") + print(f" Target Node: {c_bold(node)}") + print(f" Client Email: {email}") + print(f" Beneficiary: @{res.get('beneficiary_node')} ({c_cyan(str(res.get('invite_code_queued')))})") + print(f" Status: {c_yellow(res.get('status', 'in_progress').upper())}") + print(f" Next Step: {res.get('message')}\n") + else: + print(c_red(f"\n✘ Onboarding failed: {res.get('error')}\n"), file=sys.stderr) + sys.exit(1) + + elif action == "submit-otp": + node = args.node + otp = args.otp + email = getattr(args, "email", None) + + res = onboard_pipeline.submit_onboarding_otp(node, otp, email=email) + if as_json: + print(json.dumps(res, indent=2)) + return + if res.get("ok"): + print(f"\n{c_green('=== ONBOARDING & REDEMPTION COMPLETE ===')}") + print(f" Node: {c_bold(node)}") + print(f" Beneficiary: @{res.get('beneficiary_node')}") + print(f" Token Grant: +1,000,000,000 Muse tokens each") + print(f" Message: {c_green(res.get('message'))}\n") + else: + print(c_red(f"\n✘ OTP submission failed: {res.get('error')}\n"), file=sys.stderr) + sys.exit(1) + + elif action == "status": + node = args.node + state = onboard_pipeline.OnboardState.load(node) + if not state: + print(c_red(f"No onboarding record found for node '{node}'"), file=sys.stderr) + sys.exit(1) + if as_json: + print(json.dumps(onboard_pipeline.asdict(state), indent=2)) + return + print(f"\n{c_bold('=== ONBOARDING STATUS: @' + state.node.upper() + ' ===')}") + print(f" Email: {state.email}") + print(f" Current Stage: {c_cyan(state.stage)}") + print(f" Beneficiary Agent:@{state.beneficiary_node or '-'} (Code: {c_cyan(str(state.invite_code or '-'))})") + if state.detail: + print(f" Detail: {state.detail}") + if state.redemption_result: + rr = state.redemption_result + r_st = c_green("SUCCESS") if rr.get("ok") else c_red("FAILED") + print(f" Redemption: {r_st}") + print() + + elif action == "salvage-wo": + node = getattr(args, "node", "646") or "646" + target_chat = getattr(args, "target", "646 tasks") or "646 tasks" + res = onboard_pipeline.issue_salvage_work_order(node, to_sidechat=target_chat) + if as_json: + print(json.dumps(res, indent=2)) + return + if res.get("ok"): + print(c_green(f"\n✔ Cryptographically signed salvage work order dispatched for @{node} -> {target_chat}\n")) + else: + print(c_red(f"\n✘ Failed to dispatch work order: {res}\n"), file=sys.stderr) + sys.exit(1) + + elif action == "feed-matrix": + matrix = onboard_pipeline.calculate_feeding_weights() + if as_json: + print(json.dumps({"ok": True, "feed_matrix": matrix}, indent=2)) + return + print("\n" + c_bold("=== FLEET FEEDING & TOKEN ALLOCATION MATRIX ===")) + print(c_dim(" (Ranked by: Urgency + Work Done Over Time + Job Amount + Role Criticality)\n")) + headers = ["RANK", "AGENT", "ROLE", "FEED SCORE", "STATUS", "JOBS", "WORK DONE", "CODE"] + rows = [] + for idx, r in enumerate(matrix, 1): + st = c_red("BLOCKED") if r["is_blocked"] else (c_yellow("LIMIT") if r["status"] == "LIMIT_REACHED" else c_green("ACTIVE")) + rows.append([ + f"#{idx}", + c_bold(r["node"]), + r["role"], + c_cyan(str(r["feeding_weight"]) + " pts"), + st, + str(r["jobs_count"]), + f"{r['work_done_msgs']} msgs", + c_bold(r["code"]), + ]) + print_table(headers, rows) + print() + + +# --------------------------------------------------------------------------- +# Domain: CHROMEBOX (agent browser settings-menu toggles) +# --------------------------------------------------------------------------- +def cmd_chromebox(args): + from hatch_menu import toggles as menu_toggles + action = getattr(args, "chrome_action", None) + if action != "permissions": + print(c_red(" Error: expected 'permissions' (box chromebox " + "permissions list|get|set|describe)"), + file=sys.stderr) + sys.exit(2) + node = getattr(args, "node", None) + as_json = getattr(args, "json", False) + perm_action = getattr(args, "perm_action", None) or "list" + + if perm_action == "list": + tab = getattr(args, "tab", None) + try: + res = menu_toggles.list_toggles(node, tab=tab) + except menu_toggles.MenuError as e: + print(c_red(f" Error: {e}"), file=sys.stderr) + sys.exit(2) + if as_json: + print(json.dumps(res, indent=2)) + return + if not res.get("ok"): + print(c_red(f" Error: {res.get('error')}"), file=sys.stderr) + sys.exit(1) + print("\n" + c_bold(f"=== CHROMEBOX TOGGLES: {node} ===") + "\n") + rows = [] + for name in sorted(res.get("toggles", {})): + val = res["toggles"][name] + rows.append([name, c_red("-") if val is None else str(val)]) + print_table(["TOGGLE", "VALUE"], rows) + print() + elif perm_action == "get": + name = getattr(args, "toggle", None) + try: + res = menu_toggles.get_toggle(node, name) + except menu_toggles.MenuError as e: + print(c_red(f" Error: {e}"), file=sys.stderr) + sys.exit(2) + if as_json: + print(json.dumps(res, indent=2)) + elif res.get("ok"): + print(f" {c_bold(name)} = {c_cyan(res['value'])}") + else: + print(c_red(f" Error: {res.get('error')}"), file=sys.stderr) + sys.exit(1) + elif perm_action == "set": + name = getattr(args, "toggle", None) + value = getattr(args, "value", None) + try: + res = menu_toggles.set_toggle(node, name, value) + except menu_toggles.MenuError as e: + print(c_red(f" Error: {e}"), file=sys.stderr) + sys.exit(2) + if as_json: + print(json.dumps(res, indent=2)) + elif res.get("ok"): + print(c_green(f" ✔ {name} = {res['value']} on {node} " + f"(readback verified).")) + else: + print(c_red(f" ✘ Set failed on {node}: {res.get('error')}"), + file=sys.stderr) + sys.exit(1) + elif perm_action == "describe": + tab = getattr(args, "tab", None) + try: + res = menu_toggles.describe_tab(node, tab) + except menu_toggles.MenuError as e: + print(c_red(f" Error: {e}"), file=sys.stderr) + sys.exit(2) + print(json.dumps(res, indent=2)) + else: + print(c_red(f" Error: unknown action {perm_action!r}"), + file=sys.stderr) + sys.exit(2) + + # --------------------------------------------------------------------------- # Domain: DM (Direct Messaging & Work Orders) # --------------------------------------------------------------------------- @@ -4275,11 +4959,16 @@ def build_parser(): subparsers = parser.add_subparsers(dest="domain", help="Orchestration Domain") # Domain: FLEET - p_fleet = subparsers.add_parser("fleet", parents=[common], help="Node health, CDP status, active tabs, watch, restart") - p_fleet.add_argument("action", nargs="?", default="status", choices=["status", "watch", "restart", "cdp"]) - p_fleet.add_argument("node", nargs="?", default=None, help="Target node (for restart / cdp)") + p_fleet = subparsers.add_parser("fleet", parents=[common], help="Node health, CDP status, active tabs, watch, restart, heal") + p_fleet.add_argument("action", nargs="?", default="status", choices=["status", "watch", "restart", "cdp", "heal"]) + p_fleet.add_argument("node", nargs="?", default=None, help="Target node (for restart / cdp / heal)") p_fleet.add_argument("--interval", type=int, default=2, help="Watch refresh interval in seconds") + # Domain: WATCHDOG + p_watchdog = subparsers.add_parser("watchdog", parents=[common], help="Watchdog timers: status, trigger runs") + p_watchdog.add_argument("action", nargs="?", default="status", choices=["status", "run"]) + p_watchdog.add_argument("target", nargs="?", default=None, help="Target node (or 'relay') for run") + # Domain: APPROVALS p_approvals = subparsers.add_parser("approvals", parents=[common], help="Inspect and handle agent browser & gateway approvals") p_approval = subparsers.add_parser("approval", parents=[common], help="Alias for 'approvals'") @@ -4343,6 +5032,107 @@ def build_parser(): p_app_req_key.add_argument("node", choices=VALID_NODES, help="Target node requesting key") p_app_req_key.add_argument("--reason", default="Passkey authentication required", help="Reason for key request") + # Domain: MUSE-CHOICES + p_mc = subparsers.add_parser("muse-choices", parents=[common], help="Muse TUI A/B/C choice auto-answer daemon (on/off/status/logs)") + mc_sub = p_mc.add_subparsers(dest="mc_action") + + p_mc_on = mc_sub.add_parser("on", parents=[common], help="Enable auto-answers on all Muse panes") + p_mc_on.add_argument("--dry-run", action="store_true", help="Log answers instead of sending keys") + + p_mc_off = mc_sub.add_parser("off", parents=[common], help="Disable auto-answers and stop all watchers") + + p_mc_status = mc_sub.add_parser("status", parents=[common], help="Show desired state, watchers, recent answers") + + p_mc_logs = mc_sub.add_parser("logs", parents=[common], help="Tail one pane watcher log") + p_mc_logs.add_argument("--socket", required=True, help="Tmux socket path (e.g. /tmp/tmux-1000/default)") + p_mc_logs.add_argument("--pane", required=True, help="Pane id (e.g. %%37)") + p_mc_logs.add_argument("-n", type=int, default=20, help="Lines to show (default: 20)") + + p_mc_rec = mc_sub.add_parser("reconcile", parents=[common], help="Enforce desired state now (start missing / stop excess)") + + # Domain: INVITE + p_invite = subparsers.add_parser("invite", parents=[common], help="Muse.ai invite codes: find per-agent codes and redeem") + p_invite.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node (status)") + inv_sub = p_invite.add_subparsers(dest="invite_action") + + p_inv_status = inv_sub.add_parser("status", parents=[common], help="Invite code matrix across fleet (default)") + p_inv_status.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node") + + p_inv_list = inv_sub.add_parser("list", parents=[common], help="Alias for 'status'") + p_inv_list.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node") + + p_inv_code = inv_sub.add_parser("code", parents=[common], help="Show one agent's invite code") + p_inv_code.add_argument("node", choices=VALID_NODES, help="Target node") + + p_inv_find = inv_sub.add_parser("find", parents=[common], help="Alias for 'code'") + p_inv_find.add_argument("node", choices=VALID_NODES, help="Target node") + + p_inv_redeem = inv_sub.add_parser("redeem", parents=[common], help="Redeem an invite code on a node") + p_inv_redeem.add_argument("node", choices=VALID_NODES, help="Target node") + p_inv_redeem.add_argument("code", help="6-char invite code") + + p_inv_salvage = inv_sub.add_parser("salvage", parents=[common], help="Salvage an out-of-tokens agent (defaults to 646)") + p_inv_salvage.add_argument("node", nargs="?", default="646", choices=VALID_NODES, help="Blocked agent node (default: 646)") + p_inv_salvage.add_argument("--helper", choices=VALID_NODES, help="Specific helper agent to redeem code") + + # Domain: USAGE + p_usage = subparsers.add_parser("usage", parents=[common], help="Muse.ai usage limits per agent") + p_usage.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node") + p_usage.add_argument("node_pos", nargs="?", default=None, choices=VALID_NODES, help="Optional node positional argument") + + # Domain: SETTINGS + p_settings = subparsers.add_parser("settings", parents=[common], help="Settings menu RPA automation") + settings_sub = p_settings.add_subparsers(dest="settings_action") + p_set_usage = settings_sub.add_parser("usage", parents=[common], help="Extract usage via Settings RPA") + p_set_usage.add_argument("node", choices=VALID_NODES, default="646", nargs="?", help="Target node (default: 646)") + p_set_check = settings_sub.add_parser("check-redeem", parents=[common], help="Check if Redeem Invite Code is in General Settings") + p_set_check.add_argument("node", choices=VALID_NODES, default="646", nargs="?", help="Target node (default: 646)") + + # Domain: ONBOARD + p_onboard = subparsers.add_parser("onboard", parents=[common], help="Agent onboarding & automated invite code salvage pipeline") + onboard_sub = p_onboard.add_subparsers(dest="onboard_action") + + p_onb_start = onboard_sub.add_parser("start", parents=[common], help="Start onboarding pipeline (provisions infra + initiates auth)") + p_onb_start.add_argument("node", help="Target new node label (e.g. dev2, client1)") + p_onb_start.add_argument("--email", required=True, help="Client login email") + p_onb_start.add_argument("--for", dest="for_agent", choices=VALID_NODES, help="Beneficiary agent to unblock (defaults to most urgent)") + p_onb_start.add_argument("--code", help="Explicit 6-character invite code to redeem") + p_onb_start.add_argument("--account-name", help="Display name hint for multi-account selection") + + p_onb_otp = onboard_sub.add_parser("submit-otp", parents=[common], help="Submit transient OTP and complete auto-redemption") + p_onb_otp.add_argument("node", help="Target node label") + p_onb_otp.add_argument("otp", help="6-digit verification code") + p_onb_otp.add_argument("--email", help="Client email (optional)") + + p_onb_stat = onboard_sub.add_parser("status", parents=[common], help="Query onboarding pipeline state for a node") + p_onb_stat.add_argument("node", help="Target node label") + + p_onb_wo = onboard_sub.add_parser("salvage-wo", parents=[common], help="Dispatch cryptographically signed salvage work order to sidechat") + p_onb_wo.add_argument("node", nargs="?", default="646", choices=VALID_NODES, help="Blocked agent node (default: 646)") + p_onb_wo.add_argument("--target", default="646 tasks", help="Target sidechat (default: 646 tasks)") + + onboard_sub.add_parser("feed-matrix", parents=[common], help="Display all agents ranked by feeding weight, job volume, role, and work done over time") + + # Domain: CHROMEBOX + p_chromebox = subparsers.add_parser("chromebox", parents=[common], help="Agent browser settings-menu toggles (chromebox RPA)") + chrome_sub = p_chromebox.add_subparsers(dest="chrome_action") + p_chrome_perm = chrome_sub.add_parser("permissions", parents=[common], help="Settings menu toggles (permissions + related tabs)") + p_chrome_perm.add_argument("node", choices=VALID_NODES, help="Target node") + perm_sub = p_chrome_perm.add_subparsers(dest="perm_action") + + p_perm_list = perm_sub.add_parser("list", parents=[common], help="List toggle states (default)") + p_perm_list.add_argument("--tab", default=None, help="Only this settings tab") + + p_perm_get = perm_sub.add_parser("get", parents=[common], help="Read one toggle") + p_perm_get.add_argument("toggle", help="Toggle address (e.g. permissions.web_access, permissions.websites:example.com)") + + p_perm_set = perm_sub.add_parser("set", parents=[common], help="Set one toggle (verified with readback)") + p_perm_set.add_argument("toggle", help="Toggle address") + p_perm_set.add_argument("value", help="Value (on/off, Allow/Ask/Deny, auto_allow/always_ask, theme name)") + + p_perm_desc = perm_sub.add_parser("describe", parents=[common], help="Full inventory of one settings tab (JSON)") + p_perm_desc.add_argument("tab", help="Settings tab name (e.g. Permissions)") + # Domain: DM p_dm = subparsers.add_parser("dm", parents=[common], help="Inter-agent DMs, work orders ([WO]), acks, live log tail") dm_sub = p_dm.add_subparsers(dest="action") @@ -4817,11 +5607,20 @@ def build_parser(): p_docs = subparsers.add_parser("docs", aliases=["doc"], parents=[common], help="Agent lookups, surfaces for box.muse-dev.online, sentence grammar & regex") p_docs.add_argument("docs_args", nargs=argparse.REMAINDER, help="Arguments passed directly to docs-lookup.py") + # Domain: TUI (Interactive full-screen Muse TUI & Box fleet console) + p_tui = subparsers.add_parser("tui", parents=[common], help="Interactive full-screen Muse TUI & Box fleet console") + p_tui.add_argument("tui_args", nargs=argparse.REMAINDER, help="Arguments passed directly to muse-tui.py") + return parser def main(): if len(sys.argv) > 1: - if sys.argv[1] == "tmux": + if sys.argv[1] == "tui": + tui_args = sys.argv[2:] + cmd = [sys.executable, str(BIN_DIR / "muse-tui.py"), "--mode", "box"] + tui_args + res = subprocess.run(cmd) + sys.exit(res.returncode) + elif sys.argv[1] == "tmux": cmd = [sys.executable, str(BIN_DIR / "muse-tmux.py")] + (sys.argv[2:] or ["list"]) res = subprocess.run(cmd) sys.exit(res.returncode) @@ -4891,6 +5690,20 @@ def main(): print("Error: Specify node (e.g. super fleet cdp muse)", file=sys.stderr) sys.exit(1) cmd_fleet_cdp(args) + elif act == "heal": + if not getattr(args, "node", None): + print("Error: Specify node to heal (e.g. super fleet heal dev)", file=sys.stderr) + sys.exit(1) + cmd_fleet_heal(args) + elif args.domain == "watchdog": + act = getattr(args, "action", "status") or "status" + if act == "status": + cmd_watchdog_status(args) + elif act == "run": + if not getattr(args, "target", None): + print("Error: Specify node or 'relay' (e.g. box watchdog run dev)", file=sys.stderr) + sys.exit(1) + cmd_watchdog_run(args) elif args.domain == "dm": act = getattr(args, "action", None) if not act or act == "log": @@ -5083,8 +5896,20 @@ def main(): cmd_swarm_prune(args) else: p_swarm.print_help() - elif args.domain in ("approvals", "approval"): + elif args.domain in ("approvals", "approval", "blocked"): cmd_approvals(args) + elif args.domain == "muse-choices": + cmd_muse_choices(args) + elif args.domain == "invite": + cmd_invite(args) + elif args.domain == "usage": + cmd_usage(args) + elif args.domain == "settings": + cmd_settings(args) + elif args.domain == "onboard": + cmd_onboard(args) + elif args.domain == "chromebox": + cmd_chromebox(args) elif args.domain in ("deploy", "subagent"): if args.domain == "subagent": args.action = "subagent" diff --git a/tests/test_context_menus.py b/tests/test_context_menus.py index 026dc50..97f33f1 100644 --- a/tests/test_context_menus.py +++ b/tests/test_context_menus.py @@ -170,15 +170,155 @@ class TestContextMenus(unittest.TestCase): self.assertEqual(self.tui.modal, "chat_context") self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678") - def test_slash_command_context(self): - """Slash command /context opens chat_context or agent_context.""" - self.tui._execute_input_line("/context chat") + def test_mouse_motion_does_not_dismiss_context_menu(self): + """Moving the mouse (is_drag=True) over screen after context popup opens does NOT close it.""" + # Simulate right click to open message context + # Transcript area starts around y=4 + handled = self.tui._handle_mouse(50, 6, curses.BUTTON3_CLICKED) + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "message_context") + + # Now simulate mouse motion event (REPORT_MOUSE_POSITION) anywhere + handled_motion = self.tui._handle_mouse(10, 10, curses.REPORT_MOUSE_POSITION) + self.assertTrue(handled_motion) + # Modal must remain open! + self.assertEqual(self.tui.modal, "message_context") + + def test_middle_click_opens_context_menu(self): + """Middle click (BUTTON2_CLICKED) also opens context menu for terminals intercepting button 3.""" + self.tui.modal = None + handled = self.tui._handle_mouse(50, 6, curses.BUTTON2_CLICKED) + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "message_context") + + def test_header_menu_button_opens_chat_context(self): + """Left-clicking the [⚡ Menu] action button in the thread header opens chat_context.""" + self.tui.modal = None + # Thread header button bounds: (btn_y, btn_x_start, btn_x_end) + self.tui._btn_menu_bounds = (1, 70, 78) + handled = self.tui._handle_mouse(72, 1, curses.BUTTON1_CLICKED) + self.assertTrue(handled) self.assertEqual(self.tui.modal, "chat_context") + def test_message_menu_button_opens_message_context(self): + """Left-clicking the [⚡ Menu] button on a message row opens message_context.""" self.tui.modal = None - self.tui._execute_input_line("/context agent") - self.assertEqual(self.tui.modal, "agent_context") + # Tuple: (msg_y, btn_x_start, btn_x_end, msg_obj) + self.tui._msg_menu_buttons = [ + (5, 75, 83, {"role": "user", "text": "Hello muse", "seq": 1}) + ] + handled = self.tui._handle_mouse(78, 5, curses.BUTTON1_CLICKED) + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "message_context") + + + def test_fleet_and_sidechats_separate_focus_partitions(self): + """Fleet and Sidechats operate as separate focus partitions with independent scroll & nav.""" + self.tui.focus_pane = "fleet" + self.tui.editor_mode = "NORMAL" + self.tui.fleet_focus_idx = 0 + + # Navigating down with 'j' in fleet partition advances fleet_focus_idx + self.tui._handle_key(ord('j')) + self.assertEqual(self.tui.fleet_focus_idx, 1) + + # 'l' switches from fleet to sidechats partition + self.tui._handle_key(ord('l')) + self.assertEqual(self.tui.focus_pane, "sidechats") + + # 'j' in sidechats advances thread_sel_idx + self.tui.thread_sel_idx = 0 + self.tui._handle_key(ord('j')) + self.assertEqual(self.tui.thread_sel_idx, 1) + + # 'h' switches back from sidechats to fleet + self.tui._handle_key(ord('h')) + self.assertEqual(self.tui.focus_pane, "fleet") + + def test_mouse_wheel_in_fleet_partition_scrolls_fleet_reliably(self): + """Mouse wheel scrolling over Fleet Agents partition adjusts fleet scrolling reliably.""" + self.tui.focus_pane = "fleet" + self.tui.fleet_focus_idx = 0 + + # Scroll down in fleet partition (bstate = BUTTON5_PRESSED) + bstate_down = getattr(curses, "BUTTON5_PRESSED", 0x200000) + self.tui._handle_mouse(mx=10, my=2, bstate=bstate_down) + self.assertGreaterEqual(self.tui.fleet_focus_idx, 1) + + # Scroll up in fleet partition (bstate = BUTTON4_PRESSED) + bstate_up = getattr(curses, "BUTTON4_PRESSED", 0x10000) + self.tui._handle_mouse(mx=10, my=2, bstate=bstate_up) + self.assertEqual(self.tui.fleet_focus_idx, 0) + + def test_sidebar_thread_menu_button_click(self): + """Left-clicking the [⚡] button on a sidebar thread row opens chat_context.""" + self.tui.modal = None + # Sidebar width is 26 for w=100. + # Button [⚡] is rendered around sidebar_w - 9 (col 17) + # Row 11 is thread 1 + handled = self.tui._handle_mouse(mx=17, my=11, bstate=curses.BUTTON1_CLICKED) + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "chat_context") + + + def test_sgr_escape_sequence_right_click_opens_chat_context(self): + """SGR 1006 escape sequence fallback parses right-click and opens chat_context.""" + self.tui.modal = None + # Simulate ESC [ < 2 ; 11 ; 12 M (Right click at mx=10, my=11 on thread 1) + raw_sgr = [ord('['), ord('<'), ord('2'), ord(';'), ord('1'), ord('1'), ord(';'), ord('1'), ord('2'), ord('M')] + with patch.object(self.tui, "_read_escape_sequence", return_value=raw_sgr): + handled = self.tui._handle_escape_sequence() + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "chat_context") + self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678") + + def test_double_click_on_thread_opens_chat_context(self): + """Double-clicking a sidebar thread row opens chat_context.""" + self.tui.modal = None + bstate = getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0x8) + handled = self.tui._handle_mouse(mx=10, my=11, bstate=bstate) + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "chat_context") + + def test_click_on_already_active_thread_opens_chat_context(self): + """Left-clicking on the currently active thread in sidechats opens its context menu.""" + self.tui.modal = None + self.tui.focus_pane = "sidechats" + # Thread 0 (Main Chat, sess-main-1234) is currently active, located at my = 10 + handled = self.tui._handle_mouse(mx=5, my=10, bstate=curses.BUTTON1_CLICKED) + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "chat_context") + self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-main-1234") + + def test_click_right_side_of_thread_opens_chat_context(self): + """Left-clicking on the right side of a thread row ([⚡] or [sid]) opens chat_context.""" + self.tui.modal = None + sidebar_w = 26 + # Click at mx = 20 (right side of thread 1 at my = 11) + handled = self.tui._handle_mouse(mx=20, my=11, bstate=curses.BUTTON1_CLICKED) + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "chat_context") + + def test_space_key_in_normal_mode_opens_context(self): + """Pressing Space in NORMAL mode opens context menu for focused partition.""" + self.tui.modal = None + self.tui.editor_mode = "NORMAL" + self.tui.focus_pane = "sidechats" + self.tui.thread_sel_idx = 1 + handled = self.tui._handle_key(ord(' ')) + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "chat_context") + self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678") + + def test_shift_and_alt_left_click_opens_chat_context(self): + """Shift+Left Click or Alt+Left Click operates as right-click modifier.""" + self.tui.modal = None + shift_mask = getattr(curses, "BUTTON_SHIFT", 0x4000000) + handled = self.tui._handle_mouse(mx=10, my=11, bstate=curses.BUTTON1_CLICKED | shift_mask) + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "chat_context") if __name__ == "__main__": unittest.main() + diff --git a/tests/test_focus_highlight.py b/tests/test_focus_highlight.py new file mode 100644 index 0000000..82b1f57 --- /dev/null +++ b/tests/test_focus_highlight.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +test_focus_highlight.py — Comprehensive tests verifying active portion highlighting in MuseTUI: +1. Sidebar focused (focus_pane="sidebar", editor_mode="NORMAL") highlights sidebar header, + vertical pane divider, selected thread cursor, and bottom status bar hints. +2. Transcript focused (focus_pane="transcript", editor_mode="NORMAL") highlights transcript header, + header underline rule, vertical divider, and bottom status bar hints. +3. Composer active (editor_mode="INSERT") prominently highlights bottom composer divider + and status hints without distracting pane navigation highlights. +""" + +import unittest +from unittest.mock import MagicMock +import importlib.util +from pathlib import Path + +REPO_ROOT = Path("/home/super/Projects/NetVM") +TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py" + +spec = importlib.util.spec_from_file_location("muse_tui", str(TUI_PATH)) +muse_mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(muse_mod) + + +class TestActivePortionHighlighting(unittest.TestCase): + def setUp(self): + self.mock_stdscr = MagicMock() + self.mock_stdscr.getmaxyx.return_value = (30, 100) + self.tui = muse_mod.MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse") + self.calls = [] + + def record_addstr(win, y, x, text, attr=0): + self.calls.append((y, x, text, attr)) + + self.tui.safe_addstr = record_addstr + self.tui.toast_time = 0 + self.tui.data.nodes = ["muse", "646", "pip"] + self.tui.data.active_node = "muse" + self.tui.data.active_thread_id = "test_thread" + self.tui.data.active_thread_title = "Work Thread" + self.tui.data.threads_cache["muse"] = [ + {"session_id": "test_thread", "title": "Work Thread", "is_main": False}, + {"session_id": "main_id", "title": "Main Chat", "is_main": True}, + ] + self.tui.data.history_cache[("muse", "test_thread")] = [ + {"role": "user", "text": "Hello agent", "seq": 1, "message_id": "m1"}, + {"role": "assistant", "text": "Hello user", "seq": 2, "message_id": "m2"}, + ] + + def test_sidebar_focused_highlight(self): + self.tui.focus_pane = "sidebar" + self.tui.editor_mode = "NORMAL" + + # 1. Render Muse View + self.calls.clear() + self.tui._render_muse_view(2, 0, 24, 100) + + rendered_texts = [c[2] for c in self.calls] + full_text = " ".join(rendered_texts) + + # Sidebar header has [● FOCUS] badge + self.assertTrue(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts), + f"Expected [● FOCUS] in sidebar header. Got:\n{full_text}") + + # Vertical divider has heavy vertical char ┃ + self.assertTrue(any("┃" in t for t in rendered_texts), + "Expected vertical divider '┃' when pane is focused") + + # Transcript header does NOT have [● FOCUSED] badge + self.assertFalse(any("[● FOCUSED]" in t for t in rendered_texts), + "Transcript header should not be [● FOCUSED] when sidebar is active") + + # 2. Render Bottom Bar + self.calls.clear() + self.tui._render_bottom_bar(27, 0, 3, 100) + + bottom_texts = [c[2] for c in self.calls] + # Active pane divider has SIDEBAR highlight + self.assertTrue(any("ACTIVE PANE: SIDEBAR" in t for t in bottom_texts), + f"Expected ACTIVE PANE: SIDEBAR in bottom divider. Got:\n{bottom_texts}") + # Status hints show SIDEBAR FOCUSED + self.assertTrue(any("SIDEBAR FOCUSED" in t for t in bottom_texts), + f"Expected SIDEBAR FOCUSED in hints. Got:\n{bottom_texts}") + + def test_transcript_focused_highlight(self): + self.tui.focus_pane = "transcript" + self.tui.editor_mode = "NORMAL" + + # 1. Render Muse View + self.calls.clear() + self.tui._render_muse_view(2, 0, 24, 100) + + rendered_texts = [c[2] for c in self.calls] + + # Transcript header has [● FOCUSED] badge + self.assertTrue(any("[● FOCUSED] Thread:" in t for t in rendered_texts), + f"Expected [● FOCUSED] in transcript header. Got:\n{rendered_texts}") + + # Transcript horizontal divider has heavy rule ━ + self.assertTrue(any("━" in t for t in rendered_texts), + "Expected transcript underline divider '━' when transcript is focused") + + # Sidebar header does NOT have [● FOCUS] badge + self.assertFalse(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts), + "Sidebar should not have [● FOCUS] when transcript is active") + + # 2. Render Bottom Bar + self.calls.clear() + self.tui._render_bottom_bar(27, 0, 3, 100) + + bottom_texts = [c[2] for c in self.calls] + self.assertTrue(any("ACTIVE PANE: TRANSCRIPT" in t for t in bottom_texts), + f"Expected ACTIVE PANE: TRANSCRIPT in bottom divider. Got:\n{bottom_texts}") + self.assertTrue(any("TRANSCRIPT FOCUSED" in t for t in bottom_texts), + f"Expected TRANSCRIPT FOCUSED in hints. Got:\n{bottom_texts}") + + def test_insert_mode_composer_highlight(self): + self.tui.editor_mode = "INSERT" + + # 1. Render Bottom Bar + self.calls.clear() + self.tui._render_bottom_bar(27, 0, 3, 100) + + bottom_texts = [c[2] for c in self.calls] + self.assertTrue(any("COMPOSER: INSERT MODE ACTIVE" in t for t in bottom_texts), + f"Expected COMPOSER: INSERT MODE ACTIVE in divider. Got:\n{bottom_texts}") + self.assertTrue(any("INSERT ACTIVE" in t for t in bottom_texts), + f"Expected INSERT ACTIVE in hints. Got:\n{bottom_texts}") + + # 2. Render Muse View - neither pane should show [● FOCUS] / [● FOCUSED] + self.calls.clear() + self.tui._render_muse_view(2, 0, 24, 100) + + rendered_texts = [c[2] for c in self.calls] + self.assertFalse(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts), + "Sidebar should not claim active focus in INSERT mode") + self.assertFalse(any("[● FOCUSED] Thread:" in t for t in rendered_texts), + "Transcript should not claim active focus in INSERT mode") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_main_nav.py b/tests/test_main_nav.py index 478b2c8..dbcc973 100644 --- a/tests/test_main_nav.py +++ b/tests/test_main_nav.py @@ -11,6 +11,7 @@ test_main_nav.py — Comprehensive unit and integration test suite for: import unittest import subprocess import json +import re import os import sys import tempfile @@ -56,8 +57,8 @@ class TestSidechatNavigationLogic(unittest.TestCase): import dm # Well-known mappings self.assertEqual(dm.resolve_sidechat_target("main"), "main") - self.assertEqual(dm.resolve_sidechat_target("646 tasks"), "1dfb3199-2f99-446c-83e3-848ae2da0a12") - self.assertEqual(dm.resolve_sidechat_target("heartbeat"), "757198c3-c1b2-48b8-ba2b-062c84f71b02") + hb_uuid = dm.resolve_sidechat_target("heartbeat") + self.assertTrue(bool(re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", hb_uuid))) def test_uuid_regex_detection(self): import dm, re