#!/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()