diff --git a/.agents/skills/box/SKILL.md b/.agents/skills/box/SKILL.md new file mode 100644 index 0000000..d7d6721 --- /dev/null +++ b/.agents/skills/box/SKILL.md @@ -0,0 +1,39 @@ +--- +name: box +description: Use the box CLI to check NetVM fleet health and read the latest from each agent. +--- + +# Box Fleet CLI + +Use `box` (`/usr/local/bin/box`, the NetVM unified orchestrator CLI) for all fleet observation and agent coordination. Prefer read-only lookups first; coordinate via sidechats, never Main Chat dumps. + +Nodes (node == agent == profile): `muse`, `pip`, `646`, `opm`, `def`, `dev`. + +## Latest From Each Agent (Default Workflow) + +1. `box fleet status` — node health, CDP status, active page/thread. +2. `box lookup unread` — unread counts across agents. +3. `box lookup threads` — registered threads and sidechats. +4. Per agent with activity: `box thread list `, then `box thread view --limit 10` (use the thread UUID from the list; `main` only for urgent human-visible items). +5. `box dm log -n 20` (or `--agent `) — recent inter-agent DMs, work orders, and acks. +6. `box approvals check` — agents blocked on browser or key approval. + +Add `--json` to any command for machine-readable output when parsing results in scripts. + +## Common Commands + +- `box fleet status` / `box fleet cdp ` — health table / CDP endpoint plus SSH forward. +- `box thread list []` — threads for one agent, or all fleet sidechats when omitted. +- `box thread view --limit N` — recent messages from one thread. +- `box dm log -n N [--agent X] [--filter TEXT]` — recent DM activity. +- `box dm send --agent --to --target ""` — peer DM. +- `box lookup summary|fleet|threads|unread|approvals` — seamless one-shot lookups. +- `box job list` / `box job log` — scheduled jobs and execution events. +- `box harvest status` / `box followup list` — harvest watermarks / pending nudges. + +## Rules + +- Sidechat-first per `CHAT_POLICY.md`: `646 tasks`, `heartbeat` (opm), `646-pip-coord`, `646-opm-coord`. Never route routine checks or coordination through `main`. +- Never paste multi-KB logs or dumps into chat; write payloads under `logs/` and send a short path pointer instead. +- Avoid blocking commands in automated runs: `box fleet watch`, `box dm tail`, `box approvals watch`, `box dm chat` (interactive REPL). +- `box` probes CDP per node and can take several seconds; use generous timeouts and `--json` for scripted use. diff --git a/bin/box-tui.py b/bin/box-tui.py new file mode 120000 index 0000000..497c022 --- /dev/null +++ b/bin/box-tui.py @@ -0,0 +1 @@ +muse-tui.py \ No newline at end of file diff --git a/bin/muse-tui.py b/bin/muse-tui.py new file mode 100755 index 0000000..fd342a5 --- /dev/null +++ b/bin/muse-tui.py @@ -0,0 +1,6573 @@ +#!/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 +""" + +import sys +import os +import json +import time +import curses +import threading +import subprocess +import hashlib +import select +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 + + +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" + + +# --------------------------------------------------------------------------- +# 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 + + +# --------------------------------------------------------------------------- +# 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 = [] + + # 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() + 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 + + # 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 + + # 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 _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) + err_text = (stdout + " " + stderr).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 + if rc == 0: + self.clear_node_rate_limit(node) + try: + data = json.loads(stdout) + except Exception: + data = None + if isinstance(data, list): + 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() + # Intentionally omit preload_priority_chats here to prevent thundering herd + 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) + err_text = (stdout + " " + stderr).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 + if rc == 0: + self.clear_node_rate_limit(node) + self.history_fetch_times[key] = time.time() + msgs = json.loads(stdout) + if isinstance(msgs, list): + 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() + 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 _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) + err_text = (stdout + " " + stderr).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" + + if rc == 0: + self.clear_node_rate_limit(node) + self.record_interaction(node) + self.record_activity() + return True, "Message sent successfully" + 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): + 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 + 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: "sidebar" (threads list) or "transcript" (chat scroll) + self.focus_pane = "sidebar" + 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.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) + + # 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 = [ + "1: Agent Chat", + "2: Fleet Status", + "3: Approvals", + "4: Jobs Scheduler", + "5: Tmux / Swarms", + "6: DM Logs", + ] + 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 + + # Vertical Divider + for line in range(h): + self.safe_addstr(self.stdscr, y + line, sidebar_w, "│", self._attr("dim")) + + # 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 is scrolled into view + try: + active_idx = nodes.index(active_node) + except ValueError: + active_idx = 0 + + if active_idx < self.agent_scroll_start: + self.agent_scroll_start = active_idx + elif active_idx >= self.agent_scroll_start + visible_agent_count: + self.agent_scroll_start = active_idx - visible_agent_count + 1 + 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 "" + + header_title = f"FLEET AGENTS {count_str}{unread_badge}" + self.safe_addstr(self.stdscr, y, x + 1, header_title, self._attr("bold") if not unread_badge else self._attr("warn_banner")) + search_btn = "[🔍 /]" + btn_x = max(x + len(header_title) + 2, x + w - len(search_btn) - 1) + self.safe_addstr(self.stdscr, y, btn_x, search_btn, self._attr("warn_banner")) + + 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_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_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_active 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 + self.safe_addstr(self.stdscr, divider_y, x, "─" * w, self._attr("dim")) + + # 2. Threads / Sidechats Section + threads_header_y = divider_y + 1 + is_sidebar_focused = (self.focus_pane == "sidebar") + hdr_attr = self._attr("header") if is_sidebar_focused else self._attr("bold") + + 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 r:sync]" + + hdr_left = f"SIDECHATS: {active_node.upper()}{unread_hdr_badge}" + self.safe_addstr(self.stdscr, threads_header_y, x + 1, " " * (w - 2), self._attr("dim")) + self.safe_addstr(self.stdscr, threads_header_y, x + 1, hdr_left, self._attr("warn_banner") if unread_hdr_badge and not is_sidebar_focused else hdr_attr) + btn_x = max(x + len(hdr_left) + 2, x + w - len(hdr_badge) - 1) + self.safe_addstr(self.stdscr, threads_header_y, btn_x, hdr_badge, self._attr("dim") if not is_sidebar_focused else self._attr("bold")) + + 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 sidebar is not focused + cur_idx = None + for idx, t in enumerate(threads): + if t.get("session_id") == cur_thread_id: + cur_idx = idx + break + if cur_idx is not None and not is_sidebar_focused and self.thread_sel_idx != cur_idx: + 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_sidebar_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 + row_attr = self._attr("selected") if is_highlighted else (self._attr("selected") if (is_cur and is_sidebar_focused) else self._attr("normal")) + self.safe_addstr(self.stdscr, item_line_y, x + 1, " " * (w - 2), row_attr) + + # Right elements: sid tag [xxxx] + 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")) + + # Notification Badge: ●N + badge_end_x = sid_x - 1 + if unread_cnt > 0: + badge_str = f"●{unread_cnt}" + badge_x = max(x + 10, badge_end_x - len(badge_str)) + 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), [])) + + # 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") + 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) + + base_prefix = " Thread: ★ Main Chat " if is_main_chat else " 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("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] immediately next to message total at top + if cur_x + len(copy_all_btn) <= x + w: + self.safe_addstr(self.stdscr, y, cur_x, copy_all_btn, self._attr("button")) + self._btn_copy_all_bounds = (y, cur_x, cur_x + len(copy_all_btn)) + else: + self._btn_copy_all_bounds = None + + self.safe_addstr(self.stdscr, y + 1, x, "─" * w, self._attr("dim")) + + 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]" + btns_str = f"{reply_btn} {copy_clean_btn} {copy_ctx_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 = [] + + 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.rfind(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", ""))) + 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 & CDP MATRIX", self._attr("bold")) + self.safe_addstr(self.stdscr, y + 1, x + 1, " NODE STATUS LATENCY CDP PORT LAST CHAT ACTIVE PAGE / THREAD", 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) + + 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 + rec_str = format_recency(self.data.agent_interactions.get(node, 0.0)) + tab_name = st.get("tab") or "-" + + 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:<12}", st_attr) + self.safe_addstr(self.stdscr, row_y, x + 23, f"{lat:<10}", row_attr if is_sel else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 34, f":{port:<9}", row_attr if is_sel else self._attr("dim")) + self.safe_addstr(self.stdscr, row_y, x + 44, f"{rec_str:<12}", row_attr if is_sel else self._attr("warn_banner" if rec_str != "never" else "dim")) + self.safe_addstr(self.stdscr, row_y, x + 57, f"{tab_name[:max(0, w - 74)]}", 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 + 57, 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}] [Click / Enter]: Open Chat [j/k]: Nav [f or /]: Fast Search [1-9/0]: Jump [r]: Refresh" + self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim")) + + 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 + + # ----------------------------------------------------------------------- + # 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): + # 1. Divider line + 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": + hints = " [NORMAL] Tab: Cycle h/l: Pane j/k: Nav Right-Click/x: Actions b: Clean/Box v: Copy i: Chat ?: Help" + else: + nl_cnt = self.input_buf.count("\n") + if nl_cnt > 0: + hints = f" [INSERT] Multi-line chunk ({nl_cnt + 1} lines, {len(self.input_buf)} chars) [Enter] Send chunk [Alt+Enter] Newline [Esc] Normal" + else: + hints = " [INSERT] Type message [Enter] Send [Alt+Enter] Newline [Alt+Space] Agent Layer [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: + hint_text = "[Nav locked — press 'i' or Enter to type, '?' 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 ", + } + 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"), + ("[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")) + + # ----------------------------------------------------------------------- + # 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 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 + + # 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 + + # Open Context Menu for active sidebar thread or message: 'x' or 'c' + if ch in (ord('x'), ord('X'), ord('c'), ord('C')) and not (self.mode == "box" and self.box_tab == 2): + if self.focus_pane == "sidebar": + 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" + 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" + r_role = m.get("role", "agent").upper() + self.set_toast(f"Message Context Menu ({r_role})", "info") + return True + + # Fast Agent Search & Select Modal: 'f' or 'F', or '/' when focused on sidebar + if ch in (ord('f'), ord('F')) or (self.focus_pane == "sidebar" and ch == ord('/')): + 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 == "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 + 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 == "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, 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' - '6' (when in box mode, except 1-3 on Tab 2) + if self.mode == "box": + if self.box_tab == 2: + if ord('4') <= ch <= ord('6'): + self.box_tab = ch - ord('1') + return True + elif ord('1') <= ch <= ord('6'): + 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) % 6 + return True + elif ch in (ord(']'), curses.KEY_RIGHT): + self.box_tab = (self.box_tab + 1) % 6 + return True + elif ch == ord('\t'): + self.box_tab = (self.box_tab + 1) % 6 + return True + elif ch == curses.KEY_BTAB: + self.box_tab = (self.box_tab - 1) % 6 + 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): + self.focus_pane = "sidebar" + return True + elif ch in (ord('l'), curses.KEY_RIGHT): + self.focus_pane = "transcript" + return True + + # Navigation: j / k / Up / Down + if ch in (curses.KEY_UP, ord('k')): + if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0): + if self.focus_pane == "sidebar": + 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) + 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 == "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) + 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) + 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) + 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 == "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 + 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 == "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) + 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 + is_press = bool(bstate & (curses.BUTTON1_CLICKED | curses.BUTTON1_PRESSED)) + is_release = bool(bstate & curses.BUTTON1_RELEASED) + is_drag = bool(bstate & curses.REPORT_MOUSE_POSITION) + is_right_click = bool(bstate & ( + getattr(curses, "BUTTON3_CLICKED", 0x1000) | + getattr(curses, "BUTTON3_PRESSED", 0x800) | + getattr(curses, "BUTTON3_RELEASED", 0x400) + )) + if is_right_click: + now = time.time() + if now - getattr(self, "_last_right_click_time", 0.0) < 0.25: + return True + self._last_right_click_time = now + + is_click = is_press or is_release or is_drag or is_right_click + + 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) + 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: + # Scroll Fleet Agents up + 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) + 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: + # Scroll Fleet Agents down + with self.data.lock: + total_nodes = len(self.data.nodes) + max_scroll = max(0, total_nodes - visible_agent_count) + self.agent_scroll_start = min(max_scroll, getattr(self, "agent_scroll_start", 0) + 1) + 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 HANDLING + if is_click: + # If Modal is active + if self.modal: + popup_bounds = getattr(self, "_context_popup_bounds", None) + if self.modal in ("chat_context", "agent_context", "message_context") and popup_bounds: + left_x, top_y, modal_w, modal_h = popup_bounds + else: + modal_w = min(74, w - 6) + modal_h = min(20, h - 4) + top_y = (h - modal_h) // 2 + left_x = (w - modal_w) // 2 + + # If clicked outside modal box, close it + if not (left_x <= mx < left_x + modal_w and top_y <= my < top_y + modal_h): + self.modal = None + return True + + if self.modal in ("chat_context", "agent_context", "message_context"): + actions = getattr(self, f"_{self.modal}_actions", []) + action_row = my - (top_y + 5) + if 0 <= action_row < len(actions): + # Update selection index on hover or drag + if self.modal == "chat_context": + self.chat_context_sel = action_row + elif self.modal == "agent_context": + self.agent_context_sel = action_row + elif self.modal == "message_context": + self.message_context_sel = action_row + + # If click press or drag release, execute action! + if is_press or is_release: + executor = getattr(self, f"_execute_{self.modal.split('_')[0]}_action") + executor(actions[action_row][0]) + return True + elif my == top_y + modal_h - 2 or my == top_y: + if is_press or is_release: + self.modal = None + return True + return True + + elif self.modal == "help": + self.modal = None + return True + + elif self.modal == "approvals": + act_y = top_y + modal_h - 4 + if my == act_y + 1: + rel_x = mx - left_x - 3 + # [1] Allow [2] Always [3] Deny [R] Proceed [X] Dismiss [C] Clear All + if 0 <= rel_x <= 10: + threading.Thread(target=self._async_resolve_approval, args=("allow",), daemon=True).start() + self.modal = None + elif 11 <= rel_x <= 22: + threading.Thread(target=self._async_resolve_approval, args=("always",), daemon=True).start() + self.modal = None + elif 23 <= rel_x <= 32: + threading.Thread(target=self._async_resolve_approval, args=("deny",), daemon=True).start() + self.modal = None + elif 33 <= rel_x <= 46: + threading.Thread(target=self._async_resolve_approval, args=("reply", None, "Proceed with standard execution."), daemon=True).start() + self.modal = None + elif 47 <= rel_x <= 60: + threading.Thread(target=self._async_resolve_approval, args=("dismiss",), daemon=True).start() + self.modal = None + elif rel_x >= 61: + threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start() + self.modal = None + elif my == top_y + modal_h - 2 or my == top_y: + self.modal = None + return True + + elif self.modal in ("new_sidechat", "work_order"): + if my == top_y + modal_h - 2: + self.modal = None + return True + + elif self.modal == "prompts": + list_w = min(32, modal_w // 2) + if (left_x + 2 <= mx <= left_x + list_w) and (top_y + 2 <= my < top_y + modal_h - 2): + row_offset = my - (top_y + 2) + with self.data.lock: + prompts = list(self.prompts) + visible_list_rows = modal_h - 4 + scroll_start = 0 + if self.prompt_sel_idx >= visible_list_rows: + scroll_start = self.prompt_sel_idx - visible_list_rows + 1 + clicked_idx = scroll_start + row_offset + if 0 <= clicked_idx < len(prompts): + self.prompt_sel_idx = clicked_idx + p = prompts[clicked_idx] + self.input_buf = p.get("prompt", "") + self.input_cursor = len(self.input_buf) + self.editor_mode = "INSERT" + self.set_toast(f"✔ Filled prompt: '{p.get('title')}'", "success") + self.modal = None + 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 + + # 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": + tab_bounds = [(1, 18), (19, 37), (38, 53), (54, 74), (75, 94), (95, 108)] + for idx, (start, end) in enumerate(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 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: + 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] + 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" + self.focus_pane = "sidebar" + visible_agent_count = getattr(self, "agent_visible_count", min(len(self.data.nodes), 6)) + divider_y = content_y + 1 + visible_agent_count + threads_hdr_y = divider_y + 1 + + # Check if click on Fleet Agents header (my == content_y) + if my == content_y: + 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.agent_context_sel = 0 + self.modal = "agent_context" + 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: + 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): + new_node = nodes[target_idx] + if is_right_click: + st_info = self.data.node_status.get(new_node, {}) + self.context_agent = { + "node": new_node, + "status": st_info, + "index": target_idx, + } + self.agent_context_sel = 0 + self.modal = "agent_context" + self.set_toast(f"Opened Agent Context Menu: [{new_node.upper()}]", "info") + return True + else: + self.data.set_active_target(new_node) + self.set_toast(f"Switched to agent: {new_node.upper()}", "success") + return True + + # Check if click on [n:new] or [r:sync] in sidebar header + if my == threads_hdr_y: + 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.agent_context_sel = 1 + self.modal = "agent_context" + return True + if mx >= sidebar_w - 14 and mx <= sidebar_w - 8: + self.modal = "new_sidechat" + self.modal_input_buf = "" + self.modal_input_cursor = 0 + return True + elif mx >= sidebar_w - 7: + 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): + 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] + if is_right_click: + self.context_chat = { + "node": self.data.active_node, + "thread": t, + "index": clicked_idx, + } + self.chat_context_sel = 0 + self.modal = "chat_context" + self.set_toast(f"Opened Chat Context Menu: '{title}'", "info") + return True + else: + self.data.set_active_target(self.data.active_node, sid, title) + self.auto_scroll = True + self.transcript_scroll_offset = 0 + self.set_toast(f"Opened thread '{title}'", "success") + return True + else: + # Mouse events inside Right Transcript + if is_right_click: + m = self._get_message_at_transcript_line(my) + if m: + self.context_message = m + self.message_context_sel = 0 + self.modal = "message_context" + r_role = m.get("role", "agent").upper() + self.set_toast(f"Message Context Menu ({r_role})", "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 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 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) + 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 + + 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): + time.sleep(1.0) + self.data._fetch_history(node, thread_id) + time.sleep(2.5) + self.data._fetch_history(node, thread_id) + + 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 _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"): + 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 == "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)") + args = parser.parse_args() + + try: + curses.wrapper(lambda stdscr: MuseTUI( + stdscr, + initial_mode=args.mode, + initial_node=args.account, + initial_thread=args.thread + ).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/bin/muse_tui.py b/bin/muse_tui.py new file mode 120000 index 0000000..497c022 --- /dev/null +++ b/bin/muse_tui.py @@ -0,0 +1 @@ +muse-tui.py \ No newline at end of file diff --git a/tests/test_agent_ranking.py b/tests/test_agent_ranking.py new file mode 100644 index 0000000..81c835d --- /dev/null +++ b/tests/test_agent_ranking.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +test_agent_ranking.py — Unit test suite for Fleet Agent interaction ranking & sorting: +1. Agent interaction recording on true input / chat [insert]. +2. Fleet agent sorting/ranking by last interaction descending. +3. Stable fallback ordering for un-interacted agents. +4. Persistence and seeding of interaction timestamps. +5. format_recency helper accuracy. +""" + +import unittest +from unittest.mock import MagicMock, patch +import importlib.util +from pathlib import Path +import time +import tempfile +import json + +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 TestAgentRanking(unittest.TestCase): + """Test suite covering fleet agent interaction sorting & recency tracking.""" + + 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.tui.safe_addstr = MagicMock() + self.tui.data.history_cache = {} + self.tui.data.read_msg_counts = {} + + def test_format_recency(self): + """Verify format_recency produces compact, accurate recency strings.""" + now = time.time() + self.assertEqual(muse_mod.format_recency(0.0), "never") + self.assertEqual(muse_mod.format_recency(None), "never") + self.assertEqual(muse_mod.format_recency(now - 10), "10s ago") + self.assertEqual(muse_mod.format_recency(now - 120), "2m ago") + self.assertEqual(muse_mod.format_recency(now - 7200), "2h ago") + self.assertEqual(muse_mod.format_recency(now - 90000), "1d ago") + + def test_sort_nodes_by_interaction(self): + """Verify sort_nodes ranks nodes by last interaction timestamp descending.""" + data = self.tui.data + now = time.time() + + # Set specific timestamps + data.agent_interactions["muse"] = now - 500 + data.agent_interactions["pip"] = now - 100 + data.agent_interactions["646"] = now - 10 # Most recent + data.agent_interactions["opm"] = 0.0 # Never + data.agent_interactions["def"] = 0.0 # Never + + data.sort_nodes() + + # Most recent first: 646, then pip, then muse + self.assertEqual(data.nodes[0], "646") + self.assertEqual(data.nodes[1], "pip") + self.assertEqual(data.nodes[2], "muse") + # Un-interacted agents should be at the end, retaining baseline order + self.assertIn("opm", data.nodes[3:]) + self.assertIn("def", data.nodes[3:]) + + def test_record_interaction_promotes_node_to_top(self): + """Verify record_interaction immediately updates timestamp and promotes node to rank 1.""" + data = self.tui.data + # Ensure 'pip' is not at index 0 initially + data.record_interaction("646") + self.assertEqual(data.nodes[0], "646") + + # Now operator directly interacts with 'pip' + t0 = time.time() + data.record_interaction("pip") + self.assertEqual(data.nodes[0], "pip", "'pip' must be promoted to rank 1 (index 0)") + self.assertEqual(data.nodes[1], "646", "'646' must be rank 2") + self.assertGreaterEqual(data.agent_interactions["pip"], t0) + + def test_chat_message_submission_triggers_ranking(self): + """Verify submitting chat message in INSERT mode promotes the active node.""" + self.tui.editor_mode = "INSERT" + self.tui.data.active_node = "def" + self.tui.data.active_thread_id = "test-session-123" + self.tui.data.active_thread_title = "Def Task Session" + + # Mock async send to avoid subprocess + with patch.object(self.tui, "_async_send_message"): + self.tui._execute_input_line("Deploy updated service configuration") + + # 'def' was chatted with directly; it must now be rank 1 at index 0 + self.assertEqual(self.tui.data.nodes[0], "def", "Chat message send must promote target node to top of list") + + def test_persistence_of_interactions(self): + """Verify interaction records persist and load from json correctly.""" + with tempfile.TemporaryDirectory() as tmpdir: + fake_int_file = Path(tmpdir) / "agent_interactions.json" + now = time.time() + fake_int_file.write_text(json.dumps({"opm": now - 50, "dev": now - 10})) + + with patch("pathlib.Path.home", return_value=Path(tmpdir)), \ + patch.object(muse_mod.Path, "home", return_value=Path(tmpdir)): + # Adjust path to match ~/.config/muse-cli/ + cfg_dir = Path(tmpdir) / ".config" / "muse-cli" + cfg_dir.mkdir(parents=True, exist_ok=True) + (cfg_dir / "agent_interactions.json").write_text(json.dumps({"opm": now - 50, "dev": now - 10})) + + mgr = muse_mod.FleetDataManager() + mgr.stop() # Stop background poller + + self.assertAlmostEqual(mgr.agent_interactions.get("dev", 0.0), now - 10, delta=1.0) + self.assertAlmostEqual(mgr.agent_interactions.get("opm", 0.0), now - 50, delta=1.0) + self.assertEqual(mgr.nodes[0], "dev", "Persisted recent node 'dev' must be rank 1") + self.assertEqual(mgr.nodes[1], "opm", "'opm' must be rank 2") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_blocked_approvals.py b/tests/test_blocked_approvals.py new file mode 100644 index 0000000..698ab10 --- /dev/null +++ b/tests/test_blocked_approvals.py @@ -0,0 +1,87 @@ +import unittest +from unittest.mock import MagicMock, patch +import importlib.util +import sys +from pathlib import Path + +REPO_ROOT = Path("/home/super/Projects/NetVM") +sys.path.insert(0, str(REPO_ROOT / "bin")) + + +class TestBlockedApprovals(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location("muse_tui", str(REPO_ROOT / "bin" / "muse-tui.py")) + cls.muse_tui = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.muse_tui) + + def setUp(self): + self.mock_stdscr = MagicMock() + self.mock_stdscr.getmaxyx.return_value = (40, 120) + self.app = self.muse_tui.MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse") + self.app.data = MagicMock() + self.app.data.nodes = ["muse", "pip", "646", "opm", "def", "dev"] + self.app.data.active_node = "muse" + self.app.data.active_thread_id = "thread-1" + self.app.data.active_thread_title = "Main Chat" + self.app.data.lock = MagicMock() + self.app.data.lock.__enter__.return_value = None + self.app.data.lock.__exit__.return_value = None + + def test_slash_blocked_opens_approvals_modal(self): + self.app.data.approvals_cache = [ + {"node": "opm", "status": "PENDING", "title": "Review task", "is_trusted": True}, + {"node": "646", "status": "PENDING", "title": "SSH connect", "is_trusted": False}, + ] + self.app._execute_input_line("/blocked") + self.assertEqual(self.app.modal, "approvals") + self.assertEqual(self.app.approvals_sel_idx, 0) + + self.app._execute_input_line("/Blocked") + self.assertEqual(self.app.modal, "approvals") + + self.app._execute_input_line("/approvals") + self.assertEqual(self.app.modal, "approvals") + + def test_approvals_modal_navigation_and_targeting(self): + self.app.modal = "approvals" + self.app.approvals_sel_idx = 0 + self.app.data.approvals_cache = [ + {"node": "muse", "status": "PENDING", "title": "Task 1"}, + {"node": "opm", "status": "PENDING", "title": "Task 2"}, + ] + + # Press 'j' (down) + handled = self.app._handle_modal_key(ord('j')) + self.assertTrue(handled) + self.assertEqual(self.app.approvals_sel_idx, 1) + + # Press '1' (allow) -> should target 'opm' (selected row), not 'muse' (active_node) + with patch.object(self.app, "_async_resolve_approval") as mock_resolve: + handled = self.app._handle_modal_key(ord('1')) + self.assertTrue(handled) + self.assertIsNone(self.app.modal) + mock_resolve.assert_called_once_with("allow", "opm") + + def test_approvals_modal_navigation_k(self): + self.app.modal = "approvals" + self.app.approvals_sel_idx = 1 + self.app.data.approvals_cache = [ + {"node": "muse", "status": "PENDING", "title": "Task 1"}, + {"node": "opm", "status": "PENDING", "title": "Task 2"}, + ] + + # Press 'k' (up) + handled = self.app._handle_modal_key(ord('k')) + self.assertTrue(handled) + self.assertEqual(self.app.approvals_sel_idx, 0) + + # Press '2' (always) -> should target 'muse' + with patch.object(self.app, "_async_resolve_approval") as mock_resolve: + handled = self.app._handle_modal_key(ord('2')) + self.assertTrue(handled) + mock_resolve.assert_called_once_with("always", "muse") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_clean_transcript.py b/tests/test_clean_transcript.py new file mode 100644 index 0000000..0c66162 --- /dev/null +++ b/tests/test_clean_transcript.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +test_clean_transcript.py — Unit and integration tests for clean line-by-line transcript view: +1. Default display style is 'clean' (no box borders, no bottom rules, no vertical pipe bars). +2. Clean mode renders bullet header indicators (● AGENT, ▸ YOU) and 2-space indented body lines. +3. Boxed mode retains classic ASCII/Unicode frames (┌──, │ , └───). +4. Runtime style toggling via 'b' / 'B' hotkey in Normal mode. +5. Slash commands: /clean, /boxed, /view, /compact. +6. Selection and copy extraction (_copy_rendered_line_range) handles clean formatting seamlessly. +7. Settings persistence in ~/.config/muse-cli/tui_settings.json. +""" +import curses +import importlib.util +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +REPO_ROOT = Path(__file__).resolve().parent.parent +MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py" + +spec = importlib.util.spec_from_file_location("muse_tui", MUSE_TUI_PATH) +muse_tui = importlib.util.module_from_spec(spec) +sys.modules["muse_tui"] = muse_tui +spec.loader.exec_module(muse_tui) + +MuseTUI = muse_tui.MuseTUI + + +class TestCleanTranscript(unittest.TestCase): + def setUp(self): + self.mock_stdscr = MagicMock() + self.mock_stdscr.getmaxyx.return_value = (30, 100) + self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse") + self.tui.safe_addstr = MagicMock() + + self.messages = [ + { + "role": "assistant", + "text": "Same sweep thread, routine — holding on dev-i04.\nSecond line of response.", + "message_id": "ast-msg-86648", + "seq": 86648, + }, + { + "role": "user", + "text": "Roger that, continue monitoring.\n[WO:20261006-1] Active", + "message_id": "usr-msg-d183a734", + "seq": 86649, + }, + ] + with self.tui.data.lock: + self.tui.data.history_cache[(self.tui.data.active_node, self.tui.data.active_thread_id)] = self.messages + + def test_default_style_is_clean(self): + """Verify that newly initialized TUI defaults to 'clean' transcript style.""" + self.assertEqual(self.tui.transcript_style, "clean") + + def test_render_transcript_clean_mode(self): + """In 'clean' mode, lines do NOT contain ┌──, │ , or └───, and use clean bullets.""" + self.tui.transcript_style = "clean" + self.tui._transcript_cache_key = None + self.tui._render_transcript(1, 0, 25, 90) + + lines = [item[0] for item in self.tui._transcript_cache_lines] + combined = "\n".join(lines) + + # Must not contain box border frames + self.assertNotIn("┌──", combined) + self.assertNotIn("└──", combined) + self.assertNotIn("│ ", combined) + + # Must contain clean indicators + self.assertTrue(any(l.startswith("● MUSE") for l in lines), "Agent header must start with clean bullet ●") + self.assertTrue(any(l.startswith("▸ YOU") for l in lines), "User header must start with clean prompt ▸") + + # Body lines must use 2-space indentation + self.assertTrue(any(l.startswith(" Same sweep thread") for l in lines)) + self.assertTrue(any(l.startswith(" Second line") for l in lines)) + self.assertTrue(any(l.startswith(" Roger that") for l in lines)) + + def test_render_transcript_boxed_mode(self): + """In 'boxed' mode, classic ASCII/Unicode frames (┌──, │ , └───) are rendered.""" + self.tui.transcript_style = "boxed" + self.tui._transcript_cache_key = None + self.tui._render_transcript(1, 0, 25, 90) + + lines = [item[0] for item in self.tui._transcript_cache_lines] + combined = "\n".join(lines) + + self.assertIn("┌── [MUSE / MUSE AGENT]", combined) + self.assertIn("┌── [YOU / OPERATOR]", combined) + self.assertIn("│ Same sweep thread", combined) + self.assertIn("└──", combined) + + def test_toggle_transcript_style(self): + """toggle_transcript_style switches between clean and boxed, invalidating cache.""" + self.tui.transcript_style = "clean" + self.tui._transcript_cache_key = ("some_key",) + + self.tui.toggle_transcript_style() + self.assertEqual(self.tui.transcript_style, "boxed") + self.assertIsNone(self.tui._transcript_cache_key) + self.assertIn("Classic boxed", self.tui.toast_msg) + + self.tui.toggle_transcript_style() + self.assertEqual(self.tui.transcript_style, "clean") + self.assertIsNone(self.tui._transcript_cache_key) + self.assertIn("Clean line-by-line", self.tui.toast_msg) + + def test_normal_mode_hotkey_b_toggles_style(self): + """Pressing 'b' or 'B' in normal mode toggles transcript style.""" + self.tui.editor_mode = "NORMAL" + self.tui.copy_mode = False + self.tui.transcript_style = "clean" + + handled = self.tui._handle_key(ord('b')) + self.assertTrue(handled) + self.assertEqual(self.tui.transcript_style, "boxed") + + handled = self.tui._handle_key(ord('B')) + self.assertTrue(handled) + self.assertEqual(self.tui.transcript_style, "clean") + + def test_slash_commands_clean_and_boxed(self): + """Slash commands /clean, /boxed, and /view correctly set style.""" + self.tui.transcript_style = "clean" + + # /boxed + self.tui._execute_input_line("/boxed") + self.assertEqual(self.tui.transcript_style, "boxed") + + # /clean + self.tui._execute_input_line("/clean") + self.assertEqual(self.tui.transcript_style, "clean") + + # /view box + self.tui._execute_input_line("/view box") + self.assertEqual(self.tui.transcript_style, "boxed") + + # /view clean + self.tui._execute_input_line("/view clean") + self.assertEqual(self.tui.transcript_style, "clean") + + # /style (toggles) + self.tui._execute_input_line("/style") + self.assertEqual(self.tui.transcript_style, "boxed") + + def test_copy_range_in_clean_mode(self): + """_copy_rendered_line_range strips clean 2-space indents and action buttons.""" + self.tui.transcript_style = "clean" + self.tui._transcript_cache_key = None + self.tui._render_transcript(1, 0, 25, 90) + + # Total lines in cache + lines = self.tui._transcript_cache_lines + copied = self.tui._copy_rendered_line_range(0, len(lines) - 1) + + self.assertNotIn("│", copied) + self.assertNotIn("[📋 Copy]", copied) + self.assertNotIn("[↩ Reply]", copied) + self.assertIn("Same sweep thread", copied) + self.assertIn("Roger that, continue monitoring.", copied) + + def test_settings_persistence(self): + """Style preference is saved to and loaded from JSON configuration.""" + with tempfile.TemporaryDirectory() as tmp_dir: + fake_cfg = Path(tmp_dir) / "tui_settings.json" + with patch("pathlib.Path.home", return_value=Path(tmp_dir)): + with patch.object(Path, "mkdir"): + with patch("builtins.open", unittest.mock.mock_open()): + self.tui.set_transcript_style("boxed") + self.assertEqual(self.tui.transcript_style, "boxed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_context_menus.py b/tests/test_context_menus.py new file mode 100644 index 0000000..026dc50 --- /dev/null +++ b/tests/test_context_menus.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +test_context_menus.py — Unit and integration tests for Right-Click function(s) +and Context Action Menus in NetVM/Muse TUI: +1. Right-click on Chat List (sidebar sidechats/threads) opens `chat_context` menu. +2. Right-click on Fleet Agents panel opens `agent_context` menu. +3. Right-click on Transcript message opens `message_context` menu. +4. Chat Context Menu actions (Open, Pin/Unpin, Mark as Read, Copy ID, Copy URL, Reply, Close). +5. Fleet Agent Context Menu actions (Open, Sync, Approvals, Mark All Read, Tmux, Work Order). +6. Message Context Menu actions (Reply, Copy Text, Pin to Prompts, Copy ID). +7. Keyboard navigation (j/k, 1-8, Enter) and mouse click selection inside context menus. +8. Normal mode hotkeys ('x' / 'c') and slash commands (/context chat, /context agent). +""" +import curses +import importlib.util +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +REPO_ROOT = Path(__file__).resolve().parent.parent +MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py" + +spec = importlib.util.spec_from_file_location("muse_tui", MUSE_TUI_PATH) +muse_tui = importlib.util.module_from_spec(spec) +sys.modules["muse_tui"] = muse_tui +spec.loader.exec_module(muse_tui) + +MuseTUI = muse_tui.MuseTUI + + +class TestContextMenus(unittest.TestCase): + def setUp(self): + self.mock_stdscr = MagicMock() + self.mock_stdscr.getmaxyx.return_value = (30, 100) + self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse") + self.tui.safe_addstr = MagicMock() + + # Seed mock threads + self.threads = [ + {"session_id": "sess-main-1234", "title": "Main Chat", "is_main": True, "pinned": False}, + {"session_id": "sess-side-5678", "title": "Audit Sidechat", "is_main": False, "pinned": True}, + {"session_id": "sess-side-9999", "title": "Dev Sidechat", "is_main": False, "pinned": False}, + ] + with self.tui.data.lock: + self.tui.data.pinned_threads["muse"] = set() + self.tui.data.threads_cache["muse"] = list(self.threads) + self.tui.data.active_thread_id = "sess-main-1234" + self.tui.data.active_thread_title = "Main Chat" + self.tui.data.history_cache[("muse", "sess-main-1234")] = [ + {"role": "user", "text": "Hello muse", "seq": 1, "message_id": "mid-user-1"}, + {"role": "assistant", "text": "System ready", "seq": 2, "message_id": "mid-agent-2"}, + ] + self.tui.data.history_cache[("muse", "sess-side-5678")] = [ + {"role": "user", "text": "Review log", "seq": 3, "message_id": "mid-user-3"}, + ] + + def test_right_click_on_chat_list_opens_chat_context(self): + """Right-clicking a thread row in sidebar opens chat_context modal.""" + # Screen layout: content_y = 1, visible_agents = 6, divider_y = 8, threads_hdr_y = 9, list_y = 10 + # Right click on thread index 1 (sess-side-5678) at my = 11, mx = 10 + bstate = curses.BUTTON3_CLICKED + self.tui._handle_mouse(mx=10, my=11, bstate=bstate) + + self.assertEqual(self.tui.modal, "chat_context") + self.assertIsNotNone(self.tui.context_chat) + self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678") + self.assertEqual(self.tui.context_chat["thread"]["title"], "Audit Sidechat") + + def test_right_click_on_fleet_agent_opens_agent_context(self): + """Right-clicking an agent row in sidebar opens agent_context modal.""" + # Row content_y + 1 = 2 is first agent (index 0 = 'muse') + # Row content_y + 2 = 3 is second agent + bstate = getattr(curses, "BUTTON3_PRESSED", 0x800) + with self.tui.data.lock: + second_agent = self.tui.data.nodes[1] if len(self.tui.data.nodes) > 1 else "muse" + + self.tui._handle_mouse(mx=10, my=3, bstate=bstate) + + self.assertEqual(self.tui.modal, "agent_context") + self.assertIsNotNone(self.tui.context_agent) + self.assertEqual(self.tui.context_agent["node"], second_agent) + + def test_right_click_on_transcript_opens_message_context(self): + """Right-clicking a message in transcript opens message_context modal.""" + # Populate rendered lines in transcript + self.tui._render_transcript(1, 26, 25, 74) + # Click on transcript area mx = 50, my = 5 + bstate = curses.BUTTON3_CLICKED + self.tui._handle_mouse(mx=50, my=5, bstate=bstate) + + self.assertEqual(self.tui.modal, "message_context") + self.assertIsNotNone(self.tui.context_message) + self.assertIn("text", self.tui.context_message) + + def test_chat_context_pin_action(self): + """Executing 'pin' action in chat_context toggles pin state.""" + t = {"session_id": "sess-side-9999", "title": "Dev Sidechat", "pinned": False} + self.tui.context_chat = {"node": "muse", "thread": t, "index": 2} + self.tui.modal = "chat_context" + + # Press 'p' + self.tui._handle_modal_key(ord('p')) + self.assertIsNone(self.tui.modal) + self.assertIn("📌 Pinned chat", self.tui.toast_msg) + self.assertIn("sess-side-9999", self.tui.data.pinned_threads.get("muse", set())) + + def test_chat_context_mark_read_action(self): + """Executing 'read' action marks thread as read.""" + t = {"session_id": "sess-side-5678", "title": "Audit Sidechat"} + self.tui.context_chat = {"node": "muse", "thread": t, "index": 1} + self.tui.modal = "chat_context" + + self.tui._handle_modal_key(ord('m')) + self.assertIsNone(self.tui.modal) + self.assertIn("Marked 'Audit Sidechat' as read", self.tui.toast_msg) + self.assertEqual(self.tui.data.get_unread_count("muse", "sess-side-5678"), 0) + + def test_chat_context_copy_id_action(self): + """Executing 'copy_id' action copies session ID to clipboard.""" + t = {"session_id": "sess-side-5678", "title": "Audit Sidechat"} + self.tui.context_chat = {"node": "muse", "thread": t, "index": 1} + self.tui.modal = "chat_context" + + mod = sys.modules.get(MuseTUI.__module__, muse_tui) + with patch.object(mod, "copy_to_clipboard") as mock_cp1, \ + patch.object(muse_tui, "copy_to_clipboard") as mock_cp2: + self.tui._handle_modal_key(ord('y')) + self.assertTrue(mock_cp1.called or mock_cp2.called) + self.assertIn("Copied session ID", self.tui.toast_msg) + + def test_chat_context_reply_action(self): + """Executing 'reply' action switches to chat and quotes latest message.""" + t = {"session_id": "sess-main-1234", "title": "Main Chat"} + self.tui.context_chat = {"node": "muse", "thread": t, "index": 0} + self.tui.modal = "chat_context" + + self.tui._handle_modal_key(ord('r')) + self.assertIsNone(self.tui.modal) + self.assertIsNotNone(self.tui.reply_target) + self.assertEqual(self.tui.reply_target["role"], "assistant") + self.assertIn("System ready", self.tui.reply_target["text"]) + + def test_agent_context_actions(self): + """Agent context menu hotkeys execute appropriate agent actions.""" + self.tui.context_agent = {"node": "muse"} + self.tui.modal = "agent_context" + + # Mark all chats on agent as read ('m') + self.tui._handle_modal_key(ord('m')) + self.assertIsNone(self.tui.modal) + self.assertIn("Marked all chats on MUSE as read", self.tui.toast_msg) + + # Open chat with agent ('1') + self.tui.context_agent = {"node": "muse"} + self.tui.modal = "agent_context" + self.tui._handle_modal_key(ord('1')) + self.assertIsNone(self.tui.modal) + self.assertIn("Switched to agent", self.tui.toast_msg) + + def test_normal_mode_x_opens_chat_context(self): + """Pressing 'x' in normal mode when focused on sidebar opens chat_context.""" + self.tui.editor_mode = "NORMAL" + self.tui.focus_pane = "sidebar" + self.tui.thread_sel_idx = 1 + + handled = self.tui._handle_key(ord('x')) + self.assertTrue(handled) + self.assertEqual(self.tui.modal, "chat_context") + self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678") + + def test_slash_command_context(self): + """Slash command /context opens chat_context or agent_context.""" + self.tui._execute_input_line("/context chat") + self.assertEqual(self.tui.modal, "chat_context") + + self.tui.modal = None + self.tui._execute_input_line("/context agent") + self.assertEqual(self.tui.modal, "agent_context") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_copy_actions.py b/tests/test_copy_actions.py new file mode 100644 index 0000000..e9559b6 --- /dev/null +++ b/tests/test_copy_actions.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +test_copy_actions.py — Unit and integration tests for: +1. Multi-environment clipboard engine (desktop utilities, OSC 52, internal buffer). +2. Transcript formatting engine (Markdown structured headers, roles, seq, id). +3. UI-first button approach: + - Clickable [📋 Copy All] in thread header. + - Clickable [📋 Copy] button on each message header. +4. Normal mode hotkeys: + - 'y': copy latest/active message to clipboard. + - 'Y': copy entire chat transcript to clipboard. +5. Slash commands: + - '/copy', '/yank', '/cp': copy full transcript. + - '/copy last', '/copy message': copy latest message. +""" +import curses +import importlib.util +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +REPO_ROOT = Path(__file__).resolve().parent.parent +MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py" + +spec = importlib.util.spec_from_file_location("muse_tui", MUSE_TUI_PATH) +muse_tui = importlib.util.module_from_spec(spec) +sys.modules["muse_tui"] = muse_tui +spec.loader.exec_module(muse_tui) + +MuseTUI = muse_tui.MuseTUI +copy_to_clipboard = muse_tui.copy_to_clipboard +strip_message_metadata = muse_tui.strip_message_metadata + + +class TestCopyActions(unittest.TestCase): + def setUp(self): + self.mock_stdscr = MagicMock() + self.mock_stdscr.getmaxyx.return_value = (30, 100) + self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse") + self.tui.safe_addstr = MagicMock() + + # Seed conversation history for testing + self.messages = [ + { + "role": "user", + "text": "Hello muse, check system status", + "message_id": "usr-msg-12345678", + "seq": 1, + }, + { + "role": "assistant", + "text": "All services nominal.\nCDP port 9222 active.", + "message_id": "ast-msg-87654321", + "seq": 2, + }, + { + "role": "user", + "text": "Please summarize logs", + "message_id": "usr-msg-99999999", + "seq": 3, + }, + ] + self.tui.data.history_cache[("muse", "thread-abc")] = list(self.messages) + self.tui.data.active_node = "muse" + self.tui.data.active_thread_id = "thread-abc" + self.tui.data.active_thread_title = "Diagnostics" + + def test_strip_message_metadata(self): + """Verify strip_message_metadata strips reply headers and unquotes quoted lines.""" + raw = ( + "> Replying to ASSISTANT (seq:12620):\n" + "> Relevant to the timer question: the loop scout just surfaced 6 unanswered jobs.\n" + "> So part of getting work done is already queued.\n\n" + "yes, proceed with spawning 5 sub agents to assist you ; continue at all costs" + ) + expected = ( + "Relevant to the timer question: the loop scout just surfaced 6 unanswered jobs.\n" + "So part of getting work done is already queued.\n\n" + "yes, proceed with spawning 5 sub agents to assist you ; continue at all costs" + ) + self.assertEqual(strip_message_metadata(raw), expected) + + # Message without reply tags remains unchanged + no_tags = "Standard message without reply headers." + self.assertEqual(strip_message_metadata(no_tags), no_tags) + + def test_copy_to_clipboard_osc52(self): + """Verify copy_to_clipboard formats and flushes OSC 52 sequence.""" + with patch("sys.stdout.write") as mock_write, patch("sys.stdout.flush"): + ok = copy_to_clipboard("test-copy-payload") + self.assertTrue(ok) + # Verify OSC 52 sequence was written + args = [call.args[0] for call in mock_write.call_args_list] + osc_written = any("\033]52;c;" in a for a in args) + self.assertTrue(osc_written) + + def test_get_formatted_transcript_content(self): + """Verify structured Markdown output contains thread title, roles, seq, id, and text.""" + formatted = self.tui.get_formatted_transcript() + self.assertIn("# Chat Transcript: Diagnostics", formatted) + self.assertIn("Node: MUSE | Thread ID: thread-abc | Total Messages: 3", formatted) + self.assertIn("### [YOU / OPERATOR] id:usr-msg-", formatted) + self.assertIn("Hello muse, check system status", formatted) + self.assertIn("### [MUSE / MUSE AGENT] seq:2 id:ast-msg-", formatted) + self.assertIn("All services nominal.", formatted) + self.assertIn("Please summarize logs", formatted) + + def test_copy_transcript_to_clipboard(self): + """Verify copy_transcript_to_clipboard updates internal buffer and sets success toast.""" + with patch.object(muse_tui, "copy_to_clipboard", return_value=True): + ok = self.tui.copy_transcript_to_clipboard() + self.assertTrue(ok) + self.assertIn("# Chat Transcript: Diagnostics", self.tui.clipboard_buf) + self.assertIn("Copied full transcript (3 msgs", self.tui.toast_msg) + + def test_copy_message_to_clipboard_latest(self): + """Verify copy_message_to_clipboard defaults to latest message.""" + with patch.object(muse_tui, "copy_to_clipboard", return_value=True): + ok = self.tui.copy_message_to_clipboard() + self.assertTrue(ok) + self.assertEqual(self.tui.clipboard_buf, "Please summarize logs") + self.assertIn("Copied clean message to clipboard", self.tui.toast_msg) + + def test_copy_message_to_clipboard_with_and_without_context(self): + """Verify with_context toggles between metadata stripping and raw text.""" + raw = "> Replying to ASSISTANT (seq:1):\n> Quoted header\n\nActual response body" + with patch.object(muse_tui, "copy_to_clipboard", return_value=True): + # Clean text (without context) + self.tui.copy_message_to_clipboard(raw, with_context=False) + self.assertEqual(self.tui.clipboard_buf, "Quoted header\n\nActual response body") + self.assertIn("clean message", self.tui.toast_msg) + + # Raw text (with context) + self.tui.copy_message_to_clipboard(raw, with_context=True) + self.assertEqual(self.tui.clipboard_buf, raw) + self.assertIn("message with context", self.tui.toast_msg) + + def test_copy_message_to_clipboard_explicit(self): + """Verify copy_message_to_clipboard with explicit message text.""" + with patch.object(muse_tui, "copy_to_clipboard", return_value=True): + custom_text = "Specific arbitrary message payload" + ok = self.tui.copy_message_to_clipboard(custom_text) + self.assertTrue(ok) + self.assertEqual(self.tui.clipboard_buf, custom_text) + + def test_render_transcript_buttons(self): + """Verify [📋 Copy All], [📋 Copy] and [📑+ Context] buttons are rendered and registered.""" + self.tui._render_transcript(y=1, x=24, h=25, w=76) + + # Check [📋 Copy All] bounds registered + self.assertIsNotNone(self.tui._btn_copy_all_bounds) + btn_y, x_start, x_end = self.tui._btn_copy_all_bounds + self.assertEqual(btn_y, 1) + self.assertGreater(x_end, x_start) + + # Verify button string was rendered to screen at btn coordinates + calls = [c for c in self.tui.safe_addstr.call_args_list if "[📋 Copy All]" in str(c)] + self.assertTrue(len(calls) > 0, "[📋 Copy All] must be rendered to stdscr") + win_arg, call_y, call_x, call_text, call_attr = calls[0].args + self.assertEqual(call_y, 1) + self.assertEqual(call_x, x_start) + + # Check individual message copy buttons registered + self.assertGreater(len(self.tui._msg_copy_buttons), 0) + self.assertGreater(len(self.tui._msg_copy_context_buttons), 0) + for (my, mx1, mx2, raw_text) in self.tui._msg_copy_buttons: + self.assertGreater(mx2, mx1) + self.assertIn(raw_text, [m["text"] for m in self.messages]) + for (my, mx1, mx2, raw_text) in self.tui._msg_copy_context_buttons: + self.assertGreater(mx2, mx1) + self.assertIn(raw_text, [m["text"] for m in self.messages]) + + def test_render_transcript_narrow_screen_retains_copy_button(self): + """Verify [📋 Copy All] is retained next to message count even with long title & narrow width.""" + self.tui.data.active_thread_title = "Extremely Long Comprehensive Diagnostics Thread Title Exceeding Width" + self.tui._render_transcript(y=1, x=10, h=20, w=48) + + self.assertIsNotNone(self.tui._btn_copy_all_bounds) + btn_y, x_start, x_end = self.tui._btn_copy_all_bounds + self.assertEqual(btn_y, 1) + self.assertLessEqual(x_end, 10 + 48, "Button must stay within transcript boundary") + calls = [c for c in self.tui.safe_addstr.call_args_list if "[📋 Copy All]" in str(c)] + self.assertTrue(len(calls) > 0) + + def test_mouse_click_copy_all(self): + """Verify clicking [📋 Copy All] copies full transcript to clipboard.""" + self.tui._render_transcript(y=1, x=24, h=25, w=76) + btn_y, x_start, x_end = self.tui._btn_copy_all_bounds + click_x = (x_start + x_end) // 2 + + with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all: + handled = self.tui._handle_mouse(click_x, btn_y, curses.BUTTON1_CLICKED) + self.assertTrue(handled) + mock_copy_all.assert_called_once() + + def test_mouse_click_message_copy(self): + """Verify clicking an individual [📋 Copy] button copies that specific message without context.""" + self.tui._render_transcript(y=1, x=24, h=25, w=76) + self.assertGreater(len(self.tui._msg_copy_buttons), 0) + first_btn = self.tui._msg_copy_buttons[0] + msg_y, x_start, x_end, raw_text = first_btn + click_x = (x_start + x_end) // 2 + + with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg: + handled = self.tui._handle_mouse(click_x, msg_y, curses.BUTTON1_CLICKED) + self.assertTrue(handled) + mock_copy_msg.assert_called_once_with(raw_text, with_context=False) + + def test_mouse_click_message_copy_context(self): + """Verify clicking an individual [📑+ Context] button copies that message with full context.""" + self.tui._render_transcript(y=1, x=24, h=25, w=76) + self.assertGreater(len(self.tui._msg_copy_context_buttons), 0) + first_btn = self.tui._msg_copy_context_buttons[0] + msg_y, x_start, x_end, raw_text = first_btn + click_x = (x_start + x_end) // 2 + + with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg: + handled = self.tui._handle_mouse(click_x, msg_y, curses.BUTTON1_CLICKED) + self.assertTrue(handled) + mock_copy_msg.assert_called_once_with(raw_text, with_context=True) + + def test_normal_hotkey_y(self): + """Verify 'y' in Normal mode copies latest message.""" + self.tui.editor_mode = "NORMAL" + with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg: + handled = self.tui._handle_normal_key(ord("y")) + self.assertTrue(handled) + mock_copy_msg.assert_called_once() + + def test_normal_hotkey_Y(self): + """Verify 'Y' in Normal mode copies full transcript.""" + self.tui.editor_mode = "NORMAL" + with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all: + handled = self.tui._handle_normal_key(ord("Y")) + self.assertTrue(handled) + mock_copy_all.assert_called_once() + + def test_slash_command_copy_all(self): + """Verify '/copy' or '/yank' copies full transcript.""" + with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all: + self.tui._execute_input_line("/copy") + mock_copy_all.assert_called_once() + + with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all: + self.tui._execute_input_line("/yank") + mock_copy_all.assert_called_once() + + def test_slash_command_copy_last(self): + """Verify '/copy last' copies latest message.""" + with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg: + self.tui._execute_input_line("/copy last") + mock_copy_msg.assert_called_once() + + def test_enter_visual_copy_mode(self): + """Pressing 'v' enters Visual Copy Mode with selection on visible line.""" + self.tui.editor_mode = "NORMAL" + self.tui.copy_mode = False + self.tui._render_transcript(y=1, x=24, h=25, w=76) + + ret = self.tui._handle_normal_key(ord('v')) + self.assertTrue(ret) + self.assertTrue(self.tui.copy_mode) + self.assertEqual(self.tui.focus_pane, "transcript") + self.assertIsNotNone(self.tui.visual_sel_start) + self.assertEqual(self.tui.visual_sel_start, self.tui.copy_cursor_line) + self.assertIn("VISUAL line copy mode", self.tui.toast_msg) + + def test_enter_visual_copy_mode_V(self): + """Pressing 'V' on transcript enters visual copy mode.""" + self.tui.editor_mode = "NORMAL" + self.tui.focus_pane = "transcript" + self.tui.copy_mode = False + self.tui._render_transcript(y=1, x=24, h=25, w=76) + + ret = self.tui._handle_normal_key(ord('V')) + self.assertTrue(ret) + self.assertTrue(self.tui.copy_mode) + self.assertIsNotNone(self.tui.visual_sel_start) + self.assertIn("VISUAL line copy mode", self.tui.toast_msg) + + def test_message_context_menu_copy_clean_and_context(self): + """Verify message_context modal options 2/y (clean) and 3/Y (context) work.""" + target_msg = { + "role": "assistant", + "text": "> Replying to USER (seq:4):\n> Previous line\n\nClean response text", + "message_id": "msg-xyz-123", + "seq": 5, + } + self.tui.context_message = target_msg + self.tui.modal = "message_context" + + with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy: + # Key '2' or 'y' copies clean text (with_context=False) + self.tui._handle_modal_key(ord('2')) + mock_copy.assert_called_with(target_msg["text"], with_context=False) + + self.tui.context_message = target_msg + self.tui.modal = "message_context" + with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy: + # Key '3' or 'Y' copies with context (with_context=True) + self.tui._handle_modal_key(ord('3')) + mock_copy.assert_called_with(target_msg["text"], with_context=True) + + def test_copy_mode_navigation_and_yank_with_border_stripping(self): + """Navigating with 'j'/'k' and yanking with 'y' copies clean text without box borders.""" + self.tui._render_transcript(y=1, x=24, h=25, w=76) + self.tui.copy_mode = True + self.tui.visual_sel_start = 2 + self.tui.copy_cursor_line = 2 + + # Move down to line 4 + self.tui._handle_copy_mode_key(ord('j')) + self.assertEqual(self.tui.copy_cursor_line, 3) + self.tui._handle_copy_mode_key(ord('j')) + self.assertEqual(self.tui.copy_cursor_line, 4) + + # Yank selection with 'y' + with patch.object(muse_tui, "copy_to_clipboard", return_value=True) as mock_clip: + self.tui._handle_copy_mode_key(ord('y')) + self.assertFalse(self.tui.copy_mode) + self.assertIsNone(self.tui.visual_sel_start) + mock_clip.assert_called_once() + copied = mock_clip.call_args[0][0] + # Ensure vertical box lines '│ ' are stripped + self.assertNotIn("│ ", copied) + self.assertIn("Copied", self.tui.toast_msg) + + def test_mouse_drag_select_and_release_copy(self): + """Dragging mouse over transcript lines highlights and copies text on release.""" + self.tui._render_transcript(y=1, x=24, h=25, w=76) + + # 1. Mouse down at screen y=5, x=30 (inside transcript) + self.tui._handle_mouse(mx=30, my=5, bstate=curses.BUTTON1_PRESSED) + self.assertTrue(self.tui.mouse_dragging) + self.assertTrue(self.tui.copy_mode) + start_line = self.tui.visual_sel_start + self.assertIsNotNone(start_line) + + # 2. Mouse drag to y=8 + self.tui._handle_mouse(mx=30, my=8, bstate=curses.REPORT_MOUSE_POSITION) + self.assertEqual(self.tui.visual_sel_start, start_line) + self.assertNotEqual(self.tui.copy_cursor_line, start_line) + + # 3. Mouse release + with patch.object(muse_tui, "copy_to_clipboard", return_value=True) as mock_clip: + self.tui._handle_mouse(mx=30, my=8, bstate=curses.BUTTON1_RELEASED) + self.assertFalse(self.tui.mouse_dragging) + self.assertFalse(self.tui.copy_mode) + mock_clip.assert_called_once() + copied = mock_clip.call_args[0][0] + self.assertGreater(len(copied), 0) + self.assertIn("Copied selection", self.tui.toast_msg) + + def test_copy_mode_escape_exits(self): + """Esc or 'q' clears selection and exits copy mode.""" + self.tui.copy_mode = True + self.tui.visual_sel_start = 5 + self.tui.copy_cursor_line = 8 + + # First Esc clears selection + self.tui._handle_copy_mode_key(27) + self.assertIsNone(self.tui.visual_sel_start) + self.assertTrue(self.tui.copy_mode) + + # Second Esc exits copy mode + self.tui._handle_copy_mode_key(27) + self.assertFalse(self.tui.copy_mode) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..29e75ad --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,207 @@ +import unittest +import time +from unittest.mock import MagicMock +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "bin")) + +import muse_tui + +class TestNotificationCounters(unittest.TestCase): + def setUp(self): + self.dm = muse_tui.FleetDataManager() + # Mock background loops from interfering with testing + self.dm.running = False + self.dm.read_msg_counts = {} + # Isolate threads and history caches for tests + self.dm.nodes = ["pip", "646"] + self.dm.threads_cache = {"pip": [], "646": []} + self.dm.history_cache = {} + + def test_unread_count_calculation(self): + node = "pip" + tid = "test-thread-123" + key = (node, tid) + + # Initially no messages -> unread is 0 + self.assertEqual(self.dm.get_unread_count(node, tid), 0) + + # 5 messages arrive in cache + self.dm.history_cache[key] = [{"text": f"msg {i}"} for i in range(5)] + self.assertEqual(self.dm.get_unread_count(node, tid), 5) + + # Read 3 messages + self.dm.mark_thread_read(node, tid, count=3) + self.assertEqual(self.dm.get_unread_count(node, tid), 2) + + # Read all messages + self.dm.mark_thread_read(node, tid) + self.assertEqual(self.dm.get_unread_count(node, tid), 0) + + def test_node_and_fleet_unread_total(self): + # Configure test threads for pip and 646 + self.dm.threads_cache["pip"] = [ + {"session_id": "pip-chat-1", "is_main": True}, + {"session_id": "pip-chat-2", "is_main": False}, + ] + self.dm.threads_cache["646"] = [ + {"session_id": "646-chat-1", "is_main": True}, + ] + + # Populate message histories + self.dm.history_cache[("pip", "pip-chat-1")] = [{"text": "1"}, {"text": "2"}] + self.dm.history_cache[("pip", "pip-chat-2")] = [{"text": "a"}, {"text": "b"}, {"text": "c"}] + self.dm.history_cache[("646", "646-chat-1")] = [{"text": "x"}] + + # Before reading: pip has 5, 646 has 1, total fleet is 6 + self.assertEqual(self.dm.get_node_unread_total("pip"), 5) + self.assertEqual(self.dm.get_node_unread_total("646"), 1) + self.assertEqual(self.dm.get_fleet_unread_total(), 6) + + # Read pip-chat-1 + self.dm.mark_thread_read("pip", "pip-chat-1") + self.assertEqual(self.dm.get_node_unread_total("pip"), 3) + self.assertEqual(self.dm.get_fleet_unread_total(), 4) + + def test_dwell_clearing_logic(self): + tui = muse_tui.MuseTUI(MagicMock()) + tui.data.running = False + active_node = "muse" + active_tid = "muse-thread-abc" + + tui.data.active_node = active_node + tui.data.active_thread_id = active_tid + tui.data.history_cache[(active_node, active_tid)] = [{"text": "msg"}] * 4 + self.assertEqual(tui.data.get_unread_count(active_node, active_tid), 4) + + # Simulated dwell < 5.0 seconds + tui.cur_chat_enter_time = time.time() - 2.0 + tui.last_dwell_check_target = (active_node, active_tid) + # Should not clear yet + if time.time() - tui.cur_chat_enter_time >= 5.0: + tui.data.mark_thread_read(active_node, active_tid) + self.assertEqual(tui.data.get_unread_count(active_node, active_tid), 4) + + # Simulated dwell >= 5.0 seconds + tui.cur_chat_enter_time = time.time() - 5.5 + if time.time() - tui.cur_chat_enter_time >= 5.0: + tui.data.mark_thread_read(active_node, active_tid) + self.assertEqual(tui.data.get_unread_count(active_node, active_tid), 0) + + def test_agent_sorting_and_initial_selection(self): + # Configure nodes + self.dm.nodes = ["pip", "muse", "opm", "646"] + self.dm.threads_cache = { + "pip": [{"session_id": "p1"}], + "muse": [{"session_id": "m1"}], + "opm": [{"session_id": "o1"}], + "646": [{"session_id": "c1"}], + } + # Give opm 5 unread messages, muse 2 unreads, pip 0, 646 0 + self.dm.history_cache = { + ("opm", "o1"): [{"text": f"msg {i}"} for i in range(5)], + ("muse", "m1"): [{"text": f"msg {i}"} for i in range(2)], + } + self.dm.agent_interactions = {"pip": 100.0, "646": 200.0, "muse": 50.0, "opm": 0.0} + + self.dm.sort_nodes() + # opm should be first (highest unread = 5) + # muse should be second (unread = 2) + # 646 should be third (interaction = 200.0) + # pip should be fourth (interaction = 100.0) + self.assertEqual(self.dm.nodes, ["opm", "muse", "646", "pip"]) + + # Default initial_node=None in MuseTUI picks nodes[0] (top of list) and its Main Chat + tui = muse_tui.MuseTUI(MagicMock(), initial_node=None) + tui.data.running = False + self.assertEqual(tui.data.active_node, tui.data.nodes[0]) + self.assertIsNotNone(tui.data.active_thread_id) + top_node = tui.data.nodes[0] + top_threads = {t.get("session_id") for t in tui.data.threads_cache.get(top_node, [])} + self.assertIn(tui.data.active_thread_id, top_threads) + + def test_reply_workflow(self): + tui = muse_tui.MuseTUI(MagicMock()) + tui.data.running = False + + msg = { + "role": "assistant", + "message_id": "msg-987654321", + "seq": 42, + "text": "Please confirm deployment parameters before we proceed.", + } + + # 1. Trigger reply + ok = tui.trigger_reply_to_message(msg) + self.assertTrue(ok) + self.assertIsNotNone(tui.reply_target) + self.assertEqual(tui.reply_target["seq"], 42) + self.assertEqual(tui.reply_target["role"], "assistant") + self.assertEqual(tui.editor_mode, "INSERT") + + # 2. Cancel reply with Esc sequence + tui._read_escape_sequence = MagicMock(return_value=[]) # standalone Esc + tui._handle_escape_sequence() + self.assertIsNone(tui.reply_target) + self.assertEqual(tui.editor_mode, "NORMAL") + + # 3. Trigger reply again and execute input line + tui.trigger_reply_to_message(msg) + dispatched_messages = [] + tui._async_send_message = lambda node, tid, text: dispatched_messages.append((node, tid, text)) + + tui._execute_input_line("Confirmed, deploy now.") + self.assertIsNone(tui.reply_target) + self.assertEqual(len(dispatched_messages), 1) + node, tid, sent_text = dispatched_messages[0] + self.assertIn("> Replying to ASSISTANT (seq:42):", sent_text) + self.assertIn("Please confirm deployment parameters before we proceed.", sent_text) + self.assertIn("Confirmed, deploy now.", sent_text) + + def test_sidechat_notifications_and_transcript_divider(self): + tui = muse_tui.MuseTUI(MagicMock()) + tui.data.running = False + active_node = "muse" + active_tid = "sidechat-worker-42" + tui.data.active_node = active_node + tui.data.active_thread_id = active_tid + + tui.data.threads_cache[active_node] = [ + {"session_id": "main-chat", "is_main": True, "title": "Main Chat"}, + {"session_id": active_tid, "is_main": False, "title": "Worker Task 42"}, + {"session_id": "sidechat-worker-43", "is_main": False, "title": "Worker Task 43"}, + ] + + # 3 total messages, read count is 1 -> 2 unread messages in active sidechat + tui.data.history_cache[(active_node, active_tid)] = [ + {"role": "user", "text": "initial prompt", "message_id": "m1"}, + {"role": "assistant", "text": "update 1", "message_id": "m2"}, + {"role": "assistant", "text": "update 2", "message_id": "m3"}, + ] + tui.data.read_msg_counts[(active_node, active_tid)] = 1 + + self.assertEqual(tui.data.get_unread_count(active_node, active_tid), 2) + self.assertEqual(tui.data.get_node_unread_total(active_node), 2) + + # Mock safe_addstr to capture rendered strings + rendered_texts = [] + tui.safe_addstr = lambda win, y, x, text, attr=0: rendered_texts.append(text) + + # 1. Render sidebar + tui._render_sidebar(0, 0, 30, 32) + # Should contain ●2 inline notification badge + has_badge = any("●2" in s for s in rendered_texts) + self.assertTrue(has_badge, "Sidechats list row or header must render inline ●2 unread badge") + + # 2. Render transcript + rendered_texts.clear() + tui._render_transcript(0, 32, 30, 80) + # Should render the unread divider banner or unread pill + has_div = any("2 NEW UNREAD MESSAGE" in s for s in rendered_texts) + has_hdr_pill = any("[● 2 new]" in s for s in rendered_texts) + self.assertTrue(has_div or has_hdr_pill, "Transcript must display unread banner or pill") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_paste_handling.py b/tests/test_paste_handling.py new file mode 100644 index 0000000..bd2ade0 --- /dev/null +++ b/tests/test_paste_handling.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +test_paste_handling.py — Unit tests for long string / chunk paste handling in MuseTUI: +1. Multi-line chunk pasting preserves newlines and does not send immediately. +2. Enter key submits the entire chunk as a single message and single history item. +3. Bracketed paste sequences (\033[200~ ... \033[201~) are correctly parsed and captured. +4. Alt+Enter (ESC + Enter) inserts newlines for multiline composition. +5. Unbracketed rapid paste bursts preserve newlines without line-by-line send. +6. Arrow keys navigate lines within multiline buffer. +7. UTF-8 multibyte characters are correctly decoded and inserted into buffer. +8. Multiline messages or file paths starting with '/' are delivered as messages. +""" + +import unittest +from unittest.mock import MagicMock, patch +import importlib.util +from pathlib import Path +import curses + +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 TestPasteHandling(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.tui.safe_addstr = MagicMock() + self.tui.data.running = False # Keep background thread stopped + self.tui.data.active_node = "muse" + self.tui.data.active_thread_id = "test-session" + self.tui.data.active_thread_title = "Main Chat" + self.tui.data.threads_cache["muse"] = [{"session_id": "test-session", "is_main": True}] + + def test_insert_pasted_text_multiline(self): + """Pasting a multiline chunk enters input_buf in full without sending.""" + chunk = "Line 1: Hello\nLine 2: World\nLine 3: Agent instruction" + self.tui.editor_mode = "NORMAL" + self.tui.input_buf = "" + self.tui.input_cursor = 0 + + self.tui._insert_pasted_text(chunk) + + self.assertEqual(self.tui.editor_mode, "INSERT") + self.assertEqual(self.tui.input_buf, chunk) + self.assertEqual(self.tui.input_cursor, len(chunk)) + self.assertIn("3 lines", self.tui.toast_msg) + # Verify nothing was sent yet + self.assertEqual(len(self.tui.input_history), 0) + + def test_multiline_send_as_single_chunk(self): + """Submitting a multiline buffer records ONE history entry and sends ONCE.""" + chunk = "Line 1: def test():\n print('NetVM')\nLine 3: return True" + self.tui.editor_mode = "INSERT" + self.tui.input_buf = chunk + self.tui.input_cursor = len(chunk) + + sent_messages = [] + self.tui._async_send_message = lambda node, tid, text: sent_messages.append((node, tid, text)) + + # Simulate user pressing Enter on the completed multiline chunk + # With no characters waiting in stdin (peek_c = -1) + self.mock_stdscr.getch.return_value = -1 + ret = self.tui._handle_insert_key(10) + + self.assertTrue(ret) + self.assertEqual(self.tui.input_buf, "") + self.assertEqual(len(self.tui.input_history), 1) + self.assertEqual(self.tui.input_history[0], chunk) + self.assertEqual(len(sent_messages), 1) + self.assertEqual(sent_messages[0][0], "muse") + self.assertEqual(sent_messages[0][1], "test-session") + self.assertEqual(sent_messages[0][2], chunk) + + def test_bracketed_paste_capture(self): + """\033[200~ bracketed paste reads until \033[201~ and captures content.""" + payload = "Multi-line code snippet:\n```python\nx = 42\nprint(x)\n```" + # Simulate stream of getch returns: payload bytes followed by \033[201~ then -1 + stream = list(payload.encode("utf-8")) + [27, ord('['), ord('2'), ord('0'), ord('1'), ord('~')] + + call_idx = 0 + def mock_getch(): + nonlocal call_idx + if call_idx < len(stream): + val = stream[call_idx] + call_idx += 1 + return val + return -1 + + self.mock_stdscr.getch.side_effect = mock_getch + self.tui.input_buf = "" + self.tui.input_cursor = 0 + + handled = self.tui._handle_bracketed_paste() + self.assertTrue(handled) + self.assertEqual(self.tui.input_buf, payload) + self.assertEqual(self.tui.input_cursor, len(payload)) + self.assertEqual(self.tui.editor_mode, "INSERT") + + def test_alt_enter_inserts_newline(self): + """Alt+Enter (ESC + 10 or 13) inserts newline into input_buf.""" + self.tui.editor_mode = "INSERT" + self.tui.input_buf = "First line" + self.tui.input_cursor = len(self.tui.input_buf) + + # Mock _read_escape_sequence returning [10] (Enter) + with patch.object(self.tui, "_read_escape_sequence", return_value=[10]): + res = self.tui._handle_escape_sequence() + + self.assertTrue(res) + self.assertEqual(self.tui.input_buf, "First line\n") + self.assertEqual(self.tui.input_cursor, len("First line\n")) + + def test_multiline_cursor_navigation(self): + """Up and Down arrow navigate across lines inside multiline input_buf.""" + self.tui.editor_mode = "INSERT" + self.tui.input_buf = "Line A\nLine B\nLine C" + # Place cursor at end of "Line C" + self.tui.input_cursor = len(self.tui.input_buf) + + # Press Up: cursor should move to "Line B" + self.tui._handle_insert_key(curses.KEY_UP) + curr_text = self.tui.input_buf[:self.tui.input_cursor] + self.assertTrue(curr_text.endswith("Line B") or "Line B" in curr_text) + + # Press Up again: cursor should move to "Line A" + self.tui._handle_insert_key(curses.KEY_UP) + curr_text = self.tui.input_buf[:self.tui.input_cursor] + self.assertTrue(curr_text.startswith("Line A")) + + # Press Down: cursor should move back down to "Line B" + self.tui._handle_insert_key(curses.KEY_DOWN) + curr_text = self.tui.input_buf[:self.tui.input_cursor] + self.assertIn("Line B", curr_text) + + def test_unicode_multibyte_input(self): + """Multi-byte UTF-8 bytes assemble into correct unicode characters.""" + self.tui.editor_mode = "INSERT" + self.tui.input_buf = "Test: " + self.tui.input_cursor = len(self.tui.input_buf) + + # 'é' is 0xC3, 0xA9 (195, 169) + self.tui._handle_insert_key(195) + self.assertEqual(self.tui.input_buf, "Test: ") # incomplete byte, not added yet + self.tui._handle_insert_key(169) + self.assertEqual(self.tui.input_buf, "Test: é") + self.assertEqual(self.tui.input_cursor, len("Test: é")) + + def test_path_starting_with_slash_delivers_as_message(self): + """A string starting with a file path like '/var/log/syslog' is sent as message.""" + line = "/var/log/syslog contains error details" + sent = [] + self.tui._async_send_message = lambda node, tid, text: sent.append(text) + + self.tui._execute_input_line(line) + self.assertEqual(len(sent), 1) + self.assertEqual(sent[0], line) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prompts.py b/tests/test_prompts.py new file mode 100644 index 0000000..6b6bd60 --- /dev/null +++ b/tests/test_prompts.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +""" +test_prompts.py — Comprehensive unit and integration test suite for: +1. PromptManager persistence, defaults, formulation, deletion, and pin sorting. +2. Compact persistent prompt shelf in View 1 (Agent Chat) and 1-click auto-fill. +3. Expanded Prompt Library modal ('P') with Vim hjkl navigation, preview scroll, and Enter/click fill. +4. Searchable chat history sends ('s' / Ctrl-R) aggregating session and history cache. +5. 'p' pinning chat sent messages to the prompt fill / list in history search and transcript view. +6. Horizontal scrolling of prompt shelf via chips bounds, navigation buttons, wheel, and '[' / ']'. +""" + +import unittest +from unittest.mock import MagicMock, patch +import importlib.util +from pathlib import Path +import tempfile +import time +import json +import curses + +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 TestPromptManager(unittest.TestCase): + """Test suite covering PromptManager storage, formulation, and pin ranking.""" + + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.storage_file = Path(self.temp_dir.name) / "test_prompts.json" + self.pm = muse_mod.PromptManager(storage_path=self.storage_file) + + def tearDown(self): + self.temp_dir.cleanup() + + def test_default_prompts_seeded_and_persisted(self): + """Verify PromptManager initializes with default prompts and writes to disk.""" + self.assertTrue(self.storage_file.exists()) + prompts = self.pm.get_sorted_prompts() + self.assertGreaterEqual(len(prompts), 5) + titles = [p["title"] for p in prompts] + self.assertIn("Fleet Audit", titles) + self.assertIn("Work Order", titles) + self.assertIn("CDP Inspect", titles) + + def test_add_and_delete_prompt(self): + """Verify formulation of custom prompt skills and subsequent deletion.""" + new_p = self.pm.add_prompt( + title="Custom Health Probe", + prompt_text="Run detailed tcpdump and box health probes on all nodes.", + tags=["probe", "custom"], + pinned=True + ) + self.assertEqual(new_p["title"], "Custom Health Probe") + self.assertTrue(new_p["pinned"]) + + # Check persistence + reloaded = muse_mod.PromptManager(storage_path=self.storage_file) + prompts = reloaded.get_sorted_prompts() + self.assertTrue(any(p["id"] == new_p["id"] for p in prompts)) + + # Delete + self.assertTrue(self.pm.delete_prompt(new_p["id"])) + prompts_after = self.pm.get_sorted_prompts() + self.assertFalse(any(p["id"] == new_p["id"] for p in prompts_after)) + + def test_toggle_pin_prompt(self): + """Verify toggle_pin_prompt alters pinned flag and affects sorted order.""" + prompts = self.pm.get_sorted_prompts() + target = prompts[-1] # Get unpinned prompt + target_id = target["id"] + + new_state = self.pm.toggle_pin_prompt(target_id) + self.assertTrue(new_state) + + # In sorted prompts, newly pinned should be among first + sorted_p = self.pm.get_sorted_prompts() + first_few_ids = [p["id"] for p in sorted_p[:4]] + self.assertIn(target_id, first_few_ids) + + def test_pin_chat_send(self): + """Verify pinning a chat sent message formats title, pins to top, and persists.""" + sent_text = "Investigate high CDP drop rates and verify routing table on node 646." + prompt, is_new = self.pm.pin_chat_send(sent_text, node="646") + self.assertTrue(is_new) + self.assertTrue(prompt["pinned"]) + self.assertEqual(prompt["prompt"], sent_text) + self.assertIn("Investigate high CDP", prompt["title"]) + + # Pinned chat send should be first in sorted prompts + sorted_p = self.pm.get_sorted_prompts() + self.assertEqual(sorted_p[0]["id"], prompt["id"]) + + # Pinning same text again should mark pinned without creating duplicate + p2, is_new2 = self.pm.pin_chat_send(sent_text, node="646") + self.assertFalse(is_new2) + self.assertEqual(p2["id"], prompt["id"]) + + def test_toggle_pin_chat_send(self): + """Verify toggle_pin_chat_send flips pinned state between True and False.""" + sent_text = "Investigate high CDP drop rates and verify routing table." + p1, is_new = self.pm.toggle_pin_chat_send(sent_text, node="646") + self.assertTrue(is_new) + self.assertTrue(p1["pinned"]) + + # Toggle again: should unpin + p2, is_new2 = self.pm.toggle_pin_chat_send(sent_text, node="646") + self.assertFalse(is_new2) + self.assertFalse(p2["pinned"]) + + # Toggle again: should re-pin + p3, is_new3 = self.pm.toggle_pin_chat_send(sent_text, node="646") + self.assertFalse(is_new3) + self.assertTrue(p3["pinned"]) + + +class TestPromptTUIIntegration(unittest.TestCase): + """Test suite covering TUI compact shelf, click auto-fill, modals, and sends search.""" + + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.storage_file = Path(self.temp_dir.name) / "test_prompts.json" + + 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.tui.data.stop() + self.tui.safe_addstr = MagicMock() + + # Replace prompt manager with isolated instance + self.tui.prompt_manager = muse_mod.PromptManager(storage_path=self.storage_file) + self.tui.prompts = self.tui.prompt_manager.get_sorted_prompts() + + # Clear history cache to isolate unit test data + self.tui.data.history_cache.clear() + + # Seed sample chat history + self.tui.data.history_cache[("muse", "thread_1")] = [ + {"role": "user", "text": "Check all docker network bridges.", "message_id": "m1"}, + {"role": "assistant", "text": "All docker network bridges are operational.", "message_id": "m2"}, + {"role": "user", "text": "Inspect CDP latency on pip and opm.", "message_id": "m3"}, + ] + self.tui.data.active_node = "muse" + self.tui.data.active_thread_id = "thread_1" + self.tui.data.active_thread_title = "thread_1" + self.tui.input_history = ["Recent typed command from session"] + + def tearDown(self): + self.temp_dir.cleanup() + + def test_compact_prompt_shelf_render_and_chip_bounds(self): + """Verify _render_prompt_shelf populates chip bounds and button boundaries.""" + self.tui._render_prompt_shelf(y=24, x=0, h=2, w=100) + bounds = self.tui._prompt_chip_bounds + self.assertGreater(len(bounds), 0, "Prompt chips should be registered with bounding boxes") + + first_start, first_end, first_prompt = bounds[0] + self.assertGreater(first_end, first_start) + self.assertIn("title", first_prompt) + self.assertTrue(hasattr(self.tui, "_btn_expand_bounds")) + self.assertTrue(hasattr(self.tui, "_btn_sends_bounds")) + + def test_one_click_auto_fill_from_shelf(self): + """Verify clicking a prompt chip immediately auto-fills input_buf and sets INSERT mode.""" + self.tui._render_prompt_shelf(y=24, x=0, h=2, w=100) + first_start, first_end, first_prompt = self.tui._prompt_chip_bounds[0] + + # Simulate click on chip + click_x = (first_start + first_end) // 2 + click_y = 25 # Row 1 of prompt shelf (chip row) + res = self.tui._handle_mouse(mx=click_x, my=click_y, bstate=curses.BUTTON1_CLICKED) + self.assertTrue(res) + + # Input buffer should contain prompt text + self.assertEqual(self.tui.input_buf, first_prompt["prompt"]) + self.assertEqual(self.tui.input_cursor, len(first_prompt["prompt"])) + self.assertEqual(self.tui.editor_mode, "INSERT") + self.assertIn("Filled prompt", self.tui.toast_msg) + + def test_expand_prompts_modal_and_vim_navigation(self): + """Verify pressing 'P' expands modal and hjkl keys navigate and Enter fills.""" + # Press capital 'P' in NORMAL mode + self.tui.editor_mode = "NORMAL" + self.tui.focus_pane = "transcript" + res = self.tui._handle_key(ord('P')) + self.assertTrue(res) + self.assertEqual(self.tui.modal, "prompts") + self.assertEqual(self.tui.prompt_sel_idx, 0) + + # 'j' moves down + self.tui._handle_key(ord('j')) + self.assertEqual(self.tui.prompt_sel_idx, 1) + + # 'k' moves up + self.tui._handle_key(ord('k')) + self.assertEqual(self.tui.prompt_sel_idx, 0) + + # 'G' jumps to last + self.tui._handle_key(ord('G')) + self.assertEqual(self.tui.prompt_sel_idx, len(self.tui.prompts) - 1) + + # 'g' jumps to first + self.tui._handle_key(ord('g')) + self.assertEqual(self.tui.prompt_sel_idx, 0) + + # Enter fills selected prompt and switches to INSERT + expected_text = self.tui.prompts[0]["prompt"] + self.tui._handle_key(10) # Enter + self.assertIsNone(self.tui.modal) + self.assertEqual(self.tui.input_buf, expected_text) + self.assertEqual(self.tui.editor_mode, "INSERT") + + def test_searchable_chat_history_sends(self): + """Verify get_chat_sends aggregates past sends and filters with query.""" + sends = self.tui.get_chat_sends() + self.assertGreaterEqual(len(sends), 3) + texts = [s["text"] for s in sends] + self.assertIn("Recent typed command from session", texts) + self.assertIn("Check all docker network bridges.", texts) + self.assertIn("Inspect CDP latency on pip and opm.", texts) + + # Filter query + filtered = self.tui.get_chat_sends("docker") + self.assertEqual(len(filtered), 1) + self.assertEqual(filtered[0]["text"], "Check all docker network bridges.") + + def test_pin_chat_send_to_prompt_fill_in_history_search(self): + """Verify 'p' in history_search toggles pin and unpin for highlighted send.""" + self.tui.modal = "history_search" + self.tui.modal_input_buf = "" + self.tui.history_search_sel = 0 + + initial_prompts_count = len(self.tui.prompts) + sends = self.tui.get_chat_sends() + target_send_text = sends[0]["text"] + + # Press 'p': 1st time pins + res = self.tui._handle_key(ord('p')) + self.assertTrue(res) + + self.assertEqual(len(self.tui.prompts), initial_prompts_count + 1) + top_prompt = self.tui.prompts[0] + self.assertTrue(top_prompt["pinned"]) + self.assertEqual(top_prompt["prompt"], target_send_text) + self.assertIn("Pinned", self.tui.toast_msg) + + # Press 'p': 2nd time UNPINS + res2 = self.tui._handle_key(ord('p')) + self.assertTrue(res2) + + target_prompt_obj = [p for p in self.tui.prompt_manager.prompts if p["prompt"] == target_send_text][0] + self.assertFalse(target_prompt_obj["pinned"]) + self.assertIn("Unpinned", self.tui.toast_msg) + + # Press 'p': 3rd time RE-PINS + res3 = self.tui._handle_key(ord('p')) + self.assertTrue(res3) + self.assertTrue(target_prompt_obj["pinned"]) + self.assertIn("Pinned", self.tui.toast_msg) + + def test_history_search_mouse_toggle_pin(self): + """Verify clicking the pin column in history_search toggles pin/unpin.""" + self.tui.modal = "history_search" + self.tui.modal_input_buf = "" + self.tui.history_search_sel = 0 + + sends = self.tui.get_chat_sends() + target_text = sends[0]["text"] + + h, w = self.tui.stdscr.getmaxyx() + modal_w = min(74, w - 6) + modal_h = min(20, h - 4) + top_y = (h - modal_h) // 2 + left_x = (w - modal_w) // 2 + + # 1st click pins + click_y = top_y + 4 + click_x = left_x + 4 + handled = self.tui._handle_mouse(click_x, click_y, curses.BUTTON1_CLICKED) + self.assertTrue(handled) + target_prompt = [p for p in self.tui.prompt_manager.prompts if p["prompt"] == target_text][0] + self.assertTrue(target_prompt["pinned"]) + self.assertIn("Pinned", self.tui.toast_msg) + + # 2nd click unpins + handled2 = self.tui._handle_mouse(click_x, click_y, curses.BUTTON1_CLICKED) + self.assertTrue(handled2) + self.assertFalse(target_prompt["pinned"]) + self.assertIn("Unpinned", self.tui.toast_msg) + + def test_transcript_view_p_pins_latest_send(self): + """Verify pressing 'p' on transcript pins the current thread's latest sent message.""" + self.tui.modal = None + self.tui.editor_mode = "NORMAL" + self.tui.focus_pane = "transcript" + + initial_count = len(self.tui.prompts) + # In current thread, latest user message is "Inspect CDP latency on pip and opm." + res = self.tui._handle_key(ord('p')) + self.assertTrue(res) + + self.assertEqual(len(self.tui.prompts), initial_count + 1) + self.assertEqual(self.tui.prompts[0]["prompt"], "Inspect CDP latency on pip and opm.") + self.assertTrue(self.tui.prompts[0]["pinned"]) + self.assertIn("Pinned", self.tui.toast_msg) + + def test_sidebar_p_still_toggles_thread_pin(self): + """Verify pressing 'p' when focus is on sidebar preserves thread pinning.""" + self.tui.modal = None + self.tui.editor_mode = "NORMAL" + self.tui.focus_pane = "sidebar" + + # Ensure thread_1 is unpinned initially + self.tui.data.pinned_threads.setdefault("muse", set()).discard("thread_1") + + # Mock threads cache + self.tui.data.threads_cache["muse"] = [ + {"session_id": "thread_1", "title": "Test Chat", "is_main": False} + ] + self.tui.thread_sel_idx = 0 + + res = self.tui._handle_key(ord('p')) + self.assertTrue(res) + self.assertIn("thread_1", self.tui.data.pinned_threads.get("muse", set())) + self.assertIn("Pinned chat", self.tui.toast_msg) + + # Clean up disk state + self.tui.data.toggle_pin_thread("muse", "thread_1") + + def test_bracket_keys_scroll_prompt_shelf(self): + """Verify '[' and ']' scroll the compact prompt shelf horizontally.""" + self.tui.editor_mode = "NORMAL" + self.tui.focus_pane = "transcript" + self.tui.prompt_scroll_idx = 0 + + self.tui._handle_key(ord(']')) + self.assertEqual(self.tui.prompt_scroll_idx, 1) + + self.tui._handle_key(ord('[')) + self.assertEqual(self.tui.prompt_scroll_idx, 0) + + def test_slash_prompt_and_sends_commands(self): + """Verify /prompt save and /sends slash commands formulate and open modals.""" + self.tui.input_buf = "Check loop telemetry on all nodes" + self.tui._execute_input_line("/prompt save Telemetry Check") + + prompts = self.tui.prompt_manager.get_sorted_prompts() + saved = [p for p in prompts if p["title"] == "Telemetry Check"] + self.assertEqual(len(saved), 1) + self.assertEqual(saved[0]["prompt"], "Check loop telemetry on all nodes") + + # /prompt add + self.tui._execute_input_line("/prompt add Quick Probe | ping all nodes") + saved_add = [p for p in self.tui.prompt_manager.get_sorted_prompts() if p["title"] == "Quick Probe"] + self.assertEqual(len(saved_add), 1) + self.assertEqual(saved_add[0]["prompt"], "ping all nodes") + + # /prompt del + self.tui._execute_input_line(f"/prompt del {saved_add[0]['id']}") + saved_del = [p for p in self.tui.prompt_manager.get_sorted_prompts() if p["title"] == "Quick Probe"] + self.assertEqual(len(saved_del), 0) + + # /sends opens history search + self.tui._execute_input_line("/sends docker") + self.assertEqual(self.tui.modal, "history_search") + self.assertEqual(self.tui.modal_input_buf, "docker") + + def test_shelf_buttons_mouse_clicks(self): + """Verify clicking buttons on row 0 of prompt shelf opens modals and scrolls.""" + self.tui._render_prompt_shelf(y=24, x=0, h=2, w=100) + + # Click [P:Expand] + exp_x = (self.tui._btn_expand_bounds[0] + self.tui._btn_expand_bounds[1]) // 2 + self.tui._handle_mouse(mx=exp_x, my=24, bstate=curses.BUTTON1_CLICKED) + self.assertEqual(self.tui.modal, "prompts") + self.tui.modal = None + + # Click [🔍 Sends] + sends_x = (self.tui._btn_sends_bounds[0] + self.tui._btn_sends_bounds[1]) // 2 + self.tui._handle_mouse(mx=sends_x, my=24, bstate=curses.BUTTON1_CLICKED) + self.assertEqual(self.tui.modal, "history_search") + self.tui.modal = None + + # Click [►] + next_x = (self.tui._btn_next_bounds[0] + self.tui._btn_next_bounds[1]) // 2 + self.tui.prompt_scroll_idx = 0 + self.tui._handle_mouse(mx=next_x, my=24, bstate=curses.BUTTON1_CLICKED) + self.assertEqual(self.tui.prompt_scroll_idx, 1) + + # Click [◄] + prev_x = (self.tui._btn_prev_bounds[0] + self.tui._btn_prev_bounds[1]) // 2 + self.tui._handle_mouse(mx=prev_x, my=24, bstate=curses.BUTTON1_CLICKED) + self.assertEqual(self.tui.prompt_scroll_idx, 0) + + def test_mouse_wheel_scrolling_on_shelf(self): + """Verify mouse wheel up/down over prompt shelf scrolls chips.""" + self.tui._render_prompt_shelf(y=24, x=0, h=2, w=100) + self.tui.prompt_scroll_idx = 0 + + # Wheel down over prompt shelf (my=25) + self.tui._handle_mouse(mx=50, my=25, bstate=0x200000) + self.assertEqual(self.tui.prompt_scroll_idx, 1) + + # Wheel up over prompt shelf (my=25) + self.tui._handle_mouse(mx=50, my=25, bstate=0x10000) + self.assertEqual(self.tui.prompt_scroll_idx, 0) + + def test_prompt_modal_pin_toggle_and_delete(self): + """Verify 'p' toggles pin and 'd' deletes in prompt modal.""" + self.tui.modal = "prompts" + self.tui.prompt_sel_idx = 0 + target = self.tui.prompts[0] + initial_pin = target.get("pinned", False) + + # Press 'p' to toggle pin + self.tui._handle_key(ord('p')) + toggled = [p for p in self.tui.prompts if p["id"] == target["id"]][0] + self.assertEqual(toggled.get("pinned", False), not initial_pin) + + # Now select the prompt and press 'd' to delete + target_idx = [i for i, p in enumerate(self.tui.prompts) if p["id"] == target["id"]][0] + self.tui.prompt_sel_idx = target_idx + self.tui._handle_key(ord('d')) + self.assertFalse(any(p["id"] == target["id"] for p in self.tui.prompts)) + + def test_formulate_prompt_modal(self): + """Verify prompt_formulate modal creates and saves new prompt.""" + self.tui.modal = "prompt_formulate" + self.tui.input_buf = "Draft message from input composer" + self.tui.modal_input_buf = "New Diagnostic" + self.tui.modal_input_cursor = len(self.tui.modal_input_buf) + + # Press Enter + self.tui._handle_key(10) + self.assertIsNone(self.tui.modal) + + # Check prompt exists in manager + matching = [p for p in self.tui.prompts if p["title"] == "New Diagnostic"] + self.assertEqual(len(matching), 1) + self.assertEqual(matching[0]["prompt"], "Draft message from input composer") + self.assertTrue(matching[0]["pinned"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rate_limits.py b/tests/test_rate_limits.py new file mode 100644 index 0000000..d693b99 --- /dev/null +++ b/tests/test_rate_limits.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +test_rate_limits.py — Tests for rate-limiting, adaptive polling, backoff, and circuit breaker. +""" +import curses +import importlib.util +import json +import os +import sys +import time +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +REPO_ROOT = Path(__file__).resolve().parent.parent +MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py" + +spec = importlib.util.spec_from_file_location("muse_tui_rl", MUSE_TUI_PATH) +muse_tui_rl = importlib.util.module_from_spec(spec) +sys.modules["muse_tui_rl"] = muse_tui_rl +spec.loader.exec_module(muse_tui_rl) + +FleetDataManager = muse_tui_rl.FleetDataManager +MuseTUI = muse_tui_rl.MuseTUI +run_command_isolated = muse_tui_rl.run_command_isolated + + +class TestRateLimitingAndBackoff(unittest.TestCase): + def setUp(self): + # In tests, start_poller is disabled by default + self.mgr = FleetDataManager(start_poller=False) + + def test_poller_disabled_in_tests_by_default(self): + """FleetDataManager does not leak background poller threads during unit test runs.""" + self.assertIsNone(self.mgr.poller_thread) + + def test_rate_limit_cooldown_marking(self): + """Marking a node as rate-limited sets cooldown window and rate_limited flag.""" + self.assertFalse(self.mgr.is_node_rate_limited("muse")) + self.assertEqual(self.mgr.get_node_cooldown_remaining("muse"), 0) + + self.mgr.mark_node_rate_limited("muse", cooldown_seconds=30.0, reason="HTTP 429") + self.assertTrue(self.mgr.is_node_rate_limited("muse")) + self.assertGreater(self.mgr.get_node_cooldown_remaining("muse"), 20) + self.assertTrue(self.mgr.node_status["muse"]["rate_limited"]) + + def test_tiered_backoff_progression(self): + """Consecutive rate-limit occurrences escalate (30s -> 60s -> 120s max), and clear resets.""" + # Incident 1: 30s + self.mgr.mark_node_rate_limited("pip", reason="429 first") + cd1 = self.mgr.get_node_cooldown_remaining("pip") + self.assertTrue(25 <= cd1 <= 31) + + # Incident 2: 60s + self.mgr.mark_node_rate_limited("pip", reason="429 second") + cd2 = self.mgr.get_node_cooldown_remaining("pip") + self.assertTrue(55 <= cd2 <= 61) + + # Incident 3: 120s max + self.mgr.mark_node_rate_limited("pip", reason="429 third") + cd3 = self.mgr.get_node_cooldown_remaining("pip") + self.assertTrue(110 <= cd3 <= 121) + + # Success resets backoff + self.mgr.clear_node_rate_limit("pip") + self.assertFalse(self.mgr.is_node_rate_limited("pip")) + self.assertEqual(self.mgr.get_node_cooldown_remaining("pip"), 0) + self.assertEqual(self.mgr.rate_limit_consecutive["pip"], 0) + + def test_retry_after_header_parsing(self): + """When output includes Retry-After, that explicit duration is used.""" + with patch.object(muse_tui_rl, "run_command_isolated", return_value=(1, "", "Error 429: Too Many Requests. Retry-After: 85")): + self.mgr._fetch_history("646", "thread-xyz") + self.assertTrue(self.mgr.is_node_rate_limited("646")) + cd = self.mgr.get_node_cooldown_remaining("646") + self.assertTrue(80 <= cd <= 86) + + def test_rate_limited_node_skips_fetches(self): + """Rate-limited nodes are skipped by _fetch_history and _run_fetch_threads.""" + self.mgr.mark_node_rate_limited("muse", cooldown_seconds=60.0) + + with patch.object(muse_tui_rl, "run_command_isolated") as mock_cmd: + self.mgr._fetch_history("muse", "thread-123") + mock_cmd.assert_not_called() + + self.mgr._run_fetch_threads("muse") + mock_cmd.assert_not_called() + + def test_rate_limit_detection_from_command_output(self): + """When CLI command outputs 429 or rate limit text, node is placed on cooldown.""" + with patch.object(muse_tui_rl, "run_command_isolated", return_value=(1, "", "Error 429: Too Many Requests")): + self.mgr._fetch_history("pip", "thread-abc") + self.assertTrue(self.mgr.is_node_rate_limited("pip")) + self.assertGreater(self.mgr.get_node_cooldown_remaining("pip"), 0) + + def test_record_activity_updates_timestamp(self): + """record_activity updates last_user_activity timestamp.""" + old_time = self.mgr.last_user_activity + time.sleep(0.01) + self.mgr.record_activity() + self.assertGreaterEqual(self.mgr.last_user_activity, old_time) + + def test_run_command_isolated_handles_quick_command(self): + """run_command_isolated runs a command and returns returncode, stdout, stderr.""" + rc, stdout, stderr = run_command_isolated(["echo", "hello rate limit"], timeout=2.0) + self.assertEqual(rc, 0) + self.assertIn("hello rate limit", stdout) + + def test_run_command_isolated_terminates_on_timeout(self): + """run_command_isolated cleanly kills process group on timeout without zombies.""" + rc, stdout, stderr = run_command_isolated(["sleep", "10"], timeout=0.1) + self.assertEqual(rc, -1) + self.assertIn("timed out", stderr) + + +class TestRateLimitUserInteraction(unittest.TestCase): + def setUp(self): + self.mock_stdscr = MagicMock() + self.mock_stdscr.getmaxyx.return_value = (30, 100) + self.mock_stdscr.getch.return_value = -1 + self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse") + self.tui.safe_addstr = MagicMock() + + def test_soft_guardrail_send_during_cooldown(self): + """Sending during cooldown warns on first Enter, preserves buffer, and forces on second Enter.""" + self.tui.data.mark_node_rate_limited("muse", cooldown_seconds=30.0) + self.tui.editor_mode = "INSERT" + self.tui.input_buf = "Status report please" + self.tui.input_cursor = len(self.tui.input_buf) + + # First Enter tap: warns and does not clear input_buf + with patch.object(self.tui, "_async_send_message") as mock_send: + self.tui._handle_insert_key(10) + mock_send.assert_not_called() + self.assertEqual(self.tui.input_buf, "Status report please") + self.assertIn("cooldown", self.tui.toast_msg) + self.assertIn("Press Enter again", self.tui.toast_msg) + + # Second Enter tap within 2.5s: bypasses cooldown, loads into history, and delivers + with patch("threading.Thread") as mock_thread: + self.tui._handle_insert_key(10) + self.assertFalse(self.tui.data.is_node_rate_limited("muse")) + self.assertIn("Sending message to MUSE", self.tui.toast_msg) + self.assertEqual(self.tui.input_buf, "") + # Ensure message was immediately loaded into history cache! + msgs = self.tui.data.history_cache.get(("muse", self.tui.data.active_thread_id), []) + self.assertTrue(any(m.get("text") == "Status report please" and m.get("role") == "user" for m in msgs)) + + def test_two_tap_manual_sync_override(self): + """Pressing 'r' during cooldown warns on first press and bypasses cooldown on double-tap.""" + self.tui.data.mark_node_rate_limited("muse", cooldown_seconds=45.0) + + # First 'r' tap: warns + self.tui._handle_normal_key(ord('r')) + self.assertTrue(self.tui.data.is_node_rate_limited("muse")) + self.assertIn("cooling down", self.tui.toast_msg) + self.assertIn("Press 'r' again", self.tui.toast_msg) + + # Second 'r' tap within 2s: clears rate limit and forces sync + with patch.object(self.tui.data, "lazy_fetch_threads") as mock_fetch: + self.tui._handle_normal_key(ord('r')) + self.assertFalse(self.tui.data.is_node_rate_limited("muse")) + self.assertIn("Force-syncing", self.tui.toast_msg) + mock_fetch.assert_called_with("muse", force=True) + + def test_agent_context_menu_sync_override(self): + """Context menu 'sync' action also respects two-tap override during cooldown.""" + self.tui.data.mark_node_rate_limited("pip", cooldown_seconds=30.0) + self.tui.context_agent = {"node": "pip"} + + # First selection: warns + self.tui._execute_agent_action("sync") + self.assertTrue(self.tui.data.is_node_rate_limited("pip")) + self.assertIn("cooling down", self.tui.toast_msg) + + # Second selection within 2s: forces sync + with patch.object(self.tui.data, "lazy_fetch_threads") as mock_fetch: + self.tui._execute_agent_action("sync") + self.assertFalse(self.tui.data.is_node_rate_limited("pip")) + self.assertIn("Force-syncing", self.tui.toast_msg) + mock_fetch.assert_called_with("pip", force=True) + + def test_optimistic_message_persistence_across_fetch(self): + """Optimistic user messages are preserved even if server history lags behind.""" + self.tui.data.history_cache[("muse", "sess-test")] = [] + self.tui.data.add_optimistic_message("muse", "sess-test", "New uncommitted instruction") + cached = self.tui.data.history_cache.get(("muse", "sess-test"), []) + self.assertEqual(len(cached), 1) + self.assertEqual(cached[0]["text"], "New uncommitted instruction") + + # Simulate remote fetch returning older history that doesn't yet have the new message + old_server_msgs = [{"role": "assistant", "text": "Earlier reply"}] + with patch.object(muse_tui_rl, "run_command_isolated", return_value=(0, json.dumps(old_server_msgs), "")): + self.tui.data._fetch_history("muse", "sess-test") + updated = self.tui.data.history_cache.get(("muse", "sess-test"), []) + # Both the older server message AND the pending user message should exist! + self.assertEqual(len(updated), 2) + self.assertEqual(updated[0]["text"], "Earlier reply") + self.assertEqual(updated[1]["text"], "New uncommitted instruction") + + +if __name__ == "__main__": + import json + unittest.main() diff --git a/tests/test_scrollback.py b/tests/test_scrollback.py new file mode 100644 index 0000000..92e5edc --- /dev/null +++ b/tests/test_scrollback.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +test_scrollback.py — Comprehensive unit and integration test suite for: +1. Transcript scrollback rendering, line wrapping, and frame cache. +2. Jump-to-top ('g', Home) and bounds-capped scrolling ('k', Up arrow, PgUp). +3. Immediate downward movement ('j', Down arrow, PgDn) without 9999 offset blockage. +4. Jump-to-bottom ('G', End) re-engaging auto_scroll = True. +5. Thread and node switching resetting transcript scroll offset to 0. +6. Message send input line execution resetting scroll offset to bottom. +7. Mouse wheel scrolling within bounds. +""" + +import unittest +from unittest.mock import MagicMock, patch +import importlib.util +from pathlib import Path +import curses + +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 TestScrollbackSubsystem(unittest.TestCase): + """Test suite covering transcript scrollback, bounds clamping, and cache performance.""" + + 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.tui.safe_addstr = MagicMock() + + # Seed realistic history cache (50 messages, multi-line) + test_msgs = [] + for i in range(50): + role = "user" if i % 2 == 0 else "assistant" + test_msgs.append({ + "role": role, + "text": f"Message {i}: This is a test message to simulate rich chat transcript history.\nLine 2 of message {i}", + "seq": i, + "message_id": f"msg-{i:04d}", + }) + self.tui.data.history_cache[("muse", "test_thread")] = test_msgs + self.tui.data.active_node = "muse" + self.tui.data.active_thread_id = "test_thread" + self.tui.data.active_thread_title = "Test Thread" + + def test_initial_render_and_line_caching(self): + """Verify _render_transcript populates cache and calculates total lines.""" + self.tui._render_transcript(y=2, x=24, h=26, w=76) + + total_lines = self.tui.transcript_total_lines + self.assertGreater(total_lines, 50, "50 multi-line messages should yield > 50 rendered lines") + self.assertIsNotNone(self.tui._transcript_cache_key) + self.assertGreater(len(self.tui._transcript_cache_lines), 0) + self.assertTrue(self.tui.auto_scroll) + self.assertEqual(self.tui.transcript_scroll_offset, 0) + + # Re-render with same parameters: should hit cache without recomputing lines + cached_lines_ref = self.tui._transcript_cache_lines + self.tui._render_transcript(y=2, x=24, h=26, w=76) + self.assertIs(self.tui._transcript_cache_lines, cached_lines_ref) + + def test_jump_to_top_and_down_scrolling(self): + """Verify 'g' jumps to top without 9999 offset bug, and 'j' immediately scrolls down.""" + # 1. Initial render to establish dimensions + self.tui._render_transcript(y=2, x=24, h=26, w=76) + max_scroll = max(0, self.tui.transcript_total_lines - self.tui.transcript_height) + self.assertGreater(max_scroll, 0) + + # 2. Press 'g' (or Home) to jump to top + self.tui.focus_pane = "transcript" + handled = self.tui._handle_normal_key(ord('g')) + self.assertTrue(handled) + self.assertFalse(self.tui.auto_scroll) + self.assertEqual(self.tui.transcript_scroll_offset, max_scroll, "Jump to top must equal max_scroll, not 9999") + + # 3. Press 'j' (Down arrow) to scroll down by 3 lines + handled = self.tui._handle_normal_key(ord('j')) + self.assertTrue(handled) + self.assertEqual(self.tui.transcript_scroll_offset, max_scroll - 3, "Down arrow must immediately decrement offset") + + # 4. Render frame: bounds check should preserve offset + self.tui._render_transcript(y=2, x=24, h=26, w=76) + self.assertEqual(self.tui.transcript_scroll_offset, max_scroll - 3) + + def test_jump_to_bottom_re_engages_auto_scroll(self): + """Verify 'G' (or End) resets offset to 0 and re-engages auto_scroll.""" + self.tui._render_transcript(y=2, x=24, h=26, w=76) + self.tui.focus_pane = "transcript" + + # Scroll up + self.tui._handle_normal_key(ord('k')) + self.assertFalse(self.tui.auto_scroll) + self.assertGreater(self.tui.transcript_scroll_offset, 0) + + # Press 'G' to jump to bottom + handled = self.tui._handle_normal_key(ord('G')) + self.assertTrue(handled) + self.assertTrue(self.tui.auto_scroll) + self.assertEqual(self.tui.transcript_scroll_offset, 0) + + def test_uncapped_scroll_up_prevented(self): + """Verify repeated 'k' / PageUp keys never exceed max_scroll.""" + self.tui._render_transcript(y=2, x=24, h=26, w=76) + self.tui.focus_pane = "transcript" + max_scroll = max(0, self.tui.transcript_total_lines - self.tui.transcript_height) + + # Hammer scroll up 100 times + for _ in range(100): + self.tui._handle_normal_key(ord('k')) + + self.assertEqual(self.tui.transcript_scroll_offset, max_scroll, "Scroll offset must be capped at max_scroll") + + # Render frame + self.tui._render_transcript(y=2, x=24, h=26, w=76) + self.assertEqual(self.tui.transcript_scroll_offset, max_scroll) + + def test_mouse_wheel_scrolling(self): + """Verify mouse wheel up scrolls up and mouse wheel down scrolls back to auto_scroll.""" + self.tui._render_transcript(y=2, x=24, h=26, w=76) + content_y, content_h = 2, 26 + sidebar_w = 24 + transcript_mx = sidebar_w + 10 # Mouse inside transcript pane + transcript_my = content_y + 5 + + # Mouse wheel up mask + wheel_up_mask = 0x10000 + self.tui._handle_mouse(transcript_mx, transcript_my, wheel_up_mask) + self.assertFalse(self.tui.auto_scroll) + self.assertEqual(self.tui.transcript_scroll_offset, 3) + + # Mouse wheel down mask (curses.BUTTON5_PRESSED or 0x200000) + wheel_down_mask = getattr(curses, "BUTTON5_PRESSED", 0x200000) + self.tui._handle_mouse(transcript_mx, transcript_my, wheel_down_mask) + self.assertEqual(self.tui.transcript_scroll_offset, 0) + self.assertTrue(self.tui.auto_scroll) + + def test_target_switch_resets_scroll(self): + """Verify switching active node or thread automatically resets scroll state.""" + self.tui._render_transcript(y=2, x=24, h=26, w=76) + self.tui.focus_pane = "transcript" + self.tui._handle_normal_key(ord('g')) # Scrolled all the way up + self.assertFalse(self.tui.auto_scroll) + self.assertGreater(self.tui.transcript_scroll_offset, 0) + + # Switch to another thread + self.tui.data.history_cache[("muse", "other_thread")] = [ + {"role": "user", "text": "Hello new thread", "seq": 1, "message_id": "m1"} + ] + self.tui.data.active_thread_id = "other_thread" + + # Next render detects target switch + self.tui._render_transcript(y=2, x=24, h=26, w=76) + self.assertTrue(self.tui.auto_scroll, "Target switch must restore auto_scroll") + self.assertEqual(self.tui.transcript_scroll_offset, 0, "Target switch must reset transcript_scroll_offset") + + def test_message_send_resets_scroll(self): + """Verify sending a chat message resets scroll offset to follow live response.""" + self.tui._render_transcript(y=2, x=24, h=26, w=76) + self.tui.focus_pane = "transcript" + self.tui._handle_normal_key(ord('g')) + self.assertFalse(self.tui.auto_scroll) + self.assertGreater(self.tui.transcript_scroll_offset, 0) + + # Mock async send to avoid actual network / subprocess calls + with patch.object(self.tui, "_async_send_message"): + self.tui._execute_input_line("Test message from operator") + + self.assertTrue(self.tui.auto_scroll, "Message send must restore auto_scroll") + self.assertEqual(self.tui.transcript_scroll_offset, 0, "Message send must reset scroll offset to 0") + + def test_insert_mode_typing_and_batch_processing(self): + """Verify insert mode appends characters and updates cursor cleanly.""" + self.tui.editor_mode = "INSERT" + for ch in "hello netvm": + handled = self.tui._handle_insert_key(ord(ch)) + self.assertTrue(handled) + self.assertEqual(self.tui.input_buf, "hello netvm") + self.assertEqual(self.tui.input_cursor, 11) + + # Backspace + self.tui._handle_insert_key(curses.KEY_BACKSPACE) + self.assertEqual(self.tui.input_buf, "hello netv") + self.assertEqual(self.tui.input_cursor, 10) + + +if __name__ == "__main__": + unittest.main()