diff --git a/bin/box-fleet-tui.py b/bin/box-fleet-tui.py new file mode 100755 index 0000000..f5da338 --- /dev/null +++ b/bin/box-fleet-tui.py @@ -0,0 +1,1584 @@ +#!/usr/bin/env python3 +"""box-fleet-tui.py — Fleet control TUI for NetVM (stdlib curses). + +Surfaces 6 views: + [1] Timers — user timer states (NEXT/LAST per fleet timer). + [2] Harvest — harvest watermarks per agent with freshness highlight. + [3] Followups — follow-up queue counts by status. + [4] Approvals — pending approvals across the fleet. + [5] Activity — per-agent last activity. + [6] Runtimes — agent sockets/sessions vs the fleet manifest, task + queue depths, and the reconcile dry-run plan. + ACTIONABLE: R runs the reconciler now, o/Enter + attaches to the selected session. Tabs 1-5 stay + read-only. + +All data-gathering lives in pure, testable functions taking injected +runners/readers (see gather_*). The curses UI is a thin renderer over +those functions. Missing files/commands yield "n/a" rows — never a crash. + +Usage: + box fleet-tui (wired in bin/super-cli.py) + box tui fleet + python3 bin/box-fleet-tui.py [--once [--json]] # non-interactive dump +""" + +from __future__ import annotations + +import curses +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple +import urllib.request + +try: + import agent_cognitive_probe as acp +except ImportError: + acp = None + +REPO_ROOT = Path(__file__).resolve().parent.parent +BIN_DIR = REPO_ROOT / "bin" +if str(BIN_DIR) not in sys.path: + sys.path.insert(0, str(BIN_DIR)) +SYSTEMD_DIR = REPO_ROOT / "systemd" +WATERMARKS_FILE = REPO_ROOT / "siphon-watermarks.json" +FOLLOWUPS_FILE = REPO_ROOT / "followups.json" +JOB_SIDECHATS_FILE = REPO_ROOT / "job-sidechats.json" +DM_LOG = REPO_ROOT / "dm-log.jsonl" +JOB_LOG = REPO_ROOT / "job-log.jsonl" +CHAT_HISTORY_LOG = REPO_ROOT / "logs" / "chat-history.jsonl" +FLEET_MANIFEST = REPO_ROOT / "fleet" / "agents.json" +FLEET_TASKS_DIR = REPO_ROOT / "fleet" / "tasks" +FLEET_DONE_DIR = FLEET_TASKS_DIR / "done" + +FLEET_AGENTS = ["muse", "pip", "646", "opm", "def", "dev"] + +NA = "n/a" + +# Freshness thresholds (seconds) for harvest watermarks. +FRESH_S = 15 * 60 +AGING_S = 2 * 60 * 60 + +RunFn = Callable[..., Tuple[int, str]] +ReadJsonFn = Callable[[Path], Optional[Any]] +IterLinesFn = Callable[[Path], Iterable[str]] + +EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + + +# ===================================================================== +# Default IO primitives (injectable seams for tests) +# ===================================================================== + +def _run(cmd: List[str], timeout: int = 15) -> Tuple[int, str]: + """Run cmd, capture output. Returns (returncode, combined_output).""" + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + 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 _read_json(path: Path) -> Optional[Any]: + """Read + parse a JSON file. Missing/corrupt -> None (never raises).""" + try: + with open(path, "r") as f: + return json.load(f) + except Exception: + return None + + +def _iter_lines(path: Path, max_bytes: int = 65536) -> Iterable[str]: + """Yield trailing lines of a (possibly large) text file, oldest first. + + Reads only the last max_bytes. Missing/unreadable -> yields nothing. + """ + try: + with open(path, "rb") as f: + f.seek(0, 2) + size = f.tell() + f.seek(max(0, size - max_bytes)) + chunk = f.read().decode("utf-8", errors="replace") + except Exception: + return + lines = chunk.splitlines() + # Drop a probable partial first line when we sliced mid-file. + if size > max_bytes and lines: + lines = lines[1:] + for line in lines: + yield line + + +# ===================================================================== +# Small pure helpers +# ===================================================================== + +def parse_ts(value: Any) -> Optional[datetime]: + """Parse an ISO-8601 timestamp (or epoch number) into aware datetime.""" + if value is None: + return None + if isinstance(value, (int, float)): + try: + return datetime.fromtimestamp(float(value), tz=timezone.utc) + except Exception: + return None + if not isinstance(value, str) or not value.strip(): + return None + try: + dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except Exception: + return None + + +def relative_age(value: Any, now: Optional[datetime] = None) -> str: + """Short relative age ("5m ago") for a timestamp; "n/a" when unknown.""" + dt = parse_ts(value) + if dt is None: + return NA + now = now or datetime.now(timezone.utc) + diff = int((now - dt).total_seconds()) + if diff < 0: + return "just now" + if diff < 60: + return "%ds ago" % diff + if diff < 3600: + return "%dm ago" % (diff // 60) + if diff < 86400: + return "%dh ago" % (diff // 3600) + return "%dd ago" % (diff // 86400) + + +def freshness_bucket(value: Any, now: Optional[datetime] = None) -> str: + """Freshness of a timestamp: fresh / aging / stale / unknown.""" + dt = parse_ts(value) + if dt is None: + return "unknown" + now = now or datetime.now(timezone.utc) + age = (now - dt).total_seconds() + if age < 0: + return "fresh" + if age < FRESH_S: + return "fresh" + if age < AGING_S: + return "aging" + return "stale" + + +def short_ts(value: Any) -> str: + """Compact "MM-DD HH:MM" display for a timestamp; "n/a" when unknown.""" + dt = parse_ts(value) + if dt is None: + return NA + try: + local = dt.astimezone() + except Exception: + local = dt + return local.strftime("%m-%d %H:%M") + + +# ===================================================================== +# Surface 1: user timer states (NEXT/LAST per fleet timer) +# ===================================================================== + +def shipped_timers(systemd_dir: Optional[Path] = None, + listdir: Optional[Callable[[Path], List[str]]] = None) -> List[str]: + """Sorted *.timer unit names shipped in systemd/. Missing dir -> [].""" + d = Path(systemd_dir) if systemd_dir else SYSTEMD_DIR + try: + if listdir is not None: + names = listdir(d) + else: + names = [p.name for p in d.iterdir() if p.is_file()] + except Exception: + return [] + return sorted(n for n in names if n.endswith(".timer")) + + +def _parse_list_timers_json(out: str) -> Dict[str, Dict[str, Any]]: + """Parse `systemctl list-timers --output=json` (µs-since-epoch ints).""" + data = json.loads(out) + states: Dict[str, Dict[str, Any]] = {} + for entry in data: + if not isinstance(entry, dict): + continue + unit = entry.get("unit") + if not unit: + continue + nxt = entry.get("next") or 0 + last = entry.get("last") or 0 + try: + next_ts = float(nxt) / 1e6 if float(nxt) > 0 else None + except Exception: + next_ts = None + try: + last_ts = float(last) / 1e6 if float(last) > 0 else None + except Exception: + last_ts = None + states[unit] = { + "next_ts": next_ts, + "last_ts": last_ts, + "activates": entry.get("activates") or NA, + } + return states + + +def _parse_list_timers_text(out: str) -> Dict[str, Dict[str, Any]]: + """Fallback parser for the human-readable list-timers table. + + NEXT/LAST columns carry weekday + date + time + zone, e.g. + "Wed 2026-10-07 05:37:00 UTC"; "-" marks an empty cell. + """ + states: Dict[str, Dict[str, Any]] = {} + for line in out.splitlines(): + line = line.rstrip() + if not line or line.startswith("NEXT"): + continue + parts = line.split() + if len(parts) < 3: + continue + unit = parts[-2] + activates = parts[-1] + if not unit.endswith(".timer"): + continue + # Walk cells: NEXT is 4 tokens ("Wed DATE TIME TZ") or "-", + # then LEFT (1-3 tokens ending in "ago" or a duration), then LAST. + rest = parts[:-2] + try: + if rest and rest[0] == "-": + next_ts = None + rest = rest[1:] + else: + next_ts = _table_cell_ts(rest[:4]) + rest = rest[4:] + # Skip LEFT cell: consume until LAST cell starts (weekday or "-"). + weekdays = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} + while rest and rest[0] not in weekdays and rest[0] != "-": + rest = rest[1:] + if rest and rest[0] == "-": + last_ts = None + else: + last_ts = _table_cell_ts(rest[:4]) + except Exception: + next_ts, last_ts = None, None + states[unit] = {"next_ts": next_ts, "last_ts": last_ts, + "activates": activates} + return states + + +def _table_cell_ts(tokens: List[str]) -> Optional[float]: + if len(tokens) < 4: + return None + try: + dt = datetime.strptime(" ".join(tokens[1:3]), "%Y-%m-%d %H:%M:%S") + dt = dt.replace(tzinfo=timezone.utc) + return (dt - EPOCH).total_seconds() + except Exception: + return None + + +def gather_timers(run: Optional[RunFn] = None, + timer_names: Optional[List[str]] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """NEXT/LAST state per fleet timer.runner failure -> "n/a" rows. + + Returns {"rows": [{timer, next, next_rel, last, last_rel, activates, + present}], "note": str}. `timer_names=None` reports every non-transient + timer the manager lists (run-*.timer excluded). + """ + run = run or _run + now = now or datetime.now(timezone.utc) + states: Dict[str, Dict[str, Any]] = {} + note = "" + try: + rc, out = run(["systemctl", "--user", "list-timers", "--no-pager", + "--all", "--output=json"], timeout=15) + except Exception as e: + rc, out = 127, str(e) + if rc == 0 and out.strip(): + try: + states = _parse_list_timers_json(out) + except Exception: + note = "json parse failed; tried text fallback. " + try: + rc2, out2 = run(["systemctl", "--user", "list-timers", + "--no-pager", "--all"], timeout=15) + if rc2 == 0: + states = _parse_list_timers_text(out2) + except Exception: + states = {} + else: + note = "systemctl unavailable (%s). " % (out[:60] if out else "rc=%s" % rc) + + if timer_names is None: + names = sorted(u for u in states if not u.startswith("run-")) + else: + names = list(timer_names) + rows = [] + for name in names: + st = states.get(name) + if st is None: + rows.append({"timer": name, "next": NA, "next_rel": NA, + "last": NA, "last_rel": NA, "activates": NA, + "present": False}) + continue + nts, lts = st.get("next_ts"), st.get("last_ts") + rows.append({ + "timer": name, + "next": short_ts(nts) if nts else NA, + "next_rel": _future_rel(nts, now), + "last": short_ts(lts) if lts else NA, + "last_rel": relative_age(lts, now) if lts else NA, + "activates": st.get("activates") or NA, + "present": True, + }) + return {"rows": rows, "note": note.strip()} + + +def _future_rel(ts: Optional[float], now: datetime) -> str: + if not ts: + return NA + diff = int(ts - now.timestamp()) + if diff < 0: + return "due" + if diff < 60: + return "in %ds" % diff + if diff < 3600: + return "in %dm" % (diff // 60) + if diff < 86400: + return "in %dh" % (diff // 3600) + return "in %dd" % (diff // 86400) + + +# ===================================================================== +# Surface 2: harvest watermarks per agent (freshness highlighted) +# ===================================================================== + +def _latest_harvest_ts(iter_lines: IterLinesFn) -> Dict[str, str]: + """Latest chat-history ts per "agent:thread_id" key.""" + latest: Dict[str, str] = {} + try: + lines = iter_lines(CHAT_HISTORY_LOG) + except Exception: + return latest + try: + for line in lines: + if not line or not line.strip(): + continue + try: + rec = json.loads(line) + except Exception: + continue + if not isinstance(rec, dict): + continue + agent, tid = rec.get("agent"), rec.get("thread_id") + ts = rec.get("ts") + if agent and tid and ts: + latest["%s:%s" % (agent, tid)] = ts + except Exception: + pass + return latest + + +def gather_harvest(read_json: Optional[ReadJsonFn] = None, + iter_lines: Optional[IterLinesFn] = None, + agents: Optional[List[str]] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """Watermark + freshness rows per agent main chat and sidechat threads. + + Returns {"rows": [{agent, thread_name, thread_id, watermark, last, + last_rel, freshness}], "note": str}. Missing files -> "n/a" cells. + """ + read_json = read_json or _read_json + iter_lines = iter_lines or _iter_lines + agents = agents or FLEET_AGENTS + now = now or datetime.now(timezone.utc) + notes: List[str] = [] + + watermarks: Dict[str, Any] = {} + try: + loaded = read_json(WATERMARKS_FILE) + if isinstance(loaded, dict): + watermarks = loaded + else: + notes.append("watermarks file missing/empty.") + except Exception: + notes.append("watermarks unreadable.") + + sidechats: Dict[str, Any] = {} + try: + loaded = read_json(JOB_SIDECHATS_FILE) + if isinstance(loaded, dict): + sidechats = loaded + except Exception: + pass + + latest = _latest_harvest_ts(iter_lines) + + # Sidechat threads grouped by owning agent. + owned: Dict[str, List[Tuple[str, str]]] = {a: [] for a in agents} + for alias, val in sidechats.items(): + if alias.startswith("_") or not isinstance(val, dict): + continue + tid = val.get("thread_uuid") or val.get("uuid") + owner = val.get("agent", "opm") + if tid and owner in owned: + owned[owner].append((alias, tid)) + + rows = [] + for agent in agents: + targets: List[Tuple[str, str]] = [("Main Chat", "main")] + targets.extend(sorted(owned.get(agent, []))) + for thread_name, tid in targets: + key = "%s:%s" % (agent, tid) + wm = watermarks.get(key) + wm_s = str(wm)[:24] if wm else NA + last_ts = latest.get(key) + rows.append({ + "agent": agent, + "thread_name": thread_name, + "thread_id": tid, + "watermark": wm_s, + "last": short_ts(last_ts) if last_ts else NA, + "last_rel": relative_age(last_ts, now) if last_ts else NA, + "freshness": freshness_bucket(last_ts, now) if last_ts else "unknown", + }) + return {"rows": rows, "note": " ".join(notes)} + + +# ===================================================================== +# Surface 3: follow-up queue counts by status +# ===================================================================== + +FOLLOWUP_STATUSES = ("pending", "escalated", "resolved") + + +def gather_followups(read_json: Optional[ReadJsonFn] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """Queue counts by status + oldest pending item. Corrupt -> zeros. + + Returns {"counts": {status: n}, "total": n, "overdue": n, + "oldest_pending": {...}|None, "by_recipient": {agent: n}, "note": str}. + """ + read_json = read_json or _read_json + now = now or datetime.now(timezone.utc) + try: + loaded = read_json(FOLLOWUPS_FILE) + followups = loaded if isinstance(loaded, dict) else {} + note = "" if loaded is not None else "followups file missing/empty." + except Exception: + followups, note = {}, "followups unreadable." + + counts: Dict[str, int] = {s: 0 for s in FOLLOWUP_STATUSES} + other = 0 + overdue = 0 + by_recipient: Dict[str, int] = {} + oldest: Optional[Dict[str, Any]] = None + oldest_dt: Optional[datetime] = None + for dm_id, rec in followups.items(): + if not isinstance(rec, dict): + other += 1 + continue + st = str(rec.get("status", "pending")) + if st in counts: + counts[st] += 1 + else: + other += 1 + if st == "pending": + recip = str(rec.get("recipient", "?")) + by_recipient[recip] = by_recipient.get(recip, 0) + 1 + dl = parse_ts(rec.get("deadline")) + if dl is not None and dl < now: + overdue += 1 + sent = parse_ts(rec.get("sent_at")) + if sent is not None and (oldest_dt is None or sent < oldest_dt): + oldest_dt = sent + oldest = { + "dm_id": str(dm_id)[:8], + "route": "%s -> %s" % (rec.get("sender", "?"), + rec.get("recipient", "?")), + "target": str(rec.get("target", "main"))[:24], + "sent": short_ts(rec.get("sent_at")), + "sent_rel": relative_age(rec.get("sent_at"), now), + } + if other: + counts["other"] = other + total = sum(counts.values()) + return {"counts": counts, "total": total, "overdue": overdue, + "oldest_pending": oldest, "by_recipient": by_recipient, + "note": note} + + +# ===================================================================== +# Surface 4: pending approvals +# ===================================================================== + +def _default_approval_check(nodes: List[str]) -> List[Dict[str, Any]]: + """Lazy approvals.check_fleet_approvals with per-node isolation.""" + try: + sys.path.insert(0, str(BIN_DIR)) + import approvals + except Exception as e: + raise RuntimeError("approvals module unavailable: %s" % e) + results = [] + for node in nodes: + try: + results.append(approvals.inspect_node_approvals(node)) + except Exception as e: + results.append({"node": node, "status": "ERROR", + "has_pending": False, "error": str(e)}) + return results + + +def gather_approvals(check: Optional[Callable[[List[str]], List[Dict[str, Any]]]] = None, + nodes: Optional[List[str]] = None) -> Dict[str, Any]: + """Pending approvals across the fleet. Probe failure -> note, no crash. + + Returns {"pending": [{node, status, title, target, trusted}], + "total_pending": n, "unreachable": [nodes], "checked": n, "note": str}. + """ + nodes = nodes or FLEET_AGENTS + check = check or _default_approval_check + try: + results = check(list(nodes)) + except Exception as e: + return {"pending": [], "total_pending": 0, "unreachable": [], + "checked": 0, "note": "approval probe failed: %s" % e} + if not isinstance(results, list): + return {"pending": [], "total_pending": 0, "unreachable": [], + "checked": 0, "note": "approval probe returned no data."} + pending, unreachable = [], [] + for item in results: + if not isinstance(item, dict): + continue + node = str(item.get("node", "?")) + status = str(item.get("status", "UNKNOWN")) + if status == "UNREACHABLE": + unreachable.append(node) + if item.get("has_pending"): + title = str(item.get("title", ""))[:60] or status + pending.append({ + "node": node, + "status": status, + "title": title, + "target": str(item.get("target", "-"))[:32], + "trusted": bool(item.get("is_trusted", False)), + }) + return {"pending": pending, "total_pending": len(pending), + "unreachable": unreachable, "checked": len(results), "note": ""} + + +# ===================================================================== +# Surface 5: per-agent last activity +# ===================================================================== + +def _scan_activity(iter_lines: IterLinesFn) -> Dict[str, Dict[str, str]]: + """Last-seen {agent: {ts, source, detail}} across dm/job/chat logs.""" + last: Dict[str, Dict[str, str]] = {} + sources: List[Tuple[Path, str]] = [ + (DM_LOG, "dm"), (JOB_LOG, "job"), (CHAT_HISTORY_LOG, "chat"), + ] + for path, source in sources: + try: + lines = iter_lines(path) + except Exception: + continue + try: + for line in lines: + if not line or not line.strip(): + continue + try: + rec = json.loads(line) + except Exception: + continue + if not isinstance(rec, dict): + continue + agent = rec.get("agent") + ts = rec.get("ts") + if not agent or not ts or parse_ts(ts) is None: + continue + prev = last.get(str(agent)) + if prev is not None and str(prev["ts"]) >= str(ts): + continue + if source == "dm": + detail = "%s -> %s (%s)" % ( + rec.get("agent", "?"), rec.get("to", "?"), + rec.get("target", rec.get("type", "?"))) + elif source == "job": + detail = str(rec.get("result_snippet", rec.get("type", "?")))[:48] + else: + detail = str(rec.get("thread_name", rec.get("thread_id", "?")))[:40] + last[str(agent)] = {"ts": str(ts), "source": source, + "detail": detail} + except Exception: + continue + return last + + +def gather_activity(iter_lines: Optional[IterLinesFn] = None, + agents: Optional[List[str]] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """Per-agent last activity rows. Empty logs -> "n/a" rows, never crash. + + Returns {"rows": [{agent, last, last_rel, source, detail, freshness}], + "note": str}. + """ + iter_lines = iter_lines or _iter_lines + agents = agents or FLEET_AGENTS + now = now or datetime.now(timezone.utc) + try: + last = _scan_activity(iter_lines) + except Exception as e: + last = {} + note = "activity scan failed: %s" % e + else: + note = "" if last else "no activity records found." + rows = [] + for agent in agents: + rec = last.get(agent) + if rec is None: + rows.append({"agent": agent, "last": NA, "last_rel": NA, + "source": NA, "detail": NA, "freshness": "unknown"}) + else: + rows.append({ + "agent": agent, + "last": short_ts(rec["ts"]), + "last_rel": relative_age(rec["ts"], now), + "source": rec["source"], + "detail": rec["detail"], + "freshness": freshness_bucket(rec["ts"], now), + }) + return {"rows": rows, "note": note} + + +def gather_runtimes(rows_fn: Optional[Callable[[], List[Dict[str, Any]]]] = None, + manifest_fn: Optional[Callable[[], Dict[str, Any]]] = None, + state_fn: Optional[Callable[[], Dict[str, Any]]] = None, + queue_fn: Optional[Callable[[], Dict[str, Any]]] = None, + done_count_fn: Optional[Callable[[], int]] = None, + brief_read: Optional[Callable[[str], Optional[str]]] = None, + plan_fn: Optional[Callable[[], Dict[str, Any]]] = None, + fleet_sockets: Optional[List[str]] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """Agent sockets/sessions vs manifest + queue + reconcile plan. + + Every input is injectable; defaults hit the live fleet (tmux rows, + manifest, reconciler state, queue, dry-run plan). Never raises: + backend failures yield notes and n/a fields. + """ + try: + import muse_choice_watcher as mcw + import runtime_reconcile as rec + except Exception as e: + return {"note": "runtimes backend unavailable (%s)" % e, "agents": []} + + if fleet_sockets is None: + try: + fleet_sockets = list(mcw.FLEET_SOCKETS) + except Exception: + fleet_sockets = [] + manifest_path = str(FLEET_MANIFEST) + if manifest_fn is None: + manifest_fn = lambda: rec.load_manifest(manifest_path) # noqa: E731 + if state_fn is None: + state_fn = lambda: rec.load_state( # noqa: E731 + str(REPO_ROOT / "fleet" / "state.json")) + if queue_fn is None: + queue_fn = lambda: rec.queue_status(str(FLEET_TASKS_DIR)) # noqa: E731 + if rows_fn is None: + rows_fn = mcw.all_runtime_rows # noqa: E731 + if plan_fn is None: + plan_fn = lambda: rec.run_reconcile( # noqa: E731 + manifest_path, dry_run=True) + if brief_read is None: + def brief_read(path: str) -> Optional[str]: + try: + with open(path) as f: + return f.read().strip() + except Exception: + return None + + notes: List[str] = [] + try: + manifest = manifest_fn() + except Exception as e: + manifest = {"agents": [], "errors": [str(e)]} + agents_declared = manifest.get("agents", []) or [] + for err in manifest.get("errors", []) or []: + if "no manifest" not in str(err): + notes.append("manifest: %s" % err) + try: + rows = rows_fn() or [] + except Exception as e: + rows = [] + notes.append("runtime rows: %s" % e) + try: + state = state_fn() or {} + except Exception: + state = {} + records = state.get("agents") or {} + try: + queue = queue_fn() or {} + except Exception as e: + queue = {} + notes.append("queue: %s" % e) + if done_count_fn is None: + try: + done_names = [n for n in os.listdir(FLEET_DONE_DIR) + if not n.startswith(".")] + done_count = len(done_names) + except Exception: + done_count = 0 + else: + try: + done_count = done_count_fn() + except Exception: + done_count = 0 + + live_by_session: Dict[str, List[Dict[str, Any]]] = {} + for row in rows: + live_by_session.setdefault(row.get("session", ""), []).append(row) + + agents: List[Dict[str, Any]] = [] + for entry in agents_declared: + session = entry.get("session", "?") + sock = entry.get("socket", "?") + panes = [r for r in live_by_session.get(session, []) + if r.get("socket") == sock] + pane = panes[0] if panes else None + key = rec.state_key(sock, session) + record = records.get(key) + briefed: Any = False + if record is not None: + text = brief_read(entry.get("brief", "")) + if text is None: + briefed = "unknown" + elif rec.brief_sha(text) == record.get("brief_sha"): + briefed = True + else: + briefed = "stale" + agents.append({ + "socket": sock, "session": session, + "hat": entry.get("hat", "?"), + "enabled": bool(entry.get("enabled", True)), + "live": pane is not None, + "pane": pane.get("pane") if pane else NA, + "state": pane.get("state", NA) if pane else NA, + "mode": (pane.get("effective_mode") + or pane.get("permission_mode") or NA) if pane else NA, + "watcher": bool(pane.get("watcher_alive")) if pane else False, + "briefed": briefed, + }) + + manifest_sessions = {a["session"] for a in agents} + strays: List[Dict[str, Any]] = [] + for row in rows: + if (row.get("socket") in (fleet_sockets or []) + and row.get("session") not in manifest_sessions): + strays.append({ + "socket": row.get("socket", "?"), + "session": row.get("session", "?"), + "pane": row.get("pane", NA), + "cmd": (row.get("cmd") or "")[:24], + "state": row.get("state", NA), + }) + + try: + plan_report = plan_fn() or {} + except Exception as e: + plan_report = {} + notes.append("reconcile plan: %s" % e) + plan = {"launch": [], "brief": [], "nudge": [], "failed": []} + for item in plan_report.get("agents", []) or []: + action = item.get("action") + if action in plan: + plan[action].append(item.get("session", "?")) + for nudge in plan_report.get("nudges", []) or []: + plan["nudge"].append("%s:%s" % (nudge.get("session", "?"), + nudge.get("kind", "?"))) + + last_run: Any = NA + try: + ts = (state or {}).get("last_run") + if ts: + last_run = relative_age(float(ts), now) + except Exception: + last_run = NA + + return { + "agents": agents, + "strays": strays, + "queue": {"pending": queue.get("pending", 0) if queue else 0, + "claimed": queue.get("claimed", {}) if queue else {}, + "done": done_count}, + "last_run": last_run, + "plan": plan, + "plan_errors": (plan_report.get("errors", []) or []) + + (plan_report.get("claims", {}).get("errors", []) or []), + "note": "; ".join(notes), + } + + +def attach_argv(sock: str, session: str) -> List[str]: + """tmux attach argv for a runtime open. Pure (tested).""" + return ["tmux", "-S", sock, "attach-session", "-t", session] + + +def do_reconcile_now(manifest_path: Optional[str] = None, + run_fn: Optional[Callable[..., Dict[str, Any]]] = None + ) -> Tuple[bool, str]: + """Run the runtime reconciler now, summarize the report. + + Returns (ok, one-line summary). Never raises. + """ + try: + if run_fn is None: + import runtime_reconcile as rec + run_fn = rec.run_reconcile + report = run_fn(manifest_path or str(FLEET_MANIFEST)) + except Exception as e: + return False, "reconcile failed: %s" % e + try: + actions = [a.get("action", "?") + for a in report.get("agents", []) or []] + launched = sum(1 for a in actions if a == "launched") + briefed = sum(1 for a in actions if a == "briefed") + failed = sum(1 for a in actions if a == "failed") + nudged = len(report.get("nudges", []) or []) + requeued = len((report.get("claims", {}) or {}).get("requeued", []) + or []) + ok_count = sum(1 for a in actions if a in ("ok", "adopted")) + msg = ("reconcile: %d ok, %d launched, %d briefed, " + "%d nudged, %d requeued" % ( + ok_count, launched, briefed, nudged, requeued)) + if failed: + msg += ", %d FAILED" % failed + return bool(report.get("ok", True)), msg + except Exception as e: + return False, "reconcile report unreadable: %s" % e + + +def gather_work(run: Optional[RunFn] = None, + read_json: Optional[ReadJsonFn] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """Gather real-time cognitive sensor states, Gitea build issues, and loopback state.""" + _run_fn = run or _run + _read_fn = read_json or _read_json + + cog_states = {} + if acp: + for agent in FLEET_AGENTS: + try: + cog_states[agent] = acp.get_passive_cognitive_state(agent) + except Exception: + cog_states[agent] = {"status": "UNKNOWN", "cognitive_lock": False, "badge": "⚠️ UNKNOWN"} + else: + for agent in FLEET_AGENTS: + cog_states[agent] = {"status": "NO_PROBE", "cognitive_lock": False, "badge": "n/a"} + + # Fetch recent build tickets from Gitea + issues = [] + try: + req = urllib.request.Request( + "https://tea.muse-dev.online/api/v1/repos/super/box/issues?state=all&limit=8", + headers={"User-Agent": "Box-Fleet-TUI/1.0"} + ) + with urllib.request.urlopen(req, timeout=3.0) as r: + issues = json.loads(r.read().decode()) + except Exception: + pass + + # Check loopback timer state + rc, loop_txt = _run_fn(["systemctl", "--user", "is-active", "box-work-loopback.timer"], timeout=5) + loop_active = (rc == 0 and "active" in loop_txt.lower()) + + return { + "cognitive": cog_states, + "issues": issues if isinstance(issues, list) else [], + "loopback_active": loop_active, + } + + +def gather_all(run: Optional[RunFn] = None, + read_json: Optional[ReadJsonFn] = None, + iter_lines: Optional[IterLinesFn] = None, + approval_check: Optional[Callable] = None, + timer_names: Optional[List[str]] = None, + runtimes_kwargs: Optional[Dict[str, Any]] = None, + now: Optional[datetime] = None) -> Dict[str, Any]: + """One-shot snapshot of all seven surfaces (TUI refresh + --once).""" + if timer_names is None: + timer_names = shipped_timers() + return { + "timers": gather_timers(run=run, timer_names=timer_names, now=now), + "harvest": gather_harvest(read_json=read_json, iter_lines=iter_lines, + now=now), + "followups": gather_followups(read_json=read_json, now=now), + "approvals": gather_approvals(check=approval_check), + "activity": gather_activity(iter_lines=iter_lines, now=now), + "runtimes": gather_runtimes(**(runtimes_kwargs or {}), now=now), + "work": gather_work(run=run, read_json=read_json, now=now), + } + + +# ===================================================================== +# Curses UI (thin read-only renderer over gather_*) +# ===================================================================== + +AUTO_REFRESH_S = 60.0 + + +class BoxFleetTUI: + """Fleet control console. Tabs 1-5 read-only; 6:RUNTIMES acts.""" + + RUNTIMES_TAB = 5 + WORK_TAB = 6 + + def __init__(self, stdscr: "curses.window"): + self.stdscr = stdscr + self.current_tab = 0 + self.tabs = [ + "1: TIMERS", + "2: HARVEST", + "3: FOLLOWUPS", + "4: APPROVALS", + "5: ACTIVITY", + "6: RUNTIMES", + "7: WORK", + ] + self.rt_sel = 0 + self.rt_items: List[Dict[str, str]] = [] + self.work_sel = 0 + try: + curses.curs_set(0) + except Exception: + pass + self.stdscr.nodelay(True) + self.stdscr.keypad(True) + if hasattr(curses, "set_escdelay"): + try: + curses.set_escdelay(25) + except Exception: + pass + self._init_colors() + + self.timer_names = shipped_timers() + self.scroll = 0 + self.show_help = False + self.status_msg = "Loading fleet snapshot..." + self.last_refresh = 0.0 + self.snapshot: Dict[str, Any] = {} + self.refresh() + + # -- setup ------------------------------------------------------ + + def _init_colors(self) -> None: + try: + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_CYAN, -1) + curses.init_pair(2, curses.COLOR_YELLOW, -1) + curses.init_pair(3, curses.COLOR_GREEN, -1) + curses.init_pair(4, curses.COLOR_RED, -1) + curses.init_pair(5, curses.COLOR_MAGENTA, -1) + curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN) + curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_WHITE) + curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW) + except Exception: + pass + + def _attr(self, name: str) -> int: + try: + mapping = { + "normal": curses.color_pair(0), + "cyan": curses.color_pair(1) | curses.A_BOLD, + "yellow": curses.color_pair(2) | curses.A_BOLD, + "green": curses.color_pair(3) | curses.A_BOLD, + "red": curses.color_pair(4) | curses.A_BOLD, + "magenta": curses.color_pair(5) | curses.A_BOLD, + "head_sel": curses.color_pair(6) | curses.A_BOLD, + "row_sel": curses.color_pair(7) | curses.A_BOLD, + "warn": curses.color_pair(8) | curses.A_BOLD, + "dim": curses.A_DIM, + } + return mapping.get(name, 0) + except Exception: + return 0 + + def freshness_attr(self, bucket: str) -> int: + return {"fresh": self._attr("green"), + "aging": self._attr("yellow"), + "stale": self._attr("red")}.get(bucket, self._attr("dim")) + + # -- data ------------------------------------------------------- + + def refresh(self) -> None: + try: + self.snapshot = gather_all(timer_names=self.timer_names) + self.status_msg = "Snapshot %s (auto-refresh %ds; r=refresh)" % ( + datetime.now().strftime("%H:%M:%S"), int(AUTO_REFRESH_S)) + except Exception as e: + self.snapshot = {} + self.status_msg = "Refresh failed (showing n/a): %s" % e + self.last_refresh = time.time() + self.scroll = 0 + + # -- render helpers --------------------------------------------- + + def safe_addstr(self, y: int, x: int, text: str, attr: int = 0) -> None: + h, w = self.stdscr.getmaxyx() + if 0 <= y < h and 0 <= x < w: + try: + self.stdscr.addstr(y, x, text[:max(0, w - x - 1)], attr) + except Exception: + pass + + def _render_header(self, w: int) -> None: + self.safe_addstr(0, 0, " " * w, self._attr("head_sel")) + title = " FLEET CONTROL [box fleet-tui] " + self.safe_addstr(0, 1, title, self._attr("head_sel")) + self.safe_addstr(1, 0, " " * w, self._attr("dim")) + col = 1 + for idx, tab_name in enumerate(self.tabs): + pill = " [%s] " % tab_name + attr = self._attr("head_sel") if idx == self.current_tab else self._attr("dim") + self.safe_addstr(1, col, pill, attr) + col += len(pill) + 1 + self.safe_addstr(2, 0, "-" * w, self._attr("dim")) + + def _render_footer(self, h: int, w: int) -> None: + self.safe_addstr(h - 2, 0, "-" * w, self._attr("dim")) + if self.current_tab == self.RUNTIMES_TAB: + hints = (" 1-7/Tab: Tabs j/k: Select R: Reconcile " + "o: Attach ?: Help q: Quit") + elif self.current_tab == self.WORK_TAB: + hints = (" 1-7/Tab: Tabs W: Dispatch F: Refocus " + "L: Sweep H: Heal r: Refresh ?: Help q: Quit") + else: + hints = (" 1-7/Tab: Tabs j/k: Scroll r: Refresh " + "?: Help q: Quit") + self.safe_addstr(h - 1, 1, self.status_msg[: w - 2], self._attr("dim")) + if len(self.status_msg) + len(hints) + 2 < w: + self.safe_addstr(h - 1, w - len(hints) - 1, hints, self._attr("dim")) + + def _body(self, h: int, w: int, title: str, + lines: List[Tuple[str, str]]) -> None: + self.safe_addstr(3, 2, title, self._attr("cyan")) + self.safe_addstr(4, 2, "-" * (w - 4), self._attr("dim")) + max_rows = h - 8 + visible = lines[self.scroll:self.scroll + max_rows] + for i, (text, attr_name) in enumerate(visible): + self.safe_addstr(5 + i, 2, text, self._attr(attr_name)) + if self.scroll > 0: + self.safe_addstr(5, w - 6, "^more", self._attr("dim")) + if self.scroll + max_rows < len(lines): + self.safe_addstr(h - 3, w - 6, "vmore", self._attr("dim")) + + # -- per-tab renderers ------------------------------------------ + + def _render_timers(self, h: int, w: int) -> None: + data = self.snapshot.get("timers", {}) + rows = data.get("rows", []) + lines: List[Tuple[str, str]] = [ + ("%-32s %-11s %-9s %-11s %-9s %s" + % ("TIMER", "NEXT", "IN", "LAST", "AGO", "ACTIVATES"), "dim"), + ] + for r in rows: + missing = (r.get("next", NA) == NA and r.get("last", NA) == NA) + attr = "dim" if missing else ("yellow" if r.get("next", NA) == NA else "normal") + lines.append(( + "%-32s %-11s %-9s %-11s %-9s %s" % ( + r.get("timer", "?")[:32], r.get("next", NA)[:11], + r.get("next_rel", NA)[:9], r.get("last", NA)[:11], + r.get("last_rel", NA)[:9], + r.get("activates", NA)[:28]), attr)) + if not rows: + lines.append(("(No fleet timers discovered in systemd/)", "dim")) + if data.get("note"): + lines.append(("", "normal")) + lines.append(("note: %s" % data["note"][: w - 10], "yellow")) + self._body(h, w, "USER TIMER STATES (NEXT/LAST)", lines) + + def _render_harvest(self, h: int, w: int) -> None: + data = self.snapshot.get("harvest", {}) + rows = data.get("rows", []) + lines: List[Tuple[str, str]] = [ + ("%-6s %-20s %-24s %-11s %-9s %s" + % ("AGENT", "THREAD", "WATERMARK", "LAST", "AGO", "FRESH"), "dim"), + ] + for r in rows: + fresh = r.get("freshness", "unknown") + if fresh == "fresh": + attr = "green" + elif fresh == "aging": + attr = "yellow" + elif fresh == "stale": + attr = "red" + elif r.get("watermark", NA) == NA: + attr = "dim" + lines.append(( + "%-6s %-20s %-24s %-11s %-9s %s" % ( + r.get("agent", "?"), r.get("thread_name", "?")[:20], + r.get("watermark", NA)[:24], r.get("last", NA)[:11], + r.get("last_rel", NA)[:9], fresh), attr)) + if data.get("note"): + lines.append(("", "normal")) + lines.append(("note: %s" % data["note"][: w - 10], "yellow")) + self._body(h, w, "HARVEST WATERMARKS PER AGENT (freshness highlighted)", lines) + + def _render_followups(self, h: int, w: int) -> None: + data = self.snapshot.get("followups", {}) + counts = data.get("counts", {}) + lines: List[Tuple[str, str]] = [ + ("Queue depth: %d tracked | overdue: %d" % ( + data.get("total", 0), data.get("overdue", 0)), "cyan"), + ("", "normal"), + ("%-12s %s" % ("STATUS", "COUNT"), "dim"), + ] + for st in ("pending", "escalated", "resolved", "other"): + if st not in counts: + continue + attr = {"pending": "yellow", "escalated": "red", + "resolved": "green"}.get(st, "dim") + lines.append(("%-12s %d" % (st.upper(), counts[st]), attr)) + lines.append(("", "normal")) + by_rec = data.get("by_recipient", {}) + if by_rec: + lines.append(("Pending by recipient:", "dim")) + for agent, n in sorted(by_rec.items()): + lines.append((" %-8s %d" % (agent, n), "normal")) + lines.append(("", "normal")) + oldest = data.get("oldest_pending") + if oldest: + lines.append(("Oldest pending: %s %s %s sent %s (%s)" % ( + oldest.get("dm_id", "?"), oldest.get("route", "?"), + oldest.get("target", "?"), oldest.get("sent", NA), + oldest.get("sent_rel", NA)), "yellow")) + else: + lines.append(("Oldest pending: none", "dim")) + if data.get("note"): + lines.append(("", "normal")) + lines.append(("note: %s" % data["note"][: w - 10], "yellow")) + self._body(h, w, "FOLLOW-UP QUEUE COUNTS BY STATUS", lines) + + def _render_approvals(self, h: int, w: int) -> None: + data = self.snapshot.get("approvals", {}) + pending = data.get("pending", []) + lines: List[Tuple[str, str]] = [ + ("Pending: %d | checked: %d nodes | unreachable: %s" % ( + data.get("total_pending", 0), data.get("checked", 0), + ", ".join(data.get("unreachable", [])) or "none"), "cyan"), + ("", "normal"), + ] + if not pending: + lines.append(("(No pending approvals — or probe returned n/a.)", "dim")) + else: + lines.append(("%-6s %-12s %-32s %s" + % ("NODE", "STATUS", "TARGET", "TITLE"), "dim")) + for p in pending: + lines.append(( + "%-6s %-12s %-32s %s" % ( + p.get("node", "?"), p.get("status", "?")[:12], + p.get("target", "-")[:32], + p.get("title", "")[: w - 58]), "red")) + if data.get("note"): + lines.append(("", "normal")) + lines.append(("note: %s" % data["note"][: w - 10], "yellow")) + self._body(h, w, "PENDING APPROVALS", lines) + + def _render_activity(self, h: int, w: int) -> None: + data = self.snapshot.get("activity", {}) + rows = data.get("rows", []) + lines: List[Tuple[str, str]] = [ + ("%-6s %-11s %-9s %-6s %-7s %s" + % ("AGENT", "LAST", "AGO", "SRC", "FRESH", "DETAIL"), "dim"), + ] + for r in rows: + fresh = r.get("freshness", "unknown") + attr = "green" if fresh == "fresh" else ( + "yellow" if fresh == "aging" else ( + "red" if fresh == "stale" else "dim")) + lines.append(( + "%-6s %-11s %-9s %-6s %-7s %s" % ( + r.get("agent", "?"), r.get("last", NA)[:11], + r.get("last_rel", NA)[:9], r.get("source", NA)[:6], + fresh, r.get("detail", NA)[: w - 48]), attr)) + if data.get("note"): + lines.append(("", "normal")) + lines.append(("note: %s" % data["note"][: w - 10], "yellow")) + self._body(h, w, "PER-AGENT LAST ACTIVITY", lines) + + def _render_runtimes(self, h: int, w: int) -> None: + data = self.snapshot.get("runtimes", {}) + queue = data.get("queue", {}) + plan = data.get("plan", {}) + claimed = queue.get("claimed", {}) or {} + claimed_txt = (", ".join("%s:%d" % (s, n) + for s, n in sorted(claimed.items())) + or "none") + lines: List[Tuple[str, str]] = [ + ("Manifest agents | last reconcile run: %s" + % data.get("last_run", NA), "cyan"), + ("", "normal"), + ("%-26s %-8s %-5s %-5s %-11s %-10s %-7s %s" + % ("SESSION", "HAT", "LIVE", "PANE", "STATE", "MODE", + "WATCHER", "BRIEFED"), "dim"), + ] + items: List[Dict[str, str]] = [] + line_of_item: Dict[int, int] = {} + for agent in data.get("agents", []): + live = agent.get("live") + briefed = agent.get("briefed") + if isinstance(briefed, bool): + briefed_txt = "yes" if briefed else "NO" + else: + briefed_txt = str(briefed) + attr = "dim" if not agent.get("enabled") else ( + "red" if not live else ( + "yellow" if briefed_txt in ("NO", "stale") + else "normal")) + idx = len(items) + if live: + items.append({"socket": agent.get("socket", "?"), + "session": agent.get("session", "?")}) + line_of_item[idx] = len(lines) + else: + idx = -1 + marker = ">" if idx == self.rt_sel else " " + lines.append(( + "%s%-25s %-8s %-5s %-5s %-11s %-10s %-7s %s" % ( + marker, agent.get("session", "?")[:25], + agent.get("hat", "?")[:8], + "YES" if live else "NO", + agent.get("pane", NA)[:5], + agent.get("state", NA)[:11], + agent.get("mode", NA)[:10], + "ALIVE" if agent.get("watcher") else "-", + briefed_txt), attr)) + strays = data.get("strays", []) + if strays: + lines.append(("", "normal")) + lines.append(("Stray sessions (fleet sockets, not in manifest):", + "dim")) + for stray in strays: + idx = len(items) + items.append({"socket": stray.get("socket", "?"), + "session": stray.get("session", "?")}) + line_of_item[idx] = len(lines) + marker = ">" if idx == self.rt_sel else " " + lines.append(( + "%s%-25s %-8s %-5s %-5s %-11s %-10s %-7s %s" % ( + marker, stray.get("session", "?")[:25], + "stray", + "YES", stray.get("pane", NA)[:5], + stray.get("state", NA)[:11], "", "", + (stray.get("cmd", "") or "")[:20]), "yellow")) + lines.append(("", "normal")) + lines.append(("Task queue: pending %d | claimed %s | done %d" + % (queue.get("pending", 0), claimed_txt[: w - 48], + queue.get("done", 0)), "cyan")) + plan_bits = [] + for key in ("launch", "brief", "nudge", "failed"): + names = plan.get(key, []) or [] + if names: + plan_bits.append("%s: %s" % (key, ", ".join(names))) + lines.append(("", "normal")) + lines.append(("Reconcile plan (dry-run): %s" + % ("; ".join(plan_bits) or "steady state"), "cyan")) + for err in data.get("plan_errors", []) or []: + lines.append(("plan error: %s" % str(err)[: w - 14], "red")) + if data.get("note"): + lines.append(("", "normal")) + lines.append(("note: %s" % data["note"][: w - 10], "yellow")) + self.rt_items = items + if self.rt_items: + self.rt_sel = max(0, min(self.rt_sel, len(self.rt_items) - 1)) + sel_line = line_of_item.get(self.rt_sel) + if sel_line is not None and sel_line < len(lines): + text, _ = lines[sel_line] + lines[sel_line] = (text, "row_sel") + self._body(h, w, "AGENT SOCKETS/RUNTIMES (j/k select, R reconcile, o attach)", lines) + + def _action_reconcile(self) -> None: + self.status_msg = "Running reconciler..." + self.stdscr.refresh() + ok, msg = do_reconcile_now() + self.refresh() + self.status_msg = msg + + def _action_attach(self) -> None: + if not self.rt_items: + self.status_msg = "Nothing to attach (no live sessions listed)" + return + self.rt_sel = max(0, min(self.rt_sel, len(self.rt_items) - 1)) + item = self.rt_items[self.rt_sel] + argv = attach_argv(item["socket"], item["session"]) + try: + curses.endwin() + r = subprocess.run(argv, timeout=86400) + rc = r.returncode + except Exception as e: + rc = -1 + err = str(e) + try: + self.stdscr.refresh() + self.stdscr.nodelay(True) + self.stdscr.keypad(True) + try: + curses.curs_set(0) + except Exception: + pass + except Exception: + pass + if rc == 0: + self.status_msg = "Detached from %s (r to refresh)" % item["session"] + elif rc == -1: + self.status_msg = "Attach failed: %s" % err + else: + self.status_msg = ("Attach to %s exited rc=%d " + "(session may be gone)" % (item["session"], rc)) + self.refresh() + + def _render_work(self, h: int, w: int) -> None: + data = self.snapshot.get("work", {}) + cog = data.get("cognitive", {}) + issues = data.get("issues", []) + loop_active = data.get("loopback_active", False) + + lines: List[Tuple[str, str]] = [ + ("COGNITIVE CONSENSUS & WORK PIPELINE | Loopback Daemon: %s" + % ("ACTIVE" if loop_active else "INACTIVE"), "cyan" if loop_active else "yellow"), + ("", "normal"), + ("%-6s %-16s %-8s %-24s %s" + % ("AGENT", "COGNITIVE STATE", "LOCK", "ACTIVE URL", "TITLE"), "dim"), + ] + + for agent in FLEET_AGENTS: + st = cog.get(agent, {}) + badge = st.get("badge") or st.get("status") or "UNKNOWN" + lock = "LOCKED" if st.get("cognitive_lock") else "OPEN" + scr = st.get("screen") or {} + url = scr.get("url") or "-" + title = scr.get("title") or "-" + lock_attr = "red" if lock == "LOCKED" else "green" + lines.append(( + "%-6s %-16s %-8s %-24s %s" % ( + agent, badge[:16], lock, url[:24], title[:w - 60] + ), lock_attr + )) + + lines.append(("", "normal")) + lines.append(("BUILD TICKETS (GITEA REPO: super/box)", "cyan")) + lines.append(("%-6s %-10s %-10s %-12s %s" + % ("ISSUE", "STATE", "ASSIGNEE", "CREATED", "TITLE"), "dim")) + if not issues: + lines.append(("(No recent build tickets found on tea.muse-dev.online)", "dim")) + else: + for idx, iss in enumerate(issues[:8]): + inum = "#%s" % iss.get("number") + istate = iss.get("state", "").upper() + asg = (iss.get("assignee") or {}).get("username") or "unassigned" + created = (iss.get("created_at") or "")[:10] + title = iss.get("title") or "" + marker = ">" if idx == self.work_sel else " " + i_attr = "green" if istate == "CLOSED" else "yellow" + lines.append(( + "%s%-5s %-10s %-10s %-12s %s" % ( + marker, inum, istate, asg[:10], created, title[:w - 45] + ), i_attr + )) + + self._body(h, w, "WORK PIPELINE & COGNITIVE CONSENSUS (W dispatch, F refocus, L sweep, H heal)", lines) + + def _action_dispatch_work(self) -> None: + self.status_msg = "Dispatching work ticket via box work..." + self.stdscr.refresh() + cmd = [sys.executable, str(BIN_DIR / "box-work.py"), "start", "--agent", "dev", "Run autonomous build task"] + rc, out = _run(cmd, timeout=30) + self.refresh() + first = out.splitlines()[0] if out else "Done" + self.status_msg = "Dispatch result: %s" % first[:60] + + def _action_refocus(self) -> None: + agent = FLEET_AGENTS[self.work_sel % len(FLEET_AGENTS)] + self.status_msg = "Refocusing @%s to Main Chat..." % agent.upper() + self.stdscr.refresh() + cmd = [sys.executable, str(BIN_DIR / "agent-cognitive-probe.py"), "nav-main", agent] + rc, out = _run(cmd, timeout=15) + self.refresh() + self.status_msg = "Refocus @%s: %s" % (agent.upper(), "OK (🟢 IDLE)" if rc == 0 else "FAILED") + + def _action_loopback(self) -> None: + self.status_msg = "Running cognitive loopback sweep..." + self.stdscr.refresh() + cmd = [sys.executable, str(BIN_DIR / "box-readback-loopback.py"), "sweep"] + rc, out = _run(cmd, timeout=30) + self.refresh() + self.status_msg = "Loopback sweep: %s" % ("Completed OK" if rc == 0 else "Completed with warnings") + + def _action_heal(self) -> None: + agent = FLEET_AGENTS[self.work_sel % len(FLEET_AGENTS)] + self.status_msg = "Auto-healing cognitive state for @%s..." % agent.upper() + self.stdscr.refresh() + cmd = [sys.executable, str(BIN_DIR / "box-work.py"), "heal", agent] + rc, out = _run(cmd, timeout=20) + self.refresh() + self.status_msg = "Heal @%s: %s" % (agent.upper(), "Restored" if rc == 0 else "Alerts") + + def _render_help(self, h: int, w: int) -> None: + modal_w = min(64, w - 6) + modal_h = 15 + top = (h - modal_h) // 2 + left = (w - modal_w) // 2 + for y in range(top, top + modal_h): + self.safe_addstr(y, left, " " * modal_w, self._attr("row_sel")) + self.safe_addstr(top, left, "+" + "-" * (modal_w - 2) + "+", + self._attr("cyan")) + for y in range(top + 1, top + modal_h - 1): + self.safe_addstr(y, left, "|", self._attr("cyan")) + self.safe_addstr(y, left + modal_w - 1, "|", self._attr("cyan")) + self.safe_addstr(top + modal_h - 1, left, "+" + "-" * (modal_w - 2) + "+", + self._attr("cyan")) + self.safe_addstr(top + 1, left + 3, "FLEET CONTROL HELP", + self._attr("cyan")) + for i, hint in enumerate([ + "1-7 / Tab: switch surfaces (1-5 read-only)", + "j/k / Up/Down: scroll (select on Runtimes & Work)", + "r: refresh snapshot now", + "R (Runtimes): run the reconciler now", + "o / Enter (Runtimes): attach to selection", + "W (Work): dispatch build ticket", + "F (Work): refocus agent to Main Chat", + "L (Work): loopback sweep", + "H (Work): heal agent", + "q / Esc: quit (Esc closes help first)", + "", + "Missing data shows as 'n/a' — never a crash.", + ]): + self.safe_addstr(top + 3 + i, left + 4, hint, self._attr("normal")) + + # -- input + main loop ------------------------------------------ + + def _handle_key(self, ch: int) -> bool: + if ch in (3, 4): # Ctrl+C / Ctrl+D + return False + if self.show_help: + if ch in (27, ord("q"), ord("Q"), ord("?")): + self.show_help = False + return True + if ch in (ord("q"), ord("Q")): + return False + if ch in (27, ord("?")): + self.show_help = True + return True + if ch in (ord("1"), ord("2"), ord("3"), ord("4"), ord("5"), + ord("6"), ord("7")): + self.current_tab = ch - ord("1") + self.scroll = 0 + return True + if ch == ord("\t"): + self.current_tab = (self.current_tab + 1) % len(self.tabs) + self.scroll = 0 + return True + if ch in (ord("j"), curses.KEY_DOWN): + if self.current_tab == self.RUNTIMES_TAB and self.rt_items: + self.rt_sel = min(len(self.rt_items) - 1, self.rt_sel + 1) + elif self.current_tab == self.WORK_TAB: + self.work_sel = min(7, self.work_sel + 1) + else: + self.scroll += 1 + return True + if ch in (ord("k"), curses.KEY_UP): + if self.current_tab == self.RUNTIMES_TAB and self.rt_items: + self.rt_sel = max(0, self.rt_sel - 1) + elif self.current_tab == self.WORK_TAB: + self.work_sel = max(0, self.work_sel - 1) + else: + self.scroll = max(0, self.scroll - 1) + return True + if ch == ord("r"): + self.refresh() + return True + if ch == ord("R"): + if self.current_tab == self.RUNTIMES_TAB: + self._action_reconcile() + else: + self.refresh() + return True + if ch in (ord("o"), ord("O"), 10, 13, curses.KEY_ENTER): + if self.current_tab == self.RUNTIMES_TAB: + self._action_attach() + return True + if ch in (ord("w"), ord("W")): + if self.current_tab == self.WORK_TAB: + self._action_dispatch_work() + return True + if ch in (ord("f"), ord("F")): + if self.current_tab == self.WORK_TAB: + self._action_refocus() + return True + if ch in (ord("l"), ord("L")): + if self.current_tab == self.WORK_TAB: + self._action_loopback() + return True + if ch in (ord("h"), ord("H")): + if self.current_tab == self.WORK_TAB: + self._action_heal() + return True + return True + + def run(self) -> None: + renderers = [self._render_timers, self._render_harvest, + self._render_followups, self._render_approvals, + self._render_activity, self._render_runtimes, + self._render_work] + while True: + h, w = self.stdscr.getmaxyx() + self.stdscr.erase() + self._render_header(w) + try: + renderers[self.current_tab](h, w) + except Exception as e: + self.safe_addstr(5, 4, "Render error (n/a): %s" % e, + self._attr("red")) + self._render_footer(h, w) + if self.show_help: + self._render_help(h, w) + self.stdscr.refresh() + try: + ch = self.stdscr.getch() + if ch != -1 and not self._handle_key(ch): + break + except KeyboardInterrupt: + break + if time.time() - self.last_refresh > AUTO_REFRESH_S: + self.refresh() + time.sleep(0.05) + + +def main(argv: Optional[List[str]] = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if "--once" in argv: + snap = gather_all() + if "--json" in argv: + print(json.dumps(snap, indent=2, default=str)) + else: + for surface, data in snap.items(): + print("== %s ==" % surface.upper()) + print(json.dumps(data, indent=2, default=str)[:2000]) + return 0 + curses.wrapper(lambda stdscr: BoxFleetTUI(stdscr).run()) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/muse-tui.py b/bin/muse-tui.py new file mode 100755 index 0000000..9d3e94d --- /dev/null +++ b/bin/muse-tui.py @@ -0,0 +1,8053 @@ +#!/usr/bin/env python3 +""" +muse-tui.py — High-Performance Terminal User Interface for Muse Agents & NetVM Fleet Command. + +Dual-mode interface: +1. Muse Mode (Default for 'muse tui'): + - Multi-agent conversational terminal (muse, pip, 646, opm, def, dev) + - Real-time sidechat / thread navigation and creation + - Live streaming transcript with syntax highlighting (User, Agent, Tool calls, Work Orders) + - In-terminal Work Order generator (/wo) with cryptographic signing + - Instant approval detection and 1-key resolution drawer (Allow Once, Always Allow, Deny) + +2. Box Mode (Default for 'box tui' or toggled via F5/Tab): + - Fleet Vitality & CDP Latency monitor + - Fleet-wide Approvals Center + - Job Scheduler & Dispatch trigger + - Background Tmux sessions & Swarm worker monitor + - Live Event & DM log tailer + - Container SSH tunnel health & tmux pop-out dialer +""" + +import sys +import os +import json +import time +import curses +import threading +import subprocess +import hashlib +import select +import shlex +import signal +import textwrap +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +import re + +# Paths +REPO_ROOT = Path("/home/super/Projects/NetVM") +BIN_DIR = REPO_ROOT / "bin" +if str(BIN_DIR) not in sys.path: + sys.path.insert(0, str(BIN_DIR)) + +# Dynamic node registry loader +VALID_NODES = ["muse", "pip", "646", "opm", "def", "dev"] +try: + import importlib.util + spec = importlib.util.spec_from_file_location("netvm_registry", str(BIN_DIR / "netvm-registry.py")) + netvm_registry = importlib.util.module_from_spec(spec) + spec.loader.exec_module(netvm_registry) + REGISTRY_NODES = list(netvm_registry.load().keys()) + if REGISTRY_NODES: + VALID_NODES = REGISTRY_NODES +except Exception: + netvm_registry = None + +# Try importing approvals engine +try: + import approvals as approvals_engine +except ImportError: + approvals_engine = None + +# Try importing cognitive sensing and box work engines +try: + import agent_cognitive_probe as acp +except ImportError: + acp = None + +try: + import box_work +except ImportError: + box_work = None + + +def copy_to_clipboard(text: str) -> bool: + """Copy text to system clipboard using desktop utilities and ANSI OSC 52.""" + if not text: + return False + success = False + + # 1. Desktop clipboard utilities + for cmd in ( + ["wl-copy"], + ["xclip", "-selection", "clipboard"], + ["xsel", "--clipboard", "--input"], + ["pbcopy"], + ): + try: + p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL) + p.communicate(input=text.encode("utf-8"), timeout=1.0) + if p.returncode == 0: + success = True + break + except (FileNotFoundError, Exception): + continue + + # 2. OSC 52 ANSI escape sequence (works over SSH, tmux, modern terminals) + try: + import base64 + b64 = base64.b64encode(text.encode("utf-8")).decode("ascii") + if os.environ.get("TMUX"): + osc52 = f"\033Ptmux;\033\033]52;c;{b64}\007\033\\" + else: + osc52 = f"\033]52;c;{b64}\007" + sys.stdout.write(osc52) + sys.stdout.flush() + success = True + except Exception: + pass + + return success + + +def strip_message_metadata(text: str) -> str: + """ + Strip extra reply tags, header info, and unquote quoted reply lines. + Leaves the substantive text clean line-by-line. + """ + if not text: + return "" + lines = text.split("\n") + # Check if message starts with a reply quote block: '> Replying to ...:' + if lines and re.match(r"^>\s*Replying to\s+.*:\s*$", lines[0].strip(), re.IGNORECASE): + # Discard the header line + lines = lines[1:] + # Strip leading quote mark '> ' from quoted lines until an empty line separates them + cleaned_lines = [] + for line in lines: + if line.startswith("> "): + cleaned_lines.append(line[2:]) + elif line.startswith(">"): + cleaned_lines.append(line[1:]) + else: + cleaned_lines.append(line) + text = "\n".join(cleaned_lines) + return text.strip() + + +def run_command_isolated(cmd: list, timeout: float = 12.0) -> tuple[int, str, str]: + """Execute command in a dedicated process group to prevent zombie subprocesses on timeout.""" + try: + p = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + try: + stdout, stderr = p.communicate(timeout=timeout) + return p.returncode, stdout, stderr + except subprocess.TimeoutExpired: + try: + os.killpg(p.pid, signal.SIGKILL) + except Exception: + pass + try: + p.communicate(timeout=0.5) + except Exception: + pass + return -1, "", "Process timed out and was terminated" + except Exception as e: + return -1, "", str(e) + + +def format_recency(ts: float) -> str: + """Format an epoch timestamp into a compact human recency string (e.g. 5m ago, 2h ago).""" + if not ts or ts <= 0: + return "never" + diff = time.time() - ts + if diff < 0: + return "now" + if diff < 60: + return f"{int(diff)}s ago" + elif diff < 3600: + return f"{int(diff // 60)}m ago" + elif diff < 86400: + return f"{int(diff // 3600)}h ago" + else: + return f"{int(diff // 86400)}d ago" + return "never" + + +# --------------------------------------------------------------------------- +# SSH / Container Tunnel Management Subsystem +# --------------------------------------------------------------------------- +SSH_JUMP_HOST = os.environ.get("SSH_JUMP_HOST", "34.139.37.135") +SSH_OPERATOR_USER = os.environ.get("OPERATOR_USER", "super") +SSH_IDENTITY_FILE = os.environ.get("SSH_IDENTITY_FILE", "") + +try: + from agent_md import TUNNEL_PORTS as SSH_TUNNEL_PORTS +except Exception: + SSH_TUNNEL_PORTS = { + "muse-main": {"port": 2224, "terminal": 7681, "user": "muse"}, + "muse": {"port": 2225, "terminal": 7682, "user": "hatch"}, + "646": {"port": 2226, "terminal": 7683, "user": "hatch"}, + "pip": {"port": 2227, "terminal": 7684, "user": "hatch"}, + "opm": {"port": 2228, "terminal": 7685, "user": "hatch"}, + "def": {"port": 2229, "terminal": 7686, "user": "hatch"}, + "dev": {"port": 2230, "terminal": 7687, "user": "hatch"}, + } + + +def build_ssh_dial_command(account: str, port=None, user=None, jump_host=None, + operator_user=None, identity_file=None, + ssh_options=None, remote_command=None) -> list: + """Build the jump-host dial argv for an agent container. + + ssh_options are inserted before the destination; remote_command (str or + list) is appended after it for non-interactive probes. + """ + info = SSH_TUNNEL_PORTS.get(account, {}) + port = port or info.get("port") + user = user or info.get("user", "hatch") + jump_host = jump_host or SSH_JUMP_HOST + operator_user = operator_user or SSH_OPERATOR_USER + if identity_file is None: + identity_file = SSH_IDENTITY_FILE + cmd = ["ssh", "-o", "StrictHostKeyChecking=no"] + if identity_file: + cmd += ["-o", "IdentitiesOnly=yes", "-i", identity_file] + if ssh_options: + cmd += list(ssh_options) + cmd += ["-J", f"{operator_user}@{jump_host}", "-p", str(port), f"{user}@localhost"] + if remote_command: + cmd += [remote_command] if isinstance(remote_command, str) else list(remote_command) + return cmd + + +def build_ssh_dial_string(account: str, **kwargs) -> str: + """Shell-quoted dial command for display, clipboard copy, and pop-out.""" + return " ".join(shlex.quote(p) for p in build_ssh_dial_command(account, **kwargs)) + + +def build_ssh_popout_shell(account: str, **kwargs) -> str: + """Interactive shell line for the pop-out window: ssh, then keep a shell.""" + dial = build_ssh_dial_string(account, **kwargs) + return f"{dial}; echo '[ssh exited ($?) — window kept open, exit to close]'; exec \"${{SHELL:-/bin/bash}}\"" + + +def build_tmux_popout_command(label: str, shell_command: str, socket_path: str = None) -> list: + """Build `tmux new-window` argv opening shell_command in a fresh window.""" + safe_label = re.sub(r"[^A-Za-z0-9_.-]", "-", label)[:32] or "ssh" + cmd = ["tmux"] + if socket_path: + cmd += ["-S", socket_path] + return cmd + ["new-window", "-n", safe_label, shell_command] + + +def ssh_row_order(nodes: list, extra_accounts=()) -> list: + """Fleet nodes first, then any extra tunnel accounts (e.g. muse-main).""" + rows = list(nodes) + for acct in extra_accounts: + if acct not in rows: + rows.append(acct) + for acct in SSH_TUNNEL_PORTS: + if acct not in rows: + rows.append(acct) + return rows + + +# --------------------------------------------------------------------------- +# Prompt & Skill Library Subsystem +# --------------------------------------------------------------------------- +DEFAULT_PROMPTS = [ + { + "id": "audit", + "title": "Fleet Audit", + "prompt": "Check all fleet nodes for blocked states and summarize current task loops.", + "tags": ["fleet", "health"], + "pinned": True, + "created_at": 1718000000.0, + }, + { + "id": "wo", + "title": "Work Order", + "prompt": "Review recent logs and formulate a formal work order for the active issue.", + "tags": ["task", "ops"], + "pinned": True, + "created_at": 1718000001.0, + }, + { + "id": "cdp", + "title": "CDP Inspect", + "prompt": "Inspect active CDP ports and page states across all nodes.", + "tags": ["cdp", "debug"], + "pinned": True, + "created_at": 1718000002.0, + }, + { + "id": "digest", + "title": "Loop Digest", + "prompt": "Provide a concise bullet-point digest of current work in progress and blockers.", + "tags": ["digest", "summary"], + "pinned": False, + "created_at": 1718000003.0, + }, + { + "id": "review", + "title": "Code Review", + "prompt": "Review recent code diffs, verify correctness, and check for potential regressions.", + "tags": ["code", "review"], + "pinned": False, + "created_at": 1718000004.0, + }, + { + "id": "tests", + "title": "Run Tests", + "prompt": "Execute the test suite and report any test failures with root cause analysis.", + "tags": ["test", "qa"], + "pinned": False, + "created_at": 1718000005.0, + }, + { + "id": "status", + "title": "Agent Status", + "prompt": "Report detailed status, active subtasks, and pending approvals for this node.", + "tags": ["agent", "status"], + "pinned": False, + "created_at": 1718000006.0, + }, +] + + +class PromptManager: + """Manages persistent prompt templates and skills stored in prompts.json.""" + + def __init__(self, storage_path: Path = None): + self.storage_path = storage_path or (Path.home() / ".config" / "muse-cli" / "prompts.json") + self.prompts = [] + self._load_prompts() + + def _load_prompts(self): + """Load prompts from disk or initialize with built-in defaults.""" + if self.storage_path.exists(): + try: + data = json.loads(self.storage_path.read_text(encoding="utf-8")) + if isinstance(data, list) and data: + self.prompts = data + return + except Exception: + pass + # Initialize defaults + self.prompts = [dict(p) for p in DEFAULT_PROMPTS] + self._save_prompts() + + def _save_prompts(self): + """Persist prompts to disk.""" + try: + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + self.storage_path.write_text(json.dumps(self.prompts, indent=2), encoding="utf-8") + except Exception: + pass + + def get_sorted_prompts(self) -> list: + """Return prompts with pinned items first, then remaining items in order.""" + return sorted(self.prompts, key=lambda p: (not p.get("pinned", False), -p.get("created_at", 0.0))) + + def add_prompt(self, title: str, prompt_text: str, tags: list = None, pid: str = None, pinned: bool = False) -> dict: + """Formulate and store a new prompt skill.""" + pid = pid or re.sub(r"[^a-zA-Z0-9_-]", "", title.lower().replace(" ", "_"))[:16] or f"p_{int(time.time()*1000)%100000}" + prompt_item = { + "id": pid, + "title": title.strip(), + "prompt": prompt_text.strip(), + "tags": tags or ["custom"], + "pinned": pinned, + "created_at": time.time(), + } + for idx, p in enumerate(self.prompts): + if p.get("id") == pid: + self.prompts[idx] = prompt_item + self._save_prompts() + return prompt_item + + self.prompts.insert(0 if pinned else len(self.prompts), prompt_item) + self._save_prompts() + return prompt_item + + def pin_chat_send(self, text: str, node: str = "muse", title: str = None, toggle: bool = False) -> tuple[dict, bool]: + """Pin a sent chat message to the prompt fill / list. If toggle=True, unpins if already pinned.""" + clean_text = text.strip() + for p in self.prompts: + if p.get("prompt", "").strip() == clean_text: + if toggle: + p["pinned"] = not p.get("pinned", False) + else: + p["pinned"] = True + self._save_prompts() + return p, False + + first_line = clean_text.split("\n")[0].strip() + derived_title = first_line[:24] + ("…" if len(first_line) > 24 else "") if not title else title + pid = f"pin_{int(time.time()*1000) % 1000000}" + prompt_item = { + "id": pid, + "title": derived_title, + "prompt": clean_text, + "tags": ["pinned", node], + "pinned": True, + "created_at": time.time(), + } + self.prompts.insert(0, prompt_item) + self._save_prompts() + return prompt_item, True + + def toggle_pin_chat_send(self, text: str, node: str = "muse", title: str = None) -> tuple[dict, bool]: + """Toggle pinned status for a sent chat message in prompts.""" + return self.pin_chat_send(text, node=node, title=title, toggle=True) + + def toggle_pin_prompt(self, pid: str) -> bool: + """Toggle pinned state for a prompt. Returns new state.""" + for p in self.prompts: + if p.get("id") == pid: + p["pinned"] = not p.get("pinned", False) + self._save_prompts() + return p["pinned"] + return False + + def delete_prompt(self, pid: str) -> bool: + """Delete a prompt by ID.""" + for idx, p in enumerate(self.prompts): + if p.get("id") == pid: + self.prompts.pop(idx) + self._save_prompts() + return True + return False + + +# --------------------------------------------------------------------------- +# Box Mode Tab Bar (single source of truth for renderer + click handler) +# --------------------------------------------------------------------------- +BOX_TABS = [ + "1: Agent Chat", + "2: Fleet Status", + "3: Approvals", + "4: Jobs Scheduler", + "5: Tmux / Swarms", + "6: DM Logs", + "7: SSH / Boxes", + "8: Work Pipeline", +] + + +def box_tab_bounds(tabs=None, x: int = 0) -> list: + """Clickable x-ranges for the Box tab bar, mirroring _render_box_tabs.""" + bounds = [] + cur_x = x + 1 + for tab_name in (tabs if tabs is not None else BOX_TABS): + label = f" [{tab_name}] " + bounds.append((cur_x, cur_x + len(label) - 1)) + cur_x += len(label) + 1 + return bounds + + +# --------------------------------------------------------------------------- +# Data Layer & Async Poller +# --------------------------------------------------------------------------- +class FleetDataManager: + """Thread-safe background data fetcher for fleet state, chat, and approvals.""" + + def __init__(self, start_poller: bool = None): + self.lock = threading.RLock() + self.nodes = list(VALID_NODES) + self.active_node = "muse" + self.active_thread_id = None + self.active_thread_title = "" + + # Background worker control & adaptive activity tracking + self.running = True + self.refresh_event = threading.Event() + self.user_poll_trigger = threading.Event() + self.last_user_activity = time.time() + self.rate_limit_cooldown = {} # node -> float epoch timestamp until cooldown ends + self.rate_limit_consecutive = {n: 0 for n in self.nodes} # node -> consecutive 429 count + self.history_fetch_times = {} # (node, thread_id) -> float epoch timestamp of last successful fetch + + # Cached state + self.node_status = {n: {"status": "ACTIVE", "latency": None, "tab": "", "blocked": False, "rate_limited": False} for n in self.nodes} + self.threads_cache = {n: [] for n in self.nodes} + self.history_cache = {} # (node, thread_id) -> list of msgs + self.approvals_cache = [] # list of approval dicts + self.jobs_cache = [] + self.tmux_cache = [] + self.dm_logs_cache = [] + + # SSH / container tunnel health (Box tab 7) + self.ssh_cache = {} # account -> health dict from ssh-check + state_since + self.ssh_jump_reachable = None # None = never checked + self.ssh_checked_at = 0.0 + self.ssh_check_latency_ms = None + self.ssh_check_error = "" + + # Cognitive sensing & Work Pipeline caches (Box tab 8 & Fleet matrix) + self.cognitive_cache = {} + self.work_issues_cache = [] + self.work_prs_cache = [] + self.last_work_poll = 0.0 + + # Interaction ranking: node -> float timestamp of last true input / chat [insert] + self.agent_interactions = {n: 0.0 for n in self.nodes} + self._load_agent_interactions() + + # Dynamic & lazy loading state + self.loading_threads = set() # set of node names currently being fetched + self.loaded_nodes = set() # set of node names that completed at least one fetch attempt + self.fetch_times = {} # node -> float timestamp of last fetch + self.loading_history = set() # set of (node, thread_id) currently fetching history + # Pinned chats map: node -> set of session_ids + self.pinned_threads = {n: set() for n in self.nodes} + # Read state map: (node, session_id) -> int (number of messages read) + self.read_msg_counts = {} + self._load_pinned_threads() + self._load_history_cache() + self._load_read_state() + self._init_threads_cache() + self._seed_interactions_from_threads() + self.sort_nodes() + if self.nodes: + self.active_node = self.nodes[0] + self.select_main_chat(self.active_node) + + # In unit tests, avoid auto-spawning background worker threads that hit network + in_test = bool( + os.environ.get("BOX_TESTS_NESTED") or + os.environ.get("MUSE_NO_POLLER") or + "unittest" in sys.modules or + any("unittest" in arg or "pytest" in arg for arg in sys.argv) + ) + if start_poller is None: + start_poller = not in_test + + if start_poller: + self.preload_priority_chats(self.active_node, sidechat_limit=0) + self.poller_thread = threading.Thread(target=self._worker_loop, daemon=True) + self.poller_thread.start() + # First SSH sweep in background so Box tab 7 is warm on open + threading.Thread(target=self._fetch_ssh_health, daemon=True).start() + else: + self.poller_thread = None + + def record_activity(self): + """Record user input/interaction to keep poll interval in fast/active mode.""" + self.last_user_activity = time.time() + + def is_node_rate_limited(self, node: str) -> bool: + """Check if node is currently in rate-limit cooldown window.""" + if not node: + return False + until = self.rate_limit_cooldown.get(node, 0.0) + return time.time() < until + + def get_node_cooldown_remaining(self, node: str) -> int: + """Return remaining seconds in rate-limit cooldown (0 if not rate limited).""" + until = self.rate_limit_cooldown.get(node, 0.0) + rem = until - time.time() + return max(0, int(rem)) + + def mark_node_rate_limited(self, node: str, cooldown_seconds: float = None, reason: str = "", retry_after: float = None): + """Place node into rate-limit cooldown window using tiered backoff (30s -> 60s -> 120s max).""" + if retry_after is not None and retry_after > 0: + cd = retry_after + elif cooldown_seconds is not None: + cd = cooldown_seconds + else: + consec = self.rate_limit_consecutive.get(node, 0) + self.rate_limit_consecutive[node] = consec + 1 + cd = min(120.0, 30.0 * (2 ** consec)) # 30s -> 60s -> 120s max + + self.rate_limit_cooldown[node] = time.time() + cd + with self.lock: + if node in self.node_status: + self.node_status[node]["rate_limited"] = True + + def clear_node_rate_limit(self, node: str): + """Reset rate limit cooldown and consecutive counter upon successful response or explicit override.""" + self.rate_limit_cooldown[node] = 0.0 + self.rate_limit_consecutive[node] = 0 + with self.lock: + if node in self.node_status: + self.node_status[node]["rate_limited"] = False + + def _load_pinned_threads(self): + """Load pinned thread IDs from ~/.config/muse-cli/pinned_threads.json.""" + pinned_file = Path.home() / ".config" / "muse-cli" / "pinned_threads.json" + if pinned_file.exists(): + try: + data = json.loads(pinned_file.read_text(encoding="utf-8")) + for n, sids in data.items(): + if n in self.nodes and isinstance(sids, list): + self.pinned_threads[n] = set(sids) + except Exception: + pass + + def _save_pinned_threads(self): + """Persist pinned thread IDs to disk.""" + pinned_file = Path.home() / ".config" / "muse-cli" / "pinned_threads.json" + try: + pinned_file.parent.mkdir(parents=True, exist_ok=True) + data = {n: list(self.pinned_threads.get(n, set())) for n in self.nodes} + pinned_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + except Exception: + pass + + def _load_agent_interactions(self): + """Load interaction timestamps from ~/.config/muse-cli/agent_interactions.json.""" + int_file = Path.home() / ".config" / "muse-cli" / "agent_interactions.json" + if int_file.exists(): + try: + data = json.loads(int_file.read_text(encoding="utf-8")) + if isinstance(data, dict): + for n, ts in data.items(): + if n in self.nodes and isinstance(ts, (int, float)): + self.agent_interactions[n] = float(ts) + except Exception: + pass + + def _save_agent_interactions(self): + """Persist interaction timestamps to disk outside lock.""" + int_file = Path.home() / ".config" / "muse-cli" / "agent_interactions.json" + try: + int_file.parent.mkdir(parents=True, exist_ok=True) + with self.lock: + data = {n: self.agent_interactions.get(n, 0.0) for n in self.nodes} + payload = json.dumps(data, indent=2) + int_file.write_text(payload, encoding="utf-8") + except Exception: + pass + + def _seed_interactions_from_threads(self): + """Seed interactions from cached thread updated times for any nodes with no record.""" + with self.lock: + for n in self.nodes: + if self.agent_interactions.get(n, 0.0) == 0.0: + threads = self.threads_cache.get(n, []) + newest = 0.0 + for t in threads: + up_str = t.get("updated") + if up_str: + try: + dt = datetime.fromisoformat(up_str) + ts = dt.timestamp() + if ts > newest: + newest = ts + except Exception: + pass + if newest > 0.0: + self.agent_interactions[n] = newest + + def sort_nodes(self): + """Sort fleet agents by: + 1. Highest unread messages total (descending) + 2. Most recent direct operator interaction (descending) + 3. Baseline order from VALID_NODES.""" + with self.lock: + baseline = {n: i for i, n in enumerate(VALID_NODES)} + self.nodes.sort( + key=lambda n: ( + self.get_node_unread_total(n), + self.agent_interactions.get(n, 0.0), + -baseline.get(n, 999) + ), + reverse=True + ) + + def record_interaction(self, node: str, ts: float = None): + """Record direct operator interaction with an agent via true input / chat [insert].""" + if node not in self.nodes: + return + now = ts if ts is not None else time.time() + with self.lock: + self.agent_interactions[node] = now + self._save_agent_interactions() + self.sort_nodes() + self.trigger_refresh() + + def toggle_pin_thread(self, node: str, session_id: str) -> bool: + """Toggle pinned state for a thread. Returns new pinned state.""" + with self.lock: + pset = self.pinned_threads.setdefault(node, set()) + if session_id in pset: + pset.discard(session_id) + new_state = False + else: + pset.add(session_id) + new_state = True + self._save_pinned_threads() + if node in self.threads_cache: + self.threads_cache[node] = self.sort_threads(node, self.threads_cache[node]) + self._save_threads_cache() + self.trigger_refresh() + return new_state + + def _load_history_cache(self): + """Seed history cache from ~/.config/muse-cli/history_cache.json.""" + hist_file = Path.home() / ".config" / "muse-cli" / "history_cache.json" + if hist_file.exists(): + try: + data = json.loads(hist_file.read_text(encoding="utf-8")) + for k, msgs in data.items(): + if "/" in k and isinstance(msgs, list): + n, tid = k.split("/", 1) + if tid == "None": + tid = None + if n in self.nodes: + self.history_cache[(n, tid)] = msgs + except Exception: + pass + + def _save_history_cache(self): + """Persist history cache to ~/.config/muse-cli/history_cache.json.""" + hist_file = Path.home() / ".config" / "muse-cli" / "history_cache.json" + try: + hist_file.parent.mkdir(parents=True, exist_ok=True) + with self.lock: + data = {f"{k[0]}/{k[1]}": list(msgs) for k, msgs in self.history_cache.items()} + payload = json.dumps(data, indent=1) + hist_file.write_text(payload, encoding="utf-8") + except Exception: + pass + + def _load_read_state(self): + """Load read message counts from ~/.config/muse-cli/read_state.json.""" + read_file = Path.home() / ".config" / "muse-cli" / "read_state.json" + if read_file.exists(): + try: + data = json.loads(read_file.read_text(encoding="utf-8")) + if isinstance(data, dict): + for k, count in data.items(): + if "/" in k and isinstance(count, int): + n, tid = k.split("/", 1) + if n in self.nodes: + self.read_msg_counts[(n, tid)] = max(0, count) + except Exception: + pass + + def _save_read_state(self): + """Persist read message counts to disk.""" + read_file = Path.home() / ".config" / "muse-cli" / "read_state.json" + try: + read_file.parent.mkdir(parents=True, exist_ok=True) + with self.lock: + data = {f"{k[0]}/{k[1]}": count for k, count in self.read_msg_counts.items()} + read_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + except Exception: + pass + + def mark_thread_read(self, node: str, thread_id: str, count: int = None): + """Mark a thread/sidechat as read up to count or current history message total.""" + if not node: + return + key = (node, thread_id) + with self.lock: + if count is not None: + cur_total = count + else: + cur_total = len(self.history_cache.get(key, [])) + old_count = self.read_msg_counts.get(key, 0) + if cur_total >= old_count: + self.read_msg_counts[key] = cur_total + self._save_read_state() + self.trigger_refresh() + + def mark_node_read(self, node: str): + """Mark all threads/sidechats for a node as read.""" + if not node: + return + with self.lock: + threads = list(self.threads_cache.get(node, [])) + for t in threads: + sid = t.get("session_id") + if sid: + key = (node, sid) + total = len(self.history_cache.get(key, [])) + old_count = self.read_msg_counts.get(key, 0) + if total >= old_count: + self.read_msg_counts[key] = total + self._save_read_state() + self.trigger_refresh() + + def mark_fleet_read(self): + """Mark all threads across all agents in the fleet as read.""" + with self.lock: + for node in self.nodes: + threads = list(self.threads_cache.get(node, [])) + for t in threads: + sid = t.get("session_id") + if sid: + key = (node, sid) + total = len(self.history_cache.get(key, [])) + old_count = self.read_msg_counts.get(key, 0) + if total >= old_count: + self.read_msg_counts[key] = total + self._save_read_state() + self.trigger_refresh() + + def get_unread_count(self, node: str, thread_id: str) -> int: + """Get number of unread/new messages in a thread.""" + key = (node, thread_id) + with self.lock: + total = len(self.history_cache.get(key, [])) + read_cnt = self.read_msg_counts.get(key, 0) + return max(0, total - read_cnt) + + def get_node_unread_total(self, node: str) -> int: + """Get total unread messages across all threads/sidechats for an agent.""" + total_unread = 0 + with self.lock: + threads = list(self.threads_cache.get(node, [])) + for t in threads: + sid = t.get("session_id") + if sid: + total_unread += self.get_unread_count(node, sid) + return total_unread + + def get_fleet_unread_total(self) -> int: + """Get grand total of unread messages across all agents in the fleet.""" + return sum(self.get_node_unread_total(n) for n in self.nodes) + + def sort_threads(self, node: str, thread_list: list) -> list: + """Sort threads with Main Chat first (prio 0), Pinned chats second (prio 1), then recency (prio 2).""" + pinned_set = self.pinned_threads.get(node, set()) + mains = [] + pinned = [] + regular = [] + + for t in thread_list: + if not isinstance(t, dict) or not t.get("session_id"): + continue + sid = t.get("session_id") + title_lower = (t.get("title") or "").lower() + is_main = ( + t.get("thread") is False or + t.get("is_main") is True or + title_lower in ("main chat", "main", "start conversation with muse") + ) + is_pin = sid in pinned_set + + t["is_main"] = is_main + t["pinned"] = is_pin + + if is_main: + mains.append(t) + elif is_pin: + pinned.append(t) + else: + regular.append(t) + + mains.sort(key=lambda x: x.get("updated") or "", reverse=True) + pinned.sort(key=lambda x: x.get("updated") or "", reverse=True) + regular.sort(key=lambda x: x.get("updated") or "", reverse=True) + + return mains + pinned + regular + + def preload_priority_chats(self, node: str = None, sidechat_limit: int = 15): + """Proactively fetch and cache message history for Main Chat, pinned chats, and recent sidechats.""" + nodes_to_preload = [node] if node else self.nodes + for n in nodes_to_preload: + with self.lock: + threads = list(self.threads_cache.get(n, [])) + side_cnt = 0 + for t in threads: + sid = t.get("session_id") + if not sid: + continue + is_prio = t.get("is_main") or t.get("pinned") + if is_prio: + threading.Thread(target=self._fetch_history, args=(n, sid), daemon=True).start() + elif side_cnt < sidechat_limit: + side_cnt += 1 + threading.Thread(target=self._fetch_history, args=(n, sid), daemon=True).start() + + def _init_threads_cache(self): + """Seed threads cache from persistent disk cache and job-sidechats.json.""" + # 1. Seed from job-sidechats.json + job_chats_file = REPO_ROOT / "job-sidechats.json" + if job_chats_file.exists(): + try: + with open(job_chats_file, "r", encoding="utf-8") as f: + js_data = json.load(f) + for k, v in js_data.items(): + if not isinstance(v, dict): + continue + agent = v.get("agent") + if agent not in self.nodes: + agent = "muse" + sid = v.get("thread_uuid") + title = v.get("description") or k + if sid: + existing_ids = {t.get("session_id") for t in self.threads_cache[agent]} + if sid not in existing_ids: + self.threads_cache[agent].append({"session_id": sid, "title": title}) + except Exception: + pass + + # 2. Seed from ~/.config/muse-cli/threads_cache.json if present (higher precedence / recent) + cache_file = Path.home() / ".config" / "muse-cli" / "threads_cache.json" + if cache_file.exists(): + try: + with open(cache_file, "r", encoding="utf-8") as f: + disk_cache = json.load(f) + if isinstance(disk_cache, dict): + for n, t_list in disk_cache.items(): + if n in self.nodes and isinstance(t_list, list): + live_ids = {t.get("session_id") for t in t_list if isinstance(t, dict) and t.get("session_id")} + merged = [t for t in t_list if isinstance(t, dict) and t.get("session_id")] + for old_t in self.threads_cache.get(n, []): + if old_t.get("session_id") not in live_ids: + merged.append(old_t) + self.threads_cache[n] = merged + except Exception: + pass + + # Seed pinned_threads from threads_cache if pinned_threads is empty + for n in self.nodes: + if not self.pinned_threads.get(n): + for t in self.threads_cache.get(n, []): + if t.get("pinned") and t.get("session_id"): + self.pinned_threads.setdefault(n, set()).add(t["session_id"]) + + # Sort all cached threads so Main Chat and pinned chats are at top + for n in self.nodes: + if n in self.threads_cache: + self.threads_cache[n] = self.sort_threads(n, self.threads_cache[n]) + + # Set default active thread for initial active_node if available (now Main Chat!) + self.select_main_chat(self.active_node) + + def select_main_chat(self, node: str = None) -> tuple: + """Select Main Chat for the specified node or current active node.""" + with self.lock: + target_node = node or self.active_node + self.active_node = target_node + node_threads = self.threads_cache.get(target_node, []) + main_t = next((t for t in node_threads if t.get("is_main")), (node_threads[0] if node_threads else None)) + if main_t: + self.active_thread_id = main_t.get("session_id") + self.active_thread_title = main_t.get("title") or "Main Chat" + else: + self.active_thread_id = None + self.active_thread_title = "Main Chat" + return self.active_thread_id, self.active_thread_title + + def _save_threads_cache(self): + cache_file = Path.home() / ".config" / "muse-cli" / "threads_cache.json" + try: + cache_file.parent.mkdir(parents=True, exist_ok=True) + with self.lock: + data_copy = {n: list(self.threads_cache.get(n, [])) for n in self.nodes} + payload = json.dumps(data_copy, indent=2) + cache_file.write_text(payload, encoding="utf-8") + except Exception: + pass + + def is_loading_threads(self, node: str) -> bool: + with self.lock: + return node in self.loading_threads + + def is_loading_history(self, node: str, thread_id: str) -> bool: + with self.lock: + return (node, thread_id) in self.loading_history + + def stop(self): + self.running = False + if hasattr(self, 'user_poll_trigger') and self.user_poll_trigger: + self.user_poll_trigger.set() + if hasattr(self, 'refresh_event') and self.refresh_event: + self.refresh_event.set() + + def trigger_refresh(self): + """User or explicit action requested an immediate poll cycle.""" + if hasattr(self, 'user_poll_trigger') and self.user_poll_trigger: + self.user_poll_trigger.set() + if hasattr(self, 'refresh_event') and self.refresh_event: + self.refresh_event.set() + + def set_active_target(self, node: str, thread_id: str = None, thread_title: str = None): + with self.lock: + node_changed = (node in self.nodes and node != self.active_node) + if node in self.nodes: + self.active_node = node + if thread_id is not None: + self.active_thread_id = thread_id + if thread_title is not None: + self.active_thread_title = thread_title + else: + found_title = None + for t in self.threads_cache.get(node, []): + if t.get("session_id") == thread_id: + found_title = t.get("title") + break + self.active_thread_title = found_title or thread_id[:8] + elif node_changed or not self.active_thread_id: + self.select_main_chat(node) + elif thread_title is not None: + self.active_thread_title = thread_title + self.record_activity() + self.lazy_fetch_threads(node) + self.trigger_refresh() + + def lazy_fetch_threads(self, node: str, force: bool = False): + """Lazily trigger background fetch of threads for a node if needed.""" + with self.lock: + if node not in self.nodes or self.is_node_rate_limited(node): + return + if node in self.loading_threads: + return + now = time.time() + last_fetch = self.fetch_times.get(node, 0.0) + if not force and node in self.loaded_nodes and (now - last_fetch) < 20.0: + return + self.loading_threads.add(node) + + threading.Thread(target=self._run_fetch_threads, args=(node,), daemon=True).start() + + def _worker_loop(self): + """Periodic background data refresh with adaptive backoff and rate limit protections.""" + last_fast = 0.0 + last_med = 0.0 + last_slow = 0.0 + last_fleet_approvals = 0.0 + last_ssh = 0.0 + + # Initial fetch of active node main chat only + with self.lock: + init_node = self.active_node + if init_node and not self.is_node_rate_limited(init_node): + self.lazy_fetch_threads(init_node) + self._fetch_history(init_node, None) + + while self.running: + now = time.time() + user_triggered = False + if hasattr(self, 'user_poll_trigger') and self.user_poll_trigger.is_set(): + user_triggered = True + self.user_poll_trigger.clear() + + # Adaptive fast polling cadence: + # Active (input/interaction within 20s): 2.5s + # Idling (20s - 60s): 6.0s + # Long idle (>60s): 15.0s + idle_seconds = now - getattr(self, "last_user_activity", 0.0) + if idle_seconds < 20.0: + fast_cadence = 2.5 + elif idle_seconds < 60.0: + fast_cadence = 6.0 + else: + fast_cadence = 15.0 + + # 1. Fast Poll: Current chat history & active node approvals + if (now - last_fast >= fast_cadence) or user_triggered: + with self.lock: + cur_node = self.active_node + cur_thread = self.active_thread_id + node_threads = self.threads_cache.get(cur_node, []) + cur_valid = any(t.get("session_id") == cur_thread for t in node_threads) + if not cur_thread or not cur_valid: + cur_thread, _ = self.select_main_chat(cur_node) + if cur_node and not self.is_node_rate_limited(cur_node): + self._fetch_history(cur_node, cur_thread) + self._fetch_active_node_approval(cur_node) + last_fast = now + + # 2. Medium Poll (every 20s): Fleet health / CDP status & tmux + if (now - last_med >= 20.0): + self._fetch_fleet_health() + self._fetch_tmux_sessions() + last_med = now + + # 3. Fleet Approvals Poll (every 12s, or immediately on user action) + if (now - last_fleet_approvals >= 12.0) or user_triggered: + self._fetch_approvals() + last_fleet_approvals = now + + # 4. Slow Poll (every 45s): Refresh thread list & jobs lazily + if (now - last_slow >= 45.0) or user_triggered: + with self.lock: + cur_node = self.active_node + if cur_node and not self.is_node_rate_limited(cur_node): + self.lazy_fetch_threads(cur_node) + self._fetch_jobs() + self._fetch_dm_logs() + last_slow = now + + # 5. SSH tunnel health (every 30s): single VM-side sweep + if (now - last_ssh >= 30.0): + self._fetch_ssh_health() + last_ssh = now + + # 6. Work pipeline & Cognitive sensing sweep (every 15s) + if (now - getattr(self, "last_work_poll", 0.0) >= 15.0) or user_triggered: + self._fetch_cognitive_and_work() + self.last_work_poll = now + + # Sleep in short increments to allow prompt wakeup on user actions + for _ in range(10): + if not self.running or (hasattr(self, 'user_poll_trigger') and self.user_poll_trigger.is_set()): + break + time.sleep(0.1) + + def _fetch_cognitive_and_work(self): + """Fetch real-time cognitive sensor states and Gitea build pipeline.""" + cog_states = {} + if acp: + for node in self.nodes: + try: + cog_states[node] = acp.get_passive_cognitive_state(node) + except Exception: + pass + + issues = [] + prs = [] + try: + req_i = urllib.request.Request( + "https://tea.muse-dev.online/api/v1/repos/super/box/issues?state=all&limit=10", + headers={"User-Agent": "Box-Work-CLI/1.0"} + ) + with urllib.request.urlopen(req_i, timeout=3.0) as r: + issues = json.loads(r.read().decode()) + except Exception: + pass + + try: + req_p = urllib.request.Request( + "https://tea.muse-dev.online/api/v1/repos/super/box/pulls?state=all&limit=6", + headers={"User-Agent": "Box-Work-CLI/1.0"} + ) + with urllib.request.urlopen(req_p, timeout=3.0) as r: + prs = json.loads(r.read().decode()) + except Exception: + pass + + with self.lock: + self.cognitive_cache = cog_states + self.work_issues_cache = issues if isinstance(issues, list) else [] + self.work_prs_cache = prs if isinstance(prs, list) else [] + + def _run_fetch_threads(self, node: str): + if self.is_node_rate_limited(node): + return + try: + cmd = [str(BIN_DIR / "muse-cli-node"), node, "threads"] + rc, stdout, stderr = run_command_isolated(cmd, timeout=8.0) + if rc != 0: + err_text = (stderr + " " + stdout).lower() + if any(pat in err_text for pat in ["429", "rate limit", "too many requests", "throttled", "ratelimitexceeded"]): + retry_match = re.search(r"retry[-_]after[:\s]+(\d+)", err_text) + retry_val = float(retry_match.group(1)) if retry_match else None + self.mark_node_rate_limited(node, reason="Rate limit detected on threads", retry_after=retry_val) + return + + try: + data = json.loads(stdout) + except Exception: + data = None + + if isinstance(data, dict) and data.get("error"): + err_text = str(data.get("error")).lower() + if any(pat in err_text for pat in ["429", "rate limit", "too many requests", "throttled", "ratelimitexceeded"]): + self.mark_node_rate_limited(node, reason="Rate limit detected on threads") + return + + if isinstance(data, list): + self.clear_node_rate_limit(node) + with self.lock: + existing = self.threads_cache.get(node, []) + live_ids = {t.get("session_id") for t in data if isinstance(t, dict) and t.get("session_id")} + merged = [t for t in data if isinstance(t, dict) and t.get("session_id")] + for old_t in existing: + sid = old_t.get("session_id") + if sid and sid not in live_ids: + merged.append(old_t) + sorted_threads = self.sort_threads(node, merged) + self.threads_cache[node] = sorted_threads + if self.active_node == node: + cur_valid = any(t.get("session_id") == self.active_thread_id for t in sorted_threads) + if not cur_valid or not self.active_thread_id: + self.select_main_chat(node) + self._save_threads_cache() + self.trigger_refresh() + except Exception: + pass + finally: + with self.lock: + self.loading_threads.discard(node) + self.loaded_nodes.add(node) + self.fetch_times[node] = time.time() + + def _fetch_threads(self, node: str): + with self.lock: + if node in self.loading_threads: + return + self.loading_threads.add(node) + self._run_fetch_threads(node) + + def _fetch_history(self, node: str, thread_id: str = None): + if not node or self.is_node_rate_limited(node): + return + is_main = False + with self.lock: + for t in self.threads_cache.get(node, []): + if (thread_id and t.get("session_id") == thread_id and t.get("is_main")) or (not thread_id and t.get("is_main")): + is_main = True + if not thread_id: + thread_id = t.get("session_id") + break + key = (node, thread_id) + with self.lock: + if key in self.loading_history: + return + self.loading_history.add(key) + try: + cmd = [str(BIN_DIR / "muse-cli-node"), node, "history", "--limit", "120"] + if thread_id and not is_main: + cmd.extend(["--thread", thread_id]) + rc, stdout, stderr = run_command_isolated(cmd, timeout=12.0) + if rc != 0: + err_text = (stderr + " " + stdout).lower() + if any(pat in err_text for pat in ["429", "rate limit", "too many requests", "throttled", "ratelimitexceeded"]): + retry_match = re.search(r"retry[-_]after[:\s]+(\d+)", err_text) + retry_val = float(retry_match.group(1)) if retry_match else None + self.mark_node_rate_limited(node, reason="Rate limit detected on history", retry_after=retry_val) + return + + msgs = None + try: + msgs = json.loads(stdout) + except Exception: + msgs = None + + if isinstance(msgs, dict) and msgs.get("error"): + err_text = str(msgs.get("error")).lower() + if any(pat in err_text for pat in ["429", "rate limit", "too many requests", "throttled", "ratelimitexceeded"]): + self.mark_node_rate_limited(node, reason="Rate limit detected on history") + return + + if isinstance(msgs, list): + self.clear_node_rate_limit(node) + self.history_fetch_times[key] = time.time() + with self.lock: + existing = list(self.history_cache.get(key, [])) + now_ts = time.time() + # Preserve recent optimistic user messages not yet returned by backend + pending_opt = [ + m for m in existing + if m.get("optimistic") and (now_ts - m.get("timestamp", now_ts)) < 45.0 + and not any(sm.get("role") == "user" and sm.get("text") == m.get("text") for sm in msgs) + ] + merged = msgs + pending_opt + self.history_cache[key] = merged + if is_main: + self.history_cache[(node, None)] = merged + self._save_history_cache() + self.trigger_refresh() + except Exception: + pass + finally: + with self.lock: + self.loading_history.discard(key) + + def _fetch_active_node_approval(self, node: str): + """Quick single-node check for active node approvals without scanning full fleet.""" + if not approvals_engine or not node: + return + try: + res = approvals_engine.inspect_node_approvals(node) + with self.lock: + if node in self.node_status: + st = res.get("status") + is_blocked = st in ("PENDING", "KEY_APPROVAL") + is_waiting = (st == "INPUT_WAIT") + self.node_status[node]["blocked"] = is_blocked + self.node_status[node]["waiting"] = is_waiting + self.node_status[node]["approval_status"] = st + except Exception: + pass + + def _fetch_approvals(self): + if not approvals_engine: + return + try: + results = approvals_engine.check_fleet_approvals(self.nodes) + with self.lock: + self.approvals_cache = results + for it in results: + n = it.get("node") + if n in self.node_status: + st = it.get("status") + is_blocked = st in ("PENDING", "KEY_APPROVAL") + is_waiting = (st == "INPUT_WAIT") + self.node_status[n]["blocked"] = is_blocked + self.node_status[n]["waiting"] = is_waiting + self.node_status[n]["approval_status"] = st + except Exception: + pass + + def _fetch_fleet_health(self): + if not netvm_registry: + return + try: + reg = netvm_registry.load() + for n in self.nodes: + rec = reg.get(n) + if not rec: + continue + peer_ip = rec.get("peer_ip", "127.0.0.1") + port = rec.get("cdp_port", 9410) + url = f"http://{peer_ip}:{port}/json/version" + t0 = time.time() + try: + req = urllib.request.Request(url, headers={"User-Agent": "muse-tui/1.0"}) + with urllib.request.urlopen(req, timeout=1.2) as resp: + lat = int((time.time() - t0) * 1000) + with self.lock: + self.node_status[n]["latency"] = lat + self.node_status[n]["status"] = "ACTIVE" + except Exception: + with self.lock: + self.node_status[n]["status"] = "UNREACHABLE" + self.node_status[n]["latency"] = None + + # Also grab active page title + tab_url = f"http://{peer_ip}:{port}/json/list" + try: + req = urllib.request.Request(tab_url, headers={"User-Agent": "muse-tui/1.0"}) + with urllib.request.urlopen(req, timeout=1.0) as resp: + tabs = json.load(resp) + pages = [t for t in tabs if t.get("type") == "page"] + if pages: + t_title = pages[0].get("title", "") + with self.lock: + self.node_status[n]["tab"] = t_title + except Exception: + pass + except Exception: + pass + + def _fetch_jobs(self): + try: + cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-list"] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=3) + if res.returncode == 0: + data = json.loads(res.stdout) + with self.lock: + self.jobs_cache = data.get("jobs", []) + except Exception: + pass + + def _fetch_tmux_sessions(self): + try: + cmd = ["tmux", "-S", "/tmp/tmux-muse.sock", "ls"] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=2) + if res.returncode == 0: + lines = [l.strip() for l in res.stdout.strip().split("\n") if l.strip()] + with self.lock: + self.tmux_cache = lines + else: + with self.lock: + self.tmux_cache = [] + except Exception: + pass + + def _apply_ssh_check_result(self, data: dict, now: float = None): + """Merge one ssh-check payload into ssh_cache with flap tracking. + + state_since records the last (ssh_up, term_up) transition per + account so the SSH view can show uptime/downtime durations. + """ + now = now if now is not None else time.time() + if not isinstance(data, dict): + return + accounts = data.get("accounts", {}) + if not isinstance(accounts, dict): + accounts = {} + with self.lock: + self.ssh_jump_reachable = data.get("jump_reachable") + self.ssh_checked_at = now + self.ssh_check_latency_ms = data.get("latency_ms") + self.ssh_check_error = "" if data.get("jump_reachable") else str(data.get("error", "")) + for acct, info in accounts.items(): + if not isinstance(info, dict): + continue + prev = self.ssh_cache.get(acct, {}) + entry = dict(info) + prev_state = (prev.get("ssh_up"), prev.get("term_up")) + new_state = (entry.get("ssh_up"), entry.get("term_up")) + if prev_state != new_state or "state_since" not in prev: + entry["state_since"] = now + entry["prev_ssh_up"] = prev.get("ssh_up") + else: + entry["state_since"] = prev.get("state_since", now) + entry["prev_ssh_up"] = prev.get("prev_ssh_up") + self.ssh_cache[acct] = entry + + def _fetch_ssh_health(self): + """Run box-ctl ssh-check (single VM-side sweep) and merge results.""" + try: + cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "ssh-check"] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if res.returncode == 0: + try: + data = json.loads(res.stdout) + except Exception: + return + if isinstance(data, dict) and data.get("ok"): + self._apply_ssh_check_result(data) + except Exception: + pass + + def probe_container_uptime(self, account: str) -> tuple[bool, str]: + """On-demand end-to-end probe: run `uptime` inside the container.""" + if account not in SSH_TUNNEL_PORTS: + return False, f"No tunnel registered for '{account}'" + cmd = build_ssh_dial_command( + account, + ssh_options=["-o", "BatchMode=yes", "-o", "ConnectTimeout=12"], + remote_command="uptime", + ) + rc, stdout, stderr = run_command_isolated(cmd, timeout=25.0) + if rc == 0 and (stdout or "").strip(): + return True, stdout.strip() + err_lines = (stderr or stdout or f"exit {rc}").strip().splitlines() + return False, (err_lines[-1] if err_lines else f"exit {rc}")[:200] + + def _fetch_dm_logs(self): + log_path = REPO_ROOT / "dm-log.jsonl" + if not log_path.exists(): + return + try: + # Read last 40 lines + with open(log_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines()[-40:] + parsed = [] + for l in reversed(lines): + try: + parsed.append(json.loads(l)) + except Exception: + pass + with self.lock: + self.dm_logs_cache = parsed + except Exception: + pass + + def add_optimistic_message(self, node: str, thread_id: str, text: str): + """Immediately append the user's message to history cache so it renders without waiting for remote roundtrip.""" + key = (node, thread_id) + msg_id = f"opt-{int(time.time()*1000)}" + now_iso = datetime.now(timezone.utc).isoformat() + opt_msg = { + "role": "user", + "text": text, + "message_id": msg_id, + "created": now_iso, + "optimistic": True, + "timestamp": time.time(), + } + with self.lock: + cur_msgs = list(self.history_cache.get(key, [])) + if not any(m.get("text") == text and m.get("role") == "user" for m in cur_msgs[-2:]): + cur_msgs.append(opt_msg) + self.history_cache[key] = cur_msgs + is_main = any(t.get("session_id") == thread_id and t.get("is_main") for t in self.threads_cache.get(node, [])) + if is_main or not thread_id: + self.history_cache[(node, None)] = cur_msgs + + # Synchronous actions executed from UI + def send_chat_message(self, node: str, thread_id: str, text: str) -> tuple[bool, str]: + is_main = False + with self.lock: + for t in self.threads_cache.get(node, []): + if t.get("session_id") == thread_id and t.get("is_main"): + is_main = True + break + cmd = [str(BIN_DIR / "muse-cli-node"), node, "send", "--wait", "0"] + if thread_id and not is_main: + cmd.extend(["--thread", thread_id]) + cmd.append(text) + + last_err = "" + for attempt in range(3): + try: + rc, stdout, stderr = run_command_isolated(cmd, timeout=15.0) + if rc == 0: + self.clear_node_rate_limit(node) + self.record_interaction(node) + self.record_activity() + return True, "Message sent successfully" + + err_text = (stderr + " " + stdout).lower() + if any(pat in err_text for pat in ["429", "rate limit", "too many requests", "throttled", "ratelimitexceeded"]): + retry_match = re.search(r"retry[-_]after[:\s]+(\d+)", err_text) + retry_val = float(retry_match.group(1)) if retry_match else None + self.mark_node_rate_limited(node, reason="Send hit rate limit", retry_after=retry_val) + return False, "Server rate limit reached" + + last_err = (stderr or stdout or "").strip() + time.sleep(0.2) + except Exception as e: + last_err = str(e) + time.sleep(0.2) + return False, last_err + + def create_sidechat(self, node: str, title: str) -> tuple[bool, str, str]: + cmd = [str(BIN_DIR / "muse-cli-node"), node, "session-start", "--title", title] + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if res.returncode == 0: + try: + data = json.loads(res.stdout) + sid = data.get("session_id") + if sid: + self._fetch_threads(node) + self.set_active_target(node, sid, title) + return True, sid, "Created sidechat successfully" + except Exception: + pass + return False, "", res.stderr or res.stdout + except Exception as e: + return False, "", str(e) + + def issue_work_order(self, node: str, thread_id: str, title: str, body: str, priority: str = "routine") -> tuple[bool, str]: + wo_id = hashlib.sha256(f"super-{node}-{title}-{time.time()}".encode()).hexdigest()[:8] + prefix = "[URGENT] " if priority == "urgent" else "" + wo_content = f"{prefix}[WO:{wo_id}] [from super] {title} — {body}" + return self.send_chat_message(node, thread_id, wo_content) + + def resolve_approval(self, node: str, action: str, **kwargs) -> tuple[bool, str]: + if not approvals_engine: + return False, "Approvals engine not loaded" + try: + if action == "allow": + res = approvals_engine.allow_node_approval(node, always=False, force=True, caller="muse-tui") + elif action == "always": + res = approvals_engine.allow_node_approval(node, always=True, force=True, caller="muse-tui") + elif action == "deny": + res = approvals_engine.deny_node_approval(node, caller="muse-tui") + elif action == "auto": + res = approvals_engine.auto_approve_fleet(caller="muse-tui") + elif action == "reply": + msg = kwargs.get("message", "Proceed with standard execution.") + res = approvals_engine.reply_node_task(node, msg, caller="muse-tui") + elif action == "dismiss": + res = approvals_engine.dismiss_node_task(node, caller="muse-tui") + else: + return False, f"Unknown action: {action}" + self.trigger_refresh() + ok = res.get("ok", False) + msg = res.get("message") or res.get("result") or res.get("error") or str(res) + return ok, msg + except Exception as e: + return False, str(e) + + def dismiss_task(self, node: str) -> tuple[bool, str]: + if not approvals_engine: + return False, "Approvals engine not loaded" + try: + res = approvals_engine.dismiss_node_task(node, caller="muse-tui") + self.trigger_refresh() + ok = res.get("ok", False) + msg = res.get("result") or res.get("error") or str(res) + return ok, msg + except Exception as e: + return False, str(e) + + def clear_all_waits(self) -> tuple[bool, str]: + if not approvals_engine: + return False, "Approvals engine not loaded" + try: + res = approvals_engine.clear_node_waits(None, caller="muse-tui") + self.trigger_refresh() + count = res.get("total_cleared", 0) + return True, f"Cleared {count} task wait(s)" + except Exception as e: + return False, str(e) + + def trigger_job(self, job_name: str) -> tuple[bool, str]: + try: + cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-trigger", job_name] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=8) + self.trigger_refresh() + if res.returncode == 0: + return True, f"Triggered job {job_name}" + return False, res.stderr or res.stdout + except Exception as e: + return False, str(e) + + def kill_tmux_session(self, sess_name: str) -> tuple[bool, str]: + try: + clean_name = sess_name.split(":")[0].strip() + cmd = ["tmux", "-S", "/tmp/tmux-muse.sock", "kill-session", "-t", clean_name] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=3) + self.trigger_refresh() + if res.returncode == 0: + return True, f"Killed session {clean_name}" + return False, res.stderr or res.stdout + except Exception as e: + return False, str(e) + + +# --------------------------------------------------------------------------- +# Curses TUI Interface +# --------------------------------------------------------------------------- +class MuseTUI: + """Full-terminal curses application supporting Muse and Box operational modes.""" + + def __init__(self, stdscr, initial_mode="muse", initial_node=None, initial_thread=None, initial_tab=None): + self.stdscr = stdscr + self.mode = initial_mode # "muse" or "box" + self.box_tab = 0 # 0: Chat, 1: Fleet, 2: Approvals, 3: Jobs, 4: Tmux, 5: Logs, 6: SSH, 7: Work + if initial_mode == "box" and initial_tab is not None: + tab_map = {"chat": 0, "fleet": 1, "approvals": 2, "jobs": 3, "tmux": 4, "logs": 5, "ssh": 6, "work": 7} + self.box_tab = tab_map.get(str(initial_tab).lower(), 0) + self.work_sel_idx = 0 + self.menu_tab = "activity" + self.dispatch_target_node = "dev" + self.data = FleetDataManager() + + # Selection state: default to top of sorted list (highest unread / most recently interacted) + if initial_node and initial_node in self.data.nodes: + target_node = initial_node + elif self.data.nodes: + target_node = self.data.nodes[0] + else: + target_node = "muse" + + if initial_thread: + self.data.active_node = target_node + self.data.active_thread_id = initial_thread + found_title = None + for t in self.data.threads_cache.get(target_node, []): + if t.get("session_id") == initial_thread: + found_title = t.get("title") + break + self.data.active_thread_title = found_title or (initial_thread[:8] if initial_thread else "Sidechat") + else: + self.data.select_main_chat(target_node) + + # Navigation & Modal State (Neovim-style modal lock) + # editor_mode: "NORMAL" (key nav locked, composer locked) or "INSERT" (typing in prompt) + self.editor_mode = "NORMAL" + # focus_pane: "fleet" (agents list), "sidechats" / "sidebar" (threads list), or "transcript" (chat scroll) + self.focus_pane = "sidechats" + self.fleet_focus_idx = 0 + self.thread_sel_idx = 0 + self.transcript_scroll_offset = 0 # 0 = locked at bottom + self.auto_scroll = True + self.transcript_total_lines = 0 + self.transcript_height = 20 + self._transcript_cache_key = None + self._transcript_cache_lines = [] + + # Selection heads for Box mode tabs + self.fleet_sel_idx = 0 + self.approvals_sel_idx = 0 + self.jobs_sel_idx = 0 + self.jobs_scroll_start = 0 + self.tmux_sel_idx = 0 + self.ssh_sel_idx = 0 + self.ssh_scroll_idx = 0 + self.table_scroll_idx = 0 + + # Input buffer + self.input_buf = "" + self.input_cursor = 0 + self.input_history = [] + self.history_idx = -1 + + # Box mode tables scroll + self.table_scroll_idx = 0 + + # Fleet agent scroll & search state + self.agent_scroll_start = 0 + self.agent_visible_count = 6 + self.agent_search_sel = 0 + self.agent_search_scroll = 0 + + # Modals: None, "help", "approvals", "new_sidechat", "work_order", "reply_task", "agent_search", "prompts", "history_search", "prompt_formulate", "chat_context", "agent_context", "message_context" + self.modal = None + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.modal_extra = {} + + # Context Menus (Right-Click & Action Panels) + self.context_chat = None + self.chat_context_sel = 0 + self.context_agent = None + self.agent_context_sel = 0 + self.context_message = None + self.message_context_sel = 0 + self._last_right_click_time = 0.0 + + # Prompt & Skill System + self.prompt_manager = PromptManager() + self.prompts = self.prompt_manager.get_sorted_prompts() + self.prompt_scroll_idx = 0 + self.prompt_sel_idx = 0 + self.prompt_preview_scroll = 0 + self._prompt_chip_bounds = [] + + # Chat history sends search state + self.history_search_sel = 0 + self.history_search_scroll = 0 + + # Clipboard, Copy, Selection & Reply state + self.clipboard_buf = "" + self._btn_copy_all_bounds = None + self._msg_copy_buttons = [] + self._msg_reply_buttons = [] + self.reply_target = None # None or dict: {"msg_id": ..., "seq": ..., "text": ..., "preview": ...} + self.copy_mode = False # True when in tmux copy mode + self.copy_cursor_line = 0 # Absolute line index in rendered_lines + self.visual_sel_start = None # Start line index of visual selection, or None + self.mouse_dragging = False + + # Transcript display style: 'clean' (default, borderless line-by-line) or 'boxed' (classic ASCII frames) + self.transcript_style = self._load_transcript_style() + + # Status toast + self.toast_msg = "Welcome to Muse TUI. Press '?' or F1 for help, 'i' to chat." + self.toast_style = "info" + self.toast_time = time.time() + 4.0 + + # Exit state + self.should_exit = False + + # Chat dwell time tracking for clearing sidechat notifications (clears after > 5.0s dwell) + self.cur_chat_enter_time = time.time() + self.last_dwell_check_target = (self.data.active_node, self.data.active_thread_id) + + # Device modifier substrate layer (Alt+Space leader capture) + self.device_layer_active = False + self.device_layer_timeout = 0.0 + + # Colors setup + self._init_colors() + + def _init_colors(self): + try: + curses.curs_set(0) # Start with cursor parked in NORMAL mode + except Exception: + pass + + try: + self.stdscr.keypad(True) + self.stdscr.timeout(50) # 50ms getch non-blocking loop + except Exception: + pass + + if hasattr(curses, "set_escdelay"): + try: + curses.set_escdelay(25) + except Exception: + pass + + # Mouse configuration (clicks and scroll wheel) + try: + curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + if hasattr(curses, "mouseinterval"): + curses.mouseinterval(0) + sys.stdout.write("\033[?1000h\033[?1002h\033[?1006h\033[?2004h") + sys.stdout.flush() + except Exception: + pass + + try: + self.has_colors = curses.has_colors() + except Exception: + self.has_colors = False + + if self.has_colors: + try: + curses.start_color() + curses.use_default_colors() + # Color pairs: (FG, BG) + # 1: Cyan (User) + curses.init_pair(1, curses.COLOR_CYAN, -1) + # 2: Yellow (Agent / Muse) + curses.init_pair(2, curses.COLOR_YELLOW, -1) + # 3: Green (Success, Work Order, Active) + curses.init_pair(3, curses.COLOR_GREEN, -1) + # 4: Red (Error, Blocked, Deny) + curses.init_pair(4, curses.COLOR_RED, -1) + # 5: Magenta (Tool execution, Swarm) + curses.init_pair(5, curses.COLOR_MAGENTA, -1) + # 6: Header / Tabs selected / Normal mode badge + curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN) + # 7: Active pill / selection + curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_WHITE) + # 8: Warning banner + curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW) + # 9: Work order badge / Insert mode badge + curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_GREEN) + # 10: Dim text + curses.init_pair(10, curses.COLOR_WHITE, -1) + except Exception: + self.has_colors = False + + def _attr(self, name: str): + if not self.has_colors: + if name in ("bold", "user", "agent"): + return curses.A_BOLD + if name in ("button", "header", "selected", "mode_normal", "mode_insert"): + return curses.A_REVERSE + if name == "dim": + return curses.A_DIM + return curses.A_NORMAL + + table = { + "normal": curses.color_pair(0), + "user": curses.color_pair(1) | curses.A_BOLD, + "agent": curses.color_pair(2) | curses.A_BOLD, + "success": curses.color_pair(3) | curses.A_BOLD, + "green": curses.color_pair(3), + "danger": curses.color_pair(4) | curses.A_BOLD, + "red": curses.color_pair(4), + "tool": curses.color_pair(5), + "header": curses.color_pair(6) | curses.A_BOLD, + "selected": curses.color_pair(7) | curses.A_BOLD, + "warn_banner": curses.color_pair(8) | curses.A_BOLD, + "wo_badge": curses.color_pair(9) | curses.A_BOLD, + "dim": curses.color_pair(10) | curses.A_DIM, + "bold": curses.A_BOLD, + "button": curses.color_pair(7) | curses.A_BOLD, + "mode_normal": curses.color_pair(6) | curses.A_BOLD, + "mode_insert": curses.color_pair(9) | curses.A_BOLD, + } + return table.get(name, curses.A_NORMAL) + + def safe_addstr(self, win, y: int, x: int, text: str, attr=0): + """Safely write string to window without crashing on right/bottom edge.""" + max_y, max_x = getattr(self, "_term_dims", (0, 0)) + if max_y == 0 or max_x == 0: + max_y, max_x = win.getmaxyx() + if y < 0 or y >= max_y or x < 0 or x >= max_x: + return + available = max_x - x + if available <= 0: + return + slice_text = text[:available] + try: + win.addstr(y, x, slice_text, attr) + except curses.error: + pass + + def set_toast(self, msg: str, style="info", duration=4.0): + self.toast_msg = msg + self.toast_style = style + self.toast_time = time.time() + duration + + # ----------------------------------------------------------------------- + # Main Render Pipeline + # ----------------------------------------------------------------------- + def run(self): + try: + last_dims = (0, 0) + last_modal = None + last_mode_state = None + while True: + h, w = self.stdscr.getmaxyx() + self._term_dims = (h, w) + + # Only full-erase on resize, modal open/close, mode/tab switch, or explicit flag + cur_mode_state = (self.mode, self.box_tab) + if ((h, w) != last_dims or + self.modal != last_modal or + cur_mode_state != last_mode_state or + getattr(self, "need_full_redraw", False)): + self.stdscr.erase() + last_dims = (h, w) + last_modal = self.modal + last_mode_state = cur_mode_state + self.need_full_redraw = False + + if h < 14 or w < 60: + self.safe_addstr(self.stdscr, 0, 0, f"Terminal too small ({w}x{h}). Need at least 60x14.", self._attr("danger")) + self.stdscr.refresh() + time.sleep(0.1) + ch = self.stdscr.getch() + if ch == ord('q') or ch == 27: + break + continue + + # 1. Top Header Bar + self._render_header(0, 0, w) + + # 2. Mode / Tab bar + header_lines = 1 + if self.mode == "box": + self._render_box_tabs(1, 0, w) + header_lines = 2 + + content_y = header_lines + bottom_bar_h = 3 # Input bar + Toast / keyhints + is_main_view = (self.mode == "muse" or (self.mode == "box" and self.box_tab == 0)) + prompt_shelf_h = 2 if is_main_view else 0 + content_h = h - content_y - bottom_bar_h - prompt_shelf_h + prompt_shelf_y = content_y + content_h + + # 3. Main Content & Chat Dwell Tracking + if is_main_view: + # Dwell timer: when viewing an active sidechat for >5 seconds, clear unread notification counter + cur_dwell_target = (self.data.active_node, self.data.active_thread_id) + if cur_dwell_target != getattr(self, "last_dwell_check_target", None): + self.cur_chat_enter_time = time.time() + self.last_dwell_check_target = cur_dwell_target + elif cur_dwell_target[0] and cur_dwell_target[1]: + # Check if dwell threshold (5 seconds) has elapsed + if time.time() - getattr(self, "cur_chat_enter_time", 0.0) >= 5.0: + # Only clear if user is looking at bottom or auto-scrolling + if getattr(self, "auto_scroll", True) or getattr(self, "transcript_scroll_offset", 0) == 0: + self.data.mark_thread_read(cur_dwell_target[0], cur_dwell_target[1]) + + self._render_muse_view(content_y, 0, content_h, w) + self._render_prompt_shelf(prompt_shelf_y, 0, prompt_shelf_h, w) + elif self.mode == "box": + if self.box_tab == 1: + self._render_fleet_table(content_y, 0, content_h, w) + elif self.box_tab == 2: + self._render_approvals_view(content_y, 0, content_h, w) + elif self.box_tab == 3: + self._render_jobs_view(content_y, 0, content_h, w) + elif self.box_tab == 4: + self._render_tmux_view(content_y, 0, content_h, w) + elif self.box_tab == 5: + self._render_dm_logs_view(content_y, 0, content_h, w) + elif self.box_tab == 6: + self._render_ssh_view(content_y, 0, content_h, w) + elif self.box_tab == 7: + self._render_work_view(content_y, 0, content_h, w) + + # 4. Bottom Input Bar & Toast + self._render_bottom_bar(h - bottom_bar_h, 0, bottom_bar_h, w) + + # 5. Overlays / Modals (if active) + if self.modal: + self._render_modal(h, w) + + self.stdscr.refresh() + + # Handle Input with Keystroke Draining / Batching to eliminate typing lag + ch = self.stdscr.getch() + if ch != -1: + while ch != -1: + if not self._handle_key(ch): + return + if getattr(self, "should_exit", False): + return + # If in INSERT mode or modal text input, drain all pending keystrokes before redrawing + if self.editor_mode == "INSERT" or (self.modal in ("agent_search", "new_sidechat", "work_order", "history_search", "prompt_formulate")): + self.stdscr.nodelay(True) + try: + ch = self.stdscr.getch() + finally: + self.stdscr.timeout(50) + else: + break + + except KeyboardInterrupt: + pass + except Exception as e: + import traceback + crash_log = REPO_ROOT / "logs" / "tui-crash.log" + try: + crash_log.parent.mkdir(parents=True, exist_ok=True) + with open(crash_log, "a", encoding="utf-8") as f: + f.write(f"\n--- CRASH AT {datetime.now().isoformat()} ---\n") + traceback.print_exc(file=f) + except Exception: + pass + raise + finally: + try: + sys.stdout.write("\033[?1000l\033[?1002l\033[?1006l\033[?2004l") + sys.stdout.flush() + except Exception: + pass + self.data.stop() + + # ----------------------------------------------------------------------- + # Header & Tabs Rendering + # ----------------------------------------------------------------------- + def _render_header(self, y: int, x: int, w: int): + title = " MUSE TUI // NetVM Autonomous Agent Terminal " if self.mode == "muse" else " BOX TUI // NetVM Fleet Command Console " + self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("header")) + self.safe_addstr(self.stdscr, y, x + 1, title, self._attr("header")) + + # Device Substrate banner, Rate-limit Cooldown, or Health & pending approvals badge + now = time.time() + active_cd = self.data.get_node_cooldown_remaining(self.data.active_node) + is_dev_layer = getattr(self, "device_layer_active", False) and now <= self.device_layer_timeout + if active_cd > 0: + cd_txt = f" ⏳ {self.data.active_node.upper()}: RATE-LIMIT COOLDOWN ({active_cd}s) " + self.safe_addstr(self.stdscr, y, max(0, w - len(cd_txt) - 22), cd_txt, self._attr("warn_banner")) + elif is_dev_layer: + with self.data.lock: + active_node = self.data.active_node.upper() + layer_txt = f" 󰢹 DEVICE LAYER: [{active_node}] (0-9: Select | Space: Cycle | Esc: Lock) " + self.safe_addstr(self.stdscr, y, max(0, w - len(layer_txt) - 22), layer_txt, self._attr("warn_banner")) + else: + with self.data.lock: + blocked_nodes = [n for n, s in self.data.node_status.items() if s.get("blocked")] + waiting_nodes = [n for n, s in self.data.node_status.items() if s.get("waiting")] + online_count = sum(1 for s in self.data.node_status.values() if s.get("status") == "ACTIVE") + total_nodes = len(self.data.nodes) + + badge_txt = f" Fleet: {online_count}/{total_nodes} Up " + self.safe_addstr(self.stdscr, y, w - len(badge_txt) - 22, badge_txt, self._attr("selected")) + + if blocked_nodes: + app_txt = f" ⚠ {len(blocked_nodes)} BLOCKED (Press 'a') " + self.safe_addstr(self.stdscr, y, w - len(app_txt) - len(badge_txt) - 24, app_txt, self._attr("warn_banner")) + elif waiting_nodes: + app_txt = f" 󰏤 {len(waiting_nodes)} WAITING (Press 'a') " + self.safe_addstr(self.stdscr, y, w - len(app_txt) - len(badge_txt) - 24, app_txt, self._attr("dim")) + + clock = datetime.now().strftime("%H:%M:%S") + self.safe_addstr(self.stdscr, y, w - len(clock) - 2, clock, self._attr("header")) + + def _render_box_tabs(self, y: int, x: int, w: int): + tabs = BOX_TABS + self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("dim")) + cur_x = x + 1 + for idx, tab_name in enumerate(tabs): + is_active = (self.box_tab == idx) + attr = self._attr("selected") if is_active else self._attr("dim") + label = f" [{tab_name}] " + self.safe_addstr(self.stdscr, y, cur_x, label, attr) + cur_x += len(label) + 1 + + # ----------------------------------------------------------------------- + # Muse Conversational View (Split Sidebar + Transcript) + # ----------------------------------------------------------------------- + def _render_muse_view(self, y: int, x: int, h: int, w: int): + sidebar_w = min(36, max(26, w // 4)) + chat_w = w - sidebar_w - 1 + + is_sidebar_focused = (self.focus_pane in ("sidebar", "sidechats", "fleet") and self.editor_mode == "NORMAL") + is_transcript_focused = (self.focus_pane == "transcript" and self.editor_mode == "NORMAL") + + # Vertical Divider: prominently highlights active pane border + div_char = "┃" if (is_sidebar_focused or is_transcript_focused) else "│" + div_attr = self._attr("header") if is_sidebar_focused else (self._attr("user") if is_transcript_focused else self._attr("dim")) + for line in range(h): + self.safe_addstr(self.stdscr, y + line, sidebar_w, div_char, div_attr) + + # Render Left Sidebar (Nodes + Threads) + self._render_sidebar(y, x, h, sidebar_w) + + # Render Right Transcript (Chat History) + self._render_transcript(y, sidebar_w + 1, h, chat_w) + + def _render_sidebar(self, y: int, x: int, h: int, w: int): + with self.data.lock: + active_node = self.data.active_node + statuses = dict(self.data.node_status) + nodes = list(self.data.nodes) + + total_nodes = len(nodes) + # Allocate visible rows for agents: at least 3, at most 7, leaving ample space for threads + max_agent_rows = min(7, max(3, (h - 10) // 2)) + visible_agent_count = min(total_nodes, max_agent_rows) + self.agent_visible_count = visible_agent_count + + # Ensure active_node or fleet_focus_idx is in valid range + if getattr(self, "fleet_focus_idx", None) is None: + self.fleet_focus_idx = 0 + if self.fleet_focus_idx >= total_nodes: + self.fleet_focus_idx = max(0, total_nodes - 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)) + + # 1. Fleet Nodes Section Header + if total_nodes > visible_agent_count: + end_num = min(total_nodes, self.agent_scroll_start + visible_agent_count) + count_str = f"({self.agent_scroll_start + 1}-{end_num}/{total_nodes})" + else: + count_str = f"({total_nodes})" + + fleet_unread_total = self.data.get_fleet_unread_total() + unread_badge = f" [● {fleet_unread_total} New]" if fleet_unread_total > 0 else "" + + 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("header") if is_fleet_focused else self._attr("warn_banner")) + + node_y = y + 1 + for row_i in range(visible_agent_count): + idx = self.agent_scroll_start + row_i + if idx >= total_nodes: + break + 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") + lat_str = f"{latency}ms" if latency is not None else "--" + node_unread = self.data.get_node_unread_total(node) + + dot = "●" + if is_blocked: + dot_attr = self._attr("danger") + elif is_waiting: + dot_attr = self._attr("warn_banner") + elif st_info.get("status") == "ACTIVE": + dot_attr = self._attr("success") + else: + dot_attr = self._attr("red") + + if idx < 9: + num_tag = f"[{idx + 1}]" + elif idx == 9: + num_tag = "[0]" + else: + num_tag = f"[{idx + 1:>2}]" + + # Arrow indicator for scroll boundaries + scroll_arrow = "" + if row_i == 0 and self.agent_scroll_start > 0: + scroll_arrow = "▲" + elif row_i == visible_agent_count - 1 and self.agent_scroll_start + visible_agent_count < total_nodes: + scroll_arrow = "▼" + + 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_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) + + # 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_highlighted else self._attr("selected")) + + # Notification badge & status labels + status_pos = right_col - len(lat_disp) - 1 + if node_unread > 0: + badge_str = f"●{node_unread}" + self.safe_addstr(self.stdscr, node_y, status_pos - len(badge_str), badge_str, self._attr("warn_banner")) + status_pos -= (len(badge_str) + 1) + + if is_blocked: + blk_str = "BLK" + self.safe_addstr(self.stdscr, node_y, status_pos - len(blk_str), blk_str, self._attr("danger")) + elif is_waiting: + wait_str = "WAIT" + self.safe_addstr(self.stdscr, node_y, status_pos - len(wait_str), wait_str, self._attr("warn_banner")) + + node_y += 1 + + # Divider between nodes and threads + divider_y = node_y + 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 + hdr_attr = self._attr("header") if is_sidechats_focused else self._attr("bold") + + is_loading = self.data.is_loading_threads(active_node) + spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + spin_char = spinner_chars[int(time.time() * 8) % len(spinner_chars)] + + node_unread_total = self.data.get_node_unread_total(active_node) + unread_hdr_badge = f" [● {node_unread_total}]" if node_unread_total > 0 else "" + if is_loading: + hdr_badge = f"[{spin_char} Syncing]" + else: + hdr_badge = "[n:new ⚡:menu r:sync]" + + 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("header") if is_sidechats_focused else self._attr("dim")) + + list_y = threads_header_y + 1 + max_threads_visible = (y + h) - list_y + + with self.data.lock: + threads = list(self.data.threads_cache.get(active_node, [])) + cur_thread_id = self.data.active_thread_id + + if not threads: + if is_loading or active_node not in self.data.loaded_nodes: + self.safe_addstr(self.stdscr, list_y, x + 2, f"{spin_char} Loading sidechats for {active_node.upper()}...", self._attr("agent")) + self.safe_addstr(self.stdscr, list_y + 1, x + 2, "Updating dynamically in background...", self._attr("dim")) + self.safe_addstr(self.stdscr, list_y + 2, x + 2, "Press 'n' to create new sidechat", self._attr("dim")) + else: + self.safe_addstr(self.stdscr, list_y, x + 2, f"(No sidechats on {active_node})", self._attr("dim")) + 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 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_sidechats_focused and self.thread_sel_idx != cur_idx: + self.thread_sel_idx = cur_idx + + # Ensure selection in bounds + if self.thread_sel_idx >= len(threads): + self.thread_sel_idx = max(0, len(threads) - 1) + + # Scroll window for threads + scroll_start = 0 + if self.thread_sel_idx >= max_threads_visible: + scroll_start = self.thread_sel_idx - max_threads_visible + 1 + self.threads_scroll_start = scroll_start + + for i in range(max_threads_visible): + item_idx = scroll_start + i + if item_idx >= len(threads): + break + t = threads[item_idx] + sid = t.get("session_id", "") + title = t.get("title") or sid[:8] + 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_sidechats_focused) + + if is_cur: + indicator = "▶ " + elif is_main: + indicator = "★ " + elif is_pinned: + indicator = "📌 " + else: + indicator = " " + + item_line_y = list_y + i + + unread_cnt = self.data.get_unread_count(active_node, sid) + + if is_main: + title_disp = "Main Chat" + else: + title_disp = title + + # Row background + 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] 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 + if unread_cnt > 0: + badge_str = f"●{unread_cnt}" + badge_x = max(x + 10, badge_end_x - len(badge_str)) + self.safe_addstr(self.stdscr, item_line_y, badge_x, badge_str, self._attr("warn_banner")) + avail_title_end = badge_x - 1 + else: + avail_title_end = badge_end_x + + # Indicator & Title + ind_attr = self._attr("success") if is_cur else (self._attr("header") if is_main else (self._attr("warn_banner") if is_pinned else self._attr("dim"))) + self.safe_addstr(self.stdscr, item_line_y, x + 1, indicator, ind_attr) + + title_start_x = x + 1 + len(indicator) + avail_title_w = max(3, avail_title_end - title_start_x) + if len(title_disp) > avail_title_w: + title_disp = title_disp[:max(2, avail_title_w - 1)] + "…" + + title_attr = self._attr("selected") if is_highlighted else (self._attr("bold") if is_cur else (self._attr("bold") if unread_cnt > 0 else self._attr("normal"))) + self.safe_addstr(self.stdscr, item_line_y, title_start_x, title_disp, title_attr) + + def _load_transcript_style(self) -> str: + """Load transcript display style from config, defaulting to 'clean'.""" + try: + cfg_path = Path.home() / ".config" / "muse-cli" / "tui_settings.json" + if cfg_path.exists(): + data = json.loads(cfg_path.read_text(encoding="utf-8")) + return data.get("transcript_style", "clean") + except Exception: + pass + return "clean" + + def _save_transcript_style(self): + """Persist transcript display style to ~/.config/muse-cli/tui_settings.json.""" + try: + cfg_path = Path.home() / ".config" / "muse-cli" / "tui_settings.json" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + data = {} + if cfg_path.exists(): + try: + data = json.loads(cfg_path.read_text(encoding="utf-8")) + except Exception: + data = {} + data["transcript_style"] = self.transcript_style + cfg_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + except Exception: + pass + + def set_transcript_style(self, style: str): + """Set transcript display style ('clean' or 'boxed').""" + if style not in ("clean", "boxed"): + style = "clean" + self.transcript_style = style + self._transcript_cache_key = None # Force re-render of transcript + self._save_transcript_style() + lbl = "Clean line-by-line" if self.transcript_style == "clean" else "Classic boxed frames" + self.set_toast(f"Switched transcript view to: {lbl} (press 'b' to toggle)", "success") + + def toggle_transcript_style(self): + """Toggle between clean line-by-line and classic boxed frame transcript styles.""" + nxt = "boxed" if getattr(self, "transcript_style", "clean") == "clean" else "clean" + self.set_transcript_style(nxt) + + def get_formatted_transcript(self, node: Optional[str] = None, thread_id: Optional[str] = None) -> str: + """Return structured formatted Markdown of chat transcript for active or specified thread.""" + active_node = node or self.data.active_node + active_tid = thread_id or self.data.active_thread_id + active_title = self.data.active_thread_title or "Untitled Thread" + with self.data.lock: + msgs = list(self.data.history_cache.get((active_node, active_tid), [])) + + if not msgs: + return f"# Chat Transcript: {active_title}\nNode: {active_node.upper()} | Thread ID: {active_tid or 'None'}\n(Empty transcript)" + + lines = [ + f"# Chat Transcript: {active_title}", + f"Node: {active_node.upper()} | Thread ID: {active_tid or 'None'} | Total Messages: {len(msgs)}", + "──────────────────────────────────────────────────\n", + ] + + for m in msgs: + role = m.get("role", "assistant") + text = m.get("text", "") + seq = m.get("seq", "") + mid = m.get("message_id", "") + mid_str = f" id:{mid[:8]}" if mid else "" + seq_str = f" seq:{seq}" if seq else "" + + if role == "assistant": + lines.append(f"### [{active_node.upper()} / MUSE AGENT]{seq_str}{mid_str}") + else: + lines.append(f"### [YOU / OPERATOR]{mid_str}") + + lines.append(text) + lines.append("") + + return "\n".join(lines).rstrip() + + def copy_transcript_to_clipboard(self) -> bool: + """Copy full transcript of active thread to system clipboard and update internal clipboard buffer.""" + text = self.get_formatted_transcript() + self.clipboard_buf = text + ok = copy_to_clipboard(text) + active_node = self.data.active_node + active_tid = self.data.active_thread_id + with self.data.lock: + msg_count = len(self.data.history_cache.get((active_node, active_tid), [])) + self.set_toast(f"📋 Copied full transcript ({msg_count} msgs, {len(text)} chars) to clipboard", "success") + return ok + + def copy_message_to_clipboard(self, message_text: Optional[str] = None, with_context: bool = False) -> bool: + """Copy an individual message to clipboard. If with_context is False, strips metadata/reply tags.""" + text = message_text + if text is None: + active_node = self.data.active_node + active_tid = self.data.active_thread_id + with self.data.lock: + msgs = list(self.data.history_cache.get((active_node, active_tid), [])) + if msgs: + text = msgs[-1].get("text", "") + if not text: + self.set_toast("⚠️ No message content to copy", "warn") + return False + + if not with_context: + final_text = strip_message_metadata(text) + toast_label = "clean message" + else: + final_text = text + toast_label = "message with context" + + self.clipboard_buf = final_text + ok = copy_to_clipboard(final_text) + preview = (final_text.replace("\n", " ")[:30] + "...") if len(final_text) > 30 else final_text.replace("\n", " ") + self.set_toast(f"📋 Copied {toast_label} to clipboard: \"{preview}\" ({len(final_text)} chars)", "success") + return ok + + def trigger_reply_to_message(self, message: dict) -> bool: + """Trigger reply to a specific message bubble or segment.""" + if not message: + return False + mid = message.get("message_id") or "" + seq = message.get("seq") or "" + role = message.get("role", "assistant") + text = (message.get("text") or "").strip() + preview = text.replace("\n", " ")[:45] + ("…" if len(text) > 45 else "") + self.reply_target = { + "msg_id": mid, + "seq": seq, + "role": role, + "text": text, + "preview": preview, + } + self.editor_mode = "INSERT" + self.focus_pane = "transcript" + ref_id = f"seq:{seq}" if seq else f"id:{mid[:8]}" + self.set_toast(f"↩ Replying to {role.upper()} ({ref_id}): \"{preview}\"", "success") + return True + + def _get_transcript_line_idx_at(self, screen_y: int): + """Convert a screen y coordinate to an absolute line index in _transcript_cache_lines.""" + h, w = self.stdscr.getmaxyx() + header_lines = 2 if self.mode == "box" else 1 + content_y = header_lines + transcript_y = content_y + 2 + transcript_h = getattr(self, "transcript_height", max(1, h - content_y - 5)) + + row = screen_y - transcript_y + if 0 <= row < transcript_h: + total_lines = getattr(self, "transcript_total_lines", 0) + if total_lines == 0: + return None + max_offset = max(0, total_lines - transcript_h) + start_line = max_offset - getattr(self, "transcript_scroll_offset", 0) + target_idx = start_line + row + if 0 <= target_idx < total_lines: + return target_idx + return None + + def _get_message_at_transcript_line(self, screen_y: int): + """Find the message object associated with the transcript line at screen_y.""" + target_idx = self._get_transcript_line_idx_at(screen_y) + if target_idx is None: + return None + lines = getattr(self, "_transcript_cache_lines", []) + for i in range(target_idx, -1, -1): + if i < len(lines) and lines[i][2] is not None: + return lines[i][2] + return None + + def _get_current_visible_line_idx(self) -> int: + """Get the middle visible line index in transcript.""" + total_lines = getattr(self, "transcript_total_lines", 0) + if total_lines == 0: + return 0 + transcript_h = getattr(self, "transcript_height", 20) + max_offset = max(0, total_lines - transcript_h) + start_line = max_offset - getattr(self, "transcript_scroll_offset", 0) + return min(total_lines - 1, max(0, start_line + transcript_h // 2)) + + def _copy_rendered_line_range(self, start_idx: int, end_idx: int) -> str: + """Extract clean text from rendered_lines across [start_idx, end_idx].""" + lines = getattr(self, "_transcript_cache_lines", []) + if not lines: + return "" + s = max(0, min(start_idx, end_idx)) + e = min(len(lines) - 1, max(start_idx, end_idx)) + out = [] + for i in range(s, e + 1): + raw = lines[i][0] + # Strip decorative vertical borders, 2-space indents, and buttons + if raw.startswith("│ "): + clean = raw[3:] + elif raw.startswith("│ "): + clean = raw[2:] + elif raw.startswith("│"): + clean = raw[1:] + elif raw.startswith(" "): + clean = raw[2:] + elif raw.startswith("┌──") or raw.startswith("└──") or raw.startswith("● ") or raw.startswith("▸ "): + clean = re.sub(r"\[(↩ Reply|📋 Copy)\]", "", raw).strip() + else: + clean = raw + out.append(clean) + text = "\n".join(out).strip("\n") + if text: + self.clipboard_buf = text + copy_to_clipboard(text) + return text + + def _render_transcript(self, y: int, x: int, h: int, w: int): + self._btn_copy_all_bounds = None + self._msg_copy_buttons = [] + with self.data.lock: + active_node = self.data.active_node + 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) + if cur_target != getattr(self, "_last_rendered_target", None): + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self._last_rendered_target = cur_target + + # Thread Header Banner + is_focused = (self.focus_pane == "transcript" and self.editor_mode == "NORMAL") + badge = "" + is_main_chat = False + with self.data.lock: + cached_threads = self.data.threads_cache.get(active_node, []) + for t in cached_threads: + if t.get("session_id") == active_tid: + if t.get("is_main"): + badge = " [★ MAIN CHAT]" + is_main_chat = True + elif t.get("pinned"): + badge = " [📌 PINNED]" + break + + tid_short = f"({active_tid[:8]}) " if active_tid else "" + cd = self.data.get_node_cooldown_remaining(active_node) + if cd > 0: + recency_badge = f"[⏳ {cd}s CD] " + else: + hist_ts = self.data.history_fetch_times.get((active_node, active_tid), self.data.fetch_times.get(active_node, 0.0)) + if hist_ts > 0 and (time.time() - hist_ts) < 25.0: + recency_badge = "[LIVE] " + elif hist_ts > 0: + recency_badge = f"[{format_recency(hist_ts)}] " + else: + recency_badge = "" + + node_str = f"| Node: {active_node.upper()} {recency_badge}" + # Compute unread count for current thread/main chat + chat_unread = self.data.get_unread_count(active_node, active_tid) + if chat_unread > 0: + msgs_str = f"| {len(msgs)} msgs " + unread_pill = f"[● {chat_unread} new] " + else: + msgs_str = f"| {len(msgs)} msgs " + unread_pill = "" + copy_all_btn = "[📋 Copy All]" + + # Ensure message count, unread pill, and copy button ALWAYS have room + tail_w = len(msgs_str) + len(unread_pill) + len(copy_all_btn) + 4 + avail_for_left = max(15, w - tail_w) + + 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() + meta_str = f" {meta} " if meta else " " + + max_title_w = avail_for_left - len(base_prefix) - len(meta_str) + if max_title_w < 6: + meta = f"{badge} {node_str}".strip() + meta_str = f" {meta} " if meta else " " + max_title_w = avail_for_left - len(base_prefix) - len(meta_str) + + if max_title_w < 4: + meta_str = " " + max_title_w = avail_for_left - len(base_prefix) - 1 + title_disp = (raw_title[:max(2, max_title_w - 1)] + "…") if len(raw_title) > max(2, max_title_w) else raw_title + elif len(raw_title) > max_title_w: + title_disp = raw_title[:max(2, max_title_w - 1)] + "…" + else: + title_disp = raw_title + + 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("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) + if unread_pill: + self.safe_addstr(self.stdscr, y, cur_x, unread_pill, self._attr("warn_banner")) + cur_x += len(unread_pill) + + # 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("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 + + 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 + + if not msgs: + is_loading_hist = self.data.is_loading_history(active_node, active_tid) + if is_loading_hist: + spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + spin_char = spinner_chars[int(time.time() * 8) % len(spinner_chars)] + self.safe_addstr(self.stdscr, transcript_y + 2, x + 4, f"{spin_char} Loading message history for '{active_title}'...", self._attr("agent")) + self.safe_addstr(self.stdscr, transcript_y + 3, x + 4, "Syncing latest conversation packets...", self._attr("dim")) + else: + self.safe_addstr(self.stdscr, transcript_y + 2, x + 4, f"No messages in thread '{active_title}'.", self._attr("dim")) + self.safe_addstr(self.stdscr, transcript_y + 3, x + 4, "Type your prompt in the box below and press Enter to chat.", self._attr("dim")) + return + + # Check wrapped lines cache to prevent CPU thrashing during scrolling + last_msg = msgs[-1] if msgs else {} + last_id = last_msg.get("seq") or last_msg.get("message_id") or "" + last_text_hash = hashlib.md5((last_msg.get("text") or "").encode()).hexdigest()[:8] + cache_key = (active_node, active_tid, len(msgs), last_id, last_text_hash, w, chat_unread, getattr(self, "transcript_style", "clean")) + if cache_key == getattr(self, "_transcript_cache_key", None) and getattr(self, "_transcript_cache_lines", None): + rendered_lines = self._transcript_cache_lines + else: + rendered_lines = [] + max_line_w = max(10, w - 6) + unread_start_idx = len(msgs) - chat_unread if chat_unread > 0 else -1 + is_boxed = (getattr(self, "transcript_style", "clean") == "boxed") + + for m_idx, m in enumerate(msgs): + # If this message is the start of unread messages, render a prominent divider banner + if m_idx == unread_start_idx: + div_lbl = f" ● {chat_unread} NEW UNREAD MESSAGE{'S' if chat_unread > 1 else ''} " + pad_len = max(2, (max_line_w - len(div_lbl)) // 2) + div_str = "─" * pad_len + div_lbl + "─" * max(2, max_line_w - len(div_lbl) - pad_len) + rendered_lines.append((div_str, self._attr("warn_banner"), None)) + rendered_lines.append(("", self._attr("normal"), None)) + + role = m.get("role", "assistant") + text = m.get("text", "") + seq = m.get("seq", "") + mid = m.get("message_id", "")[:8] + + # Role Header + is_msg_unread = (m_idx >= unread_start_idx and unread_start_idx >= 0) + unread_flag = " ● NEW" if is_msg_unread else "" + if role == "assistant": + if is_boxed: + prefix = f"┌── [{active_node.upper()} / MUSE AGENT] ─── seq:{seq} id:{mid}{unread_flag}" + else: + seq_info = f"seq:{seq} " if seq else "" + mid_info = f"id:{mid}" if mid else "" + prefix = f"● {active_node.upper()} {seq_info}{mid_info}{unread_flag}".strip() + hdr_attr = self._attr("warn_banner") if is_msg_unread else self._attr("agent") + else: + if is_boxed: + prefix = f"┌── [YOU / OPERATOR] ─── id:{mid}{unread_flag}" + else: + mid_info = f"id:{mid}" if mid else "" + prefix = f"▸ YOU {mid_info}{unread_flag}".strip() + hdr_attr = self._attr("user") + + reply_btn = "[↩ Reply]" + copy_clean_btn = "[📋 Copy]" + copy_ctx_btn = "[📑+ Context]" + 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)) + + # Body lines with wrapping + body_lines = text.split("\n") + content_w = max_line_w - (3 if is_boxed else 2) + for bl in body_lines: + if len(bl) > 3000: + bl = bl[:3000] + " ... [line truncated]" + + if "[WO:" in bl: + line_attr = self._attr("wo_badge") + elif "[TOOL " in bl or "[RESULT " in bl: + line_attr = self._attr("tool") + else: + line_attr = self._attr("normal") if role == "assistant" else self._attr("green") + + # Fast-path: Only invoke textwrap if line exceeds column width + if len(bl) > content_w: + wrapped = textwrap.wrap(bl, width=content_w, break_long_words=True) or [""] + else: + wrapped = [bl] + + for wl in wrapped: + if is_boxed: + rendered_lines.append((f"│ {wl}", line_attr, None)) + else: + rendered_lines.append((f" {wl}", line_attr, None)) + + if is_boxed: + rendered_lines.append(("└" + "─" * min(40, w - 8), self._attr("dim"), None)) + rendered_lines.append(("", self._attr("normal"), None)) # Spacer + + self._transcript_cache_key = cache_key + self._transcript_cache_lines = rendered_lines + + # Scroll calculation & strict state bounds tracking + total_lines = len(rendered_lines) + self.transcript_total_lines = total_lines + self.transcript_height = transcript_h + max_offset = max(0, total_lines - transcript_h) + + # STRICT BOUNDS CLAMPING: Prevent scrollback from ever getting overwhelmed or detached + if self.auto_scroll or max_offset == 0: + self.transcript_scroll_offset = 0 + self.auto_scroll = True + start_line = max_offset + else: + self.transcript_scroll_offset = max(0, min(self.transcript_scroll_offset, max_offset)) + if self.transcript_scroll_offset == 0: + self.auto_scroll = True + start_line = max_offset - self.transcript_scroll_offset + + 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 + sel_max = None + if is_copy_mode and self.visual_sel_start is not None: + sel_min = min(self.visual_sel_start, self.copy_cursor_line) + sel_max = max(self.visual_sel_start, self.copy_cursor_line) + + for row in range(transcript_h): + idx = start_line + row + if idx < total_lines: + item = rendered_lines[idx] + txt = item[0] + attr = item[1] + msg_obj = item[2] if len(item) > 2 else None + + if is_copy_mode: + if sel_min is not None and sel_min <= idx <= sel_max: + attr = self._attr("selected") + elif idx == self.copy_cursor_line: + attr = self._attr("warn_banner") + + self.safe_addstr(self.stdscr, transcript_y + row, x + 2, txt.ljust(max(0, w - 4)), attr) + if isinstance(msg_obj, dict): + # Draw Reply button + if "[↩ Reply]" in txt: + r_str = "[↩ Reply]" + r_rel_x = txt.find(r_str) + if r_rel_x >= 0: + r_scr_x = x + 2 + r_rel_x + self.safe_addstr(self.stdscr, transcript_y + row, r_scr_x, r_str, self._attr("warn_banner")) + self._msg_reply_buttons.append((transcript_y + row, r_scr_x, r_scr_x + len(r_str), msg_obj)) + + # Draw Clean Copy button + if "[📋 Copy]" in txt: + c_str = "[📋 Copy]" + c_rel_x = txt.find(c_str) + if c_rel_x >= 0: + c_scr_x = x + 2 + c_rel_x + self.safe_addstr(self.stdscr, transcript_y + row, c_scr_x, c_str, self._attr("button")) + self._msg_copy_buttons.append((transcript_y + row, c_scr_x, c_scr_x + len(c_str), msg_obj.get("text", ""))) + + # Draw Full Context Copy button + if "[📑+ Context]" in txt: + ctx_str = "[📑+ Context]" + 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")) + + # Visual vertical scrollbar track on rightmost margin + if total_lines > transcript_h: + track_h = transcript_h + thumb_pos = int((start_line / max(1, total_lines - transcript_h)) * (track_h - 1)) + for row in range(track_h): + sb_char = "█" if row == thumb_pos else "│" + sb_attr = self._attr("selected") if row == thumb_pos else self._attr("dim") + self.safe_addstr(self.stdscr, transcript_y + row, x + w - 1, sb_char, sb_attr) + + # Scroll indicator if not at bottom (shows unread lines based on scroll length / distance) + if not self.auto_scroll and self.transcript_scroll_offset > 0: + pct = int((start_line / max(1, max_offset)) * 100) if max_offset > 0 else 100 + unread_str = f" | ● {self.transcript_scroll_offset} unread lines below" if self.transcript_scroll_offset > 0 else "" + scroll_pill = f" [↑ SCROLLED BACK ({self.transcript_scroll_offset}/{max_offset} lines | {pct}%{unread_str}) - Click or press 'G'/End to follow] " + self.safe_addstr(self.stdscr, y + h - 1, max(x + 2, x + w - len(scroll_pill) - 2), scroll_pill, self._attr("warn_banner")) + + # ----------------------------------------------------------------------- + # Box Mode Views (Fleet, Approvals, Jobs, Tmux, Logs) + # ----------------------------------------------------------------------- + def _render_fleet_table(self, y: int, x: int, h: int, w: int): + self.safe_addstr(self.stdscr, y, x + 1, "NETVM FLEET VITALITY & COGNITIVE CONSENSUS MATRIX", self._attr("bold")) + self.safe_addstr(self.stdscr, y + 1, x + 1, " NODE STATUS COGNITIVE LOCK LATENCY CDP PORT WORK ASSIGNMENT / PAGE", self._attr("dim")) + self.safe_addstr(self.stdscr, y + 2, x + 1, "─" * (w - 2), self._attr("dim")) + + with self.data.lock: + statuses = dict(self.data.node_status) + cog_cache = dict(self.data.cognitive_cache) + work_issues = list(self.data.work_issues_cache) + + total_nodes = len(self.data.nodes) + if self.fleet_sel_idx >= total_nodes: + self.fleet_sel_idx = max(0, total_nodes - 1) + + visible_rows = max(3, h - 5) + scroll_start = getattr(self, "table_scroll_idx", 0) + if self.fleet_sel_idx < scroll_start: + scroll_start = self.fleet_sel_idx + elif self.fleet_sel_idx >= scroll_start + visible_rows: + scroll_start = self.fleet_sel_idx - visible_rows + 1 + max_scroll = max(0, total_nodes - visible_rows) + scroll_start = max(0, min(scroll_start, max_scroll)) + self.table_scroll_idx = scroll_start + + row_y = y + 3 + for row_i in range(visible_rows): + idx = scroll_start + row_i + if idx >= total_nodes: + break + node = self.data.nodes[idx] + is_sel = (idx == self.fleet_sel_idx) + st = statuses.get(node, {}) + status_text = st.get("status", "ACTIVE") + is_blocked = st.get("blocked", False) + if is_blocked: + status_text = "BLOCKED" + lat = f"{st.get('latency')}ms" if st.get("latency") is not None else "--" + port = 9410 + idx * 10 + if node == "dev": + port = 9455 + tab_name = st.get("tab") or "-" + + # Cognitive state & Lock + cog = cog_cache.get(node, {}) + c_st = cog.get("status") or ("INPUT_WAIT" if is_blocked else ("ACTIVE" if status_text == "ACTIVE" else "IDLE")) + c_locked = cog.get("cognitive_lock", False) + lock_str = "LOCKED" if c_locked else "OPEN" + lock_attr = self._attr("danger") if c_locked else self._attr("success") + + # Assigned build ticket + assigned = None + for iss in work_issues: + if iss.get("assignee", {}).get("username") == node and iss.get("state") == "open": + assigned = f"#{iss.get('number')} {iss.get('title', '')[:28]}" + break + work_txt = assigned or tab_name + + head = "▶ " if is_sel else " " + row_attr = self._attr("selected") if is_sel else self._attr("normal") + node_attr = row_attr if is_sel else self._attr("bold") + st_attr = row_attr if is_sel else (self._attr("danger") if is_blocked else (self._attr("success") if status_text == "ACTIVE" else self._attr("red"))) + + node_unread = self.data.get_node_unread_total(node) + node_tag = f"{node} [●{node_unread}]" if node_unread > 0 else node + + self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{node_tag:<8}"[:8], node_attr) + self.safe_addstr(self.stdscr, row_y, x + 10, f"{status_text:<10}", st_attr) + self.safe_addstr(self.stdscr, row_y, x + 21, 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 + 37, f"{lock_str:<8}", row_attr if is_sel else lock_attr) + self.safe_addstr(self.stdscr, row_y, x + 46, f"{lat:<10}", row_attr if is_sel else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 57, f":{port:<9}", row_attr if is_sel else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 68, f"{work_txt[:max(0, w - 85)]}", row_attr if is_sel else self._attr("normal")) + chat_btn = f"[▶ Chat +{node_unread}]" if node_unread > 0 else "[▶ Open Chat]" + btn_attr = self._attr("warn_banner") if (is_sel or node_unread > 0) else self._attr("dim") + self.safe_addstr(self.stdscr, row_y, max(x + 68, w - 17), chat_btn, btn_attr) + row_y += 1 + + hint_y = y + h - 1 + sel_node = self.data.nodes[self.fleet_sel_idx].upper() if self.data.nodes else "-" + hints = f"Selected: [{sel_node}] [Enter]: Chat [W]: Dispatch Work [F]: Refocus Main [T]: Thoughts [P]: Menu [H]: Heal [L]: Loopback" + 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")) + self.safe_addstr(self.stdscr, y + 1, x + 1, "─" * (w - 2), self._attr("dim")) + + with self.data.lock: + cog_cache = dict(self.data.cognitive_cache) + 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", + "dev": "builder", + "pip": "builder", + "def": "fleet-agent", + "muse": "fleet-agent", + "muse-main": "host", + } + ports = { + "opm": 2228, + "646": 2226, + "dev": 2230, + "pip": 2227, + "def": 2229, + "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 + + 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 " " + 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") + + cog = cog_cache.get(node, {}) + c_st = cog.get("status") or "IDLE" + if node == "muse-main": + c_st = "HOST" + c_locked = cog.get("cognitive_lock", False) + lock_str = "LOCKED" if c_locked else "OPEN" + lock_attr = self._attr("danger") if c_locked else self._attr("success") + + assigned = None + for iss in issues: + if iss.get("assignee", {}).get("username") == node and iss.get("state") == "open": + assigned = f"#{iss.get('number')} {iss.get('title', '')[:30]}" + 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_y += 1 + + # 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")) + row_y += 1 + + 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]: + num = f"#{iss.get('number')}" + st = iss.get("state", "").upper() + asg = iss.get("assignee", {}).get("username", "-") if iss.get("assignee") else "-" + tit = iss.get("title", "")[:44] + lbls = ", ".join(l.get("name") for l in iss.get("labels", [])) + + 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_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")) + row_y += 1 + + 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]: + pnum = f"#{pr.get('number')}" + merged = pr.get("merged", False) + p_st = "MERGED" if merged else pr.get("state", "").upper() + p_head = pr.get("head", {}).get("ref", "-")[:32] + p_tit = pr.get("title", "")[:42] + st_attr = self._attr("success") if merged else self._attr("warn_banner") + + 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")) + 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")) + + # Footer hints + hint_y = y + h - 1 + hints = "[W]: Dispatch Work Ticket [F]: Refocus Main Chat [T]: Thoughts [P]: Menu [L]: Loopback Sweep [H]: Heal [r]: Refresh" + 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): + self.safe_addstr(self.stdscr, y, x + 1, "FLEET APPROVALS & PERMISSION GATES", self._attr("bold")) + self.safe_addstr(self.stdscr, y + 1, x + 1, " NODE STATUS TARGET / PURPOSE FAST ACTIONS", self._attr("dim")) + self.safe_addstr(self.stdscr, y + 2, x + 1, "─" * (w - 2), self._attr("dim")) + + with self.data.lock: + apps = list(self.data.approvals_cache) + + if not apps: + 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) + + row_y = y + 3 + for idx, it in enumerate(apps): + if row_y >= y + h - 2: + break + n = it.get("node", "-") + st = it.get("status", "CLEAR") + is_sel = (idx == self.approvals_sel_idx) + + tgt = it.get("target") or it.get("purpose") or it.get("title") or "-" + if st == "INPUT_WAIT": + waits = it.get("input_waits") or [] + if waits: + tgt = "; ".join(f"{w.get('task')}" for w in waits) + else: + tgt = "Subagent waiting for operator input" + + st_attr = self._attr("danger") if st in ("PENDING", "INPUT_WAIT", "KEY_APPROVAL") else self._attr("success") + + head = "▶ " if is_sel else " " + row_attr = self._attr("selected") if is_sel else self._attr("normal") + + line_str = f"{head}{n:<6} {st:<12} {tgt[:w - 54]:<40}" + self.safe_addstr(self.stdscr, row_y, x + 1, line_str, row_attr) + + act_x = min(w - 38, max(x + 64, len(line_str) + 3)) + if st == "PENDING": + self.safe_addstr(self.stdscr, row_y, act_x, "[1:Allow] [2:Always] [3:Deny]", self._attr("warn_banner") if is_sel else self._attr("dim")) + elif st == "INPUT_WAIT": + self.safe_addstr(self.stdscr, row_y, act_x, "[r:Proceed] [x:Dismiss] [Enter:Chat]", self._attr("wo_badge") if is_sel else self._attr("dim")) + else: + self.safe_addstr(self.stdscr, row_y, act_x, "[Clear]", self._attr("dim")) + + row_y += 1 + + hint_y = y + h - 1 + sel_node = apps[self.approvals_sel_idx].get("node", "").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" + 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): + self.safe_addstr(self.stdscr, y, x + 1, "SYSTEMD & BOX JOBS SCHEDULER", self._attr("bold")) + self.safe_addstr(self.stdscr, y + 1, x + 1, " NAME AGENT SCHEDULE TIMEOUT DESCRIPTION", self._attr("dim")) + self.safe_addstr(self.stdscr, y + 2, x + 1, "─" * (w - 2), self._attr("dim")) + + with self.data.lock: + jobs = list(self.data.jobs_cache) + + if not jobs: + self.safe_addstr(self.stdscr, y + 3, x + 2, "No jobs loaded from box-ctl.", self._attr("dim")) + return + + if self.jobs_sel_idx >= len(jobs): + self.jobs_sel_idx = max(0, len(jobs) - 1) + + visible_rows = max(1, h - 5) + # Dynamic viewport scroll so selected head is always in view as position changes + scroll_start = getattr(self, "jobs_scroll_start", 0) + if self.jobs_sel_idx < scroll_start: + scroll_start = self.jobs_sel_idx + elif self.jobs_sel_idx >= scroll_start + visible_rows: + scroll_start = self.jobs_sel_idx - visible_rows + 1 + self.jobs_scroll_start = max(0, min(scroll_start, max(0, len(jobs) - visible_rows))) + + pos_info = f" [{self.jobs_sel_idx + 1}/{len(jobs)}] " + self.safe_addstr(self.stdscr, y, w - len(pos_info) - 2, pos_info, self._attr("selected")) + + for i in range(visible_rows): + idx = self.jobs_scroll_start + i + if idx >= len(jobs): + break + j = jobs[idx] + is_sel = (idx == self.jobs_sel_idx) + name = j.get("name", "") + agent = j.get("agent", "-") + sched = j.get("schedule", "-") + timeout = f"{j.get('timeout', '-')}s" + desc = j.get("description", "-") or "-" + + head = "▶ " if is_sel else " " + row_attr = self._attr("selected") if is_sel else self._attr("normal") + row_y = y + 3 + i + + self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{name[:25]:<26}", row_attr) + self.safe_addstr(self.stdscr, row_y, x + 30, f"{agent:<7}", row_attr if is_sel else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 38, f"{sched[:19]:<20}", row_attr if is_sel else (self._attr("green") if sched != "manual" else self._attr("dim"))) + self.safe_addstr(self.stdscr, row_y, x + 59, f"{timeout:<9}", row_attr if is_sel else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 69, f"{desc[:w - 95]}", row_attr if is_sel else self._attr("normal")) + + # Interactive action pills: [▶ Chat] and [Run] + chat_btn = "[▶ Chat]" + run_btn = "[Run]" + self.safe_addstr(self.stdscr, row_y, max(x + 70, w - 21), chat_btn, self._attr("selected") if is_sel else self._attr("wo_badge")) + self.safe_addstr(self.stdscr, row_y, max(x + 80, w - 10), run_btn, self._attr("warn_banner") if is_sel else self._attr("dim")) + + # Scrollbar track + if len(jobs) > visible_rows: + track_h = visible_rows + thumb_pos = int((self.jobs_scroll_start / max(1, len(jobs) - 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_job = jobs[self.jobs_sel_idx].get("name", "") if jobs else "-" + hints = f"Selected: [{sel_job}] [Enter / Click]: Read/Chat in Agent 1 [r/t]: Trigger Job Now [s]: Status [j/k]: Scroll" + self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim")) + + def _render_tmux_view(self, y: int, x: int, h: int, w: int): + self.safe_addstr(self.stdscr, y, x + 1, "SHARED MUSE TMUX SESSIONS (/tmp/tmux-muse.sock)", self._attr("bold")) + self.safe_addstr(self.stdscr, y + 1, x + 1, " SESSION NAME / STATUS", self._attr("dim")) + self.safe_addstr(self.stdscr, y + 2, x + 1, "─" * (w - 2), self._attr("dim")) + + with self.data.lock: + sessions = list(self.data.tmux_cache) + + if not sessions: + 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) + + row_y = y + 3 + for idx, s in enumerate(sessions[:h - 5]): + is_sel = (idx == self.tmux_sel_idx) + head = "▶ " if is_sel else " " + row_attr = self._attr("selected") if is_sel else self._attr("normal") + line = f"{head}{s}" + self.safe_addstr(self.stdscr, row_y, x + 1, line[:w - 22], row_attr) + self.safe_addstr(self.stdscr, row_y, max(x + 30, w - 20), "[Attach]", self._attr("wo_badge") if is_sel else self._attr("dim")) + 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 + + 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" + 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): + self.safe_addstr(self.stdscr, y, x + 1, "LIVE DM LOG FEED (dm-log.jsonl)", self._attr("bold")) + self.safe_addstr(self.stdscr, y + 1, x + 1, "─" * (w - 2), self._attr("dim")) + + with self.data.lock: + logs = list(self.data.dm_logs_cache) + + row_y = y + 2 + for it in logs[:h - 4]: + l_type = it.get("type", "") + agent = it.get("agent", "") + to = it.get("to", "") + target = it.get("target", "") + ts = it.get("ts", "")[:19].replace("T", " ") + mid = it.get("id", "") + + 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) + row_y += 1 + + def get_ssh_rows(self) -> list: + """SSH view row order: fleet nodes first, then extra tunnel accounts.""" + with self.data.lock: + extras = list(self.data.ssh_cache.keys()) + nodes = list(self.data.nodes) + return ssh_row_order(nodes, extras) + + def _render_ssh_view(self, y: int, x: int, h: int, w: int): + self.safe_addstr(self.stdscr, y, x + 1, f"CONTAINER SSH TUNNEL HEALTH (jump: {SSH_OPERATOR_USER}@{SSH_JUMP_HOST})", self._attr("bold")) + + with self.data.lock: + jump = self.data.ssh_jump_reachable + checked_at = self.data.ssh_checked_at + sweep_ms = self.data.ssh_check_latency_ms + check_err = self.data.ssh_check_error + ssh_cache = dict(self.data.ssh_cache) + + if jump is None: + jump_txt, jump_attr = "sweep pending…", self._attr("dim") + elif jump: + jump_txt = f"jump OK (sweep {sweep_ms}ms, checked {format_recency(checked_at)})" + jump_attr = self._attr("success") + else: + jump_txt = f"jump UNREACHABLE ({(check_err or 'unknown')[:w - 24]})" + jump_attr = self._attr("danger") + self.safe_addstr(self.stdscr, y + 1, x + 1, jump_txt[:w - 2], jump_attr) + self.safe_addstr(self.stdscr, y + 2, x + 1, " ACCOUNT SSH PORT SSH STATE LAT SSH BANNER / HOSTKEY TERM PORT TERM STATE SINCE", self._attr("dim")) + self.safe_addstr(self.stdscr, y + 3, x + 1, "─" * (w - 2), self._attr("dim")) + + rows = self.get_ssh_rows() + if self.ssh_sel_idx >= len(rows): + self.ssh_sel_idx = max(0, len(rows) - 1) + + visible_rows = max(3, h - 6) + scroll_start = getattr(self, "ssh_scroll_idx", 0) + if self.ssh_sel_idx < scroll_start: + scroll_start = self.ssh_sel_idx + elif self.ssh_sel_idx >= scroll_start + visible_rows: + scroll_start = self.ssh_sel_idx - visible_rows + 1 + scroll_start = max(0, min(scroll_start, max(0, len(rows) - visible_rows))) + self.ssh_scroll_idx = scroll_start + + row_y = y + 4 + for row_i in range(visible_rows): + idx = scroll_start + row_i + if idx >= len(rows): + break + acct = rows[idx] + is_sel = (idx == self.ssh_sel_idx) + row_attr = self._attr("selected") if is_sel else self._attr("normal") + info = SSH_TUNNEL_PORTS.get(acct, {}) + health = ssh_cache.get(acct, {}) + sport = info.get("port", "?") + tport = info.get("terminal", "?") + + ssh_up = health.get("ssh_up") + term_up = health.get("term_up") + if ssh_up is True: + ssh_txt, ssh_attr = "UP ", self._attr("success") + elif ssh_up is False: + ssh_txt, ssh_attr = "DOWN", self._attr("danger") + else: + ssh_txt, ssh_attr = "?? ", self._attr("dim") + if term_up is True: + term_txt, term_attr = "UP ", self._attr("success") + elif term_up is False: + term_txt, term_attr = "DOWN", self._attr("danger") + else: + term_txt, term_attr = "?? ", self._attr("dim") + if is_sel: + ssh_attr = row_attr + term_attr = row_attr + + lat = health.get("ssh_latency_ms") + lat_txt = f"{lat}ms" if lat is not None else "--" + banner = (health.get("ssh_banner") or health.get("term_http") or "-").strip() or "-" + since_ts = health.get("state_since", 0.0) + if ssh_up is None and term_up is None: + since_txt = "never checked" if jump is None else "unknown" + else: + since_txt = f"{'up' if ssh_up else 'down'} {format_recency(since_ts)}" + + head = "▶ " if is_sel else " " + name_attr = row_attr if is_sel else self._attr("bold") + self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{acct:<10}"[:12], name_attr) + self.safe_addstr(self.stdscr, row_y, x + 13, f":{sport:<8}", row_attr if is_sel else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 23, ssh_txt, ssh_attr) + self.safe_addstr(self.stdscr, row_y, x + 32, f"{lat_txt:<7}", row_attr if is_sel else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 40, banner[:26].ljust(26), row_attr if is_sel else self._attr("normal")) + self.safe_addstr(self.stdscr, row_y, x + 67, f":{tport:<8}", row_attr if is_sel else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 77, term_txt, term_attr) + self.safe_addstr(self.stdscr, row_y, x + 83, since_txt[:w - 84 - 8], row_attr if is_sel else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, max(x + 90, w - 8), "[SSH]", self._attr("wo_badge") if is_sel else self._attr("dim")) + row_y += 1 + + hint_y = y + h - 1 + sel_acct = rows[self.ssh_sel_idx].upper() if rows else "-" + hints = f"Selected: [{sel_acct}] [Enter/s]: SSH pop-out (tmux) [c]: Copy dial [u]: Container uptime [r]: Refresh [j/k]: Nav" + self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim")) + + # ----------------------------------------------------------------------- + # Chat History Sends Search & Prompts Subsystem + # ----------------------------------------------------------------------- + def get_chat_sends(self, query: str = "") -> list: + """Gather all operator/user sent messages across current session and history cache.""" + sends = [] + seen_texts = set() + + # 1. From live session input_history (most recent first) + for txt in reversed(self.input_history): + txt_s = txt.strip() + if txt_s and not txt_s.startswith("/") and txt_s not in seen_texts: + seen_texts.add(txt_s) + sends.append({ + "text": txt_s, + "node": self.data.active_node, + "thread_title": self.data.active_thread_title or "active", + "timestamp": time.time(), + "source": "session", + }) + + # 2. From cached message history across all nodes/threads + with self.data.lock: + hist_items = list(self.data.history_cache.items()) + + for (node, tid), msgs in hist_items: + th_title = tid[:8] if tid else "thread" + cached_threads = self.data.threads_cache.get(node, []) + for t in cached_threads: + if t.get("session_id") == tid: + th_title = t.get("title") or th_title + break + + for m in reversed(msgs): + if m.get("role") == "user": + text = (m.get("text") or "").strip() + if text and text not in seen_texts and not text.startswith("/"): + seen_texts.add(text) + ts = 0.0 + if m.get("timestamp"): + try: + ts = datetime.fromisoformat(m["timestamp"]).timestamp() + except Exception: + pass + sends.append({ + "text": text, + "node": node, + "thread_title": th_title, + "timestamp": ts, + "source": "history", + }) + + # Sort sends by timestamp descending (most recent first) + sends.sort(key=lambda s: s.get("timestamp", 0.0), reverse=True) + + # Check pinned status against prompt_manager + pinned_prompts_texts = {p["prompt"].strip() for p in self.prompt_manager.prompts if p.get("pinned")} + for s in sends: + s["is_pinned"] = s["text"] in pinned_prompts_texts + + # Filter by query if provided + if query: + q = query.lower() + sends = [s for s in sends if q in s["text"].lower() or q in s["node"].lower() or q in s["thread_title"].lower()] + + return sends + + def _render_prompt_shelf(self, y: int, x: int, h: int, w: int): + """Render compact persistent prompt shelf in View 1 (Agent Chat).""" + if h <= 0 or w <= 0: + return + + self._prompt_chip_bounds = [] + self._prompt_shelf_y = y + with self.data.lock: + prompts = list(self.prompts) + + # Clear background rows + self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("normal")) + if h > 1: + self.safe_addstr(self.stdscr, y + 1, x, " " * w, self._attr("normal")) + + # Row 0: Header & Controls + hdr_lbl = " PROMPTS " + self.safe_addstr(self.stdscr, y, x + 1, hdr_lbl, self._attr("header")) + + btn_x = x + 1 + len(hdr_lbl) + 1 + exp_btn = "[P:Expand]" + self.safe_addstr(self.stdscr, y, btn_x, exp_btn, self._attr("warn_banner")) + self._btn_expand_bounds = (btn_x, btn_x + len(exp_btn)) + + sends_btn = " [🔍 Sends] " + sends_x = btn_x + len(exp_btn) + 1 + self.safe_addstr(self.stdscr, y, sends_x, sends_btn, self._attr("selected")) + self._btn_sends_bounds = (sends_x, sends_x + len(sends_btn)) + + nav_btns = " [◄] [►] " + nav_x = sends_x + len(sends_btn) + 1 + self.safe_addstr(self.stdscr, y, nav_x, nav_btns, self._attr("dim")) + self._btn_prev_bounds = (nav_x + 1, nav_x + 4) + self._btn_next_bounds = (nav_x + 5, nav_x + 8) + + new_btn = "[+New]" + new_x = nav_x + len(nav_btns) + 1 + self.safe_addstr(self.stdscr, y, new_x, new_btn, self._attr("dim")) + self._btn_new_bounds = (new_x, new_x + len(new_btn)) + + # Fill line with subtle divider + div_start = new_x + len(new_btn) + 1 + if div_start < w: + self.safe_addstr(self.stdscr, y, div_start, "─" * (w - div_start), self._attr("dim")) + + # Row 1: Prompt chips + chip_y = y + 1 if h > 1 else y + cur_x = x + 1 + + if self.prompt_scroll_idx > 0: + self.safe_addstr(self.stdscr, chip_y, cur_x, "◀ ", self._attr("warn_banner")) + cur_x += 2 + + visible_prompts = prompts[self.prompt_scroll_idx:] + for idx_offset, p in enumerate(visible_prompts): + p_idx = self.prompt_scroll_idx + idx_offset + 1 + is_pinned = p.get("pinned", False) + title = p.get("title", "Prompt") + pin_badge = "📌" if is_pinned else "" + chip_str = f"[{pin_badge}{p_idx}:{title}]" + + if cur_x + len(chip_str) >= w - 4: + remaining_w = w - cur_x - 3 + if remaining_w > 8: + chip_str = f"[{pin_badge}{p_idx}:{title[:remaining_w - 7]}…]" + attr = self._attr("wo_badge") if is_pinned else self._attr("normal") + self.safe_addstr(self.stdscr, chip_y, cur_x, chip_str, attr) + self._prompt_chip_bounds.append((cur_x, cur_x + len(chip_str), p)) + cur_x += len(chip_str) + self.safe_addstr(self.stdscr, chip_y, w - 2, "▶", self._attr("warn_banner")) + break + + attr = self._attr("wo_badge") if is_pinned else self._attr("normal") + self.safe_addstr(self.stdscr, chip_y, cur_x, chip_str, attr) + self._prompt_chip_bounds.append((cur_x, cur_x + len(chip_str), p)) + cur_x += len(chip_str) + 1 + + # ----------------------------------------------------------------------- + # Bottom Input Bar & Toast + # ----------------------------------------------------------------------- + def _render_bottom_bar(self, y: int, x: int, h: int, w: int): + 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")) + self.safe_addstr(self.stdscr, y + 2, x, " " * w, self._attr("normal")) + + # 2. Toast / Status message or quick shortcuts + now = time.time() + is_dev_layer = getattr(self, "device_layer_active", False) and now <= self.device_layer_timeout + if is_dev_layer: + with self.data.lock: + legend = " ".join([f"{i+1}:{n.upper()}" for i, n in enumerate(self.data.nodes)]) + hints = f" [DEVICE SUBSTRATE] {legend} | [Space/n] Next | [p/Back] Prev | [Esc] Lock" + self.safe_addstr(self.stdscr, y + 1, x + 1, hints[:w - 2], self._attr("warn_banner")) + elif now < self.toast_time: + attr = self._attr("success") if self.toast_style == "success" else (self._attr("danger") if self.toast_style == "error" else self._attr("selected")) + self.safe_addstr(self.stdscr, y + 1, x + 1, f" {self.toast_msg} ", attr) + elif getattr(self, "reply_target", None): + rep = self.reply_target + r_role = rep.get("role", "agent").upper() + r_seq = rep.get("seq") or rep.get("msg_id", "")[:8] + r_prev = rep.get("preview", "")[:35] + rep_pill = f" [↩ Replying to {r_role} (id:{r_seq}): \"{r_prev}\" | Esc to cancel] " + self.safe_addstr(self.stdscr, y + 1, x + 1, rep_pill[:w - 2], self._attr("warn_banner")) + elif getattr(self, "copy_mode", False): + sel_count = (abs(self.copy_cursor_line - self.visual_sel_start) + 1) if self.visual_sel_start is not None else 1 + sel_lbl = f"{sel_count} lines selected" if self.visual_sel_start is not None else "line cursor" + hints = f" [COPY MODE: {sel_lbl}] j/k: Move v: Toggle Select y/Enter: Copy to Clipboard Esc/q: Exit (Shift+Drag mouse)" + self.safe_addstr(self.stdscr, y + 1, x + 1, hints[:w - 2], self._attr("warn_banner")) + else: + if self.editor_mode == "NORMAL": + 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 ACTIVE] Multi-line ({nl_cnt + 1} lines) [Enter] Send [Alt+Enter] Newline [Esc] Normal" + else: + 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 + prompt_y = y + 2 + with self.data.lock: + node = self.data.active_node + cur_tid = self.data.active_thread_id + cached_threads = self.data.threads_cache.get(node, []) + is_main = False + for t in cached_threads: + if t.get("session_id") == cur_tid and t.get("is_main"): + is_main = True + break + if is_main: + tid = "★ Main Chat" + else: + tid = (self.data.active_thread_title or "sidechat")[:12] + + if is_dev_layer: + mode_badge = " DEVICE " + mode_attr = self._attr("warn_banner") + else: + mode_badge = " NORMAL " if self.editor_mode == "NORMAL" else " INSERT " + mode_attr = self._attr("mode_normal") if self.editor_mode == "NORMAL" else self._attr("mode_insert") + self.safe_addstr(self.stdscr, prompt_y, x + 1, mode_badge, mode_attr) + + nl_cnt = self.input_buf.count("\n") + lines_badge = f" [{nl_cnt + 1} lines]" if nl_cnt > 0 else "" + prompt_str = f" {node} ({tid}){lines_badge} > " + prompt_x = x + 1 + len(mode_badge) + self.safe_addstr(self.stdscr, prompt_y, prompt_x, prompt_str, self._attr("user")) + + inp_x = prompt_x + len(prompt_str) + max_inp_w = w - inp_x - 1 + + disp_buf = self.input_buf.replace("\n", "↵") + if self.editor_mode == "NORMAL": + if self.input_buf: + 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: + 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: + curses.curs_set(0) + except curses.error: + pass + else: + # INSERT mode + cursor = self.input_cursor + start_idx = 0 + if cursor >= max_inp_w: + start_idx = cursor - max_inp_w + 1 + disp_text = disp_buf[start_idx:start_idx + max_inp_w] + self.safe_addstr(self.stdscr, prompt_y, inp_x, disp_text, self._attr("normal")) + + if not self.modal: + screen_cx = inp_x + (cursor - start_idx) + try: + curses.curs_set(1) + self.stdscr.move(prompt_y, min(screen_cx, w - 1)) + except curses.error: + pass + + # ----------------------------------------------------------------------- + # Interactive Modals (Help, Approvals, New Sidechat, Work Order) + # ----------------------------------------------------------------------- + def _render_modal(self, screen_h: int, screen_w: int): + if not self.modal: + return + + is_context_popup = self.modal in ("chat_context", "agent_context", "message_context") + if is_context_popup: + modal_w = min(56, screen_w - 4) + modal_h = min(17 if self.modal == "chat_context" else (14 if self.modal == "agent_context" else 11), screen_h - 2) + anchor_x, anchor_y = getattr(self, "context_popup_pos", (screen_w // 4, screen_h // 3)) + top_y = max(1, min(anchor_y, screen_h - modal_h - 2)) + left_x = max(1, min(anchor_x, screen_w - modal_w - 2)) + self._context_popup_bounds = (left_x, top_y, modal_w, modal_h) + else: + modal_w = min(84 if self.modal in ("prompts", "history_search") else 74, screen_w - 6) + modal_h = min(22 if self.modal in ("prompts", "history_search") else 20, screen_h - 4) + top_y = (screen_h - modal_h) // 2 + left_x = (screen_w - modal_w) // 2 + self._context_popup_bounds = (left_x, top_y, modal_w, modal_h) + + # Draw modal box + for row in range(modal_h): + self.safe_addstr(self.stdscr, top_y + row, left_x, " " * modal_w, self._attr("header")) + + # Border + self.safe_addstr(self.stdscr, top_y, left_x, "┌" + "─" * (modal_w - 2) + "┐", self._attr("dim")) + for row in range(1, modal_h - 1): + self.safe_addstr(self.stdscr, top_y + row, left_x, "│", self._attr("dim")) + self.safe_addstr(self.stdscr, top_y + row, left_x + modal_w - 1, "│", self._attr("dim")) + self.safe_addstr(self.stdscr, top_y + row, left_x + 1, " " * (modal_w - 2), self._attr("normal")) + self.safe_addstr(self.stdscr, top_y + modal_h - 1, left_x, "└" + "─" * (modal_w - 2) + "┘", self._attr("dim")) + + # Title + title_map = { + "help": " KEYBINDINGS & NAVIGATION CHEAT-SHEET ", + "approvals": " PENDING APPROVALS RESOLUTION CENTER ", + "new_sidechat": " CREATE NEW SIDECHAT ", + "rename_sidechat": " RENAME SIDECHAT ", + "work_order": " ISSUE OPERATOR WORK ORDER ([WO]) ", + "agent_search": " 󰢹 FAST AGENT SEARCH & SELECT ", + "prompts": " 󰢹 PROMPT & SKILL LIBRARY // VIM HJKL NAVIGATION ", + "history_search": " 󰢹 SEARCH CHAT SENDS // PIN TO PROMPT FILL ", + "prompt_formulate": " FORMULATE NEW PROMPT SKILL ", + "chat_context": " 󰢹 CHAT ACTIONS ", + "agent_context": " 󰢹 AGENT ACTIONS ", + "message_context": " 󰢹 MESSAGE ACTIONS ", + "work_dispatch": " DISPATCH NEW BUILD TICKET (box work start) ", + "agent_thoughts": " LIVE COGNITIVE THOUGHT STREAM & SCREEN ", + "agent_menu": " AGENT PROFILE MENU (TASKS / TIMERS / APPROVALS) ", + } + title = title_map.get(self.modal, " DIALOG ") + self.safe_addstr(self.stdscr, top_y, left_x + 3, title, self._attr("header")) + + # Modal Content + if self.modal == "help": + help_lines = [ + ("[NORMAL MODE]", "Vim-style modal lock: prompt is locked against accidental input"), + ("[i] or [a]", "Enter INSERT mode (focus bottom prompt to type chat/command)"), + ("[v] / [[]", "Tmux/Vi Visual Copy Mode: j/k to select lines, y to copy"), + ("[Shift+Drag]", "Terminal-native text selection bypass (copy with Ctrl+Shift+C)"), + ("[Mouse Drag]", "Click & drag on transcript to highlight and auto-copy lines"), + ("[f] or [/]", "Fast search & select fleet agent modal (type/click to jump)"), + ("[Tab] / [S-Tab]", "Cycle & instantly load next / prev conversation thread"), + ("[h] / [l]", "Switch active focus between Sidebar and Transcript"), + ("[j] / [k] / [↑/↓]", "Navigate threads in sidebar or scroll chat transcript"), + ("[Enter]", "Open selected thread (Sidebar) or enter Insert mode (Transcript)"), + ("[1] - [9] / [0]", "Instantly switch active agent node (in NORMAL mode)"), + ("[Alt+Space]", "Device/Agent modifier substrate: 0-9 jumps, Space cycles"), + ("[n]", "Create new sidechat session on active node"), + ("[y] / [Y]", "Copy latest message ([y]) or full transcript ([Y]) to clipboard"), + ("[b]", "Toggle clean line-by-line vs classic boxed frame view"), + ("[R-Click / x]", "Open Context Menu for chat thread, fleet agent, or message"), + ("[p] or [/pin]", "Pin/unpin thread (sidebar) or pin chat send to prompts (chat)"), + ("[s] / [Ctrl-R]", "Search chat sends history & pin to prompt shelf"), + ("[P] / [/prompt]", "Open expanded Prompt & Skills library modal"), + ("[w] or [/wo]", "Compose and cryptographically sign a Work Order"), + ("[a] or [F2]", "Open Approvals Resolution Drawer (Allow, Always, Deny)"), + ("[F5] or [m]", "Toggle between Muse Chat TUI and Box Fleet Command TUI"), + ("[1]-[7] (Box)", "Switch Box tabs: Chat/Fleet/Approvals/Jobs/Tmux/Logs/SSH"), + ("[Tab 7: SSH]", "Enter/s: tmux pop-out c: copy dial u: container uptime r: refresh"), + ("[g] / [G] / [Home/End]", "Jump to oldest message / follow live latest message"), + ("[q]", "Quit TUI (in NORMAL mode)"), + ] + for idx, (k, desc) in enumerate(help_lines): + if idx + 2 >= modal_h - 1: + break + self.safe_addstr(self.stdscr, top_y + 2 + idx, left_x + 3, f"{k:<14}", self._attr("bold")) + self.safe_addstr(self.stdscr, top_y + 2 + idx, left_x + 18, desc[:modal_w - 21], self._attr("normal")) + + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "Press [Esc] or [Enter] to close", self._attr("dim")) + + elif self.modal == "approvals": + with self.data.lock: + apps = [it for it in self.data.approvals_cache if it.get("status") in ("PENDING", "INPUT_WAIT", "KEY_APPROVAL")] + + if not apps: + self.safe_addstr(self.stdscr, top_y + 3, left_x + 4, "✔ No pending approvals or input waits across fleet!", self._attr("success")) + self.safe_addstr(self.stdscr, top_y + 5, left_x + 4, "Press [Esc] to return to chat.", self._attr("dim")) + else: + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, "Pending approvals & blocked tasks requiring operator review (j/k to select):", self._attr("dim")) + self.approvals_sel_idx = max(0, min(getattr(self, "approvals_sel_idx", 0), len(apps) - 1)) + sel_app = apps[self.approvals_sel_idx] + sel_node = sel_app.get("node", self.data.active_node) + + max_rows = max(1, modal_h - 9) + start_row = 0 + if self.approvals_sel_idx >= max_rows: + start_row = self.approvals_sel_idx - max_rows + 1 + + for idx, it in enumerate(apps[start_row:start_row + max_rows]): + real_idx = start_row + idx + n = it.get("node") + st = it.get("status") + tgt = it.get("target") or it.get("purpose") or "" + if not tgt or tgt == "No pending approvals": + waits = it.get("input_waits") or [] + if waits: + tgt = waits[0].get("description") or waits[0].get("task") or "Operator input requested" + else: + tgt = it.get("title") or "Task intervention requested" + bg_count = it.get("bg_tasks_count", 0) + bg_tag = f" [{bg_count} review]" if bg_count else "" + + is_sel = (real_idx == self.approvals_sel_idx) + cursor = "▶ " if is_sel else " " + line = f"{cursor}[{n.upper()}] {st}{bg_tag}: {tgt[:modal_w - 28]}" + attr = self._attr("selected") if is_sel else self._attr("danger") + self.safe_addstr(self.stdscr, top_y + 4 + idx, left_x + 3, line, attr) + + # Detail box for selected approval + det_y = top_y + modal_h - 5 + trust = "TRUSTED" if sel_app.get("is_trusted") else "UNTRUSTED / HUMAN REVIEW" + full_title = sel_app.get("title") or sel_app.get("purpose") or "" + self.safe_addstr(self.stdscr, det_y, left_x + 3, f"Selected: [{sel_node.upper()}] — {trust}: {full_title[:modal_w - len(sel_node) - len(trust) - 20]}", self._attr("bold")) + + act_y = top_y + modal_h - 3 + self.safe_addstr(self.stdscr, act_y, left_x + 3, f"Actions for [{sel_node.upper()}]:", self._attr("bold")) + self.safe_addstr(self.stdscr, act_y + 1, left_x + 3, "[1] Allow [2] Always [3] Deny [R] Proceed [X] Dismiss [P] Auto-Fleet [C] Clear All", self._attr("selected")) + + elif self.modal == "new_sidechat": + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Agent: {self.data.active_node.upper()} (Sidechat-first policy)", self._attr("bold")) + self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, "Enter sidechat title / purpose:", self._attr("dim")) + + # Input field + field_y = top_y + 5 + field_w = modal_w - 6 + self.safe_addstr(self.stdscr, field_y, left_x + 3, "┌" + "─" * field_w + "┐", self._attr("dim")) + self.safe_addstr(self.stdscr, field_y + 1, left_x + 3, "│ " + self.modal_input_buf.ljust(field_w - 2) + " │", self._attr("normal")) + self.safe_addstr(self.stdscr, field_y + 2, left_x + 3, "└" + "─" * field_w + "┘", self._attr("dim")) + + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Enter] Create Sidechat [Esc] Cancel", self._attr("dim")) + + # Move cursor to modal input + try: + self.stdscr.move(field_y + 1, left_x + 5 + self.modal_input_cursor) + except curses.error: + pass + + elif self.modal == "work_order": + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Target: {self.data.active_node.upper()} ({self.data.active_thread_title})", self._attr("bold")) + self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, "Format: — <body>", self._attr("dim")) + + field_y = top_y + 5 + field_w = modal_w - 6 + self.safe_addstr(self.stdscr, field_y, left_x + 3, "┌" + "─" * field_w + "┐", self._attr("dim")) + self.safe_addstr(self.stdscr, field_y + 1, left_x + 3, "│ " + self.modal_input_buf.ljust(field_w - 2) + " │", self._attr("normal")) + self.safe_addstr(self.stdscr, field_y + 2, left_x + 3, "└" + "─" * field_w + "┘", self._attr("dim")) + + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Enter] Sign & Issue Work Order [Esc] Cancel", self._attr("dim")) + try: + self.stdscr.move(field_y + 1, left_x + 5 + self.modal_input_cursor) + except curses.error: + pass + + elif self.modal == "agent_search": + q = self.modal_input_buf.strip().lower() + with self.data.lock: + all_nodes = list(self.data.nodes) + statuses = dict(self.data.node_status) + active_node = self.data.active_node + + matches = [n for n in all_nodes if q in n.lower()] if q else all_nodes + if matches: + self.agent_search_sel = max(0, min(getattr(self, "agent_search_sel", 0), len(matches) - 1)) + else: + self.agent_search_sel = 0 + + # Search Prompt field + prompt_label = "Search: " + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, prompt_label, self._attr("bold")) + query_box = self.modal_input_buf + " " + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3 + len(prompt_label), query_box[:modal_w - len(prompt_label) - 8], self._attr("selected")) + + # Divider + self.safe_addstr(self.stdscr, top_y + 3, left_x + 2, "─" * (modal_w - 4), self._attr("dim")) + + # Results List Area + list_start_y = top_y + 4 + max_results_visible = max(3, modal_h - 7) + + scroll_offset = 0 + if self.agent_search_sel >= max_results_visible: + scroll_offset = self.agent_search_sel - max_results_visible + 1 + self.agent_search_scroll = scroll_offset + + if not matches: + self.safe_addstr(self.stdscr, list_start_y + 1, left_x + 4, f"No agents matching '{self.modal_input_buf}'", self._attr("dim")) + else: + for i in range(max_results_visible): + match_idx = scroll_offset + i + if match_idx >= len(matches): + break + node = matches[match_idx] + is_sel = (match_idx == self.agent_search_sel) + is_current = (node == active_node) + st = statuses.get(node, {}) + is_blocked = st.get("blocked", False) + is_waiting = st.get("waiting", False) + lat = st.get("latency") + lat_str = f"{lat}ms" if lat is not None else "--" + + st_str = "BLOCKED" if is_blocked else ("WAIT" if is_waiting else st.get("status", "ACTIVE")) + dot = "●" + dot_attr = self._attr("danger") if is_blocked else (self._attr("warn_banner") if is_waiting else self._attr("success")) + + pointer = "▶ " if is_sel else " " + orig_idx = all_nodes.index(node) if node in all_nodes else match_idx + num_badge = f"[{orig_idx + 1}]" if orig_idx < 9 else (f"[0]" if orig_idx == 9 else f"[{orig_idx + 1:>2}]") + curr_tag = " (Current)" if is_current else "" + + rec_str = format_recency(self.data.agent_interactions.get(node, 0.0)) + row_attr = self._attr("selected") if is_sel else self._attr("normal") + row_text = f"{pointer}{num_badge} {node:<6}{curr_tag}" + self.safe_addstr(self.stdscr, list_start_y + i, left_x + 3, f"{row_text:<20}", row_attr) + self.safe_addstr(self.stdscr, list_start_y + i, left_x + 24, dot, dot_attr) + self.safe_addstr(self.stdscr, list_start_y + i, left_x + 26, f" {st_str:<7} {rec_str:>8} {lat_str:>5}", row_attr if is_sel else self._attr("dim")) + + # Footer + footer_txt = "[Enter] Select [↑/↓] Navigate [1-9] Jump [Esc] Cancel" + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, footer_txt, self._attr("dim")) + + # Cursor in search box + try: + self.stdscr.move(top_y + 2, left_x + 3 + len(prompt_label) + self.modal_input_cursor) + except curses.error: + pass + + elif self.modal == "prompts": + with self.data.lock: + prompts = list(self.prompts) + + if self.prompt_sel_idx >= len(prompts): + self.prompt_sel_idx = max(0, len(prompts) - 1) + + list_w = min(32, modal_w // 2) + preview_w = modal_w - list_w - 3 + left_list_x = left_x + 2 + right_preview_x = left_x + list_w + 2 + + # Divider line between list and preview + for row in range(1, modal_h - 2): + self.safe_addstr(self.stdscr, top_y + row, left_x + list_w + 1, "│", self._attr("dim")) + + # Header on list side + self.safe_addstr(self.stdscr, top_y + 1, left_list_x, f"SKILL PROMPTS ({len(prompts)})", self._attr("bold")) + + 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 + + for r_i in range(visible_list_rows): + p_idx = scroll_start + r_i + if p_idx >= len(prompts): + break + p = prompts[p_idx] + is_sel = (p_idx == self.prompt_sel_idx) + pointer = "▶ " if is_sel else " " + pin_icon = "📌" if p.get("pinned") else " " + title = p.get("title", "Prompt") + line_str = f"{pointer}{pin_icon}{p_idx + 1}. {title}" + row_attr = self._attr("selected") if is_sel else (self._attr("wo_badge") if p.get("pinned") else self._attr("normal")) + self.safe_addstr(self.stdscr, top_y + 2 + r_i, left_list_x, f"{line_str[:list_w - 2]:<{list_w - 2}}", row_attr) + + # Right preview pane + if prompts and 0 <= self.prompt_sel_idx < len(prompts): + sel_p = prompts[self.prompt_sel_idx] + title = sel_p.get("title", "") + pid = sel_p.get("id", "") + tags = ", ".join(sel_p.get("tags", [])) + body = sel_p.get("prompt", "") + is_pinned = sel_p.get("pinned", False) + + self.safe_addstr(self.stdscr, top_y + 1, right_preview_x, f"Title: {title[:preview_w - 8]}", self._attr("header")) + self.safe_addstr(self.stdscr, top_y + 2, right_preview_x, f"ID: {pid} Tags: [{tags}]", self._attr("dim")) + pin_txt = "📌 Pinned: Yes (in compact shelf)" if is_pinned else "Pinned: No" + self.safe_addstr(self.stdscr, top_y + 3, right_preview_x, pin_txt, self._attr("wo_badge") if is_pinned else self._attr("dim")) + self.safe_addstr(self.stdscr, top_y + 4, right_preview_x, "─" * (preview_w - 2), self._attr("dim")) + + body_lines = [] + for para in body.split("\n"): + if para: + wrapped = textwrap.wrap(para, width=preview_w - 3, break_long_words=True) or [""] + body_lines.extend(wrapped) + else: + body_lines.append("") + + preview_avail_rows = modal_h - 7 + scroll = max(0, min(self.prompt_preview_scroll, max(0, len(body_lines) - preview_avail_rows))) + for b_i in range(preview_avail_rows): + b_idx = scroll + b_i + if b_idx >= len(body_lines): + break + self.safe_addstr(self.stdscr, top_y + 5 + b_i, right_preview_x, body_lines[b_idx][:preview_w - 2], self._attr("normal")) + + # Footer + footer_txt = "[Enter/Click: Fill] [p: Pin] [n: Formulate] [d: Delete] [s: Sends] [hjkl: Nav] [Esc: Close]" + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, footer_txt[:modal_w - 6], self._attr("dim")) + + elif self.modal == "history_search": + q = self.modal_input_buf.strip().lower() + sends = self.get_chat_sends(q) + + if sends: + self.history_search_sel = max(0, min(getattr(self, "history_search_sel", 0), len(sends) - 1)) + else: + self.history_search_sel = 0 + + # Search field + prompt_label = "Search sends: " + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, prompt_label, self._attr("bold")) + query_box = self.modal_input_buf + " " + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3 + len(prompt_label), query_box[:modal_w - len(prompt_label) - 8], self._attr("selected")) + + self.safe_addstr(self.stdscr, top_y + 3, left_x + 2, "─" * (modal_w - 4), self._attr("dim")) + + # Results + list_start_y = top_y + 4 + max_results_visible = max(2, modal_h - 8) + + scroll_offset = 0 + if self.history_search_sel >= max_results_visible: + scroll_offset = self.history_search_sel - max_results_visible + 1 + self.history_search_scroll = scroll_offset + + if not sends: + self.safe_addstr(self.stdscr, list_start_y + 1, left_x + 4, f"No sent messages matching '{self.modal_input_buf}'", self._attr("dim")) + else: + for i in range(max_results_visible): + match_idx = scroll_offset + i + if match_idx >= len(sends): + break + send = sends[match_idx] + is_sel = (match_idx == self.history_search_sel) + pointer = "▶ " if is_sel else " " + is_pinned = send.get("is_pinned", False) + pin_icon = "📌" if is_pinned else " " + node_tag = f"[{send.get('node', 'agent').upper()}]" + rec = format_recency(send.get("timestamp", 0.0)) + snippet = send.get("text", "").replace("\n", " ") + line_str = f"{pointer}{pin_icon} {node_tag:<7} {snippet[:modal_w - 28]:<34} {rec:>6}" + row_attr = self._attr("selected") if is_sel else (self._attr("wo_badge") if is_pinned else self._attr("normal")) + self.safe_addstr(self.stdscr, list_start_y + i, left_x + 3, line_str[:modal_w - 6], row_attr) + + # Preview box for selected send + prev_y = top_y + modal_h - 4 + self.safe_addstr(self.stdscr, prev_y, left_x + 2, "─" * (modal_w - 4), self._attr("dim")) + if sends and 0 <= self.history_search_sel < len(sends): + sel_send = sends[self.history_search_sel] + sel_text = sel_send.get("text", "").replace("\n", " ") + prev_text = f"Selected: \"{sel_text[:modal_w - 18]}\"" + self.safe_addstr(self.stdscr, prev_y + 1, left_x + 3, prev_text, self._attr("green")) + + # Footer + is_cur_pinned = bool(sends and 0 <= self.history_search_sel < len(sends) and sends[self.history_search_sel].get("is_pinned")) + pin_action_txt = "[p: Unpin from Prompts]" if is_cur_pinned else "[p: Pin to Prompts]" + footer_txt = f"{pin_action_txt} [Enter: Fill Composer] [↑/↓/Tab: Nav] [Esc: Cancel]" + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, footer_txt[:modal_w - 6], self._attr("dim")) + + # Cursor in search box + try: + self.stdscr.move(top_y + 2, left_x + 3 + len(prompt_label) + self.modal_input_cursor) + except curses.error: + pass + + elif self.modal == "prompt_formulate": + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, "Formulate New Prompt Skill (saved to prompts.json)", self._attr("bold")) + self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, "Prompt Title / Name:", self._attr("dim")) + + field_y = top_y + 5 + field_w = modal_w - 6 + self.safe_addstr(self.stdscr, field_y, left_x + 3, "┌" + "─" * field_w + "┐", self._attr("dim")) + self.safe_addstr(self.stdscr, field_y + 1, left_x + 3, "│ " + self.modal_input_buf.ljust(field_w - 2) + " │", self._attr("normal")) + self.safe_addstr(self.stdscr, field_y + 2, left_x + 3, "└" + "─" * field_w + "┘", self._attr("dim")) + + body_preview = self.modal_extra.get("body") or self.input_buf.strip() or "(Will formulate from draft or title)" + self.safe_addstr(self.stdscr, top_y + 9, left_x + 3, f"Prompt Body: {body_preview[:modal_w - 18]}", self._attr("normal")) + + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Enter] Save Prompt Skill [Esc] Cancel", self._attr("dim")) + try: + self.stdscr.move(field_y + 1, left_x + 5 + self.modal_input_cursor) + except curses.error: + pass + + elif self.modal == "rename_sidechat": + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, "Rename Sidechat Title:", self._attr("bold")) + field_y = top_y + 4 + field_w = modal_w - 6 + self.safe_addstr(self.stdscr, field_y, left_x + 3, "┌" + "─" * field_w + "┐", self._attr("dim")) + self.safe_addstr(self.stdscr, field_y + 1, left_x + 3, "│ " + self.modal_input_buf.ljust(field_w - 2) + " │", self._attr("normal")) + self.safe_addstr(self.stdscr, field_y + 2, left_x + 3, "└" + "─" * field_w + "┘", self._attr("dim")) + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Enter] Save Title [Esc] Cancel", self._attr("dim")) + try: + self.stdscr.move(field_y + 1, left_x + 5 + self.modal_input_cursor) + except curses.error: + pass + + elif self.modal == "chat_context": + chat_info = getattr(self, "context_chat", None) or {} + t = chat_info.get("thread", {}) + node = chat_info.get("node", self.data.active_node) + sid = t.get("session_id", "") + title = t.get("title") or (sid[:8] if sid else "Main Chat") + is_main = t.get("is_main", False) + is_pinned = t.get("pinned", False) + unread = self.data.get_unread_count(node, sid) + + type_lbl = "★ MAIN CHAT" if is_main else "SIDECHAT" + pin_lbl = "📌 PINNED" if is_pinned else "" + unread_lbl = f"● {unread} UNREAD" if unread > 0 else "0 unread" + status_tags = " | ".join(filter(None, [type_lbl, pin_lbl, unread_lbl])) + + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Target: [{node.upper()}] \"{title[:modal_w - len(node) - 18]}\"", self._attr("bold")) + self.safe_addstr(self.stdscr, top_y + 3, left_x + 3, f"Status: {status_tags} (id: {sid[:12]})", self._attr("dim")) + self.safe_addstr(self.stdscr, top_y + 4, left_x + 2, "─" * (modal_w - 4), self._attr("dim")) + + actions = [ + ("open", "[1] ▶ Open & Focus Chat", "[Enter/o]"), + ("pin", f"[2] 📌 {'Unpin Chat' if is_pinned else 'Pin to Top'}", "[p]"), + ("read", "[3] ✔ Mark as Read", "[m]"), + ("copy_id", "[4] 📋 Copy Session ID", "[y]"), + ("copy_url", "[5] 🔗 Copy Chat Web URL", "[u]"), + ("reply", "[6] ↩ Reply to Latest", "[r]"), + ("rename", "[7] ✏ Rename Sidechat", "[e]"), + ("fork", "[8] 🌱 Fork to Sidechat", "[b]"), + ("close", "[9] 🗑 Close Sidechat", "[d]"), + ] + self._chat_context_actions = actions + self.chat_context_sel = max(0, min(getattr(self, "chat_context_sel", 0), len(actions) - 1)) + + start_row = top_y + 5 + for idx, (act_id, label, hotkey) in enumerate(actions): + cur_y = start_row + idx + if cur_y >= top_y + modal_h - 2: + break + is_sel = (idx == self.chat_context_sel) + pointer = "▶ " if is_sel else " " + row_attr = self._attr("selected") if is_sel else self._attr("normal") + self.safe_addstr(self.stdscr, cur_y, left_x + 3, " " * (modal_w - 6), row_attr) + self.safe_addstr(self.stdscr, cur_y, left_x + 3, f"{pointer}{label}", row_attr) + self.safe_addstr(self.stdscr, cur_y, left_x + modal_w - 3 - len(hotkey), hotkey, self._attr("dim") if not is_sel else self._attr("selected")) + + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Esc/q] Close [1-9/Hotkeys/Drag+Release] Run Action", self._attr("dim")) + + elif self.modal == "agent_context": + agent_info = getattr(self, "context_agent", None) or {} + node = agent_info.get("node", self.data.active_node) + st_info = self.data.node_status.get(node, {}) + status_str = st_info.get("status", "ACTIVE") + latency = st_info.get("latency") + lat_str = f"{latency}ms" if latency is not None else "--" + node_unread = self.data.get_node_unread_total(node) + thread_cnt = len(self.data.threads_cache.get(node, [])) + + status_summary = f"{status_str} ({lat_str}) | {thread_cnt} chats | ● {node_unread} unread" + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Fleet Agent: [{node.upper()}]", self._attr("bold")) + self.safe_addstr(self.stdscr, top_y + 3, left_x + 3, f"Status: {status_summary}", self._attr("dim")) + self.safe_addstr(self.stdscr, top_y + 4, left_x + 2, "─" * (modal_w - 4), self._attr("dim")) + + actions = [ + ("open", "[1] ▶ Open Chat with Agent", "[Enter/o]"), + ("approvals", "[2] ⚡ Resolve Approvals", "[a]"), + ("tmux", "[3] 📋 Copy Tmux Command", "[t]"), + ("wo", "[4] 📝 Issue Work Order", "[w]"), + ("sync", "[5] 🔄 Sync Threads", "[s]"), + ("read_all", "[6] ✔ Mark All Read", "[m]"), + ] + self._agent_context_actions = actions + self.agent_context_sel = max(0, min(getattr(self, "agent_context_sel", 0), len(actions) - 1)) + + start_row = top_y + 5 + for idx, (act_id, label, hotkey) in enumerate(actions): + cur_y = start_row + idx + if cur_y >= top_y + modal_h - 2: + break + is_sel = (idx == self.agent_context_sel) + pointer = "▶ " if is_sel else " " + row_attr = self._attr("selected") if is_sel else self._attr("normal") + self.safe_addstr(self.stdscr, cur_y, left_x + 3, " " * (modal_w - 6), row_attr) + self.safe_addstr(self.stdscr, cur_y, left_x + 3, f"{pointer}{label}", row_attr) + self.safe_addstr(self.stdscr, cur_y, left_x + modal_w - 3 - len(hotkey), hotkey, self._attr("dim") if not is_sel else self._attr("selected")) + + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Esc/q] Close [j/k/↑/↓] Navigate [1-8/Click] Run Action", self._attr("dim")) + + elif self.modal == "message_context": + m = getattr(self, "context_message", None) or {} + role = m.get("role", "agent").upper() + seq = m.get("seq") + mid = m.get("message_id", "")[:8] + snippet = m.get("text", "").replace("\n", " ")[:modal_w - 24] + ref_id = f"seq:{seq}" if seq else f"id:{mid}" + + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Message: [{role}] ({ref_id})", self._attr("bold")) + self.safe_addstr(self.stdscr, top_y + 3, left_x + 3, f"Preview: \"{snippet}…\"", self._attr("dim")) + self.safe_addstr(self.stdscr, top_y + 4, left_x + 2, "─" * (modal_w - 4), self._attr("dim")) + + actions = [ + ("reply", "[1] ↩ Reply to Message", "[Enter / r]"), + ("copy", "[2] 📋 Copy Clean Text (No Meta)", "[y]"), + ("copy_context", "[3] 📑 Copy Full (With Context)", "[Y]"), + ("pin", "[4] 📌 Pin to Prompt Shelf", "[p]"), + ("copy_id", "[5] 🏷 Copy Message ID", "[c]"), + ] + self._message_context_actions = actions + self.message_context_sel = max(0, min(getattr(self, "message_context_sel", 0), len(actions) - 1)) + + start_row = top_y + 5 + for idx, (act_id, label, hotkey) in enumerate(actions): + cur_y = start_row + idx + if cur_y >= top_y + modal_h - 2: + break + is_sel = (idx == self.message_context_sel) + pointer = "▶ " if is_sel else " " + row_attr = self._attr("selected") if is_sel else self._attr("normal") + self.safe_addstr(self.stdscr, cur_y, left_x + 3, " " * (modal_w - 6), row_attr) + self.safe_addstr(self.stdscr, cur_y, left_x + 3, f"{pointer}{label}", row_attr) + self.safe_addstr(self.stdscr, cur_y, left_x + modal_w - 3 - len(hotkey), hotkey, self._attr("dim") if not is_sel else self._attr("selected")) + + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Esc/q] Close [j/k/↑/↓] Navigate [1-5/Click] Run Action", self._attr("dim")) + + elif self.modal == "work_dispatch": + target = getattr(self, "dispatch_target_node", "dev") + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Target Worker: @{target.upper()} [Tab: Cycle Worker]", self._attr("bold")) + self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, "Enter Build Ticket Title & Goal:", self._attr("dim")) + + field_y = top_y + 5 + field_w = modal_w - 6 + self.safe_addstr(self.stdscr, field_y, left_x + 3, "┌" + "─" * field_w + "┐", self._attr("dim")) + self.safe_addstr(self.stdscr, field_y + 1, left_x + 3, "│ " + self.modal_input_buf.ljust(field_w - 2) + " │", self._attr("normal")) + self.safe_addstr(self.stdscr, field_y + 2, left_x + 3, "└" + "─" * field_w + "┘", self._attr("dim")) + + self.safe_addstr(self.stdscr, top_y + modal_h - 4, left_x + 3, "⚡ Dispatches with 45s Synchronous Readback Gate", self._attr("header")) + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Enter] Start & Dispatch Ticket [Tab] Cycle Target [Esc] Cancel", self._attr("dim")) + + try: + self.stdscr.move(field_y + 1, left_x + 5 + self.modal_input_cursor) + except curses.error: + pass + + elif self.modal == "agent_thoughts": + node = getattr(self, "selected_thought_node", self.data.active_node) + scr_data = getattr(self, "agent_thought_data", {}) + screen = scr_data.get("screen", {}) + url = screen.get("url", "-") + title = screen.get("title", "Active Screen") + status = screen.get("avatar_status") or "Connected" + gen = "🧠 GENERATING" if screen.get("is_generating") else ("💭 TYPING" if screen.get("is_typing") else "🟢 SETTLED") + + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Agent: @{node.upper()} | State: {gen} | Avatar: {status}", self._attr("bold")) + self.safe_addstr(self.stdscr, top_y + 3, left_x + 3, f"Context: {title[:modal_w - 20]} ({url[:max(0, modal_w - len(title) - 25)]})", self._attr("dim")) + self.safe_addstr(self.stdscr, top_y + 4, left_x + 2, "─" * (modal_w - 4), self._attr("dim")) + + paragraphs = screen.get("recent_paragraphs", []) + p_start_y = top_y + 5 + max_p_lines = modal_h - 8 + if not paragraphs: + self.safe_addstr(self.stdscr, p_start_y, left_x + 3, "No active thought paragraphs captured yet.", self._attr("dim")) + else: + for idx, p in enumerate(paragraphs[-max_p_lines:]): + clean_p = p.replace("\n", " ").strip() + self.safe_addstr(self.stdscr, p_start_y + idx, left_x + 3, f"• {clean_p[:modal_w - 8]}", self._attr("normal")) + + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[F] Refocus Main Chat [W] Dispatch Work [Esc/q] Close", self._attr("header")) + + elif self.modal == "agent_menu": + node = getattr(self, "selected_menu_node", self.data.active_node) + tab = getattr(self, "menu_tab", "activity") + menu_data = getattr(self, "agent_menu_data", {}) + + tabs_bar = f"Tab: [1: Activity] [2: Upcoming] [3: Approvals] [4: Identity] (Current: {tab.upper()})" + self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Agent: @{node.upper()} — Profile Menu", self._attr("bold")) + self.safe_addstr(self.stdscr, top_y + 3, left_x + 3, tabs_bar[:modal_w - 6], self._attr("header")) + self.safe_addstr(self.stdscr, top_y + 4, left_x + 2, "─" * (modal_w - 4), self._attr("dim")) + + items = menu_data.get("data", {}).get("items", []) + m_start_y = top_y + 5 + max_items = modal_h - 8 + if not items: + raw = menu_data.get("data", {}).get("raw_text", "") + if raw: + for idx, line in enumerate(raw.splitlines()[:max_items]): + self.safe_addstr(self.stdscr, m_start_y + idx, left_x + 3, line[:modal_w - 8], self._attr("normal")) + else: + self.safe_addstr(self.stdscr, m_start_y, left_x + 3, "No entries found in menu.", self._attr("dim")) + else: + for idx, it in enumerate(items[:max_items]): + t_str = f" [{it['time']}]" if it.get("time") else "" + line_str = f"• {it['title']}: {it.get('detail', '')}{t_str}" + self.safe_addstr(self.stdscr, m_start_y + idx, left_x + 3, line_str[:modal_w - 8], self._attr("normal")) + + self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[1-4 / Tab] Switch Tab [Esc/q] Close", self._attr("dim")) + + # ----------------------------------------------------------------------- + # Keypress & Event Handling + # ----------------------------------------------------------------------- + def _switch_to_node_idx(self, idx: int) -> bool: + with self.data.lock: + nodes = list(self.data.nodes) + if 0 <= idx < len(nodes): + node = nodes[idx] + self.data.set_active_target(node) + self.thread_sel_idx = 0 + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.set_toast(f"󰢹 Agent [{idx + 1}/{len(nodes)}]: {node.upper()}", "success") + return True + return False + + def _cycle_active_node(self, step: int = 1) -> None: + with self.data.lock: + nodes = list(self.data.nodes) + cur = self.data.active_node + if not nodes: + return + try: + cur_idx = nodes.index(cur) + except ValueError: + cur_idx = 0 + new_idx = (cur_idx + step) % len(nodes) + self._switch_to_node_idx(new_idx) + + def _handle_device_layer_key(self, ch: int) -> bool: + # Number keys 0-9: jump directly to agent + if ord('0') <= ch <= ord('9'): + digit = ch - ord('0') + idx = (digit - 1) if digit > 0 else (len(self.data.nodes) - 1) + self._switch_to_node_idx(idx) + self.device_layer_active = False + return True + + # Space, Tab, n/N: Cycle forward through agents + if ch in (ord(' '), ord('n'), ord('N'), 9): + self.device_layer_timeout = time.time() + 4.0 + self._cycle_active_node(step=1) + return True + + # / or f/F: Open fast agent search modal directly from device layer + if ch in (ord('/'), ord('f'), ord('F')): + self.device_layer_active = False + self.modal = "agent_search" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.agent_search_sel = 0 + self.agent_search_scroll = 0 + return True + + # p/P, Backspace, Left, h: Cycle backward through agents + if ch in (ord('p'), ord('P'), curses.KEY_BACKSPACE, 127, 8, curses.KEY_LEFT, ord('h')): + self.device_layer_timeout = time.time() + 4.0 + self._cycle_active_node(step=-1) + return True + + # Escape or Enter: Lock in current agent and exit modifier layer + if ch in (27, curses.KEY_ENTER, 10, 13): + self.device_layer_active = False + self.set_toast(f"Locked agent: {self.data.active_node.upper()}", "info") + return True + + # Any other key: exit modifier substrate and fall through to standard key handler + self.device_layer_active = False + return self._handle_key(ch) + + def _read_escape_sequence(self): + """Read any trailing escape sequence characters without blocking.""" + self.stdscr.nodelay(True) + seq = [] + try: + while len(seq) < 32: + c = self.stdscr.getch() + if c == -1: + break + seq.append(c) + # Terminate on standard ANSI escape final characters + if seq[0] == ord('[') and len(seq) >= 2: + 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) + return seq + + def _handle_escape_sequence(self) -> bool: + seq = self._read_escape_sequence() + if not seq: + # Standalone Esc pressed + if getattr(self, "copy_mode", False): + self.copy_mode = False + self.visual_sel_start = None + self.set_toast("Exited copy mode.", "info") + return True + if getattr(self, "reply_target", None): + self.reply_target = None + self.set_toast("↩ Cancelled reply target.", "info") + if self.editor_mode == "INSERT": + self.editor_mode = "NORMAL" + return True + if self.modal: + self.modal = None + return True + if self.editor_mode == "INSERT": + self.editor_mode = "NORMAL" + self.set_toast("NORMAL mode (Nav locked). Press 'i' to chat.", "info") + return True + else: + # In NORMAL mode: reset scroll offset or dismiss toast + if not self.auto_scroll: + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.set_toast("Follow mode resumed", "info") + 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": + self.editor_mode = "INSERT" + self.input_buf = self.input_buf[:self.input_cursor] + "\n" + self.input_buf[self.input_cursor:] + self.input_cursor += 1 + nl_cnt = self.input_buf.count("\n") + self.set_toast(f"Inserted newline ({nl_cnt + 1} lines). Press [Enter] to send.", "info") + return True + + # Alt+Space captured (ESC + Space): activates device substrate modifier layer + if seq and seq[0] == ord(' '): + if getattr(self, "device_layer_active", False) and time.time() <= self.device_layer_timeout: + self.device_layer_timeout = time.time() + 4.0 + self._cycle_active_node(step=1) + else: + self.device_layer_active = True + self.device_layer_timeout = time.time() + 4.0 + self.set_toast("󰢹 DEVICE LAYER: Press 0-9 to Jump, Space/n to Cycle, p: Prev, /: Search, Esc: Lock", "info") + return True + + # Alt+Number captured (ESC + 0..9): jump directly to agent + if seq and ord('0') <= seq[0] <= ord('9'): + digit = seq[0] - ord('0') + idx = (digit - 1) if digit > 0 else (len(self.data.nodes) - 1) + self._switch_to_node_idx(idx) + self.device_layer_active = False + return True + + # Alt+n / Alt+N: direct shortcut to cycle forward + if seq and seq[0] in (ord('n'), ord('N')): + self._cycle_active_node(step=1) + return True + + # Alt+p / Alt+P: direct shortcut to cycle backward + if seq and seq[0] in (ord('p'), ord('P')): + self._cycle_active_node(step=-1) + return True + + # Alt+f / Alt+/: direct shortcut to open agent search + if seq and seq[0] in (ord('f'), ord('F'), ord('/')): + self.modal = "agent_search" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.agent_search_sel = 0 + self.agent_search_scroll = 0 + return True + + # Multi-char escape sequence + if seq[0] in (ord('['), ord('O')): + seq_str = "".join(chr(c) for c in seq) + # Bracketed paste start sequence: \033[200~ + if seq_str == "[200~": + return self._handle_bracketed_paste() + # Check CSI u or extended kitty/xterm Alt+Space (e.g. \e[32;3u or \e[27;3;32~) + seq_str = "".join(chr(c) for c in seq) + if "32;3u" in seq_str or "27;3;32" in seq_str: + if getattr(self, "device_layer_active", False) and time.time() <= self.device_layer_timeout: + self.device_layer_timeout = time.time() + 4.0 + self._cycle_active_node(step=1) + else: + self.device_layer_active = True + self.device_layer_timeout = time.time() + 4.0 + self.set_toast("󰢹 DEVICE LAYER: Press 0-9 to Jump, Space/n to Cycle, p: Prev, /: Search, Esc: Lock", "info") + return True + + if len(seq) >= 2: + cmd = seq[1] + if cmd == ord('A'): + return self._handle_key(curses.KEY_UP) + elif cmd == ord('B'): + return self._handle_key(curses.KEY_DOWN) + elif cmd == ord('C'): + return self._handle_key(curses.KEY_RIGHT) + elif cmd == ord('D'): + return self._handle_key(curses.KEY_LEFT) + elif cmd == ord('Z'): + return self._handle_key(curses.KEY_BTAB) + elif cmd in (ord('H'), ord('1')): + return self._handle_key(curses.KEY_HOME) + elif cmd in (ord('F'), ord('4')): + return self._handle_key(curses.KEY_END) + elif cmd == ord('3'): + return self._handle_key(curses.KEY_DC) + elif cmd == ord('5'): + return self._handle_key(curses.KEY_PPAGE) + elif cmd == ord('6'): + return self._handle_key(curses.KEY_NPAGE) + elif cmd == ord('<'): + # SGR mouse sequence: \033[<btn;x;yM + sgr_bytes = seq[2:] + sgr_str = "".join(chr(c) for c in sgr_bytes) + try: + sgr_clean = sgr_str.rstrip("Mm") + s_btn, s_x, s_y = [int(p) for p in sgr_clean.split(";")] + mx = s_x - 1 + my = s_y - 1 + is_release = sgr_str.endswith("m") + if s_btn == 64: + return self._handle_mouse(mx, my, getattr(curses, "BUTTON4_PRESSED", 0x10000)) + elif s_btn == 65: + return self._handle_mouse(mx, my, getattr(curses, "BUTTON5_PRESSED", 0x200000)) + elif is_release: + return self._handle_mouse(mx, my, curses.BUTTON1_RELEASED) + elif s_btn == 32: + return self._handle_mouse(mx, my, curses.REPORT_MOUSE_POSITION) + elif s_btn == 0 and sgr_str.endswith("M"): + return self._handle_mouse(mx, my, curses.BUTTON1_CLICKED) + except Exception: + pass + return True + return True + + def _handle_bracketed_paste(self) -> bool: + """Read and insert bracketed paste content until \033[201~.""" + self.stdscr.nodelay(True) + raw_bytes = bytearray() + end_seq = b"\x1b[201~" + end_seq_short = b"[201~" + + try: + idle_retries = 0 + while True: + c = self.stdscr.getch() + if c == -1: + idle_retries += 1 + if idle_retries > 10: # ~100ms idle threshold + break + time.sleep(0.01) + continue + idle_retries = 0 + + if 0 <= c <= 255: + raw_bytes.append(c) + if raw_bytes.endswith(end_seq): + raw_bytes = raw_bytes[:-len(end_seq)] + break + elif raw_bytes.endswith(end_seq_short): + raw_bytes = raw_bytes[:-len(end_seq_short)] + break + finally: + self.stdscr.timeout(50) + + try: + text = raw_bytes.decode("utf-8", errors="replace") + except Exception: + text = raw_bytes.decode("latin-1", errors="replace") + + text = text.replace("\r\n", "\n").replace("\r", "\n") + if not text: + return True + + self._insert_pasted_text(text) + return True + + def _insert_pasted_text(self, text: str): + """Insert multi-character pasted text into active input field or modal.""" + if self.modal in ("agent_search", "history_search"): + clean = text.replace("\n", " ").strip() + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + clean + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor += len(clean) + return + + if self.modal == "new_sidechat": + clean = text.replace("\n", " ").strip() + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + clean + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor += len(clean) + return + + if self.modal in ("work_order", "prompt_formulate"): + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + text + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor += len(text) + return + + # Main chat input line + if self.editor_mode != "INSERT": + self.editor_mode = "INSERT" + + self.input_buf = self.input_buf[:self.input_cursor] + text + self.input_buf[self.input_cursor:] + self.input_cursor += len(text) + + nl_cnt = text.count("\n") + if nl_cnt > 0: + self.set_toast(f"📋 Pasted chunk ({nl_cnt + 1} lines, {len(text)} chars). Press [Enter] to send.", "success") + else: + self.set_toast(f"📋 Pasted {len(text)} chars.", "info") + + def _handle_key(self, ch: int) -> bool: + self.data.record_activity() + # Global Quitting + if ch in (3, 4): # Ctrl+C or Ctrl+D + return False + + # Mouse Events (clicks & wheel scrolls) + if ch == curses.KEY_MOUSE: + try: + _, mx, my, _, bstate = curses.getmouse() + return self._handle_mouse(mx, my, bstate) + except curses.error: + return True + + # Ctrl+Space (NUL char 0) as alternate trigger for device modifier substrate + if ch == 0: + if getattr(self, "device_layer_active", False) and time.time() <= self.device_layer_timeout: + self.device_layer_timeout = time.time() + 4.0 + self._cycle_active_node(step=1) + else: + self.device_layer_active = True + self.device_layer_timeout = time.time() + 4.0 + self.set_toast("󰢹 DEVICE LAYER: Press 0-9 to Jump, Space/n to Cycle, p: Prev, /: Search, Esc: Lock", "info") + return True + + # Handle raw Escape (27) or ANSI escape sequence (including Alt+Space) + if ch == 27: + return self._handle_escape_sequence() + + # Intercept if Device Modifier substrate layer is active + if getattr(self, "device_layer_active", False): + if time.time() > self.device_layer_timeout: + self.device_layer_active = False + else: + return self._handle_device_layer_key(ch) + + # If modal is open, direct all keystrokes to modal + if self.modal: + return self._handle_modal_key(ch) + + # Global Function Keys + if ch == curses.KEY_F1: + self.modal = "help" + return True + elif ch == curses.KEY_F2: + self.modal = "approvals" + return True + elif ch == curses.KEY_F5: + self.mode = "box" if self.mode == "muse" else "muse" + self.set_toast(f"Switched mode to {self.mode.upper()}", "info") + return True + + # Route by editor_mode + if self.editor_mode == "INSERT": + return self._handle_insert_key(ch) + else: + return self._handle_normal_key(ch) + + def _handle_copy_mode_key(self, ch: int) -> bool: + """Handle tmux-style copy-mode navigation, visual line selection, and yanking.""" + total = getattr(self, "transcript_total_lines", 0) + h_vis = getattr(self, "transcript_height", 20) + max_off = max(0, total - h_vis) + + # Exit copy mode: Esc or 'q' + if ch in (27, ord('q'), ord('Q')): + if self.visual_sel_start is not None: + self.visual_sel_start = None + self.set_toast("Selection cleared.", "info") + else: + self.copy_mode = False + self.set_toast("Exited copy mode.", "info") + return True + + # Toggle Visual Line Selection: 'v' or 'V' or Space + if ch in (ord('v'), ord('V'), ord(' ')): + if self.visual_sel_start is None: + self.visual_sel_start = self.copy_cursor_line + self.set_toast("VISUAL line selection started. Move with j/k, copy with y.", "warn_banner") + else: + self.visual_sel_start = None + self.set_toast("VISUAL selection cleared.", "info") + return True + + # Copy / Yank: 'y', 'Y', or Enter + if ch in (ord('y'), ord('Y'), curses.KEY_ENTER, 10, 13): + if self.visual_sel_start is not None: + s = min(self.visual_sel_start, self.copy_cursor_line) + e = max(self.visual_sel_start, self.copy_cursor_line) + else: + s = self.copy_cursor_line + e = self.copy_cursor_line + + text = self._copy_rendered_line_range(s, e) + self.copy_mode = False + self.visual_sel_start = None + if text: + cnt = len(text.splitlines()) + self.set_toast(f"📋 Copied {cnt} lines ({len(text)} chars) to clipboard", "success") + else: + self.set_toast("⚠️ No text to copy on current line", "warn") + return True + + # Navigate Down: 'j' or Down Arrow + if ch in (ord('j'), curses.KEY_DOWN): + if total > 0 and self.copy_cursor_line < total - 1: + self.copy_cursor_line += 1 + start_l = max_off - self.transcript_scroll_offset + if self.copy_cursor_line >= start_l + h_vis: + self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - 1) + if self.transcript_scroll_offset == 0: + self.auto_scroll = True + return True + + # Navigate Up: 'k' or Up Arrow + if ch in (ord('k'), curses.KEY_UP): + if total > 0 and self.copy_cursor_line > 0: + self.copy_cursor_line -= 1 + start_l = max_off - self.transcript_scroll_offset + if self.copy_cursor_line < start_l: + self.auto_scroll = False + self.transcript_scroll_offset = min(max_off, self.transcript_scroll_offset + 1) + return True + + # Page Up: PgUp or Ctrl+U (21) or 'b' + if ch in (curses.KEY_PPAGE, 21, ord('b')): + step = max(5, h_vis - 2) + self.copy_cursor_line = max(0, self.copy_cursor_line - step) + self.auto_scroll = False + self.transcript_scroll_offset = min(max_off, self.transcript_scroll_offset + step) + return True + + # Page Down: PgDn or Ctrl+D (4) or 'f' + if ch in (curses.KEY_NPAGE, 4): + step = max(5, h_vis - 2) + self.copy_cursor_line = min(max(0, total - 1), self.copy_cursor_line + step) + self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - step) + if self.transcript_scroll_offset == 0: + self.auto_scroll = True + return True + + # Top of history: 'g' or Home + if ch in (ord('g'), curses.KEY_HOME): + self.copy_cursor_line = 0 + self.auto_scroll = False + self.transcript_scroll_offset = max_off + return True + + # Bottom of history: 'G' or End + if ch in (ord('G'), curses.KEY_END): + self.copy_cursor_line = max(0, total - 1) + self.auto_scroll = True + self.transcript_scroll_offset = 0 + return True + + return True + + def _handle_normal_key(self, ch: int) -> bool: + # If in tmux copy-mode, route all keys to copy mode handler + if getattr(self, "copy_mode", False): + return self._handle_copy_mode_key(ch) + + # Enter Visual Copy Mode: 'v' or 'V' (starts selection on visible line) + if ch in (ord('v'), ord('V')): + self.copy_mode = True + self.focus_pane = "transcript" + self.copy_cursor_line = self._get_current_visible_line_idx() + self.visual_sel_start = self.copy_cursor_line + self.set_toast("VISUAL line copy mode. Move with j/k, copy with y/Enter, cancel with Esc/q.", "warn_banner") + return True + + # Quit + if ch in (ord('q'), ord('Q')): + return False + + # Toggle Clean line-by-line vs Classic Boxed transcript view: 'b' or 'B' + if ch in (ord('b'), ord('B')): + self.toggle_transcript_style() + return True + + # Box mode Fast Actions: Tab 6 (SSH) — placed before the 's'/'c'/'y' + # globals below so SSH keys win on this tab. + if self.mode == "box" and self.box_tab == 6: + if ch in (curses.KEY_ENTER, 10, 13): + self._ssh_popout_selected() + return True + elif ch in (ord('s'), ord('S')): + self._ssh_popout_selected() + return True + elif ch in (ord('c'), ord('C')): + self._ssh_copy_dial_selected() + return True + elif ch in (ord('u'), ord('U')): + rows = self.get_ssh_rows() + if rows and 0 <= self.ssh_sel_idx < len(rows): + acct = rows[self.ssh_sel_idx] + threading.Thread(target=self._async_ssh_uptime, args=(acct,), daemon=True).start() + self.set_toast(f"Probing container uptime on {acct}...", "info") + return True + elif ch in (ord('r'), ord('R')): + threading.Thread(target=self.data._fetch_ssh_health, daemon=True).start() + 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): + 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] + else: + target_node = getattr(self, "dispatch_target_node", self.data.active_node) + + if ch in (ord('w'), ord('W')): + self.modal = "work_dispatch" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.dispatch_target_node = target_node + return True + elif ch in (ord('f'), ord('F')): + self._async_refocus_main(target_node) + return True + elif ch in (ord('t'), ord('T')): + self._open_agent_thoughts(target_node) + return True + elif ch in (ord('p'), ord('P')): + self._open_agent_menu(target_node) + return True + elif ch in (ord('l'), ord('L')): + self._async_run_loopback() + return True + elif ch in (ord('h'), ord('H')): + self._async_heal_agent(target_node) + return True + elif ch in (ord('r'), ord('R')): + threading.Thread(target=self.data._fetch_cognitive_and_work, daemon=True).start() + self.set_toast("Refreshing work pipeline & cognitive state...", "info") + return True + + # 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): + t = threads[self.thread_sel_idx] + self.context_chat = { + "node": self.data.active_node, + "thread": t, + "index": self.thread_sel_idx, + } + 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 + else: + m = None + idx = getattr(self, "copy_cursor_line", None) + if idx is not None: + lines = getattr(self, "_transcript_cache_lines", []) + for i in range(min(idx, len(lines) - 1), -1, -1): + if i < len(lines) and lines[i][2]: + m = lines[i][2] + break + 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.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 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 + self.agent_search_sel = 0 + self.agent_search_scroll = 0 + return True + + # Enter Insert Mode + if ch in (ord('i'), ord('I')): + self.editor_mode = "INSERT" + self.input_cursor = len(self.input_buf) + self.set_toast("INSERT mode. Press [Esc] to lock.", "info") + return True + + # Enter Command / Slash Mode (when on transcript) + if ch in (ord('/'), ord(':')): + self.editor_mode = "INSERT" + if not self.input_buf.startswith("/"): + self.input_buf = "/" + self.input_buf + self.input_cursor = len(self.input_buf) + return True + + # 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 == "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, [])) + if threads and 0 <= self.thread_sel_idx < len(threads): + t = threads[self.thread_sel_idx] + sid = t.get("session_id") + title = t.get("title") or sid[:8] + self.data.set_active_target(self.data.active_node, sid, title) + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.focus_pane = "transcript" + self.set_toast(f"Opened thread '{title}'", "success") + return True + else: + # On transcript: Enter switches to INSERT mode + self.editor_mode = "INSERT" + self.input_cursor = len(self.input_buf) + return True + elif self.mode == "box": + if self.box_tab == 1: + # Fleet tab: open chat with selected node + if 0 <= self.fleet_sel_idx < len(self.data.nodes): + node = self.data.nodes[self.fleet_sel_idx] + self.data.set_active_target(node) + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.box_tab = 0 + self.set_toast(f"Opened Chat with {node.upper()}", "success") + return True + elif self.box_tab == 2: + # Approvals tab: open chat with node + with self.data.lock: + apps = list(self.data.approvals_cache) + if apps and 0 <= self.approvals_sel_idx < len(apps): + node = apps[self.approvals_sel_idx].get("node") + if node: + self.data.set_active_target(node) + self.box_tab = 0 + self.set_toast(f"Opened Chat with {node.upper()}", "success") + return True + elif self.box_tab == 3: + # Jobs tab: link to conversation on [Enter] for entry/read/scrollback via Agent Chat 1 + with self.data.lock: + jobs = list(self.data.jobs_cache) + if jobs and 0 <= self.jobs_sel_idx < len(jobs): + job = jobs[self.jobs_sel_idx] + jname = job.get("name") + agent = job.get("agent", self.data.active_node) + self._open_job_conversation(jname, agent) + return True + elif self.box_tab == 4: + # Tmux tab: display attach instructions toast + with self.data.lock: + sessions = list(self.data.tmux_cache) + if sessions and 0 <= self.tmux_sel_idx < len(sessions): + sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip() + 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) + 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 = "" + 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') + if ch in (ord('p'), ord('P')): + 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 + if threads and 0 <= target_idx < len(threads): + t = threads[target_idx] + sid = t.get("session_id") + if sid: + new_pin = self.data.toggle_pin_thread(self.data.active_node, sid) + state = "Pinned" if new_pin else "Unpinned" + title = t.get("title") or sid[:8] + self.set_toast(f"📌 {state} chat: {title[:25]}", "success") + return True + else: + if ch == ord('P'): + self.modal = "prompts" + self.prompt_sel_idx = 0 + return True + else: + # 'p': Pin latest chat sent from active thread (or recent sends) to prompt fill / list + cur_node = self.data.active_node + cur_tid = self.data.active_thread_id + with self.data.lock: + thread_msgs = list(self.data.history_cache.get((cur_node, cur_tid), [])) + user_thread_msgs = [m for m in thread_msgs if m.get("role") == "user" and (m.get("text") or "").strip()] + + if user_thread_msgs: + target_text = user_thread_msgs[-1]["text"].strip() + target_send = {"text": target_text, "node": cur_node} + else: + sends = self.get_chat_sends() + node_sends = [s for s in sends if s.get("node") == cur_node] + target_send = node_sends[0] if node_sends else (sends[0] if sends else None) + + if target_send: + p, is_new = self.prompt_manager.pin_chat_send(target_send["text"], node=target_send.get("node", cur_node), toggle=True) + self.prompts = self.prompt_manager.get_sorted_prompts() + 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.modal = "prompts" + self.prompt_sel_idx = 0 + return True + + # Search Chat Sends: 's' or 'S' or Ctrl-R (18) + if ch in (ord('s'), ord('S'), 18): + self.modal = "history_search" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.history_search_sel = 0 + self.history_search_scroll = 0 + return True + + # Scroll compact prompt shelf left/right: '[' and ']' + if ch == ord('['): + self.prompt_scroll_idx = max(0, self.prompt_scroll_idx - 1) + return True + if ch == ord(']'): + self.prompt_scroll_idx = min(max(0, len(self.prompts) - 1), self.prompt_scroll_idx + 1) + return True + + # Copy to clipboard: 'y' for active/latest message, 'Y' for full transcript + if ch == ord('y'): + self.copy_message_to_clipboard() + return True + if ch == ord('Y'): + self.copy_transcript_to_clipboard() + return True + + # Help modal + if ch in (ord('?'), curses.KEY_F1): + self.modal = "help" + return True + + # Approvals modal + if ch in (ord('a'), ord('A'), curses.KEY_F2, curses.KEY_F3): + if self.mode == "box" and self.box_tab == 4: + # On Tmux tab, 'a' shows attach instructions + with self.data.lock: + sessions = list(self.data.tmux_cache) + if sessions and 0 <= self.tmux_sel_idx < len(sessions): + sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip() + self.set_toast(f"Attach: tmux -S /tmp/tmux-muse.sock attach -t {sess_name}", "info") + return True + self.modal = "approvals" + return True + + # New sidechat modal + if ch in (ord('n'), ord('N')): + self.modal = "new_sidechat" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + return True + + # Work Order modal + if ch in (ord('w'), ord('W')): + self.modal = "work_order" + 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): + self.mode = "box" if self.mode == "muse" else "muse" + self.set_toast(f"Switched mode to {self.mode.upper()}", "info") + return True + + # Box mode Fast Actions: Tab 2 (Approvals) + if self.mode == "box" and self.box_tab == 2: + with self.data.lock: + apps = list(self.data.approvals_cache) + cur_node = apps[self.approvals_sel_idx].get("node") if apps and 0 <= self.approvals_sel_idx < len(apps) else self.data.active_node + + if ch == ord('1'): + threading.Thread(target=self._async_resolve_approval, args=("allow", cur_node), daemon=True).start() + return True + elif ch == ord('2'): + threading.Thread(target=self._async_resolve_approval, args=("always", cur_node), daemon=True).start() + return True + elif ch == ord('3'): + threading.Thread(target=self._async_resolve_approval, args=("deny", cur_node), daemon=True).start() + return True + elif ch in (ord('r'), ord('R')): + threading.Thread(target=self._async_resolve_approval, args=("reply", cur_node, "Proceed with standard execution."), daemon=True).start() + return True + elif ch in (ord('x'), ord('X')): + threading.Thread(target=self._async_resolve_approval, args=("dismiss", cur_node), daemon=True).start() + return True + elif ch in (ord('c'), ord('C')): + threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start() + return True + + # Box mode Fast Actions: Tab 3 (Jobs) + if self.mode == "box" and self.box_tab == 3: + if ch in (ord('r'), ord('R'), ord('t'), ord('T')): + with self.data.lock: + jobs = list(self.data.jobs_cache) + if jobs and 0 <= self.jobs_sel_idx < len(jobs): + jname = jobs[self.jobs_sel_idx].get("name") + threading.Thread(target=self._async_trigger_job, args=(jname,), daemon=True).start() + return True + elif ch in (ord('s'), ord('S')): + threading.Thread(target=self.data._fetch_jobs, daemon=True).start() + self.set_toast("Refreshed jobs scheduler list.", "info") + return True + + # Box mode Fast Actions: Tab 4 (Tmux) + if self.mode == "box" and self.box_tab == 4: + if ch in (ord('x'), ord('X'), ord('d'), ord('D'), ord('K')): + with self.data.lock: + sessions = list(self.data.tmux_cache) + if sessions and 0 <= self.tmux_sel_idx < len(sessions): + sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip() + threading.Thread(target=self._async_kill_tmux, args=(sess_name,), daemon=True).start() + return True + elif ch in (ord('r'), ord('R')): + threading.Thread(target=self.data._fetch_tmux_sessions, daemon=True).start() + 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: + node = self.data.active_node + tid = self.data.active_thread_id + msgs = self.data.transcript_cache.get(f"{node}/{tid}", []) + if msgs: + self.trigger_reply_to_message(msgs[-1]) + return True + else: + self.set_toast("⚠️ No messages to reply to", "warn") + return True + + # Global Refresh / Sync (with two-tap force override during cooldown) + if ch in (ord('r'), ord('R')): + node = self.data.active_node + cd = self.data.get_node_cooldown_remaining(node) + if cd > 0: + now = time.time() + last_force = getattr(self, "_last_force_sync_time", 0.0) + if (now - last_force) <= 2.0: + self.data.clear_node_rate_limit(node) + self.data.lazy_fetch_threads(node, force=True) + self.data.trigger_refresh() + self.set_toast(f"⚡ Force-syncing {node.upper()} (cooldown bypassed)...", "info") + self._last_force_sync_time = 0.0 + else: + self._last_force_sync_time = now + self.set_toast(f"⏳ {node.upper()} cooling down ({cd}s left). Press 'r' again within 2s to force.", "warn") + return True + self.data.lazy_fetch_threads(node, force=True) + self.data.trigger_refresh() + self.set_toast(f"Syncing threads for {node.upper()}...", "info") + return True + + # Node Jump: '1' - '9' and '0' (when in muse mode) + if self.mode == "muse" and (ord('1') <= ch <= ord('9') or ch == ord('0')): + idx = (ch - ord('1')) if ch != ord('0') else 9 + if idx < len(self.data.nodes): + node = self.data.nodes[idx] + self.data.set_active_target(node) + self.thread_sel_idx = 0 + self.set_toast(f"Switched to agent: {node.upper()}", "success") + return True + + # Box mode tab selection: '1' - '8' (when in box mode, except 1-3 on Tab 2) + if self.mode == "box": + if self.box_tab == 2: + if ord('4') <= ch <= ord('8'): + self.box_tab = ch - ord('1') + return True + elif ord('1') <= ch <= ord('8'): + self.box_tab = ch - ord('1') + return True + + # Box mode tab navigation (when not in Chat tab 0): '[' / ']' / Tab / Shift-Tab + if self.mode == "box" and self.box_tab != 0: + if ch in (ord('['), curses.KEY_LEFT): + self.box_tab = (self.box_tab - 1) % 8 + return True + elif ch in (ord(']'), curses.KEY_RIGHT): + self.box_tab = (self.box_tab + 1) % 8 + return True + elif ch == ord('\t'): + self.box_tab = (self.box_tab + 1) % 8 + return True + elif ch == curses.KEY_BTAB: + self.box_tab = (self.box_tab - 1) % 8 + return True + + # Muse View / Chat Tab: Direct Conversation Cycling & Pane Switching + if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): + if ch == ord('\t'): + # Tab: Cycle to next conversation thread and load immediately + with self.data.lock: + threads = list(self.data.threads_cache.get(self.data.active_node, [])) + if threads: + self.thread_sel_idx = (self.thread_sel_idx + 1) % len(threads) + t = threads[self.thread_sel_idx] + sid = t.get("session_id") + title = t.get("title") or sid[:8] + self.data.set_active_target(self.data.active_node, sid, title) + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.set_toast(f"Thread: {title} ({self.thread_sel_idx + 1}/{len(threads)})", "info") + else: + if self.data.is_loading_threads(self.data.active_node): + self.set_toast(f"Threads are currently loading for {self.data.active_node.upper()}...", "info") + else: + self.set_toast("No threads to cycle. Press 'n' to create one.", "info") + return True + + elif ch == curses.KEY_BTAB: + # Shift-Tab: Cycle to previous conversation thread + with self.data.lock: + threads = list(self.data.threads_cache.get(self.data.active_node, [])) + if threads: + self.thread_sel_idx = (self.thread_sel_idx - 1) % len(threads) + t = threads[self.thread_sel_idx] + sid = t.get("session_id") + title = t.get("title") or sid[:8] + self.data.set_active_target(self.data.active_node, sid, title) + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.set_toast(f"Thread: {title} ({self.thread_sel_idx + 1}/{len(threads)})", "info") + else: + if self.data.is_loading_threads(self.data.active_node): + self.set_toast(f"Threads are currently loading for {self.data.active_node.upper()}...", "info") + else: + self.set_toast("No threads to cycle. Press 'n' to create one.", "info") + return True + + elif ch in (ord('h'), curses.KEY_LEFT): + 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): + 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 == "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 + max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", 20)) + self.transcript_scroll_offset = min(max_scroll, self.transcript_scroll_offset + 3) + elif self.mode == "box": + if self.box_tab == 1: + self.fleet_sel_idx = max(0, self.fleet_sel_idx - 1) + elif self.box_tab == 2: + self.approvals_sel_idx = max(0, self.approvals_sel_idx - 1) + elif self.box_tab == 3: + 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 == 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) + else: + self.table_scroll_idx = max(0, self.table_scroll_idx - 1) + return True + + 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 == "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: + self.thread_sel_idx = min(len(threads) - 1, self.thread_sel_idx + 1) + else: + if self.transcript_scroll_offset > 0: + self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - 3) + if self.transcript_scroll_offset == 0: + self.auto_scroll = True + elif self.mode == "box": + if self.box_tab == 1: + self.fleet_sel_idx = min(len(self.data.nodes) - 1, self.fleet_sel_idx + 1) + elif self.box_tab == 2: + with self.data.lock: + apps = list(self.data.approvals_cache) + if apps: + self.approvals_sel_idx = min(len(apps) - 1, self.approvals_sel_idx + 1) + elif self.box_tab == 3: + with self.data.lock: + jobs = list(self.data.jobs_cache) + if jobs: + self.jobs_sel_idx = min(len(jobs) - 1, self.jobs_sel_idx + 1) + elif self.box_tab == 4: + with self.data.lock: + 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 == 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) + else: + self.table_scroll_idx += 1 + return True + + # PageUp / PageDown / Ctrl+U / Ctrl+D + if ch in (curses.KEY_PPAGE, 21): # PageUp or Ctrl+U + if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): + self.auto_scroll = False + max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", 20)) + step = max(5, getattr(self, "transcript_height", 20) - 2) + self.transcript_scroll_offset = min(max_scroll, self.transcript_scroll_offset + step) + elif self.mode == "box": + if self.box_tab == 1: + self.fleet_sel_idx = max(0, self.fleet_sel_idx - 5) + elif self.box_tab == 2: + self.approvals_sel_idx = max(0, self.approvals_sel_idx - 5) + elif self.box_tab == 3: + 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 == 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) + else: + self.table_scroll_idx = max(0, self.table_scroll_idx - 5) + return True + + elif ch in (curses.KEY_NPAGE, 4): # PageDown or Ctrl+D + if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): + step = max(5, getattr(self, "transcript_height", 20) - 2) + self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - step) + if self.transcript_scroll_offset == 0: + self.auto_scroll = True + elif self.mode == "box": + if self.box_tab == 1: + self.fleet_sel_idx = min(len(self.data.nodes) - 1, self.fleet_sel_idx + 5) + elif self.box_tab == 2: + with self.data.lock: + apps = list(self.data.approvals_cache) + if apps: + self.approvals_sel_idx = min(len(apps) - 1, self.approvals_sel_idx + 5) + elif self.box_tab == 3: + with self.data.lock: + jobs = list(self.data.jobs_cache) + if jobs: + self.jobs_sel_idx = min(len(jobs) - 1, self.jobs_sel_idx + 5) + elif self.box_tab == 4: + with self.data.lock: + 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 == 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) + else: + self.table_scroll_idx += 5 + return True + + # 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 == "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 + max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", 20)) + self.transcript_scroll_offset = max_scroll + elif self.mode == "box": + if self.box_tab == 1: + self.fleet_sel_idx = 0 + elif self.box_tab == 2: + self.approvals_sel_idx = 0 + elif self.box_tab == 3: + self.jobs_sel_idx = 0 + elif self.box_tab == 4: + self.tmux_sel_idx = 0 + elif self.box_tab == 6: + self.ssh_sel_idx = 0 + elif self.box_tab == 7: + self.work_sel_idx = 0 + else: + self.table_scroll_idx = 0 + return True + + 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 == "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: + self.thread_sel_idx = max(0, len(threads) - 1) + else: + self.auto_scroll = True + self.transcript_scroll_offset = 0 + elif self.mode == "box": + if self.box_tab == 1: + self.fleet_sel_idx = max(0, len(self.data.nodes) - 1) + elif self.box_tab == 2: + with self.data.lock: + apps = list(self.data.approvals_cache) + if apps: + self.approvals_sel_idx = max(0, len(apps) - 1) + elif self.box_tab == 3: + with self.data.lock: + jobs = list(self.data.jobs_cache) + if jobs: + self.jobs_sel_idx = max(0, len(jobs) - 1) + elif self.box_tab == 4: + with self.data.lock: + sessions = list(self.data.tmux_cache) + if sessions: + self.tmux_sel_idx = max(0, len(sessions) - 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 + else: + self.table_scroll_idx = max(0, len(self.data.nodes) - 5) + return True + + return True + + def _handle_mouse(self, mx: int, my: int, bstate: int) -> bool: + """Handle mouse clicks, selection, and mouse wheel scrolling.""" + self.data.record_activity() + # Scroll up masks: BUTTON4_PRESSED, BUTTON4_CLICKED, 0x10000, 0x20000 + is_scroll_up = bool(bstate & (getattr(curses, "BUTTON4_PRESSED", 0x10000) | + getattr(curses, "BUTTON4_CLICKED", 0x20000) | + 0x10000 | 0x20000)) + + # Scroll down masks: BUTTON5_PRESSED, BUTTON5_CLICKED, 0x200000, 0x400000, 0x8000000 + is_scroll_down = bool(bstate & (getattr(curses, "BUTTON5_PRESSED", 0x200000) | + getattr(curses, "BUTTON5_CLICKED", 0x400000) | + 0x200000 | 0x400000 | 0x8000000)) + + # Button state masks + btn3_mask = ( + getattr(curses, "BUTTON3_CLICKED", 0x1000) | + getattr(curses, "BUTTON3_PRESSED", 0x800) | + 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.15: + return True + self._last_right_click_time = now + + 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)) + header_lines = 2 if self.mode == "box" else 1 + bottom_bar_h = 3 + content_y = header_lines + is_main_view = (self.mode == "muse" or (self.mode == "box" and self.box_tab == 0)) + prompt_shelf_h = 2 if is_main_view else 0 + prompt_shelf_y = getattr(self, "_prompt_shelf_y", h - bottom_bar_h - prompt_shelf_h) + content_h = h - content_y - bottom_bar_h - prompt_shelf_h + + # 1. WHEEL SCROLLING + if is_scroll_up: + if self.modal: + return True + if is_main_view and prompt_shelf_h > 0 and (prompt_shelf_y <= my < h - bottom_bar_h): + self.prompt_scroll_idx = max(0, self.prompt_scroll_idx - 1) + return True + if self.mode == "box" and self.box_tab != 0: + if self.box_tab == 1: + self.fleet_sel_idx = max(0, self.fleet_sel_idx - 1) + elif self.box_tab == 2: + self.approvals_sel_idx = max(0, self.approvals_sel_idx - 1) + elif self.box_tab == 3: + 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 == 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) + else: + self.table_scroll_idx = max(0, self.table_scroll_idx - 1) + elif mx >= sidebar_w: + # Scroll Transcript history up + self.auto_scroll = False + max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", content_h - 2)) + self.transcript_scroll_offset = min(max_scroll, self.transcript_scroll_offset + 3) + 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 or self.focus_pane == "fleet": + # Scroll Fleet Agents up + 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) + return True + + if is_scroll_down: + if self.modal: + return True + if is_main_view and prompt_shelf_h > 0 and (prompt_shelf_y <= my < h - bottom_bar_h): + self.prompt_scroll_idx = min(max(0, len(self.prompts) - 1), self.prompt_scroll_idx + 1) + return True + if self.mode == "box" and self.box_tab != 0: + if self.box_tab == 1: + self.fleet_sel_idx = min(len(self.data.nodes) - 1, self.fleet_sel_idx + 1) + elif self.box_tab == 2: + with self.data.lock: + apps = list(self.data.approvals_cache) + if apps: + self.approvals_sel_idx = min(len(apps) - 1, self.approvals_sel_idx + 1) + elif self.box_tab == 3: + with self.data.lock: + jobs = list(self.data.jobs_cache) + if jobs: + self.jobs_sel_idx = min(len(jobs) - 1, self.jobs_sel_idx + 1) + elif self.box_tab == 4: + with self.data.lock: + 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 == 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) + else: + self.table_scroll_idx += 1 + elif mx >= sidebar_w: + # Scroll Transcript history down + self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - 3) + if self.transcript_scroll_offset == 0: + self.auto_scroll = True + 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 or self.focus_pane == "fleet": + # Scroll Fleet Agents down + with self.data.lock: + total_nodes = len(self.data.nodes) + 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: + threads = self.data.threads_cache.get(self.data.active_node, []) + if threads: + self.thread_sel_idx = min(len(threads) - 1, self.thread_sel_idx + 1) + return True + + # 2. CLICK & MODAL INTERACTION HANDLING + if is_click: + # If Modal is active + if self.modal: + 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(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) + + is_inside_modal = (left_x <= mx < left_x + modal_w and top_y <= my < top_y + modal_h) + + 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 (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 + 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 + 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 + 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 + + # Header clicks (my == 0) + if my == 0: + if mx < 35: + self.mode = "box" if self.mode == "muse" else "muse" + self.set_toast(f"Mode switched to {self.mode.upper()}", "info") + return True + with self.data.lock: + has_blocked = any(s.get("blocked") for s in self.data.node_status.values()) + if has_blocked and (w - 60 <= mx <= w - 24): + self.modal = "approvals" + return True + + # Box Tabs click (my == 1 and self.mode == "box") + if my == 1 and self.mode == "box": + for idx, (start, end) in enumerate(box_tab_bounds()): + if start <= mx <= end: + self.box_tab = idx + return True + return True + + # 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" + self.prompt_sel_idx = 0 + return True + if hasattr(self, "_btn_sends_bounds") and self._btn_sends_bounds[0] <= mx <= self._btn_sends_bounds[1]: + self.modal = "history_search" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.history_search_sel = 0 + return True + if hasattr(self, "_btn_prev_bounds") and self._btn_prev_bounds[0] <= mx <= self._btn_prev_bounds[1]: + self.prompt_scroll_idx = max(0, self.prompt_scroll_idx - 1) + return True + if hasattr(self, "_btn_next_bounds") and self._btn_next_bounds[0] <= mx <= self._btn_next_bounds[1]: + self.prompt_scroll_idx = min(max(0, len(self.prompts) - 1), self.prompt_scroll_idx + 1) + return True + if hasattr(self, "_btn_new_bounds") and self._btn_new_bounds[0] <= mx <= self._btn_new_bounds[1]: + self.modal = "prompt_formulate" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + return True + else: + for (start_x, end_x, p) in getattr(self, "_prompt_chip_bounds", []): + if start_x <= mx <= end_x: + 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") + return True + return True + + # 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: + self.input_cursor = min(len(self.input_buf), max(0, mx - prompt_len)) + else: + self.input_cursor = len(self.input_buf) + return True + + # Box Fleet Status click (tab 1): clicking a node row opens chat + if self.mode == "box" and self.box_tab == 1: + if content_y + 3 <= my < content_y + content_h: + row_offset = my - (content_y + 3) + with self.data.lock: + nodes = list(self.data.nodes) + 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") + 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: + self.editor_mode = "NORMAL" + 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 = { + "node": self.data.active_node, + "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" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.agent_search_sel = 0 + self.agent_search_scroll = 0 + return True + + # 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] + 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: + 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], [⚡: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 = { + "node": cur_node, + "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 + # 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 - 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") + return True + + # Check if click on a Thread in list + list_y = threads_hdr_y + 1 + thread_row = my - list_y + 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): + self.thread_sel_idx = clicked_idx + t = threads[clicked_idx] + sid = t.get("session_id") + title = t.get("title") or sid[:8] + # 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: + 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: + if getattr(self, "mouse_dragging", False): + idx = self._get_transcript_line_idx_at(my) + if idx is not None: + self.copy_cursor_line = idx + return True + + # 2. Mouse Release (Finish drag-select and copy to clipboard) + if is_release: + if getattr(self, "mouse_dragging", False): + self.mouse_dragging = False + idx = self._get_transcript_line_idx_at(my) + if idx is not None: + self.copy_cursor_line = idx + if self.visual_sel_start is not None: + s = min(self.visual_sel_start, self.copy_cursor_line) + e = max(self.visual_sel_start, self.copy_cursor_line) + text = self._copy_rendered_line_range(s, e) + self.copy_mode = False + self.visual_sel_start = None + if text: + cnt = len(text.splitlines()) + self.set_toast(f"📋 Copied selection ({cnt} lines, {len(text)} chars) to clipboard", "success") + return True + + # 3. Mouse Press (Click down) + if is_press: + # Check if clicked on [📋 Copy All] button in thread header + if getattr(self, "_btn_copy_all_bounds", None): + btn_y, btn_x_start, btn_x_end = self._btn_copy_all_bounds + if my == btn_y and btn_x_start <= mx <= btn_x_end: + 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: + self.trigger_reply_to_message(msg_obj) + return True + + # Check if clicked on any visible [📋 Copy] button on message headers (clean text) + for (msg_y, btn_x_start, btn_x_end, msg_raw_text) in getattr(self, "_msg_copy_buttons", []): + if my == msg_y and btn_x_start <= mx <= btn_x_end: + self.copy_message_to_clipboard(msg_raw_text, with_context=False) + return True + + # Check if clicked on any visible [📑+ Context] button on message headers (with context) + for (msg_y, btn_x_start, btn_x_end, msg_raw_text) in getattr(self, "_msg_copy_context_buttons", []): + if my == msg_y and btn_x_start <= mx <= btn_x_end: + 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 + self.transcript_scroll_offset = 0 + self.set_toast("Resumed live bottom following", "success") + return True + + # Otherwise: start mouse drag selection on text line! + idx = self._get_transcript_line_idx_at(my) + if idx is not None: + self.mouse_dragging = True + self.copy_mode = True + self.visual_sel_start = idx + self.copy_cursor_line = idx + self.editor_mode = "NORMAL" + self.focus_pane = "transcript" + return True + + self.editor_mode = "NORMAL" + self.focus_pane = "transcript" + return True + + # Box View clicks (Tabs 1, 2, 3, 4, 6) + elif self.mode == "box": + self.editor_mode = "NORMAL" + row_idx = my - (content_y + 3) + + if self.box_tab == 1: + # Tab 1: Fleet Status + if 0 <= row_idx < len(self.data.nodes): + self.fleet_sel_idx = row_idx + sel_node = self.data.nodes[row_idx] + self.data.set_active_target(sel_node) + self.box_tab = 0 + self.focus_pane = "transcript" + self.set_toast(f"✔ Refocused Agent Chat on {sel_node.upper()}", "success") + return True + + elif self.box_tab == 2: + # Tab 2: Approvals + # Check bottom hint line: Clear All + if my == content_y + content_h - 1: + if mx >= 40: + threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start() + return True + + with self.data.lock: + apps = list(self.data.approvals_cache) + + if 0 <= row_idx < len(apps): + self.approvals_sel_idx = row_idx + app = apps[row_idx] + node = app.get("node", self.data.active_node) + st = app.get("status") or "PENDING" + act_x = min(w - 38, max(64, 48)) + + if mx >= act_x: + if st == "PENDING": + if mx < act_x + 10: + threading.Thread(target=self._async_resolve_approval, args=("allow", node), daemon=True).start() + elif mx < act_x + 21: + threading.Thread(target=self._async_resolve_approval, args=("always", node), daemon=True).start() + else: + threading.Thread(target=self._async_resolve_approval, args=("deny", node), daemon=True).start() + elif st == "INPUT_WAIT": + if mx < act_x + 12: + threading.Thread(target=self._async_resolve_approval, args=("reply", node, "Proceed with standard execution."), daemon=True).start() + elif mx < act_x + 24: + threading.Thread(target=self._async_resolve_approval, args=("dismiss", node), daemon=True).start() + else: + self.data.set_active_target(node) + self.box_tab = 0 + self.set_toast(f"Switched to Chat with {node.upper()}", "success") + else: + threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start() + else: + self.set_toast(f"Selected approval [{node.upper()}]. Use keys: [1/2/3/r/x/c]", "info") + return True + + elif self.box_tab == 3: + # Tab 3: Jobs Scheduler + with self.data.lock: + jobs = list(self.data.jobs_cache) + scroll_start = getattr(self, "jobs_scroll_start", 0) + clicked_idx = scroll_start + row_idx + if 0 <= clicked_idx < len(jobs): + self.jobs_sel_idx = clicked_idx + job = jobs[clicked_idx] + job_name = job.get("name") + agent = job.get("agent", self.data.active_node) + if mx >= w - 12: + # [Run] button + threading.Thread(target=self._async_trigger_job, args=(job_name,), daemon=True).start() + else: + # Clicked row, name, or [▶ Chat] -> open conversation in Agent Chat 1! + self._open_job_conversation(job_name, agent) + return True + + elif self.box_tab == 4: + # Tab 4: Tmux Sessions + with self.data.lock: + sessions = list(self.data.tmux_cache) + if 0 <= row_idx < len(sessions): + self.tmux_sel_idx = row_idx + sess_line = sessions[row_idx] + sess_name = sess_line.split(":")[0].strip() + if mx >= w - 12: + # [Kill] + threading.Thread(target=self._async_kill_tmux, args=(sess_name,), daemon=True).start() + elif mx >= w - 22: + # [Attach] + self.set_toast(f"Attach: tmux -S /tmp/tmux-muse.sock attach -t {sess_name}", "info") + else: + self.set_toast(f"Selected session '{sess_name}'. Click [Kill] or [Attach].", "info") + return True + + elif self.box_tab == 6: + # Tab 6: SSH / Boxes (rows start one line lower: jump-status line) + rows = self.get_ssh_rows() + scroll_start = getattr(self, "ssh_scroll_idx", 0) + clicked_idx = scroll_start + row_idx - 1 + if 0 <= clicked_idx < len(rows): + self.ssh_sel_idx = clicked_idx + if mx >= w - 8: + # [SSH] pop-out + self._ssh_popout_selected() + else: + acct = rows[clicked_idx] + self.set_toast(f"Selected [{acct.upper()}]. Click [SSH] or press Enter to pop out.", "info") + return True + + elif self.box_tab == 7: + # Tab 7: Work Pipeline + if 0 <= row_idx < 10: + self.work_sel_idx = row_idx + self.set_toast(f"Selected Work item #{row_idx + 1}", "info") + return True + + return True + + def _handle_insert_key(self, ch: int) -> bool: + # Enter -> Submit Message / Slash command (or collect multiline paste burst) + if ch in (curses.KEY_ENTER, 10, 13): + # Check if this Enter is part of a rapid paste stream (unbracketed paste) + self.stdscr.nodelay(True) + try: + peek_c = self.stdscr.getch() + finally: + self.stdscr.timeout(50) + + if peek_c != -1 and isinstance(peek_c, int): + # Part of rapid unbracketed paste burst! Keep newline and consume rest of burst + burst_bytes = bytearray([10]) + if 0 <= peek_c <= 255: + burst_bytes.append(peek_c) + + self.stdscr.nodelay(True) + try: + idle = 0 + while True: + c = self.stdscr.getch() + if c == -1: + idle += 1 + if idle > 4: + break + time.sleep(0.005) + continue + idle = 0 + if 0 <= c <= 255: + burst_bytes.append(c) + finally: + self.stdscr.timeout(50) + + burst_text = burst_bytes.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n") + self.input_buf = self.input_buf[:self.input_cursor] + burst_text + self.input_buf[self.input_cursor:] + self.input_cursor += len(burst_text) + nl_cnt = self.input_buf.count("\n") + self.set_toast(f"📋 Pasted chunk ({nl_cnt + 1} lines). Press [Enter] to send.", "success") + return True + + line = self.input_buf.strip() + if not line: + return True + + # Soft guardrail: require double-tap Enter during active rate limit cooldown + is_cmd = line.startswith("/") and not line.startswith("//") + if not is_cmd: + cur_node = self.data.active_node + cd = self.data.get_node_cooldown_remaining(cur_node) + if cd > 0: + now = time.time() + last_force = getattr(self, "_last_force_send_time", 0.0) + if (now - last_force) <= 2.5: + self.data.clear_node_rate_limit(cur_node) + self.set_toast(f"⚡ Bypassed cooldown. Force-sending to {cur_node.upper()}...", "warn") + self._last_force_send_time = 0.0 + else: + self._last_force_send_time = now + self.set_toast(f"⏳ {cur_node.upper()} in cooldown ({cd}s left). Press Enter again within 2.5s to force-send.", "warn") + return True + + self.input_history.append(line) + self.history_idx = len(self.input_history) + self.input_buf = "" + self.input_cursor = 0 + self._execute_input_line(line) + if getattr(self, "should_exit", False): + return False + return True + + # Backspace + elif ch in (curses.KEY_BACKSPACE, 127, 8): + if self.input_cursor > 0: + self.input_buf = self.input_buf[:self.input_cursor - 1] + self.input_buf[self.input_cursor:] + self.input_cursor -= 1 + return True + + # Delete + elif ch == curses.KEY_DC: + if self.input_cursor < len(self.input_buf): + self.input_buf = self.input_buf[:self.input_cursor] + self.input_buf[self.input_cursor + 1:] + return True + + # Left / Right Cursor + elif ch == curses.KEY_LEFT: + if self.input_cursor > 0: + self.input_cursor -= 1 + return True + elif ch == curses.KEY_RIGHT: + if self.input_cursor < len(self.input_buf): + self.input_cursor += 1 + return True + + # Home / End + elif ch in (curses.KEY_HOME, 1): # Home or Ctrl+A + self.input_cursor = 0 + return True + elif ch in (curses.KEY_END, 5): # End or Ctrl+E + self.input_cursor = len(self.input_buf) + return True + + # Word Deletion: Ctrl+W + elif ch == 23: + parts = self.input_buf[:self.input_cursor].rstrip().rsplit(" ", 1) + keep = parts[0] + " " if len(parts) > 1 else "" + self.input_buf = keep + self.input_buf[self.input_cursor:] + self.input_cursor = len(keep) + return True + + # Line clear: Ctrl+U + elif ch == 21: + self.input_buf = "" + self.input_cursor = 0 + return True + + # History Navigation / Multiline Navigation: Up / Down + elif ch == curses.KEY_UP: + if "\n" in self.input_buf: + curr = self.input_buf[:self.input_cursor] + last_nl = curr.rfind("\n") + if last_nl != -1: + prev_line_start = curr[:last_nl].rfind("\n") + prev_line_start = 0 if prev_line_start == -1 else (prev_line_start + 1) + col = len(curr) - (last_nl + 1) + self.input_cursor = min(last_nl, prev_line_start + col) + return True + if self.input_history and self.history_idx > 0: + self.history_idx -= 1 + self.input_buf = self.input_history[self.history_idx] + self.input_cursor = len(self.input_buf) + return True + + elif ch == curses.KEY_DOWN: + if "\n" in self.input_buf: + rest = self.input_buf[self.input_cursor:] + next_nl = rest.find("\n") + if next_nl != -1: + next_line_start = self.input_cursor + next_nl + 1 + following_nl = self.input_buf[next_line_start:].find("\n") + next_line_end = len(self.input_buf) if following_nl == -1 else (next_line_start + following_nl) + curr = self.input_buf[:self.input_cursor] + curr_col = len(curr) - (curr.rfind("\n") + 1) + self.input_cursor = min(next_line_end, next_line_start + curr_col) + return True + if self.input_history and self.history_idx < len(self.input_history) - 1: + self.history_idx += 1 + self.input_buf = self.input_history[self.history_idx] + self.input_cursor = len(self.input_buf) + elif self.input_history and self.history_idx == len(self.input_history) - 1: + self.history_idx = len(self.input_history) + self.input_buf = "" + self.input_cursor = 0 + return True + + # Tab in insert mode: return to normal mode (modal lock) + elif ch == ord('\t'): + self.editor_mode = "NORMAL" + self.set_toast("NORMAL mode (Nav locked). Press 'i' to type.", "info") + return True + + # Printable Characters + elif 32 <= ch <= 126: + char = chr(ch) + self.input_buf = self.input_buf[:self.input_cursor] + char + self.input_buf[self.input_cursor:] + self.input_cursor += 1 + return True + + # Multi-byte UTF-8 or Extended Unicode + elif 128 <= ch <= 255: + if not hasattr(self, "_utf8_buf"): + self._utf8_buf = bytearray() + self._utf8_buf.append(ch) + try: + char = self._utf8_buf.decode("utf-8") + self._utf8_buf.clear() + self.input_buf = self.input_buf[:self.input_cursor] + char + self.input_buf[self.input_cursor:] + self.input_cursor += len(char) + except UnicodeDecodeError: + if len(self._utf8_buf) >= 4: + self._utf8_buf.clear() + return True + + elif ch >= 256: + try: + char = chr(ch) + if char.isprintable(): + self.input_buf = self.input_buf[:self.input_cursor] + char + self.input_buf[self.input_cursor:] + self.input_cursor += len(char) + return True + except Exception: + pass + return True + + return True + + def _execute_input_line(self, line: str): + """Execute typed line either as slash command or direct agent chat message.""" + if line.startswith("/"): + parts = line[1:].split(" ", 1) + cmd = parts[0].lower() + arg = parts[1].strip() if len(parts) > 1 else "" + + if cmd in ("exit", "quit", "q"): + self.should_exit = True + return + elif cmd in ("help", "h"): + self.modal = "help" + elif cmd in ("refresh", "sync", "r"): + self.data.trigger_refresh() + self.set_toast("Forced refresh across fleet.", "info") + elif cmd in ("clear", "cls"): + self.transcript_scroll_offset = 0 + self.set_toast("Transcript view reset.", "info") + elif cmd == "mode": + self.mode = "box" if self.mode == "muse" else "muse" + self.set_toast(f"Mode switched to {self.mode.upper()}", "info") + elif cmd in ("node", "agent", "account", "a", "switch"): + if not arg: + self.modal = "agent_search" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.agent_search_sel = 0 + self.agent_search_scroll = 0 + else: + matched = None + for n in self.data.nodes: + if n.lower() == arg.lower(): + matched = n + break + if not matched: + for n in self.data.nodes: + if n.lower().startswith(arg.lower()): + matched = n + break + if matched: + self.data.set_active_target(matched) + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.data._fetch_threads(matched) + self.set_toast(f"Switched to node {matched.upper()}", "success") + else: + self.set_toast(f"Unknown node '{arg}'. Valid: {', '.join(self.data.nodes)}", "error") + elif cmd == "new": + title = arg or "operator-session" + self.set_toast(f"Creating sidechat '{title}'...", "info") + threading.Thread(target=self._async_create_sidechat, args=(title,), daemon=True).start() + elif cmd in ("wo", "workorder"): + if not arg: + self.modal = "work_order" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + else: + wo_parts = arg.split(" ", 1) + title = wo_parts[0] + body = wo_parts[1] if len(wo_parts) > 1 else title + threading.Thread(target=self._async_issue_wo, args=(title, body), daemon=True).start() + elif cmd in ("blocked", "approvals", "approval", "app"): + self.modal = "approvals" + self.approvals_sel_idx = 0 + with self.data.lock: + apps = [it for it in self.data.approvals_cache if it.get("status") in ("PENDING", "INPUT_WAIT", "KEY_APPROVAL")] + if apps: + self.set_toast(f"⚠ {len(apps)} agent(s) blocked/waiting. Select with j/k, [1] Allow, [2] Always, [3] Deny", "warn_banner") + else: + self.set_toast("✔ Fleet is clear: 0 agents blocked.", "success") + threading.Thread(target=self.data._fetch_approvals, daemon=True).start() + return + elif cmd in ("approve", "allow"): + threading.Thread(target=self._async_resolve_approval, args=("allow",), daemon=True).start() + elif cmd == "always": + threading.Thread(target=self._async_resolve_approval, args=("always",), daemon=True).start() + elif cmd == "deny": + threading.Thread(target=self._async_resolve_approval, args=("deny",), daemon=True).start() + elif cmd in ("auto", "autopilot"): + threading.Thread(target=self._async_resolve_approval, args=("auto",), daemon=True).start() + elif cmd in ("pin", "unpin"): + tid = self.data.active_thread_id + node = self.data.active_node + if tid: + new_pin = self.data.toggle_pin_thread(node, tid) + state = "Pinned" if new_pin else "Unpinned" + else: + self.set_toast("No active chat to pin/unpin", "error") + elif cmd in ("prompt", "prompts", "p", "skill", "skills"): + if not arg: + self.modal = "prompts" + self.prompt_sel_idx = 0 + elif arg.startswith("save "): + title = arg[5:].strip() + body = self.input_buf.strip() or title + p = self.prompt_manager.add_prompt(title, body, tags=["custom", "saved"], pinned=True) + self.prompts = self.prompt_manager.get_sorted_prompts() + self.set_toast(f"✔ Stored prompt skill: '{p['title']}'", "success") + elif arg.startswith("add "): + content = arg[4:].strip() + parts = content.split("|", 1) + title = parts[0].strip() + body = parts[1].strip() if len(parts) > 1 else title + p = self.prompt_manager.add_prompt(title, body, tags=["custom"], pinned=True) + self.prompts = self.prompt_manager.get_sorted_prompts() + self.set_toast(f"✔ Added prompt skill: '{p['title']}'", "success") + elif arg.startswith("del ") or arg.startswith("delete "): + pid = arg.split(" ", 1)[1].strip() + if self.prompt_manager.delete_prompt(pid): + self.prompts = self.prompt_manager.get_sorted_prompts() + self.set_toast(f"Deleted prompt '{pid}'", "warn_banner") + else: + self.set_toast(f"Prompt '{pid}' not found", "error") + else: + self.modal = "prompts" + self.prompt_sel_idx = 0 + elif cmd in ("sends", "history", "search"): + self.modal = "history_search" + self.modal_input_buf = arg + self.modal_input_cursor = len(arg) + self.history_search_sel = 0 + self.history_search_scroll = 0 + elif cmd in ("copy", "yank", "cp"): + if arg.lower() in ("last", "msg", "message"): + self.copy_message_to_clipboard() + else: + self.copy_transcript_to_clipboard() + elif cmd in ("clean", "boxed", "box", "view", "style", "compact"): + if cmd == "clean" or arg.lower() in ("clean", "compact"): + if self.transcript_style != "clean": + self.set_transcript_style("clean") + else: + self.set_toast("Transcript style is already Clean (line-by-line).", "info") + elif cmd in ("boxed", "box") or arg.lower() in ("boxed", "box"): + if self.transcript_style != "boxed": + self.set_transcript_style("boxed") + else: + self.set_toast("Transcript style is already Boxed (classic frames).", "info") + else: + self.toggle_transcript_style() + elif cmd in ("context", "menu", "actions"): + if arg.lower() in ("agent", "fleet", "node"): + st_info = self.data.node_status.get(self.data.active_node, {}) + self.context_agent = { + "node": self.data.active_node, + "status": st_info, + "index": 0, + } + self.agent_context_sel = 0 + self.modal = "agent_context" + elif arg.lower() in ("msg", "message"): + 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.message_context_sel = 0 + self.modal = "message_context" + else: + self.set_toast("No message to show context menu for", "warn_banner") + else: + with self.data.lock: + threads = list(self.data.threads_cache.get(self.data.active_node, [])) + t = None + if threads: + for th in threads: + if th.get("session_id") == self.data.active_thread_id: + t = th + break + if not t: + t = threads[0] + if t: + self.context_chat = { + "node": self.data.active_node, + "thread": t, + "index": 0, + } + self.chat_context_sel = 0 + self.modal = "chat_context" + else: + self.set_toast("No active chat for context menu", "warn_banner") + else: + if "\n" in line or "/" in cmd or line.startswith("//"): + pass # Pass through to deliver as chat message + else: + self.set_toast(f"Unknown slash command: /{cmd}. Type /help for cheat-sheet.", "error") + return + if not ("\n" in line or "/" in cmd or line.startswith("//")): + return + + # Direct standard message to active thread + with self.data.lock: + cur_node = self.data.active_node + cur_thread = self.data.active_thread_id + cur_title = self.data.active_thread_title + node_threads = self.data.threads_cache.get(cur_node, []) + cur_valid = any(t.get("session_id") == cur_thread for t in node_threads) + if not cur_thread or not cur_valid: + cur_thread, cur_title = self.data.select_main_chat(cur_node) + + # Quote target message if replying + if getattr(self, "reply_target", None): + rep = self.reply_target + r_role = rep.get("role", "agent").upper() + r_ref = f"seq:{rep.get('seq')}" if rep.get("seq") else f"id:{rep.get('msg_id', '')[:8]}" + r_text = rep.get("text", "").strip() + if len(r_text) > 280: + q_text = r_text[:280].rstrip() + "..." + else: + q_text = r_text + quoted_lines = "\n".join(f"> {l}" for l in q_text.split("\n")) + line = f"> Replying to {r_role} ({r_ref}):\n{quoted_lines}\n\n{line}" + self.reply_target = None + + if not cur_title: + cur_title = "Main Chat" + + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.data.record_interaction(cur_node) + self.data.record_activity() + self.data.add_optimistic_message(cur_node, cur_thread, line) + self.set_toast(f"Sending message to {cur_node.upper()} ({cur_title})...", "info") + threading.Thread(target=self._async_send_message, args=(cur_node, cur_thread, line), daemon=True).start() + + # Async action helpers + def _async_send_message(self, node: str, thread_id: str, text: str): + ok, res = self.data.send_chat_message(node, thread_id, text) + if ok: + self.set_toast(f"✔ Delivered to {node.upper()}.", "success") + threading.Thread(target=self._post_send_sync, args=(node, thread_id), daemon=True).start() + else: + self.set_toast(f"Failed to deliver: {res[:40]}", "error") + + def _post_send_sync(self, node: str, thread_id: str): + # 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 + ok, sid, msg = self.data.create_sidechat(node, title) + if ok: + self.set_toast(f"✔ Created sidechat '{title}' [{sid[:6]}]", "success") + else: + self.set_toast(f"Failed to create sidechat: {msg[:40]}", "error") + + def _async_issue_wo(self, title: str, body: str): + node = self.data.active_node + self.data.record_interaction(node) + tid = self.data.active_thread_id + if not tid: + self.set_toast("Cannot issue Work Order: No active sidechat.", "error") + return + self.set_toast(f"Issuing Work Order '{title}'...", "info") + ok, res = self.data.issue_work_order(node, tid, title, body) + if ok: + self.set_toast(f"✔ Work Order '{title}' delivered to {node.upper()}.", "success") + else: + self.set_toast(f"Work Order delivery failed: {res[:40]}", "error") + + def _async_resolve_approval(self, action: str, node: str = None, text: str = ""): + node = node or self.data.active_node + if action == "clear_all": + ok, msg = self.data.clear_all_waits() + if ok: + self.set_toast("✔ All pending task waits cleared across fleet.", "success") + else: + self.set_toast(f"Clear failed: {msg[:40]}", "error") + return + elif action == "dismiss": + ok, msg = self.data.dismiss_task(node) + if ok: + self.set_toast(f"✔ Dismissed active task wait on {node.upper()}.", "success") + else: + self.set_toast(f"Dismiss failed: {msg[:40]}", "error") + return + elif action == "reply": + msg_text = text or "Proceed with standard execution." + self.data.record_interaction(node) + ok, msg = self.data.reply_task(node, msg_text) + if ok: + self.set_toast(f"✔ Responded to task wait on {node.upper()}.", "success") + else: + self.set_toast(f"Reply failed: {msg[:40]}", "error") + return + + ok, msg = self.data.resolve_approval(node, action) + if ok: + self.set_toast(f"✔ Approval '{action}' applied to {node.upper()}.", "success") + else: + self.set_toast(f"Approval failed: {msg[:40]}", "error") + + def _open_job_conversation(self, job_name: str, agent: str): + """Link to conversation thread for the selected job in Agent Chat 1 for reading / entry / scrollback.""" + target_agent = agent if agent in self.data.nodes else self.data.active_node + target_thread_id = None + target_title = None + + # 1. Check job-sidechats.json + sidechats_path = REPO_ROOT / "job-sidechats.json" + if sidechats_path.exists(): + try: + with open(sidechats_path, "r", encoding="utf-8") as f: + sc_data = json.load(f) + + keys_to_try = [ + job_name, + f"{job_name}@{target_agent}", + f"{job_name}@{agent}", + f"{agent} tasks", + f"{agent}-tasks", + ] + for k in keys_to_try: + if k in sc_data: + match = sc_data[k] + target_thread_id = match.get("thread_uuid") or match.get("session_id") + target_agent = match.get("agent", target_agent) + target_title = match.get("title") or match.get("description") or k + break + + if not target_thread_id: + for k, match in sc_data.items(): + if job_name in k or k in job_name: + target_thread_id = match.get("thread_uuid") or match.get("session_id") + target_agent = match.get("agent", target_agent) + target_title = match.get("title") or match.get("description") or k + break + except Exception: + pass + + # 2. Check threads cache for the agent + with self.data.lock: + threads = list(self.data.threads_cache.get(target_agent, [])) + if not target_thread_id and threads: + for t in threads: + t_title = (t.get("title") or "").lower() + if job_name.lower() in t_title or t_title in job_name.lower(): + target_thread_id = t.get("session_id") + target_title = t.get("title") + break + + if not target_title: + target_title = f"job:{job_name}" + + # Set active target + self.data.set_active_target(target_agent, target_thread_id, target_title) + + # Trigger async fetch for threads & history + threading.Thread(target=self.data._fetch_threads, args=(target_agent,), daemon=True).start() + if target_thread_id: + threading.Thread(target=self.data._fetch_history, args=(target_agent, target_thread_id), daemon=True).start() + + # Switch to Agent Chat (Tab 0) + self.box_tab = 0 + self.focus_pane = "transcript" + self.auto_scroll = False + self.transcript_scroll_offset = 0 + if target_thread_id: + self.set_toast(f"✔ Linked to conversation for '{job_name}' [{target_thread_id[:8]}] on {target_agent.upper()}", "success") + else: + self.set_toast(f"✔ Switched to {target_agent.upper()} for job '{job_name}'", "info") + + def _async_trigger_job(self, job_name: str): + self.set_toast(f"Triggering job '{job_name}'...", "info") + ok, msg = self.data.trigger_job(job_name) + if ok: + self.set_toast(f"✔ Job '{job_name}' triggered successfully.", "success") + else: + self.set_toast(f"Job trigger failed: {msg[:40]}", "error") + + def _async_kill_tmux(self, sess_name: str): + self.set_toast(f"Terminating tmux session '{sess_name}'...", "info") + ok, msg = self.data.kill_tmux_session(sess_name) + if ok: + self.set_toast(f"✔ Session '{sess_name}' killed.", "success") + else: + self.set_toast(f"Kill failed: {msg[:40]}", "error") + + def _ssh_popout_selected(self): + """Open the selected container SSH session in a new tmux window.""" + rows = self.get_ssh_rows() + if not rows or not (0 <= self.ssh_sel_idx < len(rows)): + self.set_toast("No SSH row selected.", "warn") + return + acct = rows[self.ssh_sel_idx] + if acct not in SSH_TUNNEL_PORTS: + self.set_toast(f"No tunnel registered for '{acct}'.", "warn") + return + if not os.environ.get("TMUX"): + # Refuse to spawn into an invisible server: new-window would + # create a detached server the operator cannot see. + try: + probe = subprocess.run(["tmux", "ls"], capture_output=True, text=True, timeout=3) + server_up = probe.returncode == 0 + except Exception: + server_up = False + if not server_up: + copy_to_clipboard(build_ssh_dial_string(acct)) + self.set_toast("Not inside tmux and no server running; dial copied to clipboard.", "warn") + return + shell_cmd = build_ssh_popout_shell(acct) + pop_cmd = build_tmux_popout_command(f"ssh-{acct}", shell_cmd) + try: + curses.def_prog_mode() + curses.endwin() + try: + res = subprocess.run(pop_cmd, capture_output=True, text=True, timeout=5) + finally: + try: + curses.reset_prog_mode() + self.stdscr.refresh() + except Exception: + pass + self.need_full_redraw = True + if res.returncode == 0: + self.set_toast(f"Opened SSH to {acct} in tmux window ssh-{acct}.", "success") + else: + copy_to_clipboard(build_ssh_dial_string(acct)) + err = (res.stderr or "").strip().splitlines() + hint = err[-1][:60] if err else f"exit {res.returncode}" + self.set_toast(f"tmux pop-out failed ({hint}); dial copied.", "error") + except Exception as e: + copy_to_clipboard(build_ssh_dial_string(acct)) + self.set_toast(f"Pop-out failed ({e}); dial copied to clipboard.", "error") + + def _ssh_copy_dial_selected(self): + """Copy the selected container's dial command to the clipboard.""" + rows = self.get_ssh_rows() + if not rows or not (0 <= self.ssh_sel_idx < len(rows)): + self.set_toast("No SSH row selected.", "warn") + return + acct = rows[self.ssh_sel_idx] + if acct not in SSH_TUNNEL_PORTS: + self.set_toast(f"No tunnel registered for '{acct}'.", "warn") + return + dial = build_ssh_dial_string(acct) + if copy_to_clipboard(dial): + self.set_toast(f"Copied dial for {acct}: {dial[:80]}", "success") + else: + self.set_toast(f"Dial for {acct}: {dial}", "info") + + def _async_ssh_uptime(self, account: str): + ok, out_text = self.data.probe_container_uptime(account) + if ok: + first = (out_text or "").strip().splitlines() + self.set_toast(f"{account} uptime: {first[0][:90]}" if first else f"{account}: uptime probe empty.", "success" if first else "warn") + else: + self.set_toast(f"{account} uptime failed: {out_text[:90]}", "error") + + def _async_dispatch_work(self, goal: str, target_node: str): + """Dispatch a new work build ticket with 45s synchronous readback gate.""" + self.set_toast(f"⚡ Dispatching work ticket to @{target_node.upper()}...", "info") + try: + cmd = [sys.executable, str(BIN_DIR / "box-work.py"), "start", "--agent", target_node, goal] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=90) + if res.returncode == 0: + self.set_toast(f"✔ Work dispatched to @{target_node.upper()}: {goal[:32]}", "success") + else: + err = (res.stderr or res.stdout or "Execution failed").strip().splitlines()[-1] + self.set_toast(f"⚠️ Dispatch warning: {err[:45]}", "warn_banner") + except Exception as e: + self.set_toast(f"❌ Dispatch error: {str(e)[:40]}", "error") + self.data._fetch_cognitive_and_work() + + def _async_refocus_main(self, node: str): + """Refocus agent browser tab back to Main Chat to unlock from settled sidechat.""" + self.set_toast(f"Refocusing @{node.upper()} to Main Chat...", "info") + def _run(): + ok = False + if acp: + try: + ok = acp.navigate_to_main_chat(node) + except Exception: + pass + if not ok: + cmd = [sys.executable, str(BIN_DIR / "agent-cognitive-probe.py"), "nav-main", node] + res = subprocess.run(cmd, capture_output=True, text=True) + ok = (res.returncode == 0) + if ok: + self.set_toast(f"✔ @{node.upper()} refocused to Main Chat (🟢 IDLE)", "success") + else: + self.set_toast(f"❌ Failed to refocus @{node.upper()}", "error") + self.data._fetch_cognitive_and_work() + threading.Thread(target=_run, daemon=True).start() + + def _async_run_loopback(self): + """Trigger an immediate cognitive loopback sweep across active tickets.""" + self.set_toast("Running cognitive loopback sweep on active tickets...", "info") + def _run(): + cmd = [sys.executable, str(BIN_DIR / "box-readback-loopback.py"), "sweep"] + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode == 0: + self.set_toast("✔ Loopback sweep completed successfully.", "success") + else: + self.set_toast("⚠️ Loopback sweep completed with warnings.", "warn_banner") + self.data._fetch_cognitive_and_work() + threading.Thread(target=_run, daemon=True).start() + + def _async_heal_agent(self, node: str): + """Run cognitive state auto-heal against target agent.""" + self.set_toast(f"Attempting cognitive auto-heal on @{node.upper()}...", "info") + def _run(): + cmd = [sys.executable, str(BIN_DIR / "box-work.py"), "heal", node] + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode == 0: + self.set_toast(f"✔ @{node.upper()} cognitive state restored.", "success") + else: + self.set_toast(f"⚠️ Auto-heal for @{node.upper()} finished with alerts.", "warn_banner") + self.data._fetch_cognitive_and_work() + threading.Thread(target=_run, daemon=True).start() + + def _open_agent_thoughts(self, node: str): + """Open modal streaming live thought paragraphs and screen state.""" + self.selected_thought_node = node + self.agent_thought_data = {} + self.modal = "agent_thoughts" + def _fetch(): + scr = {} + if acp: + try: + scr = acp.inspect_agent_screen(node) + except Exception: + pass + if not scr: + cmd = [sys.executable, str(BIN_DIR / "agent-cognitive-probe.py"), "inspect", node, "--json"] + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if res.returncode == 0: + scr = json.loads(res.stdout) + except Exception: + pass + self.agent_thought_data = scr + threading.Thread(target=_fetch, daemon=True).start() + + def _open_agent_menu(self, node: str, tab: str = "activity"): + """Open agent profile menu (tasks / timers / approvals / identity).""" + self.selected_menu_node = node + self.menu_tab = tab + self.agent_menu_data = {} + self.modal = "agent_menu" + self._async_load_menu(node, tab) + + def _async_load_menu(self, node: str, tab: str): + """Fetch agent profile menu items for a specific tab.""" + self.menu_tab = tab + def _fetch(): + data = {} + if acp: + try: + data = acp.navigate_agent_profile_menu(node, tab) + except Exception: + pass + if not data: + cmd = [sys.executable, str(BIN_DIR / "agent-cognitive-probe.py"), "menu", node, "--tab", tab, "--json"] + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if res.returncode == 0: + data = json.loads(res.stdout) + except Exception: + pass + self.agent_menu_data = data + threading.Thread(target=_fetch, daemon=True).start() + + def _execute_chat_action(self, action_id: str): + """Execute selected contextual action on the targeted chat thread.""" + chat_info = getattr(self, "context_chat", None) or {} + t = chat_info.get("thread", {}) + node = chat_info.get("node", self.data.active_node) + sid = t.get("session_id", "") + title = t.get("title") or (sid[:8] if sid else "Main Chat") + is_main = t.get("is_main", False) + + if action_id == "open": + self.data.set_active_target(node, sid, title) + self.focus_pane = "transcript" + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.set_toast(f"Opened thread '{title}'", "success") + self.modal = None + elif action_id == "pin": + if sid: + new_pin = self.data.toggle_pin_thread(node, sid) + state = "Pinned" if new_pin else "Unpinned" + self.set_toast(f"📌 {state} chat: {title[:25]}", "success") + self.modal = None + elif action_id == "read": + if sid: + self.data.mark_thread_read(node, sid) + self.set_toast(f"✔ Marked '{title[:25]}' as read.", "success") + self.modal = None + elif action_id == "copy_id": + if sid: + copy_to_clipboard(sid) + self.set_toast(f"📋 Copied session ID: {sid}", "success") + self.modal = None + elif action_id == "copy_url": + if sid: + url = f"https://muse.ai/thread/{sid}" + copy_to_clipboard(url) + self.set_toast(f"🔗 Copied thread URL: {url}", "success") + self.modal = None + elif action_id == "reply": + self.data.set_active_target(node, sid, title) + with self.data.lock: + msgs = list(self.data.history_cache.get((node, sid), [])) + if msgs: + self.trigger_reply_to_message(msgs[-1]) + else: + self.editor_mode = "INSERT" + self.set_toast(f"Switched to {title} (no messages to reply to)", "info") + self.modal = None + elif action_id == "new": + self.data.set_active_target(node) + self.modal = "new_sidechat" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + elif action_id == "rename": + self.modal = "rename_sidechat" + self.modal_input_buf = title + self.modal_input_cursor = len(title) + self.modal_extra = {"node": node, "session_id": sid} + return + elif action_id == "fork": + fork_title = f"{title[:20]} (fork)" + ok, new_sid, msg = self.data.create_sidechat(node, fork_title) + if ok and new_sid: + with self.data.lock: + orig_msgs = list(self.data.history_cache.get((node, sid), [])) + if orig_msgs: + self.data.history_cache[(node, new_sid)] = [dict(m) for m in orig_msgs] + self.data.set_active_target(node, new_sid, fork_title) + self.set_toast(f"🌱 Forked sidechat: '{fork_title}'", "success") + else: + self.set_toast(f"Failed to fork sidechat: {msg[:40]}", "error") + self.modal = None + elif action_id == "close": + if is_main: + self.set_toast("⚠️ Cannot close Main Chat.", "warn_banner") + else: + with self.data.lock: + threads = self.data.threads_cache.get(node, []) + self.data.threads_cache[node] = [th for th in threads if th.get("session_id") != sid] + if self.data.active_thread_id == sid: + self.data.select_main_chat(node) + self.set_toast(f"🗑 Closed sidechat '{title}'", "info") + self.modal = None + + def _execute_agent_action(self, action_id: str): + """Execute selected contextual action on the targeted fleet agent.""" + agent_info = getattr(self, "context_agent", None) or {} + node = agent_info.get("node", self.data.active_node) + + if action_id == "open": + self.data.set_active_target(node) + self.focus_pane = "transcript" + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.set_toast(f"Switched to agent: {node.upper()}", "success") + self.modal = None + elif action_id == "sync": + cd = self.data.get_node_cooldown_remaining(node) + if cd > 0: + now = time.time() + last_force = getattr(self, "_last_force_sync_time", 0.0) + if (now - last_force) <= 2.0: + self.data.clear_node_rate_limit(node) + self.data.lazy_fetch_threads(node, force=True) + self.data.trigger_refresh() + self.set_toast(f"⚡ Force-syncing {node.upper()} (cooldown bypassed)...", "info") + self._last_force_sync_time = 0.0 + self.modal = None + else: + self._last_force_sync_time = now + self.set_toast(f"⏳ {node.upper()} cooling down ({cd}s left). Select Sync again within 2s to force.", "warn") + else: + self.data.lazy_fetch_threads(node, force=True) + self.data.trigger_refresh() + self.set_toast(f"Syncing threads for {node.upper()}...", "info") + self.modal = None + elif action_id == "approvals": + with self.data.lock: + apps = [it for it in self.data.approvals_cache if it.get("node") == node] + self.modal = "approvals" + if apps: + self.set_toast(f"⚡ {len(apps)} pending approval(s) for {node.upper()}", "warn_banner") + else: + self.set_toast(f"✔ 0 pending approvals for {node.upper()}", "success") + elif action_id == "read_all": + self.data.mark_node_read(node) + self.set_toast(f"✔ Marked all chats on {node.upper()} as read.", "success") + self.modal = None + elif action_id == "tmux": + cmd = f"tmux -S /tmp/tmux-muse.sock attach -t agent-{node}" + copy_to_clipboard(cmd) + self.set_toast(f"📋 Copied tmux command: {cmd}", "success") + self.modal = None + elif action_id == "wo": + self.modal = "work_order" + self.modal_input_buf = f"[{node.upper()}] " + self.modal_input_cursor = len(self.modal_input_buf) + elif action_id == "new": + self.data.set_active_target(node) + self.modal = "new_sidechat" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + elif action_id == "focus": + self.data.set_active_target(node) + self.focus_pane = "sidebar" + self.set_toast(f"Focused sidebar on {node.upper()}", "info") + self.modal = None + + def _execute_message_action(self, action_id: str): + """Execute selected contextual action on the targeted message.""" + m = getattr(self, "context_message", None) or {} + if not m: + self.modal = None + return + + if action_id == "reply": + self.trigger_reply_to_message(m) + self.modal = None + elif action_id == "copy": + self.copy_message_to_clipboard(m.get("text", ""), with_context=False) + self.modal = None + elif action_id == "copy_context": + self.copy_message_to_clipboard(m.get("text", ""), with_context=True) + self.modal = None + elif action_id == "pin": + text = m.get("text", "") + if text: + p, is_new = self.prompt_manager.pin_chat_send(text, node=self.data.active_node, toggle=True) + self.prompts = self.prompt_manager.get_sorted_prompts() + state = "Pinned to" if is_new else "Pinned" + self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success") + self.modal = None + elif action_id == "copy_id": + mid = m.get("message_id") or str(m.get("seq", "")) + if mid: + copy_to_clipboard(mid) + self.set_toast(f"📋 Copied message ID: {mid}", "success") + self.modal = None + + def _handle_modal_key(self, ch: int) -> bool: + # Close on Esc or 'q' (except in text entry modals) + if ch in (27, ord('q'), ord('Q')) and self.modal not in ("new_sidechat", "work_order", "agent_search", "work_dispatch"): + self.modal = None + return True + + if self.modal in ("help",): + # Double-press ? or F1 or Space or Enter closes help + if ch in (curses.KEY_ENTER, 10, 13, ord(' '), ord('?'), curses.KEY_F1): + self.modal = None + return True + + elif self.modal == "chat_context": + actions = getattr(self, "_chat_context_actions", [ + ("open", "", ""), ("pin", "", ""), ("read", "", ""), + ("copy_id", "", ""), ("copy_url", "", ""), ("reply", "", ""), + ("new", "", ""), ("close", "", "") + ]) + if ch in (27, ord('q'), ord('Q')): + self.modal = None + return True + + if ch in (ord('j'), ord('J'), curses.KEY_DOWN): + self.chat_context_sel = min(len(actions) - 1, getattr(self, "chat_context_sel", 0) + 1) + return True + elif ch in (ord('k'), ord('K'), curses.KEY_UP): + self.chat_context_sel = max(0, getattr(self, "chat_context_sel", 0) - 1) + return True + + if ch in (curses.KEY_ENTER, 10, 13, ord(' ')): + idx = max(0, min(getattr(self, "chat_context_sel", 0), len(actions) - 1)) + self._execute_chat_action(actions[idx][0]) + return True + + key_map = { + ord('1'): "open", ord('o'): "open", ord('O'): "open", + ord('2'): "pin", ord('p'): "pin", ord('P'): "pin", + ord('3'): "read", ord('m'): "read", ord('M'): "read", + ord('4'): "copy_id", ord('y'): "copy_id", ord('Y'): "copy_id", + ord('5'): "copy_url", ord('u'): "copy_url", ord('U'): "copy_url", + ord('6'): "reply", ord('r'): "reply", ord('R'): "reply", + ord('7'): "rename", ord('e'): "rename", ord('E'): "rename", + ord('8'): "fork", ord('b'): "fork", ord('B'): "fork", + ord('9'): "close", ord('d'): "close", ord('D'): "close", + } + if ch in key_map: + self._execute_chat_action(key_map[ch]) + return True + return True + + elif self.modal == "rename_sidechat": + if ch == 27: + self.modal = None + return True + if ch in (curses.KEY_ENTER, 10, 13): + new_title = self.modal_input_buf.strip() + node = self.modal_extra.get("node", self.data.active_node) + sid = self.modal_extra.get("session_id", self.data.active_thread_id) + if new_title and sid: + with self.data.lock: + threads = self.data.threads_cache.get(node, []) + for th in threads: + if th.get("session_id") == sid: + th["title"] = new_title + break + if self.data.active_thread_id == sid: + self.data.active_thread_title = new_title + self.data._save_threads_cache(node) + self.set_toast(f"✏ Renamed chat to '{new_title}'", "success") + self.modal = None + return True + if ch in (curses.KEY_BACKSPACE, 127, 8): + if self.modal_input_cursor > 0: + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor -= 1 + return True + elif ch == curses.KEY_DC: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:] + return True + elif ch == curses.KEY_LEFT: + if self.modal_input_cursor > 0: + self.modal_input_cursor -= 1 + return True + elif ch == curses.KEY_RIGHT: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_cursor += 1 + return True + elif 32 <= ch <= 126: + char = chr(ch) + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor += 1 + return True + + elif self.modal == "agent_context": + actions = getattr(self, "_agent_context_actions", [ + ("open", "", ""), ("approvals", "", ""), ("tmux", "", ""), + ("wo", "", ""), ("sync", "", ""), ("read_all", "", "") + ]) + if ch in (27, ord('q'), ord('Q')): + self.modal = None + return True + + if ch in (ord('j'), ord('J'), curses.KEY_DOWN): + self.agent_context_sel = min(len(actions) - 1, getattr(self, "agent_context_sel", 0) + 1) + return True + elif ch in (ord('k'), ord('K'), curses.KEY_UP): + self.agent_context_sel = max(0, getattr(self, "agent_context_sel", 0) - 1) + return True + + if ch in (curses.KEY_ENTER, 10, 13, ord(' ')): + idx = max(0, min(getattr(self, "agent_context_sel", 0), len(actions) - 1)) + self._execute_agent_action(actions[idx][0]) + return True + + key_map = { + ord('1'): "open", ord('o'): "open", ord('O'): "open", + ord('2'): "approvals", ord('a'): "approvals", ord('A'): "approvals", + ord('3'): "tmux", ord('t'): "tmux", ord('T'): "tmux", + ord('4'): "wo", ord('w'): "wo", ord('W'): "wo", + ord('5'): "sync", ord('s'): "sync", ord('S'): "sync", + ord('6'): "read_all", ord('m'): "read_all", ord('M'): "read_all", + } + if ch in key_map: + self._execute_agent_action(key_map[ch]) + return True + return True + + elif self.modal == "message_context": + actions = getattr(self, "_message_context_actions", [ + ("reply", "", ""), ("copy", "", ""), ("copy_context", "", ""), ("pin", "", ""), ("copy_id", "", "") + ]) + if ch in (27, ord('q'), ord('Q')): + self.modal = None + return True + + if ch in (ord('j'), ord('J'), curses.KEY_DOWN): + self.message_context_sel = min(len(actions) - 1, getattr(self, "message_context_sel", 0) + 1) + return True + elif ch in (ord('k'), ord('K'), curses.KEY_UP): + self.message_context_sel = max(0, getattr(self, "message_context_sel", 0) - 1) + return True + + if ch in (curses.KEY_ENTER, 10, 13, ord(' ')): + idx = max(0, min(getattr(self, "message_context_sel", 0), len(actions) - 1)) + self._execute_message_action(actions[idx][0]) + return True + + key_map = { + ord('1'): "reply", ord('r'): "reply", ord('R'): "reply", + ord('2'): "copy", ord('y'): "copy", + ord('3'): "copy_context", ord('Y'): "copy_context", + ord('4'): "pin", ord('p'): "pin", ord('P'): "pin", + ord('5'): "copy_id", ord('c'): "copy_id", ord('C'): "copy_id", + } + if ch in key_map: + self._execute_message_action(key_map[ch]) + return True + return True + + elif self.modal == "approvals": + with self.data.lock: + apps = [it for it in self.data.approvals_cache if it.get("status") in ("PENDING", "INPUT_WAIT", "KEY_APPROVAL")] + + # Pressing 'a' or F2 again closes approvals modal + if ch in (ord('a'), ord('A'), curses.KEY_F2): + self.modal = None + return True + + if ch in (ord('j'), ord('J'), curses.KEY_DOWN): + if apps: + self.approvals_sel_idx = min(len(apps) - 1, getattr(self, "approvals_sel_idx", 0) + 1) + return True + elif ch in (ord('k'), ord('K'), curses.KEY_UP): + if apps: + self.approvals_sel_idx = max(0, getattr(self, "approvals_sel_idx", 0) - 1) + return True + + self.approvals_sel_idx = max(0, min(getattr(self, "approvals_sel_idx", 0), len(apps) - 1)) if apps else 0 + sel_node = apps[self.approvals_sel_idx].get("node") if apps else self.data.active_node + + if ch == ord('1'): + self._async_resolve_approval("allow", sel_node) + self.modal = None + return True + elif ch == ord('2'): + self._async_resolve_approval("always", sel_node) + self.modal = None + return True + elif ch == ord('3'): + self._async_resolve_approval("deny", sel_node) + self.modal = None + return True + elif ch in (ord('r'), ord('R')): + self._async_resolve_approval("reply", sel_node) + self.modal = None + return True + elif ch in (ord('x'), ord('X')): + self._async_resolve_approval("dismiss", sel_node) + self.modal = None + return True + elif ch in (ord('c'), ord('C')): + self._async_resolve_approval("clear_all") + self.modal = None + return True + elif ch in (ord('p'), ord('P')): + self._async_resolve_approval("auto") + self.modal = None + return True + + elif self.modal == "agent_search": + 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 + + # Esc closes + if ch == 27: + self.modal = None + return True + + # Enter selects + if ch in (curses.KEY_ENTER, 10, 13): + if matches: + idx = max(0, min(getattr(self, "agent_search_sel", 0), len(matches) - 1)) + target_node = matches[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 + + # Navigation Up / Down + if ch in (curses.KEY_UP, 16): # Up or Ctrl+P + if matches: + self.agent_search_sel = max(0, getattr(self, "agent_search_sel", 0) - 1) + return True + elif ch in (curses.KEY_DOWN, 14): # Down or Ctrl+N + if matches: + self.agent_search_sel = min(len(matches) - 1, getattr(self, "agent_search_sel", 0) + 1) + return True + elif ch == ord('\t'): # Tab cycles forward + if matches: + self.agent_search_sel = (getattr(self, "agent_search_sel", 0) + 1) % len(matches) + return True + elif ch == curses.KEY_BTAB: # Shift-Tab cycles backward + if matches: + self.agent_search_sel = (getattr(self, "agent_search_sel", 0) - 1) % len(matches) + return True + + # Text input handling + if ch in (curses.KEY_BACKSPACE, 127, 8): + if self.modal_input_cursor > 0: + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor -= 1 + self.agent_search_sel = 0 + return True + elif ch == curses.KEY_DC: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:] + self.agent_search_sel = 0 + return True + elif ch == curses.KEY_LEFT: + if self.modal_input_cursor > 0: + self.modal_input_cursor -= 1 + return True + elif ch == curses.KEY_RIGHT: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_cursor += 1 + return True + elif 32 <= ch <= 126: + char = chr(ch) + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor += 1 + self.agent_search_sel = 0 + return True + + elif self.modal in ("new_sidechat", "work_order"): + if ch in (curses.KEY_ENTER, 10, 13): + val = self.modal_input_buf.strip() + if self.modal == "new_sidechat": + if val: + threading.Thread(target=self._async_create_sidechat, args=(val,), daemon=True).start() + elif self.modal == "work_order": + if val: + parts = val.split(" ", 1) + title = parts[0] + body = parts[1] if len(parts) > 1 else title + threading.Thread(target=self._async_issue_wo, args=(title, body), daemon=True).start() + self.modal = None + return True + elif ch in (curses.KEY_BACKSPACE, 127, 8): + if self.modal_input_cursor > 0: + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor -= 1 + return True + elif ch == curses.KEY_DC: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:] + return True + elif ch == curses.KEY_LEFT: + if self.modal_input_cursor > 0: + self.modal_input_cursor -= 1 + return True + elif ch == curses.KEY_RIGHT: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_cursor += 1 + return True + elif 32 <= ch <= 126: + char = chr(ch) + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor += 1 + return True + + elif self.modal == "work_dispatch": + if ch == 27: + self.modal = None + return True + if ch == ord('\t'): + with self.data.lock: + nodes = list(self.data.nodes) + cur = getattr(self, "dispatch_target_node", "dev") + cur_idx = nodes.index(cur) if cur in nodes else 0 + self.dispatch_target_node = nodes[(cur_idx + 1) % len(nodes)] + return True + if ch in (curses.KEY_ENTER, 10, 13): + val = self.modal_input_buf.strip() + target = getattr(self, "dispatch_target_node", "dev") + if val: + threading.Thread(target=self._async_dispatch_work, args=(val, target), daemon=True).start() + self.modal = None + return True + if ch in (curses.KEY_BACKSPACE, 127, 8): + if self.modal_input_cursor > 0: + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor -= 1 + return True + elif ch == curses.KEY_DC: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:] + return True + elif ch == curses.KEY_LEFT: + if self.modal_input_cursor > 0: + self.modal_input_cursor -= 1 + return True + elif ch == curses.KEY_RIGHT: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_cursor += 1 + return True + elif 32 <= ch <= 126: + char = chr(ch) + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor += 1 + return True + + elif self.modal == "agent_thoughts": + if ch in (27, ord('q'), ord('Q')): + self.modal = None + return True + node = getattr(self, "selected_thought_node", self.data.active_node) + if ch in (ord('f'), ord('F')): + self._async_refocus_main(node) + self.modal = None + return True + elif ch in (ord('w'), ord('W')): + self.dispatch_target_node = node + self.modal = "work_dispatch" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + return True + elif ch in (ord('r'), ord('R')): + self._open_agent_thoughts(node) + return True + + elif self.modal == "agent_menu": + if ch in (27, ord('q'), ord('Q')): + self.modal = None + return True + node = getattr(self, "selected_menu_node", self.data.active_node) + tabs = ["activity", "upcoming", "approvals", "identity"] + cur_tab = getattr(self, "menu_tab", "activity") + if ch in (ord('1'), ord('a'), ord('A')): + self._async_load_menu(node, "activity") + return True + elif ch in (ord('2'), ord('u'), ord('U')): + self._async_load_menu(node, "upcoming") + return True + elif ch in (ord('3'), ord('p'), ord('P')): + self._async_load_menu(node, "approvals") + return True + elif ch in (ord('4'), ord('i'), ord('I')): + self._async_load_menu(node, "identity") + return True + elif ch == ord('\t'): + c_idx = tabs.index(cur_tab) if cur_tab in tabs else 0 + next_tab = tabs[(c_idx + 1) % len(tabs)] + self._async_load_menu(node, next_tab) + return True + elif ch in (ord('r'), ord('R')): + self._async_load_menu(node, cur_tab) + return True + + elif self.modal == "prompts": + if ch in (27, ord('q')): + self.modal = None + return True + + if ch in (curses.KEY_ENTER, 10, 13): + with self.data.lock: + prompts = list(self.prompts) + if prompts and 0 <= self.prompt_sel_idx < len(prompts): + p = 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 + + if ch in (curses.KEY_UP, ord('k')): + self.prompt_sel_idx = max(0, self.prompt_sel_idx - 1) + self.prompt_preview_scroll = 0 + return True + elif ch in (curses.KEY_DOWN, ord('j')): + with self.data.lock: + prompts_len = len(self.prompts) + self.prompt_sel_idx = min(max(0, prompts_len - 1), self.prompt_sel_idx + 1) + self.prompt_preview_scroll = 0 + return True + elif ch in (curses.KEY_RIGHT, ord('l')): + self.prompt_preview_scroll += 1 + return True + elif ch in (curses.KEY_LEFT, ord('h')): + self.prompt_preview_scroll = max(0, self.prompt_preview_scroll - 1) + return True + elif ch == ord('g'): + self.prompt_sel_idx = 0 + self.prompt_preview_scroll = 0 + return True + elif ch == ord('G'): + with self.data.lock: + prompts_len = len(self.prompts) + self.prompt_sel_idx = max(0, prompts_len - 1) + self.prompt_preview_scroll = 0 + return True + elif ch in (ord('p'), ord('P')): + with self.data.lock: + prompts = list(self.prompts) + if prompts and 0 <= self.prompt_sel_idx < len(prompts): + p = 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 ch in (ord('d'), ord('x')): + with self.data.lock: + prompts = list(self.prompts) + if prompts and 0 <= self.prompt_sel_idx < len(prompts): + p = 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 ch in (ord('n'), ord('a')): + self.modal = "prompt_formulate" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + return True + elif ch in (ord('s'), ord('S')): + self.modal = "history_search" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + self.history_search_sel = 0 + self.history_search_scroll = 0 + return True + + elif self.modal == "history_search": + q = self.modal_input_buf.strip().lower() + sends = self.get_chat_sends(q) + + if ch == 27: + self.modal = None + return True + + if ch in (curses.KEY_ENTER, 10, 13): + if sends and 0 <= self.history_search_sel < len(sends): + send = sends[self.history_search_sel] + 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 ch in (ord('p'), ord('P'), 16): + if 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 + + if ch in (curses.KEY_UP,): + if sends: + self.history_search_sel = max(0, self.history_search_sel - 1) + return True + elif ch in (curses.KEY_DOWN, 14): + if sends: + self.history_search_sel = min(len(sends) - 1, self.history_search_sel + 1) + return True + elif ch == ord('\t'): + if sends: + self.history_search_sel = (self.history_search_sel + 1) % len(sends) + return True + elif ch == curses.KEY_BTAB: + if sends: + self.history_search_sel = (self.history_search_sel - 1) % len(sends) + return True + + if ch in (curses.KEY_BACKSPACE, 127, 8): + if self.modal_input_cursor > 0: + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor -= 1 + self.history_search_sel = 0 + return True + elif ch == curses.KEY_DC: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:] + self.history_search_sel = 0 + return True + elif ch == curses.KEY_LEFT: + if self.modal_input_cursor > 0: + self.modal_input_cursor -= 1 + return True + elif ch == curses.KEY_RIGHT: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_cursor += 1 + return True + elif 32 <= ch <= 126: + char = chr(ch) + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor += 1 + self.history_search_sel = 0 + return True + + elif self.modal == "prompt_formulate": + if ch == 27: + self.modal = None + return True + elif ch in (curses.KEY_ENTER, 10, 13): + title = self.modal_input_buf.strip() or "Custom Prompt" + body = self.modal_extra.get("body") or self.input_buf.strip() or title + p = self.prompt_manager.add_prompt(title, body, tags=["custom", "formulated"], pinned=True) + self.prompts = self.prompt_manager.get_sorted_prompts() + self.set_toast(f"✔ Stored prompt skill: '{p['title']}'", "success") + self.modal = None + return True + elif ch in (curses.KEY_BACKSPACE, 127, 8): + if self.modal_input_cursor > 0: + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor -= 1 + return True + elif ch == curses.KEY_DC: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:] + return True + elif ch == curses.KEY_LEFT: + if self.modal_input_cursor > 0: + self.modal_input_cursor -= 1 + return True + elif ch == curses.KEY_RIGHT: + if self.modal_input_cursor < len(self.modal_input_buf): + self.modal_input_cursor += 1 + return True + elif 32 <= ch <= 126: + char = chr(ch) + self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:] + self.modal_input_cursor += 1 + return True + + return True + + +# --------------------------------------------------------------------------- +# CLI Entrypoint +# --------------------------------------------------------------------------- +def main(): + import argparse + parser = argparse.ArgumentParser(description="Muse & Box Interactive TUI") + parser.add_argument("--account", "-a", choices=VALID_NODES, default=None, help="Initial agent account (default: top of list)") + parser.add_argument("--thread", "-t", help="Initial thread UUID to open") + parser.add_argument("--mode", "-m", choices=["muse", "box"], default="muse", help="TUI mode (default: muse)") + parser.add_argument("--tab", choices=["chat", "fleet", "approvals", "jobs", "tmux", "logs", "ssh"], default=None, help="Initial Box tab (box mode only)") + args = parser.parse_args() + + try: + curses.wrapper(lambda stdscr: MuseTUI( + stdscr, + initial_mode=args.mode, + initial_node=args.account, + initial_thread=args.thread, + initial_tab=args.tab + ).run()) + except KeyboardInterrupt: + pass + except Exception as e: + crash_log = REPO_ROOT / "logs" / "tui-crash.log" + print(f"\n[!] Muse TUI error: {e}", file=sys.stderr) + print(f" Traceback logged to: {crash_log}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_box_cognitive.py b/tests/test_box_cognitive.py index b3d2ac5..e2213ec 100644 --- a/tests/test_box_cognitive.py +++ b/tests/test_box_cognitive.py @@ -184,5 +184,22 @@ class TestBoxWorkCognitiveIntegration(unittest.TestCase): self.assertEqual(cm.exception.code, 1) +class TestBoxFleetTUIWorkIntegration(unittest.TestCase): + def test_gather_work_surface(self): + import importlib.util + from pathlib import Path + bin_dir = Path(__file__).resolve().parent.parent / "bin" + spec = importlib.util.spec_from_file_location("box_fleet_tui", str(bin_dir / "box-fleet-tui.py")) + bft = importlib.util.module_from_spec(spec) + spec.loader.exec_module(bft) + + fake_run = lambda cmd, timeout=15: (0, "active") + snap = bft.gather_work(run=fake_run) + self.assertIn("cognitive", snap) + self.assertIn("issues", snap) + self.assertIn("loopback_active", snap) + self.assertTrue(snap["loopback_active"]) + + if __name__ == "__main__": unittest.main()