Files
box/bin/muse-tui.py
T

7482 lines
360 KiB
Python
Executable File

#!/usr/bin/env python3
"""
muse-tui.py — High-Performance Terminal User Interface for Muse Agents & NetVM Fleet Command.
Dual-mode interface:
1. Muse Mode (Default for 'muse tui'):
- Multi-agent conversational terminal (muse, pip, 646, opm, def, dev)
- Real-time sidechat / thread navigation and creation
- Live streaming transcript with syntax highlighting (User, Agent, Tool calls, Work Orders)
- In-terminal Work Order generator (/wo) with cryptographic signing
- Instant approval detection and 1-key resolution drawer (Allow Once, Always Allow, Deny)
2. Box Mode (Default for 'box tui' or toggled via F5/Tab):
- Fleet Vitality & CDP Latency monitor
- Fleet-wide Approvals Center
- Job Scheduler & Dispatch trigger
- Background Tmux sessions & Swarm worker monitor
- Live Event & DM log tailer
- Container SSH tunnel health & tmux pop-out dialer
"""
import sys
import os
import json
import time
import curses
import threading
import subprocess
import hashlib
import select
import shlex
import signal
import textwrap
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
import re
# Paths
REPO_ROOT = Path("/home/super/Projects/NetVM")
BIN_DIR = REPO_ROOT / "bin"
if str(BIN_DIR) not in sys.path:
sys.path.insert(0, str(BIN_DIR))
# Dynamic node registry loader
VALID_NODES = ["muse", "pip", "646", "opm", "def", "dev"]
try:
import importlib.util
spec = importlib.util.spec_from_file_location("netvm_registry", str(BIN_DIR / "netvm-registry.py"))
netvm_registry = importlib.util.module_from_spec(spec)
spec.loader.exec_module(netvm_registry)
REGISTRY_NODES = list(netvm_registry.load().keys())
if REGISTRY_NODES:
VALID_NODES = REGISTRY_NODES
except Exception:
netvm_registry = None
# Try importing approvals engine
try:
import approvals as approvals_engine
except ImportError:
approvals_engine = None
def copy_to_clipboard(text: str) -> bool:
"""Copy text to system clipboard using desktop utilities and ANSI OSC 52."""
if not text:
return False
success = False
# 1. Desktop clipboard utilities
for cmd in (
["wl-copy"],
["xclip", "-selection", "clipboard"],
["xsel", "--clipboard", "--input"],
["pbcopy"],
):
try:
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)
p.communicate(input=text.encode("utf-8"), timeout=1.0)
if p.returncode == 0:
success = True
break
except (FileNotFoundError, Exception):
continue
# 2. OSC 52 ANSI escape sequence (works over SSH, tmux, modern terminals)
try:
import base64
b64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
if os.environ.get("TMUX"):
osc52 = f"\033Ptmux;\033\033]52;c;{b64}\007\033\\"
else:
osc52 = f"\033]52;c;{b64}\007"
sys.stdout.write(osc52)
sys.stdout.flush()
success = True
except Exception:
pass
return success
def strip_message_metadata(text: str) -> str:
"""
Strip extra reply tags, header info, and unquote quoted reply lines.
Leaves the substantive text clean line-by-line.
"""
if not text:
return ""
lines = text.split("\n")
# Check if message starts with a reply quote block: '> Replying to ...:'
if lines and re.match(r"^>\s*Replying to\s+.*:\s*$", lines[0].strip(), re.IGNORECASE):
# Discard the header line
lines = lines[1:]
# Strip leading quote mark '> ' from quoted lines until an empty line separates them
cleaned_lines = []
for line in lines:
if line.startswith("> "):
cleaned_lines.append(line[2:])
elif line.startswith(">"):
cleaned_lines.append(line[1:])
else:
cleaned_lines.append(line)
text = "\n".join(cleaned_lines)
return text.strip()
def run_command_isolated(cmd: list, timeout: float = 12.0) -> tuple[int, str, str]:
"""Execute command in a dedicated process group to prevent zombie subprocesses on timeout."""
try:
p = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True,
)
try:
stdout, stderr = p.communicate(timeout=timeout)
return p.returncode, stdout, stderr
except subprocess.TimeoutExpired:
try:
os.killpg(p.pid, signal.SIGKILL)
except Exception:
pass
try:
p.communicate(timeout=0.5)
except Exception:
pass
return -1, "", "Process timed out and was terminated"
except Exception as e:
return -1, "", str(e)
def format_recency(ts: float) -> str:
"""Format an epoch timestamp into a compact human recency string (e.g. 5m ago, 2h ago)."""
if not ts or ts <= 0:
return "never"
diff = time.time() - ts
if diff < 0:
return "now"
if diff < 60:
return f"{int(diff)}s ago"
elif diff < 3600:
return f"{int(diff // 60)}m ago"
elif diff < 86400:
return f"{int(diff // 3600)}h ago"
else:
return f"{int(diff // 86400)}d ago"
return "never"
# ---------------------------------------------------------------------------
# SSH / Container Tunnel Management Subsystem
# ---------------------------------------------------------------------------
SSH_JUMP_HOST = os.environ.get("SSH_JUMP_HOST", "34.139.37.135")
SSH_OPERATOR_USER = os.environ.get("OPERATOR_USER", "super")
SSH_IDENTITY_FILE = os.environ.get("SSH_IDENTITY_FILE", "")
try:
from agent_md import TUNNEL_PORTS as SSH_TUNNEL_PORTS
except Exception:
SSH_TUNNEL_PORTS = {
"muse-main": {"port": 2224, "terminal": 7681, "user": "muse"},
"muse": {"port": 2225, "terminal": 7682, "user": "hatch"},
"646": {"port": 2226, "terminal": 7683, "user": "hatch"},
"pip": {"port": 2227, "terminal": 7684, "user": "hatch"},
"opm": {"port": 2228, "terminal": 7685, "user": "hatch"},
"def": {"port": 2229, "terminal": 7686, "user": "hatch"},
"dev": {"port": 2230, "terminal": 7687, "user": "hatch"},
}
def build_ssh_dial_command(account: str, port=None, user=None, jump_host=None,
operator_user=None, identity_file=None,
ssh_options=None, remote_command=None) -> list:
"""Build the jump-host dial argv for an agent container.
ssh_options are inserted before the destination; remote_command (str or
list) is appended after it for non-interactive probes.
"""
info = SSH_TUNNEL_PORTS.get(account, {})
port = port or info.get("port")
user = user or info.get("user", "hatch")
jump_host = jump_host or SSH_JUMP_HOST
operator_user = operator_user or SSH_OPERATOR_USER
if identity_file is None:
identity_file = SSH_IDENTITY_FILE
cmd = ["ssh", "-o", "StrictHostKeyChecking=no"]
if identity_file:
cmd += ["-o", "IdentitiesOnly=yes", "-i", identity_file]
if ssh_options:
cmd += list(ssh_options)
cmd += ["-J", f"{operator_user}@{jump_host}", "-p", str(port), f"{user}@localhost"]
if remote_command:
cmd += [remote_command] if isinstance(remote_command, str) else list(remote_command)
return cmd
def build_ssh_dial_string(account: str, **kwargs) -> str:
"""Shell-quoted dial command for display, clipboard copy, and pop-out."""
return " ".join(shlex.quote(p) for p in build_ssh_dial_command(account, **kwargs))
def build_ssh_popout_shell(account: str, **kwargs) -> str:
"""Interactive shell line for the pop-out window: ssh, then keep a shell."""
dial = build_ssh_dial_string(account, **kwargs)
return f"{dial}; echo '[ssh exited ($?) — window kept open, exit to close]'; exec \"${{SHELL:-/bin/bash}}\""
def build_tmux_popout_command(label: str, shell_command: str, socket_path: str = None) -> list:
"""Build `tmux new-window` argv opening shell_command in a fresh window."""
safe_label = re.sub(r"[^A-Za-z0-9_.-]", "-", label)[:32] or "ssh"
cmd = ["tmux"]
if socket_path:
cmd += ["-S", socket_path]
return cmd + ["new-window", "-n", safe_label, shell_command]
def ssh_row_order(nodes: list, extra_accounts=()) -> list:
"""Fleet nodes first, then any extra tunnel accounts (e.g. muse-main)."""
rows = list(nodes)
for acct in extra_accounts:
if acct not in rows:
rows.append(acct)
for acct in SSH_TUNNEL_PORTS:
if acct not in rows:
rows.append(acct)
return rows
# ---------------------------------------------------------------------------
# Prompt & Skill Library Subsystem
# ---------------------------------------------------------------------------
DEFAULT_PROMPTS = [
{
"id": "audit",
"title": "Fleet Audit",
"prompt": "Check all fleet nodes for blocked states and summarize current task loops.",
"tags": ["fleet", "health"],
"pinned": True,
"created_at": 1718000000.0,
},
{
"id": "wo",
"title": "Work Order",
"prompt": "Review recent logs and formulate a formal work order for the active issue.",
"tags": ["task", "ops"],
"pinned": True,
"created_at": 1718000001.0,
},
{
"id": "cdp",
"title": "CDP Inspect",
"prompt": "Inspect active CDP ports and page states across all nodes.",
"tags": ["cdp", "debug"],
"pinned": True,
"created_at": 1718000002.0,
},
{
"id": "digest",
"title": "Loop Digest",
"prompt": "Provide a concise bullet-point digest of current work in progress and blockers.",
"tags": ["digest", "summary"],
"pinned": False,
"created_at": 1718000003.0,
},
{
"id": "review",
"title": "Code Review",
"prompt": "Review recent code diffs, verify correctness, and check for potential regressions.",
"tags": ["code", "review"],
"pinned": False,
"created_at": 1718000004.0,
},
{
"id": "tests",
"title": "Run Tests",
"prompt": "Execute the test suite and report any test failures with root cause analysis.",
"tags": ["test", "qa"],
"pinned": False,
"created_at": 1718000005.0,
},
{
"id": "status",
"title": "Agent Status",
"prompt": "Report detailed status, active subtasks, and pending approvals for this node.",
"tags": ["agent", "status"],
"pinned": False,
"created_at": 1718000006.0,
},
]
class PromptManager:
"""Manages persistent prompt templates and skills stored in prompts.json."""
def __init__(self, storage_path: Path = None):
self.storage_path = storage_path or (Path.home() / ".config" / "muse-cli" / "prompts.json")
self.prompts = []
self._load_prompts()
def _load_prompts(self):
"""Load prompts from disk or initialize with built-in defaults."""
if self.storage_path.exists():
try:
data = json.loads(self.storage_path.read_text(encoding="utf-8"))
if isinstance(data, list) and data:
self.prompts = data
return
except Exception:
pass
# Initialize defaults
self.prompts = [dict(p) for p in DEFAULT_PROMPTS]
self._save_prompts()
def _save_prompts(self):
"""Persist prompts to disk."""
try:
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
self.storage_path.write_text(json.dumps(self.prompts, indent=2), encoding="utf-8")
except Exception:
pass
def get_sorted_prompts(self) -> list:
"""Return prompts with pinned items first, then remaining items in order."""
return sorted(self.prompts, key=lambda p: (not p.get("pinned", False), -p.get("created_at", 0.0)))
def add_prompt(self, title: str, prompt_text: str, tags: list = None, pid: str = None, pinned: bool = False) -> dict:
"""Formulate and store a new prompt skill."""
pid = pid or re.sub(r"[^a-zA-Z0-9_-]", "", title.lower().replace(" ", "_"))[:16] or f"p_{int(time.time()*1000)%100000}"
prompt_item = {
"id": pid,
"title": title.strip(),
"prompt": prompt_text.strip(),
"tags": tags or ["custom"],
"pinned": pinned,
"created_at": time.time(),
}
for idx, p in enumerate(self.prompts):
if p.get("id") == pid:
self.prompts[idx] = prompt_item
self._save_prompts()
return prompt_item
self.prompts.insert(0 if pinned else len(self.prompts), prompt_item)
self._save_prompts()
return prompt_item
def pin_chat_send(self, text: str, node: str = "muse", title: str = None, toggle: bool = False) -> tuple[dict, bool]:
"""Pin a sent chat message to the prompt fill / list. If toggle=True, unpins if already pinned."""
clean_text = text.strip()
for p in self.prompts:
if p.get("prompt", "").strip() == clean_text:
if toggle:
p["pinned"] = not p.get("pinned", False)
else:
p["pinned"] = True
self._save_prompts()
return p, False
first_line = clean_text.split("\n")[0].strip()
derived_title = first_line[:24] + ("…" if len(first_line) > 24 else "") if not title else title
pid = f"pin_{int(time.time()*1000) % 1000000}"
prompt_item = {
"id": pid,
"title": derived_title,
"prompt": clean_text,
"tags": ["pinned", node],
"pinned": True,
"created_at": time.time(),
}
self.prompts.insert(0, prompt_item)
self._save_prompts()
return prompt_item, True
def toggle_pin_chat_send(self, text: str, node: str = "muse", title: str = None) -> tuple[dict, bool]:
"""Toggle pinned status for a sent chat message in prompts."""
return self.pin_chat_send(text, node=node, title=title, toggle=True)
def toggle_pin_prompt(self, pid: str) -> bool:
"""Toggle pinned state for a prompt. Returns new state."""
for p in self.prompts:
if p.get("id") == pid:
p["pinned"] = not p.get("pinned", False)
self._save_prompts()
return p["pinned"]
return False
def delete_prompt(self, pid: str) -> bool:
"""Delete a prompt by ID."""
for idx, p in enumerate(self.prompts):
if p.get("id") == pid:
self.prompts.pop(idx)
self._save_prompts()
return True
return False
# ---------------------------------------------------------------------------
# Box Mode Tab Bar (single source of truth for renderer + click handler)
# ---------------------------------------------------------------------------
BOX_TABS = [
"1: Agent Chat",
"2: Fleet Status",
"3: Approvals",
"4: Jobs Scheduler",
"5: Tmux / Swarms",
"6: DM Logs",
"7: SSH / Boxes",
]
def box_tab_bounds(tabs=None, x: int = 0) -> list:
"""Clickable x-ranges for the Box tab bar, mirroring _render_box_tabs."""
bounds = []
cur_x = x + 1
for tab_name in (tabs if tabs is not None else BOX_TABS):
label = f" [{tab_name}] "
bounds.append((cur_x, cur_x + len(label) - 1))
cur_x += len(label) + 1
return bounds
# ---------------------------------------------------------------------------
# Data Layer & Async Poller
# ---------------------------------------------------------------------------
class FleetDataManager:
"""Thread-safe background data fetcher for fleet state, chat, and approvals."""
def __init__(self, start_poller: bool = None):
self.lock = threading.RLock()
self.nodes = list(VALID_NODES)
self.active_node = "muse"
self.active_thread_id = None
self.active_thread_title = ""
# Background worker control & adaptive activity tracking
self.running = True
self.refresh_event = threading.Event()
self.user_poll_trigger = threading.Event()
self.last_user_activity = time.time()
self.rate_limit_cooldown = {} # node -> float epoch timestamp until cooldown ends
self.rate_limit_consecutive = {n: 0 for n in self.nodes} # node -> consecutive 429 count
self.history_fetch_times = {} # (node, thread_id) -> float epoch timestamp of last successful fetch
# Cached state
self.node_status = {n: {"status": "ACTIVE", "latency": None, "tab": "", "blocked": False, "rate_limited": False} for n in self.nodes}
self.threads_cache = {n: [] for n in self.nodes}
self.history_cache = {} # (node, thread_id) -> list of msgs
self.approvals_cache = [] # list of approval dicts
self.jobs_cache = []
self.tmux_cache = []
self.dm_logs_cache = []
# SSH / container tunnel health (Box tab 7)
self.ssh_cache = {} # account -> health dict from ssh-check + state_since
self.ssh_jump_reachable = None # None = never checked
self.ssh_checked_at = 0.0
self.ssh_check_latency_ms = None
self.ssh_check_error = ""
# Interaction ranking: node -> float timestamp of last true input / chat [insert]
self.agent_interactions = {n: 0.0 for n in self.nodes}
self._load_agent_interactions()
# Dynamic & lazy loading state
self.loading_threads = set() # set of node names currently being fetched
self.loaded_nodes = set() # set of node names that completed at least one fetch attempt
self.fetch_times = {} # node -> float timestamp of last fetch
self.loading_history = set() # set of (node, thread_id) currently fetching history
# Pinned chats map: node -> set of session_ids
self.pinned_threads = {n: set() for n in self.nodes}
# Read state map: (node, session_id) -> int (number of messages read)
self.read_msg_counts = {}
self._load_pinned_threads()
self._load_history_cache()
self._load_read_state()
self._init_threads_cache()
self._seed_interactions_from_threads()
self.sort_nodes()
if self.nodes:
self.active_node = self.nodes[0]
self.select_main_chat(self.active_node)
# In unit tests, avoid auto-spawning background worker threads that hit network
in_test = bool(
os.environ.get("BOX_TESTS_NESTED") or
os.environ.get("MUSE_NO_POLLER") or
"unittest" in sys.modules or
any("unittest" in arg or "pytest" in arg for arg in sys.argv)
)
if start_poller is None:
start_poller = not in_test
if start_poller:
self.preload_priority_chats(self.active_node, sidechat_limit=0)
self.poller_thread = threading.Thread(target=self._worker_loop, daemon=True)
self.poller_thread.start()
# First SSH sweep in background so Box tab 7 is warm on open
threading.Thread(target=self._fetch_ssh_health, daemon=True).start()
else:
self.poller_thread = None
def record_activity(self):
"""Record user input/interaction to keep poll interval in fast/active mode."""
self.last_user_activity = time.time()
def is_node_rate_limited(self, node: str) -> bool:
"""Check if node is currently in rate-limit cooldown window."""
if not node:
return False
until = self.rate_limit_cooldown.get(node, 0.0)
return time.time() < until
def get_node_cooldown_remaining(self, node: str) -> int:
"""Return remaining seconds in rate-limit cooldown (0 if not rate limited)."""
until = self.rate_limit_cooldown.get(node, 0.0)
rem = until - time.time()
return max(0, int(rem))
def mark_node_rate_limited(self, node: str, cooldown_seconds: float = None, reason: str = "", retry_after: float = None):
"""Place node into rate-limit cooldown window using tiered backoff (30s -> 60s -> 120s max)."""
if retry_after is not None and retry_after > 0:
cd = retry_after
elif cooldown_seconds is not None:
cd = cooldown_seconds
else:
consec = self.rate_limit_consecutive.get(node, 0)
self.rate_limit_consecutive[node] = consec + 1
cd = min(120.0, 30.0 * (2 ** consec)) # 30s -> 60s -> 120s max
self.rate_limit_cooldown[node] = time.time() + cd
with self.lock:
if node in self.node_status:
self.node_status[node]["rate_limited"] = True
def clear_node_rate_limit(self, node: str):
"""Reset rate limit cooldown and consecutive counter upon successful response or explicit override."""
self.rate_limit_cooldown[node] = 0.0
self.rate_limit_consecutive[node] = 0
with self.lock:
if node in self.node_status:
self.node_status[node]["rate_limited"] = False
def _load_pinned_threads(self):
"""Load pinned thread IDs from ~/.config/muse-cli/pinned_threads.json."""
pinned_file = Path.home() / ".config" / "muse-cli" / "pinned_threads.json"
if pinned_file.exists():
try:
data = json.loads(pinned_file.read_text(encoding="utf-8"))
for n, sids in data.items():
if n in self.nodes and isinstance(sids, list):
self.pinned_threads[n] = set(sids)
except Exception:
pass
def _save_pinned_threads(self):
"""Persist pinned thread IDs to disk."""
pinned_file = Path.home() / ".config" / "muse-cli" / "pinned_threads.json"
try:
pinned_file.parent.mkdir(parents=True, exist_ok=True)
data = {n: list(self.pinned_threads.get(n, set())) for n in self.nodes}
pinned_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
except Exception:
pass
def _load_agent_interactions(self):
"""Load interaction timestamps from ~/.config/muse-cli/agent_interactions.json."""
int_file = Path.home() / ".config" / "muse-cli" / "agent_interactions.json"
if int_file.exists():
try:
data = json.loads(int_file.read_text(encoding="utf-8"))
if isinstance(data, dict):
for n, ts in data.items():
if n in self.nodes and isinstance(ts, (int, float)):
self.agent_interactions[n] = float(ts)
except Exception:
pass
def _save_agent_interactions(self):
"""Persist interaction timestamps to disk outside lock."""
int_file = Path.home() / ".config" / "muse-cli" / "agent_interactions.json"
try:
int_file.parent.mkdir(parents=True, exist_ok=True)
with self.lock:
data = {n: self.agent_interactions.get(n, 0.0) for n in self.nodes}
payload = json.dumps(data, indent=2)
int_file.write_text(payload, encoding="utf-8")
except Exception:
pass
def _seed_interactions_from_threads(self):
"""Seed interactions from cached thread updated times for any nodes with no record."""
with self.lock:
for n in self.nodes:
if self.agent_interactions.get(n, 0.0) == 0.0:
threads = self.threads_cache.get(n, [])
newest = 0.0
for t in threads:
up_str = t.get("updated")
if up_str:
try:
dt = datetime.fromisoformat(up_str)
ts = dt.timestamp()
if ts > newest:
newest = ts
except Exception:
pass
if newest > 0.0:
self.agent_interactions[n] = newest
def sort_nodes(self):
"""Sort fleet agents by:
1. Highest unread messages total (descending)
2. Most recent direct operator interaction (descending)
3. Baseline order from VALID_NODES."""
with self.lock:
baseline = {n: i for i, n in enumerate(VALID_NODES)}
self.nodes.sort(
key=lambda n: (
self.get_node_unread_total(n),
self.agent_interactions.get(n, 0.0),
-baseline.get(n, 999)
),
reverse=True
)
def record_interaction(self, node: str, ts: float = None):
"""Record direct operator interaction with an agent via true input / chat [insert]."""
if node not in self.nodes:
return
now = ts if ts is not None else time.time()
with self.lock:
self.agent_interactions[node] = now
self._save_agent_interactions()
self.sort_nodes()
self.trigger_refresh()
def toggle_pin_thread(self, node: str, session_id: str) -> bool:
"""Toggle pinned state for a thread. Returns new pinned state."""
with self.lock:
pset = self.pinned_threads.setdefault(node, set())
if session_id in pset:
pset.discard(session_id)
new_state = False
else:
pset.add(session_id)
new_state = True
self._save_pinned_threads()
if node in self.threads_cache:
self.threads_cache[node] = self.sort_threads(node, self.threads_cache[node])
self._save_threads_cache()
self.trigger_refresh()
return new_state
def _load_history_cache(self):
"""Seed history cache from ~/.config/muse-cli/history_cache.json."""
hist_file = Path.home() / ".config" / "muse-cli" / "history_cache.json"
if hist_file.exists():
try:
data = json.loads(hist_file.read_text(encoding="utf-8"))
for k, msgs in data.items():
if "/" in k and isinstance(msgs, list):
n, tid = k.split("/", 1)
if tid == "None":
tid = None
if n in self.nodes:
self.history_cache[(n, tid)] = msgs
except Exception:
pass
def _save_history_cache(self):
"""Persist history cache to ~/.config/muse-cli/history_cache.json."""
hist_file = Path.home() / ".config" / "muse-cli" / "history_cache.json"
try:
hist_file.parent.mkdir(parents=True, exist_ok=True)
with self.lock:
data = {f"{k[0]}/{k[1]}": list(msgs) for k, msgs in self.history_cache.items()}
payload = json.dumps(data, indent=1)
hist_file.write_text(payload, encoding="utf-8")
except Exception:
pass
def _load_read_state(self):
"""Load read message counts from ~/.config/muse-cli/read_state.json."""
read_file = Path.home() / ".config" / "muse-cli" / "read_state.json"
if read_file.exists():
try:
data = json.loads(read_file.read_text(encoding="utf-8"))
if isinstance(data, dict):
for k, count in data.items():
if "/" in k and isinstance(count, int):
n, tid = k.split("/", 1)
if n in self.nodes:
self.read_msg_counts[(n, tid)] = max(0, count)
except Exception:
pass
def _save_read_state(self):
"""Persist read message counts to disk."""
read_file = Path.home() / ".config" / "muse-cli" / "read_state.json"
try:
read_file.parent.mkdir(parents=True, exist_ok=True)
with self.lock:
data = {f"{k[0]}/{k[1]}": count for k, count in self.read_msg_counts.items()}
read_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
except Exception:
pass
def mark_thread_read(self, node: str, thread_id: str, count: int = None):
"""Mark a thread/sidechat as read up to count or current history message total."""
if not node:
return
key = (node, thread_id)
with self.lock:
if count is not None:
cur_total = count
else:
cur_total = len(self.history_cache.get(key, []))
old_count = self.read_msg_counts.get(key, 0)
if cur_total >= old_count:
self.read_msg_counts[key] = cur_total
self._save_read_state()
self.trigger_refresh()
def mark_node_read(self, node: str):
"""Mark all threads/sidechats for a node as read."""
if not node:
return
with self.lock:
threads = list(self.threads_cache.get(node, []))
for t in threads:
sid = t.get("session_id")
if sid:
key = (node, sid)
total = len(self.history_cache.get(key, []))
old_count = self.read_msg_counts.get(key, 0)
if total >= old_count:
self.read_msg_counts[key] = total
self._save_read_state()
self.trigger_refresh()
def mark_fleet_read(self):
"""Mark all threads across all agents in the fleet as read."""
with self.lock:
for node in self.nodes:
threads = list(self.threads_cache.get(node, []))
for t in threads:
sid = t.get("session_id")
if sid:
key = (node, sid)
total = len(self.history_cache.get(key, []))
old_count = self.read_msg_counts.get(key, 0)
if total >= old_count:
self.read_msg_counts[key] = total
self._save_read_state()
self.trigger_refresh()
def get_unread_count(self, node: str, thread_id: str) -> int:
"""Get number of unread/new messages in a thread."""
key = (node, thread_id)
with self.lock:
total = len(self.history_cache.get(key, []))
read_cnt = self.read_msg_counts.get(key, 0)
return max(0, total - read_cnt)
def get_node_unread_total(self, node: str) -> int:
"""Get total unread messages across all threads/sidechats for an agent."""
total_unread = 0
with self.lock:
threads = list(self.threads_cache.get(node, []))
for t in threads:
sid = t.get("session_id")
if sid:
total_unread += self.get_unread_count(node, sid)
return total_unread
def get_fleet_unread_total(self) -> int:
"""Get grand total of unread messages across all agents in the fleet."""
return sum(self.get_node_unread_total(n) for n in self.nodes)
def sort_threads(self, node: str, thread_list: list) -> list:
"""Sort threads with Main Chat first (prio 0), Pinned chats second (prio 1), then recency (prio 2)."""
pinned_set = self.pinned_threads.get(node, set())
mains = []
pinned = []
regular = []
for t in thread_list:
if not isinstance(t, dict) or not t.get("session_id"):
continue
sid = t.get("session_id")
title_lower = (t.get("title") or "").lower()
is_main = (
t.get("thread") is False or
t.get("is_main") is True or
title_lower in ("main chat", "main", "start conversation with muse")
)
is_pin = sid in pinned_set
t["is_main"] = is_main
t["pinned"] = is_pin
if is_main:
mains.append(t)
elif is_pin:
pinned.append(t)
else:
regular.append(t)
mains.sort(key=lambda x: x.get("updated") or "", reverse=True)
pinned.sort(key=lambda x: x.get("updated") or "", reverse=True)
regular.sort(key=lambda x: x.get("updated") or "", reverse=True)
return mains + pinned + regular
def preload_priority_chats(self, node: str = None, sidechat_limit: int = 15):
"""Proactively fetch and cache message history for Main Chat, pinned chats, and recent sidechats."""
nodes_to_preload = [node] if node else self.nodes
for n in nodes_to_preload:
with self.lock:
threads = list(self.threads_cache.get(n, []))
side_cnt = 0
for t in threads:
sid = t.get("session_id")
if not sid:
continue
is_prio = t.get("is_main") or t.get("pinned")
if is_prio:
threading.Thread(target=self._fetch_history, args=(n, sid), daemon=True).start()
elif side_cnt < sidechat_limit:
side_cnt += 1
threading.Thread(target=self._fetch_history, args=(n, sid), daemon=True).start()
def _init_threads_cache(self):
"""Seed threads cache from persistent disk cache and job-sidechats.json."""
# 1. Seed from job-sidechats.json
job_chats_file = REPO_ROOT / "job-sidechats.json"
if job_chats_file.exists():
try:
with open(job_chats_file, "r", encoding="utf-8") as f:
js_data = json.load(f)
for k, v in js_data.items():
if not isinstance(v, dict):
continue
agent = v.get("agent")
if agent not in self.nodes:
agent = "muse"
sid = v.get("thread_uuid")
title = v.get("description") or k
if sid:
existing_ids = {t.get("session_id") for t in self.threads_cache[agent]}
if sid not in existing_ids:
self.threads_cache[agent].append({"session_id": sid, "title": title})
except Exception:
pass
# 2. Seed from ~/.config/muse-cli/threads_cache.json if present (higher precedence / recent)
cache_file = Path.home() / ".config" / "muse-cli" / "threads_cache.json"
if cache_file.exists():
try:
with open(cache_file, "r", encoding="utf-8") as f:
disk_cache = json.load(f)
if isinstance(disk_cache, dict):
for n, t_list in disk_cache.items():
if n in self.nodes and isinstance(t_list, list):
live_ids = {t.get("session_id") for t in t_list if isinstance(t, dict) and t.get("session_id")}
merged = [t for t in t_list if isinstance(t, dict) and t.get("session_id")]
for old_t in self.threads_cache.get(n, []):
if old_t.get("session_id") not in live_ids:
merged.append(old_t)
self.threads_cache[n] = merged
except Exception:
pass
# Seed pinned_threads from threads_cache if pinned_threads is empty
for n in self.nodes:
if not self.pinned_threads.get(n):
for t in self.threads_cache.get(n, []):
if t.get("pinned") and t.get("session_id"):
self.pinned_threads.setdefault(n, set()).add(t["session_id"])
# Sort all cached threads so Main Chat and pinned chats are at top
for n in self.nodes:
if n in self.threads_cache:
self.threads_cache[n] = self.sort_threads(n, self.threads_cache[n])
# Set default active thread for initial active_node if available (now Main Chat!)
self.select_main_chat(self.active_node)
def select_main_chat(self, node: str = None) -> tuple:
"""Select Main Chat for the specified node or current active node."""
with self.lock:
target_node = node or self.active_node
self.active_node = target_node
node_threads = self.threads_cache.get(target_node, [])
main_t = next((t for t in node_threads if t.get("is_main")), (node_threads[0] if node_threads else None))
if main_t:
self.active_thread_id = main_t.get("session_id")
self.active_thread_title = main_t.get("title") or "Main Chat"
else:
self.active_thread_id = None
self.active_thread_title = "Main Chat"
return self.active_thread_id, self.active_thread_title
def _save_threads_cache(self):
cache_file = Path.home() / ".config" / "muse-cli" / "threads_cache.json"
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
with self.lock:
data_copy = {n: list(self.threads_cache.get(n, [])) for n in self.nodes}
payload = json.dumps(data_copy, indent=2)
cache_file.write_text(payload, encoding="utf-8")
except Exception:
pass
def is_loading_threads(self, node: str) -> bool:
with self.lock:
return node in self.loading_threads
def is_loading_history(self, node: str, thread_id: str) -> bool:
with self.lock:
return (node, thread_id) in self.loading_history
def stop(self):
self.running = False
if hasattr(self, 'user_poll_trigger') and self.user_poll_trigger:
self.user_poll_trigger.set()
if hasattr(self, 'refresh_event') and self.refresh_event:
self.refresh_event.set()
def trigger_refresh(self):
"""User or explicit action requested an immediate poll cycle."""
if hasattr(self, 'user_poll_trigger') and self.user_poll_trigger:
self.user_poll_trigger.set()
if hasattr(self, 'refresh_event') and self.refresh_event:
self.refresh_event.set()
def set_active_target(self, node: str, thread_id: str = None, thread_title: str = None):
with self.lock:
node_changed = (node in self.nodes and node != self.active_node)
if node in self.nodes:
self.active_node = node
if thread_id is not None:
self.active_thread_id = thread_id
if thread_title is not None:
self.active_thread_title = thread_title
else:
found_title = None
for t in self.threads_cache.get(node, []):
if t.get("session_id") == thread_id:
found_title = t.get("title")
break
self.active_thread_title = found_title or thread_id[:8]
elif node_changed or not self.active_thread_id:
self.select_main_chat(node)
elif thread_title is not None:
self.active_thread_title = thread_title
self.record_activity()
self.lazy_fetch_threads(node)
self.trigger_refresh()
def lazy_fetch_threads(self, node: str, force: bool = False):
"""Lazily trigger background fetch of threads for a node if needed."""
with self.lock:
if node not in self.nodes or self.is_node_rate_limited(node):
return
if node in self.loading_threads:
return
now = time.time()
last_fetch = self.fetch_times.get(node, 0.0)
if not force and node in self.loaded_nodes and (now - last_fetch) < 20.0:
return
self.loading_threads.add(node)
threading.Thread(target=self._run_fetch_threads, args=(node,), daemon=True).start()
def _worker_loop(self):
"""Periodic background data refresh with adaptive backoff and rate limit protections."""
last_fast = 0.0
last_med = 0.0
last_slow = 0.0
last_fleet_approvals = 0.0
last_ssh = 0.0
# Initial fetch of active node main chat only
with self.lock:
init_node = self.active_node
if init_node and not self.is_node_rate_limited(init_node):
self.lazy_fetch_threads(init_node)
self._fetch_history(init_node, None)
while self.running:
now = time.time()
user_triggered = False
if hasattr(self, 'user_poll_trigger') and self.user_poll_trigger.is_set():
user_triggered = True
self.user_poll_trigger.clear()
# Adaptive fast polling cadence:
# Active (input/interaction within 20s): 2.5s
# Idling (20s - 60s): 6.0s
# Long idle (>60s): 15.0s
idle_seconds = now - getattr(self, "last_user_activity", 0.0)
if idle_seconds < 20.0:
fast_cadence = 2.5
elif idle_seconds < 60.0:
fast_cadence = 6.0
else:
fast_cadence = 15.0
# 1. Fast Poll: Current chat history & active node approvals
if (now - last_fast >= fast_cadence) or user_triggered:
with self.lock:
cur_node = self.active_node
cur_thread = self.active_thread_id
node_threads = self.threads_cache.get(cur_node, [])
cur_valid = any(t.get("session_id") == cur_thread for t in node_threads)
if not cur_thread or not cur_valid:
cur_thread, _ = self.select_main_chat(cur_node)
if cur_node and not self.is_node_rate_limited(cur_node):
self._fetch_history(cur_node, cur_thread)
self._fetch_active_node_approval(cur_node)
last_fast = now
# 2. Medium Poll (every 20s): Fleet health / CDP status & tmux
if (now - last_med >= 20.0):
self._fetch_fleet_health()
self._fetch_tmux_sessions()
last_med = now
# 3. Fleet Approvals Poll (every 12s, or immediately on user action)
if (now - last_fleet_approvals >= 12.0) or user_triggered:
self._fetch_approvals()
last_fleet_approvals = now
# 4. Slow Poll (every 45s): Refresh thread list & jobs lazily
if (now - last_slow >= 45.0) or user_triggered:
with self.lock:
cur_node = self.active_node
if cur_node and not self.is_node_rate_limited(cur_node):
self.lazy_fetch_threads(cur_node)
self._fetch_jobs()
self._fetch_dm_logs()
last_slow = now
# 5. SSH tunnel health (every 30s): single VM-side sweep
if (now - last_ssh >= 30.0):
self._fetch_ssh_health()
last_ssh = now
# 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)
if rc != 0:
err_text = (stderr + " " + stdout).lower()
if any(pat in err_text for pat in ["429", "rate limit", "too many requests", "throttled", "ratelimitexceeded"]):
retry_match = re.search(r"retry[-_]after[:\s]+(\d+)", err_text)
retry_val = float(retry_match.group(1)) if retry_match else None
self.mark_node_rate_limited(node, reason="Rate limit detected on threads", retry_after=retry_val)
return
try:
data = json.loads(stdout)
except Exception:
data = None
if isinstance(data, dict) and data.get("error"):
err_text = str(data.get("error")).lower()
if any(pat in err_text for pat in ["429", "rate limit", "too many requests", "throttled", "ratelimitexceeded"]):
self.mark_node_rate_limited(node, reason="Rate limit detected on threads")
return
if isinstance(data, list):
self.clear_node_rate_limit(node)
with self.lock:
existing = self.threads_cache.get(node, [])
live_ids = {t.get("session_id") for t in data if isinstance(t, dict) and t.get("session_id")}
merged = [t for t in data if isinstance(t, dict) and t.get("session_id")]
for old_t in existing:
sid = old_t.get("session_id")
if sid and sid not in live_ids:
merged.append(old_t)
sorted_threads = self.sort_threads(node, merged)
self.threads_cache[node] = sorted_threads
if self.active_node == node:
cur_valid = any(t.get("session_id") == self.active_thread_id for t in sorted_threads)
if not cur_valid or not self.active_thread_id:
self.select_main_chat(node)
self._save_threads_cache()
self.trigger_refresh()
except Exception:
pass
finally:
with self.lock:
self.loading_threads.discard(node)
self.loaded_nodes.add(node)
self.fetch_times[node] = time.time()
def _fetch_threads(self, node: str):
with self.lock:
if node in self.loading_threads:
return
self.loading_threads.add(node)
self._run_fetch_threads(node)
def _fetch_history(self, node: str, thread_id: str = None):
if not node or self.is_node_rate_limited(node):
return
is_main = False
with self.lock:
for t in self.threads_cache.get(node, []):
if (thread_id and t.get("session_id") == thread_id and t.get("is_main")) or (not thread_id and t.get("is_main")):
is_main = True
if not thread_id:
thread_id = t.get("session_id")
break
key = (node, thread_id)
with self.lock:
if key in self.loading_history:
return
self.loading_history.add(key)
try:
cmd = [str(BIN_DIR / "muse-cli-node"), node, "history", "--limit", "120"]
if thread_id and not is_main:
cmd.extend(["--thread", thread_id])
rc, stdout, stderr = run_command_isolated(cmd, timeout=12.0)
if rc != 0:
err_text = (stderr + " " + stdout).lower()
if any(pat in err_text for pat in ["429", "rate limit", "too many requests", "throttled", "ratelimitexceeded"]):
retry_match = re.search(r"retry[-_]after[:\s]+(\d+)", err_text)
retry_val = float(retry_match.group(1)) if retry_match else None
self.mark_node_rate_limited(node, reason="Rate limit detected on history", retry_after=retry_val)
return
msgs = None
try:
msgs = json.loads(stdout)
except Exception:
msgs = None
if isinstance(msgs, dict) and msgs.get("error"):
err_text = str(msgs.get("error")).lower()
if any(pat in err_text for pat in ["429", "rate limit", "too many requests", "throttled", "ratelimitexceeded"]):
self.mark_node_rate_limited(node, reason="Rate limit detected on history")
return
if isinstance(msgs, list):
self.clear_node_rate_limit(node)
self.history_fetch_times[key] = time.time()
with self.lock:
existing = list(self.history_cache.get(key, []))
now_ts = time.time()
# Preserve recent optimistic user messages not yet returned by backend
pending_opt = [
m for m in existing
if m.get("optimistic") and (now_ts - m.get("timestamp", now_ts)) < 45.0
and not any(sm.get("role") == "user" and sm.get("text") == m.get("text") for sm in msgs)
]
merged = msgs + pending_opt
self.history_cache[key] = merged
if is_main:
self.history_cache[(node, None)] = merged
self._save_history_cache()
self.trigger_refresh()
except Exception:
pass
finally:
with self.lock:
self.loading_history.discard(key)
def _fetch_active_node_approval(self, node: str):
"""Quick single-node check for active node approvals without scanning full fleet."""
if not approvals_engine or not node:
return
try:
res = approvals_engine.inspect_node_approvals(node)
with self.lock:
if node in self.node_status:
st = res.get("status")
is_blocked = st in ("PENDING", "KEY_APPROVAL")
is_waiting = (st == "INPUT_WAIT")
self.node_status[node]["blocked"] = is_blocked
self.node_status[node]["waiting"] = is_waiting
self.node_status[node]["approval_status"] = st
except Exception:
pass
def _fetch_approvals(self):
if not approvals_engine:
return
try:
results = approvals_engine.check_fleet_approvals(self.nodes)
with self.lock:
self.approvals_cache = results
for it in results:
n = it.get("node")
if n in self.node_status:
st = it.get("status")
is_blocked = st in ("PENDING", "KEY_APPROVAL")
is_waiting = (st == "INPUT_WAIT")
self.node_status[n]["blocked"] = is_blocked
self.node_status[n]["waiting"] = is_waiting
self.node_status[n]["approval_status"] = st
except Exception:
pass
def _fetch_fleet_health(self):
if not netvm_registry:
return
try:
reg = netvm_registry.load()
for n in self.nodes:
rec = reg.get(n)
if not rec:
continue
peer_ip = rec.get("peer_ip", "127.0.0.1")
port = rec.get("cdp_port", 9410)
url = f"http://{peer_ip}:{port}/json/version"
t0 = time.time()
try:
req = urllib.request.Request(url, headers={"User-Agent": "muse-tui/1.0"})
with urllib.request.urlopen(req, timeout=1.2) as resp:
lat = int((time.time() - t0) * 1000)
with self.lock:
self.node_status[n]["latency"] = lat
self.node_status[n]["status"] = "ACTIVE"
except Exception:
with self.lock:
self.node_status[n]["status"] = "UNREACHABLE"
self.node_status[n]["latency"] = None
# Also grab active page title
tab_url = f"http://{peer_ip}:{port}/json/list"
try:
req = urllib.request.Request(tab_url, headers={"User-Agent": "muse-tui/1.0"})
with urllib.request.urlopen(req, timeout=1.0) as resp:
tabs = json.load(resp)
pages = [t for t in tabs if t.get("type") == "page"]
if pages:
t_title = pages[0].get("title", "")
with self.lock:
self.node_status[n]["tab"] = t_title
except Exception:
pass
except Exception:
pass
def _fetch_jobs(self):
try:
cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-list"]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
if res.returncode == 0:
data = json.loads(res.stdout)
with self.lock:
self.jobs_cache = data.get("jobs", [])
except Exception:
pass
def _fetch_tmux_sessions(self):
try:
cmd = ["tmux", "-S", "/tmp/tmux-muse.sock", "ls"]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=2)
if res.returncode == 0:
lines = [l.strip() for l in res.stdout.strip().split("\n") if l.strip()]
with self.lock:
self.tmux_cache = lines
else:
with self.lock:
self.tmux_cache = []
except Exception:
pass
def _apply_ssh_check_result(self, data: dict, now: float = None):
"""Merge one ssh-check payload into ssh_cache with flap tracking.
state_since records the last (ssh_up, term_up) transition per
account so the SSH view can show uptime/downtime durations.
"""
now = now if now is not None else time.time()
if not isinstance(data, dict):
return
accounts = data.get("accounts", {})
if not isinstance(accounts, dict):
accounts = {}
with self.lock:
self.ssh_jump_reachable = data.get("jump_reachable")
self.ssh_checked_at = now
self.ssh_check_latency_ms = data.get("latency_ms")
self.ssh_check_error = "" if data.get("jump_reachable") else str(data.get("error", ""))
for acct, info in accounts.items():
if not isinstance(info, dict):
continue
prev = self.ssh_cache.get(acct, {})
entry = dict(info)
prev_state = (prev.get("ssh_up"), prev.get("term_up"))
new_state = (entry.get("ssh_up"), entry.get("term_up"))
if prev_state != new_state or "state_since" not in prev:
entry["state_since"] = now
entry["prev_ssh_up"] = prev.get("ssh_up")
else:
entry["state_since"] = prev.get("state_since", now)
entry["prev_ssh_up"] = prev.get("prev_ssh_up")
self.ssh_cache[acct] = entry
def _fetch_ssh_health(self):
"""Run box-ctl ssh-check (single VM-side sweep) and merge results."""
try:
cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "ssh-check"]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if res.returncode == 0:
try:
data = json.loads(res.stdout)
except Exception:
return
if isinstance(data, dict) and data.get("ok"):
self._apply_ssh_check_result(data)
except Exception:
pass
def probe_container_uptime(self, account: str) -> tuple[bool, str]:
"""On-demand end-to-end probe: run `uptime` inside the container."""
if account not in SSH_TUNNEL_PORTS:
return False, f"No tunnel registered for '{account}'"
cmd = build_ssh_dial_command(
account,
ssh_options=["-o", "BatchMode=yes", "-o", "ConnectTimeout=12"],
remote_command="uptime",
)
rc, stdout, stderr = run_command_isolated(cmd, timeout=25.0)
if rc == 0 and (stdout or "").strip():
return True, stdout.strip()
err_lines = (stderr or stdout or f"exit {rc}").strip().splitlines()
return False, (err_lines[-1] if err_lines else f"exit {rc}")[:200]
def _fetch_dm_logs(self):
log_path = REPO_ROOT / "dm-log.jsonl"
if not log_path.exists():
return
try:
# Read last 40 lines
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()[-40:]
parsed = []
for l in reversed(lines):
try:
parsed.append(json.loads(l))
except Exception:
pass
with self.lock:
self.dm_logs_cache = parsed
except Exception:
pass
def add_optimistic_message(self, node: str, thread_id: str, text: str):
"""Immediately append the user's message to history cache so it renders without waiting for remote roundtrip."""
key = (node, thread_id)
msg_id = f"opt-{int(time.time()*1000)}"
now_iso = datetime.now(timezone.utc).isoformat()
opt_msg = {
"role": "user",
"text": text,
"message_id": msg_id,
"created": now_iso,
"optimistic": True,
"timestamp": time.time(),
}
with self.lock:
cur_msgs = list(self.history_cache.get(key, []))
if not any(m.get("text") == text and m.get("role") == "user" for m in cur_msgs[-2:]):
cur_msgs.append(opt_msg)
self.history_cache[key] = cur_msgs
is_main = any(t.get("session_id") == thread_id and t.get("is_main") for t in self.threads_cache.get(node, []))
if is_main or not thread_id:
self.history_cache[(node, None)] = cur_msgs
# Synchronous actions executed from UI
def send_chat_message(self, node: str, thread_id: str, text: str) -> tuple[bool, str]:
is_main = False
with self.lock:
for t in self.threads_cache.get(node, []):
if t.get("session_id") == thread_id and t.get("is_main"):
is_main = True
break
cmd = [str(BIN_DIR / "muse-cli-node"), node, "send", "--wait", "0"]
if thread_id and not is_main:
cmd.extend(["--thread", thread_id])
cmd.append(text)
last_err = ""
for attempt in range(3):
try:
rc, stdout, stderr = run_command_isolated(cmd, timeout=15.0)
if rc == 0:
self.clear_node_rate_limit(node)
self.record_interaction(node)
self.record_activity()
return True, "Message sent successfully"
err_text = (stderr + " " + stdout).lower()
if any(pat in err_text for pat in ["429", "rate limit", "too many requests", "throttled", "ratelimitexceeded"]):
retry_match = re.search(r"retry[-_]after[:\s]+(\d+)", err_text)
retry_val = float(retry_match.group(1)) if retry_match else None
self.mark_node_rate_limited(node, reason="Send hit rate limit", retry_after=retry_val)
return False, "Server rate limit reached"
last_err = (stderr or stdout or "").strip()
time.sleep(0.2)
except Exception as e:
last_err = str(e)
time.sleep(0.2)
return False, last_err
def create_sidechat(self, node: str, title: str) -> tuple[bool, str, str]:
cmd = [str(BIN_DIR / "muse-cli-node"), node, "session-start", "--title", title]
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if res.returncode == 0:
try:
data = json.loads(res.stdout)
sid = data.get("session_id")
if sid:
self._fetch_threads(node)
self.set_active_target(node, sid, title)
return True, sid, "Created sidechat successfully"
except Exception:
pass
return False, "", res.stderr or res.stdout
except Exception as e:
return False, "", str(e)
def issue_work_order(self, node: str, thread_id: str, title: str, body: str, priority: str = "routine") -> tuple[bool, str]:
wo_id = hashlib.sha256(f"super-{node}-{title}-{time.time()}".encode()).hexdigest()[:8]
prefix = "[URGENT] " if priority == "urgent" else ""
wo_content = f"{prefix}[WO:{wo_id}] [from super] {title} — {body}"
return self.send_chat_message(node, thread_id, wo_content)
def resolve_approval(self, node: str, action: str, **kwargs) -> tuple[bool, str]:
if not approvals_engine:
return False, "Approvals engine not loaded"
try:
if action == "allow":
res = approvals_engine.allow_node_approval(node, always=False, force=True, caller="muse-tui")
elif action == "always":
res = approvals_engine.allow_node_approval(node, always=True, force=True, caller="muse-tui")
elif action == "deny":
res = approvals_engine.deny_node_approval(node, caller="muse-tui")
elif action == "auto":
res = approvals_engine.auto_approve_fleet(caller="muse-tui")
elif action == "reply":
msg = kwargs.get("message", "Proceed with standard execution.")
res = approvals_engine.reply_node_task(node, msg, caller="muse-tui")
elif action == "dismiss":
res = approvals_engine.dismiss_node_task(node, caller="muse-tui")
else:
return False, f"Unknown action: {action}"
self.trigger_refresh()
ok = res.get("ok", False)
msg = res.get("message") or res.get("result") or res.get("error") or str(res)
return ok, msg
except Exception as e:
return False, str(e)
def dismiss_task(self, node: str) -> tuple[bool, str]:
if not approvals_engine:
return False, "Approvals engine not loaded"
try:
res = approvals_engine.dismiss_node_task(node, caller="muse-tui")
self.trigger_refresh()
ok = res.get("ok", False)
msg = res.get("result") or res.get("error") or str(res)
return ok, msg
except Exception as e:
return False, str(e)
def clear_all_waits(self) -> tuple[bool, str]:
if not approvals_engine:
return False, "Approvals engine not loaded"
try:
res = approvals_engine.clear_node_waits(None, caller="muse-tui")
self.trigger_refresh()
count = res.get("total_cleared", 0)
return True, f"Cleared {count} task wait(s)"
except Exception as e:
return False, str(e)
def trigger_job(self, job_name: str) -> tuple[bool, str]:
try:
cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-trigger", job_name]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=8)
self.trigger_refresh()
if res.returncode == 0:
return True, f"Triggered job {job_name}"
return False, res.stderr or res.stdout
except Exception as e:
return False, str(e)
def kill_tmux_session(self, sess_name: str) -> tuple[bool, str]:
try:
clean_name = sess_name.split(":")[0].strip()
cmd = ["tmux", "-S", "/tmp/tmux-muse.sock", "kill-session", "-t", clean_name]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
self.trigger_refresh()
if res.returncode == 0:
return True, f"Killed session {clean_name}"
return False, res.stderr or res.stdout
except Exception as e:
return False, str(e)
# ---------------------------------------------------------------------------
# Curses TUI Interface
# ---------------------------------------------------------------------------
class MuseTUI:
"""Full-terminal curses application supporting Muse and Box operational modes."""
def __init__(self, stdscr, initial_mode="muse", initial_node=None, initial_thread=None, initial_tab=None):
self.stdscr = stdscr
self.mode = initial_mode # "muse" or "box"
self.box_tab = 0 # 0: Chat, 1: Fleet, 2: Approvals, 3: Jobs, 4: Tmux, 5: Logs, 6: SSH
if initial_mode == "box" and initial_tab is not None:
tab_map = {"chat": 0, "fleet": 1, "approvals": 2, "jobs": 3, "tmux": 4, "logs": 5, "ssh": 6}
self.box_tab = tab_map.get(str(initial_tab).lower(), 0)
self.data = FleetDataManager()
# Selection state: default to top of sorted list (highest unread / most recently interacted)
if initial_node and initial_node in self.data.nodes:
target_node = initial_node
elif self.data.nodes:
target_node = self.data.nodes[0]
else:
target_node = "muse"
if initial_thread:
self.data.active_node = target_node
self.data.active_thread_id = initial_thread
found_title = None
for t in self.data.threads_cache.get(target_node, []):
if t.get("session_id") == initial_thread:
found_title = t.get("title")
break
self.data.active_thread_title = found_title or (initial_thread[:8] if initial_thread else "Sidechat")
else:
self.data.select_main_chat(target_node)
# Navigation & Modal State (Neovim-style modal lock)
# editor_mode: "NORMAL" (key nav locked, composer locked) or "INSERT" (typing in prompt)
self.editor_mode = "NORMAL"
# focus_pane: "fleet" (agents list), "sidechats" / "sidebar" (threads list), or "transcript" (chat scroll)
self.focus_pane = "sidechats"
self.fleet_focus_idx = 0
self.thread_sel_idx = 0
self.transcript_scroll_offset = 0 # 0 = locked at bottom
self.auto_scroll = True
self.transcript_total_lines = 0
self.transcript_height = 20
self._transcript_cache_key = None
self._transcript_cache_lines = []
# Selection heads for Box mode tabs
self.fleet_sel_idx = 0
self.approvals_sel_idx = 0
self.jobs_sel_idx = 0
self.jobs_scroll_start = 0
self.tmux_sel_idx = 0
self.ssh_sel_idx = 0
self.ssh_scroll_idx = 0
self.table_scroll_idx = 0
# Input buffer
self.input_buf = ""
self.input_cursor = 0
self.input_history = []
self.history_idx = -1
# Box mode tables scroll
self.table_scroll_idx = 0
# Fleet agent scroll & search state
self.agent_scroll_start = 0
self.agent_visible_count = 6
self.agent_search_sel = 0
self.agent_search_scroll = 0
# Modals: None, "help", "approvals", "new_sidechat", "work_order", "reply_task", "agent_search", "prompts", "history_search", "prompt_formulate", "chat_context", "agent_context", "message_context"
self.modal = None
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.modal_extra = {}
# Context Menus (Right-Click & Action Panels)
self.context_chat = None
self.chat_context_sel = 0
self.context_agent = None
self.agent_context_sel = 0
self.context_message = None
self.message_context_sel = 0
self._last_right_click_time = 0.0
# Prompt & Skill System
self.prompt_manager = PromptManager()
self.prompts = self.prompt_manager.get_sorted_prompts()
self.prompt_scroll_idx = 0
self.prompt_sel_idx = 0
self.prompt_preview_scroll = 0
self._prompt_chip_bounds = []
# Chat history sends search state
self.history_search_sel = 0
self.history_search_scroll = 0
# Clipboard, Copy, Selection & Reply state
self.clipboard_buf = ""
self._btn_copy_all_bounds = None
self._msg_copy_buttons = []
self._msg_reply_buttons = []
self.reply_target = None # None or dict: {"msg_id": ..., "seq": ..., "text": ..., "preview": ...}
self.copy_mode = False # True when in tmux copy mode
self.copy_cursor_line = 0 # Absolute line index in rendered_lines
self.visual_sel_start = None # Start line index of visual selection, or None
self.mouse_dragging = False
# Transcript display style: 'clean' (default, borderless line-by-line) or 'boxed' (classic ASCII frames)
self.transcript_style = self._load_transcript_style()
# Status toast
self.toast_msg = "Welcome to Muse TUI. Press '?' or F1 for help, 'i' to chat."
self.toast_style = "info"
self.toast_time = time.time() + 4.0
# Exit state
self.should_exit = False
# Chat dwell time tracking for clearing sidechat notifications (clears after > 5.0s dwell)
self.cur_chat_enter_time = time.time()
self.last_dwell_check_target = (self.data.active_node, self.data.active_thread_id)
# Device modifier substrate layer (Alt+Space leader capture)
self.device_layer_active = False
self.device_layer_timeout = 0.0
# Colors setup
self._init_colors()
def _init_colors(self):
try:
curses.curs_set(0) # Start with cursor parked in NORMAL mode
except Exception:
pass
try:
self.stdscr.keypad(True)
self.stdscr.timeout(50) # 50ms getch non-blocking loop
except Exception:
pass
if hasattr(curses, "set_escdelay"):
try:
curses.set_escdelay(25)
except Exception:
pass
# Mouse configuration (clicks and scroll wheel)
try:
curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
if hasattr(curses, "mouseinterval"):
curses.mouseinterval(0)
sys.stdout.write("\033[?1000h\033[?1002h\033[?1006h\033[?2004h")
sys.stdout.flush()
except Exception:
pass
try:
self.has_colors = curses.has_colors()
except Exception:
self.has_colors = False
if self.has_colors:
try:
curses.start_color()
curses.use_default_colors()
# Color pairs: (FG, BG)
# 1: Cyan (User)
curses.init_pair(1, curses.COLOR_CYAN, -1)
# 2: Yellow (Agent / Muse)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
# 3: Green (Success, Work Order, Active)
curses.init_pair(3, curses.COLOR_GREEN, -1)
# 4: Red (Error, Blocked, Deny)
curses.init_pair(4, curses.COLOR_RED, -1)
# 5: Magenta (Tool execution, Swarm)
curses.init_pair(5, curses.COLOR_MAGENTA, -1)
# 6: Header / Tabs selected / Normal mode badge
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN)
# 7: Active pill / selection
curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_WHITE)
# 8: Warning banner
curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW)
# 9: Work order badge / Insert mode badge
curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_GREEN)
# 10: Dim text
curses.init_pair(10, curses.COLOR_WHITE, -1)
except Exception:
self.has_colors = False
def _attr(self, name: str):
if not self.has_colors:
if name in ("bold", "user", "agent"):
return curses.A_BOLD
if name in ("button", "header", "selected", "mode_normal", "mode_insert"):
return curses.A_REVERSE
if name == "dim":
return curses.A_DIM
return curses.A_NORMAL
table = {
"normal": curses.color_pair(0),
"user": curses.color_pair(1) | curses.A_BOLD,
"agent": curses.color_pair(2) | curses.A_BOLD,
"success": curses.color_pair(3) | curses.A_BOLD,
"green": curses.color_pair(3),
"danger": curses.color_pair(4) | curses.A_BOLD,
"red": curses.color_pair(4),
"tool": curses.color_pair(5),
"header": curses.color_pair(6) | curses.A_BOLD,
"selected": curses.color_pair(7) | curses.A_BOLD,
"warn_banner": curses.color_pair(8) | curses.A_BOLD,
"wo_badge": curses.color_pair(9) | curses.A_BOLD,
"dim": curses.color_pair(10) | curses.A_DIM,
"bold": curses.A_BOLD,
"button": curses.color_pair(7) | curses.A_BOLD,
"mode_normal": curses.color_pair(6) | curses.A_BOLD,
"mode_insert": curses.color_pair(9) | curses.A_BOLD,
}
return table.get(name, curses.A_NORMAL)
def safe_addstr(self, win, y: int, x: int, text: str, attr=0):
"""Safely write string to window without crashing on right/bottom edge."""
max_y, max_x = getattr(self, "_term_dims", (0, 0))
if max_y == 0 or max_x == 0:
max_y, max_x = win.getmaxyx()
if y < 0 or y >= max_y or x < 0 or x >= max_x:
return
available = max_x - x
if available <= 0:
return
slice_text = text[:available]
try:
win.addstr(y, x, slice_text, attr)
except curses.error:
pass
def set_toast(self, msg: str, style="info", duration=4.0):
self.toast_msg = msg
self.toast_style = style
self.toast_time = time.time() + duration
# -----------------------------------------------------------------------
# Main Render Pipeline
# -----------------------------------------------------------------------
def run(self):
try:
last_dims = (0, 0)
last_modal = None
last_mode_state = None
while True:
h, w = self.stdscr.getmaxyx()
self._term_dims = (h, w)
# Only full-erase on resize, modal open/close, mode/tab switch, or explicit flag
cur_mode_state = (self.mode, self.box_tab)
if ((h, w) != last_dims or
self.modal != last_modal or
cur_mode_state != last_mode_state or
getattr(self, "need_full_redraw", False)):
self.stdscr.erase()
last_dims = (h, w)
last_modal = self.modal
last_mode_state = cur_mode_state
self.need_full_redraw = False
if h < 14 or w < 60:
self.safe_addstr(self.stdscr, 0, 0, f"Terminal too small ({w}x{h}). Need at least 60x14.", self._attr("danger"))
self.stdscr.refresh()
time.sleep(0.1)
ch = self.stdscr.getch()
if ch == ord('q') or ch == 27:
break
continue
# 1. Top Header Bar
self._render_header(0, 0, w)
# 2. Mode / Tab bar
header_lines = 1
if self.mode == "box":
self._render_box_tabs(1, 0, w)
header_lines = 2
content_y = header_lines
bottom_bar_h = 3 # Input bar + Toast / keyhints
is_main_view = (self.mode == "muse" or (self.mode == "box" and self.box_tab == 0))
prompt_shelf_h = 2 if is_main_view else 0
content_h = h - content_y - bottom_bar_h - prompt_shelf_h
prompt_shelf_y = content_y + content_h
# 3. Main Content & Chat Dwell Tracking
if is_main_view:
# Dwell timer: when viewing an active sidechat for >5 seconds, clear unread notification counter
cur_dwell_target = (self.data.active_node, self.data.active_thread_id)
if cur_dwell_target != getattr(self, "last_dwell_check_target", None):
self.cur_chat_enter_time = time.time()
self.last_dwell_check_target = cur_dwell_target
elif cur_dwell_target[0] and cur_dwell_target[1]:
# Check if dwell threshold (5 seconds) has elapsed
if time.time() - getattr(self, "cur_chat_enter_time", 0.0) >= 5.0:
# Only clear if user is looking at bottom or auto-scrolling
if getattr(self, "auto_scroll", True) or getattr(self, "transcript_scroll_offset", 0) == 0:
self.data.mark_thread_read(cur_dwell_target[0], cur_dwell_target[1])
self._render_muse_view(content_y, 0, content_h, w)
self._render_prompt_shelf(prompt_shelf_y, 0, prompt_shelf_h, w)
elif self.mode == "box":
if self.box_tab == 1:
self._render_fleet_table(content_y, 0, content_h, w)
elif self.box_tab == 2:
self._render_approvals_view(content_y, 0, content_h, w)
elif self.box_tab == 3:
self._render_jobs_view(content_y, 0, content_h, w)
elif self.box_tab == 4:
self._render_tmux_view(content_y, 0, content_h, w)
elif self.box_tab == 5:
self._render_dm_logs_view(content_y, 0, content_h, w)
elif self.box_tab == 6:
self._render_ssh_view(content_y, 0, content_h, w)
# 4. Bottom Input Bar & Toast
self._render_bottom_bar(h - bottom_bar_h, 0, bottom_bar_h, w)
# 5. Overlays / Modals (if active)
if self.modal:
self._render_modal(h, w)
self.stdscr.refresh()
# Handle Input with Keystroke Draining / Batching to eliminate typing lag
ch = self.stdscr.getch()
if ch != -1:
while ch != -1:
if not self._handle_key(ch):
return
if getattr(self, "should_exit", False):
return
# If in INSERT mode or modal text input, drain all pending keystrokes before redrawing
if self.editor_mode == "INSERT" or (self.modal in ("agent_search", "new_sidechat", "work_order", "history_search", "prompt_formulate")):
self.stdscr.nodelay(True)
try:
ch = self.stdscr.getch()
finally:
self.stdscr.timeout(50)
else:
break
except KeyboardInterrupt:
pass
except Exception as e:
import traceback
crash_log = REPO_ROOT / "logs" / "tui-crash.log"
try:
crash_log.parent.mkdir(parents=True, exist_ok=True)
with open(crash_log, "a", encoding="utf-8") as f:
f.write(f"\n--- CRASH AT {datetime.now().isoformat()} ---\n")
traceback.print_exc(file=f)
except Exception:
pass
raise
finally:
try:
sys.stdout.write("\033[?1000l\033[?1002l\033[?1006l\033[?2004l")
sys.stdout.flush()
except Exception:
pass
self.data.stop()
# -----------------------------------------------------------------------
# Header & Tabs Rendering
# -----------------------------------------------------------------------
def _render_header(self, y: int, x: int, w: int):
title = " MUSE TUI // NetVM Autonomous Agent Terminal " if self.mode == "muse" else " BOX TUI // NetVM Fleet Command Console "
self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("header"))
self.safe_addstr(self.stdscr, y, x + 1, title, self._attr("header"))
# Device Substrate banner, Rate-limit Cooldown, or Health & pending approvals badge
now = time.time()
active_cd = self.data.get_node_cooldown_remaining(self.data.active_node)
is_dev_layer = getattr(self, "device_layer_active", False) and now <= self.device_layer_timeout
if active_cd > 0:
cd_txt = f" ⏳ {self.data.active_node.upper()}: RATE-LIMIT COOLDOWN ({active_cd}s) "
self.safe_addstr(self.stdscr, y, max(0, w - len(cd_txt) - 22), cd_txt, self._attr("warn_banner"))
elif is_dev_layer:
with self.data.lock:
active_node = self.data.active_node.upper()
layer_txt = f" 󰢹 DEVICE LAYER: [{active_node}] (0-9: Select | Space: Cycle | Esc: Lock) "
self.safe_addstr(self.stdscr, y, max(0, w - len(layer_txt) - 22), layer_txt, self._attr("warn_banner"))
else:
with self.data.lock:
blocked_nodes = [n for n, s in self.data.node_status.items() if s.get("blocked")]
waiting_nodes = [n for n, s in self.data.node_status.items() if s.get("waiting")]
online_count = sum(1 for s in self.data.node_status.values() if s.get("status") == "ACTIVE")
total_nodes = len(self.data.nodes)
badge_txt = f" Fleet: {online_count}/{total_nodes} Up "
self.safe_addstr(self.stdscr, y, w - len(badge_txt) - 22, badge_txt, self._attr("selected"))
if blocked_nodes:
app_txt = f" ⚠ {len(blocked_nodes)} BLOCKED (Press 'a') "
self.safe_addstr(self.stdscr, y, w - len(app_txt) - len(badge_txt) - 24, app_txt, self._attr("warn_banner"))
elif waiting_nodes:
app_txt = f" 󰏤 {len(waiting_nodes)} WAITING (Press 'a') "
self.safe_addstr(self.stdscr, y, w - len(app_txt) - len(badge_txt) - 24, app_txt, self._attr("dim"))
clock = datetime.now().strftime("%H:%M:%S")
self.safe_addstr(self.stdscr, y, w - len(clock) - 2, clock, self._attr("header"))
def _render_box_tabs(self, y: int, x: int, w: int):
tabs = BOX_TABS
self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("dim"))
cur_x = x + 1
for idx, tab_name in enumerate(tabs):
is_active = (self.box_tab == idx)
attr = self._attr("selected") if is_active else self._attr("dim")
label = f" [{tab_name}] "
self.safe_addstr(self.stdscr, y, cur_x, label, attr)
cur_x += len(label) + 1
# -----------------------------------------------------------------------
# Muse Conversational View (Split Sidebar + Transcript)
# -----------------------------------------------------------------------
def _render_muse_view(self, y: int, x: int, h: int, w: int):
sidebar_w = min(36, max(26, w // 4))
chat_w = w - sidebar_w - 1
is_sidebar_focused = (self.focus_pane in ("sidebar", "sidechats", "fleet") and self.editor_mode == "NORMAL")
is_transcript_focused = (self.focus_pane == "transcript" and self.editor_mode == "NORMAL")
# Vertical Divider: prominently highlights active pane border
div_char = "┃" if (is_sidebar_focused or is_transcript_focused) else "│"
div_attr = self._attr("header") if is_sidebar_focused else (self._attr("user") if is_transcript_focused else self._attr("dim"))
for line in range(h):
self.safe_addstr(self.stdscr, y + line, sidebar_w, div_char, div_attr)
# Render Left Sidebar (Nodes + Threads)
self._render_sidebar(y, x, h, sidebar_w)
# Render Right Transcript (Chat History)
self._render_transcript(y, sidebar_w + 1, h, chat_w)
def _render_sidebar(self, y: int, x: int, h: int, w: int):
with self.data.lock:
active_node = self.data.active_node
statuses = dict(self.data.node_status)
nodes = list(self.data.nodes)
total_nodes = len(nodes)
# Allocate visible rows for agents: at least 3, at most 7, leaving ample space for threads
max_agent_rows = min(7, max(3, (h - 10) // 2))
visible_agent_count = min(total_nodes, max_agent_rows)
self.agent_visible_count = visible_agent_count
# Ensure active_node or fleet_focus_idx is in valid range
if getattr(self, "fleet_focus_idx", None) is None:
self.fleet_focus_idx = 0
if self.fleet_focus_idx >= total_nodes:
self.fleet_focus_idx = max(0, total_nodes - 1)
is_fleet_focused = (self.focus_pane in ("fleet", "sidebar") and self.editor_mode == "NORMAL")
is_sidechats_focused = (self.focus_pane in ("sidechats", "sidebar") and self.editor_mode == "NORMAL")
# Keep agent_scroll_start tracking focus index when fleet partition is focused
if is_fleet_focused:
target_agent_idx = self.fleet_focus_idx
else:
try:
target_agent_idx = nodes.index(active_node)
except ValueError:
target_agent_idx = 0
if target_agent_idx < self.agent_scroll_start:
self.agent_scroll_start = target_agent_idx
elif target_agent_idx >= self.agent_scroll_start + visible_agent_count:
self.agent_scroll_start = target_agent_idx - visible_agent_count + 1
max_scroll = max(0, total_nodes - visible_agent_count)
self.agent_scroll_start = max(0, min(self.agent_scroll_start, max_scroll))
# 1. Fleet Nodes Section Header
if total_nodes > visible_agent_count:
end_num = min(total_nodes, self.agent_scroll_start + visible_agent_count)
count_str = f"({self.agent_scroll_start + 1}-{end_num}/{total_nodes})"
else:
count_str = f"({total_nodes})"
fleet_unread_total = self.data.get_fleet_unread_total()
unread_badge = f" [● {fleet_unread_total} New]" if fleet_unread_total > 0 else ""
focus_pill = "[● FOCUS] " if is_fleet_focused else ""
header_title = f"{focus_pill}FLEET AGENTS {count_str}{unread_badge}"
self.safe_addstr(self.stdscr, y, x + 1, " " * (w - 2), self._attr("header") if is_fleet_focused else self._attr("dim"))
hdr_attr = self._attr("header") if is_fleet_focused else (self._attr("warn_banner") if unread_badge else self._attr("bold"))
self.safe_addstr(self.stdscr, y, x + 1, header_title, hdr_attr)
search_btn = "[🔍 /]"
btn_x = max(x + len(header_title) + 2, x + w - len(search_btn) - 1)
self.safe_addstr(self.stdscr, y, btn_x, search_btn, self._attr("header") if is_fleet_focused else self._attr("warn_banner"))
node_y = y + 1
for row_i in range(visible_agent_count):
idx = self.agent_scroll_start + row_i
if idx >= total_nodes:
break
node = nodes[idx]
st_info = statuses.get(node, {})
is_active = (node == active_node)
is_highlighted = (is_fleet_focused and idx == self.fleet_focus_idx)
is_blocked = st_info.get("blocked", False)
is_waiting = st_info.get("waiting", False)
latency = st_info.get("latency")
lat_str = f"{latency}ms" if latency is not None else "--"
node_unread = self.data.get_node_unread_total(node)
dot = "●"
if is_blocked:
dot_attr = self._attr("danger")
elif is_waiting:
dot_attr = self._attr("warn_banner")
elif st_info.get("status") == "ACTIVE":
dot_attr = self._attr("success")
else:
dot_attr = self._attr("red")
if idx < 9:
num_tag = f"[{idx + 1}]"
elif idx == 9:
num_tag = "[0]"
else:
num_tag = f"[{idx + 1:>2}]"
# Arrow indicator for scroll boundaries
scroll_arrow = ""
if row_i == 0 and self.agent_scroll_start > 0:
scroll_arrow = "▲"
elif row_i == visible_agent_count - 1 and self.agent_scroll_start + visible_agent_count < total_nodes:
scroll_arrow = "▼"
pointer = "▶" if is_active else (" " if not scroll_arrow else scroll_arrow)
line_str = f" {pointer}{num_tag} {node:<5} "
attr = self._attr("selected") if is_highlighted else (self._attr("bold") if is_active else self._attr("normal"))
self.safe_addstr(self.stdscr, node_y, x + 1, " " * (w - 2), attr)
self.safe_addstr(self.stdscr, node_y, x + 1, line_str, attr)
self.safe_addstr(self.stdscr, node_y, x + 12, dot, dot_attr)
# Right-aligned latency tag [0ms]
right_col = x + w - 2
lat_disp = f"[{lat_str}]"
self.safe_addstr(self.stdscr, node_y, right_col - len(lat_disp), lat_disp, self._attr("dim") if not is_highlighted else self._attr("selected"))
# Notification badge & status labels
status_pos = right_col - len(lat_disp) - 1
if node_unread > 0:
badge_str = f"●{node_unread}"
self.safe_addstr(self.stdscr, node_y, status_pos - len(badge_str), badge_str, self._attr("warn_banner"))
status_pos -= (len(badge_str) + 1)
if is_blocked:
blk_str = "BLK"
self.safe_addstr(self.stdscr, node_y, status_pos - len(blk_str), blk_str, self._attr("danger"))
elif is_waiting:
wait_str = "WAIT"
self.safe_addstr(self.stdscr, node_y, status_pos - len(wait_str), wait_str, self._attr("warn_banner"))
node_y += 1
# Divider between nodes and threads
divider_y = node_y
div_attr = self._attr("header") if (is_fleet_focused or is_sidechats_focused) else self._attr("dim")
self.safe_addstr(self.stdscr, divider_y, x, "─" * w, div_attr)
# 2. Threads / Sidechats Section
threads_header_y = divider_y + 1
hdr_attr = self._attr("header") if is_sidechats_focused else self._attr("bold")
is_loading = self.data.is_loading_threads(active_node)
spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
spin_char = spinner_chars[int(time.time() * 8) % len(spinner_chars)]
node_unread_total = self.data.get_node_unread_total(active_node)
unread_hdr_badge = f" [● {node_unread_total}]" if node_unread_total > 0 else ""
if is_loading:
hdr_badge = f"[{spin_char} Syncing]"
else:
hdr_badge = "[n:new ⚡:menu r:sync]"
focus_dot = "● " if is_sidechats_focused else ""
hdr_left = f"{focus_dot}SIDECHATS: {active_node.upper()}{unread_hdr_badge}"
self.safe_addstr(self.stdscr, threads_header_y, x + 1, " " * (w - 2), self._attr("header") if is_sidechats_focused else self._attr("dim"))
self.safe_addstr(self.stdscr, threads_header_y, x + 1, hdr_left, self._attr("header") if is_sidechats_focused else (self._attr("warn_banner") if unread_hdr_badge else hdr_attr))
btn_x = max(x + len(hdr_left) + 2, x + w - len(hdr_badge) - 1)
self.safe_addstr(self.stdscr, threads_header_y, btn_x, hdr_badge, self._attr("header") if is_sidechats_focused else self._attr("dim"))
list_y = threads_header_y + 1
max_threads_visible = (y + h) - list_y
with self.data.lock:
threads = list(self.data.threads_cache.get(active_node, []))
cur_thread_id = self.data.active_thread_id
if not threads:
if is_loading or active_node not in self.data.loaded_nodes:
self.safe_addstr(self.stdscr, list_y, x + 2, f"{spin_char} Loading sidechats for {active_node.upper()}...", self._attr("agent"))
self.safe_addstr(self.stdscr, list_y + 1, x + 2, "Updating dynamically in background...", self._attr("dim"))
self.safe_addstr(self.stdscr, list_y + 2, x + 2, "Press 'n' to create new sidechat", self._attr("dim"))
else:
self.safe_addstr(self.stdscr, list_y, x + 2, f"(No sidechats on {active_node})", self._attr("dim"))
self.safe_addstr(self.stdscr, list_y + 1, x + 2, "Press 'n' to create sidechat", self._attr("dim"))
return
# Sync selection head to active thread if sidechats are not focused
cur_idx = None
for idx, t in enumerate(threads):
if t.get("session_id") == cur_thread_id:
cur_idx = idx
break
if cur_idx is not None and not is_sidechats_focused and self.thread_sel_idx != cur_idx:
self.thread_sel_idx = cur_idx
# Ensure selection in bounds
if self.thread_sel_idx >= len(threads):
self.thread_sel_idx = max(0, len(threads) - 1)
# Scroll window for threads
scroll_start = 0
if self.thread_sel_idx >= max_threads_visible:
scroll_start = self.thread_sel_idx - max_threads_visible + 1
self.threads_scroll_start = scroll_start
for i in range(max_threads_visible):
item_idx = scroll_start + i
if item_idx >= len(threads):
break
t = threads[item_idx]
sid = t.get("session_id", "")
title = t.get("title") or sid[:8]
is_main = t.get("is_main", False)
is_pinned = t.get("pinned", False)
is_cur = (sid == cur_thread_id)
is_highlighted = (item_idx == self.thread_sel_idx and is_sidechats_focused)
if is_cur:
indicator = "▶ "
elif is_main:
indicator = "★ "
elif is_pinned:
indicator = "📌 "
else:
indicator = " "
item_line_y = list_y + i
unread_cnt = self.data.get_unread_count(active_node, sid)
if is_main:
title_disp = "Main Chat"
else:
title_disp = title
# Row background
if is_highlighted:
row_attr = self._attr("selected")
elif is_cur:
row_attr = self._attr("bold")
else:
row_attr = self._attr("normal")
self.safe_addstr(self.stdscr, item_line_y, x + 1, " " * (w - 2), row_attr)
# Right elements: sid tag [xxxx] and action button [⚡]
sid_tag = f"[{sid[:4]}]"
sid_x = x + w - 1 - len(sid_tag)
self.safe_addstr(self.stdscr, item_line_y, sid_x, sid_tag, self._attr("dim") if not is_highlighted else self._attr("selected"))
# Context menu button [⚡] next to session id for easy click
menu_btn = "[⚡]"
menu_btn_x = sid_x - len(menu_btn) - 1
btn_attr = self._attr("header") if is_cur else (self._attr("selected") if is_highlighted else self._attr("dim"))
self.safe_addstr(self.stdscr, item_line_y, menu_btn_x, menu_btn, btn_attr)
badge_end_x = menu_btn_x - 1
# Notification Badge: ●N
if unread_cnt > 0:
badge_str = f"●{unread_cnt}"
badge_x = max(x + 10, badge_end_x - len(badge_str))
self.safe_addstr(self.stdscr, item_line_y, badge_x, badge_str, self._attr("warn_banner"))
avail_title_end = badge_x - 1
else:
avail_title_end = badge_end_x
# Indicator & Title
ind_attr = self._attr("success") if is_cur else (self._attr("header") if is_main else (self._attr("warn_banner") if is_pinned else self._attr("dim")))
self.safe_addstr(self.stdscr, item_line_y, x + 1, indicator, ind_attr)
title_start_x = x + 1 + len(indicator)
avail_title_w = max(3, avail_title_end - title_start_x)
if len(title_disp) > avail_title_w:
title_disp = title_disp[:max(2, avail_title_w - 1)] + "…"
title_attr = self._attr("selected") if is_highlighted else (self._attr("bold") if is_cur else (self._attr("bold") if unread_cnt > 0 else self._attr("normal")))
self.safe_addstr(self.stdscr, item_line_y, title_start_x, title_disp, title_attr)
def _load_transcript_style(self) -> str:
"""Load transcript display style from config, defaulting to 'clean'."""
try:
cfg_path = Path.home() / ".config" / "muse-cli" / "tui_settings.json"
if cfg_path.exists():
data = json.loads(cfg_path.read_text(encoding="utf-8"))
return data.get("transcript_style", "clean")
except Exception:
pass
return "clean"
def _save_transcript_style(self):
"""Persist transcript display style to ~/.config/muse-cli/tui_settings.json."""
try:
cfg_path = Path.home() / ".config" / "muse-cli" / "tui_settings.json"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
data = {}
if cfg_path.exists():
try:
data = json.loads(cfg_path.read_text(encoding="utf-8"))
except Exception:
data = {}
data["transcript_style"] = self.transcript_style
cfg_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
except Exception:
pass
def set_transcript_style(self, style: str):
"""Set transcript display style ('clean' or 'boxed')."""
if style not in ("clean", "boxed"):
style = "clean"
self.transcript_style = style
self._transcript_cache_key = None # Force re-render of transcript
self._save_transcript_style()
lbl = "Clean line-by-line" if self.transcript_style == "clean" else "Classic boxed frames"
self.set_toast(f"Switched transcript view to: {lbl} (press 'b' to toggle)", "success")
def toggle_transcript_style(self):
"""Toggle between clean line-by-line and classic boxed frame transcript styles."""
nxt = "boxed" if getattr(self, "transcript_style", "clean") == "clean" else "clean"
self.set_transcript_style(nxt)
def get_formatted_transcript(self, node: Optional[str] = None, thread_id: Optional[str] = None) -> str:
"""Return structured formatted Markdown of chat transcript for active or specified thread."""
active_node = node or self.data.active_node
active_tid = thread_id or self.data.active_thread_id
active_title = self.data.active_thread_title or "Untitled Thread"
with self.data.lock:
msgs = list(self.data.history_cache.get((active_node, active_tid), []))
if not msgs:
return f"# Chat Transcript: {active_title}\nNode: {active_node.upper()} | Thread ID: {active_tid or 'None'}\n(Empty transcript)"
lines = [
f"# Chat Transcript: {active_title}",
f"Node: {active_node.upper()} | Thread ID: {active_tid or 'None'} | Total Messages: {len(msgs)}",
"──────────────────────────────────────────────────\n",
]
for m in msgs:
role = m.get("role", "assistant")
text = m.get("text", "")
seq = m.get("seq", "")
mid = m.get("message_id", "")
mid_str = f" id:{mid[:8]}" if mid else ""
seq_str = f" seq:{seq}" if seq else ""
if role == "assistant":
lines.append(f"### [{active_node.upper()} / MUSE AGENT]{seq_str}{mid_str}")
else:
lines.append(f"### [YOU / OPERATOR]{mid_str}")
lines.append(text)
lines.append("")
return "\n".join(lines).rstrip()
def copy_transcript_to_clipboard(self) -> bool:
"""Copy full transcript of active thread to system clipboard and update internal clipboard buffer."""
text = self.get_formatted_transcript()
self.clipboard_buf = text
ok = copy_to_clipboard(text)
active_node = self.data.active_node
active_tid = self.data.active_thread_id
with self.data.lock:
msg_count = len(self.data.history_cache.get((active_node, active_tid), []))
self.set_toast(f"📋 Copied full transcript ({msg_count} msgs, {len(text)} chars) to clipboard", "success")
return ok
def copy_message_to_clipboard(self, message_text: Optional[str] = None, with_context: bool = False) -> bool:
"""Copy an individual message to clipboard. If with_context is False, strips metadata/reply tags."""
text = message_text
if text is None:
active_node = self.data.active_node
active_tid = self.data.active_thread_id
with self.data.lock:
msgs = list(self.data.history_cache.get((active_node, active_tid), []))
if msgs:
text = msgs[-1].get("text", "")
if not text:
self.set_toast("⚠️ No message content to copy", "warn")
return False
if not with_context:
final_text = strip_message_metadata(text)
toast_label = "clean message"
else:
final_text = text
toast_label = "message with context"
self.clipboard_buf = final_text
ok = copy_to_clipboard(final_text)
preview = (final_text.replace("\n", " ")[:30] + "...") if len(final_text) > 30 else final_text.replace("\n", " ")
self.set_toast(f"📋 Copied {toast_label} to clipboard: \"{preview}\" ({len(final_text)} chars)", "success")
return ok
def trigger_reply_to_message(self, message: dict) -> bool:
"""Trigger reply to a specific message bubble or segment."""
if not message:
return False
mid = message.get("message_id") or ""
seq = message.get("seq") or ""
role = message.get("role", "assistant")
text = (message.get("text") or "").strip()
preview = text.replace("\n", " ")[:45] + ("…" if len(text) > 45 else "")
self.reply_target = {
"msg_id": mid,
"seq": seq,
"role": role,
"text": text,
"preview": preview,
}
self.editor_mode = "INSERT"
self.focus_pane = "transcript"
ref_id = f"seq:{seq}" if seq else f"id:{mid[:8]}"
self.set_toast(f"↩ Replying to {role.upper()} ({ref_id}): \"{preview}\"", "success")
return True
def _get_transcript_line_idx_at(self, screen_y: int):
"""Convert a screen y coordinate to an absolute line index in _transcript_cache_lines."""
h, w = self.stdscr.getmaxyx()
header_lines = 2 if self.mode == "box" else 1
content_y = header_lines
transcript_y = content_y + 2
transcript_h = getattr(self, "transcript_height", max(1, h - content_y - 5))
row = screen_y - transcript_y
if 0 <= row < transcript_h:
total_lines = getattr(self, "transcript_total_lines", 0)
if total_lines == 0:
return None
max_offset = max(0, total_lines - transcript_h)
start_line = max_offset - getattr(self, "transcript_scroll_offset", 0)
target_idx = start_line + row
if 0 <= target_idx < total_lines:
return target_idx
return None
def _get_message_at_transcript_line(self, screen_y: int):
"""Find the message object associated with the transcript line at screen_y."""
target_idx = self._get_transcript_line_idx_at(screen_y)
if target_idx is None:
return None
lines = getattr(self, "_transcript_cache_lines", [])
for i in range(target_idx, -1, -1):
if i < len(lines) and lines[i][2] is not None:
return lines[i][2]
return None
def _get_current_visible_line_idx(self) -> int:
"""Get the middle visible line index in transcript."""
total_lines = getattr(self, "transcript_total_lines", 0)
if total_lines == 0:
return 0
transcript_h = getattr(self, "transcript_height", 20)
max_offset = max(0, total_lines - transcript_h)
start_line = max_offset - getattr(self, "transcript_scroll_offset", 0)
return min(total_lines - 1, max(0, start_line + transcript_h // 2))
def _copy_rendered_line_range(self, start_idx: int, end_idx: int) -> str:
"""Extract clean text from rendered_lines across [start_idx, end_idx]."""
lines = getattr(self, "_transcript_cache_lines", [])
if not lines:
return ""
s = max(0, min(start_idx, end_idx))
e = min(len(lines) - 1, max(start_idx, end_idx))
out = []
for i in range(s, e + 1):
raw = lines[i][0]
# Strip decorative vertical borders, 2-space indents, and buttons
if raw.startswith("│ "):
clean = raw[3:]
elif raw.startswith("│ "):
clean = raw[2:]
elif raw.startswith("│"):
clean = raw[1:]
elif raw.startswith(" "):
clean = raw[2:]
elif raw.startswith("┌──") or raw.startswith("└──") or raw.startswith("● ") or raw.startswith("▸ "):
clean = re.sub(r"\[(↩ Reply|📋 Copy)\]", "", raw).strip()
else:
clean = raw
out.append(clean)
text = "\n".join(out).strip("\n")
if text:
self.clipboard_buf = text
copy_to_clipboard(text)
return text
def _render_transcript(self, y: int, x: int, h: int, w: int):
self._btn_copy_all_bounds = None
self._msg_copy_buttons = []
with self.data.lock:
active_node = self.data.active_node
active_tid = self.data.active_thread_id
active_title = self.data.active_thread_title
msgs = list(self.data.history_cache.get((active_node, active_tid), []))
if not msgs:
cached_threads = self.data.threads_cache.get(active_node, [])
is_cur_main = any(t.get("session_id") == active_tid and t.get("is_main") for t in cached_threads)
if is_cur_main or not active_tid:
fallback_msgs = self.data.history_cache.get((active_node, None), [])
if fallback_msgs:
msgs = list(fallback_msgs)
else:
for t in cached_threads:
if t.get("is_main") and t.get("session_id"):
fb = self.data.history_cache.get((active_node, t["session_id"]), [])
if fb:
msgs = list(fb)
break
# Automatically reset scroll when switching target node or thread
cur_target = (active_node, active_tid)
if cur_target != getattr(self, "_last_rendered_target", None):
self.auto_scroll = True
self.transcript_scroll_offset = 0
self._last_rendered_target = cur_target
# Thread Header Banner
is_focused = (self.focus_pane == "transcript" and self.editor_mode == "NORMAL")
badge = ""
is_main_chat = False
with self.data.lock:
cached_threads = self.data.threads_cache.get(active_node, [])
for t in cached_threads:
if t.get("session_id") == active_tid:
if t.get("is_main"):
badge = " [★ MAIN CHAT]"
is_main_chat = True
elif t.get("pinned"):
badge = " [📌 PINNED]"
break
tid_short = f"({active_tid[:8]}) " if active_tid else ""
cd = self.data.get_node_cooldown_remaining(active_node)
if cd > 0:
recency_badge = f"[⏳ {cd}s CD] "
else:
hist_ts = self.data.history_fetch_times.get((active_node, active_tid), self.data.fetch_times.get(active_node, 0.0))
if hist_ts > 0 and (time.time() - hist_ts) < 25.0:
recency_badge = "[LIVE] "
elif hist_ts > 0:
recency_badge = f"[{format_recency(hist_ts)}] "
else:
recency_badge = ""
node_str = f"| Node: {active_node.upper()} {recency_badge}"
# Compute unread count for current thread/main chat
chat_unread = self.data.get_unread_count(active_node, active_tid)
if chat_unread > 0:
msgs_str = f"| {len(msgs)} msgs "
unread_pill = f"[● {chat_unread} new] "
else:
msgs_str = f"| {len(msgs)} msgs "
unread_pill = ""
copy_all_btn = "[📋 Copy All]"
# Ensure message count, unread pill, and copy button ALWAYS have room
tail_w = len(msgs_str) + len(unread_pill) + len(copy_all_btn) + 4
avail_for_left = max(15, w - tail_w)
focus_pill = "[● FOCUSED] " if is_focused else ""
base_prefix = f" {focus_pill}Thread: ★ Main Chat " if is_main_chat else f" {focus_pill}Thread: "
raw_title = f"(\"{active_title}\")" if is_main_chat else active_title
meta = f"{badge} {tid_short}{node_str}".strip()
meta_str = f" {meta} " if meta else " "
max_title_w = avail_for_left - len(base_prefix) - len(meta_str)
if max_title_w < 6:
meta = f"{badge} {node_str}".strip()
meta_str = f" {meta} " if meta else " "
max_title_w = avail_for_left - len(base_prefix) - len(meta_str)
if max_title_w < 4:
meta_str = " "
max_title_w = avail_for_left - len(base_prefix) - 1
title_disp = (raw_title[:max(2, max_title_w - 1)] + "…") if len(raw_title) > max(2, max_title_w) else raw_title
elif len(raw_title) > max_title_w:
title_disp = raw_title[:max(2, max_title_w - 1)] + "…"
else:
title_disp = raw_title
header_txt = f"{base_prefix}{title_disp}{meta_str}{msgs_str}"
hdr_attr = self._attr("header") if is_focused else self._attr("bold")
self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("header") if is_focused else self._attr("dim"))
self.safe_addstr(self.stdscr, y, x + 1, header_txt, hdr_attr)
cur_x = x + 1 + len(header_txt)
if unread_pill:
self.safe_addstr(self.stdscr, y, cur_x, unread_pill, self._attr("warn_banner"))
cur_x += len(unread_pill)
# Place [📋 Copy All] and [⚡ Menu] immediately next to message total at top
if cur_x + len(copy_all_btn) <= x + w:
self.safe_addstr(self.stdscr, y, cur_x, copy_all_btn, self._attr("header") if is_focused else self._attr("button"))
self._btn_copy_all_bounds = (y, cur_x, cur_x + len(copy_all_btn))
cur_x += len(copy_all_btn) + 1
else:
self._btn_copy_all_bounds = None
menu_btn = "[⚡ Menu]"
if cur_x + len(menu_btn) <= x + w:
self.safe_addstr(self.stdscr, y, cur_x, menu_btn, self._attr("header") if is_focused else self._attr("button"))
self._btn_menu_bounds = (y, cur_x, cur_x + len(menu_btn))
else:
self._btn_menu_bounds = None
div_rule_char = "━" if is_focused else "─"
div_rule_attr = self._attr("user") if is_focused else self._attr("dim")
self.safe_addstr(self.stdscr, y + 1, x, div_rule_char * w, div_rule_attr)
transcript_y = y + 2
transcript_h = h - 2
if not msgs:
is_loading_hist = self.data.is_loading_history(active_node, active_tid)
if is_loading_hist:
spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
spin_char = spinner_chars[int(time.time() * 8) % len(spinner_chars)]
self.safe_addstr(self.stdscr, transcript_y + 2, x + 4, f"{spin_char} Loading message history for '{active_title}'...", self._attr("agent"))
self.safe_addstr(self.stdscr, transcript_y + 3, x + 4, "Syncing latest conversation packets...", self._attr("dim"))
else:
self.safe_addstr(self.stdscr, transcript_y + 2, x + 4, f"No messages in thread '{active_title}'.", self._attr("dim"))
self.safe_addstr(self.stdscr, transcript_y + 3, x + 4, "Type your prompt in the box below and press Enter to chat.", self._attr("dim"))
return
# Check wrapped lines cache to prevent CPU thrashing during scrolling
last_msg = msgs[-1] if msgs else {}
last_id = last_msg.get("seq") or last_msg.get("message_id") or ""
last_text_hash = hashlib.md5((last_msg.get("text") or "").encode()).hexdigest()[:8]
cache_key = (active_node, active_tid, len(msgs), last_id, last_text_hash, w, chat_unread, getattr(self, "transcript_style", "clean"))
if cache_key == getattr(self, "_transcript_cache_key", None) and getattr(self, "_transcript_cache_lines", None):
rendered_lines = self._transcript_cache_lines
else:
rendered_lines = []
max_line_w = max(10, w - 6)
unread_start_idx = len(msgs) - chat_unread if chat_unread > 0 else -1
is_boxed = (getattr(self, "transcript_style", "clean") == "boxed")
for m_idx, m in enumerate(msgs):
# If this message is the start of unread messages, render a prominent divider banner
if m_idx == unread_start_idx:
div_lbl = f" ● {chat_unread} NEW UNREAD MESSAGE{'S' if chat_unread > 1 else ''} "
pad_len = max(2, (max_line_w - len(div_lbl)) // 2)
div_str = "─" * pad_len + div_lbl + "─" * max(2, max_line_w - len(div_lbl) - pad_len)
rendered_lines.append((div_str, self._attr("warn_banner"), None))
rendered_lines.append(("", self._attr("normal"), None))
role = m.get("role", "assistant")
text = m.get("text", "")
seq = m.get("seq", "")
mid = m.get("message_id", "")[:8]
# Role Header
is_msg_unread = (m_idx >= unread_start_idx and unread_start_idx >= 0)
unread_flag = " ● NEW" if is_msg_unread else ""
if role == "assistant":
if is_boxed:
prefix = f"┌── [{active_node.upper()} / MUSE AGENT] ─── seq:{seq} id:{mid}{unread_flag}"
else:
seq_info = f"seq:{seq} " if seq else ""
mid_info = f"id:{mid}" if mid else ""
prefix = f"● {active_node.upper()} {seq_info}{mid_info}{unread_flag}".strip()
hdr_attr = self._attr("warn_banner") if is_msg_unread else self._attr("agent")
else:
if is_boxed:
prefix = f"┌── [YOU / OPERATOR] ─── id:{mid}{unread_flag}"
else:
mid_info = f"id:{mid}" if mid else ""
prefix = f"▸ YOU {mid_info}{unread_flag}".strip()
hdr_attr = self._attr("user")
reply_btn = "[↩ Reply]"
copy_clean_btn = "[📋 Copy]"
copy_ctx_btn = "[📑+ Context]"
menu_btn = "[⚡ Menu]"
btns_str = f"{reply_btn} {copy_clean_btn} {copy_ctx_btn} {menu_btn}"
target_col = max(len(prefix) + 2, max_line_w - len(btns_str))
hdr_line = prefix + " " * max(1, target_col - len(prefix)) + btns_str
rendered_lines.append((hdr_line, hdr_attr, m))
# Body lines with wrapping
body_lines = text.split("\n")
content_w = max_line_w - (3 if is_boxed else 2)
for bl in body_lines:
if len(bl) > 3000:
bl = bl[:3000] + " ... [line truncated]"
if "[WO:" in bl:
line_attr = self._attr("wo_badge")
elif "[TOOL " in bl or "[RESULT " in bl:
line_attr = self._attr("tool")
else:
line_attr = self._attr("normal") if role == "assistant" else self._attr("green")
# Fast-path: Only invoke textwrap if line exceeds column width
if len(bl) > content_w:
wrapped = textwrap.wrap(bl, width=content_w, break_long_words=True) or [""]
else:
wrapped = [bl]
for wl in wrapped:
if is_boxed:
rendered_lines.append((f"│ {wl}", line_attr, None))
else:
rendered_lines.append((f" {wl}", line_attr, None))
if is_boxed:
rendered_lines.append(("└" + "─" * min(40, w - 8), self._attr("dim"), None))
rendered_lines.append(("", self._attr("normal"), None)) # Spacer
self._transcript_cache_key = cache_key
self._transcript_cache_lines = rendered_lines
# Scroll calculation & strict state bounds tracking
total_lines = len(rendered_lines)
self.transcript_total_lines = total_lines
self.transcript_height = transcript_h
max_offset = max(0, total_lines - transcript_h)
# STRICT BOUNDS CLAMPING: Prevent scrollback from ever getting overwhelmed or detached
if self.auto_scroll or max_offset == 0:
self.transcript_scroll_offset = 0
self.auto_scroll = True
start_line = max_offset
else:
self.transcript_scroll_offset = max(0, min(self.transcript_scroll_offset, max_offset))
if self.transcript_scroll_offset == 0:
self.auto_scroll = True
start_line = max_offset - self.transcript_scroll_offset
self._msg_copy_buttons = []
self._msg_copy_context_buttons = []
self._msg_reply_buttons = []
self._msg_menu_buttons = []
is_copy_mode = getattr(self, "copy_mode", False)
sel_min = None
sel_max = None
if is_copy_mode and self.visual_sel_start is not None:
sel_min = min(self.visual_sel_start, self.copy_cursor_line)
sel_max = max(self.visual_sel_start, self.copy_cursor_line)
for row in range(transcript_h):
idx = start_line + row
if idx < total_lines:
item = rendered_lines[idx]
txt = item[0]
attr = item[1]
msg_obj = item[2] if len(item) > 2 else None
if is_copy_mode:
if sel_min is not None and sel_min <= idx <= sel_max:
attr = self._attr("selected")
elif idx == self.copy_cursor_line:
attr = self._attr("warn_banner")
self.safe_addstr(self.stdscr, transcript_y + row, x + 2, txt.ljust(max(0, w - 4)), attr)
if isinstance(msg_obj, dict):
# Draw Reply button
if "[↩ Reply]" in txt:
r_str = "[↩ Reply]"
r_rel_x = txt.find(r_str)
if r_rel_x >= 0:
r_scr_x = x + 2 + r_rel_x
self.safe_addstr(self.stdscr, transcript_y + row, r_scr_x, r_str, self._attr("warn_banner"))
self._msg_reply_buttons.append((transcript_y + row, r_scr_x, r_scr_x + len(r_str), msg_obj))
# Draw Clean Copy button
if "[📋 Copy]" in txt:
c_str = "[📋 Copy]"
c_rel_x = txt.find(c_str)
if c_rel_x >= 0:
c_scr_x = x + 2 + c_rel_x
self.safe_addstr(self.stdscr, transcript_y + row, c_scr_x, c_str, self._attr("button"))
self._msg_copy_buttons.append((transcript_y + row, c_scr_x, c_scr_x + len(c_str), msg_obj.get("text", "")))
# Draw Full Context Copy button
if "[📑+ Context]" in txt:
ctx_str = "[📑+ Context]"
ctx_rel_x = txt.find(ctx_str)
if ctx_rel_x >= 0:
ctx_scr_x = x + 2 + ctx_rel_x
self.safe_addstr(self.stdscr, transcript_y + row, ctx_scr_x, ctx_str, self._attr("button"))
self._msg_copy_context_buttons.append((transcript_y + row, ctx_scr_x, ctx_scr_x + len(ctx_str), msg_obj.get("text", "")))
# Draw Message Actions Menu button
if "[⚡ Menu]" in txt:
m_str = "[⚡ Menu]"
m_rel_x = txt.rfind(m_str)
if m_rel_x >= 0:
m_scr_x = x + 2 + m_rel_x
self.safe_addstr(self.stdscr, transcript_y + row, m_scr_x, m_str, self._attr("header"))
self._msg_menu_buttons.append((transcript_y + row, m_scr_x, m_scr_x + len(m_str), msg_obj))
else:
self.safe_addstr(self.stdscr, transcript_y + row, x + 2, " " * max(0, w - 4), self._attr("normal"))
# Visual vertical scrollbar track on rightmost margin
if total_lines > transcript_h:
track_h = transcript_h
thumb_pos = int((start_line / max(1, total_lines - transcript_h)) * (track_h - 1))
for row in range(track_h):
sb_char = "█" if row == thumb_pos else "│"
sb_attr = self._attr("selected") if row == thumb_pos else self._attr("dim")
self.safe_addstr(self.stdscr, transcript_y + row, x + w - 1, sb_char, sb_attr)
# Scroll indicator if not at bottom (shows unread lines based on scroll length / distance)
if not self.auto_scroll and self.transcript_scroll_offset > 0:
pct = int((start_line / max(1, max_offset)) * 100) if max_offset > 0 else 100
unread_str = f" | ● {self.transcript_scroll_offset} unread lines below" if self.transcript_scroll_offset > 0 else ""
scroll_pill = f" [↑ SCROLLED BACK ({self.transcript_scroll_offset}/{max_offset} lines | {pct}%{unread_str}) - Click or press 'G'/End to follow] "
self.safe_addstr(self.stdscr, y + h - 1, max(x + 2, x + w - len(scroll_pill) - 2), scroll_pill, self._attr("warn_banner"))
# -----------------------------------------------------------------------
# Box Mode Views (Fleet, Approvals, Jobs, Tmux, Logs)
# -----------------------------------------------------------------------
def _render_fleet_table(self, y: int, x: int, h: int, w: int):
self.safe_addstr(self.stdscr, y, x + 1, "NETVM FLEET VITALITY & 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
def get_ssh_rows(self) -> list:
"""SSH view row order: fleet nodes first, then extra tunnel accounts."""
with self.data.lock:
extras = list(self.data.ssh_cache.keys())
nodes = list(self.data.nodes)
return ssh_row_order(nodes, extras)
def _render_ssh_view(self, y: int, x: int, h: int, w: int):
self.safe_addstr(self.stdscr, y, x + 1, f"CONTAINER SSH TUNNEL HEALTH (jump: {SSH_OPERATOR_USER}@{SSH_JUMP_HOST})", self._attr("bold"))
with self.data.lock:
jump = self.data.ssh_jump_reachable
checked_at = self.data.ssh_checked_at
sweep_ms = self.data.ssh_check_latency_ms
check_err = self.data.ssh_check_error
ssh_cache = dict(self.data.ssh_cache)
if jump is None:
jump_txt, jump_attr = "sweep pending…", self._attr("dim")
elif jump:
jump_txt = f"jump OK (sweep {sweep_ms}ms, checked {format_recency(checked_at)})"
jump_attr = self._attr("success")
else:
jump_txt = f"jump UNREACHABLE ({(check_err or 'unknown')[:w - 24]})"
jump_attr = self._attr("danger")
self.safe_addstr(self.stdscr, y + 1, x + 1, jump_txt[:w - 2], jump_attr)
self.safe_addstr(self.stdscr, y + 2, x + 1, " ACCOUNT SSH PORT SSH STATE LAT SSH BANNER / HOSTKEY TERM PORT TERM STATE SINCE", self._attr("dim"))
self.safe_addstr(self.stdscr, y + 3, x + 1, "─" * (w - 2), self._attr("dim"))
rows = self.get_ssh_rows()
if self.ssh_sel_idx >= len(rows):
self.ssh_sel_idx = max(0, len(rows) - 1)
visible_rows = max(3, h - 6)
scroll_start = getattr(self, "ssh_scroll_idx", 0)
if self.ssh_sel_idx < scroll_start:
scroll_start = self.ssh_sel_idx
elif self.ssh_sel_idx >= scroll_start + visible_rows:
scroll_start = self.ssh_sel_idx - visible_rows + 1
scroll_start = max(0, min(scroll_start, max(0, len(rows) - visible_rows)))
self.ssh_scroll_idx = scroll_start
row_y = y + 4
for row_i in range(visible_rows):
idx = scroll_start + row_i
if idx >= len(rows):
break
acct = rows[idx]
is_sel = (idx == self.ssh_sel_idx)
row_attr = self._attr("selected") if is_sel else self._attr("normal")
info = SSH_TUNNEL_PORTS.get(acct, {})
health = ssh_cache.get(acct, {})
sport = info.get("port", "?")
tport = info.get("terminal", "?")
ssh_up = health.get("ssh_up")
term_up = health.get("term_up")
if ssh_up is True:
ssh_txt, ssh_attr = "UP ", self._attr("success")
elif ssh_up is False:
ssh_txt, ssh_attr = "DOWN", self._attr("danger")
else:
ssh_txt, ssh_attr = "?? ", self._attr("dim")
if term_up is True:
term_txt, term_attr = "UP ", self._attr("success")
elif term_up is False:
term_txt, term_attr = "DOWN", self._attr("danger")
else:
term_txt, term_attr = "?? ", self._attr("dim")
if is_sel:
ssh_attr = row_attr
term_attr = row_attr
lat = health.get("ssh_latency_ms")
lat_txt = f"{lat}ms" if lat is not None else "--"
banner = (health.get("ssh_banner") or health.get("term_http") or "-").strip() or "-"
since_ts = health.get("state_since", 0.0)
if ssh_up is None and term_up is None:
since_txt = "never checked" if jump is None else "unknown"
else:
since_txt = f"{'up' if ssh_up else 'down'} {format_recency(since_ts)}"
head = "▶ " if is_sel else " "
name_attr = row_attr if is_sel else self._attr("bold")
self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{acct:<10}"[:12], name_attr)
self.safe_addstr(self.stdscr, row_y, x + 13, f":{sport:<8}", row_attr if is_sel else self._attr("dim"))
self.safe_addstr(self.stdscr, row_y, x + 23, ssh_txt, ssh_attr)
self.safe_addstr(self.stdscr, row_y, x + 32, f"{lat_txt:<7}", row_attr if is_sel else self._attr("dim"))
self.safe_addstr(self.stdscr, row_y, x + 40, banner[:26].ljust(26), row_attr if is_sel else self._attr("normal"))
self.safe_addstr(self.stdscr, row_y, x + 67, f":{tport:<8}", row_attr if is_sel else self._attr("dim"))
self.safe_addstr(self.stdscr, row_y, x + 77, term_txt, term_attr)
self.safe_addstr(self.stdscr, row_y, x + 83, since_txt[:w - 84 - 8], row_attr if is_sel else self._attr("dim"))
self.safe_addstr(self.stdscr, row_y, max(x + 90, w - 8), "[SSH]", self._attr("wo_badge") if is_sel else self._attr("dim"))
row_y += 1
hint_y = y + h - 1
sel_acct = rows[self.ssh_sel_idx].upper() if rows else "-"
hints = f"Selected: [{sel_acct}] [Enter/s]: SSH pop-out (tmux) [c]: Copy dial [u]: Container uptime [r]: Refresh [j/k]: Nav"
self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim"))
# -----------------------------------------------------------------------
# Chat History Sends Search & Prompts Subsystem
# -----------------------------------------------------------------------
def get_chat_sends(self, query: str = "") -> list:
"""Gather all operator/user sent messages across current session and history cache."""
sends = []
seen_texts = set()
# 1. From live session input_history (most recent first)
for txt in reversed(self.input_history):
txt_s = txt.strip()
if txt_s and not txt_s.startswith("/") and txt_s not in seen_texts:
seen_texts.add(txt_s)
sends.append({
"text": txt_s,
"node": self.data.active_node,
"thread_title": self.data.active_thread_title or "active",
"timestamp": time.time(),
"source": "session",
})
# 2. From cached message history across all nodes/threads
with self.data.lock:
hist_items = list(self.data.history_cache.items())
for (node, tid), msgs in hist_items:
th_title = tid[:8] if tid else "thread"
cached_threads = self.data.threads_cache.get(node, [])
for t in cached_threads:
if t.get("session_id") == tid:
th_title = t.get("title") or th_title
break
for m in reversed(msgs):
if m.get("role") == "user":
text = (m.get("text") or "").strip()
if text and text not in seen_texts and not text.startswith("/"):
seen_texts.add(text)
ts = 0.0
if m.get("timestamp"):
try:
ts = datetime.fromisoformat(m["timestamp"]).timestamp()
except Exception:
pass
sends.append({
"text": text,
"node": node,
"thread_title": th_title,
"timestamp": ts,
"source": "history",
})
# Sort sends by timestamp descending (most recent first)
sends.sort(key=lambda s: s.get("timestamp", 0.0), reverse=True)
# Check pinned status against prompt_manager
pinned_prompts_texts = {p["prompt"].strip() for p in self.prompt_manager.prompts if p.get("pinned")}
for s in sends:
s["is_pinned"] = s["text"] in pinned_prompts_texts
# Filter by query if provided
if query:
q = query.lower()
sends = [s for s in sends if q in s["text"].lower() or q in s["node"].lower() or q in s["thread_title"].lower()]
return sends
def _render_prompt_shelf(self, y: int, x: int, h: int, w: int):
"""Render compact persistent prompt shelf in View 1 (Agent Chat)."""
if h <= 0 or w <= 0:
return
self._prompt_chip_bounds = []
self._prompt_shelf_y = y
with self.data.lock:
prompts = list(self.prompts)
# Clear background rows
self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("normal"))
if h > 1:
self.safe_addstr(self.stdscr, y + 1, x, " " * w, self._attr("normal"))
# Row 0: Header & Controls
hdr_lbl = " PROMPTS "
self.safe_addstr(self.stdscr, y, x + 1, hdr_lbl, self._attr("header"))
btn_x = x + 1 + len(hdr_lbl) + 1
exp_btn = "[P:Expand]"
self.safe_addstr(self.stdscr, y, btn_x, exp_btn, self._attr("warn_banner"))
self._btn_expand_bounds = (btn_x, btn_x + len(exp_btn))
sends_btn = " [🔍 Sends] "
sends_x = btn_x + len(exp_btn) + 1
self.safe_addstr(self.stdscr, y, sends_x, sends_btn, self._attr("selected"))
self._btn_sends_bounds = (sends_x, sends_x + len(sends_btn))
nav_btns = " [◄] [►] "
nav_x = sends_x + len(sends_btn) + 1
self.safe_addstr(self.stdscr, y, nav_x, nav_btns, self._attr("dim"))
self._btn_prev_bounds = (nav_x + 1, nav_x + 4)
self._btn_next_bounds = (nav_x + 5, nav_x + 8)
new_btn = "[+New]"
new_x = nav_x + len(nav_btns) + 1
self.safe_addstr(self.stdscr, y, new_x, new_btn, self._attr("dim"))
self._btn_new_bounds = (new_x, new_x + len(new_btn))
# Fill line with subtle divider
div_start = new_x + len(new_btn) + 1
if div_start < w:
self.safe_addstr(self.stdscr, y, div_start, "─" * (w - div_start), self._attr("dim"))
# Row 1: Prompt chips
chip_y = y + 1 if h > 1 else y
cur_x = x + 1
if self.prompt_scroll_idx > 0:
self.safe_addstr(self.stdscr, chip_y, cur_x, "◀ ", self._attr("warn_banner"))
cur_x += 2
visible_prompts = prompts[self.prompt_scroll_idx:]
for idx_offset, p in enumerate(visible_prompts):
p_idx = self.prompt_scroll_idx + idx_offset + 1
is_pinned = p.get("pinned", False)
title = p.get("title", "Prompt")
pin_badge = "📌" if is_pinned else ""
chip_str = f"[{pin_badge}{p_idx}:{title}]"
if cur_x + len(chip_str) >= w - 4:
remaining_w = w - cur_x - 3
if remaining_w > 8:
chip_str = f"[{pin_badge}{p_idx}:{title[:remaining_w - 7]}…]"
attr = self._attr("wo_badge") if is_pinned else self._attr("normal")
self.safe_addstr(self.stdscr, chip_y, cur_x, chip_str, attr)
self._prompt_chip_bounds.append((cur_x, cur_x + len(chip_str), p))
cur_x += len(chip_str)
self.safe_addstr(self.stdscr, chip_y, w - 2, "▶", self._attr("warn_banner"))
break
attr = self._attr("wo_badge") if is_pinned else self._attr("normal")
self.safe_addstr(self.stdscr, chip_y, cur_x, chip_str, attr)
self._prompt_chip_bounds.append((cur_x, cur_x + len(chip_str), p))
cur_x += len(chip_str) + 1
# -----------------------------------------------------------------------
# Bottom Input Bar & Toast
# -----------------------------------------------------------------------
def _render_bottom_bar(self, y: int, x: int, h: int, w: int):
is_insert = (self.editor_mode == "INSERT")
is_fleet_focus = (self.focus_pane == "fleet" and not is_insert)
is_sidechats_focus = (self.focus_pane in ("sidechats", "sidebar") and not is_insert)
is_transcript_focus = (self.focus_pane == "transcript" and not is_insert)
# 1. Divider line - prominently highlights active portion
if is_insert:
lbl = " ── [● COMPOSER: INSERT MODE ACTIVE] "
pad_w = max(0, w - len(lbl) - 2)
div_str = lbl + ("━" * pad_w)
self.safe_addstr(self.stdscr, y, x, div_str[:w], self._attr("mode_insert"))
elif is_fleet_focus:
lbl = " ── [● ACTIVE PANE: FLEET AGENTS] "
pad_w = max(0, w - len(lbl) - 2)
div_str = lbl + ("─" * pad_w)
self.safe_addstr(self.stdscr, y, x, "─" * w, self._attr("dim"))
self.safe_addstr(self.stdscr, y, x, lbl, self._attr("header"))
elif is_sidechats_focus:
lbl = " ── [● ACTIVE PANE: SIDEBAR] " if self.focus_pane == "sidebar" else " ── [● ACTIVE PANE: SIDECHATS] "
pad_w = max(0, w - len(lbl) - 2)
div_str = lbl + ("─" * pad_w)
self.safe_addstr(self.stdscr, y, x, "─" * w, self._attr("dim"))
self.safe_addstr(self.stdscr, y, x, lbl, self._attr("header"))
elif is_transcript_focus:
lbl = " ── [● ACTIVE PANE: TRANSCRIPT] "
pad_w = max(0, w - len(lbl) - 2)
div_str = lbl + ("─" * pad_w)
self.safe_addstr(self.stdscr, y, x, "─" * w, self._attr("dim"))
self.safe_addstr(self.stdscr, y, x, lbl, self._attr("user"))
else:
self.safe_addstr(self.stdscr, y, x, "─" * w, self._attr("dim"))
# Clear Toast and Prompt rows to prevent ghosting without full-screen erase
self.safe_addstr(self.stdscr, y + 1, x, " " * w, self._attr("normal"))
self.safe_addstr(self.stdscr, y + 2, x, " " * w, self._attr("normal"))
# 2. Toast / Status message or quick shortcuts
now = time.time()
is_dev_layer = getattr(self, "device_layer_active", False) and now <= self.device_layer_timeout
if is_dev_layer:
with self.data.lock:
legend = " ".join([f"{i+1}:{n.upper()}" for i, n in enumerate(self.data.nodes)])
hints = f" [DEVICE SUBSTRATE] {legend} | [Space/n] Next | [p/Back] Prev | [Esc] Lock"
self.safe_addstr(self.stdscr, y + 1, x + 1, hints[:w - 2], self._attr("warn_banner"))
elif now < self.toast_time:
attr = self._attr("success") if self.toast_style == "success" else (self._attr("danger") if self.toast_style == "error" else self._attr("selected"))
self.safe_addstr(self.stdscr, y + 1, x + 1, f" {self.toast_msg} ", attr)
elif getattr(self, "reply_target", None):
rep = self.reply_target
r_role = rep.get("role", "agent").upper()
r_seq = rep.get("seq") or rep.get("msg_id", "")[:8]
r_prev = rep.get("preview", "")[:35]
rep_pill = f" [↩ Replying to {r_role} (id:{r_seq}): \"{r_prev}\" | Esc to cancel] "
self.safe_addstr(self.stdscr, y + 1, x + 1, rep_pill[:w - 2], self._attr("warn_banner"))
elif getattr(self, "copy_mode", False):
sel_count = (abs(self.copy_cursor_line - self.visual_sel_start) + 1) if self.visual_sel_start is not None else 1
sel_lbl = f"{sel_count} lines selected" if self.visual_sel_start is not None else "line cursor"
hints = f" [COPY MODE: {sel_lbl}] j/k: Move v: Toggle Select y/Enter: Copy to Clipboard Esc/q: Exit (Shift+Drag mouse)"
self.safe_addstr(self.stdscr, y + 1, x + 1, hints[:w - 2], self._attr("warn_banner"))
else:
if self.editor_mode == "NORMAL":
if self.focus_pane == "fleet":
hints = " [● FLEET FOCUSED] j/k: Select agent Enter: Switch h/l: Switch pane x/Space: Agent menu ?: Help"
elif self.focus_pane == "sidebar":
hints = " [● SIDEBAR FOCUSED] j/k: Select thread Enter: Open h/l: Switch pane x/Space: Menu ?: Help"
elif self.focus_pane == "sidechats":
hints = " [● SIDECHATS FOCUSED] j/k: Select thread Enter: Open h/l: Switch pane x/Space: Menu ?: Help"
else:
hints = " [● TRANSCRIPT FOCUSED] j/k: Scroll v: Copy h/l: Switch pane i: Type R: Reply x/Space: Menu ?: Help"
else:
nl_cnt = self.input_buf.count("\n")
if nl_cnt > 0:
hints = f" [● INSERT ACTIVE] Multi-line ({nl_cnt + 1} lines) [Enter] Send [Alt+Enter] Newline [Esc] Normal"
else:
hints = " [● INSERT ACTIVE] Type message [Enter] Send [Alt+Enter] Newline [Esc] Normal mode [Ctrl+W] Del word"
self.safe_addstr(self.stdscr, y + 1, x + 1, hints[:w - 2], self._attr("dim"))
# 3. Mode Badge & Input Prompt
prompt_y = y + 2
with self.data.lock:
node = self.data.active_node
cur_tid = self.data.active_thread_id
cached_threads = self.data.threads_cache.get(node, [])
is_main = False
for t in cached_threads:
if t.get("session_id") == cur_tid and t.get("is_main"):
is_main = True
break
if is_main:
tid = "★ Main Chat"
else:
tid = (self.data.active_thread_title or "sidechat")[:12]
if is_dev_layer:
mode_badge = " DEVICE "
mode_attr = self._attr("warn_banner")
else:
mode_badge = " NORMAL " if self.editor_mode == "NORMAL" else " INSERT "
mode_attr = self._attr("mode_normal") if self.editor_mode == "NORMAL" else self._attr("mode_insert")
self.safe_addstr(self.stdscr, prompt_y, x + 1, mode_badge, mode_attr)
nl_cnt = self.input_buf.count("\n")
lines_badge = f" [{nl_cnt + 1} lines]" if nl_cnt > 0 else ""
prompt_str = f" {node} ({tid}){lines_badge} > "
prompt_x = x + 1 + len(mode_badge)
self.safe_addstr(self.stdscr, prompt_y, prompt_x, prompt_str, self._attr("user"))
inp_x = prompt_x + len(prompt_str)
max_inp_w = w - inp_x - 1
disp_buf = self.input_buf.replace("\n", "↵")
if self.editor_mode == "NORMAL":
if self.input_buf:
disp_text = f"[Draft: {disp_buf}] (press 'i' to edit)"
self.safe_addstr(self.stdscr, prompt_y, inp_x, disp_text[:max_inp_w], self._attr("dim"))
else:
if self.focus_pane == "fleet":
active_pane_name = "Fleet Agents"
elif self.focus_pane in ("sidechats", "sidebar"):
active_pane_name = "Sidechats"
else:
active_pane_name = "Transcript"
hint_text = f"[{active_pane_name} active — press 'i' or Enter to type prompt, '?' for help]"
self.safe_addstr(self.stdscr, prompt_y, inp_x, hint_text[:max_inp_w], self._attr("dim"))
if not self.modal:
try:
curses.curs_set(0)
except curses.error:
pass
else:
# INSERT mode
cursor = self.input_cursor
start_idx = 0
if cursor >= max_inp_w:
start_idx = cursor - max_inp_w + 1
disp_text = disp_buf[start_idx:start_idx + max_inp_w]
self.safe_addstr(self.stdscr, prompt_y, inp_x, disp_text, self._attr("normal"))
if not self.modal:
screen_cx = inp_x + (cursor - start_idx)
try:
curses.curs_set(1)
self.stdscr.move(prompt_y, min(screen_cx, w - 1))
except curses.error:
pass
# -----------------------------------------------------------------------
# Interactive Modals (Help, Approvals, New Sidechat, Work Order)
# -----------------------------------------------------------------------
def _render_modal(self, screen_h: int, screen_w: int):
if not self.modal:
return
is_context_popup = self.modal in ("chat_context", "agent_context", "message_context")
if is_context_popup:
modal_w = min(56, screen_w - 4)
modal_h = min(17 if self.modal == "chat_context" else (14 if self.modal == "agent_context" else 11), screen_h - 2)
anchor_x, anchor_y = getattr(self, "context_popup_pos", (screen_w // 4, screen_h // 3))
top_y = max(1, min(anchor_y, screen_h - modal_h - 2))
left_x = max(1, min(anchor_x, screen_w - modal_w - 2))
self._context_popup_bounds = (left_x, top_y, modal_w, modal_h)
else:
modal_w = min(84 if self.modal in ("prompts", "history_search") else 74, screen_w - 6)
modal_h = min(22 if self.modal in ("prompts", "history_search") else 20, screen_h - 4)
top_y = (screen_h - modal_h) // 2
left_x = (screen_w - modal_w) // 2
self._context_popup_bounds = (left_x, top_y, modal_w, modal_h)
# Draw modal box
for row in range(modal_h):
self.safe_addstr(self.stdscr, top_y + row, left_x, " " * modal_w, self._attr("header"))
# Border
self.safe_addstr(self.stdscr, top_y, left_x, "┌" + "─" * (modal_w - 2) + "┐", self._attr("dim"))
for row in range(1, modal_h - 1):
self.safe_addstr(self.stdscr, top_y + row, left_x, "│", self._attr("dim"))
self.safe_addstr(self.stdscr, top_y + row, left_x + modal_w - 1, "│", self._attr("dim"))
self.safe_addstr(self.stdscr, top_y + row, left_x + 1, " " * (modal_w - 2), self._attr("normal"))
self.safe_addstr(self.stdscr, top_y + modal_h - 1, left_x, "└" + "─" * (modal_w - 2) + "┘", self._attr("dim"))
# Title
title_map = {
"help": " KEYBINDINGS & NAVIGATION CHEAT-SHEET ",
"approvals": " PENDING APPROVALS RESOLUTION CENTER ",
"new_sidechat": " CREATE NEW SIDECHAT ",
"rename_sidechat": " RENAME SIDECHAT ",
"work_order": " ISSUE OPERATOR WORK ORDER ([WO]) ",
"agent_search": " 󰢹 FAST AGENT SEARCH & SELECT ",
"prompts": " 󰢹 PROMPT & SKILL LIBRARY // VIM HJKL NAVIGATION ",
"history_search": " 󰢹 SEARCH CHAT SENDS // PIN TO PROMPT FILL ",
"prompt_formulate": " FORMULATE NEW PROMPT SKILL ",
"chat_context": " 󰢹 CHAT ACTIONS ",
"agent_context": " 󰢹 AGENT ACTIONS ",
"message_context": " 󰢹 MESSAGE ACTIONS ",
}
title = title_map.get(self.modal, " DIALOG ")
self.safe_addstr(self.stdscr, top_y, left_x + 3, title, self._attr("header"))
# Modal Content
if self.modal == "help":
help_lines = [
("[NORMAL MODE]", "Vim-style modal lock: prompt is locked against accidental input"),
("[i] or [a]", "Enter INSERT mode (focus bottom prompt to type chat/command)"),
("[v] / [[]", "Tmux/Vi Visual Copy Mode: j/k to select lines, y to copy"),
("[Shift+Drag]", "Terminal-native text selection bypass (copy with Ctrl+Shift+C)"),
("[Mouse Drag]", "Click & drag on transcript to highlight and auto-copy lines"),
("[f] or [/]", "Fast search & select fleet agent modal (type/click to jump)"),
("[Tab] / [S-Tab]", "Cycle & instantly load next / prev conversation thread"),
("[h] / [l]", "Switch active focus between Sidebar and Transcript"),
("[j] / [k] / [↑/↓]", "Navigate threads in sidebar or scroll chat transcript"),
("[Enter]", "Open selected thread (Sidebar) or enter Insert mode (Transcript)"),
("[1] - [9] / [0]", "Instantly switch active agent node (in NORMAL mode)"),
("[Alt+Space]", "Device/Agent modifier substrate: 0-9 jumps, Space cycles"),
("[n]", "Create new sidechat session on active node"),
("[y] / [Y]", "Copy latest message ([y]) or full transcript ([Y]) to clipboard"),
("[b]", "Toggle clean line-by-line vs classic boxed frame view"),
("[R-Click / x]", "Open Context Menu for chat thread, fleet agent, or message"),
("[p] or [/pin]", "Pin/unpin thread (sidebar) or pin chat send to prompts (chat)"),
("[s] / [Ctrl-R]", "Search chat sends history & pin to prompt shelf"),
("[P] / [/prompt]", "Open expanded Prompt & Skills library modal"),
("[w] or [/wo]", "Compose and cryptographically sign a Work Order"),
("[a] or [F2]", "Open Approvals Resolution Drawer (Allow, Always, Deny)"),
("[F5] or [m]", "Toggle between Muse Chat TUI and Box Fleet Command TUI"),
("[1]-[7] (Box)", "Switch Box tabs: Chat/Fleet/Approvals/Jobs/Tmux/Logs/SSH"),
("[Tab 7: SSH]", "Enter/s: tmux pop-out c: copy dial u: container uptime r: refresh"),
("[g] / [G] / [Home/End]", "Jump to oldest message / follow live latest message"),
("[q]", "Quit TUI (in NORMAL mode)"),
]
for idx, (k, desc) in enumerate(help_lines):
if idx + 2 >= modal_h - 1:
break
self.safe_addstr(self.stdscr, top_y + 2 + idx, left_x + 3, f"{k:<14}", self._attr("bold"))
self.safe_addstr(self.stdscr, top_y + 2 + idx, left_x + 18, desc[:modal_w - 21], self._attr("normal"))
self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "Press [Esc] or [Enter] to close", self._attr("dim"))
elif self.modal == "approvals":
with self.data.lock:
apps = [it for it in self.data.approvals_cache if it.get("status") in ("PENDING", "INPUT_WAIT", "KEY_APPROVAL")]
if not apps:
self.safe_addstr(self.stdscr, top_y + 3, left_x + 4, "✔ No pending approvals or input waits across fleet!", self._attr("success"))
self.safe_addstr(self.stdscr, top_y + 5, left_x + 4, "Press [Esc] to return to chat.", self._attr("dim"))
else:
self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, "Pending approvals & blocked tasks requiring operator review (j/k to select):", self._attr("dim"))
self.approvals_sel_idx = max(0, min(getattr(self, "approvals_sel_idx", 0), len(apps) - 1))
sel_app = apps[self.approvals_sel_idx]
sel_node = sel_app.get("node", self.data.active_node)
max_rows = max(1, modal_h - 9)
start_row = 0
if self.approvals_sel_idx >= max_rows:
start_row = self.approvals_sel_idx - max_rows + 1
for idx, it in enumerate(apps[start_row:start_row + max_rows]):
real_idx = start_row + idx
n = it.get("node")
st = it.get("status")
tgt = it.get("target") or it.get("purpose") or ""
if not tgt or tgt == "No pending approvals":
waits = it.get("input_waits") or []
if waits:
tgt = waits[0].get("description") or waits[0].get("task") or "Operator input requested"
else:
tgt = it.get("title") or "Task intervention requested"
bg_count = it.get("bg_tasks_count", 0)
bg_tag = f" [{bg_count} review]" if bg_count else ""
is_sel = (real_idx == self.approvals_sel_idx)
cursor = "▶ " if is_sel else " "
line = f"{cursor}[{n.upper()}] {st}{bg_tag}: {tgt[:modal_w - 28]}"
attr = self._attr("selected") if is_sel else self._attr("danger")
self.safe_addstr(self.stdscr, top_y + 4 + idx, left_x + 3, line, attr)
# Detail box for selected approval
det_y = top_y + modal_h - 5
trust = "TRUSTED" if sel_app.get("is_trusted") else "UNTRUSTED / HUMAN REVIEW"
full_title = sel_app.get("title") or sel_app.get("purpose") or ""
self.safe_addstr(self.stdscr, det_y, left_x + 3, f"Selected: [{sel_node.upper()}] — {trust}: {full_title[:modal_w - len(sel_node) - len(trust) - 20]}", self._attr("bold"))
act_y = top_y + modal_h - 3
self.safe_addstr(self.stdscr, act_y, left_x + 3, f"Actions for [{sel_node.upper()}]:", self._attr("bold"))
self.safe_addstr(self.stdscr, act_y + 1, left_x + 3, "[1] Allow [2] Always [3] Deny [R] Proceed [X] Dismiss [P] Auto-Fleet [C] Clear All", self._attr("selected"))
elif self.modal == "new_sidechat":
self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Agent: {self.data.active_node.upper()} (Sidechat-first policy)", self._attr("bold"))
self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, "Enter sidechat title / purpose:", self._attr("dim"))
# Input field
field_y = top_y + 5
field_w = modal_w - 6
self.safe_addstr(self.stdscr, field_y, left_x + 3, "┌" + "─" * field_w + "┐", self._attr("dim"))
self.safe_addstr(self.stdscr, field_y + 1, left_x + 3, "│ " + self.modal_input_buf.ljust(field_w - 2) + " │", self._attr("normal"))
self.safe_addstr(self.stdscr, field_y + 2, left_x + 3, "└" + "─" * field_w + "┘", self._attr("dim"))
self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Enter] Create Sidechat [Esc] Cancel", self._attr("dim"))
# Move cursor to modal input
try:
self.stdscr.move(field_y + 1, left_x + 5 + self.modal_input_cursor)
except curses.error:
pass
elif self.modal == "work_order":
self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Target: {self.data.active_node.upper()} ({self.data.active_thread_title})", self._attr("bold"))
self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, "Format: <title> — <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 seq[1] == ord('M'):
# Old-style xterm mouse tracking: ESC [ M Cb Cx Cy (len 5: [ + M + 3 bytes)
if len(seq) >= 5:
break
elif chr(c).isalpha() or chr(c) == '~':
break
finally:
self.stdscr.timeout(50)
return seq
def _handle_escape_sequence(self) -> bool:
seq = self._read_escape_sequence()
if not seq:
# Standalone Esc pressed
if getattr(self, "copy_mode", False):
self.copy_mode = False
self.visual_sel_start = None
self.set_toast("Exited copy mode.", "info")
return True
if getattr(self, "reply_target", None):
self.reply_target = None
self.set_toast("↩ Cancelled reply target.", "info")
if self.editor_mode == "INSERT":
self.editor_mode = "NORMAL"
return True
if self.modal:
self.modal = None
return True
if self.editor_mode == "INSERT":
self.editor_mode = "NORMAL"
self.set_toast("NORMAL mode (Nav locked). Press 'i' to chat.", "info")
return True
else:
# In NORMAL mode: reset scroll offset or dismiss toast
if not self.auto_scroll:
self.auto_scroll = True
self.transcript_scroll_offset = 0
self.set_toast("Follow mode resumed", "info")
self.toast_time = 0
return True
# SGR 1006 mouse event fallback: ESC [ < button ; col ; row (M or m)
if len(seq) >= 6 and seq[0] == ord('[') and seq[1] == ord('<'):
try:
raw_str = "".join(chr(c) for c in seq[2:])
final_char = raw_str[-1]
if final_char in ('M', 'm'):
parts = raw_str[:-1].split(';')
if len(parts) == 3:
btn = int(parts[0])
mx = int(parts[1]) - 1
my = int(parts[2]) - 1
is_rel = (final_char == 'm')
# Build bstate mask
if btn & 64: # Wheel scroll
if (btn & 3) == 0:
bstate = getattr(curses, "BUTTON4_PRESSED", 0x10000)
else:
bstate = getattr(curses, "BUTTON5_PRESSED", 0x200000)
elif is_rel:
if (btn & 3) == 0:
bstate = curses.BUTTON1_RELEASED
elif (btn & 3) == 1:
bstate = getattr(curses, "BUTTON2_RELEASED", 0x20)
elif (btn & 3) == 2:
bstate = getattr(curses, "BUTTON3_RELEASED", 0x400)
else:
bstate = 0
else: # Press or drag
if btn & 32: # Motion/drag
bstate = curses.REPORT_MOUSE_POSITION
else:
if (btn & 3) == 0:
bstate = curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED
elif (btn & 3) == 1:
bstate = getattr(curses, "BUTTON2_CLICKED", 0x80) | getattr(curses, "BUTTON2_PRESSED", 0x40)
elif (btn & 3) == 2:
bstate = getattr(curses, "BUTTON3_CLICKED", 0x1000) | getattr(curses, "BUTTON3_PRESSED", 0x800)
else:
bstate = 0
if btn & 16: # Ctrl
bstate |= getattr(curses, "BUTTON_CTRL", 0x2000000)
if btn & 4: # Shift
bstate |= getattr(curses, "BUTTON_SHIFT", 0x4000000)
if btn & 8: # Alt
bstate |= getattr(curses, "BUTTON_ALT", 0x8000000)
return self._handle_mouse(mx, my, bstate)
except Exception:
pass
# Standard Xterm mouse event fallback: ESC [ M Cb Cx Cy
if len(seq) >= 5 and seq[0] == ord('[') and seq[1] == ord('M'):
try:
cb = seq[2]
cx = seq[3]
cy = seq[4]
mx = cx - 33
my = cy - 33
btn = cb - 32
if btn & 64:
if (btn & 3) == 0:
bstate = getattr(curses, "BUTTON4_PRESSED", 0x10000)
else:
bstate = getattr(curses, "BUTTON5_PRESSED", 0x200000)
elif (btn & 3) == 3: # Release
bstate = curses.BUTTON1_RELEASED
else:
if (btn & 3) == 0:
bstate = curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED
elif (btn & 3) == 1:
bstate = getattr(curses, "BUTTON2_CLICKED", 0x80) | getattr(curses, "BUTTON2_PRESSED", 0x40)
elif (btn & 3) == 2:
bstate = getattr(curses, "BUTTON3_CLICKED", 0x1000) | getattr(curses, "BUTTON3_PRESSED", 0x800)
else:
bstate = 0
if btn & 16:
bstate |= getattr(curses, "BUTTON_CTRL", 0x2000000)
if btn & 4:
bstate |= getattr(curses, "BUTTON_SHIFT", 0x4000000)
if btn & 8:
bstate |= getattr(curses, "BUTTON_ALT", 0x8000000)
return self._handle_mouse(mx, my, bstate)
except Exception:
pass
# Alt+Enter (ESC + Enter / Linefeed) -> Insert newline into input buffer
if seq and (seq[0] in (curses.KEY_ENTER, 10, 13) or (len(seq) == 2 and seq[0] in (ord('O'), ord('[')) and seq[1] == ord('M'))):
if self.editor_mode != "INSERT":
self.editor_mode = "INSERT"
self.input_buf = self.input_buf[:self.input_cursor] + "\n" + self.input_buf[self.input_cursor:]
self.input_cursor += 1
nl_cnt = self.input_buf.count("\n")
self.set_toast(f"Inserted newline ({nl_cnt + 1} lines). Press [Enter] to send.", "info")
return True
# Alt+Space captured (ESC + Space): activates device substrate modifier layer
if seq and seq[0] == ord(' '):
if getattr(self, "device_layer_active", False) and time.time() <= self.device_layer_timeout:
self.device_layer_timeout = time.time() + 4.0
self._cycle_active_node(step=1)
else:
self.device_layer_active = True
self.device_layer_timeout = time.time() + 4.0
self.set_toast("󰢹 DEVICE LAYER: Press 0-9 to Jump, Space/n to Cycle, p: Prev, /: Search, Esc: Lock", "info")
return True
# Alt+Number captured (ESC + 0..9): jump directly to agent
if seq and ord('0') <= seq[0] <= ord('9'):
digit = seq[0] - ord('0')
idx = (digit - 1) if digit > 0 else (len(self.data.nodes) - 1)
self._switch_to_node_idx(idx)
self.device_layer_active = False
return True
# Alt+n / Alt+N: direct shortcut to cycle forward
if seq and seq[0] in (ord('n'), ord('N')):
self._cycle_active_node(step=1)
return True
# Alt+p / Alt+P: direct shortcut to cycle backward
if seq and seq[0] in (ord('p'), ord('P')):
self._cycle_active_node(step=-1)
return True
# Alt+f / Alt+/: direct shortcut to open agent search
if seq and seq[0] in (ord('f'), ord('F'), ord('/')):
self.modal = "agent_search"
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.agent_search_sel = 0
self.agent_search_scroll = 0
return True
# Multi-char escape sequence
if seq[0] in (ord('['), ord('O')):
seq_str = "".join(chr(c) for c in seq)
# Bracketed paste start sequence: \033[200~
if seq_str == "[200~":
return self._handle_bracketed_paste()
# Check CSI u or extended kitty/xterm Alt+Space (e.g. \e[32;3u or \e[27;3;32~)
seq_str = "".join(chr(c) for c in seq)
if "32;3u" in seq_str or "27;3;32" in seq_str:
if getattr(self, "device_layer_active", False) and time.time() <= self.device_layer_timeout:
self.device_layer_timeout = time.time() + 4.0
self._cycle_active_node(step=1)
else:
self.device_layer_active = True
self.device_layer_timeout = time.time() + 4.0
self.set_toast("󰢹 DEVICE LAYER: Press 0-9 to Jump, Space/n to Cycle, p: Prev, /: Search, Esc: Lock", "info")
return True
if len(seq) >= 2:
cmd = seq[1]
if cmd == ord('A'):
return self._handle_key(curses.KEY_UP)
elif cmd == ord('B'):
return self._handle_key(curses.KEY_DOWN)
elif cmd == ord('C'):
return self._handle_key(curses.KEY_RIGHT)
elif cmd == ord('D'):
return self._handle_key(curses.KEY_LEFT)
elif cmd == ord('Z'):
return self._handle_key(curses.KEY_BTAB)
elif cmd in (ord('H'), ord('1')):
return self._handle_key(curses.KEY_HOME)
elif cmd in (ord('F'), ord('4')):
return self._handle_key(curses.KEY_END)
elif cmd == ord('3'):
return self._handle_key(curses.KEY_DC)
elif cmd == ord('5'):
return self._handle_key(curses.KEY_PPAGE)
elif cmd == ord('6'):
return self._handle_key(curses.KEY_NPAGE)
elif cmd == ord('<'):
# SGR mouse sequence: \033[<btn;x;yM
sgr_bytes = seq[2:]
sgr_str = "".join(chr(c) for c in sgr_bytes)
try:
sgr_clean = sgr_str.rstrip("Mm")
s_btn, s_x, s_y = [int(p) for p in sgr_clean.split(";")]
mx = s_x - 1
my = s_y - 1
is_release = sgr_str.endswith("m")
if s_btn == 64:
return self._handle_mouse(mx, my, getattr(curses, "BUTTON4_PRESSED", 0x10000))
elif s_btn == 65:
return self._handle_mouse(mx, my, getattr(curses, "BUTTON5_PRESSED", 0x200000))
elif is_release:
return self._handle_mouse(mx, my, curses.BUTTON1_RELEASED)
elif s_btn == 32:
return self._handle_mouse(mx, my, curses.REPORT_MOUSE_POSITION)
elif s_btn == 0 and sgr_str.endswith("M"):
return self._handle_mouse(mx, my, curses.BUTTON1_CLICKED)
except Exception:
pass
return True
return True
def _handle_bracketed_paste(self) -> bool:
"""Read and insert bracketed paste content until \033[201~."""
self.stdscr.nodelay(True)
raw_bytes = bytearray()
end_seq = b"\x1b[201~"
end_seq_short = b"[201~"
try:
idle_retries = 0
while True:
c = self.stdscr.getch()
if c == -1:
idle_retries += 1
if idle_retries > 10: # ~100ms idle threshold
break
time.sleep(0.01)
continue
idle_retries = 0
if 0 <= c <= 255:
raw_bytes.append(c)
if raw_bytes.endswith(end_seq):
raw_bytes = raw_bytes[:-len(end_seq)]
break
elif raw_bytes.endswith(end_seq_short):
raw_bytes = raw_bytes[:-len(end_seq_short)]
break
finally:
self.stdscr.timeout(50)
try:
text = raw_bytes.decode("utf-8", errors="replace")
except Exception:
text = raw_bytes.decode("latin-1", errors="replace")
text = text.replace("\r\n", "\n").replace("\r", "\n")
if not text:
return True
self._insert_pasted_text(text)
return True
def _insert_pasted_text(self, text: str):
"""Insert multi-character pasted text into active input field or modal."""
if self.modal in ("agent_search", "history_search"):
clean = text.replace("\n", " ").strip()
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + clean + self.modal_input_buf[self.modal_input_cursor:]
self.modal_input_cursor += len(clean)
return
if self.modal == "new_sidechat":
clean = text.replace("\n", " ").strip()
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + clean + self.modal_input_buf[self.modal_input_cursor:]
self.modal_input_cursor += len(clean)
return
if self.modal in ("work_order", "prompt_formulate"):
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + text + self.modal_input_buf[self.modal_input_cursor:]
self.modal_input_cursor += len(text)
return
# Main chat input line
if self.editor_mode != "INSERT":
self.editor_mode = "INSERT"
self.input_buf = self.input_buf[:self.input_cursor] + text + self.input_buf[self.input_cursor:]
self.input_cursor += len(text)
nl_cnt = text.count("\n")
if nl_cnt > 0:
self.set_toast(f"📋 Pasted chunk ({nl_cnt + 1} lines, {len(text)} chars). Press [Enter] to send.", "success")
else:
self.set_toast(f"📋 Pasted {len(text)} chars.", "info")
def _handle_key(self, ch: int) -> bool:
self.data.record_activity()
# Global Quitting
if ch in (3, 4): # Ctrl+C or Ctrl+D
return False
# Mouse Events (clicks & wheel scrolls)
if ch == curses.KEY_MOUSE:
try:
_, mx, my, _, bstate = curses.getmouse()
return self._handle_mouse(mx, my, bstate)
except curses.error:
return True
# Ctrl+Space (NUL char 0) as alternate trigger for device modifier substrate
if ch == 0:
if getattr(self, "device_layer_active", False) and time.time() <= self.device_layer_timeout:
self.device_layer_timeout = time.time() + 4.0
self._cycle_active_node(step=1)
else:
self.device_layer_active = True
self.device_layer_timeout = time.time() + 4.0
self.set_toast("󰢹 DEVICE LAYER: Press 0-9 to Jump, Space/n to Cycle, p: Prev, /: Search, Esc: Lock", "info")
return True
# Handle raw Escape (27) or ANSI escape sequence (including Alt+Space)
if ch == 27:
return self._handle_escape_sequence()
# Intercept if Device Modifier substrate layer is active
if getattr(self, "device_layer_active", False):
if time.time() > self.device_layer_timeout:
self.device_layer_active = False
else:
return self._handle_device_layer_key(ch)
# If modal is open, direct all keystrokes to modal
if self.modal:
return self._handle_modal_key(ch)
# Global Function Keys
if ch == curses.KEY_F1:
self.modal = "help"
return True
elif ch == curses.KEY_F2:
self.modal = "approvals"
return True
elif ch == curses.KEY_F5:
self.mode = "box" if self.mode == "muse" else "muse"
self.set_toast(f"Switched mode to {self.mode.upper()}", "info")
return True
# Route by editor_mode
if self.editor_mode == "INSERT":
return self._handle_insert_key(ch)
else:
return self._handle_normal_key(ch)
def _handle_copy_mode_key(self, ch: int) -> bool:
"""Handle tmux-style copy-mode navigation, visual line selection, and yanking."""
total = getattr(self, "transcript_total_lines", 0)
h_vis = getattr(self, "transcript_height", 20)
max_off = max(0, total - h_vis)
# Exit copy mode: Esc or 'q'
if ch in (27, ord('q'), ord('Q')):
if self.visual_sel_start is not None:
self.visual_sel_start = None
self.set_toast("Selection cleared.", "info")
else:
self.copy_mode = False
self.set_toast("Exited copy mode.", "info")
return True
# Toggle Visual Line Selection: 'v' or 'V' or Space
if ch in (ord('v'), ord('V'), ord(' ')):
if self.visual_sel_start is None:
self.visual_sel_start = self.copy_cursor_line
self.set_toast("VISUAL line selection started. Move with j/k, copy with y.", "warn_banner")
else:
self.visual_sel_start = None
self.set_toast("VISUAL selection cleared.", "info")
return True
# Copy / Yank: 'y', 'Y', or Enter
if ch in (ord('y'), ord('Y'), curses.KEY_ENTER, 10, 13):
if self.visual_sel_start is not None:
s = min(self.visual_sel_start, self.copy_cursor_line)
e = max(self.visual_sel_start, self.copy_cursor_line)
else:
s = self.copy_cursor_line
e = self.copy_cursor_line
text = self._copy_rendered_line_range(s, e)
self.copy_mode = False
self.visual_sel_start = None
if text:
cnt = len(text.splitlines())
self.set_toast(f"📋 Copied {cnt} lines ({len(text)} chars) to clipboard", "success")
else:
self.set_toast("⚠️ No text to copy on current line", "warn")
return True
# Navigate Down: 'j' or Down Arrow
if ch in (ord('j'), curses.KEY_DOWN):
if total > 0 and self.copy_cursor_line < total - 1:
self.copy_cursor_line += 1
start_l = max_off - self.transcript_scroll_offset
if self.copy_cursor_line >= start_l + h_vis:
self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - 1)
if self.transcript_scroll_offset == 0:
self.auto_scroll = True
return True
# Navigate Up: 'k' or Up Arrow
if ch in (ord('k'), curses.KEY_UP):
if total > 0 and self.copy_cursor_line > 0:
self.copy_cursor_line -= 1
start_l = max_off - self.transcript_scroll_offset
if self.copy_cursor_line < start_l:
self.auto_scroll = False
self.transcript_scroll_offset = min(max_off, self.transcript_scroll_offset + 1)
return True
# Page Up: PgUp or Ctrl+U (21) or 'b'
if ch in (curses.KEY_PPAGE, 21, ord('b')):
step = max(5, h_vis - 2)
self.copy_cursor_line = max(0, self.copy_cursor_line - step)
self.auto_scroll = False
self.transcript_scroll_offset = min(max_off, self.transcript_scroll_offset + step)
return True
# Page Down: PgDn or Ctrl+D (4) or 'f'
if ch in (curses.KEY_NPAGE, 4):
step = max(5, h_vis - 2)
self.copy_cursor_line = min(max(0, total - 1), self.copy_cursor_line + step)
self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - step)
if self.transcript_scroll_offset == 0:
self.auto_scroll = True
return True
# Top of history: 'g' or Home
if ch in (ord('g'), curses.KEY_HOME):
self.copy_cursor_line = 0
self.auto_scroll = False
self.transcript_scroll_offset = max_off
return True
# Bottom of history: 'G' or End
if ch in (ord('G'), curses.KEY_END):
self.copy_cursor_line = max(0, total - 1)
self.auto_scroll = True
self.transcript_scroll_offset = 0
return True
return True
def _handle_normal_key(self, ch: int) -> bool:
# If in tmux copy-mode, route all keys to copy mode handler
if getattr(self, "copy_mode", False):
return self._handle_copy_mode_key(ch)
# Enter Visual Copy Mode: 'v' or 'V' (starts selection on visible line)
if ch in (ord('v'), ord('V')):
self.copy_mode = True
self.focus_pane = "transcript"
self.copy_cursor_line = self._get_current_visible_line_idx()
self.visual_sel_start = self.copy_cursor_line
self.set_toast("VISUAL line copy mode. Move with j/k, copy with y/Enter, cancel with Esc/q.", "warn_banner")
return True
# Quit
if ch in (ord('q'), ord('Q')):
return False
# Toggle Clean line-by-line vs Classic Boxed transcript view: 'b' or 'B'
if ch in (ord('b'), ord('B')):
self.toggle_transcript_style()
return True
# Box mode Fast Actions: Tab 6 (SSH) — placed before the 's'/'c'/'y'
# globals below so SSH keys win on this tab.
if self.mode == "box" and self.box_tab == 6:
if ch in (curses.KEY_ENTER, 10, 13):
self._ssh_popout_selected()
return True
elif ch in (ord('s'), ord('S')):
self._ssh_popout_selected()
return True
elif ch in (ord('c'), ord('C')):
self._ssh_copy_dial_selected()
return True
elif ch in (ord('u'), ord('U')):
rows = self.get_ssh_rows()
if rows and 0 <= self.ssh_sel_idx < len(rows):
acct = rows[self.ssh_sel_idx]
threading.Thread(target=self._async_ssh_uptime, args=(acct,), daemon=True).start()
self.set_toast(f"Probing container uptime on {acct}...", "info")
return True
elif ch in (ord('r'), ord('R')):
threading.Thread(target=self.data._fetch_ssh_health, daemon=True).start()
self.set_toast("Probing SSH tunnels via jump host...", "info")
return True
# Open Context Menu for active sidebar thread, fleet agent, or message: 'x', 'c', or Space
if ch in (ord('x'), ord('X'), ord('c'), ord('C'), ord(' ')) and not (self.mode == "box" and self.box_tab == 2):
if self.focus_pane == "fleet":
with self.data.lock:
nodes = list(self.data.nodes)
if nodes and 0 <= getattr(self, "fleet_focus_idx", 0) < len(nodes):
node = nodes[self.fleet_focus_idx]
st_info = self.data.node_status.get(node, {})
self.context_agent = {
"node": node,
"status": st_info,
"index": self.fleet_focus_idx,
}
self.agent_context_sel = 0
self.modal = "agent_context"
self._modal_open_time = time.time()
self.set_toast(f"Opened Agent Context Menu: [{node.upper()}]", "info")
return True
elif self.focus_pane in ("sidechats", "sidebar"):
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
if threads and 0 <= self.thread_sel_idx < len(threads):
t = threads[self.thread_sel_idx]
self.context_chat = {
"node": self.data.active_node,
"thread": t,
"index": self.thread_sel_idx,
}
self.chat_context_sel = 0
self.modal = "chat_context"
self._modal_open_time = time.time()
title = t.get("title") or t.get("session_id", "")[:8]
self.set_toast(f"Opened Chat Context Menu: '{title}'", "info")
return True
else:
m = None
idx = getattr(self, "copy_cursor_line", None)
if idx is not None:
lines = getattr(self, "_transcript_cache_lines", [])
for i in range(min(idx, len(lines) - 1), -1, -1):
if i < len(lines) and lines[i][2]:
m = lines[i][2]
break
if not m:
with self.data.lock:
msgs = list(self.data.history_cache.get((self.data.active_node, self.data.active_thread_id), []))
if msgs:
m = msgs[-1]
if m:
self.context_message = m
self.message_context_sel = 0
self.modal = "message_context"
self._modal_open_time = time.time()
r_role = m.get("role", "agent").upper()
self.set_toast(f"Message Context Menu ({r_role})", "info")
return True
# Fast Agent Search & Select Modal: 'f' or 'F', or '/' when focused on fleet or sidechats
if ch in (ord('f'), ord('F')) or (self.focus_pane in ("fleet", "sidechats", "sidebar") and ch == ord('/')):
self.modal = "agent_search"
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.agent_search_sel = 0
self.agent_search_scroll = 0
return True
# Enter Insert Mode
if ch in (ord('i'), ord('I')):
self.editor_mode = "INSERT"
self.input_cursor = len(self.input_buf)
self.set_toast("INSERT mode. Press [Esc] to lock.", "info")
return True
# Enter Command / Slash Mode (when on transcript)
if ch in (ord('/'), ord(':')):
self.editor_mode = "INSERT"
if not self.input_buf.startswith("/"):
self.input_buf = "/" + self.input_buf
self.input_cursor = len(self.input_buf)
return True
# Enter key in normal mode
if ch in (curses.KEY_ENTER, 10, 13):
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
if self.focus_pane == "fleet":
# Switch to highlighted fleet agent and switch focus to sidechats
with self.data.lock:
nodes = list(self.data.nodes)
if nodes and 0 <= getattr(self, "fleet_focus_idx", 0) < len(nodes):
new_node = nodes[self.fleet_focus_idx]
self.data.set_active_target(new_node)
self.focus_pane = "sidechats"
self.set_toast(f"Switched to agent: {new_node.upper()}", "success")
return True
elif self.focus_pane in ("sidechats", "sidebar"):
# Activate selected thread
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
if threads and 0 <= self.thread_sel_idx < len(threads):
t = threads[self.thread_sel_idx]
sid = t.get("session_id")
title = t.get("title") or sid[:8]
self.data.set_active_target(self.data.active_node, sid, title)
self.auto_scroll = True
self.transcript_scroll_offset = 0
self.focus_pane = "transcript"
self.set_toast(f"Opened thread '{title}'", "success")
return True
else:
# On transcript: Enter switches to INSERT mode
self.editor_mode = "INSERT"
self.input_cursor = len(self.input_buf)
return True
elif self.mode == "box":
if self.box_tab == 1:
# Fleet tab: open chat with selected node
if 0 <= self.fleet_sel_idx < len(self.data.nodes):
node = self.data.nodes[self.fleet_sel_idx]
self.data.set_active_target(node)
self.auto_scroll = True
self.transcript_scroll_offset = 0
self.box_tab = 0
self.set_toast(f"Opened Chat with {node.upper()}", "success")
return True
elif self.box_tab == 2:
# Approvals tab: open chat with node
with self.data.lock:
apps = list(self.data.approvals_cache)
if apps and 0 <= self.approvals_sel_idx < len(apps):
node = apps[self.approvals_sel_idx].get("node")
if node:
self.data.set_active_target(node)
self.box_tab = 0
self.set_toast(f"Opened Chat with {node.upper()}", "success")
return True
elif self.box_tab == 3:
# Jobs tab: link to conversation on [Enter] for entry/read/scrollback via Agent Chat 1
with self.data.lock:
jobs = list(self.data.jobs_cache)
if jobs and 0 <= self.jobs_sel_idx < len(jobs):
job = jobs[self.jobs_sel_idx]
jname = job.get("name")
agent = job.get("agent", self.data.active_node)
self._open_job_conversation(jname, agent)
return True
elif self.box_tab == 4:
# Tmux tab: display attach instructions toast
with self.data.lock:
sessions = list(self.data.tmux_cache)
if sessions and 0 <= self.tmux_sel_idx < len(sessions):
sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip()
self.set_toast(f"Attach: tmux -S /tmp/tmux-muse.sock attach -t {sess_name}", "info")
return True
else:
return True
# Toggle Pin for selected thread (sidebar) or chat sent to prompt fill (transcript) / Expand Prompts ('P')
if ch in (ord('p'), ord('P')):
if self.focus_pane in ("sidechats", "sidebar"):
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
target_idx = self.thread_sel_idx
if threads and 0 <= target_idx < len(threads):
t = threads[target_idx]
sid = t.get("session_id")
if sid:
new_pin = self.data.toggle_pin_thread(self.data.active_node, sid)
state = "Pinned" if new_pin else "Unpinned"
title = t.get("title") or sid[:8]
self.set_toast(f"📌 {state} chat: {title[:25]}", "success")
return True
else:
if ch == ord('P'):
self.modal = "prompts"
self.prompt_sel_idx = 0
return True
else:
# 'p': Pin latest chat sent from active thread (or recent sends) to prompt fill / list
cur_node = self.data.active_node
cur_tid = self.data.active_thread_id
with self.data.lock:
thread_msgs = list(self.data.history_cache.get((cur_node, cur_tid), []))
user_thread_msgs = [m for m in thread_msgs if m.get("role") == "user" and (m.get("text") or "").strip()]
if user_thread_msgs:
target_text = user_thread_msgs[-1]["text"].strip()
target_send = {"text": target_text, "node": cur_node}
else:
sends = self.get_chat_sends()
node_sends = [s for s in sends if s.get("node") == cur_node]
target_send = node_sends[0] if node_sends else (sends[0] if sends else None)
if target_send:
p, is_new = self.prompt_manager.pin_chat_send(target_send["text"], node=target_send.get("node", cur_node), toggle=True)
self.prompts = self.prompt_manager.get_sorted_prompts()
if p.get("pinned"):
state = "Pinned to" if is_new else "Pinned"
self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success")
else:
self.set_toast(f"📌 Unpinned from prompt fill: '{p['title']}'", "warn_banner")
return True
else:
self.modal = "prompts"
self.prompt_sel_idx = 0
return True
# Search Chat Sends: 's' or 'S' or Ctrl-R (18)
if ch in (ord('s'), ord('S'), 18):
self.modal = "history_search"
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.history_search_sel = 0
self.history_search_scroll = 0
return True
# Scroll compact prompt shelf left/right: '[' and ']'
if ch == ord('['):
self.prompt_scroll_idx = max(0, self.prompt_scroll_idx - 1)
return True
if ch == ord(']'):
self.prompt_scroll_idx = min(max(0, len(self.prompts) - 1), self.prompt_scroll_idx + 1)
return True
# Copy to clipboard: 'y' for active/latest message, 'Y' for full transcript
if ch == ord('y'):
self.copy_message_to_clipboard()
return True
if ch == ord('Y'):
self.copy_transcript_to_clipboard()
return True
# Help modal
if ch in (ord('?'), curses.KEY_F1):
self.modal = "help"
return True
# Approvals modal
if ch in (ord('a'), ord('A'), curses.KEY_F2, curses.KEY_F3):
if self.mode == "box" and self.box_tab == 4:
# On Tmux tab, 'a' shows attach instructions
with self.data.lock:
sessions = list(self.data.tmux_cache)
if sessions and 0 <= self.tmux_sel_idx < len(sessions):
sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip()
self.set_toast(f"Attach: tmux -S /tmp/tmux-muse.sock attach -t {sess_name}", "info")
return True
self.modal = "approvals"
return True
# New sidechat modal
if ch in (ord('n'), ord('N')):
self.modal = "new_sidechat"
self.modal_input_buf = ""
self.modal_input_cursor = 0
return True
# Work Order modal
if ch in (ord('w'), ord('W')):
self.modal = "work_order"
self.modal_input_buf = ""
self.modal_input_cursor = 0
return True
# Mode switch (Muse vs Box)
if ch in (ord('m'), ord('M'), curses.KEY_F5):
self.mode = "box" if self.mode == "muse" else "muse"
self.set_toast(f"Switched mode to {self.mode.upper()}", "info")
return True
# Box mode Fast Actions: Tab 2 (Approvals)
if self.mode == "box" and self.box_tab == 2:
with self.data.lock:
apps = list(self.data.approvals_cache)
cur_node = apps[self.approvals_sel_idx].get("node") if apps and 0 <= self.approvals_sel_idx < len(apps) else self.data.active_node
if ch == ord('1'):
threading.Thread(target=self._async_resolve_approval, args=("allow", cur_node), daemon=True).start()
return True
elif ch == ord('2'):
threading.Thread(target=self._async_resolve_approval, args=("always", cur_node), daemon=True).start()
return True
elif ch == ord('3'):
threading.Thread(target=self._async_resolve_approval, args=("deny", cur_node), daemon=True).start()
return True
elif ch in (ord('r'), ord('R')):
threading.Thread(target=self._async_resolve_approval, args=("reply", cur_node, "Proceed with standard execution."), daemon=True).start()
return True
elif ch in (ord('x'), ord('X')):
threading.Thread(target=self._async_resolve_approval, args=("dismiss", cur_node), daemon=True).start()
return True
elif ch in (ord('c'), ord('C')):
threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start()
return True
# Box mode Fast Actions: Tab 3 (Jobs)
if self.mode == "box" and self.box_tab == 3:
if ch in (ord('r'), ord('R'), ord('t'), ord('T')):
with self.data.lock:
jobs = list(self.data.jobs_cache)
if jobs and 0 <= self.jobs_sel_idx < len(jobs):
jname = jobs[self.jobs_sel_idx].get("name")
threading.Thread(target=self._async_trigger_job, args=(jname,), daemon=True).start()
return True
elif ch in (ord('s'), ord('S')):
threading.Thread(target=self.data._fetch_jobs, daemon=True).start()
self.set_toast("Refreshed jobs scheduler list.", "info")
return True
# Box mode Fast Actions: Tab 4 (Tmux)
if self.mode == "box" and self.box_tab == 4:
if ch in (ord('x'), ord('X'), ord('d'), ord('D'), ord('K')):
with self.data.lock:
sessions = list(self.data.tmux_cache)
if sessions and 0 <= self.tmux_sel_idx < len(sessions):
sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip()
threading.Thread(target=self._async_kill_tmux, args=(sess_name,), daemon=True).start()
return True
elif ch in (ord('r'), ord('R')):
threading.Thread(target=self.data._fetch_tmux_sessions, daemon=True).start()
self.set_toast("Refreshed tmux background sessions.", "info")
return True
# Reply to latest message: 'R' (when on transcript in normal mode)
if ch == ord('R') and self.focus_pane == "transcript":
with self.data.lock:
node = self.data.active_node
tid = self.data.active_thread_id
msgs = self.data.transcript_cache.get(f"{node}/{tid}", [])
if msgs:
self.trigger_reply_to_message(msgs[-1])
return True
else:
self.set_toast("⚠️ No messages to reply to", "warn")
return True
# Global Refresh / Sync (with two-tap force override during cooldown)
if ch in (ord('r'), ord('R')):
node = self.data.active_node
cd = self.data.get_node_cooldown_remaining(node)
if cd > 0:
now = time.time()
last_force = getattr(self, "_last_force_sync_time", 0.0)
if (now - last_force) <= 2.0:
self.data.clear_node_rate_limit(node)
self.data.lazy_fetch_threads(node, force=True)
self.data.trigger_refresh()
self.set_toast(f"⚡ Force-syncing {node.upper()} (cooldown bypassed)...", "info")
self._last_force_sync_time = 0.0
else:
self._last_force_sync_time = now
self.set_toast(f"⏳ {node.upper()} cooling down ({cd}s left). Press 'r' again within 2s to force.", "warn")
return True
self.data.lazy_fetch_threads(node, force=True)
self.data.trigger_refresh()
self.set_toast(f"Syncing threads for {node.upper()}...", "info")
return True
# Node Jump: '1' - '9' and '0' (when in muse mode)
if self.mode == "muse" and (ord('1') <= ch <= ord('9') or ch == ord('0')):
idx = (ch - ord('1')) if ch != ord('0') else 9
if idx < len(self.data.nodes):
node = self.data.nodes[idx]
self.data.set_active_target(node)
self.thread_sel_idx = 0
self.set_toast(f"Switched to agent: {node.upper()}", "success")
return True
# Box mode tab selection: '1' - '7' (when in box mode, except 1-3 on Tab 2)
if self.mode == "box":
if self.box_tab == 2:
if ord('4') <= ch <= ord('7'):
self.box_tab = ch - ord('1')
return True
elif ord('1') <= ch <= ord('7'):
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) % 7
return True
elif ch in (ord(']'), curses.KEY_RIGHT):
self.box_tab = (self.box_tab + 1) % 7
return True
elif ch == ord('\t'):
self.box_tab = (self.box_tab + 1) % 7
return True
elif ch == curses.KEY_BTAB:
self.box_tab = (self.box_tab - 1) % 7
return True
# Muse View / Chat Tab: Direct Conversation Cycling & Pane Switching
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
if ch == ord('\t'):
# Tab: Cycle to next conversation thread and load immediately
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
if threads:
self.thread_sel_idx = (self.thread_sel_idx + 1) % len(threads)
t = threads[self.thread_sel_idx]
sid = t.get("session_id")
title = t.get("title") or sid[:8]
self.data.set_active_target(self.data.active_node, sid, title)
self.auto_scroll = True
self.transcript_scroll_offset = 0
self.set_toast(f"Thread: {title} ({self.thread_sel_idx + 1}/{len(threads)})", "info")
else:
if self.data.is_loading_threads(self.data.active_node):
self.set_toast(f"Threads are currently loading for {self.data.active_node.upper()}...", "info")
else:
self.set_toast("No threads to cycle. Press 'n' to create one.", "info")
return True
elif ch == curses.KEY_BTAB:
# Shift-Tab: Cycle to previous conversation thread
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
if threads:
self.thread_sel_idx = (self.thread_sel_idx - 1) % len(threads)
t = threads[self.thread_sel_idx]
sid = t.get("session_id")
title = t.get("title") or sid[:8]
self.data.set_active_target(self.data.active_node, sid, title)
self.auto_scroll = True
self.transcript_scroll_offset = 0
self.set_toast(f"Thread: {title} ({self.thread_sel_idx + 1}/{len(threads)})", "info")
else:
if self.data.is_loading_threads(self.data.active_node):
self.set_toast(f"Threads are currently loading for {self.data.active_node.upper()}...", "info")
else:
self.set_toast("No threads to cycle. Press 'n' to create one.", "info")
return True
elif ch in (ord('h'), curses.KEY_LEFT):
if self.focus_pane == "transcript":
self.focus_pane = "sidechats"
elif self.focus_pane in ("sidechats", "sidebar"):
self.focus_pane = "fleet"
else:
self.focus_pane = "sidechats"
return True
elif ch in (ord('l'), curses.KEY_RIGHT):
if self.focus_pane == "fleet":
self.focus_pane = "sidechats"
elif self.focus_pane in ("sidechats", "sidebar"):
self.focus_pane = "transcript"
else:
self.focus_pane = "transcript"
return True
# Navigation: j / k / Up / Down
if ch in (curses.KEY_UP, ord('k')):
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
if self.focus_pane == "fleet":
with self.data.lock:
nodes = list(self.data.nodes)
if nodes:
self.fleet_focus_idx = max(0, getattr(self, "fleet_focus_idx", 0) - 1)
if self.fleet_focus_idx < getattr(self, "agent_scroll_start", 0):
self.agent_scroll_start = self.fleet_focus_idx
elif self.focus_pane in ("sidechats", "sidebar"):
self.thread_sel_idx = max(0, self.thread_sel_idx - 1)
else:
self.auto_scroll = False
max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", 20))
self.transcript_scroll_offset = min(max_scroll, self.transcript_scroll_offset + 3)
elif self.mode == "box":
if self.box_tab == 1:
self.fleet_sel_idx = max(0, self.fleet_sel_idx - 1)
elif self.box_tab == 2:
self.approvals_sel_idx = max(0, self.approvals_sel_idx - 1)
elif self.box_tab == 3:
self.jobs_sel_idx = max(0, self.jobs_sel_idx - 1)
elif self.box_tab == 4:
self.tmux_sel_idx = max(0, self.tmux_sel_idx - 1)
elif self.box_tab == 6:
self.ssh_sel_idx = max(0, self.ssh_sel_idx - 1)
else:
self.table_scroll_idx = max(0, self.table_scroll_idx - 1)
return True
elif ch in (curses.KEY_DOWN, ord('j')):
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
if self.focus_pane == "fleet":
with self.data.lock:
nodes = list(self.data.nodes)
if nodes:
self.fleet_focus_idx = min(len(nodes) - 1, getattr(self, "fleet_focus_idx", 0) + 1)
vis = getattr(self, "agent_visible_count", 6)
if self.fleet_focus_idx >= getattr(self, "agent_scroll_start", 0) + vis:
self.agent_scroll_start = self.fleet_focus_idx - vis + 1
elif self.focus_pane in ("sidechats", "sidebar"):
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
if threads:
self.thread_sel_idx = min(len(threads) - 1, self.thread_sel_idx + 1)
else:
if self.transcript_scroll_offset > 0:
self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - 3)
if self.transcript_scroll_offset == 0:
self.auto_scroll = True
elif self.mode == "box":
if self.box_tab == 1:
self.fleet_sel_idx = min(len(self.data.nodes) - 1, self.fleet_sel_idx + 1)
elif self.box_tab == 2:
with self.data.lock:
apps = list(self.data.approvals_cache)
if apps:
self.approvals_sel_idx = min(len(apps) - 1, self.approvals_sel_idx + 1)
elif self.box_tab == 3:
with self.data.lock:
jobs = list(self.data.jobs_cache)
if jobs:
self.jobs_sel_idx = min(len(jobs) - 1, self.jobs_sel_idx + 1)
elif self.box_tab == 4:
with self.data.lock:
sessions = list(self.data.tmux_cache)
if sessions:
self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 1)
elif self.box_tab == 6:
rows = self.get_ssh_rows()
if rows:
self.ssh_sel_idx = min(len(rows) - 1, self.ssh_sel_idx + 1)
else:
self.table_scroll_idx += 1
return True
# PageUp / PageDown / Ctrl+U / Ctrl+D
if ch in (curses.KEY_PPAGE, 21): # PageUp or Ctrl+U
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
self.auto_scroll = False
max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", 20))
step = max(5, getattr(self, "transcript_height", 20) - 2)
self.transcript_scroll_offset = min(max_scroll, self.transcript_scroll_offset + step)
elif self.mode == "box":
if self.box_tab == 1:
self.fleet_sel_idx = max(0, self.fleet_sel_idx - 5)
elif self.box_tab == 2:
self.approvals_sel_idx = max(0, self.approvals_sel_idx - 5)
elif self.box_tab == 3:
self.jobs_sel_idx = max(0, self.jobs_sel_idx - 5)
elif self.box_tab == 4:
self.tmux_sel_idx = max(0, self.tmux_sel_idx - 5)
elif self.box_tab == 6:
self.ssh_sel_idx = max(0, self.ssh_sel_idx - 5)
else:
self.table_scroll_idx = max(0, self.table_scroll_idx - 5)
return True
elif ch in (curses.KEY_NPAGE, 4): # PageDown or Ctrl+D
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
step = max(5, getattr(self, "transcript_height", 20) - 2)
self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - step)
if self.transcript_scroll_offset == 0:
self.auto_scroll = True
elif self.mode == "box":
if self.box_tab == 1:
self.fleet_sel_idx = min(len(self.data.nodes) - 1, self.fleet_sel_idx + 5)
elif self.box_tab == 2:
with self.data.lock:
apps = list(self.data.approvals_cache)
if apps:
self.approvals_sel_idx = min(len(apps) - 1, self.approvals_sel_idx + 5)
elif self.box_tab == 3:
with self.data.lock:
jobs = list(self.data.jobs_cache)
if jobs:
self.jobs_sel_idx = min(len(jobs) - 1, self.jobs_sel_idx + 5)
elif self.box_tab == 4:
with self.data.lock:
sessions = list(self.data.tmux_cache)
if sessions:
self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 5)
elif self.box_tab == 6:
rows = self.get_ssh_rows()
if rows:
self.ssh_sel_idx = min(len(rows) - 1, self.ssh_sel_idx + 5)
else:
self.table_scroll_idx += 5
return True
# Top / Bottom: g / G / Home / End
if ch in (ord('g'), curses.KEY_HOME):
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
if self.focus_pane == "fleet":
self.fleet_focus_idx = 0
self.agent_scroll_start = 0
elif self.focus_pane in ("sidechats", "sidebar"):
self.thread_sel_idx = 0
else:
self.auto_scroll = False
max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", 20))
self.transcript_scroll_offset = max_scroll
elif self.mode == "box":
if self.box_tab == 1:
self.fleet_sel_idx = 0
elif self.box_tab == 2:
self.approvals_sel_idx = 0
elif self.box_tab == 3:
self.jobs_sel_idx = 0
elif self.box_tab == 4:
self.tmux_sel_idx = 0
elif self.box_tab == 6:
self.ssh_sel_idx = 0
else:
self.table_scroll_idx = 0
return True
elif ch in (ord('G'), curses.KEY_END):
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
if self.focus_pane == "fleet":
with self.data.lock:
nodes = list(self.data.nodes)
if nodes:
self.fleet_focus_idx = len(nodes) - 1
vis = getattr(self, "agent_visible_count", 6)
self.agent_scroll_start = max(0, len(nodes) - vis)
elif self.focus_pane in ("sidechats", "sidebar"):
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
if threads:
self.thread_sel_idx = max(0, len(threads) - 1)
else:
self.auto_scroll = True
self.transcript_scroll_offset = 0
elif self.mode == "box":
if self.box_tab == 1:
self.fleet_sel_idx = max(0, len(self.data.nodes) - 1)
elif self.box_tab == 2:
with self.data.lock:
apps = list(self.data.approvals_cache)
if apps:
self.approvals_sel_idx = max(0, len(apps) - 1)
elif self.box_tab == 3:
with self.data.lock:
jobs = list(self.data.jobs_cache)
if jobs:
self.jobs_sel_idx = max(0, len(jobs) - 1)
elif self.box_tab == 4:
with self.data.lock:
sessions = list(self.data.tmux_cache)
if sessions:
self.tmux_sel_idx = max(0, len(sessions) - 1)
elif self.box_tab == 6:
rows = self.get_ssh_rows()
if rows:
self.ssh_sel_idx = max(0, len(rows) - 1)
else:
self.table_scroll_idx = max(0, len(self.data.nodes) - 5)
return True
return True
def _handle_mouse(self, mx: int, my: int, bstate: int) -> bool:
"""Handle mouse clicks, selection, and mouse wheel scrolling."""
self.data.record_activity()
# Scroll up masks: BUTTON4_PRESSED, BUTTON4_CLICKED, 0x10000, 0x20000
is_scroll_up = bool(bstate & (getattr(curses, "BUTTON4_PRESSED", 0x10000) |
getattr(curses, "BUTTON4_CLICKED", 0x20000) |
0x10000 | 0x20000))
# Scroll down masks: BUTTON5_PRESSED, BUTTON5_CLICKED, 0x200000, 0x400000, 0x8000000
is_scroll_down = bool(bstate & (getattr(curses, "BUTTON5_PRESSED", 0x200000) |
getattr(curses, "BUTTON5_CLICKED", 0x400000) |
0x200000 | 0x400000 | 0x8000000))
# Button state masks
btn3_mask = (
getattr(curses, "BUTTON3_CLICKED", 0x1000) |
getattr(curses, "BUTTON3_PRESSED", 0x800) |
getattr(curses, "BUTTON3_RELEASED", 0x400) |
getattr(curses, "BUTTON3_DOUBLE_CLICKED", 0x2000) |
getattr(curses, "BUTTON3_TRIPLE_CLICKED", 0x4000)
)
btn2_mask = (
getattr(curses, "BUTTON2_CLICKED", 0x80) |
getattr(curses, "BUTTON2_PRESSED", 0x40) |
getattr(curses, "BUTTON2_RELEASED", 0x20) |
getattr(curses, "BUTTON2_DOUBLE_CLICKED", 0x100) |
getattr(curses, "BUTTON2_TRIPLE_CLICKED", 0x200)
)
ctrl_mask = getattr(curses, "BUTTON_CTRL", 0x2000000)
alt_mask = getattr(curses, "BUTTON_ALT", 0x8000000)
shift_mask = getattr(curses, "BUTTON_SHIFT", 0x4000000)
# Right-click detection:
# Check Button 3 (standard right click), Button 2 (middle click / 2-button trackpad),
# or Ctrl/Alt/Shift + Button 1 (macOS / laptop right-click modifier)
has_btn3 = bool(bstate & btn3_mask)
has_btn2 = bool(bstate & btn2_mask)
btn1_any = bool(bstate & (curses.BUTTON1_CLICKED | curses.BUTTON1_PRESSED | curses.BUTTON1_RELEASED))
has_ctrl_left = bool(btn1_any and (bstate & ctrl_mask))
has_alt_left = bool(btn1_any and (bstate & alt_mask))
has_shift_left = bool(btn1_any and (bstate & shift_mask))
is_right_click = has_btn3 or has_btn2 or has_ctrl_left or has_alt_left or has_shift_left
is_double_click = bool(bstate & (
getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0x8) |
getattr(curses, "BUTTON2_DOUBLE_CLICKED", 0x100) |
getattr(curses, "BUTTON3_DOUBLE_CLICKED", 0x2000)
))
# Standard left-click press: only if NOT a right-click / modifier-click!
is_press = bool(bstate & (curses.BUTTON1_CLICKED | curses.BUTTON1_PRESSED)) and not is_right_click
is_release = bool(bstate & curses.BUTTON1_RELEASED) and not is_right_click
is_drag = bool(bstate & curses.REPORT_MOUSE_POSITION)
if is_right_click:
now = time.time()
if now - getattr(self, "_last_right_click_time", 0.0) < 0.15:
return True
self._last_right_click_time = now
is_button_action = is_press or is_release or is_right_click or is_double_click
is_click = is_button_action or is_drag
h, w = self.stdscr.getmaxyx()
sidebar_w = min(36, max(26, w // 4))
header_lines = 2 if self.mode == "box" else 1
bottom_bar_h = 3
content_y = header_lines
is_main_view = (self.mode == "muse" or (self.mode == "box" and self.box_tab == 0))
prompt_shelf_h = 2 if is_main_view else 0
prompt_shelf_y = getattr(self, "_prompt_shelf_y", h - bottom_bar_h - prompt_shelf_h)
content_h = h - content_y - bottom_bar_h - prompt_shelf_h
# 1. WHEEL SCROLLING
if is_scroll_up:
if self.modal:
return True
if is_main_view and prompt_shelf_h > 0 and (prompt_shelf_y <= my < h - bottom_bar_h):
self.prompt_scroll_idx = max(0, self.prompt_scroll_idx - 1)
return True
if self.mode == "box" and self.box_tab != 0:
if self.box_tab == 1:
self.fleet_sel_idx = max(0, self.fleet_sel_idx - 1)
elif self.box_tab == 2:
self.approvals_sel_idx = max(0, self.approvals_sel_idx - 1)
elif self.box_tab == 3:
self.jobs_sel_idx = max(0, self.jobs_sel_idx - 1)
elif self.box_tab == 4:
self.tmux_sel_idx = max(0, self.tmux_sel_idx - 1)
elif self.box_tab == 6:
self.ssh_sel_idx = max(0, self.ssh_sel_idx - 1)
else:
self.table_scroll_idx = max(0, self.table_scroll_idx - 1)
elif mx >= sidebar_w:
# Scroll Transcript history up
self.auto_scroll = False
max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", content_h - 2))
self.transcript_scroll_offset = min(max_scroll, self.transcript_scroll_offset + 3)
else:
visible_agent_count = getattr(self, "agent_visible_count", min(len(self.data.nodes), 6))
divider_y = content_y + 1 + visible_agent_count
if my < divider_y or self.focus_pane == "fleet":
# Scroll Fleet Agents up
with self.data.lock:
nodes = list(self.data.nodes)
if nodes:
self.fleet_focus_idx = max(0, getattr(self, "fleet_focus_idx", 0) - 1)
if self.fleet_focus_idx < getattr(self, "agent_scroll_start", 0):
self.agent_scroll_start = self.fleet_focus_idx
else:
self.agent_scroll_start = max(0, getattr(self, "agent_scroll_start", 0) - 1)
else:
# Scroll Sidebar thread selection up
self.thread_sel_idx = max(0, self.thread_sel_idx - 1)
return True
if is_scroll_down:
if self.modal:
return True
if is_main_view and prompt_shelf_h > 0 and (prompt_shelf_y <= my < h - bottom_bar_h):
self.prompt_scroll_idx = min(max(0, len(self.prompts) - 1), self.prompt_scroll_idx + 1)
return True
if self.mode == "box" and self.box_tab != 0:
if self.box_tab == 1:
self.fleet_sel_idx = min(len(self.data.nodes) - 1, self.fleet_sel_idx + 1)
elif self.box_tab == 2:
with self.data.lock:
apps = list(self.data.approvals_cache)
if apps:
self.approvals_sel_idx = min(len(apps) - 1, self.approvals_sel_idx + 1)
elif self.box_tab == 3:
with self.data.lock:
jobs = list(self.data.jobs_cache)
if jobs:
self.jobs_sel_idx = min(len(jobs) - 1, self.jobs_sel_idx + 1)
elif self.box_tab == 4:
with self.data.lock:
sessions = list(self.data.tmux_cache)
if sessions:
self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 1)
elif self.box_tab == 6:
rows = self.get_ssh_rows()
if rows:
self.ssh_sel_idx = min(len(rows) - 1, self.ssh_sel_idx + 1)
else:
self.table_scroll_idx += 1
elif mx >= sidebar_w:
# Scroll Transcript history down
self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - 3)
if self.transcript_scroll_offset == 0:
self.auto_scroll = True
else:
visible_agent_count = getattr(self, "agent_visible_count", min(len(self.data.nodes), 6))
divider_y = content_y + 1 + visible_agent_count
if my < divider_y or self.focus_pane == "fleet":
# Scroll Fleet Agents down
with self.data.lock:
total_nodes = len(self.data.nodes)
if total_nodes > 0:
self.fleet_focus_idx = min(total_nodes - 1, getattr(self, "fleet_focus_idx", 0) + 1)
if self.fleet_focus_idx >= getattr(self, "agent_scroll_start", 0) + visible_agent_count:
self.agent_scroll_start = self.fleet_focus_idx - visible_agent_count + 1
else:
max_scroll = max(0, total_nodes - visible_agent_count)
self.agent_scroll_start = min(max_scroll, getattr(self, "agent_scroll_start", 0) + 1)
else:
# Scroll Sidebar thread selection down
with self.data.lock:
threads = self.data.threads_cache.get(self.data.active_node, [])
if threads:
self.thread_sel_idx = min(len(threads) - 1, self.thread_sel_idx + 1)
return True
# 2. CLICK & MODAL INTERACTION HANDLING
if is_click:
# If Modal is active
if self.modal:
is_context_popup = self.modal in ("chat_context", "agent_context", "message_context")
if is_context_popup:
modal_w = min(56, w - 4)
modal_h = min(17 if self.modal == "chat_context" else (14 if self.modal == "agent_context" else 11), h - 2)
anchor_x, anchor_y = getattr(self, "context_popup_pos", (w // 4, h // 3))
top_y = max(1, min(anchor_y, h - modal_h - 2))
left_x = max(1, min(anchor_x, w - modal_w - 2))
self._context_popup_bounds = (left_x, top_y, modal_w, modal_h)
else:
modal_w = min(84 if self.modal in ("prompts", "history_search") else 74, w - 6)
modal_h = min(22 if self.modal in ("prompts", "history_search") else 20, h - 4)
top_y = (h - modal_h) // 2
left_x = (w - modal_w) // 2
self._context_popup_bounds = (left_x, top_y, modal_w, modal_h)
is_inside_modal = (left_x <= mx < left_x + modal_w and top_y <= my < top_y + modal_h)
if is_inside_modal:
if self.modal in ("chat_context", "agent_context", "message_context"):
actions = getattr(self, f"_{self.modal}_actions", [])
action_row = my - (top_y + 5)
if 0 <= action_row < len(actions):
# Update selection index on hover or drag
if self.modal == "chat_context":
self.chat_context_sel = action_row
elif self.modal == "agent_context":
self.agent_context_sel = action_row
elif self.modal == "message_context":
self.message_context_sel = action_row
# If click press or drag release (after debounce), execute action!
if is_press or (is_release and time.time() - getattr(self, "_modal_open_time", 0.0) >= 0.15):
executor = getattr(self, f"_execute_{self.modal.split('_')[0]}_action")
executor(actions[action_row][0])
return True
elif my == top_y + modal_h - 2 or my == top_y:
if is_press or is_release:
self.modal = None
return True
return True
elif self.modal == "help":
if is_press:
self.modal = None
return True
elif self.modal == "approvals":
act_y = top_y + modal_h - 4
if my == act_y + 1:
rel_x = mx - left_x - 3
# [1] Allow [2] Always [3] Deny [R] Proceed [X] Dismiss [C] Clear All
if 0 <= rel_x <= 10:
threading.Thread(target=self._async_resolve_approval, args=("allow",), daemon=True).start()
self.modal = None
elif 11 <= rel_x <= 22:
threading.Thread(target=self._async_resolve_approval, args=("always",), daemon=True).start()
self.modal = None
elif 23 <= rel_x <= 32:
threading.Thread(target=self._async_resolve_approval, args=("deny",), daemon=True).start()
self.modal = None
elif 33 <= rel_x <= 46:
threading.Thread(target=self._async_resolve_approval, args=("reply", None, "Proceed with standard execution."), daemon=True).start()
self.modal = None
elif 47 <= rel_x <= 60:
threading.Thread(target=self._async_resolve_approval, args=("dismiss",), daemon=True).start()
self.modal = None
elif rel_x >= 61:
threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start()
self.modal = None
elif my == top_y + modal_h - 2 or my == top_y:
self.modal = None
return True
elif self.modal in ("new_sidechat", "work_order"):
if my == top_y + modal_h - 2:
self.modal = None
return True
elif self.modal == "prompts":
list_w = min(32, modal_w // 2)
if (left_x + 2 <= mx <= left_x + list_w) and (top_y + 2 <= my < top_y + modal_h - 2):
row_offset = my - (top_y + 2)
with self.data.lock:
prompts = list(self.prompts)
visible_list_rows = modal_h - 4
scroll_start = 0
if self.prompt_sel_idx >= visible_list_rows:
scroll_start = self.prompt_sel_idx - visible_list_rows + 1
clicked_idx = scroll_start + row_offset
if 0 <= clicked_idx < len(prompts):
self.prompt_sel_idx = clicked_idx
p = prompts[clicked_idx]
self.input_buf = p.get("prompt", "")
self.input_cursor = len(self.input_buf)
self.editor_mode = "INSERT"
self.set_toast(f"✔ Filled prompt: '{p.get('title')}'", "success")
self.modal = None
return True
if my == top_y + modal_h - 2:
rel_x = mx - left_x - 3
if 0 <= rel_x <= 18:
if self.prompts and 0 <= self.prompt_sel_idx < len(self.prompts):
p = self.prompts[self.prompt_sel_idx]
self.input_buf = p.get("prompt", "")
self.input_cursor = len(self.input_buf)
self.editor_mode = "INSERT"
self.set_toast(f"✔ Filled prompt: '{p.get('title')}'", "success")
self.modal = None
return True
elif 19 <= rel_x <= 28:
if self.prompts and 0 <= self.prompt_sel_idx < len(self.prompts):
p = self.prompts[self.prompt_sel_idx]
new_pin = self.prompt_manager.toggle_pin_prompt(p["id"])
self.prompts = self.prompt_manager.get_sorted_prompts()
state = "Pinned" if new_pin else "Unpinned"
self.set_toast(f"📌 {state} prompt: '{p['title']}'", "success")
return True
elif 29 <= rel_x <= 43:
self.modal = "prompt_formulate"
self.modal_input_buf = ""
self.modal_input_cursor = 0
return True
elif 44 <= rel_x <= 56:
if self.prompts and 0 <= self.prompt_sel_idx < len(self.prompts):
p = self.prompts[self.prompt_sel_idx]
self.prompt_manager.delete_prompt(p["id"])
self.prompts = self.prompt_manager.get_sorted_prompts()
self.prompt_sel_idx = max(0, min(self.prompt_sel_idx, len(self.prompts) - 1))
self.set_toast(f"Deleted prompt: '{p['title']}'", "warn_banner")
return True
elif 57 <= rel_x <= 68:
self.modal = "history_search"
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.history_search_sel = 0
return True
return True
elif self.modal == "history_search":
list_start_y = top_y + 4
max_results_visible = max(2, modal_h - 8)
row_offset = my - list_start_y
sends = self.get_chat_sends(self.modal_input_buf)
if 0 <= row_offset < max_results_visible and sends:
clicked_idx = getattr(self, "history_search_scroll", 0) + row_offset
if 0 <= clicked_idx < len(sends):
self.history_search_sel = clicked_idx
send = sends[clicked_idx]
if left_x + 2 <= mx <= left_x + 8:
p, is_new = self.prompt_manager.pin_chat_send(send.get("text", ""), node=send.get("node", "muse"), toggle=True)
self.prompts = self.prompt_manager.get_sorted_prompts()
send["is_pinned"] = p.get("pinned", False)
if p.get("pinned"):
state = "Pinned to" if is_new else "Pinned"
self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success")
else:
self.set_toast(f"📌 Unpinned from prompt fill: '{p['title']}'", "warn_banner")
return True
else:
self.input_buf = send.get("text", "")
self.input_cursor = len(self.input_buf)
self.editor_mode = "INSERT"
self.set_toast(f"✔ Filled from chat history: '{send.get('text', '')[:25]}...'", "success")
self.modal = None
return True
if my == top_y + modal_h - 2 and sends and 0 <= self.history_search_sel < len(sends):
send = sends[self.history_search_sel]
p, is_new = self.prompt_manager.pin_chat_send(send.get("text", ""), node=send.get("node", "muse"), toggle=True)
self.prompts = self.prompt_manager.get_sorted_prompts()
send["is_pinned"] = p.get("pinned", False)
if p.get("pinned"):
state = "Pinned to" if is_new else "Pinned"
self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success")
else:
self.set_toast(f"📌 Unpinned from prompt fill: '{p['title']}'", "warn_banner")
return True
return True
elif self.modal == "prompt_formulate":
if my == top_y + modal_h - 2:
self.modal = None
return True
elif self.modal == "agent_search":
# Check if clicked on a result row
q = self.modal_input_buf.strip().lower()
with self.data.lock:
all_nodes = list(self.data.nodes)
matches = [n for n in all_nodes if q in n.lower()] if q else all_nodes
list_start_y = top_y + 4
max_results_visible = max(3, modal_h - 7)
row_offset = my - list_start_y
if 0 <= row_offset < max_results_visible:
target_idx = getattr(self, "agent_search_scroll", 0) + row_offset
if 0 <= target_idx < len(matches):
target_node = matches[target_idx]
self.data.set_active_target(target_node)
self.auto_scroll = True
self.transcript_scroll_offset = 0
self.set_toast(f"Switched to agent: {target_node.upper()}", "success")
self.modal = None
return True
return True
else:
# Clicked OUTSIDE modal box:
# ONLY an actual mouse button press dismisses the modal!
# Motion / hover (is_drag) MUST NEVER dismiss the modal!
if is_press or is_right_click:
if time.time() - getattr(self, "_modal_open_time", 0.0) >= 0.2:
self.modal = None
return True
return True
# Header clicks (my == 0)
if my == 0:
if mx < 35:
self.mode = "box" if self.mode == "muse" else "muse"
self.set_toast(f"Mode switched to {self.mode.upper()}", "info")
return True
with self.data.lock:
has_blocked = any(s.get("blocked") for s in self.data.node_status.values())
if has_blocked and (w - 60 <= mx <= w - 24):
self.modal = "approvals"
return True
# Box Tabs click (my == 1 and self.mode == "box")
if my == 1 and self.mode == "box":
for idx, (start, end) in enumerate(box_tab_bounds()):
if start <= mx <= end:
self.box_tab = idx
return True
return True
# Prompt Shelf clicks (when in Main View)
if is_main_view and prompt_shelf_h > 0 and (prompt_shelf_y <= my < h - bottom_bar_h):
if is_right_click:
self.modal = "prompts"
self.prompt_sel_idx = 0
self._modal_open_time = time.time()
return True
if my == prompt_shelf_y:
if hasattr(self, "_btn_expand_bounds") and self._btn_expand_bounds[0] <= mx <= self._btn_expand_bounds[1]:
self.modal = "prompts"
self.prompt_sel_idx = 0
return True
if hasattr(self, "_btn_sends_bounds") and self._btn_sends_bounds[0] <= mx <= self._btn_sends_bounds[1]:
self.modal = "history_search"
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.history_search_sel = 0
return True
if hasattr(self, "_btn_prev_bounds") and self._btn_prev_bounds[0] <= mx <= self._btn_prev_bounds[1]:
self.prompt_scroll_idx = max(0, self.prompt_scroll_idx - 1)
return True
if hasattr(self, "_btn_next_bounds") and self._btn_next_bounds[0] <= mx <= self._btn_next_bounds[1]:
self.prompt_scroll_idx = min(max(0, len(self.prompts) - 1), self.prompt_scroll_idx + 1)
return True
if hasattr(self, "_btn_new_bounds") and self._btn_new_bounds[0] <= mx <= self._btn_new_bounds[1]:
self.modal = "prompt_formulate"
self.modal_input_buf = ""
self.modal_input_cursor = 0
return True
else:
for (start_x, end_x, p) in getattr(self, "_prompt_chip_bounds", []):
if start_x <= mx <= end_x:
self.input_buf = p.get("prompt", "")
self.input_cursor = len(self.input_buf)
self.editor_mode = "INSERT"
self.set_toast(f"✔ Filled prompt: '{p.get('title')}'", "success")
return True
return True
# Bottom Bar click (my >= h - bottom_bar_h)
if my >= h - bottom_bar_h:
if is_right_click:
with self.data.lock:
msgs = list(self.data.history_cache.get((self.data.active_node, self.data.active_thread_id), []))
if msgs:
self.context_message = msgs[-1]
self.context_popup_pos = (mx, my)
self.message_context_sel = 0
self.modal = "message_context"
self._modal_open_time = time.time()
return True
self.editor_mode = "INSERT"
prompt_len = len(f" INSERT {self.data.active_node} ({(self.data.active_thread_title or 'sidechat')[:12]}) > ") + 1
if mx >= prompt_len:
self.input_cursor = min(len(self.input_buf), max(0, mx - prompt_len))
else:
self.input_cursor = len(self.input_buf)
return True
# Box Fleet Status click (tab 1): clicking a node row opens chat
if self.mode == "box" and self.box_tab == 1:
if content_y + 3 <= my < content_y + content_h:
row_offset = my - (content_y + 3)
with self.data.lock:
nodes = list(self.data.nodes)
clicked_idx = getattr(self, "table_scroll_idx", 0) + row_offset
if 0 <= clicked_idx < len(nodes):
new_node = nodes[clicked_idx]
if is_right_click:
st_info = self.data.node_status.get(new_node, {})
self.context_agent = {
"node": new_node,
"status": st_info,
"index": clicked_idx,
}
self.context_popup_pos = (mx, my)
self.agent_context_sel = 0
self.modal = "agent_context"
self._modal_open_time = time.time()
self.set_toast(f"Agent Actions: [{new_node.upper()}]", "info")
return True
self.data.set_active_target(new_node)
self.box_tab = 0
self.set_toast(f"Opened Chat with {new_node.upper()}", "success")
return True
# Muse View: Left Sidebar vs Right Transcript
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
if mx < sidebar_w:
self.editor_mode = "NORMAL"
visible_agent_count = getattr(self, "agent_visible_count", min(len(self.data.nodes), 6))
divider_y = content_y + 1 + visible_agent_count
threads_hdr_y = divider_y + 1
# Check if click on Fleet Agents header (my == content_y)
if my == content_y:
self.focus_pane = "fleet"
if is_right_click:
st_info = self.data.node_status.get(self.data.active_node, {})
self.context_agent = {
"node": self.data.active_node,
"status": st_info,
"index": 0,
}
self.context_popup_pos = (mx, my)
self.agent_context_sel = 0
self.modal = "agent_context"
self._modal_open_time = time.time()
self.set_toast(f"Fleet Agent Actions: [{self.data.active_node.upper()}]", "info")
else:
self.modal = "agent_search"
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.agent_search_sel = 0
self.agent_search_scroll = 0
return True
# Check if click on Fleet Node (rows content_y + 1 to divider_y - 1)
if content_y + 1 <= my < divider_y:
self.focus_pane = "fleet"
node_row = my - content_y - 1
target_idx = getattr(self, "agent_scroll_start", 0) + node_row
with self.data.lock:
nodes = list(self.data.nodes)
if 0 <= target_idx < len(nodes):
self.fleet_focus_idx = target_idx
new_node = nodes[target_idx]
is_node_menu_click = (
is_right_click or
is_double_click or
(mx >= sidebar_w - 7) or
(new_node == self.data.active_node and is_press)
)
if is_node_menu_click:
st_info = self.data.node_status.get(new_node, {})
self.context_agent = {
"node": new_node,
"status": st_info,
"index": target_idx,
}
self.context_popup_pos = (mx, my)
self.agent_context_sel = 0
self.modal = "agent_context"
self._modal_open_time = time.time()
self.set_toast(f"Opened Agent Context Menu: [{new_node.upper()}]", "info")
return True
else:
if is_press:
self.data.set_active_target(new_node)
self.set_toast(f"Switched to agent: {new_node.upper()}", "success")
return True
# Check if click on [n:new], [⚡:menu], or [r:sync] in sidebar header
if my == threads_hdr_y:
self.focus_pane = "sidechats"
if is_right_click:
cur_node = self.data.active_node
self.context_agent = {
"node": cur_node,
"status": self.data.node_status.get(cur_node, {}),
"index": 0,
}
self.context_popup_pos = (mx, my)
self.agent_context_sel = 1
self.modal = "agent_context"
self._modal_open_time = time.time()
return True
# Click on [⚡:menu] in header
if sidebar_w - 16 <= mx <= sidebar_w - 7:
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
cur_t = next((th for th in threads if th.get("session_id") == self.data.active_thread_id), (threads[0] if threads else None))
if cur_t:
self.context_chat = {
"node": self.data.active_node,
"thread": cur_t,
"index": self.thread_sel_idx,
}
self.context_popup_pos = (mx, my)
self.chat_context_sel = 0
self.modal = "chat_context"
self._modal_open_time = time.time()
return True
elif mx >= sidebar_w - 21 and mx <= sidebar_w - 17:
self.modal = "new_sidechat"
self.modal_input_buf = ""
self.modal_input_cursor = 0
return True
elif mx >= sidebar_w - 6:
self.data.lazy_fetch_threads(self.data.active_node, force=True)
self.data.trigger_refresh()
self.set_toast(f"Syncing threads for {self.data.active_node.upper()}...", "info")
return True
# Check if click on a Thread in list
list_y = threads_hdr_y + 1
thread_row = my - list_y
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
if 0 <= thread_row < (content_y + content_h - list_y):
self.focus_pane = "sidechats"
scroll_start = getattr(self, "threads_scroll_start", 0)
clicked_idx = scroll_start + thread_row
if 0 <= clicked_idx < len(threads):
self.thread_sel_idx = clicked_idx
t = threads[clicked_idx]
sid = t.get("session_id")
title = t.get("title") or sid[:8]
# Context menu triggered by:
# 1. Right click or modifier-click (Ctrl/Alt/Shift + Left)
# 2. Double-click
# 3. Click on [⚡] button or [sid] on right side (mx >= sidebar_w - 12)
# 4. Click on the ALREADY ACTIVE thread in sidechats pane
is_menu_btn_click = (mx >= sidebar_w - 12)
is_already_active = (sid == self.data.active_thread_id)
if is_right_click or is_double_click or is_menu_btn_click or (is_already_active and is_press):
self.context_chat = {
"node": self.data.active_node,
"thread": t,
"index": clicked_idx,
}
self.context_popup_pos = (mx, my)
self.chat_context_sel = 0
self.modal = "chat_context"
self._modal_open_time = time.time()
self.set_toast(f"Opened Chat Context Menu: '{title}'", "info")
return True
else:
if is_press:
self.data.set_active_target(self.data.active_node, sid, title)
self.auto_scroll = True
self.transcript_scroll_offset = 0
self.set_toast(f"Opened thread '{title}'", "success")
return True
# Right click on sidebar background / empty space fallback
if is_right_click:
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
cur_t = next((th for th in threads if th.get("session_id") == self.data.active_thread_id), None)
if cur_t:
self.context_chat = {
"node": self.data.active_node,
"thread": cur_t,
"index": self.thread_sel_idx,
}
self.context_popup_pos = (mx, my)
self.chat_context_sel = 0
self.modal = "chat_context"
self._modal_open_time = time.time()
self.set_toast("Opened Chat Context Menu", "info")
return True
else:
# Mouse events inside Right Transcript
if is_right_click:
m = self._get_message_at_transcript_line(my)
if not m:
with self.data.lock:
msgs = list(self.data.history_cache.get((self.data.active_node, self.data.active_thread_id), []))
if msgs:
m = msgs[-1]
if m:
self.context_message = m
self.context_popup_pos = (mx, my)
self.message_context_sel = 0
self.modal = "message_context"
self._modal_open_time = time.time()
r_role = m.get("role", "agent").upper()
self.set_toast(f"Message Context Menu ({r_role})", "info")
return True
else:
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
cur_t = next((th for th in threads if th.get("session_id") == self.data.active_thread_id), None)
self.context_chat = {
"node": self.data.active_node,
"thread": cur_t or {"session_id": self.data.active_thread_id, "title": self.data.active_thread_title},
"index": 0,
}
self.context_popup_pos = (mx, my)
self.chat_context_sel = 0
self.modal = "chat_context"
self._modal_open_time = time.time()
self.set_toast("Opened Chat Context Menu", "info")
return True
# Mouse events inside Right Transcript
# 1. Mouse Drag (Motion with Button 1 Pressed)
if is_drag:
if getattr(self, "mouse_dragging", False):
idx = self._get_transcript_line_idx_at(my)
if idx is not None:
self.copy_cursor_line = idx
return True
# 2. Mouse Release (Finish drag-select and copy to clipboard)
if is_release:
if getattr(self, "mouse_dragging", False):
self.mouse_dragging = False
idx = self._get_transcript_line_idx_at(my)
if idx is not None:
self.copy_cursor_line = idx
if self.visual_sel_start is not None:
s = min(self.visual_sel_start, self.copy_cursor_line)
e = max(self.visual_sel_start, self.copy_cursor_line)
text = self._copy_rendered_line_range(s, e)
self.copy_mode = False
self.visual_sel_start = None
if text:
cnt = len(text.splitlines())
self.set_toast(f"📋 Copied selection ({cnt} lines, {len(text)} chars) to clipboard", "success")
return True
# 3. Mouse Press (Click down)
if is_press:
# Check if clicked on [📋 Copy All] button in thread header
if getattr(self, "_btn_copy_all_bounds", None):
btn_y, btn_x_start, btn_x_end = self._btn_copy_all_bounds
if my == btn_y and btn_x_start <= mx <= btn_x_end:
self.copy_transcript_to_clipboard()
return True
# Check if clicked on [⚡ Menu] button in thread header
if getattr(self, "_btn_menu_bounds", None):
btn_y, btn_x_start, btn_x_end = self._btn_menu_bounds
if my == btn_y and btn_x_start <= mx <= btn_x_end:
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
cur_t = next((th for th in threads if th.get("session_id") == self.data.active_thread_id), None)
self.context_chat = {
"node": self.data.active_node,
"thread": cur_t or {"session_id": self.data.active_thread_id, "title": self.data.active_thread_title},
"index": 0,
}
self.context_popup_pos = (mx, my)
self.chat_context_sel = 0
self.modal = "chat_context"
self._modal_open_time = time.time()
return True
# Check if clicked on any visible [↩ Reply] button on message headers
for (msg_y, btn_x_start, btn_x_end, msg_obj) in getattr(self, "_msg_reply_buttons", []):
if my == msg_y and btn_x_start <= mx <= btn_x_end:
self.trigger_reply_to_message(msg_obj)
return True
# Check if clicked on any visible [📋 Copy] button on message headers (clean text)
for (msg_y, btn_x_start, btn_x_end, msg_raw_text) in getattr(self, "_msg_copy_buttons", []):
if my == msg_y and btn_x_start <= mx <= btn_x_end:
self.copy_message_to_clipboard(msg_raw_text, with_context=False)
return True
# Check if clicked on any visible [📑+ Context] button on message headers (with context)
for (msg_y, btn_x_start, btn_x_end, msg_raw_text) in getattr(self, "_msg_copy_context_buttons", []):
if my == msg_y and btn_x_start <= mx <= btn_x_end:
self.copy_message_to_clipboard(msg_raw_text, with_context=True)
return True
# Check if clicked on any visible [⚡ Menu] button on message headers
for (msg_y, btn_x_start, btn_x_end, msg_obj) in getattr(self, "_msg_menu_buttons", []):
if my == msg_y and btn_x_start <= mx <= btn_x_end:
self.context_message = msg_obj
self.context_popup_pos = (mx, my)
self.message_context_sel = 0
self.modal = "message_context"
self._modal_open_time = time.time()
return True
# Check if clicked on the bottom "SCROLLED BACK - Follow" pill
if not self.auto_scroll and my == content_y + content_h - 1:
self.auto_scroll = True
self.transcript_scroll_offset = 0
self.set_toast("Resumed live bottom following", "success")
return True
# Otherwise: start mouse drag selection on text line!
idx = self._get_transcript_line_idx_at(my)
if idx is not None:
self.mouse_dragging = True
self.copy_mode = True
self.visual_sel_start = idx
self.copy_cursor_line = idx
self.editor_mode = "NORMAL"
self.focus_pane = "transcript"
return True
self.editor_mode = "NORMAL"
self.focus_pane = "transcript"
return True
# Box View clicks (Tabs 1, 2, 3, 4, 6)
elif self.mode == "box":
self.editor_mode = "NORMAL"
row_idx = my - (content_y + 3)
if self.box_tab == 1:
# Tab 1: Fleet Status
if 0 <= row_idx < len(self.data.nodes):
self.fleet_sel_idx = row_idx
sel_node = self.data.nodes[row_idx]
self.data.set_active_target(sel_node)
self.box_tab = 0
self.focus_pane = "transcript"
self.set_toast(f"✔ Refocused Agent Chat on {sel_node.upper()}", "success")
return True
elif self.box_tab == 2:
# Tab 2: Approvals
# Check bottom hint line: Clear All
if my == content_y + content_h - 1:
if mx >= 40:
threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start()
return True
with self.data.lock:
apps = list(self.data.approvals_cache)
if 0 <= row_idx < len(apps):
self.approvals_sel_idx = row_idx
app = apps[row_idx]
node = app.get("node", self.data.active_node)
st = app.get("status") or "PENDING"
act_x = min(w - 38, max(64, 48))
if mx >= act_x:
if st == "PENDING":
if mx < act_x + 10:
threading.Thread(target=self._async_resolve_approval, args=("allow", node), daemon=True).start()
elif mx < act_x + 21:
threading.Thread(target=self._async_resolve_approval, args=("always", node), daemon=True).start()
else:
threading.Thread(target=self._async_resolve_approval, args=("deny", node), daemon=True).start()
elif st == "INPUT_WAIT":
if mx < act_x + 12:
threading.Thread(target=self._async_resolve_approval, args=("reply", node, "Proceed with standard execution."), daemon=True).start()
elif mx < act_x + 24:
threading.Thread(target=self._async_resolve_approval, args=("dismiss", node), daemon=True).start()
else:
self.data.set_active_target(node)
self.box_tab = 0
self.set_toast(f"Switched to Chat with {node.upper()}", "success")
else:
threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start()
else:
self.set_toast(f"Selected approval [{node.upper()}]. Use keys: [1/2/3/r/x/c]", "info")
return True
elif self.box_tab == 3:
# Tab 3: Jobs Scheduler
with self.data.lock:
jobs = list(self.data.jobs_cache)
scroll_start = getattr(self, "jobs_scroll_start", 0)
clicked_idx = scroll_start + row_idx
if 0 <= clicked_idx < len(jobs):
self.jobs_sel_idx = clicked_idx
job = jobs[clicked_idx]
job_name = job.get("name")
agent = job.get("agent", self.data.active_node)
if mx >= w - 12:
# [Run] button
threading.Thread(target=self._async_trigger_job, args=(job_name,), daemon=True).start()
else:
# Clicked row, name, or [▶ Chat] -> open conversation in Agent Chat 1!
self._open_job_conversation(job_name, agent)
return True
elif self.box_tab == 4:
# Tab 4: Tmux Sessions
with self.data.lock:
sessions = list(self.data.tmux_cache)
if 0 <= row_idx < len(sessions):
self.tmux_sel_idx = row_idx
sess_line = sessions[row_idx]
sess_name = sess_line.split(":")[0].strip()
if mx >= w - 12:
# [Kill]
threading.Thread(target=self._async_kill_tmux, args=(sess_name,), daemon=True).start()
elif mx >= w - 22:
# [Attach]
self.set_toast(f"Attach: tmux -S /tmp/tmux-muse.sock attach -t {sess_name}", "info")
else:
self.set_toast(f"Selected session '{sess_name}'. Click [Kill] or [Attach].", "info")
return True
elif self.box_tab == 6:
# Tab 6: SSH / Boxes (rows start one line lower: jump-status line)
rows = self.get_ssh_rows()
scroll_start = getattr(self, "ssh_scroll_idx", 0)
clicked_idx = scroll_start + row_idx - 1
if 0 <= clicked_idx < len(rows):
self.ssh_sel_idx = clicked_idx
if mx >= w - 8:
# [SSH] pop-out
self._ssh_popout_selected()
else:
acct = rows[clicked_idx]
self.set_toast(f"Selected [{acct.upper()}]. Click [SSH] or press Enter to pop out.", "info")
return True
return True
def _handle_insert_key(self, ch: int) -> bool:
# Enter -> Submit Message / Slash command (or collect multiline paste burst)
if ch in (curses.KEY_ENTER, 10, 13):
# Check if this Enter is part of a rapid paste stream (unbracketed paste)
self.stdscr.nodelay(True)
try:
peek_c = self.stdscr.getch()
finally:
self.stdscr.timeout(50)
if peek_c != -1 and isinstance(peek_c, int):
# Part of rapid unbracketed paste burst! Keep newline and consume rest of burst
burst_bytes = bytearray([10])
if 0 <= peek_c <= 255:
burst_bytes.append(peek_c)
self.stdscr.nodelay(True)
try:
idle = 0
while True:
c = self.stdscr.getch()
if c == -1:
idle += 1
if idle > 4:
break
time.sleep(0.005)
continue
idle = 0
if 0 <= c <= 255:
burst_bytes.append(c)
finally:
self.stdscr.timeout(50)
burst_text = burst_bytes.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n")
self.input_buf = self.input_buf[:self.input_cursor] + burst_text + self.input_buf[self.input_cursor:]
self.input_cursor += len(burst_text)
nl_cnt = self.input_buf.count("\n")
self.set_toast(f"📋 Pasted chunk ({nl_cnt + 1} lines). Press [Enter] to send.", "success")
return True
line = self.input_buf.strip()
if not line:
return True
# Soft guardrail: require double-tap Enter during active rate limit cooldown
is_cmd = line.startswith("/") and not line.startswith("//")
if not is_cmd:
cur_node = self.data.active_node
cd = self.data.get_node_cooldown_remaining(cur_node)
if cd > 0:
now = time.time()
last_force = getattr(self, "_last_force_send_time", 0.0)
if (now - last_force) <= 2.5:
self.data.clear_node_rate_limit(cur_node)
self.set_toast(f"⚡ Bypassed cooldown. Force-sending to {cur_node.upper()}...", "warn")
self._last_force_send_time = 0.0
else:
self._last_force_send_time = now
self.set_toast(f"⏳ {cur_node.upper()} in cooldown ({cd}s left). Press Enter again within 2.5s to force-send.", "warn")
return True
self.input_history.append(line)
self.history_idx = len(self.input_history)
self.input_buf = ""
self.input_cursor = 0
self._execute_input_line(line)
if getattr(self, "should_exit", False):
return False
return True
# Backspace
elif ch in (curses.KEY_BACKSPACE, 127, 8):
if self.input_cursor > 0:
self.input_buf = self.input_buf[:self.input_cursor - 1] + self.input_buf[self.input_cursor:]
self.input_cursor -= 1
return True
# Delete
elif ch == curses.KEY_DC:
if self.input_cursor < len(self.input_buf):
self.input_buf = self.input_buf[:self.input_cursor] + self.input_buf[self.input_cursor + 1:]
return True
# Left / Right Cursor
elif ch == curses.KEY_LEFT:
if self.input_cursor > 0:
self.input_cursor -= 1
return True
elif ch == curses.KEY_RIGHT:
if self.input_cursor < len(self.input_buf):
self.input_cursor += 1
return True
# Home / End
elif ch in (curses.KEY_HOME, 1): # Home or Ctrl+A
self.input_cursor = 0
return True
elif ch in (curses.KEY_END, 5): # End or Ctrl+E
self.input_cursor = len(self.input_buf)
return True
# Word Deletion: Ctrl+W
elif ch == 23:
parts = self.input_buf[:self.input_cursor].rstrip().rsplit(" ", 1)
keep = parts[0] + " " if len(parts) > 1 else ""
self.input_buf = keep + self.input_buf[self.input_cursor:]
self.input_cursor = len(keep)
return True
# Line clear: Ctrl+U
elif ch == 21:
self.input_buf = ""
self.input_cursor = 0
return True
# History Navigation / Multiline Navigation: Up / Down
elif ch == curses.KEY_UP:
if "\n" in self.input_buf:
curr = self.input_buf[:self.input_cursor]
last_nl = curr.rfind("\n")
if last_nl != -1:
prev_line_start = curr[:last_nl].rfind("\n")
prev_line_start = 0 if prev_line_start == -1 else (prev_line_start + 1)
col = len(curr) - (last_nl + 1)
self.input_cursor = min(last_nl, prev_line_start + col)
return True
if self.input_history and self.history_idx > 0:
self.history_idx -= 1
self.input_buf = self.input_history[self.history_idx]
self.input_cursor = len(self.input_buf)
return True
elif ch == curses.KEY_DOWN:
if "\n" in self.input_buf:
rest = self.input_buf[self.input_cursor:]
next_nl = rest.find("\n")
if next_nl != -1:
next_line_start = self.input_cursor + next_nl + 1
following_nl = self.input_buf[next_line_start:].find("\n")
next_line_end = len(self.input_buf) if following_nl == -1 else (next_line_start + following_nl)
curr = self.input_buf[:self.input_cursor]
curr_col = len(curr) - (curr.rfind("\n") + 1)
self.input_cursor = min(next_line_end, next_line_start + curr_col)
return True
if self.input_history and self.history_idx < len(self.input_history) - 1:
self.history_idx += 1
self.input_buf = self.input_history[self.history_idx]
self.input_cursor = len(self.input_buf)
elif self.input_history and self.history_idx == len(self.input_history) - 1:
self.history_idx = len(self.input_history)
self.input_buf = ""
self.input_cursor = 0
return True
# Tab in insert mode: return to normal mode (modal lock)
elif ch == ord('\t'):
self.editor_mode = "NORMAL"
self.set_toast("NORMAL mode (Nav locked). Press 'i' to type.", "info")
return True
# Printable Characters
elif 32 <= ch <= 126:
char = chr(ch)
self.input_buf = self.input_buf[:self.input_cursor] + char + self.input_buf[self.input_cursor:]
self.input_cursor += 1
return True
# Multi-byte UTF-8 or Extended Unicode
elif 128 <= ch <= 255:
if not hasattr(self, "_utf8_buf"):
self._utf8_buf = bytearray()
self._utf8_buf.append(ch)
try:
char = self._utf8_buf.decode("utf-8")
self._utf8_buf.clear()
self.input_buf = self.input_buf[:self.input_cursor] + char + self.input_buf[self.input_cursor:]
self.input_cursor += len(char)
except UnicodeDecodeError:
if len(self._utf8_buf) >= 4:
self._utf8_buf.clear()
return True
elif ch >= 256:
try:
char = chr(ch)
if char.isprintable():
self.input_buf = self.input_buf[:self.input_cursor] + char + self.input_buf[self.input_cursor:]
self.input_cursor += len(char)
return True
except Exception:
pass
return True
return True
def _execute_input_line(self, line: str):
"""Execute typed line either as slash command or direct agent chat message."""
if line.startswith("/"):
parts = line[1:].split(" ", 1)
cmd = parts[0].lower()
arg = parts[1].strip() if len(parts) > 1 else ""
if cmd in ("exit", "quit", "q"):
self.should_exit = True
return
elif cmd in ("help", "h"):
self.modal = "help"
elif cmd in ("refresh", "sync", "r"):
self.data.trigger_refresh()
self.set_toast("Forced refresh across fleet.", "info")
elif cmd in ("clear", "cls"):
self.transcript_scroll_offset = 0
self.set_toast("Transcript view reset.", "info")
elif cmd == "mode":
self.mode = "box" if self.mode == "muse" else "muse"
self.set_toast(f"Mode switched to {self.mode.upper()}", "info")
elif cmd in ("node", "agent", "account", "a", "switch"):
if not arg:
self.modal = "agent_search"
self.modal_input_buf = ""
self.modal_input_cursor = 0
self.agent_search_sel = 0
self.agent_search_scroll = 0
else:
matched = None
for n in self.data.nodes:
if n.lower() == arg.lower():
matched = n
break
if not matched:
for n in self.data.nodes:
if n.lower().startswith(arg.lower()):
matched = n
break
if matched:
self.data.set_active_target(matched)
self.auto_scroll = True
self.transcript_scroll_offset = 0
self.data._fetch_threads(matched)
self.set_toast(f"Switched to node {matched.upper()}", "success")
else:
self.set_toast(f"Unknown node '{arg}'. Valid: {', '.join(self.data.nodes)}", "error")
elif cmd == "new":
title = arg or "operator-session"
self.set_toast(f"Creating sidechat '{title}'...", "info")
threading.Thread(target=self._async_create_sidechat, args=(title,), daemon=True).start()
elif cmd in ("wo", "workorder"):
if not arg:
self.modal = "work_order"
self.modal_input_buf = ""
self.modal_input_cursor = 0
else:
wo_parts = arg.split(" ", 1)
title = wo_parts[0]
body = wo_parts[1] if len(wo_parts) > 1 else title
threading.Thread(target=self._async_issue_wo, args=(title, body), daemon=True).start()
elif cmd in ("blocked", "approvals", "approval", "app"):
self.modal = "approvals"
self.approvals_sel_idx = 0
with self.data.lock:
apps = [it for it in self.data.approvals_cache if it.get("status") in ("PENDING", "INPUT_WAIT", "KEY_APPROVAL")]
if apps:
self.set_toast(f"⚠ {len(apps)} agent(s) blocked/waiting. Select with j/k, [1] Allow, [2] Always, [3] Deny", "warn_banner")
else:
self.set_toast("✔ Fleet is clear: 0 agents blocked.", "success")
threading.Thread(target=self.data._fetch_approvals, daemon=True).start()
return
elif cmd in ("approve", "allow"):
threading.Thread(target=self._async_resolve_approval, args=("allow",), daemon=True).start()
elif cmd == "always":
threading.Thread(target=self._async_resolve_approval, args=("always",), daemon=True).start()
elif cmd == "deny":
threading.Thread(target=self._async_resolve_approval, args=("deny",), daemon=True).start()
elif cmd in ("auto", "autopilot"):
threading.Thread(target=self._async_resolve_approval, args=("auto",), daemon=True).start()
elif cmd in ("pin", "unpin"):
tid = self.data.active_thread_id
node = self.data.active_node
if tid:
new_pin = self.data.toggle_pin_thread(node, tid)
state = "Pinned" if new_pin else "Unpinned"
else:
self.set_toast("No active chat to pin/unpin", "error")
elif cmd in ("prompt", "prompts", "p", "skill", "skills"):
if not arg:
self.modal = "prompts"
self.prompt_sel_idx = 0
elif arg.startswith("save "):
title = arg[5:].strip()
body = self.input_buf.strip() or title
p = self.prompt_manager.add_prompt(title, body, tags=["custom", "saved"], pinned=True)
self.prompts = self.prompt_manager.get_sorted_prompts()
self.set_toast(f"✔ Stored prompt skill: '{p['title']}'", "success")
elif arg.startswith("add "):
content = arg[4:].strip()
parts = content.split("|", 1)
title = parts[0].strip()
body = parts[1].strip() if len(parts) > 1 else title
p = self.prompt_manager.add_prompt(title, body, tags=["custom"], pinned=True)
self.prompts = self.prompt_manager.get_sorted_prompts()
self.set_toast(f"✔ Added prompt skill: '{p['title']}'", "success")
elif arg.startswith("del ") or arg.startswith("delete "):
pid = arg.split(" ", 1)[1].strip()
if self.prompt_manager.delete_prompt(pid):
self.prompts = self.prompt_manager.get_sorted_prompts()
self.set_toast(f"Deleted prompt '{pid}'", "warn_banner")
else:
self.set_toast(f"Prompt '{pid}' not found", "error")
else:
self.modal = "prompts"
self.prompt_sel_idx = 0
elif cmd in ("sends", "history", "search"):
self.modal = "history_search"
self.modal_input_buf = arg
self.modal_input_cursor = len(arg)
self.history_search_sel = 0
self.history_search_scroll = 0
elif cmd in ("copy", "yank", "cp"):
if arg.lower() in ("last", "msg", "message"):
self.copy_message_to_clipboard()
else:
self.copy_transcript_to_clipboard()
elif cmd in ("clean", "boxed", "box", "view", "style", "compact"):
if cmd == "clean" or arg.lower() in ("clean", "compact"):
if self.transcript_style != "clean":
self.set_transcript_style("clean")
else:
self.set_toast("Transcript style is already Clean (line-by-line).", "info")
elif cmd in ("boxed", "box") or arg.lower() in ("boxed", "box"):
if self.transcript_style != "boxed":
self.set_transcript_style("boxed")
else:
self.set_toast("Transcript style is already Boxed (classic frames).", "info")
else:
self.toggle_transcript_style()
elif cmd in ("context", "menu", "actions"):
if arg.lower() in ("agent", "fleet", "node"):
st_info = self.data.node_status.get(self.data.active_node, {})
self.context_agent = {
"node": self.data.active_node,
"status": st_info,
"index": 0,
}
self.agent_context_sel = 0
self.modal = "agent_context"
elif arg.lower() in ("msg", "message"):
with self.data.lock:
msgs = list(self.data.history_cache.get((self.data.active_node, self.data.active_thread_id), []))
if msgs:
self.context_message = msgs[-1]
self.message_context_sel = 0
self.modal = "message_context"
else:
self.set_toast("No message to show context menu for", "warn_banner")
else:
with self.data.lock:
threads = list(self.data.threads_cache.get(self.data.active_node, []))
t = None
if threads:
for th in threads:
if th.get("session_id") == self.data.active_thread_id:
t = th
break
if not t:
t = threads[0]
if t:
self.context_chat = {
"node": self.data.active_node,
"thread": t,
"index": 0,
}
self.chat_context_sel = 0
self.modal = "chat_context"
else:
self.set_toast("No active chat for context menu", "warn_banner")
else:
if "\n" in line or "/" in cmd or line.startswith("//"):
pass # Pass through to deliver as chat message
else:
self.set_toast(f"Unknown slash command: /{cmd}. Type /help for cheat-sheet.", "error")
return
if not ("\n" in line or "/" in cmd or line.startswith("//")):
return
# Direct standard message to active thread
with self.data.lock:
cur_node = self.data.active_node
cur_thread = self.data.active_thread_id
cur_title = self.data.active_thread_title
node_threads = self.data.threads_cache.get(cur_node, [])
cur_valid = any(t.get("session_id") == cur_thread for t in node_threads)
if not cur_thread or not cur_valid:
cur_thread, cur_title = self.data.select_main_chat(cur_node)
# Quote target message if replying
if getattr(self, "reply_target", None):
rep = self.reply_target
r_role = rep.get("role", "agent").upper()
r_ref = f"seq:{rep.get('seq')}" if rep.get("seq") else f"id:{rep.get('msg_id', '')[:8]}"
r_text = rep.get("text", "").strip()
if len(r_text) > 280:
q_text = r_text[:280].rstrip() + "..."
else:
q_text = r_text
quoted_lines = "\n".join(f"> {l}" for l in q_text.split("\n"))
line = f"> Replying to {r_role} ({r_ref}):\n{quoted_lines}\n\n{line}"
self.reply_target = None
if not cur_title:
cur_title = "Main Chat"
self.auto_scroll = True
self.transcript_scroll_offset = 0
self.data.record_interaction(cur_node)
self.data.record_activity()
self.data.add_optimistic_message(cur_node, cur_thread, line)
self.set_toast(f"Sending message to {cur_node.upper()} ({cur_title})...", "info")
threading.Thread(target=self._async_send_message, args=(cur_node, cur_thread, line), daemon=True).start()
# Async action helpers
def _async_send_message(self, node: str, thread_id: str, text: str):
ok, res = self.data.send_chat_message(node, thread_id, text)
if ok:
self.set_toast(f"✔ Delivered to {node.upper()}.", "success")
threading.Thread(target=self._post_send_sync, args=(node, thread_id), daemon=True).start()
else:
self.set_toast(f"Failed to deliver: {res[:40]}", "error")
def _post_send_sync(self, node: str, thread_id: str):
# Progressively poll while assistant generates response
delays = [0.8, 1.5, 2.5, 3.5, 5.0, 7.0]
with self.data.lock:
initial_count = len(self.data.history_cache.get((node, thread_id), []))
for d in delays:
time.sleep(d)
self.data._fetch_history(node, thread_id)
with self.data.lock:
current_msgs = self.data.history_cache.get((node, thread_id), [])
if len(current_msgs) > initial_count:
if any(m.get("role") == "assistant" for m in current_msgs[initial_count:]):
self.set_toast(f"✔ Reply received from {node.upper()}.", "success")
break
def _async_create_sidechat(self, title: str):
node = self.data.active_node
ok, sid, msg = self.data.create_sidechat(node, title)
if ok:
self.set_toast(f"✔ Created sidechat '{title}' [{sid[:6]}]", "success")
else:
self.set_toast(f"Failed to create sidechat: {msg[:40]}", "error")
def _async_issue_wo(self, title: str, body: str):
node = self.data.active_node
self.data.record_interaction(node)
tid = self.data.active_thread_id
if not tid:
self.set_toast("Cannot issue Work Order: No active sidechat.", "error")
return
self.set_toast(f"Issuing Work Order '{title}'...", "info")
ok, res = self.data.issue_work_order(node, tid, title, body)
if ok:
self.set_toast(f"✔ Work Order '{title}' delivered to {node.upper()}.", "success")
else:
self.set_toast(f"Work Order delivery failed: {res[:40]}", "error")
def _async_resolve_approval(self, action: str, node: str = None, text: str = ""):
node = node or self.data.active_node
if action == "clear_all":
ok, msg = self.data.clear_all_waits()
if ok:
self.set_toast("✔ All pending task waits cleared across fleet.", "success")
else:
self.set_toast(f"Clear failed: {msg[:40]}", "error")
return
elif action == "dismiss":
ok, msg = self.data.dismiss_task(node)
if ok:
self.set_toast(f"✔ Dismissed active task wait on {node.upper()}.", "success")
else:
self.set_toast(f"Dismiss failed: {msg[:40]}", "error")
return
elif action == "reply":
msg_text = text or "Proceed with standard execution."
self.data.record_interaction(node)
ok, msg = self.data.reply_task(node, msg_text)
if ok:
self.set_toast(f"✔ Responded to task wait on {node.upper()}.", "success")
else:
self.set_toast(f"Reply failed: {msg[:40]}", "error")
return
ok, msg = self.data.resolve_approval(node, action)
if ok:
self.set_toast(f"✔ Approval '{action}' applied to {node.upper()}.", "success")
else:
self.set_toast(f"Approval failed: {msg[:40]}", "error")
def _open_job_conversation(self, job_name: str, agent: str):
"""Link to conversation thread for the selected job in Agent Chat 1 for reading / entry / scrollback."""
target_agent = agent if agent in self.data.nodes else self.data.active_node
target_thread_id = None
target_title = None
# 1. Check job-sidechats.json
sidechats_path = REPO_ROOT / "job-sidechats.json"
if sidechats_path.exists():
try:
with open(sidechats_path, "r", encoding="utf-8") as f:
sc_data = json.load(f)
keys_to_try = [
job_name,
f"{job_name}@{target_agent}",
f"{job_name}@{agent}",
f"{agent} tasks",
f"{agent}-tasks",
]
for k in keys_to_try:
if k in sc_data:
match = sc_data[k]
target_thread_id = match.get("thread_uuid") or match.get("session_id")
target_agent = match.get("agent", target_agent)
target_title = match.get("title") or match.get("description") or k
break
if not target_thread_id:
for k, match in sc_data.items():
if job_name in k or k in job_name:
target_thread_id = match.get("thread_uuid") or match.get("session_id")
target_agent = match.get("agent", target_agent)
target_title = match.get("title") or match.get("description") or k
break
except Exception:
pass
# 2. Check threads cache for the agent
with self.data.lock:
threads = list(self.data.threads_cache.get(target_agent, []))
if not target_thread_id and threads:
for t in threads:
t_title = (t.get("title") or "").lower()
if job_name.lower() in t_title or t_title in job_name.lower():
target_thread_id = t.get("session_id")
target_title = t.get("title")
break
if not target_title:
target_title = f"job:{job_name}"
# Set active target
self.data.set_active_target(target_agent, target_thread_id, target_title)
# Trigger async fetch for threads & history
threading.Thread(target=self.data._fetch_threads, args=(target_agent,), daemon=True).start()
if target_thread_id:
threading.Thread(target=self.data._fetch_history, args=(target_agent, target_thread_id), daemon=True).start()
# Switch to Agent Chat (Tab 0)
self.box_tab = 0
self.focus_pane = "transcript"
self.auto_scroll = False
self.transcript_scroll_offset = 0
if target_thread_id:
self.set_toast(f"✔ Linked to conversation for '{job_name}' [{target_thread_id[:8]}] on {target_agent.upper()}", "success")
else:
self.set_toast(f"✔ Switched to {target_agent.upper()} for job '{job_name}'", "info")
def _async_trigger_job(self, job_name: str):
self.set_toast(f"Triggering job '{job_name}'...", "info")
ok, msg = self.data.trigger_job(job_name)
if ok:
self.set_toast(f"✔ Job '{job_name}' triggered successfully.", "success")
else:
self.set_toast(f"Job trigger failed: {msg[:40]}", "error")
def _async_kill_tmux(self, sess_name: str):
self.set_toast(f"Terminating tmux session '{sess_name}'...", "info")
ok, msg = self.data.kill_tmux_session(sess_name)
if ok:
self.set_toast(f"✔ Session '{sess_name}' killed.", "success")
else:
self.set_toast(f"Kill failed: {msg[:40]}", "error")
def _ssh_popout_selected(self):
"""Open the selected container SSH session in a new tmux window."""
rows = self.get_ssh_rows()
if not rows or not (0 <= self.ssh_sel_idx < len(rows)):
self.set_toast("No SSH row selected.", "warn")
return
acct = rows[self.ssh_sel_idx]
if acct not in SSH_TUNNEL_PORTS:
self.set_toast(f"No tunnel registered for '{acct}'.", "warn")
return
if not os.environ.get("TMUX"):
# Refuse to spawn into an invisible server: new-window would
# create a detached server the operator cannot see.
try:
probe = subprocess.run(["tmux", "ls"], capture_output=True, text=True, timeout=3)
server_up = probe.returncode == 0
except Exception:
server_up = False
if not server_up:
copy_to_clipboard(build_ssh_dial_string(acct))
self.set_toast("Not inside tmux and no server running; dial copied to clipboard.", "warn")
return
shell_cmd = build_ssh_popout_shell(acct)
pop_cmd = build_tmux_popout_command(f"ssh-{acct}", shell_cmd)
try:
curses.def_prog_mode()
curses.endwin()
try:
res = subprocess.run(pop_cmd, capture_output=True, text=True, timeout=5)
finally:
try:
curses.reset_prog_mode()
self.stdscr.refresh()
except Exception:
pass
self.need_full_redraw = True
if res.returncode == 0:
self.set_toast(f"Opened SSH to {acct} in tmux window ssh-{acct}.", "success")
else:
copy_to_clipboard(build_ssh_dial_string(acct))
err = (res.stderr or "").strip().splitlines()
hint = err[-1][:60] if err else f"exit {res.returncode}"
self.set_toast(f"tmux pop-out failed ({hint}); dial copied.", "error")
except Exception as e:
copy_to_clipboard(build_ssh_dial_string(acct))
self.set_toast(f"Pop-out failed ({e}); dial copied to clipboard.", "error")
def _ssh_copy_dial_selected(self):
"""Copy the selected container's dial command to the clipboard."""
rows = self.get_ssh_rows()
if not rows or not (0 <= self.ssh_sel_idx < len(rows)):
self.set_toast("No SSH row selected.", "warn")
return
acct = rows[self.ssh_sel_idx]
if acct not in SSH_TUNNEL_PORTS:
self.set_toast(f"No tunnel registered for '{acct}'.", "warn")
return
dial = build_ssh_dial_string(acct)
if copy_to_clipboard(dial):
self.set_toast(f"Copied dial for {acct}: {dial[:80]}", "success")
else:
self.set_toast(f"Dial for {acct}: {dial}", "info")
def _async_ssh_uptime(self, account: str):
ok, out_text = self.data.probe_container_uptime(account)
if ok:
first = (out_text or "").strip().splitlines()
self.set_toast(f"{account} uptime: {first[0][:90]}" if first else f"{account}: uptime probe empty.", "success" if first else "warn")
else:
self.set_toast(f"{account} uptime failed: {out_text[:90]}", "error")
def _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)")
parser.add_argument("--tab", choices=["chat", "fleet", "approvals", "jobs", "tmux", "logs", "ssh"], default=None, help="Initial Box tab (box mode only)")
args = parser.parse_args()
try:
curses.wrapper(lambda stdscr: MuseTUI(
stdscr,
initial_mode=args.mode,
initial_node=args.account,
initial_thread=args.thread,
initial_tab=args.tab
).run())
except KeyboardInterrupt:
pass
except Exception as e:
crash_log = REPO_ROOT / "logs" / "tui-crash.log"
print(f"\n[!] Muse TUI error: {e}", file=sys.stderr)
print(f" Traceback logged to: {crash_log}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()