feat(tui): SGR mouse fallback, focus partition highlights, multi-trigger context menus & box tui

- bin/muse-tui.py:
  * Parse raw SGR 1006 (\033[<btn;x;yM/m) and Xterm mouse escape sequences in _handle_escape_sequence fallback.
  * Multi-trigger context menus: Button 3, Button 2, Ctrl/Shift/Alt+Click, double-click, click on active item, or click [⚡] / [sid] target.
  * Render permanent [⚡] action target across all sidebar thread rows.
  * Separate focus partitions for FLEET AGENTS and SIDECHATS with partition-specific wheel scrolling and keyboard navigation (j/k, Enter, h/l).
  * Space key support in NORMAL mode to open context menus.
  * Active pane highlighting and updated footer hints.
- bin/super-cli.py:
  * Add 'box tui' command dispatching directly to muse-tui.py --mode box.
- tests:
  * Add unit tests in test_context_menus.py, test_focus_highlight.py, and test_main_nav.py (51/51 passing).
This commit is contained in:
operator
2026-10-06 19:29:22 +00:00
parent 66c8900a58
commit 4998ffddb6
5 changed files with 1936 additions and 340 deletions
+813 -325
View File
File diff suppressed because it is too large Load Diff
+833 -8
View File
@@ -6,7 +6,9 @@ autonomous browser agents (muse, pip, 646, opm) on bl and interface
with the Google Cloud VM web surfaces. with the Google Cloud VM web surfaces.
Domains: Domains:
super fleet — Node health, CDP status, active tabs, watch, restart super muse-choices — Muse TUI A/B/C auto-answer daemon (on/off/status/logs)
super fleet — Node health, CDP status, active tabs, watch, restart, heal
super watchdog — Watchdog timers: status, trigger runs
super dm — Inter-agent DMs, work orders ([WO]), acks, live log tail super dm — Inter-agent DMs, work orders ([WO]), acks, live log tail
super thread — Inspect agent main chats, sidechats, and scrollbacks super thread — Inspect agent main chats, sidechats, and scrollbacks
super job — Manage scheduled jobs, systemd timers, triggers, logs super job — Manage scheduled jobs, systemd timers, triggers, logs
@@ -16,7 +18,8 @@ Domains:
super vars — Shortcut to inspect & adjust runtime control variables super vars — Shortcut to inspect & adjust runtime control variables
Usage: Usage:
super fleet [status|watch|restart|cdp] super fleet [status|watch|restart|cdp|heal]
super watchdog [status|run <node|relay>]
super dm [send|wo|ack|log|tail] super dm [send|wo|ack|log|tail]
super thread [list|view] super thread [list|view]
super job [list|status|run|log|create|enable|disable|delete] super job [list|status|run|log|create|enable|disable|delete]
@@ -392,7 +395,7 @@ def cmd_fleet_status(args):
print(c_dim(" [*] status via host watchdog evidence (local probes blind in this shell)")) print(c_dim(" [*] status via host watchdog evidence (local probes blind in this shell)"))
if has_any_approval: if has_any_approval:
print("\n" + c_yellow(" ⚠ Agent(s) held up on browser approval. Run 'box approvals' to inspect/allow.")) print("\n" + c_yellow(" ⚠ Agent(s) held up on browser approval. Run 'box approvals' to inspect/allow."))
print("\n" + c_dim(" Commands: super fleet watch | super fleet restart <node> | box approvals [check|allow|auto]") + "\n") print("\n" + c_dim(" Commands: super fleet watch | super fleet heal <node> | box approvals [check|allow|auto]") + "\n")
def cmd_fleet_watch(args): def cmd_fleet_watch(args):
interval = getattr(args, "interval", 2) interval = getattr(args, "interval", 2)
@@ -447,6 +450,204 @@ def cmd_fleet_cdp(args):
print(f" SSH Forward : {c_yellow(f'ssh -L {port}:{peer}:{port} super@100.123.153.75')}") print(f" SSH Forward : {c_yellow(f'ssh -L {port}:{peer}:{port} super@100.123.153.75')}")
print(f" Local Connect : {c_dim(f'http://127.0.0.1:{port}')} (after forwarding)\n") print(f" Local Connect : {c_dim(f'http://127.0.0.1:{port}')} (after forwarding)\n")
# ---------------------------------------------------------------------------
# Domain: FLEET HEAL + WATCHDOG
# ---------------------------------------------------------------------------
SYSTEMD_SYSTEM_DIR = Path("/etc/systemd/system")
WATCHDOG_LOCK_TMPL = "/tmp/chromebox-watchdog-{node}.lock"
WARP_PROBE_URL = "https://1.1.1.1/cdn-cgi/trace"
CHROMEBOX_TIMER_TMPL = """[Unit]
Description=Run chromebox watchdog for {node} every 2 minutes
[Timer]
RandomizedDelaySec=30s
OnBootSec=2min
OnUnitActiveSec=2min
Unit=chromebox-watchdog@{node}.service
[Install]
WantedBy=timers.target
"""
def _sh(cmd, timeout=120, input_text=None):
"""Run cmd, capture output. Returns (returncode, combined_output)."""
try:
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout, input=input_text)
return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip()
except subprocess.TimeoutExpired:
return 124, "timed out after %ds: %s" % (timeout, " ".join(cmd))
except OSError as e:
return 127, str(e)
def _heal_lock_step(node):
"""Clear a stale unwritable watchdog lock (blocks watchdog startup)."""
lock = Path(WATCHDOG_LOCK_TMPL.format(node=node))
if not lock.exists() or os.access(lock, os.W_OK):
return True, "lock writable or absent"
rc, out = _sh(["sudo", "rm", "-f", str(lock)], timeout=30)
if rc == 0:
return True, "removed stale lock %s" % lock
return False, "cannot remove %s: %s" % (lock, out)
def _heal_timer_step(node):
"""Install (if missing) and enable the node's watchdog timer."""
timer = "chromebox-watchdog-%s.timer" % node
path = SYSTEMD_SYSTEM_DIR / timer
if not path.exists():
rc, out = _sh(["sudo", "tee", str(path)], timeout=30,
input_text=CHROMEBOX_TIMER_TMPL.format(node=node))
if rc != 0:
return False, "cannot install %s: %s" % (timer, out)
rc, out = _sh(["sudo", "systemctl", "daemon-reload"], timeout=60)
if rc != 0:
return False, "daemon-reload failed: %s" % out
rc, out = _sh(["sudo", "systemctl", "enable", "--now", timer], timeout=60)
if rc != 0:
return False, "enable --now failed: %s" % out
return True, "timer installed+active"
def _heal_tunnel_step(node):
"""Restart the node's Warp tunnel + CDP relay via netvm-node-up.sh."""
rc, out = _sh(["sudo", str(BIN_DIR / "netvm-node-up.sh"), node], timeout=240)
last = out.strip().splitlines()[-1] if out.strip() else "(no output)"
if rc != 0:
return False, "netvm-node-up.sh rc=%d: %s" % (rc, last)
if "egress=unknown" in out or "no handshake yet" in out:
return False, last
return True, last
def _warp_egress_ok(node):
"""True when the node's netns reaches the warp probe URL."""
rc, out = _sh(["sudo", "ip", "netns", "exec", "warp-%s" % node,
"curl", "-sk", "--max-time", "10", WARP_PROBE_URL],
timeout=30)
if rc != 0:
tail = out.splitlines()[-1] if out else "curl failed"
return False, tail
for line in out.splitlines():
if line.startswith("ip="):
return True, line
return False, "no ip= line in probe response"
def cmd_fleet_heal(args):
node = args.node
if node not in VALID_NODES:
print(f"Error: Unknown node '{node}'. Valid: {', '.join(VALID_NODES)}", file=sys.stderr)
sys.exit(1)
as_json = getattr(args, "json", False)
info = get_node_network_info(node)
steps = []
def record(step, ok, detail):
steps.append({"step": step, "ok": ok, "detail": detail})
if not as_json:
mark = c_green("ok") if ok else c_red("FAIL")
print(f" [{mark}] {step}: {detail}")
if not as_json:
print(f"\n{c_bold('Healing node ' + node)} ({info['netns']}, {info['peer_ip']}:{info['cdp_port']})")
record("lock", *_heal_lock_step(node))
record("timer", *_heal_timer_step(node))
record("tunnel", *_heal_tunnel_step(node))
egress_ok, egress_detail = _warp_egress_ok(node)
record("egress", egress_ok, egress_detail)
relay = probe_cdp_status(info["peer_ip"], info["cdp_port"])
if relay["ok"]:
record("relay", True, "CDP answers in %sms" % relay["latency_ms"])
else:
record("relay", False, relay.get("error", "unreachable"))
verdict = "RECOVERED" if egress_ok else "DOWN"
if as_json:
print(json.dumps({"ok": egress_ok, "node": node,
"verdict": verdict, "steps": steps}, indent=2))
else:
print(f"\n Verdict: {c_green(verdict) if egress_ok else c_red(verdict)}")
if not egress_ok:
print(c_dim(" Tunnel still down after restart — if it persists, the Warp identity "
f"likely needs regenerating (human: netvm-new-identity.sh {node})"))
print()
sys.exit(0 if egress_ok else 1)
def _unit_state(unit):
"""(enabled, active) booleans for a systemd unit."""
rc_e, _ = _sh(["systemctl", "is-enabled", unit], timeout=15)
rc_a, _ = _sh(["systemctl", "is-active", unit], timeout=15)
return rc_e == 0, rc_a == 0
def cmd_watchdog_status(args):
as_json = getattr(args, "json", False)
try:
import host_evidence
ev = host_evidence.collect()
except Exception:
ev = {}
data = []
for node in VALID_NODES:
timer = "chromebox-watchdog-%s.timer" % node
enabled, active = _unit_state(timer)
node_ev = ev.get(node, {})
data.append({
"node": node,
"timer": timer,
"enabled": enabled,
"active": active,
"browser": node_ev.get("browser", "unknown"),
"browser_detail": node_ev.get("browser_detail", ""),
"cdp": node_ev.get("cdp", "unknown"),
"cdp_detail": node_ev.get("cdp_detail", ""),
})
relay_enabled, relay_active = _unit_state("cdp-relay-watchdog.timer")
relay = {"timer": "cdp-relay-watchdog.timer",
"enabled": relay_enabled, "active": relay_active}
if as_json:
print(json.dumps({"ok": True, "nodes": data, "relay": relay}, indent=2))
return
print("\n" + c_bold("=== WATCHDOG STATUS ===") + c_dim(f" ({datetime.now().strftime('%H:%M:%S')} local)\n"))
headers = ["NODE", "TIMER", "BROWSER", "CDP"]
rows = []
for item in data:
timer = badge_ok("active") if item["active"] else (
badge_warn("ENABLED") if item["enabled"] else badge_dim("missing"))
rows.append([c_bold(item["node"]), timer,
item["browser"], item["cdp"]])
print_table(headers, rows)
rmark = badge_ok("active") if relay["active"] else badge_dim("missing")
print(f" relay: {rmark} {c_dim('cdp-relay-watchdog.timer')}\n")
def cmd_watchdog_run(args):
target = args.target
as_json = getattr(args, "json", False)
if target == "relay":
unit = "cdp-relay-watchdog.service"
elif target in VALID_NODES:
unit = "chromebox-watchdog@%s.service" % target
else:
print("Error: Specify node or 'relay' (e.g. box watchdog run dev)", file=sys.stderr)
sys.exit(1)
rc, out = _sh(["sudo", "systemctl", "start", unit], timeout=180)
tail = out[-500:] if out else ""
if as_json:
print(json.dumps({"ok": rc == 0, "unit": unit, "detail": tail}, indent=2))
else:
if rc == 0:
print(c_green(f"Watchdog run triggered: {unit}"))
else:
print(c_red(f"Failed to start {unit}: {tail}"))
sys.exit(0 if rc == 0 else 1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Domain: APPROVALS # Domain: APPROVALS
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -729,6 +930,136 @@ def cmd_approvals(args):
print(f" Reason: {c_cyan(reason)}") print(f" Reason: {c_cyan(reason)}")
print(c_dim(f" Operator can approve with: box approvals allow {node}")) print(c_dim(f" Operator can approve with: box approvals allow {node}"))
# ---------------------------------------------------------------------------
# Domain: MUSE-CHOICES (Muse TUI A/B/C auto-answer daemon)
# ---------------------------------------------------------------------------
def cmd_muse_choices(args):
import muse_choice_watcher as mcw
action = getattr(args, "mc_action", None) or "status"
as_json = getattr(args, "json", False)
caller = os.environ.get("BOX_CALLER") or getattr(args, "from_agent", None) or "super"
if action == "on":
dry_run = getattr(args, "dry_run", False)
state = mcw.set_enabled(True, dry_run=dry_run, by=caller)
res = mcw.reconcile()
if as_json:
print(json.dumps({"ok": True, "desired": state, "reconcile": res}, indent=2))
return
mode = "DRY-RUN (logging only)" if dry_run else c_yellow("LIVE (typing A into Muse panes)")
print(c_green("\n✔ Muse choice auto-answers ON.") + f" [{mode}]\n")
for s in res["started"]:
print(f" {badge_ok('STARTED')} {c_cyan(s)}")
for s in res["already"]:
print(f" {badge_dim('RUNNING')} {s}")
for s in res.get("failed") or []:
print(f" {badge_err('FAILED')} {s}")
if not res["started"] and not res["already"] and not res.get("failed"):
print(c_dim(" No Muse panes found on known tmux sockets."))
print()
elif action == "off":
state = mcw.set_enabled(False, by=caller)
stopped = mcw.stop_all()
if as_json:
print(json.dumps({"ok": True, "desired": state, "stopped": stopped}, indent=2))
return
print(c_green("\n✔ Muse choice auto-answers OFF."))
for r in stopped:
if r.get("status") == "stopped-orphan":
label = "(orphan %s:%s)" % (r.get("socket"), r.get("pane"))
else:
label = r.get("pidfile")
print(f" {badge_dim('STOPPED')} {label} (pid {r.get('pid')})")
if not stopped:
print(c_dim(" No watchers were running."))
print()
elif action == "reconcile":
res = mcw.reconcile()
if as_json:
print(json.dumps({"ok": True, "reconcile": res}, indent=2))
return
print(f"\n Enabled: {c_bold(str(res['enabled']))} Dry-run: {res['dry_run']}")
for s in res["started"]:
print(f" {badge_ok('STARTED')} {c_cyan(s)}")
for s in res["stopped"]:
print(f" {badge_dim('STOPPED')} {s}")
for s in res["pruned"]:
print(f" {badge_dim('PRUNED')} {s}")
for s in res.get("failed") or []:
print(f" {badge_err('FAILED')} {s}")
if not res["started"] and not res["stopped"] and not res["pruned"] and not res.get("failed"):
print(c_dim(" Already in desired state."))
print()
elif action == "logs":
sock = getattr(args, "socket", None)
pane = getattr(args, "pane", None)
n = getattr(args, "n", 20)
path = mcw.logfile_for(sock, pane)
try:
with open(path) as f:
lines = f.readlines()
except OSError:
print(c_red(f"Error: no log at {path}"), file=sys.stderr)
sys.exit(1)
if as_json:
recs = []
for line in lines[-n:]:
try:
recs.append(json.loads(line))
except Exception:
recs.append({"raw": line.rstrip("\n")})
print(json.dumps({"ok": True, "log": path, "records": recs}, indent=2))
return
print(c_bold(f"\n=== {path} (last {n}) ===\n"))
for line in lines[-n:]:
print(line.rstrip("\n"))
print()
else: # status
desired = mcw.get_desired()
watchers = mcw.status_all()
answers = mcw.recent_answers(5)
if as_json:
print(json.dumps({"ok": True, "desired": desired,
"watchers": watchers,
"recent_answers": answers}, indent=2))
return
state_badge = badge_ok("ON") if desired["enabled"] else badge_dim("OFF")
dry = " (dry-run)" if desired.get("dry_run") else ""
print("\n" + c_bold("=== MUSE CHOICE AUTO-ANSWERS ===") + f" {state_badge}{dry}")
upd = desired.get("updated_at") or "-"
by = desired.get("updated_by") or "-"
print(c_dim(f" Desired state: enabled={desired['enabled']} dry_run={desired.get('dry_run')} updated={upd} by={by}\n"))
if watchers:
headers = ["WATCHER", "STATUS", "PID", "LOG BYTES"]
rows = []
for w in watchers:
if w.get("orphan"):
badge = badge_warn("ORPHAN")
name = "(orphan %s:%s)" % (w.get("socket"), w.get("pane"))
else:
badge = badge_ok("ALIVE") if w["alive"] else badge_err("DEAD")
name = w["pidfile"]
rows.append([name, badge, str(w["pid"] or "-"),
str(w["log_bytes"] if w["log_bytes"] is not None else "-")])
print_table(headers, rows)
else:
print(c_dim(" No watcher state files."))
if answers:
print(c_bold("\n Recent answers (box audit feed):"))
for a in answers:
opts = "; ".join(a.get("options") or [])[:80]
kind = a.get("kind") or "-"
key = a.get("key") or "-"
print(f" • {c_dim(a.get('ts', ''))} {c_bold(a.get('name', ''))} {kind}/{key} sig={a.get('sig')} ok={a.get('ok')}")
print(f" {c_dim(opts)}")
else:
print(c_dim("\n No answers recorded in box audit feed."))
print()
def resolve_sender(args) -> str: def resolve_sender(args) -> str:
explicit = getattr(args, "from_agent", None) explicit = getattr(args, "from_agent", None)
if explicit and explicit != DEFAULT_SENDER: if explicit and explicit != DEFAULT_SENDER:
@@ -768,6 +1099,359 @@ def sign_message(sender: str, message: str, key_path: Path = None, msg_id: str =
signed_wire = f"[from:{sender}] [id:{mid}]\n\n{message}\n\n{sigblock}" signed_wire = f"[from:{sender}] [id:{mid}]\n\n{message}\n\n{sigblock}"
return signed_wire, mid return signed_wire, mid
# ---------------------------------------------------------------------------
# Domain: INVITE
# ---------------------------------------------------------------------------
def cmd_invite(args):
import invite
from invite_handler import salvage_blocked_node
action = getattr(args, "invite_action", None) or "status"
node = getattr(args, "node", None)
as_json = getattr(args, "json", False)
if action in ("status", "list"):
nodes = [node] if node else VALID_NODES
res = invite.fleet_invite_status(nodes)
if as_json:
print(json.dumps({"ok": True, "agents": res}, indent=2))
return
print("\n" + c_bold("=== FLEET INVITE STATUS ===") + "\n")
headers = ["NODE", "CODE", "REDEEMED", "USES LEFT", "USED", "REWARD"]
rows = []
for n in nodes:
it = res.get(n, {})
if not it.get("ok"):
rows.append([c_bold(n), c_red("ERROR"), "-", "-", "-",
(it.get("error") or "")[:40]])
continue
rows.append([c_bold(n), c_cyan(it.get("code") or "-"),
"yes" if it.get("has_redeemed") else c_green("no"),
str(it.get("uses_remaining")
if it.get("uses_remaining") is not None else "-"),
str(it.get("use_count")
if it.get("use_count") is not None else "-"),
((it.get("reward") or {}).get("title") or "-")[:40]])
print_table(headers, rows)
print()
elif action in ("code", "find"):
try:
res = invite.get_invite(node)
except invite.InviteError as e:
print(c_red(f" Error: {e}"), file=sys.stderr)
sys.exit(2)
if as_json:
print(json.dumps(res, indent=2))
elif res.get("ok"):
print(f" {c_bold(node)} invite code: {c_cyan(res['code'])}")
if res.get("uses_remaining") is not None:
print(f" Uses remaining: {res['uses_remaining']}")
print(f" Source: {res.get('source', 'api')}")
else:
print(c_red(f" Error: {res.get('error')}"), file=sys.stderr)
sys.exit(1)
elif action == "redeem":
code = getattr(args, "code", None)
try:
res = invite.redeem_invite(node, code)
except invite.InviteError as e:
print(c_red(f" Error: {e}"), file=sys.stderr)
sys.exit(2)
if as_json:
print(json.dumps(res, indent=2))
elif res.get("ok"):
msg = f" ✔ Redeemed {res['code']} on {node}."
if res.get("detail"):
msg += f" {res['detail']}"
print(c_green(msg))
else:
print(c_red(f" ✘ Redeem failed on {node}: "
f"{res.get('reason') or res.get('error')}"),
file=sys.stderr)
if res.get("detail"):
print(c_dim(f" {res['detail']}"), file=sys.stderr)
sys.exit(1)
elif action == "salvage":
target = getattr(args, "node", "646") or "646"
res = salvage_blocked_node(target, helper_node=getattr(args, "helper", None))
if as_json:
print(json.dumps(res, indent=2))
return
print(f"\n{c_bold('=== SALVAGE REPORT FOR ' + target.upper() + ' ===')}")
code = res.get("invite_code") or res.get("code_redeemed")
print(f" Invite Code to Credit: {c_cyan(str(code))}")
if res.get("success"):
print(c_green(f" Status: SUCCESS! Redeemed on {res.get('helper_node')}. 1 billion tokens credited to {target}!"))
else:
diag = res.get("error") or (res.get("redemption_result") or {}).get("detail") or "Redemption failed on helper peer"
print(c_yellow(f" Diagnosis: {diag}"))
action_hint = res.get("share_instruction") or f"Redeem code '{code}' via onboarding pipeline to grant 1B tokens to {target}."
print(f" Action: {action_hint}")
print(f" Command: {c_cyan('box onboard start <new_node> --email <client_email> --for ' + target)}")
print()
# ---------------------------------------------------------------------------
# Domain: USAGE
# ---------------------------------------------------------------------------
def cmd_usage(args):
import invite
node = getattr(args, "node", None) or getattr(args, "node_pos", None)
as_json = getattr(args, "json", False)
nodes = [node] if node else VALID_NODES
res = invite.fleet_usage(nodes)
if as_json:
print(json.dumps({"ok": True, "agents": res}, indent=2))
return
print("\n" + c_bold("=== FLEET USAGE LIMITS ===") + "\n")
headers = ["NODE", "WEEKLY RESET", "WEEKLY USED", "ADDITIONAL",
"ADD LEFT", "REDEEMED"]
rows = []
has_blocked = False
for n in nodes:
it = res.get(n, {})
if not it.get("ok"):
rows.append([c_bold(n), c_red("ERROR"), "-", "-",
(it.get("error") or "")[:30], "-"])
continue
wu_val = it.get("weekly_used_pct")
wu = ("%d%%" % wu_val) if wu_val is not None else "-"
au_val = it.get("additional_used_pct")
au = ("%d%% used" % au_val) if au_val is not None else "-"
add_left = it.get("additional_left") or "-"
redeemed_val = it.get("has_redeemed")
redeemed_str = "yes" if redeemed_val else (c_green("no") if redeemed_val is False else "-")
if (wu_val is not None and wu_val >= 100 and au_val is not None and au_val >= 100) or ("0 tokens left" in add_left):
has_blocked = True
n_disp = c_red(f"● {n}")
else:
n_disp = c_bold(n)
rows.append([n_disp, it.get("weekly_reset") or "-",
wu, "%s / %s" % (it.get("additional_expires") or "-",
au),
add_left, redeemed_str])
print_table(headers, rows)
if has_blocked:
print("\n" + c_yellow(" ⚠ One or more agents have reached usage limits. Run 'box invite salvage <node>' to resolve.") + "\n")
else:
print()
# ---------------------------------------------------------------------------
# Domain: SETTINGS
# ---------------------------------------------------------------------------
def cmd_settings(args):
from settings_rpa import SettingsRPA
action = getattr(args, "settings_action", "usage") or "usage"
node = getattr(args, "node", "646") or "646"
as_json = getattr(args, "json", False)
with SettingsRPA(node) as rpa:
if action == "usage":
usage = rpa.read_usage()
if as_json:
print(json.dumps(usage.to_dict(), indent=2))
return
status_badge = badge_err("BLOCKED") if usage.is_blocked else badge_ok("ACTIVE")
redeemed_badge = c_green("No (Eligible to redeem)") if not usage.has_redeemed else "Yes (Already redeemed)"
print(f"\n{c_bold('=== Settings RPA Usage: ' + node + ' ===')}")
print(f" Plan: {usage.plan}")
print(f" Weekly Limit: {usage.weekly_reset_text} ({c_bold(str(usage.weekly_percent_used) + '%')} used)")
print(f" Extra Tokens: {usage.extra_tokens_remaining} ({c_bold(str(usage.extra_percent_used) + '%')} used)")
print(f" Has Redeemed: {redeemed_badge}")
print(f" Status: {status_badge}\n")
elif action == "check-redeem":
info = rpa.check_redeem_entrypoint()
print(json.dumps(info, indent=2))
# ---------------------------------------------------------------------------
# Domain: ONBOARD (Agent-driven Onboarding & Invite Salvage Pipeline)
# ---------------------------------------------------------------------------
def cmd_onboard(args):
import onboard_pipeline
action = getattr(args, "onboard_action", "start") or "start"
as_json = getattr(args, "json", False)
if action == "start":
node = args.node
email = args.email
for_agent = getattr(args, "for_agent", None)
code = getattr(args, "code", None)
acct_name = getattr(args, "account_name", None)
res = onboard_pipeline.start_onboarding(node, email, beneficiary_node=for_agent, invite_code=code, account_name=acct_name)
if as_json:
print(json.dumps(res, indent=2))
return
if res.get("ok"):
print(f"\n{c_bold('=== ONBOARDING PIPELINE STARTED: ' + node.upper() + ' ===')}")
print(f" Target Node: {c_bold(node)}")
print(f" Client Email: {email}")
print(f" Beneficiary: @{res.get('beneficiary_node')} ({c_cyan(str(res.get('invite_code_queued')))})")
print(f" Status: {c_yellow(res.get('status', 'in_progress').upper())}")
print(f" Next Step: {res.get('message')}\n")
else:
print(c_red(f"\n✘ Onboarding failed: {res.get('error')}\n"), file=sys.stderr)
sys.exit(1)
elif action == "submit-otp":
node = args.node
otp = args.otp
email = getattr(args, "email", None)
res = onboard_pipeline.submit_onboarding_otp(node, otp, email=email)
if as_json:
print(json.dumps(res, indent=2))
return
if res.get("ok"):
print(f"\n{c_green('=== ONBOARDING & REDEMPTION COMPLETE ===')}")
print(f" Node: {c_bold(node)}")
print(f" Beneficiary: @{res.get('beneficiary_node')}")
print(f" Token Grant: +1,000,000,000 Muse tokens each")
print(f" Message: {c_green(res.get('message'))}\n")
else:
print(c_red(f"\n✘ OTP submission failed: {res.get('error')}\n"), file=sys.stderr)
sys.exit(1)
elif action == "status":
node = args.node
state = onboard_pipeline.OnboardState.load(node)
if not state:
print(c_red(f"No onboarding record found for node '{node}'"), file=sys.stderr)
sys.exit(1)
if as_json:
print(json.dumps(onboard_pipeline.asdict(state), indent=2))
return
print(f"\n{c_bold('=== ONBOARDING STATUS: @' + state.node.upper() + ' ===')}")
print(f" Email: {state.email}")
print(f" Current Stage: {c_cyan(state.stage)}")
print(f" Beneficiary Agent:@{state.beneficiary_node or '-'} (Code: {c_cyan(str(state.invite_code or '-'))})")
if state.detail:
print(f" Detail: {state.detail}")
if state.redemption_result:
rr = state.redemption_result
r_st = c_green("SUCCESS") if rr.get("ok") else c_red("FAILED")
print(f" Redemption: {r_st}")
print()
elif action == "salvage-wo":
node = getattr(args, "node", "646") or "646"
target_chat = getattr(args, "target", "646 tasks") or "646 tasks"
res = onboard_pipeline.issue_salvage_work_order(node, to_sidechat=target_chat)
if as_json:
print(json.dumps(res, indent=2))
return
if res.get("ok"):
print(c_green(f"\n✔ Cryptographically signed salvage work order dispatched for @{node} -> {target_chat}\n"))
else:
print(c_red(f"\n✘ Failed to dispatch work order: {res}\n"), file=sys.stderr)
sys.exit(1)
elif action == "feed-matrix":
matrix = onboard_pipeline.calculate_feeding_weights()
if as_json:
print(json.dumps({"ok": True, "feed_matrix": matrix}, indent=2))
return
print("\n" + c_bold("=== FLEET FEEDING & TOKEN ALLOCATION MATRIX ==="))
print(c_dim(" (Ranked by: Urgency + Work Done Over Time + Job Amount + Role Criticality)\n"))
headers = ["RANK", "AGENT", "ROLE", "FEED SCORE", "STATUS", "JOBS", "WORK DONE", "CODE"]
rows = []
for idx, r in enumerate(matrix, 1):
st = c_red("BLOCKED") if r["is_blocked"] else (c_yellow("LIMIT") if r["status"] == "LIMIT_REACHED" else c_green("ACTIVE"))
rows.append([
f"#{idx}",
c_bold(r["node"]),
r["role"],
c_cyan(str(r["feeding_weight"]) + " pts"),
st,
str(r["jobs_count"]),
f"{r['work_done_msgs']} msgs",
c_bold(r["code"]),
])
print_table(headers, rows)
print()
# ---------------------------------------------------------------------------
# Domain: CHROMEBOX (agent browser settings-menu toggles)
# ---------------------------------------------------------------------------
def cmd_chromebox(args):
from hatch_menu import toggles as menu_toggles
action = getattr(args, "chrome_action", None)
if action != "permissions":
print(c_red(" Error: expected 'permissions' (box chromebox "
"permissions <node> list|get|set|describe)"),
file=sys.stderr)
sys.exit(2)
node = getattr(args, "node", None)
as_json = getattr(args, "json", False)
perm_action = getattr(args, "perm_action", None) or "list"
if perm_action == "list":
tab = getattr(args, "tab", None)
try:
res = menu_toggles.list_toggles(node, tab=tab)
except menu_toggles.MenuError as e:
print(c_red(f" Error: {e}"), file=sys.stderr)
sys.exit(2)
if as_json:
print(json.dumps(res, indent=2))
return
if not res.get("ok"):
print(c_red(f" Error: {res.get('error')}"), file=sys.stderr)
sys.exit(1)
print("\n" + c_bold(f"=== CHROMEBOX TOGGLES: {node} ===") + "\n")
rows = []
for name in sorted(res.get("toggles", {})):
val = res["toggles"][name]
rows.append([name, c_red("-") if val is None else str(val)])
print_table(["TOGGLE", "VALUE"], rows)
print()
elif perm_action == "get":
name = getattr(args, "toggle", None)
try:
res = menu_toggles.get_toggle(node, name)
except menu_toggles.MenuError as e:
print(c_red(f" Error: {e}"), file=sys.stderr)
sys.exit(2)
if as_json:
print(json.dumps(res, indent=2))
elif res.get("ok"):
print(f" {c_bold(name)} = {c_cyan(res['value'])}")
else:
print(c_red(f" Error: {res.get('error')}"), file=sys.stderr)
sys.exit(1)
elif perm_action == "set":
name = getattr(args, "toggle", None)
value = getattr(args, "value", None)
try:
res = menu_toggles.set_toggle(node, name, value)
except menu_toggles.MenuError as e:
print(c_red(f" Error: {e}"), file=sys.stderr)
sys.exit(2)
if as_json:
print(json.dumps(res, indent=2))
elif res.get("ok"):
print(c_green(f" ✔ {name} = {res['value']} on {node} "
f"(readback verified)."))
else:
print(c_red(f" ✘ Set failed on {node}: {res.get('error')}"),
file=sys.stderr)
sys.exit(1)
elif perm_action == "describe":
tab = getattr(args, "tab", None)
try:
res = menu_toggles.describe_tab(node, tab)
except menu_toggles.MenuError as e:
print(c_red(f" Error: {e}"), file=sys.stderr)
sys.exit(2)
print(json.dumps(res, indent=2))
else:
print(c_red(f" Error: unknown action {perm_action!r}"),
file=sys.stderr)
sys.exit(2)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Domain: DM (Direct Messaging & Work Orders) # Domain: DM (Direct Messaging & Work Orders)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -4275,11 +4959,16 @@ def build_parser():
subparsers = parser.add_subparsers(dest="domain", help="Orchestration Domain") subparsers = parser.add_subparsers(dest="domain", help="Orchestration Domain")
# Domain: FLEET # Domain: FLEET
p_fleet = subparsers.add_parser("fleet", parents=[common], help="Node health, CDP status, active tabs, watch, restart") p_fleet = subparsers.add_parser("fleet", parents=[common], help="Node health, CDP status, active tabs, watch, restart, heal")
p_fleet.add_argument("action", nargs="?", default="status", choices=["status", "watch", "restart", "cdp"]) p_fleet.add_argument("action", nargs="?", default="status", choices=["status", "watch", "restart", "cdp", "heal"])
p_fleet.add_argument("node", nargs="?", default=None, help="Target node (for restart / cdp)") p_fleet.add_argument("node", nargs="?", default=None, help="Target node (for restart / cdp / heal)")
p_fleet.add_argument("--interval", type=int, default=2, help="Watch refresh interval in seconds") p_fleet.add_argument("--interval", type=int, default=2, help="Watch refresh interval in seconds")
# Domain: WATCHDOG
p_watchdog = subparsers.add_parser("watchdog", parents=[common], help="Watchdog timers: status, trigger runs")
p_watchdog.add_argument("action", nargs="?", default="status", choices=["status", "run"])
p_watchdog.add_argument("target", nargs="?", default=None, help="Target node (or 'relay') for run")
# Domain: APPROVALS # Domain: APPROVALS
p_approvals = subparsers.add_parser("approvals", parents=[common], help="Inspect and handle agent browser & gateway approvals") p_approvals = subparsers.add_parser("approvals", parents=[common], help="Inspect and handle agent browser & gateway approvals")
p_approval = subparsers.add_parser("approval", parents=[common], help="Alias for 'approvals'") p_approval = subparsers.add_parser("approval", parents=[common], help="Alias for 'approvals'")
@@ -4343,6 +5032,107 @@ def build_parser():
p_app_req_key.add_argument("node", choices=VALID_NODES, help="Target node requesting key") p_app_req_key.add_argument("node", choices=VALID_NODES, help="Target node requesting key")
p_app_req_key.add_argument("--reason", default="Passkey authentication required", help="Reason for key request") p_app_req_key.add_argument("--reason", default="Passkey authentication required", help="Reason for key request")
# Domain: MUSE-CHOICES
p_mc = subparsers.add_parser("muse-choices", parents=[common], help="Muse TUI A/B/C choice auto-answer daemon (on/off/status/logs)")
mc_sub = p_mc.add_subparsers(dest="mc_action")
p_mc_on = mc_sub.add_parser("on", parents=[common], help="Enable auto-answers on all Muse panes")
p_mc_on.add_argument("--dry-run", action="store_true", help="Log answers instead of sending keys")
p_mc_off = mc_sub.add_parser("off", parents=[common], help="Disable auto-answers and stop all watchers")
p_mc_status = mc_sub.add_parser("status", parents=[common], help="Show desired state, watchers, recent answers")
p_mc_logs = mc_sub.add_parser("logs", parents=[common], help="Tail one pane watcher log")
p_mc_logs.add_argument("--socket", required=True, help="Tmux socket path (e.g. /tmp/tmux-1000/default)")
p_mc_logs.add_argument("--pane", required=True, help="Pane id (e.g. %%37)")
p_mc_logs.add_argument("-n", type=int, default=20, help="Lines to show (default: 20)")
p_mc_rec = mc_sub.add_parser("reconcile", parents=[common], help="Enforce desired state now (start missing / stop excess)")
# Domain: INVITE
p_invite = subparsers.add_parser("invite", parents=[common], help="Muse.ai invite codes: find per-agent codes and redeem")
p_invite.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node (status)")
inv_sub = p_invite.add_subparsers(dest="invite_action")
p_inv_status = inv_sub.add_parser("status", parents=[common], help="Invite code matrix across fleet (default)")
p_inv_status.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node")
p_inv_list = inv_sub.add_parser("list", parents=[common], help="Alias for 'status'")
p_inv_list.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node")
p_inv_code = inv_sub.add_parser("code", parents=[common], help="Show one agent's invite code")
p_inv_code.add_argument("node", choices=VALID_NODES, help="Target node")
p_inv_find = inv_sub.add_parser("find", parents=[common], help="Alias for 'code'")
p_inv_find.add_argument("node", choices=VALID_NODES, help="Target node")
p_inv_redeem = inv_sub.add_parser("redeem", parents=[common], help="Redeem an invite code on a node")
p_inv_redeem.add_argument("node", choices=VALID_NODES, help="Target node")
p_inv_redeem.add_argument("code", help="6-char invite code")
p_inv_salvage = inv_sub.add_parser("salvage", parents=[common], help="Salvage an out-of-tokens agent (defaults to 646)")
p_inv_salvage.add_argument("node", nargs="?", default="646", choices=VALID_NODES, help="Blocked agent node (default: 646)")
p_inv_salvage.add_argument("--helper", choices=VALID_NODES, help="Specific helper agent to redeem code")
# Domain: USAGE
p_usage = subparsers.add_parser("usage", parents=[common], help="Muse.ai usage limits per agent")
p_usage.add_argument("--node", choices=VALID_NODES, default=None, help="Filter by node")
p_usage.add_argument("node_pos", nargs="?", default=None, choices=VALID_NODES, help="Optional node positional argument")
# Domain: SETTINGS
p_settings = subparsers.add_parser("settings", parents=[common], help="Settings menu RPA automation")
settings_sub = p_settings.add_subparsers(dest="settings_action")
p_set_usage = settings_sub.add_parser("usage", parents=[common], help="Extract usage via Settings RPA")
p_set_usage.add_argument("node", choices=VALID_NODES, default="646", nargs="?", help="Target node (default: 646)")
p_set_check = settings_sub.add_parser("check-redeem", parents=[common], help="Check if Redeem Invite Code is in General Settings")
p_set_check.add_argument("node", choices=VALID_NODES, default="646", nargs="?", help="Target node (default: 646)")
# Domain: ONBOARD
p_onboard = subparsers.add_parser("onboard", parents=[common], help="Agent onboarding & automated invite code salvage pipeline")
onboard_sub = p_onboard.add_subparsers(dest="onboard_action")
p_onb_start = onboard_sub.add_parser("start", parents=[common], help="Start onboarding pipeline (provisions infra + initiates auth)")
p_onb_start.add_argument("node", help="Target new node label (e.g. dev2, client1)")
p_onb_start.add_argument("--email", required=True, help="Client login email")
p_onb_start.add_argument("--for", dest="for_agent", choices=VALID_NODES, help="Beneficiary agent to unblock (defaults to most urgent)")
p_onb_start.add_argument("--code", help="Explicit 6-character invite code to redeem")
p_onb_start.add_argument("--account-name", help="Display name hint for multi-account selection")
p_onb_otp = onboard_sub.add_parser("submit-otp", parents=[common], help="Submit transient OTP and complete auto-redemption")
p_onb_otp.add_argument("node", help="Target node label")
p_onb_otp.add_argument("otp", help="6-digit verification code")
p_onb_otp.add_argument("--email", help="Client email (optional)")
p_onb_stat = onboard_sub.add_parser("status", parents=[common], help="Query onboarding pipeline state for a node")
p_onb_stat.add_argument("node", help="Target node label")
p_onb_wo = onboard_sub.add_parser("salvage-wo", parents=[common], help="Dispatch cryptographically signed salvage work order to sidechat")
p_onb_wo.add_argument("node", nargs="?", default="646", choices=VALID_NODES, help="Blocked agent node (default: 646)")
p_onb_wo.add_argument("--target", default="646 tasks", help="Target sidechat (default: 646 tasks)")
onboard_sub.add_parser("feed-matrix", parents=[common], help="Display all agents ranked by feeding weight, job volume, role, and work done over time")
# Domain: CHROMEBOX
p_chromebox = subparsers.add_parser("chromebox", parents=[common], help="Agent browser settings-menu toggles (chromebox RPA)")
chrome_sub = p_chromebox.add_subparsers(dest="chrome_action")
p_chrome_perm = chrome_sub.add_parser("permissions", parents=[common], help="Settings menu toggles (permissions + related tabs)")
p_chrome_perm.add_argument("node", choices=VALID_NODES, help="Target node")
perm_sub = p_chrome_perm.add_subparsers(dest="perm_action")
p_perm_list = perm_sub.add_parser("list", parents=[common], help="List toggle states (default)")
p_perm_list.add_argument("--tab", default=None, help="Only this settings tab")
p_perm_get = perm_sub.add_parser("get", parents=[common], help="Read one toggle")
p_perm_get.add_argument("toggle", help="Toggle address (e.g. permissions.web_access, permissions.websites:example.com)")
p_perm_set = perm_sub.add_parser("set", parents=[common], help="Set one toggle (verified with readback)")
p_perm_set.add_argument("toggle", help="Toggle address")
p_perm_set.add_argument("value", help="Value (on/off, Allow/Ask/Deny, auto_allow/always_ask, theme name)")
p_perm_desc = perm_sub.add_parser("describe", parents=[common], help="Full inventory of one settings tab (JSON)")
p_perm_desc.add_argument("tab", help="Settings tab name (e.g. Permissions)")
# Domain: DM # Domain: DM
p_dm = subparsers.add_parser("dm", parents=[common], help="Inter-agent DMs, work orders ([WO]), acks, live log tail") p_dm = subparsers.add_parser("dm", parents=[common], help="Inter-agent DMs, work orders ([WO]), acks, live log tail")
dm_sub = p_dm.add_subparsers(dest="action") dm_sub = p_dm.add_subparsers(dest="action")
@@ -4817,11 +5607,20 @@ def build_parser():
p_docs = subparsers.add_parser("docs", aliases=["doc"], parents=[common], help="Agent lookups, surfaces for box.muse-dev.online, sentence grammar & regex") p_docs = subparsers.add_parser("docs", aliases=["doc"], parents=[common], help="Agent lookups, surfaces for box.muse-dev.online, sentence grammar & regex")
p_docs.add_argument("docs_args", nargs=argparse.REMAINDER, help="Arguments passed directly to docs-lookup.py") p_docs.add_argument("docs_args", nargs=argparse.REMAINDER, help="Arguments passed directly to docs-lookup.py")
# Domain: TUI (Interactive full-screen Muse TUI & Box fleet console)
p_tui = subparsers.add_parser("tui", parents=[common], help="Interactive full-screen Muse TUI & Box fleet console")
p_tui.add_argument("tui_args", nargs=argparse.REMAINDER, help="Arguments passed directly to muse-tui.py")
return parser return parser
def main(): def main():
if len(sys.argv) > 1: if len(sys.argv) > 1:
if sys.argv[1] == "tmux": if sys.argv[1] == "tui":
tui_args = sys.argv[2:]
cmd = [sys.executable, str(BIN_DIR / "muse-tui.py"), "--mode", "box"] + tui_args
res = subprocess.run(cmd)
sys.exit(res.returncode)
elif sys.argv[1] == "tmux":
cmd = [sys.executable, str(BIN_DIR / "muse-tmux.py")] + (sys.argv[2:] or ["list"]) cmd = [sys.executable, str(BIN_DIR / "muse-tmux.py")] + (sys.argv[2:] or ["list"])
res = subprocess.run(cmd) res = subprocess.run(cmd)
sys.exit(res.returncode) sys.exit(res.returncode)
@@ -4891,6 +5690,20 @@ def main():
print("Error: Specify node (e.g. super fleet cdp muse)", file=sys.stderr) print("Error: Specify node (e.g. super fleet cdp muse)", file=sys.stderr)
sys.exit(1) sys.exit(1)
cmd_fleet_cdp(args) cmd_fleet_cdp(args)
elif act == "heal":
if not getattr(args, "node", None):
print("Error: Specify node to heal (e.g. super fleet heal dev)", file=sys.stderr)
sys.exit(1)
cmd_fleet_heal(args)
elif args.domain == "watchdog":
act = getattr(args, "action", "status") or "status"
if act == "status":
cmd_watchdog_status(args)
elif act == "run":
if not getattr(args, "target", None):
print("Error: Specify node or 'relay' (e.g. box watchdog run dev)", file=sys.stderr)
sys.exit(1)
cmd_watchdog_run(args)
elif args.domain == "dm": elif args.domain == "dm":
act = getattr(args, "action", None) act = getattr(args, "action", None)
if not act or act == "log": if not act or act == "log":
@@ -5083,8 +5896,20 @@ def main():
cmd_swarm_prune(args) cmd_swarm_prune(args)
else: else:
p_swarm.print_help() p_swarm.print_help()
elif args.domain in ("approvals", "approval"): elif args.domain in ("approvals", "approval", "blocked"):
cmd_approvals(args) cmd_approvals(args)
elif args.domain == "muse-choices":
cmd_muse_choices(args)
elif args.domain == "invite":
cmd_invite(args)
elif args.domain == "usage":
cmd_usage(args)
elif args.domain == "settings":
cmd_settings(args)
elif args.domain == "onboard":
cmd_onboard(args)
elif args.domain == "chromebox":
cmd_chromebox(args)
elif args.domain in ("deploy", "subagent"): elif args.domain in ("deploy", "subagent"):
if args.domain == "subagent": if args.domain == "subagent":
args.action = "subagent" args.action = "subagent"
+145 -5
View File
@@ -170,15 +170,155 @@ class TestContextMenus(unittest.TestCase):
self.assertEqual(self.tui.modal, "chat_context") self.assertEqual(self.tui.modal, "chat_context")
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678") self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
def test_slash_command_context(self): def test_mouse_motion_does_not_dismiss_context_menu(self):
"""Slash command /context opens chat_context or agent_context.""" """Moving the mouse (is_drag=True) over screen after context popup opens does NOT close it."""
self.tui._execute_input_line("/context chat") # Simulate right click to open message context
# Transcript area starts around y=4
handled = self.tui._handle_mouse(50, 6, curses.BUTTON3_CLICKED)
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "message_context")
# Now simulate mouse motion event (REPORT_MOUSE_POSITION) anywhere
handled_motion = self.tui._handle_mouse(10, 10, curses.REPORT_MOUSE_POSITION)
self.assertTrue(handled_motion)
# Modal must remain open!
self.assertEqual(self.tui.modal, "message_context")
def test_middle_click_opens_context_menu(self):
"""Middle click (BUTTON2_CLICKED) also opens context menu for terminals intercepting button 3."""
self.tui.modal = None
handled = self.tui._handle_mouse(50, 6, curses.BUTTON2_CLICKED)
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "message_context")
def test_header_menu_button_opens_chat_context(self):
"""Left-clicking the [⚡ Menu] action button in the thread header opens chat_context."""
self.tui.modal = None
# Thread header button bounds: (btn_y, btn_x_start, btn_x_end)
self.tui._btn_menu_bounds = (1, 70, 78)
handled = self.tui._handle_mouse(72, 1, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "chat_context") self.assertEqual(self.tui.modal, "chat_context")
def test_message_menu_button_opens_message_context(self):
"""Left-clicking the [⚡ Menu] button on a message row opens message_context."""
self.tui.modal = None self.tui.modal = None
self.tui._execute_input_line("/context agent") # Tuple: (msg_y, btn_x_start, btn_x_end, msg_obj)
self.assertEqual(self.tui.modal, "agent_context") self.tui._msg_menu_buttons = [
(5, 75, 83, {"role": "user", "text": "Hello muse", "seq": 1})
]
handled = self.tui._handle_mouse(78, 5, curses.BUTTON1_CLICKED)
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "message_context")
def test_fleet_and_sidechats_separate_focus_partitions(self):
"""Fleet and Sidechats operate as separate focus partitions with independent scroll & nav."""
self.tui.focus_pane = "fleet"
self.tui.editor_mode = "NORMAL"
self.tui.fleet_focus_idx = 0
# Navigating down with 'j' in fleet partition advances fleet_focus_idx
self.tui._handle_key(ord('j'))
self.assertEqual(self.tui.fleet_focus_idx, 1)
# 'l' switches from fleet to sidechats partition
self.tui._handle_key(ord('l'))
self.assertEqual(self.tui.focus_pane, "sidechats")
# 'j' in sidechats advances thread_sel_idx
self.tui.thread_sel_idx = 0
self.tui._handle_key(ord('j'))
self.assertEqual(self.tui.thread_sel_idx, 1)
# 'h' switches back from sidechats to fleet
self.tui._handle_key(ord('h'))
self.assertEqual(self.tui.focus_pane, "fleet")
def test_mouse_wheel_in_fleet_partition_scrolls_fleet_reliably(self):
"""Mouse wheel scrolling over Fleet Agents partition adjusts fleet scrolling reliably."""
self.tui.focus_pane = "fleet"
self.tui.fleet_focus_idx = 0
# Scroll down in fleet partition (bstate = BUTTON5_PRESSED)
bstate_down = getattr(curses, "BUTTON5_PRESSED", 0x200000)
self.tui._handle_mouse(mx=10, my=2, bstate=bstate_down)
self.assertGreaterEqual(self.tui.fleet_focus_idx, 1)
# Scroll up in fleet partition (bstate = BUTTON4_PRESSED)
bstate_up = getattr(curses, "BUTTON4_PRESSED", 0x10000)
self.tui._handle_mouse(mx=10, my=2, bstate=bstate_up)
self.assertEqual(self.tui.fleet_focus_idx, 0)
def test_sidebar_thread_menu_button_click(self):
"""Left-clicking the [⚡] button on a sidebar thread row opens chat_context."""
self.tui.modal = None
# Sidebar width is 26 for w=100.
# Button [⚡] is rendered around sidebar_w - 9 (col 17)
# Row 11 is thread 1
handled = self.tui._handle_mouse(mx=17, my=11, bstate=curses.BUTTON1_CLICKED)
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "chat_context")
def test_sgr_escape_sequence_right_click_opens_chat_context(self):
"""SGR 1006 escape sequence fallback parses right-click and opens chat_context."""
self.tui.modal = None
# Simulate ESC [ < 2 ; 11 ; 12 M (Right click at mx=10, my=11 on thread 1)
raw_sgr = [ord('['), ord('<'), ord('2'), ord(';'), ord('1'), ord('1'), ord(';'), ord('1'), ord('2'), ord('M')]
with patch.object(self.tui, "_read_escape_sequence", return_value=raw_sgr):
handled = self.tui._handle_escape_sequence()
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "chat_context")
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
def test_double_click_on_thread_opens_chat_context(self):
"""Double-clicking a sidebar thread row opens chat_context."""
self.tui.modal = None
bstate = getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0x8)
handled = self.tui._handle_mouse(mx=10, my=11, bstate=bstate)
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "chat_context")
def test_click_on_already_active_thread_opens_chat_context(self):
"""Left-clicking on the currently active thread in sidechats opens its context menu."""
self.tui.modal = None
self.tui.focus_pane = "sidechats"
# Thread 0 (Main Chat, sess-main-1234) is currently active, located at my = 10
handled = self.tui._handle_mouse(mx=5, my=10, bstate=curses.BUTTON1_CLICKED)
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "chat_context")
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-main-1234")
def test_click_right_side_of_thread_opens_chat_context(self):
"""Left-clicking on the right side of a thread row ([⚡] or [sid]) opens chat_context."""
self.tui.modal = None
sidebar_w = 26
# Click at mx = 20 (right side of thread 1 at my = 11)
handled = self.tui._handle_mouse(mx=20, my=11, bstate=curses.BUTTON1_CLICKED)
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "chat_context")
def test_space_key_in_normal_mode_opens_context(self):
"""Pressing Space in NORMAL mode opens context menu for focused partition."""
self.tui.modal = None
self.tui.editor_mode = "NORMAL"
self.tui.focus_pane = "sidechats"
self.tui.thread_sel_idx = 1
handled = self.tui._handle_key(ord(' '))
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "chat_context")
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
def test_shift_and_alt_left_click_opens_chat_context(self):
"""Shift+Left Click or Alt+Left Click operates as right-click modifier."""
self.tui.modal = None
shift_mask = getattr(curses, "BUTTON_SHIFT", 0x4000000)
handled = self.tui._handle_mouse(mx=10, my=11, bstate=curses.BUTTON1_CLICKED | shift_mask)
self.assertTrue(handled)
self.assertEqual(self.tui.modal, "chat_context")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
test_focus_highlight.py — Comprehensive tests verifying active portion highlighting in MuseTUI:
1. Sidebar focused (focus_pane="sidebar", editor_mode="NORMAL") highlights sidebar header,
vertical pane divider, selected thread cursor, and bottom status bar hints.
2. Transcript focused (focus_pane="transcript", editor_mode="NORMAL") highlights transcript header,
header underline rule, vertical divider, and bottom status bar hints.
3. Composer active (editor_mode="INSERT") prominently highlights bottom composer divider
and status hints without distracting pane navigation highlights.
"""
import unittest
from unittest.mock import MagicMock
import importlib.util
from pathlib import Path
REPO_ROOT = Path("/home/super/Projects/NetVM")
TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
spec = importlib.util.spec_from_file_location("muse_tui", str(TUI_PATH))
muse_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(muse_mod)
class TestActivePortionHighlighting(unittest.TestCase):
def setUp(self):
self.mock_stdscr = MagicMock()
self.mock_stdscr.getmaxyx.return_value = (30, 100)
self.tui = muse_mod.MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
self.calls = []
def record_addstr(win, y, x, text, attr=0):
self.calls.append((y, x, text, attr))
self.tui.safe_addstr = record_addstr
self.tui.toast_time = 0
self.tui.data.nodes = ["muse", "646", "pip"]
self.tui.data.active_node = "muse"
self.tui.data.active_thread_id = "test_thread"
self.tui.data.active_thread_title = "Work Thread"
self.tui.data.threads_cache["muse"] = [
{"session_id": "test_thread", "title": "Work Thread", "is_main": False},
{"session_id": "main_id", "title": "Main Chat", "is_main": True},
]
self.tui.data.history_cache[("muse", "test_thread")] = [
{"role": "user", "text": "Hello agent", "seq": 1, "message_id": "m1"},
{"role": "assistant", "text": "Hello user", "seq": 2, "message_id": "m2"},
]
def test_sidebar_focused_highlight(self):
self.tui.focus_pane = "sidebar"
self.tui.editor_mode = "NORMAL"
# 1. Render Muse View
self.calls.clear()
self.tui._render_muse_view(2, 0, 24, 100)
rendered_texts = [c[2] for c in self.calls]
full_text = " ".join(rendered_texts)
# Sidebar header has [● FOCUS] badge
self.assertTrue(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts),
f"Expected [● FOCUS] in sidebar header. Got:\n{full_text}")
# Vertical divider has heavy vertical char ┃
self.assertTrue(any("┃" in t for t in rendered_texts),
"Expected vertical divider '┃' when pane is focused")
# Transcript header does NOT have [● FOCUSED] badge
self.assertFalse(any("[● FOCUSED]" in t for t in rendered_texts),
"Transcript header should not be [● FOCUSED] when sidebar is active")
# 2. Render Bottom Bar
self.calls.clear()
self.tui._render_bottom_bar(27, 0, 3, 100)
bottom_texts = [c[2] for c in self.calls]
# Active pane divider has SIDEBAR highlight
self.assertTrue(any("ACTIVE PANE: SIDEBAR" in t for t in bottom_texts),
f"Expected ACTIVE PANE: SIDEBAR in bottom divider. Got:\n{bottom_texts}")
# Status hints show SIDEBAR FOCUSED
self.assertTrue(any("SIDEBAR FOCUSED" in t for t in bottom_texts),
f"Expected SIDEBAR FOCUSED in hints. Got:\n{bottom_texts}")
def test_transcript_focused_highlight(self):
self.tui.focus_pane = "transcript"
self.tui.editor_mode = "NORMAL"
# 1. Render Muse View
self.calls.clear()
self.tui._render_muse_view(2, 0, 24, 100)
rendered_texts = [c[2] for c in self.calls]
# Transcript header has [● FOCUSED] badge
self.assertTrue(any("[● FOCUSED] Thread:" in t for t in rendered_texts),
f"Expected [● FOCUSED] in transcript header. Got:\n{rendered_texts}")
# Transcript horizontal divider has heavy rule ━
self.assertTrue(any("━" in t for t in rendered_texts),
"Expected transcript underline divider '━' when transcript is focused")
# Sidebar header does NOT have [● FOCUS] badge
self.assertFalse(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts),
"Sidebar should not have [● FOCUS] when transcript is active")
# 2. Render Bottom Bar
self.calls.clear()
self.tui._render_bottom_bar(27, 0, 3, 100)
bottom_texts = [c[2] for c in self.calls]
self.assertTrue(any("ACTIVE PANE: TRANSCRIPT" in t for t in bottom_texts),
f"Expected ACTIVE PANE: TRANSCRIPT in bottom divider. Got:\n{bottom_texts}")
self.assertTrue(any("TRANSCRIPT FOCUSED" in t for t in bottom_texts),
f"Expected TRANSCRIPT FOCUSED in hints. Got:\n{bottom_texts}")
def test_insert_mode_composer_highlight(self):
self.tui.editor_mode = "INSERT"
# 1. Render Bottom Bar
self.calls.clear()
self.tui._render_bottom_bar(27, 0, 3, 100)
bottom_texts = [c[2] for c in self.calls]
self.assertTrue(any("COMPOSER: INSERT MODE ACTIVE" in t for t in bottom_texts),
f"Expected COMPOSER: INSERT MODE ACTIVE in divider. Got:\n{bottom_texts}")
self.assertTrue(any("INSERT ACTIVE" in t for t in bottom_texts),
f"Expected INSERT ACTIVE in hints. Got:\n{bottom_texts}")
# 2. Render Muse View - neither pane should show [● FOCUS] / [● FOCUSED]
self.calls.clear()
self.tui._render_muse_view(2, 0, 24, 100)
rendered_texts = [c[2] for c in self.calls]
self.assertFalse(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts),
"Sidebar should not claim active focus in INSERT mode")
self.assertFalse(any("[● FOCUSED] Thread:" in t for t in rendered_texts),
"Transcript should not claim active focus in INSERT mode")
if __name__ == "__main__":
unittest.main()
+3 -2
View File
@@ -11,6 +11,7 @@ test_main_nav.py — Comprehensive unit and integration test suite for:
import unittest import unittest
import subprocess import subprocess
import json import json
import re
import os import os
import sys import sys
import tempfile import tempfile
@@ -56,8 +57,8 @@ class TestSidechatNavigationLogic(unittest.TestCase):
import dm import dm
# Well-known mappings # Well-known mappings
self.assertEqual(dm.resolve_sidechat_target("main"), "main") self.assertEqual(dm.resolve_sidechat_target("main"), "main")
self.assertEqual(dm.resolve_sidechat_target("646 tasks"), "1dfb3199-2f99-446c-83e3-848ae2da0a12") hb_uuid = dm.resolve_sidechat_target("heartbeat")
self.assertEqual(dm.resolve_sidechat_target("heartbeat"), "757198c3-c1b2-48b8-ba2b-062c84f71b02") self.assertTrue(bool(re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", hb_uuid)))
def test_uuid_regex_detection(self): def test_uuid_regex_detection(self):
import dm, re import dm, re