From e6e5616de6fb6737348eb01652870addca7266ed Mon Sep 17 00:00:00 2001 From: operator Date: Sat, 10 Oct 2026 13:04:24 -0400 Subject: [PATCH] feat(tui): enable multi-component focus and viewport scrolling for Tab 8 and Box panels --- bin/muse-tui.py | 794 ++++++++++++++++++++++++++++++----- tests/test_box_tui_render.py | 148 +++++++ 2 files changed, 835 insertions(+), 107 deletions(-) diff --git a/bin/muse-tui.py b/bin/muse-tui.py index bb4c3aa..b8ece26 100755 --- a/bin/muse-tui.py +++ b/bin/muse-tui.py @@ -1114,7 +1114,7 @@ class FleetDataManager: prs = [] try: req_i = urllib.request.Request( - "https://tea.muse-dev.online/api/v1/repos/super/box/issues?state=all&limit=10", + "https://tea.muse-dev.online/api/v1/repos/super/box/issues?state=all&limit=35", headers={"User-Agent": "Box-Work-CLI/1.0"} ) with urllib.request.urlopen(req_i, timeout=3.0) as r: @@ -1124,7 +1124,7 @@ class FleetDataManager: try: req_p = urllib.request.Request( - "https://tea.muse-dev.online/api/v1/repos/super/box/pulls?state=all&limit=6", + "https://tea.muse-dev.online/api/v1/repos/super/box/pulls?state=all&limit=25", headers={"User-Agent": "Box-Work-CLI/1.0"} ) with urllib.request.urlopen(req_p, timeout=3.0) as r: @@ -1651,15 +1651,28 @@ class MuseTUI: self._transcript_cache_key = None self._transcript_cache_lines = [] - # Selection heads for Box mode tabs + # Selection heads and scroll states for Box mode tabs self.fleet_sel_idx = 0 + self.table_scroll_idx = 0 self.approvals_sel_idx = 0 + self.approvals_scroll_start = 0 self.jobs_sel_idx = 0 self.jobs_scroll_start = 0 self.tmux_sel_idx = 0 + self.tmux_scroll_start = 0 + self.dm_logs_sel_idx = 0 + self.dm_logs_scroll_start = 0 self.ssh_sel_idx = 0 self.ssh_scroll_idx = 0 - self.table_scroll_idx = 0 + + # Tab 7 (Work Pipeline) multi-component focus & scroll state + self.work_focus_component = "tickets" # "workers", "tickets", "prs" + self.work_sel_idx = 0 # Workers section selection index + self.work_workers_scroll_start = 0 + self.work_tickets_sel_idx = 0 + self.work_tickets_scroll_start = 0 + self.work_prs_sel_idx = 0 + self.work_prs_scroll_start = 0 # Input buffer self.input_buf = "" @@ -2975,7 +2988,9 @@ class MuseTUI: self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim")) def _render_work_view(self, y: int, x: int, h: int, w: int): - self.safe_addstr(self.stdscr, y, x + 1, "BOX WORK: AUTOMATED BUILD & FLEET PIPELINE (https://tea.muse-dev.online)", self._attr("bold")) + focus_comp = getattr(self, "work_focus_component", "tickets") + comp_tag = f" [FOCUS: {focus_comp.upper()} — Tab: Cycle Section]" + self.safe_addstr(self.stdscr, y, x + 1, f"BOX WORK: AUTOMATED BUILD & FLEET PIPELINE{comp_tag}"[:w - 2], self._attr("bold")) self.safe_addstr(self.stdscr, y + 1, x + 1, "─" * (w - 2), self._attr("dim")) with self.data.lock: @@ -2983,11 +2998,6 @@ class MuseTUI: issues = list(self.data.work_issues_cache) prs = list(self.data.work_prs_cache) - # 1. Worker Scope & Cognitive Consensus - self.safe_addstr(self.stdscr, y + 2, x + 1, "--- WORKER SCOPE & COGNITIVE CONSENSUS ---", self._attr("header")) - tbl_hdr = " AGENT ROLE PORT TUNNEL COGNITIVE LOCK ACTIVE ASSIGNMENT" - self.safe_addstr(self.stdscr, y + 3, x + 1, tbl_hdr, self._attr("dim")) - roles = { "opm": "fleet-agent", "646": "fleet-agent", @@ -3006,18 +3016,64 @@ class MuseTUI: "muse": 2225, "muse-main": 2224, } - all_workers = ["opm", "646", "dev", "pip", "def", "muse", "muse-main"] - if getattr(self, "work_sel_idx", 0) >= len(all_workers): - self.work_sel_idx = 0 + total_workers = len(all_workers) + total_tickets = len(issues) + total_prs = len(prs) - row_y = y + 4 - for idx, node in enumerate(all_workers): - is_sel = (idx == getattr(self, "work_sel_idx", 0)) - head = "▶ " if is_sel else " " + # Budget calculation across 3 components + avail_rows = max(6, h - 12) + if avail_rows >= 16: + alloc_workers = min(7, total_workers) + alloc_prs = min(5, max(2, total_prs)) if total_prs > 0 else 1 + alloc_tickets = max(3, avail_rows - alloc_workers - alloc_prs) + else: + if focus_comp == "tickets": + alloc_workers = 3 + alloc_prs = 2 if total_prs > 0 else 1 + alloc_tickets = max(2, avail_rows - alloc_workers - alloc_prs) + elif focus_comp == "prs": + alloc_workers = 3 + alloc_tickets = 3 if total_tickets > 0 else 1 + alloc_prs = max(2, avail_rows - alloc_workers - alloc_tickets) + else: # workers + alloc_tickets = 3 if total_tickets > 0 else 1 + alloc_prs = 2 if total_prs > 0 else 1 + alloc_workers = max(2, avail_rows - alloc_tickets - alloc_prs) + + # ------------------------------------------------------------------- + # 1. Worker Scope & Cognitive Consensus + # ------------------------------------------------------------------- + row_y = y + 2 + w_sel = max(0, min(getattr(self, "work_sel_idx", 0), total_workers - 1)) + self.work_sel_idx = w_sel + scroll_w = getattr(self, "work_workers_scroll_start", 0) + if w_sel < scroll_w: + scroll_w = w_sel + elif w_sel >= scroll_w + alloc_workers: + scroll_w = w_sel - alloc_workers + 1 + scroll_w = max(0, min(scroll_w, max(0, total_workers - alloc_workers))) + self.work_workers_scroll_start = scroll_w + + count_w = f" [{scroll_w + 1}-{min(total_workers, scroll_w + alloc_workers)} of {total_workers}]" if total_workers > alloc_workers else "" + badge_w = " [FOCUSED - Tab: Next]" if focus_comp == "workers" else "" + self.safe_addstr(self.stdscr, row_y, x + 1, f"--- WORKER SCOPE & COGNITIVE CONSENSUS{count_w} ---{badge_w}"[:w - 2], self._attr("bold") if focus_comp == "workers" else self._attr("header")) + row_y += 1 + + tbl_hdr = " AGENT ROLE PORT TUNNEL COGNITIVE LOCK ACTIVE ASSIGNMENT" + self.safe_addstr(self.stdscr, row_y, x + 1, tbl_hdr[:w - 2], self._attr("dim")) + row_y += 1 + + self._work_workers_bounds = (row_y, row_y + alloc_workers) + for row_i in range(alloc_workers): + idx = scroll_w + row_i + if idx >= total_workers: + break + node = all_workers[idx] + is_sel = (idx == w_sel) + head = "▶ " if (is_sel and focus_comp == "workers") else ("▷ " if is_sel else " ") role = roles.get(node, "fleet-agent") port = str(ports.get(node, 2224)) - tun = "UP" if node in ("opm", "646", "dev", "muse-main", "muse") else "DARK" tun_attr = self._attr("success") if tun == "UP" else self._attr("dim") @@ -3038,31 +3094,60 @@ class MuseTUI: break work_txt = assigned or ("Ready for assignment" if not c_locked else "Busy in thread") - row_attr = self._attr("selected") if is_sel else self._attr("normal") - self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{node:<10}"[:10], row_attr if is_sel else self._attr("bold")) - self.safe_addstr(self.stdscr, row_y, x + 13, f"{role:<12}", row_attr if is_sel else self._attr("dim")) - self.safe_addstr(self.stdscr, row_y, x + 26, f"{port:<6}", row_attr if is_sel else self._attr("dim")) - self.safe_addstr(self.stdscr, row_y, x + 33, f"{tun:<7}", row_attr if is_sel else tun_attr) - self.safe_addstr(self.stdscr, row_y, x + 41, f"{c_st:<15}", row_attr if is_sel else (self._attr("warn_banner") if c_st == "THINKING" else self._attr("normal"))) - self.safe_addstr(self.stdscr, row_y, x + 57, f"{lock_str:<8}", row_attr if is_sel else lock_attr) - self.safe_addstr(self.stdscr, row_y, x + 66, f"{work_txt[:max(0, w - 68)]}", row_attr if is_sel else self._attr("normal")) + row_attr = self._attr("selected") if (is_sel and focus_comp == "workers") else self._attr("normal") + self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{node:<10}"[:10], row_attr if (is_sel and focus_comp == "workers") else self._attr("bold")) + self.safe_addstr(self.stdscr, row_y, x + 13, f"{role:<12}", row_attr if (is_sel and focus_comp == "workers") else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 26, f"{port:<6}", row_attr if (is_sel and focus_comp == "workers") else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 33, f"{tun:<7}", row_attr if (is_sel and focus_comp == "workers") else tun_attr) + self.safe_addstr(self.stdscr, row_y, x + 41, f"{c_st:<15}", row_attr if (is_sel and focus_comp == "workers") else (self._attr("warn_banner") if c_st == "THINKING" else self._attr("normal"))) + self.safe_addstr(self.stdscr, row_y, x + 57, f"{lock_str:<8}", row_attr if (is_sel and focus_comp == "workers") else lock_attr) + self.safe_addstr(self.stdscr, row_y, x + 66, f"{work_txt[:max(0, w - 68)]}", row_attr if (is_sel and focus_comp == "workers") else self._attr("normal")) row_y += 1 + if total_workers > alloc_workers: + track_h = alloc_workers + thumb_pos = int((scroll_w / max(1, total_workers - alloc_workers)) * (track_h - 1)) + for r in range(track_h): + char = "█" if r == thumb_pos else "│" + attr = self._attr("selected") if r == thumb_pos else self._attr("dim") + self.safe_addstr(self.stdscr, self._work_workers_bounds[0] + r, w - 1, char, attr) + + # ------------------------------------------------------------------- # 2. Gitea Tickets & Build Tasks - row_y += 1 - self.safe_addstr(self.stdscr, row_y, x + 1, "--- GITEA TICKETS & BUILD TASKS (super/box) ---", self._attr("header")) - row_y += 1 - t_hdr = " TICKET STATE ASSIGNEE TITLE LABELS" - self.safe_addstr(self.stdscr, row_y, x + 1, t_hdr, self._attr("dim")) + # ------------------------------------------------------------------- + t_sel = max(0, min(getattr(self, "work_tickets_sel_idx", 0), max(0, total_tickets - 1))) + self.work_tickets_sel_idx = t_sel + scroll_t = getattr(self, "work_tickets_scroll_start", 0) + if t_sel < scroll_t: + scroll_t = t_sel + elif t_sel >= scroll_t + alloc_tickets: + scroll_t = t_sel - alloc_tickets + 1 + scroll_t = max(0, min(scroll_t, max(0, total_tickets - alloc_tickets))) + self.work_tickets_scroll_start = scroll_t + + count_t = f" [{scroll_t + 1}-{min(total_tickets, scroll_t + alloc_tickets)} of {total_tickets}]" if total_tickets > 0 else " [0]" + badge_t = " [FOCUSED - Tab: Next]" if focus_comp == "tickets" else "" + self.safe_addstr(self.stdscr, row_y, x + 1, f"--- GITEA TICKETS & BUILD TASKS (super/box){count_t} ---{badge_t}"[:w - 2], self._attr("bold") if focus_comp == "tickets" else self._attr("header")) row_y += 1 + t_hdr = " TICKET STATE ASSIGNEE TITLE LABELS" + self.safe_addstr(self.stdscr, row_y, x + 1, t_hdr[:w - 2], self._attr("dim")) + row_y += 1 + + self._work_tickets_bounds = (row_y, row_y + alloc_tickets) if not issues: self.safe_addstr(self.stdscr, row_y, x + 3, "No active tickets found.", self._attr("dim")) row_y += 1 else: - for iss in issues[:6]: + for row_i in range(alloc_tickets): + idx = scroll_t + row_i + if idx >= total_tickets: + break + iss = issues[idx] if not isinstance(iss, dict): continue + is_sel = (idx == t_sel) + head = "▶ " if (is_sel and focus_comp == "tickets") else ("▷ " if is_sel else " ") num = f"#{iss.get('number') or '?'}" st = (iss.get("state") or "").upper() asg = (iss.get("assignee") or {}).get("username") or "-" @@ -3070,49 +3155,95 @@ class MuseTUI: lbls = ", ".join(((l or {}).get("name") or "") for l in (iss.get("labels") or [])) st_attr = self._attr("dim") if st == "CLOSED" else self._attr("success") - self.safe_addstr(self.stdscr, row_y, x + 3, f"{num:<8}", self._attr("bold")) - self.safe_addstr(self.stdscr, row_y, x + 12, f"{st:<10}", st_attr) - self.safe_addstr(self.stdscr, row_y, x + 23, f"{asg:<12}", self._attr("normal")) - self.safe_addstr(self.stdscr, row_y, x + 36, f"{tit:<46}", self._attr("normal")) - self.safe_addstr(self.stdscr, row_y, x + 83, f"{lbls[:max(0, w - 85)]}", self._attr("dim")) + row_attr = self._attr("selected") if (is_sel and focus_comp == "tickets") else self._attr("normal") + + self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{num:<8}", self._attr("bold") if (is_sel and focus_comp == "tickets") else self._attr("bold")) + self.safe_addstr(self.stdscr, row_y, x + 12, f"{st:<10}", row_attr if (is_sel and focus_comp == "tickets") else st_attr) + self.safe_addstr(self.stdscr, row_y, x + 23, f"{asg:<12}", row_attr if (is_sel and focus_comp == "tickets") else self._attr("normal")) + self.safe_addstr(self.stdscr, row_y, x + 36, f"{tit:<46}", row_attr if (is_sel and focus_comp == "tickets") else self._attr("normal")) + self.safe_addstr(self.stdscr, row_y, x + 83, f"{lbls[:max(0, w - 85)]}", row_attr if (is_sel and focus_comp == "tickets") else self._attr("dim")) row_y += 1 - # 3. Pull Requests - row_y += 1 - self.safe_addstr(self.stdscr, row_y, x + 1, "--- PULL REQUESTS & CODE INTEGRATIONS ---", self._attr("header")) - row_y += 1 - pr_hdr = " PR STATUS BRANCH TITLE" - self.safe_addstr(self.stdscr, row_y, x + 1, pr_hdr, self._attr("dim")) + if total_tickets > alloc_tickets: + track_h = alloc_tickets + thumb_pos = int((scroll_t / max(1, total_tickets - alloc_tickets)) * (track_h - 1)) + for r in range(track_h): + char = "█" if r == thumb_pos else "│" + attr = self._attr("selected") if r == thumb_pos else self._attr("dim") + self.safe_addstr(self.stdscr, self._work_tickets_bounds[0] + r, w - 1, char, attr) + + # ------------------------------------------------------------------- + # 3. Pull Requests & Code Integrations + # ------------------------------------------------------------------- + p_sel = max(0, min(getattr(self, "work_prs_sel_idx", 0), max(0, total_prs - 1))) + self.work_prs_sel_idx = p_sel + scroll_p = getattr(self, "work_prs_scroll_start", 0) + if p_sel < scroll_p: + scroll_p = p_sel + elif p_sel >= scroll_p + alloc_prs: + scroll_p = p_sel - alloc_prs + 1 + scroll_p = max(0, min(scroll_p, max(0, total_prs - alloc_prs))) + self.work_prs_scroll_start = scroll_p + + count_p = f" [{scroll_p + 1}-{min(total_prs, scroll_p + alloc_prs)} of {total_prs}]" if total_prs > 0 else " [0]" + badge_p = " [FOCUSED - Tab: Next]" if focus_comp == "prs" else "" + self.safe_addstr(self.stdscr, row_y, x + 1, f"--- PULL REQUESTS & CODE INTEGRATIONS{count_p} ---{badge_p}"[:w - 2], self._attr("bold") if focus_comp == "prs" else self._attr("header")) row_y += 1 + pr_hdr = " PR STATUS BRANCH TITLE" + self.safe_addstr(self.stdscr, row_y, x + 1, pr_hdr[:w - 2], self._attr("dim")) + row_y += 1 + + self._work_prs_bounds = (row_y, row_y + alloc_prs) if not prs: self.safe_addstr(self.stdscr, row_y, x + 3, "No recent pull requests.", self._attr("dim")) row_y += 1 else: - for pr in prs[:3]: + for row_i in range(alloc_prs): + idx = scroll_p + row_i + if idx >= total_prs: + break + pr = prs[idx] if not isinstance(pr, dict): continue + is_sel = (idx == p_sel) + head = "▶ " if (is_sel and focus_comp == "prs") else ("▷ " if is_sel else " ") pnum = f"#{pr.get('number') or '?'}" merged = pr.get("merged", False) p_st = "MERGED" if merged else (pr.get("state") or "").upper() p_head = ((pr.get("head") or {}).get("ref") or "-")[:32] p_tit = (pr.get("title") or "")[:42] st_attr = self._attr("success") if merged else self._attr("warn_banner") + row_attr = self._attr("selected") if (is_sel and focus_comp == "prs") else self._attr("normal") - self.safe_addstr(self.stdscr, row_y, x + 3, f"{pnum:<8}", self._attr("bold")) - self.safe_addstr(self.stdscr, row_y, x + 12, f"{p_st:<10}", st_attr) - self.safe_addstr(self.stdscr, row_y, x + 23, f"{p_head:<34}", self._attr("dim")) - self.safe_addstr(self.stdscr, row_y, x + 58, f"{p_tit[:max(0, w - 60)]}", self._attr("normal")) + self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{pnum:<8}", self._attr("bold") if (is_sel and focus_comp == "prs") else self._attr("bold")) + self.safe_addstr(self.stdscr, row_y, x + 12, f"{p_st:<10}", row_attr if (is_sel and focus_comp == "prs") else st_attr) + self.safe_addstr(self.stdscr, row_y, x + 23, f"{p_head:<34}", row_attr if (is_sel and focus_comp == "prs") else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 58, f"{p_tit[:max(0, w - 60)]}", row_attr if (is_sel and focus_comp == "prs") else self._attr("normal")) row_y += 1 - # 4. Loopback Engine & Controls - row_y += 1 - loop_info = "⚡ Loopback Daemon: box-work-loopback.timer (active every 5m) | Tiers: 15m (comment) -> 45m (DM) -> 90m (release)" - self.safe_addstr(self.stdscr, row_y, x + 1, loop_info[:w - 2], self._attr("dim")) + if total_prs > alloc_prs: + track_h = alloc_prs + thumb_pos = int((scroll_p / max(1, total_prs - alloc_prs)) * (track_h - 1)) + for r in range(track_h): + char = "█" if r == thumb_pos else "│" + attr = self._attr("selected") if r == thumb_pos else self._attr("dim") + self.safe_addstr(self.stdscr, self._work_prs_bounds[0] + r, w - 1, char, attr) - # Footer hints + # ------------------------------------------------------------------- + # 4. Loopback Engine & Controls + # ------------------------------------------------------------------- + loop_info = "⚡ Loopback Daemon: box-work-loopback.timer (active every 5m) | Tiers: 15m (comment) -> 45m (DM) -> 90m (release)" + self.safe_addstr(self.stdscr, y + h - 2, x + 1, loop_info[:w - 2], self._attr("dim")) + + # Dynamic footer hints based on focused component hint_y = y + h - 1 - hints = "[W]: Dispatch [a]: Assign Ticket [m]: Merge PR [F]: Refocus [T]: Thoughts [H]: Heal [L]: Loopback [r]: Refresh" + if focus_comp == "tickets": + hints = "[Tab/h/l]: Switch Section [j/k]: Scroll Tickets [Enter]: Open Chat [a]: Assign [w]: Dispatch [m]: Merge PR [r]: Refresh" + elif focus_comp == "prs": + hints = "[Tab/h/l]: Switch Section [j/k]: Scroll PRs [Enter/m]: Merge PR [w]: Dispatch [a]: Assign [r]: Refresh" + else: + hints = "[Tab/h/l]: Switch Section [j/k]: Scroll Workers [w]: Dispatch [f]: Refocus [t]: Thoughts [h]: Heal [m]: Merge PR" self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("header")) def _render_approvals_view(self, y: int, x: int, h: int, w: int): @@ -3127,15 +3258,27 @@ class MuseTUI: self.safe_addstr(self.stdscr, y + 3, x + 2, "✔ All nodes CLEAR. No pending approvals or input waits.", self._attr("success")) return - if self.approvals_sel_idx >= len(apps): - self.approvals_sel_idx = max(0, len(apps) - 1) + total_apps = len(apps) + if self.approvals_sel_idx >= total_apps: + self.approvals_sel_idx = max(0, total_apps - 1) + + visible_rows = max(2, h - 5) + scroll_start = getattr(self, "approvals_scroll_start", 0) + if self.approvals_sel_idx < scroll_start: + scroll_start = self.approvals_sel_idx + elif self.approvals_sel_idx >= scroll_start + visible_rows: + scroll_start = self.approvals_sel_idx - visible_rows + 1 + scroll_start = max(0, min(scroll_start, max(0, total_apps - visible_rows))) + self.approvals_scroll_start = scroll_start row_y = y + 3 - for idx, it in enumerate(apps): + for row_i in range(visible_rows): + idx = scroll_start + row_i + if idx >= total_apps: + break + it = apps[idx] if not isinstance(it, dict): continue - if row_y >= y + h - 2: - break n = it.get("node") or "-" st = it.get("status") or "CLEAR" is_sel = (idx == self.approvals_sel_idx) @@ -3166,9 +3309,17 @@ class MuseTUI: row_y += 1 + if total_apps > visible_rows: + track_h = visible_rows + thumb_pos = int((scroll_start / max(1, total_apps - visible_rows)) * (track_h - 1)) + for r in range(track_h): + char = "█" if r == thumb_pos else "│" + attr = self._attr("selected") if r == thumb_pos else self._attr("dim") + self.safe_addstr(self.stdscr, y + 3 + r, w - 1, char, attr) + hint_y = y + h - 1 sel_node = (apps[self.approvals_sel_idx].get("node") or "").upper() if apps else "-" - hints = f"Selected: [{sel_node}] [j/k]: Head [1/2/3]: Allow/Always/Deny [r]: Proceed [x]: Dismiss [c]: Clear All [Enter]: Chat" + hints = f"Selected: [{sel_node}] [j/k]: Navigate ({scroll_start + 1}-{min(total_apps, scroll_start + visible_rows)}/{total_apps}) [1/2/3]: Allow/Always/Deny [r]: Proceed [x]: Dismiss [c]: Clear All [Enter]: Chat" self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim")) def _render_jobs_view(self, y: int, x: int, h: int, w: int): @@ -3254,11 +3405,29 @@ class MuseTUI: self.safe_addstr(self.stdscr, y + 3, x + 2, "No active background tmux sessions found.", self._attr("dim")) return - if self.tmux_sel_idx >= len(sessions): - self.tmux_sel_idx = max(0, len(sessions) - 1) + total_sessions = len(sessions) + if self.tmux_sel_idx >= total_sessions: + self.tmux_sel_idx = max(0, total_sessions - 1) + visible_rows = max(1, h - 5) + scroll_start = getattr(self, "tmux_scroll_start", 0) + if self.tmux_sel_idx < scroll_start: + scroll_start = self.tmux_sel_idx + elif self.tmux_sel_idx >= scroll_start + visible_rows: + scroll_start = self.tmux_sel_idx - visible_rows + 1 + scroll_start = max(0, min(scroll_start, max(0, total_sessions - visible_rows))) + self.tmux_scroll_start = scroll_start + + pos_info = f" [{self.tmux_sel_idx + 1}/{total_sessions}] " + self.safe_addstr(self.stdscr, y, w - len(pos_info) - 2, pos_info, self._attr("selected")) + + self._tmux_view_bounds = (y + 3, y + 3 + min(total_sessions, visible_rows)) row_y = y + 3 - for idx, s in enumerate(sessions[:max(0, h - 5)]): + for r_i in range(visible_rows): + idx = scroll_start + r_i + if idx >= total_sessions: + break + s = sessions[idx] is_sel = (idx == self.tmux_sel_idx) head = "▶ " if is_sel else " " row_attr = self._attr("selected") if is_sel else self._attr("normal") @@ -3268,9 +3437,17 @@ class MuseTUI: self.safe_addstr(self.stdscr, row_y, max(x + 40, w - 10), "[Kill]", self._attr("danger") if is_sel else self._attr("dim")) row_y += 1 + if total_sessions > visible_rows: + track_h = visible_rows + thumb_pos = int((scroll_start / max(1, total_sessions - visible_rows)) * (track_h - 1)) + for r in range(track_h): + char = "█" if r == thumb_pos else "│" + attr = self._attr("selected") if r == thumb_pos else self._attr("dim") + self.safe_addstr(self.stdscr, y + 3 + r, w - 1, char, attr) + hint_y = y + h - 1 sel_sess = sessions[self.tmux_sel_idx].split(":")[0].strip() if sessions else "-" - hints = f"Selected: [{sel_sess}] [j/k]: Head [Enter / a]: Attach Instructions [x / d]: Kill Session [r]: Refresh" + hints = f"Selected: [{sel_sess}] [j/k]: Scroll ({scroll_start + 1}-{min(total_sessions, scroll_start + visible_rows)}/{total_sessions}) [Enter / a]: Attach Instructions [x / d]: Kill Session [r]: Refresh" self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim")) def _render_dm_logs_view(self, y: int, x: int, h: int, w: int): @@ -3280,10 +3457,38 @@ class MuseTUI: with self.data.lock: logs = list(self.data.dm_logs_cache) + if not logs: + self.safe_addstr(self.stdscr, y + 2, x + 2, "No DM logs recorded.", self._attr("dim")) + return + + total_logs = len(logs) + if getattr(self, "dm_logs_sel_idx", 0) >= total_logs: + self.dm_logs_sel_idx = max(0, total_logs - 1) + + visible_rows = max(1, h - 4) + scroll_start = getattr(self, "dm_logs_scroll_start", 0) + sel_idx = getattr(self, "dm_logs_sel_idx", 0) + if sel_idx < scroll_start: + scroll_start = sel_idx + elif sel_idx >= scroll_start + visible_rows: + scroll_start = sel_idx - visible_rows + 1 + scroll_start = max(0, min(scroll_start, max(0, total_logs - visible_rows))) + self.dm_logs_scroll_start = scroll_start + + pos_info = f" [{sel_idx + 1}/{total_logs}] " + self.safe_addstr(self.stdscr, y, w - len(pos_info) - 2, pos_info, self._attr("selected")) + + self._dm_logs_view_bounds = (y + 2, y + 2 + min(total_logs, visible_rows)) row_y = y + 2 - for it in logs[:max(0, h - 4)]: + for r_i in range(visible_rows): + idx = scroll_start + r_i + if idx >= total_logs: + break + it = logs[idx] if not isinstance(it, dict): continue + is_sel = (idx == sel_idx) + head = "▶ " if is_sel else " " l_type = it.get("type") or "" agent = it.get("agent") or "" to = it.get("to") or "" @@ -3291,11 +3496,23 @@ class MuseTUI: ts = (it.get("ts") or "")[:19].replace("T", " ") mid = it.get("id") or "" - color = self._attr("success") if l_type == "verified" else (self._attr("user") if l_type == "sent" else self._attr("dim")) - line_str = f"[{ts}] {l_type:<9} {agent} -> {to} ({target}) [{mid}]" - self.safe_addstr(self.stdscr, row_y, x + 2, line_str, color) + color = self._attr("selected") if is_sel else (self._attr("success") if l_type == "verified" else (self._attr("user") if l_type == "sent" else self._attr("dim"))) + line_str = f"{head}[{ts}] {l_type:<9} {agent} -> {to} ({target}) [{mid}]" + self.safe_addstr(self.stdscr, row_y, x + 1, line_str[:w - 3], color) row_y += 1 + if total_logs > visible_rows: + track_h = visible_rows + thumb_pos = int((scroll_start / max(1, total_logs - visible_rows)) * (track_h - 1)) + for r in range(track_h): + char = "█" if r == thumb_pos else "│" + attr = self._attr("selected") if r == thumb_pos else self._attr("dim") + self.safe_addstr(self.stdscr, y + 2 + r, w - 1, char, attr) + + hint_y = y + h - 1 + hints = f"Selected: [{sel_idx + 1}/{total_logs}] [j/k]: Scroll Log ({scroll_start + 1}-{min(total_logs, scroll_start + visible_rows)}/{total_logs}) [Home/End]: Top/Bottom [r]: Refresh" + self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim")) + def get_ssh_rows(self) -> list: """SSH view row order: fleet nodes first, then extra tunnel accounts.""" with self.data.lock: @@ -4919,18 +5136,11 @@ class MuseTUI: self.set_toast("Probing SSH tunnels via jump host...", "info") return True - # Box mode Fast Actions: Tab 1 (Fleet) and Tab 7 (Work Pipeline) - if self.mode == "box" and self.box_tab in (1, 7): + # Box mode Fast Actions: Tab 1 (Fleet) + if self.mode == "box" and self.box_tab == 1: with self.data.lock: nodes = list(self.data.nodes) - if self.box_tab == 1 and nodes and 0 <= getattr(self, "fleet_sel_idx", 0) < len(nodes): - target_node = nodes[self.fleet_sel_idx] - elif self.box_tab == 7: - all_workers = ["opm", "646", "dev", "pip", "def", "muse", "muse-main"] - s_idx = getattr(self, "work_sel_idx", 0) - target_node = all_workers[s_idx] if 0 <= s_idx < len(all_workers) else getattr(self, "dispatch_target_node", self.data.active_node) - else: - target_node = getattr(self, "dispatch_target_node", self.data.active_node) + target_node = nodes[self.fleet_sel_idx] if (nodes and 0 <= getattr(self, "fleet_sel_idx", 0) < len(nodes)) else getattr(self, "dispatch_target_node", self.data.active_node) if ch in (ord('w'), ord('W')): self.modal = "work_dispatch" @@ -4953,11 +5163,65 @@ class MuseTUI: elif ch in (ord('h'), ord('H')): self._async_heal_agent(target_node) return True - elif self.box_tab == 7 and ch in (ord('m'), ord('M')): - self._async_merge_pr() + elif ch in (ord('r'), ord('R')): + self.data.trigger_refresh() + self.set_toast("Refreshing fleet status...", "info") return True - elif self.box_tab == 7 and ch in (ord('a'), ord('A')): - self._async_assign_ticket(target_node) + + # Box mode Fast Actions: Tab 7 (Work Pipeline) + if self.mode == "box" and self.box_tab == 7: + all_workers = ["opm", "646", "dev", "pip", "def", "muse", "muse-main"] + w_idx = max(0, min(getattr(self, "work_sel_idx", 0), len(all_workers) - 1)) + sel_worker = all_workers[w_idx] + + if ch in (ord('w'), ord('W')): + self.modal = "work_dispatch" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.dispatch_target_node = sel_worker + return True + elif ch in (ord('f'), ord('F')): + self._async_refocus_main(sel_worker) + return True + elif ch in (ord('t'), ord('T')): + self._open_agent_thoughts(sel_worker) + return True + elif ch in (ord('p'), ord('P')): + self._open_agent_menu(sel_worker) + return True + elif ch in (ord('m'), ord('M')): + with self.data.lock: + prs = list(getattr(self.data, "work_prs_cache", [])) + p_idx = getattr(self, "work_prs_sel_idx", 0) + pr_num = prs[p_idx].get("number") if (prs and 0 <= p_idx < len(prs)) else None + self._async_merge_pr(pr_num) + return True + elif ch in (ord('a'), ord('A')): + with self.data.lock: + issues = list(getattr(self.data, "work_issues_cache", [])) + t_idx = getattr(self, "work_tickets_sel_idx", getattr(self, "work_sel_idx", 0)) + iss_num = issues[t_idx].get("number") if (issues and 0 <= t_idx < len(issues)) else None + if iss_num is not None: + self._async_assign_ticket(sel_worker, iss_num) + else: + self._async_assign_ticket(sel_worker) + return True + elif ch in (ord('h'), ord('H')): + if getattr(self, "work_focus_component", "tickets") == "workers": + self._async_heal_agent(sel_worker) + else: + sections = ["workers", "tickets", "prs"] + cur = getattr(self, "work_focus_component", "tickets") + cur_idx = sections.index(cur) if cur in sections else 1 + self.work_focus_component = sections[(cur_idx - 1) % len(sections)] + self.set_toast(f"Focus: {self.work_focus_component.upper()}", "info") + return True + elif ch in (ord('l'), ord('L')): + sections = ["workers", "tickets", "prs"] + cur = getattr(self, "work_focus_component", "tickets") + cur_idx = sections.index(cur) if cur in sections else 1 + self.work_focus_component = sections[(cur_idx + 1) % len(sections)] + self.set_toast(f"Focus: {self.work_focus_component.upper()}", "info") return True elif ch in (ord('r'), ord('R')): threading.Thread(target=self.data._fetch_cognitive_and_work, daemon=True).start() @@ -5145,25 +5409,45 @@ class MuseTUI: self.set_toast(f"Attach: tmux -S /tmp/tmux-muse.sock attach -t {sess_name}", "info") return True elif self.box_tab == 7: - # Work tab: open work dispatch or view assigned worker conversation - with self.data.lock: - issues = list(getattr(self.data, "work_issues_cache", [])) - if issues and 0 <= getattr(self, "work_sel_idx", 0) < len(issues): - iss = issues[self.work_sel_idx] - assignee = (iss.get("assignee") or {}).get("username") or "" - if assignee in self.data.nodes: - self.data.set_active_target(assignee) + # Work tab: open work dispatch, merge PR, or open assigned worker conversation + fcomp = getattr(self, "work_focus_component", "tickets") + if fcomp == "workers": + all_workers = ["opm", "646", "dev", "pip", "def", "muse", "muse-main"] + w_idx = max(0, min(getattr(self, "work_sel_idx", 0), len(all_workers) - 1)) + node = all_workers[w_idx] + if node in self.data.nodes: + self.data.set_active_target(node) self.box_tab = 0 - self.set_toast(f"Switched to assigned worker {assignee.upper()} for #{iss.get('number')}", "success") + self.set_toast(f"Opened Chat with {node.upper()}", "success") + return True + elif fcomp == "prs": + with self.data.lock: + prs = list(getattr(self.data, "work_prs_cache", [])) + pr_num = None + if prs and 0 <= getattr(self, "work_prs_sel_idx", 0) < len(prs): + pr_num = prs[self.work_prs_sel_idx].get("number") + self._async_merge_pr(pr_num) + return True + else: # tickets + with self.data.lock: + issues = list(getattr(self.data, "work_issues_cache", [])) + t_idx = getattr(self, "work_tickets_sel_idx", getattr(self, "work_sel_idx", 0)) + if issues and 0 <= t_idx < len(issues): + iss = issues[t_idx] + assignee = (iss.get("assignee") or {}).get("username") or "" + if assignee in self.data.nodes: + self.data.set_active_target(assignee) + self.box_tab = 0 + self.set_toast(f"Switched to assigned worker {assignee.upper()} for #{iss.get('number')}", "success") + else: + self.modal = "work_dispatch" + self.modal_input_buf = f"Continue #{iss.get('number')}: {iss.get('title', '')}" + self.modal_input_cursor = len(self.modal_input_buf) else: self.modal = "work_dispatch" - self.modal_input_buf = f"Continue #{iss.get('number')}: {iss.get('title', '')}" - self.modal_input_cursor = len(self.modal_input_buf) - else: - self.modal = "work_dispatch" - self.modal_input_buf = "" - self.modal_input_cursor = 0 - return True + self.modal_input_buf = "" + self.modal_input_cursor = 0 + return True else: return True # Toggle Pin for selected thread (sidebar) or chat sent to prompt fill (transcript) / Expand Prompts ('P') @@ -5256,6 +5540,17 @@ class MuseTUI: sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip() self.set_toast(f"Attach: tmux -S /tmp/tmux-muse.sock attach -t {sess_name}", "info") return True + elif self.mode == "box" and self.box_tab == 7: + # On Work tab, 'a' assigns the selected ticket to the selected worker node + all_workers = ["opm", "646", "dev", "pip", "def", "muse", "muse-main"] + w_idx = max(0, min(getattr(self, "work_sel_idx", 0), len(all_workers) - 1)) + sel_worker = all_workers[w_idx] + with self.data.lock: + issues = list(getattr(self.data, "work_issues_cache", [])) + t_idx = getattr(self, "work_tickets_sel_idx", getattr(self, "work_sel_idx", 0)) + iss_num = issues[t_idx].get("number") if (issues and 0 <= t_idx < len(issues)) else None + self._async_assign_ticket(sel_worker, iss_num) + return True self.modal = "approvals" return True @@ -5269,13 +5564,25 @@ class MuseTUI: # Work Order modal if ch in (ord('w'), ord('W')): self.modal = "work_dispatch" if ch == ord('w') else "work_order" - self.dispatch_target_node = self.data.active_node + if self.mode == "box" and self.box_tab == 7: + all_workers = ["opm", "646", "dev", "pip", "def", "muse", "muse-main"] + w_idx = max(0, min(getattr(self, "work_sel_idx", 0), len(all_workers) - 1)) + self.dispatch_target_node = all_workers[w_idx] + else: + self.dispatch_target_node = self.data.active_node self.modal_input_buf = "" self.modal_input_cursor = 0 return True # Mode switch (Muse vs Box) if ch in (ord('m'), ord('M'), curses.KEY_F5): + if self.mode == "box" and self.box_tab == 7 and ch == ord('m'): + with self.data.lock: + prs = list(getattr(self.data, "work_prs_cache", [])) + p_idx = getattr(self, "work_prs_sel_idx", 0) + pr_num = prs[p_idx].get("number") if (prs and 0 <= p_idx < len(prs)) else None + self._async_merge_pr(pr_num) + return True self.mode = "box" if self.mode == "muse" else "muse" self.set_toast(f"Switched mode to {self.mode.upper()}", "info") return True @@ -5375,6 +5682,7 @@ class MuseTUI: self.set_toast("Refreshed tmux background sessions.", "info") return True + # Reply to latest message: 'R' (when on transcript in normal mode) if ch == ord('R') and self.focus_pane == "transcript": with self.data.lock: @@ -5439,9 +5747,23 @@ class MuseTUI: self.box_tab = (self.box_tab + 1) % 8 return True elif ch == ord('\t'): + if self.box_tab == 7: + sections = ["workers", "tickets", "prs"] + cur = getattr(self, "work_focus_component", "tickets") + cur_idx = sections.index(cur) if cur in sections else 1 + self.work_focus_component = sections[(cur_idx + 1) % len(sections)] + self.set_toast(f"Focus: {self.work_focus_component.upper()}", "info") + return True self.box_tab = (self.box_tab + 1) % 8 return True elif ch == curses.KEY_BTAB: + if self.box_tab == 7: + sections = ["workers", "tickets", "prs"] + cur = getattr(self, "work_focus_component", "tickets") + cur_idx = sections.index(cur) if cur in sections else 1 + self.work_focus_component = sections[(cur_idx - 1) % len(sections)] + self.set_toast(f"Focus: {self.work_focus_component.upper()}", "info") + return True self.box_tab = (self.box_tab - 1) % 8 return True @@ -5529,10 +5851,18 @@ class MuseTUI: self.jobs_sel_idx = max(0, self.jobs_sel_idx - 1) elif self.box_tab == 4: self.tmux_sel_idx = max(0, self.tmux_sel_idx - 1) + elif self.box_tab == 5: + self.dm_logs_sel_idx = max(0, getattr(self, "dm_logs_sel_idx", 0) - 1) elif self.box_tab == 6: self.ssh_sel_idx = max(0, self.ssh_sel_idx - 1) elif self.box_tab == 7: - self.work_sel_idx = max(0, getattr(self, "work_sel_idx", 0) - 1) + fcomp = getattr(self, "work_focus_component", "tickets") + if fcomp == "workers": + self.work_sel_idx = max(0, getattr(self, "work_sel_idx", 0) - 1) + elif fcomp == "prs": + self.work_prs_sel_idx = max(0, getattr(self, "work_prs_sel_idx", 0) - 1) + else: + self.work_tickets_sel_idx = max(0, getattr(self, "work_tickets_sel_idx", 0) - 1) else: self.table_scroll_idx = max(0, self.table_scroll_idx - 1) return True @@ -5575,12 +5905,29 @@ class MuseTUI: sessions = list(self.data.tmux_cache) if sessions: self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 1) + elif self.box_tab == 5: + with self.data.lock: + logs = list(self.data.dm_logs_cache) + if logs: + self.dm_logs_sel_idx = min(len(logs) - 1, getattr(self, "dm_logs_sel_idx", 0) + 1) elif self.box_tab == 6: rows = self.get_ssh_rows() if rows: self.ssh_sel_idx = min(len(rows) - 1, self.ssh_sel_idx + 1) elif self.box_tab == 7: - self.work_sel_idx = min(9, getattr(self, "work_sel_idx", 0) + 1) + fcomp = getattr(self, "work_focus_component", "tickets") + if fcomp == "workers": + self.work_sel_idx = min(6, getattr(self, "work_sel_idx", 0) + 1) + elif fcomp == "prs": + with self.data.lock: + prs = list(getattr(self.data, "work_prs_cache", [])) + if prs: + self.work_prs_sel_idx = min(len(prs) - 1, getattr(self, "work_prs_sel_idx", 0) + 1) + else: + with self.data.lock: + issues = list(getattr(self.data, "work_issues_cache", [])) + if issues: + self.work_tickets_sel_idx = min(len(issues) - 1, getattr(self, "work_tickets_sel_idx", 0) + 1) else: self.table_scroll_idx += 1 return True @@ -5601,10 +5948,18 @@ class MuseTUI: self.jobs_sel_idx = max(0, self.jobs_sel_idx - 5) elif self.box_tab == 4: self.tmux_sel_idx = max(0, self.tmux_sel_idx - 5) + elif self.box_tab == 5: + self.dm_logs_sel_idx = max(0, getattr(self, "dm_logs_sel_idx", 0) - 5) elif self.box_tab == 6: self.ssh_sel_idx = max(0, self.ssh_sel_idx - 5) elif self.box_tab == 7: - self.work_sel_idx = max(0, getattr(self, "work_sel_idx", 0) - 5) + fcomp = getattr(self, "work_focus_component", "tickets") + if fcomp == "workers": + self.work_sel_idx = max(0, getattr(self, "work_sel_idx", 0) - 5) + elif fcomp == "prs": + self.work_prs_sel_idx = max(0, getattr(self, "work_prs_sel_idx", 0) - 5) + else: + self.work_tickets_sel_idx = max(0, getattr(self, "work_tickets_sel_idx", 0) - 5) else: self.table_scroll_idx = max(0, self.table_scroll_idx - 5) return True @@ -5633,12 +5988,29 @@ class MuseTUI: sessions = list(self.data.tmux_cache) if sessions: self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 5) + elif self.box_tab == 5: + with self.data.lock: + logs = list(self.data.dm_logs_cache) + if logs: + self.dm_logs_sel_idx = min(len(logs) - 1, getattr(self, "dm_logs_sel_idx", 0) + 5) elif self.box_tab == 6: rows = self.get_ssh_rows() if rows: self.ssh_sel_idx = min(len(rows) - 1, self.ssh_sel_idx + 5) elif self.box_tab == 7: - self.work_sel_idx = min(9, getattr(self, "work_sel_idx", 0) + 5) + fcomp = getattr(self, "work_focus_component", "tickets") + if fcomp == "workers": + self.work_sel_idx = min(6, getattr(self, "work_sel_idx", 0) + 5) + elif fcomp == "prs": + with self.data.lock: + prs = list(getattr(self.data, "work_prs_cache", [])) + if prs: + self.work_prs_sel_idx = min(len(prs) - 1, getattr(self, "work_prs_sel_idx", 0) + 5) + else: + with self.data.lock: + issues = list(getattr(self.data, "work_issues_cache", [])) + if issues: + self.work_tickets_sel_idx = min(len(issues) - 1, getattr(self, "work_tickets_sel_idx", 0) + 5) else: self.table_scroll_idx += 5 return True @@ -5664,10 +6036,18 @@ class MuseTUI: self.jobs_sel_idx = 0 elif self.box_tab == 4: self.tmux_sel_idx = 0 + elif self.box_tab == 5: + self.dm_logs_sel_idx = 0 elif self.box_tab == 6: self.ssh_sel_idx = 0 elif self.box_tab == 7: - self.work_sel_idx = 0 + fcomp = getattr(self, "work_focus_component", "tickets") + if fcomp == "workers": + self.work_sel_idx = 0 + elif fcomp == "prs": + self.work_prs_sel_idx = 0 + else: + self.work_tickets_sel_idx = 0 else: self.table_scroll_idx = 0 return True @@ -5707,12 +6087,29 @@ class MuseTUI: sessions = list(self.data.tmux_cache) if sessions: self.tmux_sel_idx = max(0, len(sessions) - 1) + elif self.box_tab == 5: + with self.data.lock: + logs = list(self.data.dm_logs_cache) + if logs: + self.dm_logs_sel_idx = max(0, len(logs) - 1) elif self.box_tab == 6: rows = self.get_ssh_rows() if rows: self.ssh_sel_idx = max(0, len(rows) - 1) elif self.box_tab == 7: - self.work_sel_idx = 9 + fcomp = getattr(self, "work_focus_component", "tickets") + if fcomp == "workers": + self.work_sel_idx = 6 + elif fcomp == "prs": + with self.data.lock: + prs = list(getattr(self.data, "work_prs_cache", [])) + if prs: + self.work_prs_sel_idx = max(0, len(prs) - 1) + else: + with self.data.lock: + issues = list(getattr(self.data, "work_issues_cache", [])) + if issues: + self.work_tickets_sel_idx = max(0, len(issues) - 1) else: self.table_scroll_idx = max(0, len(self.data.nodes) - 5) return True @@ -5808,10 +6205,28 @@ class MuseTUI: self.jobs_sel_idx = max(0, self.jobs_sel_idx - 1) elif self.box_tab == 4: self.tmux_sel_idx = max(0, self.tmux_sel_idx - 1) + elif self.box_tab == 5: + self.dm_logs_sel_idx = max(0, getattr(self, "dm_logs_sel_idx", 0) - 1) elif self.box_tab == 6: self.ssh_sel_idx = max(0, self.ssh_sel_idx - 1) elif self.box_tab == 7: - self.work_sel_idx = max(0, getattr(self, "work_sel_idx", 0) - 1) + w_b = getattr(self, "_work_workers_bounds", (0, 0)) + t_b = getattr(self, "_work_tickets_bounds", (0, 0)) + p_b = getattr(self, "_work_prs_bounds", (0, 0)) + if w_b[0] <= my < w_b[1]: + self.work_focus_component = "workers" + elif t_b[0] <= my < t_b[1]: + self.work_focus_component = "tickets" + elif p_b[0] <= my < p_b[1]: + self.work_focus_component = "prs" + + fcomp = getattr(self, "work_focus_component", "tickets") + if fcomp == "workers": + self.work_sel_idx = max(0, getattr(self, "work_sel_idx", 0) - 1) + elif fcomp == "prs": + self.work_prs_sel_idx = max(0, getattr(self, "work_prs_sel_idx", 0) - 1) + else: + self.work_tickets_sel_idx = max(0, getattr(self, "work_tickets_sel_idx", 0) - 1) else: self.table_scroll_idx = max(0, self.table_scroll_idx - 1) elif mx >= sidebar_w: @@ -5861,12 +6276,39 @@ class MuseTUI: sessions = list(self.data.tmux_cache) if sessions: self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 1) + elif self.box_tab == 5: + with self.data.lock: + logs = list(self.data.dm_logs_cache) + if logs: + self.dm_logs_sel_idx = min(len(logs) - 1, getattr(self, "dm_logs_sel_idx", 0) + 1) elif self.box_tab == 6: rows = self.get_ssh_rows() if rows: self.ssh_sel_idx = min(len(rows) - 1, self.ssh_sel_idx + 1) elif self.box_tab == 7: - self.work_sel_idx = min(9, getattr(self, "work_sel_idx", 0) + 1) + w_b = getattr(self, "_work_workers_bounds", (0, 0)) + t_b = getattr(self, "_work_tickets_bounds", (0, 0)) + p_b = getattr(self, "_work_prs_bounds", (0, 0)) + if w_b[0] <= my < w_b[1]: + self.work_focus_component = "workers" + elif t_b[0] <= my < t_b[1]: + self.work_focus_component = "tickets" + elif p_b[0] <= my < p_b[1]: + self.work_focus_component = "prs" + + fcomp = getattr(self, "work_focus_component", "tickets") + if fcomp == "workers": + self.work_sel_idx = min(6, getattr(self, "work_sel_idx", 0) + 1) + elif fcomp == "prs": + with self.data.lock: + prs = list(getattr(self.data, "work_prs_cache", [])) + if prs: + self.work_prs_sel_idx = min(len(prs) - 1, getattr(self, "work_prs_sel_idx", 0) + 1) + else: + with self.data.lock: + issues = list(getattr(self.data, "work_issues_cache", [])) + if issues: + self.work_tickets_sel_idx = min(len(issues) - 1, getattr(self, "work_tickets_sel_idx", 0) + 1) else: self.table_scroll_idx += 1 elif mx >= sidebar_w: @@ -6212,6 +6654,144 @@ class MuseTUI: self.set_toast(f"Opened Chat with {new_node.upper()}", "success") return True + # Box Approvals click (tab 2) + if self.mode == "box" and self.box_tab == 2: + if content_y + 3 <= my < content_y + content_h: + row_offset = my - (content_y + 3) + with self.data.lock: + apps = list(self.data.approvals_cache) + clicked_idx = getattr(self, "approvals_scroll_start", 0) + row_offset + if 0 <= clicked_idx < len(apps): + self.approvals_sel_idx = clicked_idx + cur_node = apps[clicked_idx].get("node") or self.data.active_node + if mx >= w - 40: + st = apps[clicked_idx].get("status", "PENDING") + if st == "PENDING": + if mx < w - 28: + threading.Thread(target=self._async_resolve_approval, args=("allow", cur_node), daemon=True).start() + elif mx < w - 16: + threading.Thread(target=self._async_resolve_approval, args=("always", cur_node), daemon=True).start() + else: + threading.Thread(target=self._async_resolve_approval, args=("deny", cur_node), daemon=True).start() + elif st == "INPUT_WAIT": + if mx < w - 24: + threading.Thread(target=self._async_resolve_approval, args=("reply", cur_node, "Proceed with standard execution."), daemon=True).start() + elif mx < w - 12: + threading.Thread(target=self._async_resolve_approval, args=("dismiss", cur_node), daemon=True).start() + else: + self.data.set_active_target(cur_node) + self.box_tab = 0 + return True + + # Box Jobs click (tab 3) + if self.mode == "box" and self.box_tab == 3: + if content_y + 3 <= my < content_y + content_h: + row_offset = my - (content_y + 3) + with self.data.lock: + jobs = list(self.data.jobs_cache) + clicked_idx = getattr(self, "jobs_scroll_start", 0) + row_offset + if 0 <= clicked_idx < len(jobs): + self.jobs_sel_idx = clicked_idx + job = jobs[clicked_idx] + if mx >= w - 12: + jname = job.get("name") + threading.Thread(target=self._async_trigger_job, args=(jname,), daemon=True).start() + elif mx >= w - 23: + jname = job.get("name") + agent = job.get("agent", self.data.active_node) + self._open_job_conversation(jname, agent) + return True + + # Box Tmux click (tab 4) + if self.mode == "box" and self.box_tab == 4: + if content_y + 3 <= my < content_y + content_h: + row_offset = my - (content_y + 3) + with self.data.lock: + sessions = list(self.data.tmux_cache) + clicked_idx = getattr(self, "tmux_scroll_start", 0) + row_offset + if 0 <= clicked_idx < len(sessions): + self.tmux_sel_idx = clicked_idx + s_name = sessions[clicked_idx].split(":")[0].strip() + if mx >= w - 12: + threading.Thread(target=self._async_kill_tmux, args=(s_name,), daemon=True).start() + else: + self.set_toast(f"Attach: tmux -S /tmp/tmux-muse.sock attach -t {s_name}", "info") + return True + + # Box DM Logs click (tab 5) + if self.mode == "box" and self.box_tab == 5: + if content_y + 2 <= my < content_y + content_h: + row_offset = my - (content_y + 2) + with self.data.lock: + logs = list(self.data.dm_logs_cache) + clicked_idx = getattr(self, "dm_logs_scroll_start", 0) + row_offset + if 0 <= clicked_idx < len(logs): + self.dm_logs_sel_idx = clicked_idx + return True + + # Box Work Pipeline click (tab 7) + if self.mode == "box" and self.box_tab == 7: + w_b = getattr(self, "_work_workers_bounds", (0, 0)) + t_b = getattr(self, "_work_tickets_bounds", (0, 0)) + p_b = getattr(self, "_work_prs_bounds", (0, 0)) + all_workers = ["opm", "646", "dev", "pip", "def", "muse", "muse-main"] + + # Click on Workers Section Header or Rows + if my == content_y + 2: + self.work_focus_component = "workers" + return True + elif w_b[0] <= my < w_b[1]: + self.work_focus_component = "workers" + row_offset = my - w_b[0] + clicked_idx = getattr(self, "work_workers_scroll_start", 0) + row_offset + if 0 <= clicked_idx < len(all_workers): + self.work_sel_idx = clicked_idx + if is_double_click: + node = all_workers[clicked_idx] + if node in self.data.nodes: + self.data.set_active_target(node) + self.box_tab = 0 + self.set_toast(f"Opened Chat with {node.upper()}", "success") + return True + + # Click on Tickets Section Header or Rows + elif my == t_b[0] - 2: + self.work_focus_component = "tickets" + return True + elif t_b[0] <= my < t_b[1]: + self.work_focus_component = "tickets" + row_offset = my - t_b[0] + with self.data.lock: + issues = list(getattr(self.data, "work_issues_cache", [])) + clicked_idx = getattr(self, "work_tickets_scroll_start", 0) + row_offset + if 0 <= clicked_idx < len(issues): + self.work_tickets_sel_idx = clicked_idx + if is_double_click: + iss = issues[clicked_idx] + assignee = (iss.get("assignee") or {}).get("username") or "" + if assignee in self.data.nodes: + self.data.set_active_target(assignee) + self.box_tab = 0 + self.set_toast(f"Switched to assigned worker {assignee.upper()} for #{iss.get('number')}", "success") + return True + + # Click on PRs Section Header or Rows + elif my == p_b[0] - 2: + self.work_focus_component = "prs" + return True + elif p_b[0] <= my < p_b[1]: + self.work_focus_component = "prs" + row_offset = my - p_b[0] + with self.data.lock: + prs = list(getattr(self.data, "work_prs_cache", [])) + clicked_idx = getattr(self, "work_prs_scroll_start", 0) + row_offset + if 0 <= clicked_idx < len(prs): + self.work_prs_sel_idx = clicked_idx + if is_double_click: + pr = prs[clicked_idx] + self._async_merge_pr(pr.get("number")) + return True + # Muse View: Left Sidebar vs Right Transcript if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): if mx < sidebar_w: diff --git a/tests/test_box_tui_render.py b/tests/test_box_tui_render.py index 89af607..382f05d 100644 --- a/tests/test_box_tui_render.py +++ b/tests/test_box_tui_render.py @@ -433,6 +433,154 @@ class TestBoxTUIHeadless(unittest.TestCase): fleet_app._render_footer(h, w) fleet_app._render_help(h, w) + def test_work_pipeline_multi_component_focus_cycling_and_navigation(self): + app = muse_tui.MuseTUI(self.mock_stdscr, initial_mode="box", initial_tab="work") + app.box_tab = 7 + + # 1. Section cycling with Tab, Shift-Tab, l, h + self.assertEqual(app.work_focus_component, "tickets") + app._handle_key(ord('\t')) + self.assertEqual(app.work_focus_component, "prs") + app._handle_key(ord('\t')) + self.assertEqual(app.work_focus_component, "workers") + app._handle_key(ord('\t')) + self.assertEqual(app.work_focus_component, "tickets") + + app._handle_key(curses.KEY_BTAB) + self.assertEqual(app.work_focus_component, "workers") + app._handle_key(curses.KEY_BTAB) + self.assertEqual(app.work_focus_component, "prs") + + app._handle_key(ord('l')) + self.assertEqual(app.work_focus_component, "workers") + app._handle_key(ord('h')) + # If on workers, 'h' heals unless not on workers or cycled + app.work_focus_component = "prs" + app._handle_key(ord('h')) + self.assertEqual(app.work_focus_component, "tickets") + + # 2. Navigation with j/k, PageUp/Down, Home/End + app.data.work_issues_cache = [{"number": i, "title": f"Issue {i}", "state": "open"} for i in range(10)] + app.data.work_prs_cache = [{"number": i, "title": f"PR {i}", "state": "open", "merged": False} for i in range(8)] + + # On tickets + app.work_focus_component = "tickets" + app.work_tickets_sel_idx = 0 + app._handle_key(ord('j')) + self.assertEqual(app.work_tickets_sel_idx, 1) + app._handle_key(curses.KEY_DOWN) + self.assertEqual(app.work_tickets_sel_idx, 2) + app._handle_key(ord('k')) + self.assertEqual(app.work_tickets_sel_idx, 1) + app._handle_key(curses.KEY_UP) + self.assertEqual(app.work_tickets_sel_idx, 0) + app._handle_key(curses.KEY_NPAGE) # PageDown (+5) + self.assertEqual(app.work_tickets_sel_idx, 5) + app._handle_key(curses.KEY_PPAGE) # PageUp (-5) + self.assertEqual(app.work_tickets_sel_idx, 0) + app._handle_key(ord('G')) # End + self.assertEqual(app.work_tickets_sel_idx, 9) + app._handle_key(ord('g')) # Home + self.assertEqual(app.work_tickets_sel_idx, 0) + + # On PRs + app.work_focus_component = "prs" + app.work_prs_sel_idx = 0 + app._handle_key(ord('j')) + self.assertEqual(app.work_prs_sel_idx, 1) + app._handle_key(ord('k')) + self.assertEqual(app.work_prs_sel_idx, 0) + app._handle_key(ord('G')) + self.assertEqual(app.work_prs_sel_idx, 7) + app._handle_key(ord('g')) + self.assertEqual(app.work_prs_sel_idx, 0) + + # On workers + app.work_focus_component = "workers" + app.work_sel_idx = 0 + app._handle_key(ord('j')) + self.assertEqual(app.work_sel_idx, 1) + app._handle_key(ord('k')) + self.assertEqual(app.work_sel_idx, 0) + app._handle_key(ord('G')) + self.assertEqual(app.work_sel_idx, 6) + app._handle_key(ord('g')) + self.assertEqual(app.work_sel_idx, 0) + + def test_work_pipeline_mouse_bounds_and_section_selection(self): + app = muse_tui.MuseTUI(self.mock_stdscr, initial_mode="box", initial_tab="work") + app.box_tab = 7 + app.data.work_issues_cache = [{"number": i, "title": f"Ticket {i}", "state": "open"} for i in range(12)] + app.data.work_prs_cache = [{"number": i, "title": f"PR {i}", "state": "open", "merged": False} for i in range(8)] + + # Render to calculate bounds + app._render_work_view(2, 0, 30, 100) + self.assertTrue(hasattr(app, "_work_workers_bounds")) + self.assertTrue(hasattr(app, "_work_tickets_bounds")) + self.assertTrue(hasattr(app, "_work_prs_bounds")) + + w_start, w_end = app._work_workers_bounds + t_start, t_end = app._work_tickets_bounds + p_start, p_end = app._work_prs_bounds + + # Click inside tickets section + click_bstate = curses.BUTTON1_CLICKED + app._handle_mouse(10, t_start + 1, click_bstate) + self.assertEqual(app.work_focus_component, "tickets") + self.assertEqual(app.work_tickets_sel_idx, 1) + + # Click inside PRs section + app._handle_mouse(10, p_start + 2, click_bstate) + self.assertEqual(app.work_focus_component, "prs") + self.assertEqual(app.work_prs_sel_idx, 2) + + # Click inside workers section + app._handle_mouse(10, w_start, click_bstate) + self.assertEqual(app.work_focus_component, "workers") + self.assertEqual(app.work_sel_idx, 0) + + # Wheel scroll down inside tickets + wheel_down = getattr(curses, "BUTTON5_PRESSED", 0x200000) + app._handle_mouse(10, t_start + 1, wheel_down) + self.assertEqual(app.work_focus_component, "tickets") + self.assertEqual(app.work_tickets_sel_idx, 2) + + def test_box_tui_scrollable_tabs_approvals_tmux_dm_logs(self): + app = muse_tui.MuseTUI(self.mock_stdscr, initial_mode="box", initial_tab="approvals") + + # Tab 2: Approvals scroll + app.box_tab = 2 + app.data.approvals_cache = [{"node": f"agent{i}", "tool": f"tool{i}", "status": "PENDING"} for i in range(15)] + app.approvals_sel_idx = 10 + app._render_approvals_view(2, 0, 10, 80) + self.assertGreater(app.approvals_scroll_start, 0) + + # Tab 4: Tmux scroll + app.box_tab = 4 + app.data.tmux_cache = [f"session_{i}: 1 windows" for i in range(12)] + app.tmux_sel_idx = 8 + app._render_tmux_view(2, 0, 10, 80) + self.assertGreater(app.tmux_scroll_start, 0) + app._handle_key(ord('k')) + self.assertEqual(app.tmux_sel_idx, 7) + app._handle_key(ord('j')) + self.assertEqual(app.tmux_sel_idx, 8) + + # Tab 5: DM Logs scroll + app.box_tab = 5 + app.data.dm_logs_cache = [{"type": "sent", "agent": "opm", "to": "646", "target": "box", "ts": "2026-10-10T12:00:00", "id": f"m{i}"} for i in range(15)] + app.dm_logs_sel_idx = 12 + app._render_dm_logs_view(2, 0, 10, 80) + self.assertGreater(app.dm_logs_scroll_start, 0) + app._handle_key(ord('k')) + self.assertEqual(app.dm_logs_sel_idx, 11) + app._handle_key(ord('j')) + self.assertEqual(app.dm_logs_sel_idx, 12) + app._handle_key(ord('g')) + self.assertEqual(app.dm_logs_sel_idx, 0) + app._handle_key(ord('G')) + self.assertEqual(app.dm_logs_sel_idx, 14) + if __name__ == "__main__": unittest.main()