2026-10-06 07:55:27 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
muse-tui.py — High-Performance Terminal User Interface for Muse Agents & NetVM Fleet Command.
|
|
|
|
|
|
|
|
|
|
Dual-mode interface:
|
|
|
|
|
1. Muse Mode (Default for 'muse tui'):
|
|
|
|
|
- Multi-agent conversational terminal (muse, pip, 646, opm, def, dev)
|
|
|
|
|
- Real-time sidechat / thread navigation and creation
|
|
|
|
|
- Live streaming transcript with syntax highlighting (User, Agent, Tool calls, Work Orders)
|
|
|
|
|
- In-terminal Work Order generator (/wo) with cryptographic signing
|
|
|
|
|
- Instant approval detection and 1-key resolution drawer (Allow Once, Always Allow, Deny)
|
|
|
|
|
|
|
|
|
|
2. Box Mode (Default for 'box tui' or toggled via F5/Tab):
|
|
|
|
|
- Fleet Vitality & CDP Latency monitor
|
|
|
|
|
- Fleet-wide Approvals Center
|
|
|
|
|
- Job Scheduler & Dispatch trigger
|
|
|
|
|
- Background Tmux sessions & Swarm worker monitor
|
|
|
|
|
- Live Event & DM log tailer
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
import os
|
|
|
|
|
import json
|
|
|
|
|
import time
|
|
|
|
|
import curses
|
|
|
|
|
import threading
|
|
|
|
|
import subprocess
|
|
|
|
|
import hashlib
|
|
|
|
|
import select
|
|
|
|
|
import signal
|
|
|
|
|
import textwrap
|
|
|
|
|
import urllib.request
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
# Paths
|
|
|
|
|
REPO_ROOT = Path("/home/super/Projects/NetVM")
|
|
|
|
|
BIN_DIR = REPO_ROOT / "bin"
|
|
|
|
|
if str(BIN_DIR) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(BIN_DIR))
|
|
|
|
|
|
|
|
|
|
# Dynamic node registry loader
|
|
|
|
|
VALID_NODES = ["muse", "pip", "646", "opm", "def", "dev"]
|
|
|
|
|
try:
|
|
|
|
|
import importlib.util
|
|
|
|
|
spec = importlib.util.spec_from_file_location("netvm_registry", str(BIN_DIR / "netvm-registry.py"))
|
|
|
|
|
netvm_registry = importlib.util.module_from_spec(spec)
|
|
|
|
|
spec.loader.exec_module(netvm_registry)
|
|
|
|
|
REGISTRY_NODES = list(netvm_registry.load().keys())
|
|
|
|
|
if REGISTRY_NODES:
|
|
|
|
|
VALID_NODES = REGISTRY_NODES
|
|
|
|
|
except Exception:
|
|
|
|
|
netvm_registry = None
|
|
|
|
|
|
|
|
|
|
# Try importing approvals engine
|
|
|
|
|
try:
|
|
|
|
|
import approvals as approvals_engine
|
|
|
|
|
except ImportError:
|
|
|
|
|
approvals_engine = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def copy_to_clipboard(text: str) -> bool:
|
|
|
|
|
"""Copy text to system clipboard using desktop utilities and ANSI OSC 52."""
|
|
|
|
|
if not text:
|
|
|
|
|
return False
|
|
|
|
|
success = False
|
|
|
|
|
|
|
|
|
|
# 1. Desktop clipboard utilities
|
|
|
|
|
for cmd in (
|
|
|
|
|
["wl-copy"],
|
|
|
|
|
["xclip", "-selection", "clipboard"],
|
|
|
|
|
["xsel", "--clipboard", "--input"],
|
|
|
|
|
["pbcopy"],
|
|
|
|
|
):
|
|
|
|
|
try:
|
|
|
|
|
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
|
|
|
p.communicate(input=text.encode("utf-8"), timeout=1.0)
|
|
|
|
|
if p.returncode == 0:
|
|
|
|
|
success = True
|
|
|
|
|
break
|
|
|
|
|
except (FileNotFoundError, Exception):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# 2. OSC 52 ANSI escape sequence (works over SSH, tmux, modern terminals)
|
|
|
|
|
try:
|
|
|
|
|
import base64
|
|
|
|
|
b64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
|
|
|
if os.environ.get("TMUX"):
|
|
|
|
|
osc52 = f"\033Ptmux;\033\033]52;c;{b64}\007\033\\"
|
|
|
|
|
else:
|
|
|
|
|
osc52 = f"\033]52;c;{b64}\007"
|
|
|
|
|
sys.stdout.write(osc52)
|
|
|
|
|
sys.stdout.flush()
|
|
|
|
|
success = True
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
return success
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def strip_message_metadata(text: str) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Strip extra reply tags, header info, and unquote quoted reply lines.
|
|
|
|
|
Leaves the substantive text clean line-by-line.
|
|
|
|
|
"""
|
|
|
|
|
if not text:
|
|
|
|
|
return ""
|
|
|
|
|
lines = text.split("\n")
|
|
|
|
|
# Check if message starts with a reply quote block: '> Replying to ...:'
|
|
|
|
|
if lines and re.match(r"^>\s*Replying to\s+.*:\s*$", lines[0].strip(), re.IGNORECASE):
|
|
|
|
|
# Discard the header line
|
|
|
|
|
lines = lines[1:]
|
|
|
|
|
# Strip leading quote mark '> ' from quoted lines until an empty line separates them
|
|
|
|
|
cleaned_lines = []
|
|
|
|
|
for line in lines:
|
|
|
|
|
if line.startswith("> "):
|
|
|
|
|
cleaned_lines.append(line[2:])
|
|
|
|
|
elif line.startswith(">"):
|
|
|
|
|
cleaned_lines.append(line[1:])
|
|
|
|
|
else:
|
|
|
|
|
cleaned_lines.append(line)
|
|
|
|
|
text = "\n".join(cleaned_lines)
|
|
|
|
|
return text.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_command_isolated(cmd: list, timeout: float = 12.0) -> tuple[int, str, str]:
|
|
|
|
|
"""Execute command in a dedicated process group to prevent zombie subprocesses on timeout."""
|
|
|
|
|
try:
|
|
|
|
|
p = subprocess.Popen(
|
|
|
|
|
cmd,
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.PIPE,
|
|
|
|
|
text=True,
|
|
|
|
|
start_new_session=True,
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
stdout, stderr = p.communicate(timeout=timeout)
|
|
|
|
|
return p.returncode, stdout, stderr
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
try:
|
|
|
|
|
os.killpg(p.pid, signal.SIGKILL)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
p.communicate(timeout=0.5)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return -1, "", "Process timed out and was terminated"
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return -1, "", str(e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_recency(ts: float) -> str:
|
|
|
|
|
"""Format an epoch timestamp into a compact human recency string (e.g. 5m ago, 2h ago)."""
|
|
|
|
|
if not ts or ts <= 0:
|
|
|
|
|
return "never"
|
|
|
|
|
diff = time.time() - ts
|
|
|
|
|
if diff < 0:
|
|
|
|
|
return "now"
|
|
|
|
|
if diff < 60:
|
|
|
|
|
return f"{int(diff)}s ago"
|
|
|
|
|
elif diff < 3600:
|
|
|
|
|
return f"{int(diff // 60)}m ago"
|
|
|
|
|
elif diff < 86400:
|
|
|
|
|
return f"{int(diff // 3600)}h ago"
|
|
|
|
|
else:
|
|
|
|
|
return f"{int(diff // 86400)}d ago"
|
|
|
|
|
return "never"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Prompt & Skill Library Subsystem
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
DEFAULT_PROMPTS = [
|
|
|
|
|
{
|
|
|
|
|
"id": "audit",
|
|
|
|
|
"title": "Fleet Audit",
|
|
|
|
|
"prompt": "Check all fleet nodes for blocked states and summarize current task loops.",
|
|
|
|
|
"tags": ["fleet", "health"],
|
|
|
|
|
"pinned": True,
|
|
|
|
|
"created_at": 1718000000.0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "wo",
|
|
|
|
|
"title": "Work Order",
|
|
|
|
|
"prompt": "Review recent logs and formulate a formal work order for the active issue.",
|
|
|
|
|
"tags": ["task", "ops"],
|
|
|
|
|
"pinned": True,
|
|
|
|
|
"created_at": 1718000001.0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "cdp",
|
|
|
|
|
"title": "CDP Inspect",
|
|
|
|
|
"prompt": "Inspect active CDP ports and page states across all nodes.",
|
|
|
|
|
"tags": ["cdp", "debug"],
|
|
|
|
|
"pinned": True,
|
|
|
|
|
"created_at": 1718000002.0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "digest",
|
|
|
|
|
"title": "Loop Digest",
|
|
|
|
|
"prompt": "Provide a concise bullet-point digest of current work in progress and blockers.",
|
|
|
|
|
"tags": ["digest", "summary"],
|
|
|
|
|
"pinned": False,
|
|
|
|
|
"created_at": 1718000003.0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "review",
|
|
|
|
|
"title": "Code Review",
|
|
|
|
|
"prompt": "Review recent code diffs, verify correctness, and check for potential regressions.",
|
|
|
|
|
"tags": ["code", "review"],
|
|
|
|
|
"pinned": False,
|
|
|
|
|
"created_at": 1718000004.0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "tests",
|
|
|
|
|
"title": "Run Tests",
|
|
|
|
|
"prompt": "Execute the test suite and report any test failures with root cause analysis.",
|
|
|
|
|
"tags": ["test", "qa"],
|
|
|
|
|
"pinned": False,
|
|
|
|
|
"created_at": 1718000005.0,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "status",
|
|
|
|
|
"title": "Agent Status",
|
|
|
|
|
"prompt": "Report detailed status, active subtasks, and pending approvals for this node.",
|
|
|
|
|
"tags": ["agent", "status"],
|
|
|
|
|
"pinned": False,
|
|
|
|
|
"created_at": 1718000006.0,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PromptManager:
|
|
|
|
|
"""Manages persistent prompt templates and skills stored in prompts.json."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, storage_path: Path = None):
|
|
|
|
|
self.storage_path = storage_path or (Path.home() / ".config" / "muse-cli" / "prompts.json")
|
|
|
|
|
self.prompts = []
|
|
|
|
|
self._load_prompts()
|
|
|
|
|
|
|
|
|
|
def _load_prompts(self):
|
|
|
|
|
"""Load prompts from disk or initialize with built-in defaults."""
|
|
|
|
|
if self.storage_path.exists():
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(self.storage_path.read_text(encoding="utf-8"))
|
|
|
|
|
if isinstance(data, list) and data:
|
|
|
|
|
self.prompts = data
|
|
|
|
|
return
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
# Initialize defaults
|
|
|
|
|
self.prompts = [dict(p) for p in DEFAULT_PROMPTS]
|
|
|
|
|
self._save_prompts()
|
|
|
|
|
|
|
|
|
|
def _save_prompts(self):
|
|
|
|
|
"""Persist prompts to disk."""
|
|
|
|
|
try:
|
|
|
|
|
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
self.storage_path.write_text(json.dumps(self.prompts, indent=2), encoding="utf-8")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def get_sorted_prompts(self) -> list:
|
|
|
|
|
"""Return prompts with pinned items first, then remaining items in order."""
|
|
|
|
|
return sorted(self.prompts, key=lambda p: (not p.get("pinned", False), -p.get("created_at", 0.0)))
|
|
|
|
|
|
|
|
|
|
def add_prompt(self, title: str, prompt_text: str, tags: list = None, pid: str = None, pinned: bool = False) -> dict:
|
|
|
|
|
"""Formulate and store a new prompt skill."""
|
|
|
|
|
pid = pid or re.sub(r"[^a-zA-Z0-9_-]", "", title.lower().replace(" ", "_"))[:16] or f"p_{int(time.time()*1000)%100000}"
|
|
|
|
|
prompt_item = {
|
|
|
|
|
"id": pid,
|
|
|
|
|
"title": title.strip(),
|
|
|
|
|
"prompt": prompt_text.strip(),
|
|
|
|
|
"tags": tags or ["custom"],
|
|
|
|
|
"pinned": pinned,
|
|
|
|
|
"created_at": time.time(),
|
|
|
|
|
}
|
|
|
|
|
for idx, p in enumerate(self.prompts):
|
|
|
|
|
if p.get("id") == pid:
|
|
|
|
|
self.prompts[idx] = prompt_item
|
|
|
|
|
self._save_prompts()
|
|
|
|
|
return prompt_item
|
|
|
|
|
|
|
|
|
|
self.prompts.insert(0 if pinned else len(self.prompts), prompt_item)
|
|
|
|
|
self._save_prompts()
|
|
|
|
|
return prompt_item
|
|
|
|
|
|
|
|
|
|
def pin_chat_send(self, text: str, node: str = "muse", title: str = None, toggle: bool = False) -> tuple[dict, bool]:
|
|
|
|
|
"""Pin a sent chat message to the prompt fill / list. If toggle=True, unpins if already pinned."""
|
|
|
|
|
clean_text = text.strip()
|
|
|
|
|
for p in self.prompts:
|
|
|
|
|
if p.get("prompt", "").strip() == clean_text:
|
|
|
|
|
if toggle:
|
|
|
|
|
p["pinned"] = not p.get("pinned", False)
|
|
|
|
|
else:
|
|
|
|
|
p["pinned"] = True
|
|
|
|
|
self._save_prompts()
|
|
|
|
|
return p, False
|
|
|
|
|
|
|
|
|
|
first_line = clean_text.split("\n")[0].strip()
|
|
|
|
|
derived_title = first_line[:24] + ("…" if len(first_line) > 24 else "") if not title else title
|
|
|
|
|
pid = f"pin_{int(time.time()*1000) % 1000000}"
|
|
|
|
|
prompt_item = {
|
|
|
|
|
"id": pid,
|
|
|
|
|
"title": derived_title,
|
|
|
|
|
"prompt": clean_text,
|
|
|
|
|
"tags": ["pinned", node],
|
|
|
|
|
"pinned": True,
|
|
|
|
|
"created_at": time.time(),
|
|
|
|
|
}
|
|
|
|
|
self.prompts.insert(0, prompt_item)
|
|
|
|
|
self._save_prompts()
|
|
|
|
|
return prompt_item, True
|
|
|
|
|
|
|
|
|
|
def toggle_pin_chat_send(self, text: str, node: str = "muse", title: str = None) -> tuple[dict, bool]:
|
|
|
|
|
"""Toggle pinned status for a sent chat message in prompts."""
|
|
|
|
|
return self.pin_chat_send(text, node=node, title=title, toggle=True)
|
|
|
|
|
|
|
|
|
|
def toggle_pin_prompt(self, pid: str) -> bool:
|
|
|
|
|
"""Toggle pinned state for a prompt. Returns new state."""
|
|
|
|
|
for p in self.prompts:
|
|
|
|
|
if p.get("id") == pid:
|
|
|
|
|
p["pinned"] = not p.get("pinned", False)
|
|
|
|
|
self._save_prompts()
|
|
|
|
|
return p["pinned"]
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def delete_prompt(self, pid: str) -> bool:
|
|
|
|
|
"""Delete a prompt by ID."""
|
|
|
|
|
for idx, p in enumerate(self.prompts):
|
|
|
|
|
if p.get("id") == pid:
|
|
|
|
|
self.prompts.pop(idx)
|
|
|
|
|
self._save_prompts()
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Data Layer & Async Poller
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
class FleetDataManager:
|
|
|
|
|
"""Thread-safe background data fetcher for fleet state, chat, and approvals."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, start_poller: bool = None):
|
|
|
|
|
self.lock = threading.RLock()
|
|
|
|
|
self.nodes = list(VALID_NODES)
|
|
|
|
|
self.active_node = "muse"
|
|
|
|
|
self.active_thread_id = None
|
|
|
|
|
self.active_thread_title = ""
|
|
|
|
|
|
|
|
|
|
# Background worker control & adaptive activity tracking
|
|
|
|
|
self.running = True
|
|
|
|
|
self.refresh_event = threading.Event()
|
|
|
|
|
self.user_poll_trigger = threading.Event()
|
|
|
|
|
self.last_user_activity = time.time()
|
|
|
|
|
self.rate_limit_cooldown = {} # node -> float epoch timestamp until cooldown ends
|
|
|
|
|
self.rate_limit_consecutive = {n: 0 for n in self.nodes} # node -> consecutive 429 count
|
|
|
|
|
self.history_fetch_times = {} # (node, thread_id) -> float epoch timestamp of last successful fetch
|
|
|
|
|
|
|
|
|
|
# Cached state
|
|
|
|
|
self.node_status = {n: {"status": "ACTIVE", "latency": None, "tab": "", "blocked": False, "rate_limited": False} for n in self.nodes}
|
|
|
|
|
self.threads_cache = {n: [] for n in self.nodes}
|
|
|
|
|
self.history_cache = {} # (node, thread_id) -> list of msgs
|
|
|
|
|
self.approvals_cache = [] # list of approval dicts
|
|
|
|
|
self.jobs_cache = []
|
|
|
|
|
self.tmux_cache = []
|
|
|
|
|
self.dm_logs_cache = []
|
|
|
|
|
|
|
|
|
|
# Interaction ranking: node -> float timestamp of last true input / chat [insert]
|
|
|
|
|
self.agent_interactions = {n: 0.0 for n in self.nodes}
|
|
|
|
|
self._load_agent_interactions()
|
|
|
|
|
|
|
|
|
|
# Dynamic & lazy loading state
|
|
|
|
|
self.loading_threads = set() # set of node names currently being fetched
|
|
|
|
|
self.loaded_nodes = set() # set of node names that completed at least one fetch attempt
|
|
|
|
|
self.fetch_times = {} # node -> float timestamp of last fetch
|
|
|
|
|
self.loading_history = set() # set of (node, thread_id) currently fetching history
|
|
|
|
|
# Pinned chats map: node -> set of session_ids
|
|
|
|
|
self.pinned_threads = {n: set() for n in self.nodes}
|
|
|
|
|
# Read state map: (node, session_id) -> int (number of messages read)
|
|
|
|
|
self.read_msg_counts = {}
|
|
|
|
|
self._load_pinned_threads()
|
|
|
|
|
self._load_history_cache()
|
|
|
|
|
self._load_read_state()
|
|
|
|
|
self._init_threads_cache()
|
|
|
|
|
self._seed_interactions_from_threads()
|
|
|
|
|
self.sort_nodes()
|
|
|
|
|
if self.nodes:
|
|
|
|
|
self.active_node = self.nodes[0]
|
|
|
|
|
self.select_main_chat(self.active_node)
|
|
|
|
|
|
|
|
|
|
# In unit tests, avoid auto-spawning background worker threads that hit network
|
|
|
|
|
in_test = bool(
|
|
|
|
|
os.environ.get("BOX_TESTS_NESTED") or
|
|
|
|
|
os.environ.get("MUSE_NO_POLLER") or
|
|
|
|
|
"unittest" in sys.modules or
|
|
|
|
|
any("unittest" in arg or "pytest" in arg for arg in sys.argv)
|
|
|
|
|
)
|
|
|
|
|
if start_poller is None:
|
|
|
|
|
start_poller = not in_test
|
|
|
|
|
|
|
|
|
|
if start_poller:
|
|
|
|
|
self.preload_priority_chats(self.active_node, sidechat_limit=0)
|
|
|
|
|
self.poller_thread = threading.Thread(target=self._worker_loop, daemon=True)
|
|
|
|
|
self.poller_thread.start()
|
|
|
|
|
else:
|
|
|
|
|
self.poller_thread = None
|
|
|
|
|
|
|
|
|
|
def record_activity(self):
|
|
|
|
|
"""Record user input/interaction to keep poll interval in fast/active mode."""
|
|
|
|
|
self.last_user_activity = time.time()
|
|
|
|
|
|
|
|
|
|
def is_node_rate_limited(self, node: str) -> bool:
|
|
|
|
|
"""Check if node is currently in rate-limit cooldown window."""
|
|
|
|
|
if not node:
|
|
|
|
|
return False
|
|
|
|
|
until = self.rate_limit_cooldown.get(node, 0.0)
|
|
|
|
|
return time.time() < until
|
|
|
|
|
|
|
|
|
|
def get_node_cooldown_remaining(self, node: str) -> int:
|
|
|
|
|
"""Return remaining seconds in rate-limit cooldown (0 if not rate limited)."""
|
|
|
|
|
until = self.rate_limit_cooldown.get(node, 0.0)
|
|
|
|
|
rem = until - time.time()
|
|
|
|
|
return max(0, int(rem))
|
|
|
|
|
|
|
|
|
|
def mark_node_rate_limited(self, node: str, cooldown_seconds: float = None, reason: str = "", retry_after: float = None):
|
|
|
|
|
"""Place node into rate-limit cooldown window using tiered backoff (30s -> 60s -> 120s max)."""
|
|
|
|
|
if retry_after is not None and retry_after > 0:
|
|
|
|
|
cd = retry_after
|
|
|
|
|
elif cooldown_seconds is not None:
|
|
|
|
|
cd = cooldown_seconds
|
|
|
|
|
else:
|
|
|
|
|
consec = self.rate_limit_consecutive.get(node, 0)
|
|
|
|
|
self.rate_limit_consecutive[node] = consec + 1
|
|
|
|
|
cd = min(120.0, 30.0 * (2 ** consec)) # 30s -> 60s -> 120s max
|
|
|
|
|
|
|
|
|
|
self.rate_limit_cooldown[node] = time.time() + cd
|
|
|
|
|
with self.lock:
|
|
|
|
|
if node in self.node_status:
|
|
|
|
|
self.node_status[node]["rate_limited"] = True
|
|
|
|
|
|
|
|
|
|
def clear_node_rate_limit(self, node: str):
|
|
|
|
|
"""Reset rate limit cooldown and consecutive counter upon successful response or explicit override."""
|
|
|
|
|
self.rate_limit_cooldown[node] = 0.0
|
|
|
|
|
self.rate_limit_consecutive[node] = 0
|
|
|
|
|
with self.lock:
|
|
|
|
|
if node in self.node_status:
|
|
|
|
|
self.node_status[node]["rate_limited"] = False
|
|
|
|
|
|
|
|
|
|
def _load_pinned_threads(self):
|
|
|
|
|
"""Load pinned thread IDs from ~/.config/muse-cli/pinned_threads.json."""
|
|
|
|
|
pinned_file = Path.home() / ".config" / "muse-cli" / "pinned_threads.json"
|
|
|
|
|
if pinned_file.exists():
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(pinned_file.read_text(encoding="utf-8"))
|
|
|
|
|
for n, sids in data.items():
|
|
|
|
|
if n in self.nodes and isinstance(sids, list):
|
|
|
|
|
self.pinned_threads[n] = set(sids)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _save_pinned_threads(self):
|
|
|
|
|
"""Persist pinned thread IDs to disk."""
|
|
|
|
|
pinned_file = Path.home() / ".config" / "muse-cli" / "pinned_threads.json"
|
|
|
|
|
try:
|
|
|
|
|
pinned_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
data = {n: list(self.pinned_threads.get(n, set())) for n in self.nodes}
|
|
|
|
|
pinned_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _load_agent_interactions(self):
|
|
|
|
|
"""Load interaction timestamps from ~/.config/muse-cli/agent_interactions.json."""
|
|
|
|
|
int_file = Path.home() / ".config" / "muse-cli" / "agent_interactions.json"
|
|
|
|
|
if int_file.exists():
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(int_file.read_text(encoding="utf-8"))
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
for n, ts in data.items():
|
|
|
|
|
if n in self.nodes and isinstance(ts, (int, float)):
|
|
|
|
|
self.agent_interactions[n] = float(ts)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _save_agent_interactions(self):
|
|
|
|
|
"""Persist interaction timestamps to disk outside lock."""
|
|
|
|
|
int_file = Path.home() / ".config" / "muse-cli" / "agent_interactions.json"
|
|
|
|
|
try:
|
|
|
|
|
int_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with self.lock:
|
|
|
|
|
data = {n: self.agent_interactions.get(n, 0.0) for n in self.nodes}
|
|
|
|
|
payload = json.dumps(data, indent=2)
|
|
|
|
|
int_file.write_text(payload, encoding="utf-8")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _seed_interactions_from_threads(self):
|
|
|
|
|
"""Seed interactions from cached thread updated times for any nodes with no record."""
|
|
|
|
|
with self.lock:
|
|
|
|
|
for n in self.nodes:
|
|
|
|
|
if self.agent_interactions.get(n, 0.0) == 0.0:
|
|
|
|
|
threads = self.threads_cache.get(n, [])
|
|
|
|
|
newest = 0.0
|
|
|
|
|
for t in threads:
|
|
|
|
|
up_str = t.get("updated")
|
|
|
|
|
if up_str:
|
|
|
|
|
try:
|
|
|
|
|
dt = datetime.fromisoformat(up_str)
|
|
|
|
|
ts = dt.timestamp()
|
|
|
|
|
if ts > newest:
|
|
|
|
|
newest = ts
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
if newest > 0.0:
|
|
|
|
|
self.agent_interactions[n] = newest
|
|
|
|
|
|
|
|
|
|
def sort_nodes(self):
|
|
|
|
|
"""Sort fleet agents by:
|
|
|
|
|
1. Highest unread messages total (descending)
|
|
|
|
|
2. Most recent direct operator interaction (descending)
|
|
|
|
|
3. Baseline order from VALID_NODES."""
|
|
|
|
|
with self.lock:
|
|
|
|
|
baseline = {n: i for i, n in enumerate(VALID_NODES)}
|
|
|
|
|
self.nodes.sort(
|
|
|
|
|
key=lambda n: (
|
|
|
|
|
self.get_node_unread_total(n),
|
|
|
|
|
self.agent_interactions.get(n, 0.0),
|
|
|
|
|
-baseline.get(n, 999)
|
|
|
|
|
),
|
|
|
|
|
reverse=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def record_interaction(self, node: str, ts: float = None):
|
|
|
|
|
"""Record direct operator interaction with an agent via true input / chat [insert]."""
|
|
|
|
|
if node not in self.nodes:
|
|
|
|
|
return
|
|
|
|
|
now = ts if ts is not None else time.time()
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.agent_interactions[node] = now
|
|
|
|
|
self._save_agent_interactions()
|
|
|
|
|
self.sort_nodes()
|
|
|
|
|
self.trigger_refresh()
|
|
|
|
|
|
|
|
|
|
def toggle_pin_thread(self, node: str, session_id: str) -> bool:
|
|
|
|
|
"""Toggle pinned state for a thread. Returns new pinned state."""
|
|
|
|
|
with self.lock:
|
|
|
|
|
pset = self.pinned_threads.setdefault(node, set())
|
|
|
|
|
if session_id in pset:
|
|
|
|
|
pset.discard(session_id)
|
|
|
|
|
new_state = False
|
|
|
|
|
else:
|
|
|
|
|
pset.add(session_id)
|
|
|
|
|
new_state = True
|
|
|
|
|
self._save_pinned_threads()
|
|
|
|
|
if node in self.threads_cache:
|
|
|
|
|
self.threads_cache[node] = self.sort_threads(node, self.threads_cache[node])
|
|
|
|
|
self._save_threads_cache()
|
|
|
|
|
self.trigger_refresh()
|
|
|
|
|
return new_state
|
|
|
|
|
|
|
|
|
|
def _load_history_cache(self):
|
|
|
|
|
"""Seed history cache from ~/.config/muse-cli/history_cache.json."""
|
|
|
|
|
hist_file = Path.home() / ".config" / "muse-cli" / "history_cache.json"
|
|
|
|
|
if hist_file.exists():
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(hist_file.read_text(encoding="utf-8"))
|
|
|
|
|
for k, msgs in data.items():
|
|
|
|
|
if "/" in k and isinstance(msgs, list):
|
|
|
|
|
n, tid = k.split("/", 1)
|
|
|
|
|
if tid == "None":
|
|
|
|
|
tid = None
|
|
|
|
|
if n in self.nodes:
|
|
|
|
|
self.history_cache[(n, tid)] = msgs
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _save_history_cache(self):
|
|
|
|
|
"""Persist history cache to ~/.config/muse-cli/history_cache.json."""
|
|
|
|
|
hist_file = Path.home() / ".config" / "muse-cli" / "history_cache.json"
|
|
|
|
|
try:
|
|
|
|
|
hist_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with self.lock:
|
|
|
|
|
data = {f"{k[0]}/{k[1]}": list(msgs) for k, msgs in self.history_cache.items()}
|
|
|
|
|
payload = json.dumps(data, indent=1)
|
|
|
|
|
hist_file.write_text(payload, encoding="utf-8")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _load_read_state(self):
|
|
|
|
|
"""Load read message counts from ~/.config/muse-cli/read_state.json."""
|
|
|
|
|
read_file = Path.home() / ".config" / "muse-cli" / "read_state.json"
|
|
|
|
|
if read_file.exists():
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(read_file.read_text(encoding="utf-8"))
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
for k, count in data.items():
|
|
|
|
|
if "/" in k and isinstance(count, int):
|
|
|
|
|
n, tid = k.split("/", 1)
|
|
|
|
|
if n in self.nodes:
|
|
|
|
|
self.read_msg_counts[(n, tid)] = max(0, count)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _save_read_state(self):
|
|
|
|
|
"""Persist read message counts to disk."""
|
|
|
|
|
read_file = Path.home() / ".config" / "muse-cli" / "read_state.json"
|
|
|
|
|
try:
|
|
|
|
|
read_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with self.lock:
|
|
|
|
|
data = {f"{k[0]}/{k[1]}": count for k, count in self.read_msg_counts.items()}
|
|
|
|
|
read_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def mark_thread_read(self, node: str, thread_id: str, count: int = None):
|
|
|
|
|
"""Mark a thread/sidechat as read up to count or current history message total."""
|
|
|
|
|
if not node:
|
|
|
|
|
return
|
|
|
|
|
key = (node, thread_id)
|
|
|
|
|
with self.lock:
|
|
|
|
|
if count is not None:
|
|
|
|
|
cur_total = count
|
|
|
|
|
else:
|
|
|
|
|
cur_total = len(self.history_cache.get(key, []))
|
|
|
|
|
old_count = self.read_msg_counts.get(key, 0)
|
|
|
|
|
if cur_total >= old_count:
|
|
|
|
|
self.read_msg_counts[key] = cur_total
|
|
|
|
|
self._save_read_state()
|
|
|
|
|
self.trigger_refresh()
|
|
|
|
|
|
|
|
|
|
def mark_node_read(self, node: str):
|
|
|
|
|
"""Mark all threads/sidechats for a node as read."""
|
|
|
|
|
if not node:
|
|
|
|
|
return
|
|
|
|
|
with self.lock:
|
|
|
|
|
threads = list(self.threads_cache.get(node, []))
|
|
|
|
|
for t in threads:
|
|
|
|
|
sid = t.get("session_id")
|
|
|
|
|
if sid:
|
|
|
|
|
key = (node, sid)
|
|
|
|
|
total = len(self.history_cache.get(key, []))
|
|
|
|
|
old_count = self.read_msg_counts.get(key, 0)
|
|
|
|
|
if total >= old_count:
|
|
|
|
|
self.read_msg_counts[key] = total
|
|
|
|
|
self._save_read_state()
|
|
|
|
|
self.trigger_refresh()
|
|
|
|
|
|
|
|
|
|
def mark_fleet_read(self):
|
|
|
|
|
"""Mark all threads across all agents in the fleet as read."""
|
|
|
|
|
with self.lock:
|
|
|
|
|
for node in self.nodes:
|
|
|
|
|
threads = list(self.threads_cache.get(node, []))
|
|
|
|
|
for t in threads:
|
|
|
|
|
sid = t.get("session_id")
|
|
|
|
|
if sid:
|
|
|
|
|
key = (node, sid)
|
|
|
|
|
total = len(self.history_cache.get(key, []))
|
|
|
|
|
old_count = self.read_msg_counts.get(key, 0)
|
|
|
|
|
if total >= old_count:
|
|
|
|
|
self.read_msg_counts[key] = total
|
|
|
|
|
self._save_read_state()
|
|
|
|
|
self.trigger_refresh()
|
|
|
|
|
|
|
|
|
|
def get_unread_count(self, node: str, thread_id: str) -> int:
|
|
|
|
|
"""Get number of unread/new messages in a thread."""
|
|
|
|
|
key = (node, thread_id)
|
|
|
|
|
with self.lock:
|
|
|
|
|
total = len(self.history_cache.get(key, []))
|
|
|
|
|
read_cnt = self.read_msg_counts.get(key, 0)
|
|
|
|
|
return max(0, total - read_cnt)
|
|
|
|
|
|
|
|
|
|
def get_node_unread_total(self, node: str) -> int:
|
|
|
|
|
"""Get total unread messages across all threads/sidechats for an agent."""
|
|
|
|
|
total_unread = 0
|
|
|
|
|
with self.lock:
|
|
|
|
|
threads = list(self.threads_cache.get(node, []))
|
|
|
|
|
for t in threads:
|
|
|
|
|
sid = t.get("session_id")
|
|
|
|
|
if sid:
|
|
|
|
|
total_unread += self.get_unread_count(node, sid)
|
|
|
|
|
return total_unread
|
|
|
|
|
|
|
|
|
|
def get_fleet_unread_total(self) -> int:
|
|
|
|
|
"""Get grand total of unread messages across all agents in the fleet."""
|
|
|
|
|
return sum(self.get_node_unread_total(n) for n in self.nodes)
|
|
|
|
|
|
|
|
|
|
def sort_threads(self, node: str, thread_list: list) -> list:
|
|
|
|
|
"""Sort threads with Main Chat first (prio 0), Pinned chats second (prio 1), then recency (prio 2)."""
|
|
|
|
|
pinned_set = self.pinned_threads.get(node, set())
|
|
|
|
|
mains = []
|
|
|
|
|
pinned = []
|
|
|
|
|
regular = []
|
|
|
|
|
|
|
|
|
|
for t in thread_list:
|
|
|
|
|
if not isinstance(t, dict) or not t.get("session_id"):
|
|
|
|
|
continue
|
|
|
|
|
sid = t.get("session_id")
|
|
|
|
|
title_lower = (t.get("title") or "").lower()
|
|
|
|
|
is_main = (
|
|
|
|
|
t.get("thread") is False or
|
|
|
|
|
t.get("is_main") is True or
|
|
|
|
|
title_lower in ("main chat", "main", "start conversation with muse")
|
|
|
|
|
)
|
|
|
|
|
is_pin = sid in pinned_set
|
|
|
|
|
|
|
|
|
|
t["is_main"] = is_main
|
|
|
|
|
t["pinned"] = is_pin
|
|
|
|
|
|
|
|
|
|
if is_main:
|
|
|
|
|
mains.append(t)
|
|
|
|
|
elif is_pin:
|
|
|
|
|
pinned.append(t)
|
|
|
|
|
else:
|
|
|
|
|
regular.append(t)
|
|
|
|
|
|
|
|
|
|
mains.sort(key=lambda x: x.get("updated") or "", reverse=True)
|
|
|
|
|
pinned.sort(key=lambda x: x.get("updated") or "", reverse=True)
|
|
|
|
|
regular.sort(key=lambda x: x.get("updated") or "", reverse=True)
|
|
|
|
|
|
|
|
|
|
return mains + pinned + regular
|
|
|
|
|
|
|
|
|
|
def preload_priority_chats(self, node: str = None, sidechat_limit: int = 15):
|
|
|
|
|
"""Proactively fetch and cache message history for Main Chat, pinned chats, and recent sidechats."""
|
|
|
|
|
nodes_to_preload = [node] if node else self.nodes
|
|
|
|
|
for n in nodes_to_preload:
|
|
|
|
|
with self.lock:
|
|
|
|
|
threads = list(self.threads_cache.get(n, []))
|
|
|
|
|
side_cnt = 0
|
|
|
|
|
for t in threads:
|
|
|
|
|
sid = t.get("session_id")
|
|
|
|
|
if not sid:
|
|
|
|
|
continue
|
|
|
|
|
is_prio = t.get("is_main") or t.get("pinned")
|
|
|
|
|
if is_prio:
|
|
|
|
|
threading.Thread(target=self._fetch_history, args=(n, sid), daemon=True).start()
|
|
|
|
|
elif side_cnt < sidechat_limit:
|
|
|
|
|
side_cnt += 1
|
|
|
|
|
threading.Thread(target=self._fetch_history, args=(n, sid), daemon=True).start()
|
|
|
|
|
|
|
|
|
|
def _init_threads_cache(self):
|
|
|
|
|
"""Seed threads cache from persistent disk cache and job-sidechats.json."""
|
|
|
|
|
# 1. Seed from job-sidechats.json
|
|
|
|
|
job_chats_file = REPO_ROOT / "job-sidechats.json"
|
|
|
|
|
if job_chats_file.exists():
|
|
|
|
|
try:
|
|
|
|
|
with open(job_chats_file, "r", encoding="utf-8") as f:
|
|
|
|
|
js_data = json.load(f)
|
|
|
|
|
for k, v in js_data.items():
|
|
|
|
|
if not isinstance(v, dict):
|
|
|
|
|
continue
|
|
|
|
|
agent = v.get("agent")
|
|
|
|
|
if agent not in self.nodes:
|
|
|
|
|
agent = "muse"
|
|
|
|
|
sid = v.get("thread_uuid")
|
|
|
|
|
title = v.get("description") or k
|
|
|
|
|
if sid:
|
|
|
|
|
existing_ids = {t.get("session_id") for t in self.threads_cache[agent]}
|
|
|
|
|
if sid not in existing_ids:
|
|
|
|
|
self.threads_cache[agent].append({"session_id": sid, "title": title})
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# 2. Seed from ~/.config/muse-cli/threads_cache.json if present (higher precedence / recent)
|
|
|
|
|
cache_file = Path.home() / ".config" / "muse-cli" / "threads_cache.json"
|
|
|
|
|
if cache_file.exists():
|
|
|
|
|
try:
|
|
|
|
|
with open(cache_file, "r", encoding="utf-8") as f:
|
|
|
|
|
disk_cache = json.load(f)
|
|
|
|
|
if isinstance(disk_cache, dict):
|
|
|
|
|
for n, t_list in disk_cache.items():
|
|
|
|
|
if n in self.nodes and isinstance(t_list, list):
|
|
|
|
|
live_ids = {t.get("session_id") for t in t_list if isinstance(t, dict) and t.get("session_id")}
|
|
|
|
|
merged = [t for t in t_list if isinstance(t, dict) and t.get("session_id")]
|
|
|
|
|
for old_t in self.threads_cache.get(n, []):
|
|
|
|
|
if old_t.get("session_id") not in live_ids:
|
|
|
|
|
merged.append(old_t)
|
|
|
|
|
self.threads_cache[n] = merged
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Seed pinned_threads from threads_cache if pinned_threads is empty
|
|
|
|
|
for n in self.nodes:
|
|
|
|
|
if not self.pinned_threads.get(n):
|
|
|
|
|
for t in self.threads_cache.get(n, []):
|
|
|
|
|
if t.get("pinned") and t.get("session_id"):
|
|
|
|
|
self.pinned_threads.setdefault(n, set()).add(t["session_id"])
|
|
|
|
|
|
|
|
|
|
# Sort all cached threads so Main Chat and pinned chats are at top
|
|
|
|
|
for n in self.nodes:
|
|
|
|
|
if n in self.threads_cache:
|
|
|
|
|
self.threads_cache[n] = self.sort_threads(n, self.threads_cache[n])
|
|
|
|
|
|
|
|
|
|
# Set default active thread for initial active_node if available (now Main Chat!)
|
|
|
|
|
self.select_main_chat(self.active_node)
|
|
|
|
|
|
|
|
|
|
def select_main_chat(self, node: str = None) -> tuple:
|
|
|
|
|
"""Select Main Chat for the specified node or current active node."""
|
|
|
|
|
with self.lock:
|
|
|
|
|
target_node = node or self.active_node
|
|
|
|
|
self.active_node = target_node
|
|
|
|
|
node_threads = self.threads_cache.get(target_node, [])
|
|
|
|
|
main_t = next((t for t in node_threads if t.get("is_main")), (node_threads[0] if node_threads else None))
|
|
|
|
|
if main_t:
|
|
|
|
|
self.active_thread_id = main_t.get("session_id")
|
|
|
|
|
self.active_thread_title = main_t.get("title") or "Main Chat"
|
|
|
|
|
else:
|
|
|
|
|
self.active_thread_id = None
|
|
|
|
|
self.active_thread_title = "Main Chat"
|
|
|
|
|
return self.active_thread_id, self.active_thread_title
|
|
|
|
|
|
|
|
|
|
def _save_threads_cache(self):
|
|
|
|
|
cache_file = Path.home() / ".config" / "muse-cli" / "threads_cache.json"
|
|
|
|
|
try:
|
|
|
|
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with self.lock:
|
|
|
|
|
data_copy = {n: list(self.threads_cache.get(n, [])) for n in self.nodes}
|
|
|
|
|
payload = json.dumps(data_copy, indent=2)
|
|
|
|
|
cache_file.write_text(payload, encoding="utf-8")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def is_loading_threads(self, node: str) -> bool:
|
|
|
|
|
with self.lock:
|
|
|
|
|
return node in self.loading_threads
|
|
|
|
|
|
|
|
|
|
def is_loading_history(self, node: str, thread_id: str) -> bool:
|
|
|
|
|
with self.lock:
|
|
|
|
|
return (node, thread_id) in self.loading_history
|
|
|
|
|
|
|
|
|
|
def stop(self):
|
|
|
|
|
self.running = False
|
|
|
|
|
if hasattr(self, 'user_poll_trigger') and self.user_poll_trigger:
|
|
|
|
|
self.user_poll_trigger.set()
|
|
|
|
|
if hasattr(self, 'refresh_event') and self.refresh_event:
|
|
|
|
|
self.refresh_event.set()
|
|
|
|
|
|
|
|
|
|
def trigger_refresh(self):
|
|
|
|
|
"""User or explicit action requested an immediate poll cycle."""
|
|
|
|
|
if hasattr(self, 'user_poll_trigger') and self.user_poll_trigger:
|
|
|
|
|
self.user_poll_trigger.set()
|
|
|
|
|
if hasattr(self, 'refresh_event') and self.refresh_event:
|
|
|
|
|
self.refresh_event.set()
|
|
|
|
|
|
|
|
|
|
def set_active_target(self, node: str, thread_id: str = None, thread_title: str = None):
|
|
|
|
|
with self.lock:
|
|
|
|
|
node_changed = (node in self.nodes and node != self.active_node)
|
|
|
|
|
if node in self.nodes:
|
|
|
|
|
self.active_node = node
|
|
|
|
|
if thread_id is not None:
|
|
|
|
|
self.active_thread_id = thread_id
|
|
|
|
|
if thread_title is not None:
|
|
|
|
|
self.active_thread_title = thread_title
|
|
|
|
|
else:
|
|
|
|
|
found_title = None
|
|
|
|
|
for t in self.threads_cache.get(node, []):
|
|
|
|
|
if t.get("session_id") == thread_id:
|
|
|
|
|
found_title = t.get("title")
|
|
|
|
|
break
|
|
|
|
|
self.active_thread_title = found_title or thread_id[:8]
|
|
|
|
|
elif node_changed or not self.active_thread_id:
|
|
|
|
|
self.select_main_chat(node)
|
|
|
|
|
elif thread_title is not None:
|
|
|
|
|
self.active_thread_title = thread_title
|
|
|
|
|
self.record_activity()
|
|
|
|
|
self.lazy_fetch_threads(node)
|
|
|
|
|
self.trigger_refresh()
|
|
|
|
|
|
|
|
|
|
def lazy_fetch_threads(self, node: str, force: bool = False):
|
|
|
|
|
"""Lazily trigger background fetch of threads for a node if needed."""
|
|
|
|
|
with self.lock:
|
|
|
|
|
if node not in self.nodes or self.is_node_rate_limited(node):
|
|
|
|
|
return
|
|
|
|
|
if node in self.loading_threads:
|
|
|
|
|
return
|
|
|
|
|
now = time.time()
|
|
|
|
|
last_fetch = self.fetch_times.get(node, 0.0)
|
|
|
|
|
if not force and node in self.loaded_nodes and (now - last_fetch) < 20.0:
|
|
|
|
|
return
|
|
|
|
|
self.loading_threads.add(node)
|
|
|
|
|
|
|
|
|
|
threading.Thread(target=self._run_fetch_threads, args=(node,), daemon=True).start()
|
|
|
|
|
|
|
|
|
|
def _worker_loop(self):
|
|
|
|
|
"""Periodic background data refresh with adaptive backoff and rate limit protections."""
|
|
|
|
|
last_fast = 0.0
|
|
|
|
|
last_med = 0.0
|
|
|
|
|
last_slow = 0.0
|
|
|
|
|
last_fleet_approvals = 0.0
|
|
|
|
|
|
|
|
|
|
# Initial fetch of active node main chat only
|
|
|
|
|
with self.lock:
|
|
|
|
|
init_node = self.active_node
|
|
|
|
|
if init_node and not self.is_node_rate_limited(init_node):
|
|
|
|
|
self.lazy_fetch_threads(init_node)
|
|
|
|
|
self._fetch_history(init_node, None)
|
|
|
|
|
|
|
|
|
|
while self.running:
|
|
|
|
|
now = time.time()
|
|
|
|
|
user_triggered = False
|
|
|
|
|
if hasattr(self, 'user_poll_trigger') and self.user_poll_trigger.is_set():
|
|
|
|
|
user_triggered = True
|
|
|
|
|
self.user_poll_trigger.clear()
|
|
|
|
|
|
|
|
|
|
# Adaptive fast polling cadence:
|
|
|
|
|
# Active (input/interaction within 20s): 2.5s
|
|
|
|
|
# Idling (20s - 60s): 6.0s
|
|
|
|
|
# Long idle (>60s): 15.0s
|
|
|
|
|
idle_seconds = now - getattr(self, "last_user_activity", 0.0)
|
|
|
|
|
if idle_seconds < 20.0:
|
|
|
|
|
fast_cadence = 2.5
|
|
|
|
|
elif idle_seconds < 60.0:
|
|
|
|
|
fast_cadence = 6.0
|
|
|
|
|
else:
|
|
|
|
|
fast_cadence = 15.0
|
|
|
|
|
|
|
|
|
|
# 1. Fast Poll: Current chat history & active node approvals
|
|
|
|
|
if (now - last_fast >= fast_cadence) or user_triggered:
|
|
|
|
|
with self.lock:
|
|
|
|
|
cur_node = self.active_node
|
|
|
|
|
cur_thread = self.active_thread_id
|
|
|
|
|
node_threads = self.threads_cache.get(cur_node, [])
|
|
|
|
|
cur_valid = any(t.get("session_id") == cur_thread for t in node_threads)
|
|
|
|
|
if not cur_thread or not cur_valid:
|
|
|
|
|
cur_thread, _ = self.select_main_chat(cur_node)
|
|
|
|
|
if cur_node and not self.is_node_rate_limited(cur_node):
|
|
|
|
|
self._fetch_history(cur_node, cur_thread)
|
|
|
|
|
self._fetch_active_node_approval(cur_node)
|
|
|
|
|
last_fast = now
|
|
|
|
|
|
|
|
|
|
# 2. Medium Poll (every 20s): Fleet health / CDP status & tmux
|
|
|
|
|
if (now - last_med >= 20.0):
|
|
|
|
|
self._fetch_fleet_health()
|
|
|
|
|
self._fetch_tmux_sessions()
|
|
|
|
|
last_med = now
|
|
|
|
|
|
|
|
|
|
# 3. Fleet Approvals Poll (every 12s, or immediately on user action)
|
|
|
|
|
if (now - last_fleet_approvals >= 12.0) or user_triggered:
|
|
|
|
|
self._fetch_approvals()
|
|
|
|
|
last_fleet_approvals = now
|
|
|
|
|
|
|
|
|
|
# 4. Slow Poll (every 45s): Refresh thread list & jobs lazily
|
|
|
|
|
if (now - last_slow >= 45.0) or user_triggered:
|
|
|
|
|
with self.lock:
|
|
|
|
|
cur_node = self.active_node
|
|
|
|
|
if cur_node and not self.is_node_rate_limited(cur_node):
|
|
|
|
|
self.lazy_fetch_threads(cur_node)
|
|
|
|
|
self._fetch_jobs()
|
|
|
|
|
self._fetch_dm_logs()
|
|
|
|
|
last_slow = now
|
|
|
|
|
|
|
|
|
|
# Sleep in short increments to allow prompt wakeup on user actions
|
|
|
|
|
for _ in range(10):
|
|
|
|
|
if not self.running or (hasattr(self, 'user_poll_trigger') and self.user_poll_trigger.is_set()):
|
|
|
|
|
break
|
|
|
|
|
time.sleep(0.1)
|
|
|
|
|
|
|
|
|
|
def _run_fetch_threads(self, node: str):
|
|
|
|
|
if self.is_node_rate_limited(node):
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
cmd = [str(BIN_DIR / "muse-cli-node"), node, "threads"]
|
|
|
|
|
rc, stdout, stderr = run_command_isolated(cmd, timeout=8.0)
|
2026-10-06 19:29:22 +00:00
|
|
|
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)
|
2026-10-06 07:55:27 +00:00
|
|
|
return
|
2026-10-06 19:29:22 +00:00
|
|
|
|
|
|
|
|
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):
|
2026-10-06 07:55:27 +00:00
|
|
|
self.clear_node_rate_limit(node)
|
2026-10-06 19:29:22 +00:00
|
|
|
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()
|
2026-10-06 07:55:27 +00:00
|
|
|
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)
|
2026-10-06 19:29:22 +00:00
|
|
|
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)
|
2026-10-06 07:55:27 +00:00
|
|
|
return
|
2026-10-06 19:29:22 +00:00
|
|
|
|
|
|
|
|
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):
|
2026-10-06 07:55:27 +00:00
|
|
|
self.clear_node_rate_limit(node)
|
|
|
|
|
self.history_fetch_times[key] = time.time()
|
2026-10-06 19:29:22 +00:00
|
|
|
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()
|
2026-10-06 07:55:27 +00:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.loading_history.discard(key)
|
|
|
|
|
|
|
|
|
|
def _fetch_active_node_approval(self, node: str):
|
|
|
|
|
"""Quick single-node check for active node approvals without scanning full fleet."""
|
|
|
|
|
if not approvals_engine or not node:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
res = approvals_engine.inspect_node_approvals(node)
|
|
|
|
|
with self.lock:
|
|
|
|
|
if node in self.node_status:
|
|
|
|
|
st = res.get("status")
|
|
|
|
|
is_blocked = st in ("PENDING", "KEY_APPROVAL")
|
|
|
|
|
is_waiting = (st == "INPUT_WAIT")
|
|
|
|
|
self.node_status[node]["blocked"] = is_blocked
|
|
|
|
|
self.node_status[node]["waiting"] = is_waiting
|
|
|
|
|
self.node_status[node]["approval_status"] = st
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _fetch_approvals(self):
|
|
|
|
|
if not approvals_engine:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
results = approvals_engine.check_fleet_approvals(self.nodes)
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.approvals_cache = results
|
|
|
|
|
for it in results:
|
|
|
|
|
n = it.get("node")
|
|
|
|
|
if n in self.node_status:
|
|
|
|
|
st = it.get("status")
|
|
|
|
|
is_blocked = st in ("PENDING", "KEY_APPROVAL")
|
|
|
|
|
is_waiting = (st == "INPUT_WAIT")
|
|
|
|
|
self.node_status[n]["blocked"] = is_blocked
|
|
|
|
|
self.node_status[n]["waiting"] = is_waiting
|
|
|
|
|
self.node_status[n]["approval_status"] = st
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _fetch_fleet_health(self):
|
|
|
|
|
if not netvm_registry:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
reg = netvm_registry.load()
|
|
|
|
|
for n in self.nodes:
|
|
|
|
|
rec = reg.get(n)
|
|
|
|
|
if not rec:
|
|
|
|
|
continue
|
|
|
|
|
peer_ip = rec.get("peer_ip", "127.0.0.1")
|
|
|
|
|
port = rec.get("cdp_port", 9410)
|
|
|
|
|
url = f"http://{peer_ip}:{port}/json/version"
|
|
|
|
|
t0 = time.time()
|
|
|
|
|
try:
|
|
|
|
|
req = urllib.request.Request(url, headers={"User-Agent": "muse-tui/1.0"})
|
|
|
|
|
with urllib.request.urlopen(req, timeout=1.2) as resp:
|
|
|
|
|
lat = int((time.time() - t0) * 1000)
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.node_status[n]["latency"] = lat
|
|
|
|
|
self.node_status[n]["status"] = "ACTIVE"
|
|
|
|
|
except Exception:
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.node_status[n]["status"] = "UNREACHABLE"
|
|
|
|
|
self.node_status[n]["latency"] = None
|
|
|
|
|
|
|
|
|
|
# Also grab active page title
|
|
|
|
|
tab_url = f"http://{peer_ip}:{port}/json/list"
|
|
|
|
|
try:
|
|
|
|
|
req = urllib.request.Request(tab_url, headers={"User-Agent": "muse-tui/1.0"})
|
|
|
|
|
with urllib.request.urlopen(req, timeout=1.0) as resp:
|
|
|
|
|
tabs = json.load(resp)
|
|
|
|
|
pages = [t for t in tabs if t.get("type") == "page"]
|
|
|
|
|
if pages:
|
|
|
|
|
t_title = pages[0].get("title", "")
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.node_status[n]["tab"] = t_title
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _fetch_jobs(self):
|
|
|
|
|
try:
|
|
|
|
|
cmd = ["python3", str(BIN_DIR / "box-ctl.py"), "job-list"]
|
|
|
|
|
res = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
|
|
|
|
|
if res.returncode == 0:
|
|
|
|
|
data = json.loads(res.stdout)
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.jobs_cache = data.get("jobs", [])
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _fetch_tmux_sessions(self):
|
|
|
|
|
try:
|
|
|
|
|
cmd = ["tmux", "-S", "/tmp/tmux-muse.sock", "ls"]
|
|
|
|
|
res = subprocess.run(cmd, capture_output=True, text=True, timeout=2)
|
|
|
|
|
if res.returncode == 0:
|
|
|
|
|
lines = [l.strip() for l in res.stdout.strip().split("\n") if l.strip()]
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.tmux_cache = lines
|
|
|
|
|
else:
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.tmux_cache = []
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _fetch_dm_logs(self):
|
|
|
|
|
log_path = REPO_ROOT / "dm-log.jsonl"
|
|
|
|
|
if not log_path.exists():
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
# Read last 40 lines
|
|
|
|
|
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
|
|
|
lines = f.readlines()[-40:]
|
|
|
|
|
parsed = []
|
|
|
|
|
for l in reversed(lines):
|
|
|
|
|
try:
|
|
|
|
|
parsed.append(json.loads(l))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.dm_logs_cache = parsed
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def add_optimistic_message(self, node: str, thread_id: str, text: str):
|
|
|
|
|
"""Immediately append the user's message to history cache so it renders without waiting for remote roundtrip."""
|
|
|
|
|
key = (node, thread_id)
|
|
|
|
|
msg_id = f"opt-{int(time.time()*1000)}"
|
|
|
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
opt_msg = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"text": text,
|
|
|
|
|
"message_id": msg_id,
|
|
|
|
|
"created": now_iso,
|
|
|
|
|
"optimistic": True,
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
}
|
|
|
|
|
with self.lock:
|
|
|
|
|
cur_msgs = list(self.history_cache.get(key, []))
|
|
|
|
|
if not any(m.get("text") == text and m.get("role") == "user" for m in cur_msgs[-2:]):
|
|
|
|
|
cur_msgs.append(opt_msg)
|
|
|
|
|
self.history_cache[key] = cur_msgs
|
|
|
|
|
is_main = any(t.get("session_id") == thread_id and t.get("is_main") for t in self.threads_cache.get(node, []))
|
|
|
|
|
if is_main or not thread_id:
|
|
|
|
|
self.history_cache[(node, None)] = cur_msgs
|
|
|
|
|
|
|
|
|
|
# Synchronous actions executed from UI
|
|
|
|
|
def send_chat_message(self, node: str, thread_id: str, text: str) -> tuple[bool, str]:
|
|
|
|
|
is_main = False
|
|
|
|
|
with self.lock:
|
|
|
|
|
for t in self.threads_cache.get(node, []):
|
|
|
|
|
if t.get("session_id") == thread_id and t.get("is_main"):
|
|
|
|
|
is_main = True
|
|
|
|
|
break
|
|
|
|
|
cmd = [str(BIN_DIR / "muse-cli-node"), node, "send", "--wait", "0"]
|
|
|
|
|
if thread_id and not is_main:
|
|
|
|
|
cmd.extend(["--thread", thread_id])
|
|
|
|
|
cmd.append(text)
|
|
|
|
|
|
|
|
|
|
last_err = ""
|
|
|
|
|
for attempt in range(3):
|
|
|
|
|
try:
|
|
|
|
|
rc, stdout, stderr = run_command_isolated(cmd, timeout=15.0)
|
2026-10-06 19:29:22 +00:00
|
|
|
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()
|
2026-10-06 07:55:27 +00:00
|
|
|
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):
|
|
|
|
|
self.stdscr = stdscr
|
|
|
|
|
self.mode = initial_mode # "muse" or "box"
|
|
|
|
|
self.box_tab = 0 # 0: Chat, 1: Fleet, 2: Approvals, 3: Jobs, 4: Tmux, 5: Logs
|
|
|
|
|
self.data = FleetDataManager()
|
|
|
|
|
|
|
|
|
|
# Selection state: default to top of sorted list (highest unread / most recently interacted)
|
|
|
|
|
if initial_node and initial_node in self.data.nodes:
|
|
|
|
|
target_node = initial_node
|
|
|
|
|
elif self.data.nodes:
|
|
|
|
|
target_node = self.data.nodes[0]
|
|
|
|
|
else:
|
|
|
|
|
target_node = "muse"
|
|
|
|
|
|
|
|
|
|
if initial_thread:
|
|
|
|
|
self.data.active_node = target_node
|
|
|
|
|
self.data.active_thread_id = initial_thread
|
|
|
|
|
found_title = None
|
|
|
|
|
for t in self.data.threads_cache.get(target_node, []):
|
|
|
|
|
if t.get("session_id") == initial_thread:
|
|
|
|
|
found_title = t.get("title")
|
|
|
|
|
break
|
|
|
|
|
self.data.active_thread_title = found_title or (initial_thread[:8] if initial_thread else "Sidechat")
|
|
|
|
|
else:
|
|
|
|
|
self.data.select_main_chat(target_node)
|
|
|
|
|
|
|
|
|
|
# Navigation & Modal State (Neovim-style modal lock)
|
|
|
|
|
# editor_mode: "NORMAL" (key nav locked, composer locked) or "INSERT" (typing in prompt)
|
|
|
|
|
self.editor_mode = "NORMAL"
|
2026-10-06 19:29:22 +00:00
|
|
|
# focus_pane: "fleet" (agents list), "sidechats" / "sidebar" (threads list), or "transcript" (chat scroll)
|
|
|
|
|
self.focus_pane = "sidechats"
|
|
|
|
|
self.fleet_focus_idx = 0
|
2026-10-06 07:55:27 +00:00
|
|
|
self.thread_sel_idx = 0
|
|
|
|
|
self.transcript_scroll_offset = 0 # 0 = locked at bottom
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
self.transcript_total_lines = 0
|
|
|
|
|
self.transcript_height = 20
|
|
|
|
|
self._transcript_cache_key = None
|
|
|
|
|
self._transcript_cache_lines = []
|
|
|
|
|
|
|
|
|
|
# Selection heads for Box mode tabs
|
|
|
|
|
self.fleet_sel_idx = 0
|
|
|
|
|
self.approvals_sel_idx = 0
|
|
|
|
|
self.jobs_sel_idx = 0
|
|
|
|
|
self.jobs_scroll_start = 0
|
|
|
|
|
self.tmux_sel_idx = 0
|
|
|
|
|
self.table_scroll_idx = 0
|
|
|
|
|
|
|
|
|
|
# Input buffer
|
|
|
|
|
self.input_buf = ""
|
|
|
|
|
self.input_cursor = 0
|
|
|
|
|
self.input_history = []
|
|
|
|
|
self.history_idx = -1
|
|
|
|
|
|
|
|
|
|
# Box mode tables scroll
|
|
|
|
|
self.table_scroll_idx = 0
|
|
|
|
|
|
|
|
|
|
# Fleet agent scroll & search state
|
|
|
|
|
self.agent_scroll_start = 0
|
|
|
|
|
self.agent_visible_count = 6
|
|
|
|
|
self.agent_search_sel = 0
|
|
|
|
|
self.agent_search_scroll = 0
|
|
|
|
|
|
|
|
|
|
# Modals: None, "help", "approvals", "new_sidechat", "work_order", "reply_task", "agent_search", "prompts", "history_search", "prompt_formulate", "chat_context", "agent_context", "message_context"
|
|
|
|
|
self.modal = None
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
self.modal_extra = {}
|
|
|
|
|
|
|
|
|
|
# Context Menus (Right-Click & Action Panels)
|
|
|
|
|
self.context_chat = None
|
|
|
|
|
self.chat_context_sel = 0
|
|
|
|
|
self.context_agent = None
|
|
|
|
|
self.agent_context_sel = 0
|
|
|
|
|
self.context_message = None
|
|
|
|
|
self.message_context_sel = 0
|
|
|
|
|
self._last_right_click_time = 0.0
|
|
|
|
|
|
|
|
|
|
# Prompt & Skill System
|
|
|
|
|
self.prompt_manager = PromptManager()
|
|
|
|
|
self.prompts = self.prompt_manager.get_sorted_prompts()
|
|
|
|
|
self.prompt_scroll_idx = 0
|
|
|
|
|
self.prompt_sel_idx = 0
|
|
|
|
|
self.prompt_preview_scroll = 0
|
|
|
|
|
self._prompt_chip_bounds = []
|
|
|
|
|
|
|
|
|
|
# Chat history sends search state
|
|
|
|
|
self.history_search_sel = 0
|
|
|
|
|
self.history_search_scroll = 0
|
|
|
|
|
|
|
|
|
|
# Clipboard, Copy, Selection & Reply state
|
|
|
|
|
self.clipboard_buf = ""
|
|
|
|
|
self._btn_copy_all_bounds = None
|
|
|
|
|
self._msg_copy_buttons = []
|
|
|
|
|
self._msg_reply_buttons = []
|
|
|
|
|
self.reply_target = None # None or dict: {"msg_id": ..., "seq": ..., "text": ..., "preview": ...}
|
|
|
|
|
self.copy_mode = False # True when in tmux copy mode
|
|
|
|
|
self.copy_cursor_line = 0 # Absolute line index in rendered_lines
|
|
|
|
|
self.visual_sel_start = None # Start line index of visual selection, or None
|
|
|
|
|
self.mouse_dragging = False
|
|
|
|
|
|
|
|
|
|
# Transcript display style: 'clean' (default, borderless line-by-line) or 'boxed' (classic ASCII frames)
|
|
|
|
|
self.transcript_style = self._load_transcript_style()
|
|
|
|
|
|
|
|
|
|
# Status toast
|
|
|
|
|
self.toast_msg = "Welcome to Muse TUI. Press '?' or F1 for help, 'i' to chat."
|
|
|
|
|
self.toast_style = "info"
|
|
|
|
|
self.toast_time = time.time() + 4.0
|
|
|
|
|
|
|
|
|
|
# Exit state
|
|
|
|
|
self.should_exit = False
|
|
|
|
|
|
|
|
|
|
# Chat dwell time tracking for clearing sidechat notifications (clears after > 5.0s dwell)
|
|
|
|
|
self.cur_chat_enter_time = time.time()
|
|
|
|
|
self.last_dwell_check_target = (self.data.active_node, self.data.active_thread_id)
|
|
|
|
|
|
|
|
|
|
# Device modifier substrate layer (Alt+Space leader capture)
|
|
|
|
|
self.device_layer_active = False
|
|
|
|
|
self.device_layer_timeout = 0.0
|
|
|
|
|
|
|
|
|
|
# Colors setup
|
|
|
|
|
self._init_colors()
|
|
|
|
|
|
|
|
|
|
def _init_colors(self):
|
|
|
|
|
try:
|
|
|
|
|
curses.curs_set(0) # Start with cursor parked in NORMAL mode
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self.stdscr.keypad(True)
|
|
|
|
|
self.stdscr.timeout(50) # 50ms getch non-blocking loop
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
if hasattr(curses, "set_escdelay"):
|
|
|
|
|
try:
|
|
|
|
|
curses.set_escdelay(25)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Mouse configuration (clicks and scroll wheel)
|
|
|
|
|
try:
|
|
|
|
|
curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
|
|
|
|
|
if hasattr(curses, "mouseinterval"):
|
|
|
|
|
curses.mouseinterval(0)
|
|
|
|
|
sys.stdout.write("\033[?1000h\033[?1002h\033[?1006h\033[?2004h")
|
|
|
|
|
sys.stdout.flush()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self.has_colors = curses.has_colors()
|
|
|
|
|
except Exception:
|
|
|
|
|
self.has_colors = False
|
|
|
|
|
|
|
|
|
|
if self.has_colors:
|
|
|
|
|
try:
|
|
|
|
|
curses.start_color()
|
|
|
|
|
curses.use_default_colors()
|
|
|
|
|
# Color pairs: (FG, BG)
|
|
|
|
|
# 1: Cyan (User)
|
|
|
|
|
curses.init_pair(1, curses.COLOR_CYAN, -1)
|
|
|
|
|
# 2: Yellow (Agent / Muse)
|
|
|
|
|
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
|
|
|
|
# 3: Green (Success, Work Order, Active)
|
|
|
|
|
curses.init_pair(3, curses.COLOR_GREEN, -1)
|
|
|
|
|
# 4: Red (Error, Blocked, Deny)
|
|
|
|
|
curses.init_pair(4, curses.COLOR_RED, -1)
|
|
|
|
|
# 5: Magenta (Tool execution, Swarm)
|
|
|
|
|
curses.init_pair(5, curses.COLOR_MAGENTA, -1)
|
|
|
|
|
# 6: Header / Tabs selected / Normal mode badge
|
|
|
|
|
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN)
|
|
|
|
|
# 7: Active pill / selection
|
|
|
|
|
curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
|
|
|
|
# 8: Warning banner
|
|
|
|
|
curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW)
|
|
|
|
|
# 9: Work order badge / Insert mode badge
|
|
|
|
|
curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_GREEN)
|
|
|
|
|
# 10: Dim text
|
|
|
|
|
curses.init_pair(10, curses.COLOR_WHITE, -1)
|
|
|
|
|
except Exception:
|
|
|
|
|
self.has_colors = False
|
|
|
|
|
|
|
|
|
|
def _attr(self, name: str):
|
|
|
|
|
if not self.has_colors:
|
|
|
|
|
if name in ("bold", "user", "agent"):
|
|
|
|
|
return curses.A_BOLD
|
|
|
|
|
if name in ("button", "header", "selected", "mode_normal", "mode_insert"):
|
|
|
|
|
return curses.A_REVERSE
|
|
|
|
|
if name == "dim":
|
|
|
|
|
return curses.A_DIM
|
|
|
|
|
return curses.A_NORMAL
|
|
|
|
|
|
|
|
|
|
table = {
|
|
|
|
|
"normal": curses.color_pair(0),
|
|
|
|
|
"user": curses.color_pair(1) | curses.A_BOLD,
|
|
|
|
|
"agent": curses.color_pair(2) | curses.A_BOLD,
|
|
|
|
|
"success": curses.color_pair(3) | curses.A_BOLD,
|
|
|
|
|
"green": curses.color_pair(3),
|
|
|
|
|
"danger": curses.color_pair(4) | curses.A_BOLD,
|
|
|
|
|
"red": curses.color_pair(4),
|
|
|
|
|
"tool": curses.color_pair(5),
|
|
|
|
|
"header": curses.color_pair(6) | curses.A_BOLD,
|
|
|
|
|
"selected": curses.color_pair(7) | curses.A_BOLD,
|
|
|
|
|
"warn_banner": curses.color_pair(8) | curses.A_BOLD,
|
|
|
|
|
"wo_badge": curses.color_pair(9) | curses.A_BOLD,
|
|
|
|
|
"dim": curses.color_pair(10) | curses.A_DIM,
|
|
|
|
|
"bold": curses.A_BOLD,
|
|
|
|
|
"button": curses.color_pair(7) | curses.A_BOLD,
|
|
|
|
|
"mode_normal": curses.color_pair(6) | curses.A_BOLD,
|
|
|
|
|
"mode_insert": curses.color_pair(9) | curses.A_BOLD,
|
|
|
|
|
}
|
|
|
|
|
return table.get(name, curses.A_NORMAL)
|
|
|
|
|
|
|
|
|
|
def safe_addstr(self, win, y: int, x: int, text: str, attr=0):
|
|
|
|
|
"""Safely write string to window without crashing on right/bottom edge."""
|
|
|
|
|
max_y, max_x = getattr(self, "_term_dims", (0, 0))
|
|
|
|
|
if max_y == 0 or max_x == 0:
|
|
|
|
|
max_y, max_x = win.getmaxyx()
|
|
|
|
|
if y < 0 or y >= max_y or x < 0 or x >= max_x:
|
|
|
|
|
return
|
|
|
|
|
available = max_x - x
|
|
|
|
|
if available <= 0:
|
|
|
|
|
return
|
|
|
|
|
slice_text = text[:available]
|
|
|
|
|
try:
|
|
|
|
|
win.addstr(y, x, slice_text, attr)
|
|
|
|
|
except curses.error:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def set_toast(self, msg: str, style="info", duration=4.0):
|
|
|
|
|
self.toast_msg = msg
|
|
|
|
|
self.toast_style = style
|
|
|
|
|
self.toast_time = time.time() + duration
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
# Main Render Pipeline
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
def run(self):
|
|
|
|
|
try:
|
|
|
|
|
last_dims = (0, 0)
|
|
|
|
|
last_modal = None
|
|
|
|
|
last_mode_state = None
|
|
|
|
|
while True:
|
|
|
|
|
h, w = self.stdscr.getmaxyx()
|
|
|
|
|
self._term_dims = (h, w)
|
|
|
|
|
|
|
|
|
|
# Only full-erase on resize, modal open/close, mode/tab switch, or explicit flag
|
|
|
|
|
cur_mode_state = (self.mode, self.box_tab)
|
|
|
|
|
if ((h, w) != last_dims or
|
|
|
|
|
self.modal != last_modal or
|
|
|
|
|
cur_mode_state != last_mode_state or
|
|
|
|
|
getattr(self, "need_full_redraw", False)):
|
|
|
|
|
self.stdscr.erase()
|
|
|
|
|
last_dims = (h, w)
|
|
|
|
|
last_modal = self.modal
|
|
|
|
|
last_mode_state = cur_mode_state
|
|
|
|
|
self.need_full_redraw = False
|
|
|
|
|
|
|
|
|
|
if h < 14 or w < 60:
|
|
|
|
|
self.safe_addstr(self.stdscr, 0, 0, f"Terminal too small ({w}x{h}). Need at least 60x14.", self._attr("danger"))
|
|
|
|
|
self.stdscr.refresh()
|
|
|
|
|
time.sleep(0.1)
|
|
|
|
|
ch = self.stdscr.getch()
|
|
|
|
|
if ch == ord('q') or ch == 27:
|
|
|
|
|
break
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# 1. Top Header Bar
|
|
|
|
|
self._render_header(0, 0, w)
|
|
|
|
|
|
|
|
|
|
# 2. Mode / Tab bar
|
|
|
|
|
header_lines = 1
|
|
|
|
|
if self.mode == "box":
|
|
|
|
|
self._render_box_tabs(1, 0, w)
|
|
|
|
|
header_lines = 2
|
|
|
|
|
|
|
|
|
|
content_y = header_lines
|
|
|
|
|
bottom_bar_h = 3 # Input bar + Toast / keyhints
|
|
|
|
|
is_main_view = (self.mode == "muse" or (self.mode == "box" and self.box_tab == 0))
|
|
|
|
|
prompt_shelf_h = 2 if is_main_view else 0
|
|
|
|
|
content_h = h - content_y - bottom_bar_h - prompt_shelf_h
|
|
|
|
|
prompt_shelf_y = content_y + content_h
|
|
|
|
|
|
|
|
|
|
# 3. Main Content & Chat Dwell Tracking
|
|
|
|
|
if is_main_view:
|
|
|
|
|
# Dwell timer: when viewing an active sidechat for >5 seconds, clear unread notification counter
|
|
|
|
|
cur_dwell_target = (self.data.active_node, self.data.active_thread_id)
|
|
|
|
|
if cur_dwell_target != getattr(self, "last_dwell_check_target", None):
|
|
|
|
|
self.cur_chat_enter_time = time.time()
|
|
|
|
|
self.last_dwell_check_target = cur_dwell_target
|
|
|
|
|
elif cur_dwell_target[0] and cur_dwell_target[1]:
|
|
|
|
|
# Check if dwell threshold (5 seconds) has elapsed
|
|
|
|
|
if time.time() - getattr(self, "cur_chat_enter_time", 0.0) >= 5.0:
|
|
|
|
|
# Only clear if user is looking at bottom or auto-scrolling
|
|
|
|
|
if getattr(self, "auto_scroll", True) or getattr(self, "transcript_scroll_offset", 0) == 0:
|
|
|
|
|
self.data.mark_thread_read(cur_dwell_target[0], cur_dwell_target[1])
|
|
|
|
|
|
|
|
|
|
self._render_muse_view(content_y, 0, content_h, w)
|
|
|
|
|
self._render_prompt_shelf(prompt_shelf_y, 0, prompt_shelf_h, w)
|
|
|
|
|
elif self.mode == "box":
|
|
|
|
|
if self.box_tab == 1:
|
|
|
|
|
self._render_fleet_table(content_y, 0, content_h, w)
|
|
|
|
|
elif self.box_tab == 2:
|
|
|
|
|
self._render_approvals_view(content_y, 0, content_h, w)
|
|
|
|
|
elif self.box_tab == 3:
|
|
|
|
|
self._render_jobs_view(content_y, 0, content_h, w)
|
|
|
|
|
elif self.box_tab == 4:
|
|
|
|
|
self._render_tmux_view(content_y, 0, content_h, w)
|
|
|
|
|
elif self.box_tab == 5:
|
|
|
|
|
self._render_dm_logs_view(content_y, 0, content_h, w)
|
|
|
|
|
|
|
|
|
|
# 4. Bottom Input Bar & Toast
|
|
|
|
|
self._render_bottom_bar(h - bottom_bar_h, 0, bottom_bar_h, w)
|
|
|
|
|
|
|
|
|
|
# 5. Overlays / Modals (if active)
|
|
|
|
|
if self.modal:
|
|
|
|
|
self._render_modal(h, w)
|
|
|
|
|
|
|
|
|
|
self.stdscr.refresh()
|
|
|
|
|
|
|
|
|
|
# Handle Input with Keystroke Draining / Batching to eliminate typing lag
|
|
|
|
|
ch = self.stdscr.getch()
|
|
|
|
|
if ch != -1:
|
|
|
|
|
while ch != -1:
|
|
|
|
|
if not self._handle_key(ch):
|
|
|
|
|
return
|
|
|
|
|
if getattr(self, "should_exit", False):
|
|
|
|
|
return
|
|
|
|
|
# If in INSERT mode or modal text input, drain all pending keystrokes before redrawing
|
|
|
|
|
if self.editor_mode == "INSERT" or (self.modal in ("agent_search", "new_sidechat", "work_order", "history_search", "prompt_formulate")):
|
|
|
|
|
self.stdscr.nodelay(True)
|
|
|
|
|
try:
|
|
|
|
|
ch = self.stdscr.getch()
|
|
|
|
|
finally:
|
|
|
|
|
self.stdscr.timeout(50)
|
|
|
|
|
else:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
pass
|
|
|
|
|
except Exception as e:
|
|
|
|
|
import traceback
|
|
|
|
|
crash_log = REPO_ROOT / "logs" / "tui-crash.log"
|
|
|
|
|
try:
|
|
|
|
|
crash_log.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with open(crash_log, "a", encoding="utf-8") as f:
|
|
|
|
|
f.write(f"\n--- CRASH AT {datetime.now().isoformat()} ---\n")
|
|
|
|
|
traceback.print_exc(file=f)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
raise
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
sys.stdout.write("\033[?1000l\033[?1002l\033[?1006l\033[?2004l")
|
|
|
|
|
sys.stdout.flush()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self.data.stop()
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
# Header & Tabs Rendering
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
def _render_header(self, y: int, x: int, w: int):
|
|
|
|
|
title = " MUSE TUI // NetVM Autonomous Agent Terminal " if self.mode == "muse" else " BOX TUI // NetVM Fleet Command Console "
|
|
|
|
|
self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("header"))
|
|
|
|
|
self.safe_addstr(self.stdscr, y, x + 1, title, self._attr("header"))
|
|
|
|
|
|
|
|
|
|
# Device Substrate banner, Rate-limit Cooldown, or Health & pending approvals badge
|
|
|
|
|
now = time.time()
|
|
|
|
|
active_cd = self.data.get_node_cooldown_remaining(self.data.active_node)
|
|
|
|
|
is_dev_layer = getattr(self, "device_layer_active", False) and now <= self.device_layer_timeout
|
|
|
|
|
if active_cd > 0:
|
|
|
|
|
cd_txt = f" ⏳ {self.data.active_node.upper()}: RATE-LIMIT COOLDOWN ({active_cd}s) "
|
|
|
|
|
self.safe_addstr(self.stdscr, y, max(0, w - len(cd_txt) - 22), cd_txt, self._attr("warn_banner"))
|
|
|
|
|
elif is_dev_layer:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
active_node = self.data.active_node.upper()
|
|
|
|
|
layer_txt = f" DEVICE LAYER: [{active_node}] (0-9: Select | Space: Cycle | Esc: Lock) "
|
|
|
|
|
self.safe_addstr(self.stdscr, y, max(0, w - len(layer_txt) - 22), layer_txt, self._attr("warn_banner"))
|
|
|
|
|
else:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
blocked_nodes = [n for n, s in self.data.node_status.items() if s.get("blocked")]
|
|
|
|
|
waiting_nodes = [n for n, s in self.data.node_status.items() if s.get("waiting")]
|
|
|
|
|
online_count = sum(1 for s in self.data.node_status.values() if s.get("status") == "ACTIVE")
|
|
|
|
|
total_nodes = len(self.data.nodes)
|
|
|
|
|
|
|
|
|
|
badge_txt = f" Fleet: {online_count}/{total_nodes} Up "
|
|
|
|
|
self.safe_addstr(self.stdscr, y, w - len(badge_txt) - 22, badge_txt, self._attr("selected"))
|
|
|
|
|
|
|
|
|
|
if blocked_nodes:
|
|
|
|
|
app_txt = f" ⚠ {len(blocked_nodes)} BLOCKED (Press 'a') "
|
|
|
|
|
self.safe_addstr(self.stdscr, y, w - len(app_txt) - len(badge_txt) - 24, app_txt, self._attr("warn_banner"))
|
|
|
|
|
elif waiting_nodes:
|
|
|
|
|
app_txt = f" {len(waiting_nodes)} WAITING (Press 'a') "
|
|
|
|
|
self.safe_addstr(self.stdscr, y, w - len(app_txt) - len(badge_txt) - 24, app_txt, self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
clock = datetime.now().strftime("%H:%M:%S")
|
|
|
|
|
self.safe_addstr(self.stdscr, y, w - len(clock) - 2, clock, self._attr("header"))
|
|
|
|
|
|
|
|
|
|
def _render_box_tabs(self, y: int, x: int, w: int):
|
|
|
|
|
tabs = [
|
|
|
|
|
"1: Agent Chat",
|
|
|
|
|
"2: Fleet Status",
|
|
|
|
|
"3: Approvals",
|
|
|
|
|
"4: Jobs Scheduler",
|
|
|
|
|
"5: Tmux / Swarms",
|
|
|
|
|
"6: DM Logs",
|
|
|
|
|
]
|
|
|
|
|
self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("dim"))
|
|
|
|
|
cur_x = x + 1
|
|
|
|
|
for idx, tab_name in enumerate(tabs):
|
|
|
|
|
is_active = (self.box_tab == idx)
|
|
|
|
|
attr = self._attr("selected") if is_active else self._attr("dim")
|
|
|
|
|
label = f" [{tab_name}] "
|
|
|
|
|
self.safe_addstr(self.stdscr, y, cur_x, label, attr)
|
|
|
|
|
cur_x += len(label) + 1
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
# Muse Conversational View (Split Sidebar + Transcript)
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
def _render_muse_view(self, y: int, x: int, h: int, w: int):
|
|
|
|
|
sidebar_w = min(36, max(26, w // 4))
|
|
|
|
|
chat_w = w - sidebar_w - 1
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
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"))
|
2026-10-06 07:55:27 +00:00
|
|
|
for line in range(h):
|
2026-10-06 19:29:22 +00:00
|
|
|
self.safe_addstr(self.stdscr, y + line, sidebar_w, div_char, div_attr)
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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)
|
2026-10-06 07:55:27 +00:00
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
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
|
2026-10-06 07:55:27 +00:00
|
|
|
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 ""
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
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)
|
2026-10-06 07:55:27 +00:00
|
|
|
search_btn = "[🔍 /]"
|
|
|
|
|
btn_x = max(x + len(header_title) + 2, x + w - len(search_btn) - 1)
|
2026-10-06 19:29:22 +00:00
|
|
|
self.safe_addstr(self.stdscr, y, btn_x, search_btn, self._attr("header") if is_fleet_focused else self._attr("warn_banner"))
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
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)
|
2026-10-06 19:29:22 +00:00
|
|
|
is_highlighted = (is_fleet_focused and idx == self.fleet_focus_idx)
|
2026-10-06 07:55:27 +00:00
|
|
|
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} "
|
2026-10-06 19:29:22 +00:00
|
|
|
attr = self._attr("selected") if is_highlighted else (self._attr("bold") if is_active else self._attr("normal"))
|
2026-10-06 07:55:27 +00:00
|
|
|
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}]"
|
2026-10-06 19:29:22 +00:00
|
|
|
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"))
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
# 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
|
2026-10-06 19:29:22 +00:00
|
|
|
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)
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
# 2. Threads / Sidechats Section
|
|
|
|
|
threads_header_y = divider_y + 1
|
2026-10-06 19:29:22 +00:00
|
|
|
hdr_attr = self._attr("header") if is_sidechats_focused else self._attr("bold")
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
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:
|
2026-10-06 19:29:22 +00:00
|
|
|
hdr_badge = "[n:new ⚡:menu r:sync]"
|
2026-10-06 07:55:27 +00:00
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
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))
|
2026-10-06 07:55:27 +00:00
|
|
|
btn_x = max(x + len(hdr_left) + 2, x + w - len(hdr_badge) - 1)
|
2026-10-06 19:29:22 +00:00
|
|
|
self.safe_addstr(self.stdscr, threads_header_y, btn_x, hdr_badge, self._attr("header") if is_sidechats_focused else self._attr("dim"))
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# Sync selection head to active thread if sidechats are not focused
|
2026-10-06 07:55:27 +00:00
|
|
|
cur_idx = None
|
|
|
|
|
for idx, t in enumerate(threads):
|
|
|
|
|
if t.get("session_id") == cur_thread_id:
|
|
|
|
|
cur_idx = idx
|
|
|
|
|
break
|
2026-10-06 19:29:22 +00:00
|
|
|
if cur_idx is not None and not is_sidechats_focused and self.thread_sel_idx != cur_idx:
|
2026-10-06 07:55:27 +00:00
|
|
|
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)
|
2026-10-06 19:29:22 +00:00
|
|
|
is_highlighted = (item_idx == self.thread_sel_idx and is_sidechats_focused)
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
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
|
2026-10-06 19:29:22 +00:00
|
|
|
if is_highlighted:
|
|
|
|
|
row_attr = self._attr("selected")
|
|
|
|
|
elif is_cur:
|
|
|
|
|
row_attr = self._attr("bold")
|
|
|
|
|
else:
|
|
|
|
|
row_attr = self._attr("normal")
|
2026-10-06 07:55:27 +00:00
|
|
|
self.safe_addstr(self.stdscr, item_line_y, x + 1, " " * (w - 2), row_attr)
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# Right elements: sid tag [xxxx] and action button [⚡]
|
2026-10-06 07:55:27 +00:00
|
|
|
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"))
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-10-06 07:55:27 +00:00
|
|
|
# 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), []))
|
2026-10-06 19:29:22 +00:00
|
|
|
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
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
# 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
|
2026-10-06 19:29:22 +00:00
|
|
|
is_focused = (self.focus_pane == "transcript" and self.editor_mode == "NORMAL")
|
2026-10-06 07:55:27 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
focus_pill = "[● FOCUSED] " if is_focused else ""
|
|
|
|
|
base_prefix = f" {focus_pill}Thread: ★ Main Chat " if is_main_chat else f" {focus_pill}Thread: "
|
2026-10-06 07:55:27 +00:00
|
|
|
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")
|
2026-10-06 19:29:22 +00:00
|
|
|
self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("header") if is_focused else self._attr("dim"))
|
2026-10-06 07:55:27 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# Place [📋 Copy All] and [⚡ Menu] immediately next to message total at top
|
2026-10-06 07:55:27 +00:00
|
|
|
if cur_x + len(copy_all_btn) <= x + w:
|
2026-10-06 19:29:22 +00:00
|
|
|
self.safe_addstr(self.stdscr, y, cur_x, copy_all_btn, self._attr("header") if is_focused else self._attr("button"))
|
2026-10-06 07:55:27 +00:00
|
|
|
self._btn_copy_all_bounds = (y, cur_x, cur_x + len(copy_all_btn))
|
2026-10-06 19:29:22 +00:00
|
|
|
cur_x += len(copy_all_btn) + 1
|
2026-10-06 07:55:27 +00:00
|
|
|
else:
|
|
|
|
|
self._btn_copy_all_bounds = None
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
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)
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
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]"
|
2026-10-06 19:29:22 +00:00
|
|
|
menu_btn = "[⚡ Menu]"
|
|
|
|
|
btns_str = f"{reply_btn} {copy_clean_btn} {copy_ctx_btn} {menu_btn}"
|
2026-10-06 07:55:27 +00:00
|
|
|
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 = []
|
2026-10-06 19:29:22 +00:00
|
|
|
self._msg_menu_buttons = []
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
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]"
|
2026-10-06 19:29:22 +00:00
|
|
|
ctx_rel_x = txt.find(ctx_str)
|
2026-10-06 07:55:27 +00:00
|
|
|
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", "")))
|
2026-10-06 19:29:22 +00:00
|
|
|
|
|
|
|
|
# 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))
|
2026-10-06 07:55:27 +00:00
|
|
|
else:
|
|
|
|
|
self.safe_addstr(self.stdscr, transcript_y + row, x + 2, " " * max(0, w - 4), self._attr("normal"))
|
|
|
|
|
|
|
|
|
|
# Visual vertical scrollbar track on rightmost margin
|
|
|
|
|
if total_lines > transcript_h:
|
|
|
|
|
track_h = transcript_h
|
|
|
|
|
thumb_pos = int((start_line / max(1, total_lines - transcript_h)) * (track_h - 1))
|
|
|
|
|
for row in range(track_h):
|
|
|
|
|
sb_char = "█" if row == thumb_pos else "│"
|
|
|
|
|
sb_attr = self._attr("selected") if row == thumb_pos else self._attr("dim")
|
|
|
|
|
self.safe_addstr(self.stdscr, transcript_y + row, x + w - 1, sb_char, sb_attr)
|
|
|
|
|
|
|
|
|
|
# Scroll indicator if not at bottom (shows unread lines based on scroll length / distance)
|
|
|
|
|
if not self.auto_scroll and self.transcript_scroll_offset > 0:
|
|
|
|
|
pct = int((start_line / max(1, max_offset)) * 100) if max_offset > 0 else 100
|
|
|
|
|
unread_str = f" | ● {self.transcript_scroll_offset} unread lines below" if self.transcript_scroll_offset > 0 else ""
|
|
|
|
|
scroll_pill = f" [↑ SCROLLED BACK ({self.transcript_scroll_offset}/{max_offset} lines | {pct}%{unread_str}) - Click or press 'G'/End to follow] "
|
|
|
|
|
self.safe_addstr(self.stdscr, y + h - 1, max(x + 2, x + w - len(scroll_pill) - 2), scroll_pill, self._attr("warn_banner"))
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
# Box Mode Views (Fleet, Approvals, Jobs, Tmux, Logs)
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
def _render_fleet_table(self, y: int, x: int, h: int, w: int):
|
|
|
|
|
self.safe_addstr(self.stdscr, y, x + 1, "NETVM FLEET VITALITY & CDP MATRIX", self._attr("bold"))
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 1, x + 1, " NODE STATUS LATENCY CDP PORT LAST CHAT ACTIVE PAGE / THREAD", self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 2, x + 1, "─" * (w - 2), self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
statuses = dict(self.data.node_status)
|
|
|
|
|
|
|
|
|
|
total_nodes = len(self.data.nodes)
|
|
|
|
|
if self.fleet_sel_idx >= total_nodes:
|
|
|
|
|
self.fleet_sel_idx = max(0, total_nodes - 1)
|
|
|
|
|
|
|
|
|
|
visible_rows = max(3, h - 5)
|
|
|
|
|
scroll_start = getattr(self, "table_scroll_idx", 0)
|
|
|
|
|
if self.fleet_sel_idx < scroll_start:
|
|
|
|
|
scroll_start = self.fleet_sel_idx
|
|
|
|
|
elif self.fleet_sel_idx >= scroll_start + visible_rows:
|
|
|
|
|
scroll_start = self.fleet_sel_idx - visible_rows + 1
|
|
|
|
|
max_scroll = max(0, total_nodes - visible_rows)
|
|
|
|
|
scroll_start = max(0, min(scroll_start, max_scroll))
|
|
|
|
|
self.table_scroll_idx = scroll_start
|
|
|
|
|
|
|
|
|
|
row_y = y + 3
|
|
|
|
|
for row_i in range(visible_rows):
|
|
|
|
|
idx = scroll_start + row_i
|
|
|
|
|
if idx >= total_nodes:
|
|
|
|
|
break
|
|
|
|
|
node = self.data.nodes[idx]
|
|
|
|
|
is_sel = (idx == self.fleet_sel_idx)
|
|
|
|
|
st = statuses.get(node, {})
|
|
|
|
|
status_text = st.get("status", "ACTIVE")
|
|
|
|
|
is_blocked = st.get("blocked", False)
|
|
|
|
|
if is_blocked:
|
|
|
|
|
status_text = "BLOCKED"
|
|
|
|
|
lat = f"{st.get('latency')}ms" if st.get("latency") is not None else "--"
|
|
|
|
|
port = 9410 + idx * 10
|
|
|
|
|
if node == "dev":
|
|
|
|
|
port = 9455
|
|
|
|
|
rec_str = format_recency(self.data.agent_interactions.get(node, 0.0))
|
|
|
|
|
tab_name = st.get("tab") or "-"
|
|
|
|
|
|
|
|
|
|
head = "▶ " if is_sel else " "
|
|
|
|
|
row_attr = self._attr("selected") if is_sel else self._attr("normal")
|
|
|
|
|
node_attr = row_attr if is_sel else self._attr("bold")
|
|
|
|
|
st_attr = row_attr if is_sel else (self._attr("danger") if is_blocked else (self._attr("success") if status_text == "ACTIVE" else self._attr("red")))
|
|
|
|
|
|
|
|
|
|
node_unread = self.data.get_node_unread_total(node)
|
|
|
|
|
node_tag = f"{node} [●{node_unread}]" if node_unread > 0 else node
|
|
|
|
|
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{node_tag:<8}"[:8], node_attr)
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 10, f"{status_text:<12}", st_attr)
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 23, f"{lat:<10}", row_attr if is_sel else self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 34, f":{port:<9}", row_attr if is_sel else self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 44, f"{rec_str:<12}", row_attr if is_sel else self._attr("warn_banner" if rec_str != "never" else "dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 57, f"{tab_name[:max(0, w - 74)]}", row_attr if is_sel else self._attr("normal"))
|
|
|
|
|
chat_btn = f"[▶ Chat +{node_unread}]" if node_unread > 0 else "[▶ Open Chat]"
|
|
|
|
|
btn_attr = self._attr("warn_banner") if (is_sel or node_unread > 0) else self._attr("dim")
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, max(x + 57, w - 17), chat_btn, btn_attr)
|
|
|
|
|
row_y += 1
|
|
|
|
|
|
|
|
|
|
hint_y = y + h - 1
|
|
|
|
|
sel_node = self.data.nodes[self.fleet_sel_idx].upper() if self.data.nodes else "-"
|
|
|
|
|
hints = f"Selected: [{sel_node}] [Click / Enter]: Open Chat [j/k]: Nav [f or /]: Fast Search [1-9/0]: Jump [r]: Refresh"
|
|
|
|
|
self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
def _render_approvals_view(self, y: int, x: int, h: int, w: int):
|
|
|
|
|
self.safe_addstr(self.stdscr, y, x + 1, "FLEET APPROVALS & PERMISSION GATES", self._attr("bold"))
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 1, x + 1, " NODE STATUS TARGET / PURPOSE FAST ACTIONS", self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 2, x + 1, "─" * (w - 2), self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = list(self.data.approvals_cache)
|
|
|
|
|
|
|
|
|
|
if not apps:
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 3, x + 2, "✔ All nodes CLEAR. No pending approvals or input waits.", self._attr("success"))
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self.approvals_sel_idx >= len(apps):
|
|
|
|
|
self.approvals_sel_idx = max(0, len(apps) - 1)
|
|
|
|
|
|
|
|
|
|
row_y = y + 3
|
|
|
|
|
for idx, it in enumerate(apps):
|
|
|
|
|
if row_y >= y + h - 2:
|
|
|
|
|
break
|
|
|
|
|
n = it.get("node", "-")
|
|
|
|
|
st = it.get("status", "CLEAR")
|
|
|
|
|
is_sel = (idx == self.approvals_sel_idx)
|
|
|
|
|
|
|
|
|
|
tgt = it.get("target") or it.get("purpose") or it.get("title") or "-"
|
|
|
|
|
if st == "INPUT_WAIT":
|
|
|
|
|
waits = it.get("input_waits") or []
|
|
|
|
|
if waits:
|
|
|
|
|
tgt = "; ".join(f"{w.get('task')}" for w in waits)
|
|
|
|
|
else:
|
|
|
|
|
tgt = "Subagent waiting for operator input"
|
|
|
|
|
|
|
|
|
|
st_attr = self._attr("danger") if st in ("PENDING", "INPUT_WAIT", "KEY_APPROVAL") else self._attr("success")
|
|
|
|
|
|
|
|
|
|
head = "▶ " if is_sel else " "
|
|
|
|
|
row_attr = self._attr("selected") if is_sel else self._attr("normal")
|
|
|
|
|
|
|
|
|
|
line_str = f"{head}{n:<6} {st:<12} {tgt[:w - 54]:<40}"
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 1, line_str, row_attr)
|
|
|
|
|
|
|
|
|
|
act_x = min(w - 38, max(x + 64, len(line_str) + 3))
|
|
|
|
|
if st == "PENDING":
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, act_x, "[1:Allow] [2:Always] [3:Deny]", self._attr("warn_banner") if is_sel else self._attr("dim"))
|
|
|
|
|
elif st == "INPUT_WAIT":
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, act_x, "[r:Proceed] [x:Dismiss] [Enter:Chat]", self._attr("wo_badge") if is_sel else self._attr("dim"))
|
|
|
|
|
else:
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, act_x, "[Clear]", self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
row_y += 1
|
|
|
|
|
|
|
|
|
|
hint_y = y + h - 1
|
|
|
|
|
sel_node = apps[self.approvals_sel_idx].get("node", "").upper() if apps else "-"
|
|
|
|
|
hints = f"Selected: [{sel_node}] [j/k]: Head [1/2/3]: Allow/Always/Deny [r]: Proceed [x]: Dismiss [c]: Clear All [Enter]: Chat"
|
|
|
|
|
self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
def _render_jobs_view(self, y: int, x: int, h: int, w: int):
|
|
|
|
|
self.safe_addstr(self.stdscr, y, x + 1, "SYSTEMD & BOX JOBS SCHEDULER", self._attr("bold"))
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 1, x + 1, " NAME AGENT SCHEDULE TIMEOUT DESCRIPTION", self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 2, x + 1, "─" * (w - 2), self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
jobs = list(self.data.jobs_cache)
|
|
|
|
|
|
|
|
|
|
if not jobs:
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 3, x + 2, "No jobs loaded from box-ctl.", self._attr("dim"))
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self.jobs_sel_idx >= len(jobs):
|
|
|
|
|
self.jobs_sel_idx = max(0, len(jobs) - 1)
|
|
|
|
|
|
|
|
|
|
visible_rows = max(1, h - 5)
|
|
|
|
|
# Dynamic viewport scroll so selected head is always in view as position changes
|
|
|
|
|
scroll_start = getattr(self, "jobs_scroll_start", 0)
|
|
|
|
|
if self.jobs_sel_idx < scroll_start:
|
|
|
|
|
scroll_start = self.jobs_sel_idx
|
|
|
|
|
elif self.jobs_sel_idx >= scroll_start + visible_rows:
|
|
|
|
|
scroll_start = self.jobs_sel_idx - visible_rows + 1
|
|
|
|
|
self.jobs_scroll_start = max(0, min(scroll_start, max(0, len(jobs) - visible_rows)))
|
|
|
|
|
|
|
|
|
|
pos_info = f" [{self.jobs_sel_idx + 1}/{len(jobs)}] "
|
|
|
|
|
self.safe_addstr(self.stdscr, y, w - len(pos_info) - 2, pos_info, self._attr("selected"))
|
|
|
|
|
|
|
|
|
|
for i in range(visible_rows):
|
|
|
|
|
idx = self.jobs_scroll_start + i
|
|
|
|
|
if idx >= len(jobs):
|
|
|
|
|
break
|
|
|
|
|
j = jobs[idx]
|
|
|
|
|
is_sel = (idx == self.jobs_sel_idx)
|
|
|
|
|
name = j.get("name", "")
|
|
|
|
|
agent = j.get("agent", "-")
|
|
|
|
|
sched = j.get("schedule", "-")
|
|
|
|
|
timeout = f"{j.get('timeout', '-')}s"
|
|
|
|
|
desc = j.get("description", "-") or "-"
|
|
|
|
|
|
|
|
|
|
head = "▶ " if is_sel else " "
|
|
|
|
|
row_attr = self._attr("selected") if is_sel else self._attr("normal")
|
|
|
|
|
row_y = y + 3 + i
|
|
|
|
|
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 1, f"{head}{name[:25]:<26}", row_attr)
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 30, f"{agent:<7}", row_attr if is_sel else self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 38, f"{sched[:19]:<20}", row_attr if is_sel else (self._attr("green") if sched != "manual" else self._attr("dim")))
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 59, f"{timeout:<9}", row_attr if is_sel else self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 69, f"{desc[:w - 95]}", row_attr if is_sel else self._attr("normal"))
|
|
|
|
|
|
|
|
|
|
# Interactive action pills: [▶ Chat] and [Run]
|
|
|
|
|
chat_btn = "[▶ Chat]"
|
|
|
|
|
run_btn = "[Run]"
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, max(x + 70, w - 21), chat_btn, self._attr("selected") if is_sel else self._attr("wo_badge"))
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, max(x + 80, w - 10), run_btn, self._attr("warn_banner") if is_sel else self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
# Scrollbar track
|
|
|
|
|
if len(jobs) > visible_rows:
|
|
|
|
|
track_h = visible_rows
|
|
|
|
|
thumb_pos = int((self.jobs_scroll_start / max(1, len(jobs) - visible_rows)) * (track_h - 1))
|
|
|
|
|
for r in range(track_h):
|
|
|
|
|
char = "█" if r == thumb_pos else "│"
|
|
|
|
|
attr = self._attr("selected") if r == thumb_pos else self._attr("dim")
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 3 + r, w - 1, char, attr)
|
|
|
|
|
|
|
|
|
|
hint_y = y + h - 1
|
|
|
|
|
sel_job = jobs[self.jobs_sel_idx].get("name", "") if jobs else "-"
|
|
|
|
|
hints = f"Selected: [{sel_job}] [Enter / Click]: Read/Chat in Agent 1 [r/t]: Trigger Job Now [s]: Status [j/k]: Scroll"
|
|
|
|
|
self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
def _render_tmux_view(self, y: int, x: int, h: int, w: int):
|
|
|
|
|
self.safe_addstr(self.stdscr, y, x + 1, "SHARED MUSE TMUX SESSIONS (/tmp/tmux-muse.sock)", self._attr("bold"))
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 1, x + 1, " SESSION NAME / STATUS", self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 2, x + 1, "─" * (w - 2), self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
sessions = list(self.data.tmux_cache)
|
|
|
|
|
|
|
|
|
|
if not sessions:
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 3, x + 2, "No active background tmux sessions found.", self._attr("dim"))
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self.tmux_sel_idx >= len(sessions):
|
|
|
|
|
self.tmux_sel_idx = max(0, len(sessions) - 1)
|
|
|
|
|
|
|
|
|
|
row_y = y + 3
|
|
|
|
|
for idx, s in enumerate(sessions[:h - 5]):
|
|
|
|
|
is_sel = (idx == self.tmux_sel_idx)
|
|
|
|
|
head = "▶ " if is_sel else " "
|
|
|
|
|
row_attr = self._attr("selected") if is_sel else self._attr("normal")
|
|
|
|
|
line = f"{head}{s}"
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 1, line[:w - 22], row_attr)
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, max(x + 30, w - 20), "[Attach]", self._attr("wo_badge") if is_sel else self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, max(x + 40, w - 10), "[Kill]", self._attr("danger") if is_sel else self._attr("dim"))
|
|
|
|
|
row_y += 1
|
|
|
|
|
|
|
|
|
|
hint_y = y + h - 1
|
|
|
|
|
sel_sess = sessions[self.tmux_sel_idx].split(":")[0].strip() if sessions else "-"
|
|
|
|
|
hints = f"Selected: [{sel_sess}] [j/k]: Head [Enter / a]: Attach Instructions [x / d]: Kill Session [r]: Refresh"
|
|
|
|
|
self.safe_addstr(self.stdscr, hint_y, x + 1, hints[:w - 2], self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
def _render_dm_logs_view(self, y: int, x: int, h: int, w: int):
|
|
|
|
|
self.safe_addstr(self.stdscr, y, x + 1, "LIVE DM LOG FEED (dm-log.jsonl)", self._attr("bold"))
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 1, x + 1, "─" * (w - 2), self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
logs = list(self.data.dm_logs_cache)
|
|
|
|
|
|
|
|
|
|
row_y = y + 2
|
|
|
|
|
for it in logs[:h - 4]:
|
|
|
|
|
l_type = it.get("type", "")
|
|
|
|
|
agent = it.get("agent", "")
|
|
|
|
|
to = it.get("to", "")
|
|
|
|
|
target = it.get("target", "")
|
|
|
|
|
ts = it.get("ts", "")[:19].replace("T", " ")
|
|
|
|
|
mid = it.get("id", "")
|
|
|
|
|
|
|
|
|
|
color = self._attr("success") if l_type == "verified" else (self._attr("user") if l_type == "sent" else self._attr("dim"))
|
|
|
|
|
line_str = f"[{ts}] {l_type:<9} {agent} -> {to} ({target}) [{mid}]"
|
|
|
|
|
self.safe_addstr(self.stdscr, row_y, x + 2, line_str, color)
|
|
|
|
|
row_y += 1
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
# Chat History Sends Search & Prompts Subsystem
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
def get_chat_sends(self, query: str = "") -> list:
|
|
|
|
|
"""Gather all operator/user sent messages across current session and history cache."""
|
|
|
|
|
sends = []
|
|
|
|
|
seen_texts = set()
|
|
|
|
|
|
|
|
|
|
# 1. From live session input_history (most recent first)
|
|
|
|
|
for txt in reversed(self.input_history):
|
|
|
|
|
txt_s = txt.strip()
|
|
|
|
|
if txt_s and not txt_s.startswith("/") and txt_s not in seen_texts:
|
|
|
|
|
seen_texts.add(txt_s)
|
|
|
|
|
sends.append({
|
|
|
|
|
"text": txt_s,
|
|
|
|
|
"node": self.data.active_node,
|
|
|
|
|
"thread_title": self.data.active_thread_title or "active",
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
"source": "session",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# 2. From cached message history across all nodes/threads
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
hist_items = list(self.data.history_cache.items())
|
|
|
|
|
|
|
|
|
|
for (node, tid), msgs in hist_items:
|
|
|
|
|
th_title = tid[:8] if tid else "thread"
|
|
|
|
|
cached_threads = self.data.threads_cache.get(node, [])
|
|
|
|
|
for t in cached_threads:
|
|
|
|
|
if t.get("session_id") == tid:
|
|
|
|
|
th_title = t.get("title") or th_title
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
for m in reversed(msgs):
|
|
|
|
|
if m.get("role") == "user":
|
|
|
|
|
text = (m.get("text") or "").strip()
|
|
|
|
|
if text and text not in seen_texts and not text.startswith("/"):
|
|
|
|
|
seen_texts.add(text)
|
|
|
|
|
ts = 0.0
|
|
|
|
|
if m.get("timestamp"):
|
|
|
|
|
try:
|
|
|
|
|
ts = datetime.fromisoformat(m["timestamp"]).timestamp()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
sends.append({
|
|
|
|
|
"text": text,
|
|
|
|
|
"node": node,
|
|
|
|
|
"thread_title": th_title,
|
|
|
|
|
"timestamp": ts,
|
|
|
|
|
"source": "history",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Sort sends by timestamp descending (most recent first)
|
|
|
|
|
sends.sort(key=lambda s: s.get("timestamp", 0.0), reverse=True)
|
|
|
|
|
|
|
|
|
|
# Check pinned status against prompt_manager
|
|
|
|
|
pinned_prompts_texts = {p["prompt"].strip() for p in self.prompt_manager.prompts if p.get("pinned")}
|
|
|
|
|
for s in sends:
|
|
|
|
|
s["is_pinned"] = s["text"] in pinned_prompts_texts
|
|
|
|
|
|
|
|
|
|
# Filter by query if provided
|
|
|
|
|
if query:
|
|
|
|
|
q = query.lower()
|
|
|
|
|
sends = [s for s in sends if q in s["text"].lower() or q in s["node"].lower() or q in s["thread_title"].lower()]
|
|
|
|
|
|
|
|
|
|
return sends
|
|
|
|
|
|
|
|
|
|
def _render_prompt_shelf(self, y: int, x: int, h: int, w: int):
|
|
|
|
|
"""Render compact persistent prompt shelf in View 1 (Agent Chat)."""
|
|
|
|
|
if h <= 0 or w <= 0:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._prompt_chip_bounds = []
|
|
|
|
|
self._prompt_shelf_y = y
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
prompts = list(self.prompts)
|
|
|
|
|
|
|
|
|
|
# Clear background rows
|
|
|
|
|
self.safe_addstr(self.stdscr, y, x, " " * w, self._attr("normal"))
|
|
|
|
|
if h > 1:
|
|
|
|
|
self.safe_addstr(self.stdscr, y + 1, x, " " * w, self._attr("normal"))
|
|
|
|
|
|
|
|
|
|
# Row 0: Header & Controls
|
|
|
|
|
hdr_lbl = " PROMPTS "
|
|
|
|
|
self.safe_addstr(self.stdscr, y, x + 1, hdr_lbl, self._attr("header"))
|
|
|
|
|
|
|
|
|
|
btn_x = x + 1 + len(hdr_lbl) + 1
|
|
|
|
|
exp_btn = "[P:Expand]"
|
|
|
|
|
self.safe_addstr(self.stdscr, y, btn_x, exp_btn, self._attr("warn_banner"))
|
|
|
|
|
self._btn_expand_bounds = (btn_x, btn_x + len(exp_btn))
|
|
|
|
|
|
|
|
|
|
sends_btn = " [🔍 Sends] "
|
|
|
|
|
sends_x = btn_x + len(exp_btn) + 1
|
|
|
|
|
self.safe_addstr(self.stdscr, y, sends_x, sends_btn, self._attr("selected"))
|
|
|
|
|
self._btn_sends_bounds = (sends_x, sends_x + len(sends_btn))
|
|
|
|
|
|
|
|
|
|
nav_btns = " [◄] [►] "
|
|
|
|
|
nav_x = sends_x + len(sends_btn) + 1
|
|
|
|
|
self.safe_addstr(self.stdscr, y, nav_x, nav_btns, self._attr("dim"))
|
|
|
|
|
self._btn_prev_bounds = (nav_x + 1, nav_x + 4)
|
|
|
|
|
self._btn_next_bounds = (nav_x + 5, nav_x + 8)
|
|
|
|
|
|
|
|
|
|
new_btn = "[+New]"
|
|
|
|
|
new_x = nav_x + len(nav_btns) + 1
|
|
|
|
|
self.safe_addstr(self.stdscr, y, new_x, new_btn, self._attr("dim"))
|
|
|
|
|
self._btn_new_bounds = (new_x, new_x + len(new_btn))
|
|
|
|
|
|
|
|
|
|
# Fill line with subtle divider
|
|
|
|
|
div_start = new_x + len(new_btn) + 1
|
|
|
|
|
if div_start < w:
|
|
|
|
|
self.safe_addstr(self.stdscr, y, div_start, "─" * (w - div_start), self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
# Row 1: Prompt chips
|
|
|
|
|
chip_y = y + 1 if h > 1 else y
|
|
|
|
|
cur_x = x + 1
|
|
|
|
|
|
|
|
|
|
if self.prompt_scroll_idx > 0:
|
|
|
|
|
self.safe_addstr(self.stdscr, chip_y, cur_x, "◀ ", self._attr("warn_banner"))
|
|
|
|
|
cur_x += 2
|
|
|
|
|
|
|
|
|
|
visible_prompts = prompts[self.prompt_scroll_idx:]
|
|
|
|
|
for idx_offset, p in enumerate(visible_prompts):
|
|
|
|
|
p_idx = self.prompt_scroll_idx + idx_offset + 1
|
|
|
|
|
is_pinned = p.get("pinned", False)
|
|
|
|
|
title = p.get("title", "Prompt")
|
|
|
|
|
pin_badge = "📌" if is_pinned else ""
|
|
|
|
|
chip_str = f"[{pin_badge}{p_idx}:{title}]"
|
|
|
|
|
|
|
|
|
|
if cur_x + len(chip_str) >= w - 4:
|
|
|
|
|
remaining_w = w - cur_x - 3
|
|
|
|
|
if remaining_w > 8:
|
|
|
|
|
chip_str = f"[{pin_badge}{p_idx}:{title[:remaining_w - 7]}…]"
|
|
|
|
|
attr = self._attr("wo_badge") if is_pinned else self._attr("normal")
|
|
|
|
|
self.safe_addstr(self.stdscr, chip_y, cur_x, chip_str, attr)
|
|
|
|
|
self._prompt_chip_bounds.append((cur_x, cur_x + len(chip_str), p))
|
|
|
|
|
cur_x += len(chip_str)
|
|
|
|
|
self.safe_addstr(self.stdscr, chip_y, w - 2, "▶", self._attr("warn_banner"))
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
attr = self._attr("wo_badge") if is_pinned else self._attr("normal")
|
|
|
|
|
self.safe_addstr(self.stdscr, chip_y, cur_x, chip_str, attr)
|
|
|
|
|
self._prompt_chip_bounds.append((cur_x, cur_x + len(chip_str), p))
|
|
|
|
|
cur_x += len(chip_str) + 1
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
# Bottom Input Bar & Toast
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
def _render_bottom_bar(self, y: int, x: int, h: int, w: int):
|
2026-10-06 19:29:22 +00:00
|
|
|
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"))
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
# 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":
|
2026-10-06 19:29:22 +00:00
|
|
|
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"
|
2026-10-06 07:55:27 +00:00
|
|
|
else:
|
|
|
|
|
nl_cnt = self.input_buf.count("\n")
|
|
|
|
|
if nl_cnt > 0:
|
2026-10-06 19:29:22 +00:00
|
|
|
hints = f" [● INSERT ACTIVE] Multi-line ({nl_cnt + 1} lines) [Enter] Send [Alt+Enter] Newline [Esc] Normal"
|
2026-10-06 07:55:27 +00:00
|
|
|
else:
|
2026-10-06 19:29:22 +00:00
|
|
|
hints = " [● INSERT ACTIVE] Type message [Enter] Send [Alt+Enter] Newline [Esc] Normal mode [Ctrl+W] Del word"
|
2026-10-06 07:55:27 +00:00
|
|
|
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:
|
2026-10-06 19:29:22 +00:00
|
|
|
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]"
|
2026-10-06 07:55:27 +00:00
|
|
|
self.safe_addstr(self.stdscr, prompt_y, inp_x, hint_text[:max_inp_w], self._attr("dim"))
|
|
|
|
|
if not self.modal:
|
|
|
|
|
try:
|
|
|
|
|
curses.curs_set(0)
|
|
|
|
|
except curses.error:
|
|
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
# INSERT mode
|
|
|
|
|
cursor = self.input_cursor
|
|
|
|
|
start_idx = 0
|
|
|
|
|
if cursor >= max_inp_w:
|
|
|
|
|
start_idx = cursor - max_inp_w + 1
|
|
|
|
|
disp_text = disp_buf[start_idx:start_idx + max_inp_w]
|
|
|
|
|
self.safe_addstr(self.stdscr, prompt_y, inp_x, disp_text, self._attr("normal"))
|
|
|
|
|
|
|
|
|
|
if not self.modal:
|
|
|
|
|
screen_cx = inp_x + (cursor - start_idx)
|
|
|
|
|
try:
|
|
|
|
|
curses.curs_set(1)
|
|
|
|
|
self.stdscr.move(prompt_y, min(screen_cx, w - 1))
|
|
|
|
|
except curses.error:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
# Interactive Modals (Help, Approvals, New Sidechat, Work Order)
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
|
|
|
def _render_modal(self, screen_h: int, screen_w: int):
|
|
|
|
|
if not self.modal:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
is_context_popup = self.modal in ("chat_context", "agent_context", "message_context")
|
|
|
|
|
if is_context_popup:
|
|
|
|
|
modal_w = min(56, screen_w - 4)
|
|
|
|
|
modal_h = min(17 if self.modal == "chat_context" else (14 if self.modal == "agent_context" else 11), screen_h - 2)
|
|
|
|
|
anchor_x, anchor_y = getattr(self, "context_popup_pos", (screen_w // 4, screen_h // 3))
|
|
|
|
|
top_y = max(1, min(anchor_y, screen_h - modal_h - 2))
|
|
|
|
|
left_x = max(1, min(anchor_x, screen_w - modal_w - 2))
|
|
|
|
|
self._context_popup_bounds = (left_x, top_y, modal_w, modal_h)
|
|
|
|
|
else:
|
|
|
|
|
modal_w = min(84 if self.modal in ("prompts", "history_search") else 74, screen_w - 6)
|
|
|
|
|
modal_h = min(22 if self.modal in ("prompts", "history_search") else 20, screen_h - 4)
|
|
|
|
|
top_y = (screen_h - modal_h) // 2
|
|
|
|
|
left_x = (screen_w - modal_w) // 2
|
|
|
|
|
self._context_popup_bounds = (left_x, top_y, modal_w, modal_h)
|
|
|
|
|
|
|
|
|
|
# Draw modal box
|
|
|
|
|
for row in range(modal_h):
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + row, left_x, " " * modal_w, self._attr("header"))
|
|
|
|
|
|
|
|
|
|
# Border
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y, left_x, "┌" + "─" * (modal_w - 2) + "┐", self._attr("dim"))
|
|
|
|
|
for row in range(1, modal_h - 1):
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + row, left_x, "│", self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + row, left_x + modal_w - 1, "│", self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + row, left_x + 1, " " * (modal_w - 2), self._attr("normal"))
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + modal_h - 1, left_x, "└" + "─" * (modal_w - 2) + "┘", self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
# Title
|
|
|
|
|
title_map = {
|
|
|
|
|
"help": " KEYBINDINGS & NAVIGATION CHEAT-SHEET ",
|
|
|
|
|
"approvals": " PENDING APPROVALS RESOLUTION CENTER ",
|
|
|
|
|
"new_sidechat": " CREATE NEW SIDECHAT ",
|
|
|
|
|
"rename_sidechat": " RENAME SIDECHAT ",
|
|
|
|
|
"work_order": " ISSUE OPERATOR WORK ORDER ([WO]) ",
|
|
|
|
|
"agent_search": " FAST AGENT SEARCH & SELECT ",
|
|
|
|
|
"prompts": " PROMPT & SKILL LIBRARY // VIM HJKL NAVIGATION ",
|
|
|
|
|
"history_search": " SEARCH CHAT SENDS // PIN TO PROMPT FILL ",
|
|
|
|
|
"prompt_formulate": " FORMULATE NEW PROMPT SKILL ",
|
|
|
|
|
"chat_context": " CHAT ACTIONS ",
|
|
|
|
|
"agent_context": " AGENT ACTIONS ",
|
|
|
|
|
"message_context": " MESSAGE ACTIONS ",
|
|
|
|
|
}
|
|
|
|
|
title = title_map.get(self.modal, " DIALOG ")
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y, left_x + 3, title, self._attr("header"))
|
|
|
|
|
|
|
|
|
|
# Modal Content
|
|
|
|
|
if self.modal == "help":
|
|
|
|
|
help_lines = [
|
|
|
|
|
("[NORMAL MODE]", "Vim-style modal lock: prompt is locked against accidental input"),
|
|
|
|
|
("[i] or [a]", "Enter INSERT mode (focus bottom prompt to type chat/command)"),
|
|
|
|
|
("[v] / [[]", "Tmux/Vi Visual Copy Mode: j/k to select lines, y to copy"),
|
|
|
|
|
("[Shift+Drag]", "Terminal-native text selection bypass (copy with Ctrl+Shift+C)"),
|
|
|
|
|
("[Mouse Drag]", "Click & drag on transcript to highlight and auto-copy lines"),
|
|
|
|
|
("[f] or [/]", "Fast search & select fleet agent modal (type/click to jump)"),
|
|
|
|
|
("[Tab] / [S-Tab]", "Cycle & instantly load next / prev conversation thread"),
|
|
|
|
|
("[h] / [l]", "Switch active focus between Sidebar and Transcript"),
|
|
|
|
|
("[j] / [k] / [↑/↓]", "Navigate threads in sidebar or scroll chat transcript"),
|
|
|
|
|
("[Enter]", "Open selected thread (Sidebar) or enter Insert mode (Transcript)"),
|
|
|
|
|
("[1] - [9] / [0]", "Instantly switch active agent node (in NORMAL mode)"),
|
|
|
|
|
("[Alt+Space]", "Device/Agent modifier substrate: 0-9 jumps, Space cycles"),
|
|
|
|
|
("[n]", "Create new sidechat session on active node"),
|
|
|
|
|
("[y] / [Y]", "Copy latest message ([y]) or full transcript ([Y]) to clipboard"),
|
|
|
|
|
("[b]", "Toggle clean line-by-line vs classic boxed frame view"),
|
|
|
|
|
("[R-Click / x]", "Open Context Menu for chat thread, fleet agent, or message"),
|
|
|
|
|
("[p] or [/pin]", "Pin/unpin thread (sidebar) or pin chat send to prompts (chat)"),
|
|
|
|
|
("[s] / [Ctrl-R]", "Search chat sends history & pin to prompt shelf"),
|
|
|
|
|
("[P] / [/prompt]", "Open expanded Prompt & Skills library modal"),
|
|
|
|
|
("[w] or [/wo]", "Compose and cryptographically sign a Work Order"),
|
|
|
|
|
("[a] or [F2]", "Open Approvals Resolution Drawer (Allow, Always, Deny)"),
|
|
|
|
|
("[F5] or [m]", "Toggle between Muse Chat TUI and Box Fleet Command TUI"),
|
|
|
|
|
("[g] / [G] / [Home/End]", "Jump to oldest message / follow live latest message"),
|
|
|
|
|
("[q]", "Quit TUI (in NORMAL mode)"),
|
|
|
|
|
]
|
|
|
|
|
for idx, (k, desc) in enumerate(help_lines):
|
|
|
|
|
if idx + 2 >= modal_h - 1:
|
|
|
|
|
break
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + 2 + idx, left_x + 3, f"{k:<14}", self._attr("bold"))
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + 2 + idx, left_x + 18, desc[:modal_w - 21], self._attr("normal"))
|
|
|
|
|
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "Press [Esc] or [Enter] to close", self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
elif self.modal == "approvals":
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = [it for it in self.data.approvals_cache if it.get("status") in ("PENDING", "INPUT_WAIT", "KEY_APPROVAL")]
|
|
|
|
|
|
|
|
|
|
if not apps:
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + 3, left_x + 4, "✔ No pending approvals or input waits across fleet!", self._attr("success"))
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + 5, left_x + 4, "Press [Esc] to return to chat.", self._attr("dim"))
|
|
|
|
|
else:
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, "Pending approvals & blocked tasks requiring operator review (j/k to select):", self._attr("dim"))
|
|
|
|
|
self.approvals_sel_idx = max(0, min(getattr(self, "approvals_sel_idx", 0), len(apps) - 1))
|
|
|
|
|
sel_app = apps[self.approvals_sel_idx]
|
|
|
|
|
sel_node = sel_app.get("node", self.data.active_node)
|
|
|
|
|
|
|
|
|
|
max_rows = max(1, modal_h - 9)
|
|
|
|
|
start_row = 0
|
|
|
|
|
if self.approvals_sel_idx >= max_rows:
|
|
|
|
|
start_row = self.approvals_sel_idx - max_rows + 1
|
|
|
|
|
|
|
|
|
|
for idx, it in enumerate(apps[start_row:start_row + max_rows]):
|
|
|
|
|
real_idx = start_row + idx
|
|
|
|
|
n = it.get("node")
|
|
|
|
|
st = it.get("status")
|
|
|
|
|
tgt = it.get("target") or it.get("purpose") or ""
|
|
|
|
|
if not tgt or tgt == "No pending approvals":
|
|
|
|
|
waits = it.get("input_waits") or []
|
|
|
|
|
if waits:
|
|
|
|
|
tgt = waits[0].get("description") or waits[0].get("task") or "Operator input requested"
|
|
|
|
|
else:
|
|
|
|
|
tgt = it.get("title") or "Task intervention requested"
|
|
|
|
|
bg_count = it.get("bg_tasks_count", 0)
|
|
|
|
|
bg_tag = f" [{bg_count} review]" if bg_count else ""
|
|
|
|
|
|
|
|
|
|
is_sel = (real_idx == self.approvals_sel_idx)
|
|
|
|
|
cursor = "▶ " if is_sel else " "
|
|
|
|
|
line = f"{cursor}[{n.upper()}] {st}{bg_tag}: {tgt[:modal_w - 28]}"
|
|
|
|
|
attr = self._attr("selected") if is_sel else self._attr("danger")
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + 4 + idx, left_x + 3, line, attr)
|
|
|
|
|
|
|
|
|
|
# Detail box for selected approval
|
|
|
|
|
det_y = top_y + modal_h - 5
|
|
|
|
|
trust = "TRUSTED" if sel_app.get("is_trusted") else "UNTRUSTED / HUMAN REVIEW"
|
|
|
|
|
full_title = sel_app.get("title") or sel_app.get("purpose") or ""
|
|
|
|
|
self.safe_addstr(self.stdscr, det_y, left_x + 3, f"Selected: [{sel_node.upper()}] — {trust}: {full_title[:modal_w - len(sel_node) - len(trust) - 20]}", self._attr("bold"))
|
|
|
|
|
|
|
|
|
|
act_y = top_y + modal_h - 3
|
|
|
|
|
self.safe_addstr(self.stdscr, act_y, left_x + 3, f"Actions for [{sel_node.upper()}]:", self._attr("bold"))
|
|
|
|
|
self.safe_addstr(self.stdscr, act_y + 1, left_x + 3, "[1] Allow [2] Always [3] Deny [R] Proceed [X] Dismiss [P] Auto-Fleet [C] Clear All", self._attr("selected"))
|
|
|
|
|
|
|
|
|
|
elif self.modal == "new_sidechat":
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Agent: {self.data.active_node.upper()} (Sidechat-first policy)", self._attr("bold"))
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, "Enter sidechat title / purpose:", self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
# Input field
|
|
|
|
|
field_y = top_y + 5
|
|
|
|
|
field_w = modal_w - 6
|
|
|
|
|
self.safe_addstr(self.stdscr, field_y, left_x + 3, "┌" + "─" * field_w + "┐", self._attr("dim"))
|
|
|
|
|
self.safe_addstr(self.stdscr, field_y + 1, left_x + 3, "│ " + self.modal_input_buf.ljust(field_w - 2) + " │", self._attr("normal"))
|
|
|
|
|
self.safe_addstr(self.stdscr, field_y + 2, left_x + 3, "└" + "─" * field_w + "┘", self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + modal_h - 2, left_x + 3, "[Enter] Create Sidechat [Esc] Cancel", self._attr("dim"))
|
|
|
|
|
|
|
|
|
|
# Move cursor to modal input
|
|
|
|
|
try:
|
|
|
|
|
self.stdscr.move(field_y + 1, left_x + 5 + self.modal_input_cursor)
|
|
|
|
|
except curses.error:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
elif self.modal == "work_order":
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + 2, left_x + 3, f"Target: {self.data.active_node.upper()} ({self.data.active_thread_title})", self._attr("bold"))
|
|
|
|
|
self.safe_addstr(self.stdscr, top_y + 4, left_x + 3, "Format: <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:
|
2026-10-06 19:29:22 +00:00
|
|
|
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) == '~':
|
2026-10-06 07:55:27 +00:00
|
|
|
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
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-10-06 07:55:27 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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"):
|
2026-10-06 07:55:27 +00:00
|
|
|
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"
|
2026-10-06 19:29:22 +00:00
|
|
|
self._modal_open_time = time.time()
|
2026-10-06 07:55:27 +00:00
|
|
|
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"
|
2026-10-06 19:29:22 +00:00
|
|
|
self._modal_open_time = time.time()
|
2026-10-06 07:55:27 +00:00
|
|
|
r_role = m.get("role", "agent").upper()
|
|
|
|
|
self.set_toast(f"Message Context Menu ({r_role})", "info")
|
|
|
|
|
return True
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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('/')):
|
2026-10-06 07:55:27 +00:00
|
|
|
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):
|
2026-10-06 19:29:22 +00:00
|
|
|
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"):
|
2026-10-06 07:55:27 +00:00
|
|
|
# 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')):
|
2026-10-06 19:29:22 +00:00
|
|
|
if self.focus_pane in ("sidechats", "sidebar"):
|
2026-10-06 07:55:27 +00:00
|
|
|
with self.data.lock:
|
|
|
|
|
threads = list(self.data.threads_cache.get(self.data.active_node, []))
|
|
|
|
|
target_idx = self.thread_sel_idx
|
|
|
|
|
if threads and 0 <= target_idx < len(threads):
|
|
|
|
|
t = threads[target_idx]
|
|
|
|
|
sid = t.get("session_id")
|
|
|
|
|
if sid:
|
|
|
|
|
new_pin = self.data.toggle_pin_thread(self.data.active_node, sid)
|
|
|
|
|
state = "Pinned" if new_pin else "Unpinned"
|
|
|
|
|
title = t.get("title") or sid[:8]
|
|
|
|
|
self.set_toast(f"📌 {state} chat: {title[:25]}", "success")
|
|
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
if ch == ord('P'):
|
|
|
|
|
self.modal = "prompts"
|
|
|
|
|
self.prompt_sel_idx = 0
|
|
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
# 'p': Pin latest chat sent from active thread (or recent sends) to prompt fill / list
|
|
|
|
|
cur_node = self.data.active_node
|
|
|
|
|
cur_tid = self.data.active_thread_id
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
thread_msgs = list(self.data.history_cache.get((cur_node, cur_tid), []))
|
|
|
|
|
user_thread_msgs = [m for m in thread_msgs if m.get("role") == "user" and (m.get("text") or "").strip()]
|
|
|
|
|
|
|
|
|
|
if user_thread_msgs:
|
|
|
|
|
target_text = user_thread_msgs[-1]["text"].strip()
|
|
|
|
|
target_send = {"text": target_text, "node": cur_node}
|
|
|
|
|
else:
|
|
|
|
|
sends = self.get_chat_sends()
|
|
|
|
|
node_sends = [s for s in sends if s.get("node") == cur_node]
|
|
|
|
|
target_send = node_sends[0] if node_sends else (sends[0] if sends else None)
|
|
|
|
|
|
|
|
|
|
if target_send:
|
|
|
|
|
p, is_new = self.prompt_manager.pin_chat_send(target_send["text"], node=target_send.get("node", cur_node), toggle=True)
|
|
|
|
|
self.prompts = self.prompt_manager.get_sorted_prompts()
|
|
|
|
|
if p.get("pinned"):
|
|
|
|
|
state = "Pinned to" if is_new else "Pinned"
|
|
|
|
|
self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"📌 Unpinned from prompt fill: '{p['title']}'", "warn_banner")
|
|
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
self.modal = "prompts"
|
|
|
|
|
self.prompt_sel_idx = 0
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Search Chat Sends: 's' or 'S' or Ctrl-R (18)
|
|
|
|
|
if ch in (ord('s'), ord('S'), 18):
|
|
|
|
|
self.modal = "history_search"
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
self.history_search_sel = 0
|
|
|
|
|
self.history_search_scroll = 0
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Scroll compact prompt shelf left/right: '[' and ']'
|
|
|
|
|
if ch == ord('['):
|
|
|
|
|
self.prompt_scroll_idx = max(0, self.prompt_scroll_idx - 1)
|
|
|
|
|
return True
|
|
|
|
|
if ch == ord(']'):
|
|
|
|
|
self.prompt_scroll_idx = min(max(0, len(self.prompts) - 1), self.prompt_scroll_idx + 1)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Copy to clipboard: 'y' for active/latest message, 'Y' for full transcript
|
|
|
|
|
if ch == ord('y'):
|
|
|
|
|
self.copy_message_to_clipboard()
|
|
|
|
|
return True
|
|
|
|
|
if ch == ord('Y'):
|
|
|
|
|
self.copy_transcript_to_clipboard()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Help modal
|
|
|
|
|
if ch in (ord('?'), curses.KEY_F1):
|
|
|
|
|
self.modal = "help"
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Approvals modal
|
|
|
|
|
if ch in (ord('a'), ord('A'), curses.KEY_F2, curses.KEY_F3):
|
|
|
|
|
if self.mode == "box" and self.box_tab == 4:
|
|
|
|
|
# On Tmux tab, 'a' shows attach instructions
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
sessions = list(self.data.tmux_cache)
|
|
|
|
|
if sessions and 0 <= self.tmux_sel_idx < len(sessions):
|
|
|
|
|
sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip()
|
|
|
|
|
self.set_toast(f"Attach: tmux -S /tmp/tmux-muse.sock attach -t {sess_name}", "info")
|
|
|
|
|
return True
|
|
|
|
|
self.modal = "approvals"
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# New sidechat modal
|
|
|
|
|
if ch in (ord('n'), ord('N')):
|
|
|
|
|
self.modal = "new_sidechat"
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Work Order modal
|
|
|
|
|
if ch in (ord('w'), ord('W')):
|
|
|
|
|
self.modal = "work_order"
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Mode switch (Muse vs Box)
|
|
|
|
|
if ch in (ord('m'), ord('M'), curses.KEY_F5):
|
|
|
|
|
self.mode = "box" if self.mode == "muse" else "muse"
|
|
|
|
|
self.set_toast(f"Switched mode to {self.mode.upper()}", "info")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Box mode Fast Actions: Tab 2 (Approvals)
|
|
|
|
|
if self.mode == "box" and self.box_tab == 2:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = list(self.data.approvals_cache)
|
|
|
|
|
cur_node = apps[self.approvals_sel_idx].get("node") if apps and 0 <= self.approvals_sel_idx < len(apps) else self.data.active_node
|
|
|
|
|
|
|
|
|
|
if ch == ord('1'):
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("allow", cur_node), daemon=True).start()
|
|
|
|
|
return True
|
|
|
|
|
elif ch == ord('2'):
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("always", cur_node), daemon=True).start()
|
|
|
|
|
return True
|
|
|
|
|
elif ch == ord('3'):
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("deny", cur_node), daemon=True).start()
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('r'), ord('R')):
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("reply", cur_node, "Proceed with standard execution."), daemon=True).start()
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('x'), ord('X')):
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("dismiss", cur_node), daemon=True).start()
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('c'), ord('C')):
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Box mode Fast Actions: Tab 3 (Jobs)
|
|
|
|
|
if self.mode == "box" and self.box_tab == 3:
|
|
|
|
|
if ch in (ord('r'), ord('R'), ord('t'), ord('T')):
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
jobs = list(self.data.jobs_cache)
|
|
|
|
|
if jobs and 0 <= self.jobs_sel_idx < len(jobs):
|
|
|
|
|
jname = jobs[self.jobs_sel_idx].get("name")
|
|
|
|
|
threading.Thread(target=self._async_trigger_job, args=(jname,), daemon=True).start()
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('s'), ord('S')):
|
|
|
|
|
threading.Thread(target=self.data._fetch_jobs, daemon=True).start()
|
|
|
|
|
self.set_toast("Refreshed jobs scheduler list.", "info")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Box mode Fast Actions: Tab 4 (Tmux)
|
|
|
|
|
if self.mode == "box" and self.box_tab == 4:
|
|
|
|
|
if ch in (ord('x'), ord('X'), ord('d'), ord('D'), ord('K')):
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
sessions = list(self.data.tmux_cache)
|
|
|
|
|
if sessions and 0 <= self.tmux_sel_idx < len(sessions):
|
|
|
|
|
sess_name = sessions[self.tmux_sel_idx].split(":")[0].strip()
|
|
|
|
|
threading.Thread(target=self._async_kill_tmux, args=(sess_name,), daemon=True).start()
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('r'), ord('R')):
|
|
|
|
|
threading.Thread(target=self.data._fetch_tmux, daemon=True).start()
|
|
|
|
|
self.set_toast("Refreshed tmux background sessions.", "info")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Reply to latest message: 'R' (when on transcript in normal mode)
|
|
|
|
|
if ch == ord('R') and self.focus_pane == "transcript":
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
node = self.data.active_node
|
|
|
|
|
tid = self.data.active_thread_id
|
|
|
|
|
msgs = self.data.transcript_cache.get(f"{node}/{tid}", [])
|
|
|
|
|
if msgs:
|
|
|
|
|
self.trigger_reply_to_message(msgs[-1])
|
|
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast("⚠️ No messages to reply to", "warn")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Global Refresh / Sync (with two-tap force override during cooldown)
|
|
|
|
|
if ch in (ord('r'), ord('R')):
|
|
|
|
|
node = self.data.active_node
|
|
|
|
|
cd = self.data.get_node_cooldown_remaining(node)
|
|
|
|
|
if cd > 0:
|
|
|
|
|
now = time.time()
|
|
|
|
|
last_force = getattr(self, "_last_force_sync_time", 0.0)
|
|
|
|
|
if (now - last_force) <= 2.0:
|
|
|
|
|
self.data.clear_node_rate_limit(node)
|
|
|
|
|
self.data.lazy_fetch_threads(node, force=True)
|
|
|
|
|
self.data.trigger_refresh()
|
|
|
|
|
self.set_toast(f"⚡ Force-syncing {node.upper()} (cooldown bypassed)...", "info")
|
|
|
|
|
self._last_force_sync_time = 0.0
|
|
|
|
|
else:
|
|
|
|
|
self._last_force_sync_time = now
|
|
|
|
|
self.set_toast(f"⏳ {node.upper()} cooling down ({cd}s left). Press 'r' again within 2s to force.", "warn")
|
|
|
|
|
return True
|
|
|
|
|
self.data.lazy_fetch_threads(node, force=True)
|
|
|
|
|
self.data.trigger_refresh()
|
|
|
|
|
self.set_toast(f"Syncing threads for {node.upper()}...", "info")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Node Jump: '1' - '9' and '0' (when in muse mode)
|
|
|
|
|
if self.mode == "muse" and (ord('1') <= ch <= ord('9') or ch == ord('0')):
|
|
|
|
|
idx = (ch - ord('1')) if ch != ord('0') else 9
|
|
|
|
|
if idx < len(self.data.nodes):
|
|
|
|
|
node = self.data.nodes[idx]
|
|
|
|
|
self.data.set_active_target(node)
|
|
|
|
|
self.thread_sel_idx = 0
|
|
|
|
|
self.set_toast(f"Switched to agent: {node.upper()}", "success")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Box mode tab selection: '1' - '6' (when in box mode, except 1-3 on Tab 2)
|
|
|
|
|
if self.mode == "box":
|
|
|
|
|
if self.box_tab == 2:
|
|
|
|
|
if ord('4') <= ch <= ord('6'):
|
|
|
|
|
self.box_tab = ch - ord('1')
|
|
|
|
|
return True
|
|
|
|
|
elif ord('1') <= ch <= ord('6'):
|
|
|
|
|
self.box_tab = ch - ord('1')
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Box mode tab navigation (when not in Chat tab 0): '[' / ']' / Tab / Shift-Tab
|
|
|
|
|
if self.mode == "box" and self.box_tab != 0:
|
|
|
|
|
if ch in (ord('['), curses.KEY_LEFT):
|
|
|
|
|
self.box_tab = (self.box_tab - 1) % 6
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord(']'), curses.KEY_RIGHT):
|
|
|
|
|
self.box_tab = (self.box_tab + 1) % 6
|
|
|
|
|
return True
|
|
|
|
|
elif ch == ord('\t'):
|
|
|
|
|
self.box_tab = (self.box_tab + 1) % 6
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_BTAB:
|
|
|
|
|
self.box_tab = (self.box_tab - 1) % 6
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Muse View / Chat Tab: Direct Conversation Cycling & Pane Switching
|
|
|
|
|
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
|
|
|
|
|
if ch == ord('\t'):
|
|
|
|
|
# Tab: Cycle to next conversation thread and load immediately
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
threads = list(self.data.threads_cache.get(self.data.active_node, []))
|
|
|
|
|
if threads:
|
|
|
|
|
self.thread_sel_idx = (self.thread_sel_idx + 1) % len(threads)
|
|
|
|
|
t = threads[self.thread_sel_idx]
|
|
|
|
|
sid = t.get("session_id")
|
|
|
|
|
title = t.get("title") or sid[:8]
|
|
|
|
|
self.data.set_active_target(self.data.active_node, sid, title)
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
self.set_toast(f"Thread: {title} ({self.thread_sel_idx + 1}/{len(threads)})", "info")
|
|
|
|
|
else:
|
|
|
|
|
if self.data.is_loading_threads(self.data.active_node):
|
|
|
|
|
self.set_toast(f"Threads are currently loading for {self.data.active_node.upper()}...", "info")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast("No threads to cycle. Press 'n' to create one.", "info")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif ch == curses.KEY_BTAB:
|
|
|
|
|
# Shift-Tab: Cycle to previous conversation thread
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
threads = list(self.data.threads_cache.get(self.data.active_node, []))
|
|
|
|
|
if threads:
|
|
|
|
|
self.thread_sel_idx = (self.thread_sel_idx - 1) % len(threads)
|
|
|
|
|
t = threads[self.thread_sel_idx]
|
|
|
|
|
sid = t.get("session_id")
|
|
|
|
|
title = t.get("title") or sid[:8]
|
|
|
|
|
self.data.set_active_target(self.data.active_node, sid, title)
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
self.set_toast(f"Thread: {title} ({self.thread_sel_idx + 1}/{len(threads)})", "info")
|
|
|
|
|
else:
|
|
|
|
|
if self.data.is_loading_threads(self.data.active_node):
|
|
|
|
|
self.set_toast(f"Threads are currently loading for {self.data.active_node.upper()}...", "info")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast("No threads to cycle. Press 'n' to create one.", "info")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif ch in (ord('h'), curses.KEY_LEFT):
|
2026-10-06 19:29:22 +00:00
|
|
|
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"
|
2026-10-06 07:55:27 +00:00
|
|
|
return True
|
|
|
|
|
elif ch in (ord('l'), curses.KEY_RIGHT):
|
2026-10-06 19:29:22 +00:00
|
|
|
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"
|
2026-10-06 07:55:27 +00:00
|
|
|
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):
|
2026-10-06 19:29:22 +00:00
|
|
|
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"):
|
2026-10-06 07:55:27 +00:00
|
|
|
self.thread_sel_idx = max(0, self.thread_sel_idx - 1)
|
|
|
|
|
else:
|
|
|
|
|
self.auto_scroll = False
|
|
|
|
|
max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", 20))
|
|
|
|
|
self.transcript_scroll_offset = min(max_scroll, self.transcript_scroll_offset + 3)
|
|
|
|
|
elif self.mode == "box":
|
|
|
|
|
if self.box_tab == 1:
|
|
|
|
|
self.fleet_sel_idx = max(0, self.fleet_sel_idx - 1)
|
|
|
|
|
elif self.box_tab == 2:
|
|
|
|
|
self.approvals_sel_idx = max(0, self.approvals_sel_idx - 1)
|
|
|
|
|
elif self.box_tab == 3:
|
|
|
|
|
self.jobs_sel_idx = max(0, self.jobs_sel_idx - 1)
|
|
|
|
|
elif self.box_tab == 4:
|
|
|
|
|
self.tmux_sel_idx = max(0, self.tmux_sel_idx - 1)
|
|
|
|
|
else:
|
|
|
|
|
self.table_scroll_idx = max(0, self.table_scroll_idx - 1)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif ch in (curses.KEY_DOWN, ord('j')):
|
|
|
|
|
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
|
2026-10-06 19:29:22 +00:00
|
|
|
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"):
|
2026-10-06 07:55:27 +00:00
|
|
|
with self.data.lock:
|
|
|
|
|
threads = list(self.data.threads_cache.get(self.data.active_node, []))
|
|
|
|
|
if threads:
|
|
|
|
|
self.thread_sel_idx = min(len(threads) - 1, self.thread_sel_idx + 1)
|
|
|
|
|
else:
|
|
|
|
|
if self.transcript_scroll_offset > 0:
|
|
|
|
|
self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - 3)
|
|
|
|
|
if self.transcript_scroll_offset == 0:
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
elif self.mode == "box":
|
|
|
|
|
if self.box_tab == 1:
|
|
|
|
|
self.fleet_sel_idx = min(len(self.data.nodes) - 1, self.fleet_sel_idx + 1)
|
|
|
|
|
elif self.box_tab == 2:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = list(self.data.approvals_cache)
|
|
|
|
|
if apps:
|
|
|
|
|
self.approvals_sel_idx = min(len(apps) - 1, self.approvals_sel_idx + 1)
|
|
|
|
|
elif self.box_tab == 3:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
jobs = list(self.data.jobs_cache)
|
|
|
|
|
if jobs:
|
|
|
|
|
self.jobs_sel_idx = min(len(jobs) - 1, self.jobs_sel_idx + 1)
|
|
|
|
|
elif self.box_tab == 4:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
sessions = list(self.data.tmux_cache)
|
|
|
|
|
if sessions:
|
|
|
|
|
self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 1)
|
|
|
|
|
else:
|
|
|
|
|
self.table_scroll_idx += 1
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# PageUp / PageDown / Ctrl+U / Ctrl+D
|
|
|
|
|
if ch in (curses.KEY_PPAGE, 21): # PageUp or Ctrl+U
|
|
|
|
|
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
|
|
|
|
|
self.auto_scroll = False
|
|
|
|
|
max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", 20))
|
|
|
|
|
step = max(5, getattr(self, "transcript_height", 20) - 2)
|
|
|
|
|
self.transcript_scroll_offset = min(max_scroll, self.transcript_scroll_offset + step)
|
|
|
|
|
elif self.mode == "box":
|
|
|
|
|
if self.box_tab == 1:
|
|
|
|
|
self.fleet_sel_idx = max(0, self.fleet_sel_idx - 5)
|
|
|
|
|
elif self.box_tab == 2:
|
|
|
|
|
self.approvals_sel_idx = max(0, self.approvals_sel_idx - 5)
|
|
|
|
|
elif self.box_tab == 3:
|
|
|
|
|
self.jobs_sel_idx = max(0, self.jobs_sel_idx - 5)
|
|
|
|
|
elif self.box_tab == 4:
|
|
|
|
|
self.tmux_sel_idx = max(0, self.tmux_sel_idx - 5)
|
|
|
|
|
else:
|
|
|
|
|
self.table_scroll_idx = max(0, self.table_scroll_idx - 5)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif ch in (curses.KEY_NPAGE, 4): # PageDown or Ctrl+D
|
|
|
|
|
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
|
|
|
|
|
step = max(5, getattr(self, "transcript_height", 20) - 2)
|
|
|
|
|
self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - step)
|
|
|
|
|
if self.transcript_scroll_offset == 0:
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
elif self.mode == "box":
|
|
|
|
|
if self.box_tab == 1:
|
|
|
|
|
self.fleet_sel_idx = min(len(self.data.nodes) - 1, self.fleet_sel_idx + 5)
|
|
|
|
|
elif self.box_tab == 2:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = list(self.data.approvals_cache)
|
|
|
|
|
if apps:
|
|
|
|
|
self.approvals_sel_idx = min(len(apps) - 1, self.approvals_sel_idx + 5)
|
|
|
|
|
elif self.box_tab == 3:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
jobs = list(self.data.jobs_cache)
|
|
|
|
|
if jobs:
|
|
|
|
|
self.jobs_sel_idx = min(len(jobs) - 1, self.jobs_sel_idx + 5)
|
|
|
|
|
elif self.box_tab == 4:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
sessions = list(self.data.tmux_cache)
|
|
|
|
|
if sessions:
|
|
|
|
|
self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 5)
|
|
|
|
|
else:
|
|
|
|
|
self.table_scroll_idx += 5
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Top / Bottom: g / G / Home / End
|
|
|
|
|
if ch in (ord('g'), curses.KEY_HOME):
|
|
|
|
|
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
|
2026-10-06 19:29:22 +00:00
|
|
|
if self.focus_pane == "fleet":
|
|
|
|
|
self.fleet_focus_idx = 0
|
|
|
|
|
self.agent_scroll_start = 0
|
|
|
|
|
elif self.focus_pane in ("sidechats", "sidebar"):
|
2026-10-06 07:55:27 +00:00
|
|
|
self.thread_sel_idx = 0
|
|
|
|
|
else:
|
|
|
|
|
self.auto_scroll = False
|
|
|
|
|
max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", 20))
|
|
|
|
|
self.transcript_scroll_offset = max_scroll
|
|
|
|
|
elif self.mode == "box":
|
|
|
|
|
if self.box_tab == 1:
|
|
|
|
|
self.fleet_sel_idx = 0
|
|
|
|
|
elif self.box_tab == 2:
|
|
|
|
|
self.approvals_sel_idx = 0
|
|
|
|
|
elif self.box_tab == 3:
|
|
|
|
|
self.jobs_sel_idx = 0
|
|
|
|
|
elif self.box_tab == 4:
|
|
|
|
|
self.tmux_sel_idx = 0
|
|
|
|
|
else:
|
|
|
|
|
self.table_scroll_idx = 0
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif ch in (ord('G'), curses.KEY_END):
|
|
|
|
|
if self.mode == "muse" or (self.mode == "box" and self.box_tab == 0):
|
2026-10-06 19:29:22 +00:00
|
|
|
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"):
|
2026-10-06 07:55:27 +00:00
|
|
|
with self.data.lock:
|
|
|
|
|
threads = list(self.data.threads_cache.get(self.data.active_node, []))
|
|
|
|
|
if threads:
|
|
|
|
|
self.thread_sel_idx = max(0, len(threads) - 1)
|
|
|
|
|
else:
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
elif self.mode == "box":
|
|
|
|
|
if self.box_tab == 1:
|
|
|
|
|
self.fleet_sel_idx = max(0, len(self.data.nodes) - 1)
|
|
|
|
|
elif self.box_tab == 2:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = list(self.data.approvals_cache)
|
|
|
|
|
if apps:
|
|
|
|
|
self.approvals_sel_idx = max(0, len(apps) - 1)
|
|
|
|
|
elif self.box_tab == 3:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
jobs = list(self.data.jobs_cache)
|
|
|
|
|
if jobs:
|
|
|
|
|
self.jobs_sel_idx = max(0, len(jobs) - 1)
|
|
|
|
|
elif self.box_tab == 4:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
sessions = list(self.data.tmux_cache)
|
|
|
|
|
if sessions:
|
|
|
|
|
self.tmux_sel_idx = max(0, len(sessions) - 1)
|
|
|
|
|
else:
|
|
|
|
|
self.table_scroll_idx = max(0, len(self.data.nodes) - 5)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def _handle_mouse(self, mx: int, my: int, bstate: int) -> bool:
|
|
|
|
|
"""Handle mouse clicks, selection, and mouse wheel scrolling."""
|
|
|
|
|
self.data.record_activity()
|
|
|
|
|
# Scroll up masks: BUTTON4_PRESSED, BUTTON4_CLICKED, 0x10000, 0x20000
|
|
|
|
|
is_scroll_up = bool(bstate & (getattr(curses, "BUTTON4_PRESSED", 0x10000) |
|
|
|
|
|
getattr(curses, "BUTTON4_CLICKED", 0x20000) |
|
|
|
|
|
0x10000 | 0x20000))
|
|
|
|
|
|
|
|
|
|
# Scroll down masks: BUTTON5_PRESSED, BUTTON5_CLICKED, 0x200000, 0x400000, 0x8000000
|
|
|
|
|
is_scroll_down = bool(bstate & (getattr(curses, "BUTTON5_PRESSED", 0x200000) |
|
|
|
|
|
getattr(curses, "BUTTON5_CLICKED", 0x400000) |
|
|
|
|
|
0x200000 | 0x400000 | 0x8000000))
|
|
|
|
|
|
|
|
|
|
# Button state masks
|
2026-10-06 19:29:22 +00:00
|
|
|
btn3_mask = (
|
2026-10-06 07:55:27 +00:00
|
|
|
getattr(curses, "BUTTON3_CLICKED", 0x1000) |
|
|
|
|
|
getattr(curses, "BUTTON3_PRESSED", 0x800) |
|
2026-10-06 19:29:22 +00:00
|
|
|
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)
|
2026-10-06 07:55:27 +00:00
|
|
|
))
|
2026-10-06 19:29:22 +00:00
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
2026-10-06 07:55:27 +00:00
|
|
|
if is_right_click:
|
|
|
|
|
now = time.time()
|
2026-10-06 19:29:22 +00:00
|
|
|
if now - getattr(self, "_last_right_click_time", 0.0) < 0.15:
|
2026-10-06 07:55:27 +00:00
|
|
|
return True
|
|
|
|
|
self._last_right_click_time = now
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
is_button_action = is_press or is_release or is_right_click or is_double_click
|
|
|
|
|
is_click = is_button_action or is_drag
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
h, w = self.stdscr.getmaxyx()
|
|
|
|
|
sidebar_w = min(36, max(26, w // 4))
|
|
|
|
|
header_lines = 2 if self.mode == "box" else 1
|
|
|
|
|
bottom_bar_h = 3
|
|
|
|
|
content_y = header_lines
|
|
|
|
|
is_main_view = (self.mode == "muse" or (self.mode == "box" and self.box_tab == 0))
|
|
|
|
|
prompt_shelf_h = 2 if is_main_view else 0
|
|
|
|
|
prompt_shelf_y = getattr(self, "_prompt_shelf_y", h - bottom_bar_h - prompt_shelf_h)
|
|
|
|
|
content_h = h - content_y - bottom_bar_h - prompt_shelf_h
|
|
|
|
|
|
|
|
|
|
# 1. WHEEL SCROLLING
|
|
|
|
|
if is_scroll_up:
|
|
|
|
|
if self.modal:
|
|
|
|
|
return True
|
|
|
|
|
if is_main_view and prompt_shelf_h > 0 and (prompt_shelf_y <= my < h - bottom_bar_h):
|
|
|
|
|
self.prompt_scroll_idx = max(0, self.prompt_scroll_idx - 1)
|
|
|
|
|
return True
|
|
|
|
|
if self.mode == "box" and self.box_tab != 0:
|
|
|
|
|
if self.box_tab == 1:
|
|
|
|
|
self.fleet_sel_idx = max(0, self.fleet_sel_idx - 1)
|
|
|
|
|
elif self.box_tab == 2:
|
|
|
|
|
self.approvals_sel_idx = max(0, self.approvals_sel_idx - 1)
|
|
|
|
|
elif self.box_tab == 3:
|
|
|
|
|
self.jobs_sel_idx = max(0, self.jobs_sel_idx - 1)
|
|
|
|
|
elif self.box_tab == 4:
|
|
|
|
|
self.tmux_sel_idx = max(0, self.tmux_sel_idx - 1)
|
|
|
|
|
else:
|
|
|
|
|
self.table_scroll_idx = max(0, self.table_scroll_idx - 1)
|
|
|
|
|
elif mx >= sidebar_w:
|
|
|
|
|
# Scroll Transcript history up
|
|
|
|
|
self.auto_scroll = False
|
|
|
|
|
max_scroll = max(0, getattr(self, "transcript_total_lines", 0) - getattr(self, "transcript_height", content_h - 2))
|
|
|
|
|
self.transcript_scroll_offset = min(max_scroll, self.transcript_scroll_offset + 3)
|
|
|
|
|
else:
|
|
|
|
|
visible_agent_count = getattr(self, "agent_visible_count", min(len(self.data.nodes), 6))
|
|
|
|
|
divider_y = content_y + 1 + visible_agent_count
|
2026-10-06 19:29:22 +00:00
|
|
|
if my < divider_y or self.focus_pane == "fleet":
|
2026-10-06 07:55:27 +00:00
|
|
|
# Scroll Fleet Agents up
|
2026-10-06 19:29:22 +00:00
|
|
|
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)
|
2026-10-06 07:55:27 +00:00
|
|
|
else:
|
|
|
|
|
# Scroll Sidebar thread selection up
|
|
|
|
|
self.thread_sel_idx = max(0, self.thread_sel_idx - 1)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if is_scroll_down:
|
|
|
|
|
if self.modal:
|
|
|
|
|
return True
|
|
|
|
|
if is_main_view and prompt_shelf_h > 0 and (prompt_shelf_y <= my < h - bottom_bar_h):
|
|
|
|
|
self.prompt_scroll_idx = min(max(0, len(self.prompts) - 1), self.prompt_scroll_idx + 1)
|
|
|
|
|
return True
|
|
|
|
|
if self.mode == "box" and self.box_tab != 0:
|
|
|
|
|
if self.box_tab == 1:
|
|
|
|
|
self.fleet_sel_idx = min(len(self.data.nodes) - 1, self.fleet_sel_idx + 1)
|
|
|
|
|
elif self.box_tab == 2:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = list(self.data.approvals_cache)
|
|
|
|
|
if apps:
|
|
|
|
|
self.approvals_sel_idx = min(len(apps) - 1, self.approvals_sel_idx + 1)
|
|
|
|
|
elif self.box_tab == 3:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
jobs = list(self.data.jobs_cache)
|
|
|
|
|
if jobs:
|
|
|
|
|
self.jobs_sel_idx = min(len(jobs) - 1, self.jobs_sel_idx + 1)
|
|
|
|
|
elif self.box_tab == 4:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
sessions = list(self.data.tmux_cache)
|
|
|
|
|
if sessions:
|
|
|
|
|
self.tmux_sel_idx = min(len(sessions) - 1, self.tmux_sel_idx + 1)
|
|
|
|
|
else:
|
|
|
|
|
self.table_scroll_idx += 1
|
|
|
|
|
elif mx >= sidebar_w:
|
|
|
|
|
# Scroll Transcript history down
|
|
|
|
|
self.transcript_scroll_offset = max(0, self.transcript_scroll_offset - 3)
|
|
|
|
|
if self.transcript_scroll_offset == 0:
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
else:
|
|
|
|
|
visible_agent_count = getattr(self, "agent_visible_count", min(len(self.data.nodes), 6))
|
|
|
|
|
divider_y = content_y + 1 + visible_agent_count
|
2026-10-06 19:29:22 +00:00
|
|
|
if my < divider_y or self.focus_pane == "fleet":
|
2026-10-06 07:55:27 +00:00
|
|
|
# Scroll Fleet Agents down
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
total_nodes = len(self.data.nodes)
|
2026-10-06 19:29:22 +00:00
|
|
|
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)
|
2026-10-06 07:55:27 +00:00
|
|
|
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
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# 2. CLICK & MODAL INTERACTION HANDLING
|
2026-10-06 07:55:27 +00:00
|
|
|
if is_click:
|
|
|
|
|
# If Modal is active
|
|
|
|
|
if self.modal:
|
2026-10-06 19:29:22 +00:00
|
|
|
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)
|
2026-10-06 07:55:27 +00:00
|
|
|
else:
|
2026-10-06 19:29:22 +00:00
|
|
|
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)
|
2026-10-06 07:55:27 +00:00
|
|
|
top_y = (h - modal_h) // 2
|
|
|
|
|
left_x = (w - modal_w) // 2
|
2026-10-06 19:29:22 +00:00
|
|
|
self._context_popup_bounds = (left_x, top_y, modal_w, modal_h)
|
2026-10-06 07:55:27 +00:00
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
is_inside_modal = (left_x <= mx < left_x + modal_w and top_y <= my < top_y + modal_h)
|
2026-10-06 07:55:27 +00:00
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
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
|
2026-10-06 07:55:27 +00:00
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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])
|
2026-10-06 07:55:27 +00:00
|
|
|
return True
|
2026-10-06 19:29:22 +00:00
|
|
|
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]
|
2026-10-06 07:55:27 +00:00
|
|
|
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
|
2026-10-06 19:29:22 +00:00
|
|
|
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
|
2026-10-06 07:55:27 +00:00
|
|
|
return True
|
2026-10-06 19:29:22 +00:00
|
|
|
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:
|
2026-10-06 07:55:27 +00:00
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Header clicks (my == 0)
|
|
|
|
|
if my == 0:
|
|
|
|
|
if mx < 35:
|
|
|
|
|
self.mode = "box" if self.mode == "muse" else "muse"
|
|
|
|
|
self.set_toast(f"Mode switched to {self.mode.upper()}", "info")
|
|
|
|
|
return True
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
has_blocked = any(s.get("blocked") for s in self.data.node_status.values())
|
|
|
|
|
if has_blocked and (w - 60 <= mx <= w - 24):
|
|
|
|
|
self.modal = "approvals"
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Box Tabs click (my == 1 and self.mode == "box")
|
|
|
|
|
if my == 1 and self.mode == "box":
|
|
|
|
|
tab_bounds = [(1, 18), (19, 37), (38, 53), (54, 74), (75, 94), (95, 108)]
|
|
|
|
|
for idx, (start, end) in enumerate(tab_bounds):
|
|
|
|
|
if start <= mx <= end:
|
|
|
|
|
self.box_tab = idx
|
|
|
|
|
return True
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Prompt Shelf clicks (when in Main View)
|
|
|
|
|
if is_main_view and prompt_shelf_h > 0 and (prompt_shelf_y <= my < h - bottom_bar_h):
|
2026-10-06 19:29:22 +00:00
|
|
|
if is_right_click:
|
|
|
|
|
self.modal = "prompts"
|
|
|
|
|
self.prompt_sel_idx = 0
|
|
|
|
|
self._modal_open_time = time.time()
|
|
|
|
|
return True
|
2026-10-06 07:55:27 +00:00
|
|
|
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:
|
2026-10-06 19:29:22 +00:00
|
|
|
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
|
2026-10-06 07:55:27 +00:00
|
|
|
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]
|
2026-10-06 19:29:22 +00:00
|
|
|
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
|
2026-10-06 07:55:27 +00:00
|
|
|
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:
|
2026-10-06 19:29:22 +00:00
|
|
|
self.focus_pane = "fleet"
|
2026-10-06 07:55:27 +00:00
|
|
|
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,
|
|
|
|
|
}
|
2026-10-06 19:29:22 +00:00
|
|
|
self.context_popup_pos = (mx, my)
|
2026-10-06 07:55:27 +00:00
|
|
|
self.agent_context_sel = 0
|
|
|
|
|
self.modal = "agent_context"
|
2026-10-06 19:29:22 +00:00
|
|
|
self._modal_open_time = time.time()
|
2026-10-06 07:55:27 +00:00
|
|
|
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:
|
2026-10-06 19:29:22 +00:00
|
|
|
self.focus_pane = "fleet"
|
2026-10-06 07:55:27 +00:00
|
|
|
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):
|
2026-10-06 19:29:22 +00:00
|
|
|
self.fleet_focus_idx = target_idx
|
2026-10-06 07:55:27 +00:00
|
|
|
new_node = nodes[target_idx]
|
2026-10-06 19:29:22 +00:00
|
|
|
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:
|
2026-10-06 07:55:27 +00:00
|
|
|
st_info = self.data.node_status.get(new_node, {})
|
|
|
|
|
self.context_agent = {
|
|
|
|
|
"node": new_node,
|
|
|
|
|
"status": st_info,
|
|
|
|
|
"index": target_idx,
|
|
|
|
|
}
|
2026-10-06 19:29:22 +00:00
|
|
|
self.context_popup_pos = (mx, my)
|
2026-10-06 07:55:27 +00:00
|
|
|
self.agent_context_sel = 0
|
|
|
|
|
self.modal = "agent_context"
|
2026-10-06 19:29:22 +00:00
|
|
|
self._modal_open_time = time.time()
|
2026-10-06 07:55:27 +00:00
|
|
|
self.set_toast(f"Opened Agent Context Menu: [{new_node.upper()}]", "info")
|
|
|
|
|
return True
|
|
|
|
|
else:
|
2026-10-06 19:29:22 +00:00
|
|
|
if is_press:
|
|
|
|
|
self.data.set_active_target(new_node)
|
|
|
|
|
self.set_toast(f"Switched to agent: {new_node.upper()}", "success")
|
2026-10-06 07:55:27 +00:00
|
|
|
return True
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# Check if click on [n:new], [⚡:menu], or [r:sync] in sidebar header
|
2026-10-06 07:55:27 +00:00
|
|
|
if my == threads_hdr_y:
|
2026-10-06 19:29:22 +00:00
|
|
|
self.focus_pane = "sidechats"
|
2026-10-06 07:55:27 +00:00
|
|
|
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,
|
|
|
|
|
}
|
2026-10-06 19:29:22 +00:00
|
|
|
self.context_popup_pos = (mx, my)
|
2026-10-06 07:55:27 +00:00
|
|
|
self.agent_context_sel = 1
|
|
|
|
|
self.modal = "agent_context"
|
2026-10-06 19:29:22 +00:00
|
|
|
self._modal_open_time = time.time()
|
2026-10-06 07:55:27 +00:00
|
|
|
return True
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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:
|
2026-10-06 07:55:27 +00:00
|
|
|
self.modal = "new_sidechat"
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
return True
|
2026-10-06 19:29:22 +00:00
|
|
|
elif mx >= sidebar_w - 6:
|
2026-10-06 07:55:27 +00:00
|
|
|
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):
|
2026-10-06 19:29:22 +00:00
|
|
|
self.focus_pane = "sidechats"
|
2026-10-06 07:55:27 +00:00
|
|
|
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]
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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):
|
2026-10-06 07:55:27 +00:00
|
|
|
self.context_chat = {
|
|
|
|
|
"node": self.data.active_node,
|
|
|
|
|
"thread": t,
|
|
|
|
|
"index": clicked_idx,
|
|
|
|
|
}
|
2026-10-06 19:29:22 +00:00
|
|
|
self.context_popup_pos = (mx, my)
|
2026-10-06 07:55:27 +00:00
|
|
|
self.chat_context_sel = 0
|
|
|
|
|
self.modal = "chat_context"
|
2026-10-06 19:29:22 +00:00
|
|
|
self._modal_open_time = time.time()
|
2026-10-06 07:55:27 +00:00
|
|
|
self.set_toast(f"Opened Chat Context Menu: '{title}'", "info")
|
|
|
|
|
return True
|
|
|
|
|
else:
|
2026-10-06 19:29:22 +00:00
|
|
|
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")
|
2026-10-06 07:55:27 +00:00
|
|
|
return True
|
2026-10-06 19:29:22 +00:00
|
|
|
|
|
|
|
|
# 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
|
2026-10-06 07:55:27 +00:00
|
|
|
else:
|
|
|
|
|
# Mouse events inside Right Transcript
|
|
|
|
|
if is_right_click:
|
|
|
|
|
m = self._get_message_at_transcript_line(my)
|
2026-10-06 19:29:22 +00:00
|
|
|
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]
|
2026-10-06 07:55:27 +00:00
|
|
|
if m:
|
|
|
|
|
self.context_message = m
|
2026-10-06 19:29:22 +00:00
|
|
|
self.context_popup_pos = (mx, my)
|
2026-10-06 07:55:27 +00:00
|
|
|
self.message_context_sel = 0
|
|
|
|
|
self.modal = "message_context"
|
2026-10-06 19:29:22 +00:00
|
|
|
self._modal_open_time = time.time()
|
2026-10-06 07:55:27 +00:00
|
|
|
r_role = m.get("role", "agent").upper()
|
|
|
|
|
self.set_toast(f"Message Context Menu ({r_role})", "info")
|
|
|
|
|
return True
|
2026-10-06 19:29:22 +00:00
|
|
|
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
|
2026-10-06 07:55:27 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-10-06 07:55:27 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-10-06 07:55:27 +00:00
|
|
|
# Check if clicked on the bottom "SCROLLED BACK - Follow" pill
|
|
|
|
|
if not self.auto_scroll and my == content_y + content_h - 1:
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
self.set_toast("Resumed live bottom following", "success")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Otherwise: start mouse drag selection on text line!
|
|
|
|
|
idx = self._get_transcript_line_idx_at(my)
|
|
|
|
|
if idx is not None:
|
|
|
|
|
self.mouse_dragging = True
|
|
|
|
|
self.copy_mode = True
|
|
|
|
|
self.visual_sel_start = idx
|
|
|
|
|
self.copy_cursor_line = idx
|
|
|
|
|
self.editor_mode = "NORMAL"
|
|
|
|
|
self.focus_pane = "transcript"
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
self.editor_mode = "NORMAL"
|
|
|
|
|
self.focus_pane = "transcript"
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Box View clicks (Tabs 1, 2, 3, 4)
|
|
|
|
|
elif self.mode == "box":
|
|
|
|
|
self.editor_mode = "NORMAL"
|
|
|
|
|
row_idx = my - (content_y + 3)
|
|
|
|
|
|
|
|
|
|
if self.box_tab == 1:
|
|
|
|
|
# Tab 1: Fleet Status
|
|
|
|
|
if 0 <= row_idx < len(self.data.nodes):
|
|
|
|
|
self.fleet_sel_idx = row_idx
|
|
|
|
|
sel_node = self.data.nodes[row_idx]
|
|
|
|
|
self.data.set_active_target(sel_node)
|
|
|
|
|
self.box_tab = 0
|
|
|
|
|
self.focus_pane = "transcript"
|
|
|
|
|
self.set_toast(f"✔ Refocused Agent Chat on {sel_node.upper()}", "success")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.box_tab == 2:
|
|
|
|
|
# Tab 2: Approvals
|
|
|
|
|
# Check bottom hint line: Clear All
|
|
|
|
|
if my == content_y + content_h - 1:
|
|
|
|
|
if mx >= 40:
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = list(self.data.approvals_cache)
|
|
|
|
|
|
|
|
|
|
if 0 <= row_idx < len(apps):
|
|
|
|
|
self.approvals_sel_idx = row_idx
|
|
|
|
|
app = apps[row_idx]
|
|
|
|
|
node = app.get("node", self.data.active_node)
|
|
|
|
|
st = app.get("status") or "PENDING"
|
|
|
|
|
act_x = min(w - 38, max(64, 48))
|
|
|
|
|
|
|
|
|
|
if mx >= act_x:
|
|
|
|
|
if st == "PENDING":
|
|
|
|
|
if mx < act_x + 10:
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("allow", node), daemon=True).start()
|
|
|
|
|
elif mx < act_x + 21:
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("always", node), daemon=True).start()
|
|
|
|
|
else:
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("deny", node), daemon=True).start()
|
|
|
|
|
elif st == "INPUT_WAIT":
|
|
|
|
|
if mx < act_x + 12:
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("reply", node, "Proceed with standard execution."), daemon=True).start()
|
|
|
|
|
elif mx < act_x + 24:
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("dismiss", node), daemon=True).start()
|
|
|
|
|
else:
|
|
|
|
|
self.data.set_active_target(node)
|
|
|
|
|
self.box_tab = 0
|
|
|
|
|
self.set_toast(f"Switched to Chat with {node.upper()}", "success")
|
|
|
|
|
else:
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("clear_all",), daemon=True).start()
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Selected approval [{node.upper()}]. Use keys: [1/2/3/r/x/c]", "info")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.box_tab == 3:
|
|
|
|
|
# Tab 3: Jobs Scheduler
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
jobs = list(self.data.jobs_cache)
|
|
|
|
|
scroll_start = getattr(self, "jobs_scroll_start", 0)
|
|
|
|
|
clicked_idx = scroll_start + row_idx
|
|
|
|
|
if 0 <= clicked_idx < len(jobs):
|
|
|
|
|
self.jobs_sel_idx = clicked_idx
|
|
|
|
|
job = jobs[clicked_idx]
|
|
|
|
|
job_name = job.get("name")
|
|
|
|
|
agent = job.get("agent", self.data.active_node)
|
|
|
|
|
if mx >= w - 12:
|
|
|
|
|
# [Run] button
|
|
|
|
|
threading.Thread(target=self._async_trigger_job, args=(job_name,), daemon=True).start()
|
|
|
|
|
else:
|
|
|
|
|
# Clicked row, name, or [▶ Chat] -> open conversation in Agent Chat 1!
|
|
|
|
|
self._open_job_conversation(job_name, agent)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.box_tab == 4:
|
|
|
|
|
# Tab 4: Tmux Sessions
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
sessions = list(self.data.tmux_cache)
|
|
|
|
|
if 0 <= row_idx < len(sessions):
|
|
|
|
|
self.tmux_sel_idx = row_idx
|
|
|
|
|
sess_line = sessions[row_idx]
|
|
|
|
|
sess_name = sess_line.split(":")[0].strip()
|
|
|
|
|
if mx >= w - 12:
|
|
|
|
|
# [Kill]
|
|
|
|
|
threading.Thread(target=self._async_kill_tmux, args=(sess_name,), daemon=True).start()
|
|
|
|
|
elif mx >= w - 22:
|
|
|
|
|
# [Attach]
|
|
|
|
|
self.set_toast(f"Attach: tmux -S /tmp/tmux-muse.sock attach -t {sess_name}", "info")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Selected session '{sess_name}'. Click [Kill] or [Attach].", "info")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def _handle_insert_key(self, ch: int) -> bool:
|
|
|
|
|
# Enter -> Submit Message / Slash command (or collect multiline paste burst)
|
|
|
|
|
if ch in (curses.KEY_ENTER, 10, 13):
|
|
|
|
|
# Check if this Enter is part of a rapid paste stream (unbracketed paste)
|
|
|
|
|
self.stdscr.nodelay(True)
|
|
|
|
|
try:
|
|
|
|
|
peek_c = self.stdscr.getch()
|
|
|
|
|
finally:
|
|
|
|
|
self.stdscr.timeout(50)
|
|
|
|
|
|
|
|
|
|
if peek_c != -1 and isinstance(peek_c, int):
|
|
|
|
|
# Part of rapid unbracketed paste burst! Keep newline and consume rest of burst
|
|
|
|
|
burst_bytes = bytearray([10])
|
|
|
|
|
if 0 <= peek_c <= 255:
|
|
|
|
|
burst_bytes.append(peek_c)
|
|
|
|
|
|
|
|
|
|
self.stdscr.nodelay(True)
|
|
|
|
|
try:
|
|
|
|
|
idle = 0
|
|
|
|
|
while True:
|
|
|
|
|
c = self.stdscr.getch()
|
|
|
|
|
if c == -1:
|
|
|
|
|
idle += 1
|
|
|
|
|
if idle > 4:
|
|
|
|
|
break
|
|
|
|
|
time.sleep(0.005)
|
|
|
|
|
continue
|
|
|
|
|
idle = 0
|
|
|
|
|
if 0 <= c <= 255:
|
|
|
|
|
burst_bytes.append(c)
|
|
|
|
|
finally:
|
|
|
|
|
self.stdscr.timeout(50)
|
|
|
|
|
|
|
|
|
|
burst_text = burst_bytes.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
|
self.input_buf = self.input_buf[:self.input_cursor] + burst_text + self.input_buf[self.input_cursor:]
|
|
|
|
|
self.input_cursor += len(burst_text)
|
|
|
|
|
nl_cnt = self.input_buf.count("\n")
|
|
|
|
|
self.set_toast(f"📋 Pasted chunk ({nl_cnt + 1} lines). Press [Enter] to send.", "success")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
line = self.input_buf.strip()
|
|
|
|
|
if not line:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Soft guardrail: require double-tap Enter during active rate limit cooldown
|
|
|
|
|
is_cmd = line.startswith("/") and not line.startswith("//")
|
|
|
|
|
if not is_cmd:
|
|
|
|
|
cur_node = self.data.active_node
|
|
|
|
|
cd = self.data.get_node_cooldown_remaining(cur_node)
|
|
|
|
|
if cd > 0:
|
|
|
|
|
now = time.time()
|
|
|
|
|
last_force = getattr(self, "_last_force_send_time", 0.0)
|
|
|
|
|
if (now - last_force) <= 2.5:
|
|
|
|
|
self.data.clear_node_rate_limit(cur_node)
|
|
|
|
|
self.set_toast(f"⚡ Bypassed cooldown. Force-sending to {cur_node.upper()}...", "warn")
|
|
|
|
|
self._last_force_send_time = 0.0
|
|
|
|
|
else:
|
|
|
|
|
self._last_force_send_time = now
|
|
|
|
|
self.set_toast(f"⏳ {cur_node.upper()} in cooldown ({cd}s left). Press Enter again within 2.5s to force-send.", "warn")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
self.input_history.append(line)
|
|
|
|
|
self.history_idx = len(self.input_history)
|
|
|
|
|
self.input_buf = ""
|
|
|
|
|
self.input_cursor = 0
|
|
|
|
|
self._execute_input_line(line)
|
|
|
|
|
if getattr(self, "should_exit", False):
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Backspace
|
|
|
|
|
elif ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
|
|
|
if self.input_cursor > 0:
|
|
|
|
|
self.input_buf = self.input_buf[:self.input_cursor - 1] + self.input_buf[self.input_cursor:]
|
|
|
|
|
self.input_cursor -= 1
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Delete
|
|
|
|
|
elif ch == curses.KEY_DC:
|
|
|
|
|
if self.input_cursor < len(self.input_buf):
|
|
|
|
|
self.input_buf = self.input_buf[:self.input_cursor] + self.input_buf[self.input_cursor + 1:]
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Left / Right Cursor
|
|
|
|
|
elif ch == curses.KEY_LEFT:
|
|
|
|
|
if self.input_cursor > 0:
|
|
|
|
|
self.input_cursor -= 1
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_RIGHT:
|
|
|
|
|
if self.input_cursor < len(self.input_buf):
|
|
|
|
|
self.input_cursor += 1
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Home / End
|
|
|
|
|
elif ch in (curses.KEY_HOME, 1): # Home or Ctrl+A
|
|
|
|
|
self.input_cursor = 0
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (curses.KEY_END, 5): # End or Ctrl+E
|
|
|
|
|
self.input_cursor = len(self.input_buf)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Word Deletion: Ctrl+W
|
|
|
|
|
elif ch == 23:
|
|
|
|
|
parts = self.input_buf[:self.input_cursor].rstrip().rsplit(" ", 1)
|
|
|
|
|
keep = parts[0] + " " if len(parts) > 1 else ""
|
|
|
|
|
self.input_buf = keep + self.input_buf[self.input_cursor:]
|
|
|
|
|
self.input_cursor = len(keep)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Line clear: Ctrl+U
|
|
|
|
|
elif ch == 21:
|
|
|
|
|
self.input_buf = ""
|
|
|
|
|
self.input_cursor = 0
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# History Navigation / Multiline Navigation: Up / Down
|
|
|
|
|
elif ch == curses.KEY_UP:
|
|
|
|
|
if "\n" in self.input_buf:
|
|
|
|
|
curr = self.input_buf[:self.input_cursor]
|
|
|
|
|
last_nl = curr.rfind("\n")
|
|
|
|
|
if last_nl != -1:
|
|
|
|
|
prev_line_start = curr[:last_nl].rfind("\n")
|
|
|
|
|
prev_line_start = 0 if prev_line_start == -1 else (prev_line_start + 1)
|
|
|
|
|
col = len(curr) - (last_nl + 1)
|
|
|
|
|
self.input_cursor = min(last_nl, prev_line_start + col)
|
|
|
|
|
return True
|
|
|
|
|
if self.input_history and self.history_idx > 0:
|
|
|
|
|
self.history_idx -= 1
|
|
|
|
|
self.input_buf = self.input_history[self.history_idx]
|
|
|
|
|
self.input_cursor = len(self.input_buf)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif ch == curses.KEY_DOWN:
|
|
|
|
|
if "\n" in self.input_buf:
|
|
|
|
|
rest = self.input_buf[self.input_cursor:]
|
|
|
|
|
next_nl = rest.find("\n")
|
|
|
|
|
if next_nl != -1:
|
|
|
|
|
next_line_start = self.input_cursor + next_nl + 1
|
|
|
|
|
following_nl = self.input_buf[next_line_start:].find("\n")
|
|
|
|
|
next_line_end = len(self.input_buf) if following_nl == -1 else (next_line_start + following_nl)
|
|
|
|
|
curr = self.input_buf[:self.input_cursor]
|
|
|
|
|
curr_col = len(curr) - (curr.rfind("\n") + 1)
|
|
|
|
|
self.input_cursor = min(next_line_end, next_line_start + curr_col)
|
|
|
|
|
return True
|
|
|
|
|
if self.input_history and self.history_idx < len(self.input_history) - 1:
|
|
|
|
|
self.history_idx += 1
|
|
|
|
|
self.input_buf = self.input_history[self.history_idx]
|
|
|
|
|
self.input_cursor = len(self.input_buf)
|
|
|
|
|
elif self.input_history and self.history_idx == len(self.input_history) - 1:
|
|
|
|
|
self.history_idx = len(self.input_history)
|
|
|
|
|
self.input_buf = ""
|
|
|
|
|
self.input_cursor = 0
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Tab in insert mode: return to normal mode (modal lock)
|
|
|
|
|
elif ch == ord('\t'):
|
|
|
|
|
self.editor_mode = "NORMAL"
|
|
|
|
|
self.set_toast("NORMAL mode (Nav locked). Press 'i' to type.", "info")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Printable Characters
|
|
|
|
|
elif 32 <= ch <= 126:
|
|
|
|
|
char = chr(ch)
|
|
|
|
|
self.input_buf = self.input_buf[:self.input_cursor] + char + self.input_buf[self.input_cursor:]
|
|
|
|
|
self.input_cursor += 1
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Multi-byte UTF-8 or Extended Unicode
|
|
|
|
|
elif 128 <= ch <= 255:
|
|
|
|
|
if not hasattr(self, "_utf8_buf"):
|
|
|
|
|
self._utf8_buf = bytearray()
|
|
|
|
|
self._utf8_buf.append(ch)
|
|
|
|
|
try:
|
|
|
|
|
char = self._utf8_buf.decode("utf-8")
|
|
|
|
|
self._utf8_buf.clear()
|
|
|
|
|
self.input_buf = self.input_buf[:self.input_cursor] + char + self.input_buf[self.input_cursor:]
|
|
|
|
|
self.input_cursor += len(char)
|
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
|
if len(self._utf8_buf) >= 4:
|
|
|
|
|
self._utf8_buf.clear()
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif ch >= 256:
|
|
|
|
|
try:
|
|
|
|
|
char = chr(ch)
|
|
|
|
|
if char.isprintable():
|
|
|
|
|
self.input_buf = self.input_buf[:self.input_cursor] + char + self.input_buf[self.input_cursor:]
|
|
|
|
|
self.input_cursor += len(char)
|
|
|
|
|
return True
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def _execute_input_line(self, line: str):
|
|
|
|
|
"""Execute typed line either as slash command or direct agent chat message."""
|
|
|
|
|
if line.startswith("/"):
|
|
|
|
|
parts = line[1:].split(" ", 1)
|
|
|
|
|
cmd = parts[0].lower()
|
|
|
|
|
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
|
|
|
|
|
|
|
|
if cmd in ("exit", "quit", "q"):
|
|
|
|
|
self.should_exit = True
|
|
|
|
|
return
|
|
|
|
|
elif cmd in ("help", "h"):
|
|
|
|
|
self.modal = "help"
|
|
|
|
|
elif cmd in ("refresh", "sync", "r"):
|
|
|
|
|
self.data.trigger_refresh()
|
|
|
|
|
self.set_toast("Forced refresh across fleet.", "info")
|
|
|
|
|
elif cmd in ("clear", "cls"):
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
self.set_toast("Transcript view reset.", "info")
|
|
|
|
|
elif cmd == "mode":
|
|
|
|
|
self.mode = "box" if self.mode == "muse" else "muse"
|
|
|
|
|
self.set_toast(f"Mode switched to {self.mode.upper()}", "info")
|
|
|
|
|
elif cmd in ("node", "agent", "account", "a", "switch"):
|
|
|
|
|
if not arg:
|
|
|
|
|
self.modal = "agent_search"
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
self.agent_search_sel = 0
|
|
|
|
|
self.agent_search_scroll = 0
|
|
|
|
|
else:
|
|
|
|
|
matched = None
|
|
|
|
|
for n in self.data.nodes:
|
|
|
|
|
if n.lower() == arg.lower():
|
|
|
|
|
matched = n
|
|
|
|
|
break
|
|
|
|
|
if not matched:
|
|
|
|
|
for n in self.data.nodes:
|
|
|
|
|
if n.lower().startswith(arg.lower()):
|
|
|
|
|
matched = n
|
|
|
|
|
break
|
|
|
|
|
if matched:
|
|
|
|
|
self.data.set_active_target(matched)
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
self.data._fetch_threads(matched)
|
|
|
|
|
self.set_toast(f"Switched to node {matched.upper()}", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Unknown node '{arg}'. Valid: {', '.join(self.data.nodes)}", "error")
|
|
|
|
|
elif cmd == "new":
|
|
|
|
|
title = arg or "operator-session"
|
|
|
|
|
self.set_toast(f"Creating sidechat '{title}'...", "info")
|
|
|
|
|
threading.Thread(target=self._async_create_sidechat, args=(title,), daemon=True).start()
|
|
|
|
|
elif cmd in ("wo", "workorder"):
|
|
|
|
|
if not arg:
|
|
|
|
|
self.modal = "work_order"
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
else:
|
|
|
|
|
wo_parts = arg.split(" ", 1)
|
|
|
|
|
title = wo_parts[0]
|
|
|
|
|
body = wo_parts[1] if len(wo_parts) > 1 else title
|
|
|
|
|
threading.Thread(target=self._async_issue_wo, args=(title, body), daemon=True).start()
|
|
|
|
|
elif cmd in ("blocked", "approvals", "approval", "app"):
|
|
|
|
|
self.modal = "approvals"
|
|
|
|
|
self.approvals_sel_idx = 0
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = [it for it in self.data.approvals_cache if it.get("status") in ("PENDING", "INPUT_WAIT", "KEY_APPROVAL")]
|
|
|
|
|
if apps:
|
|
|
|
|
self.set_toast(f"⚠ {len(apps)} agent(s) blocked/waiting. Select with j/k, [1] Allow, [2] Always, [3] Deny", "warn_banner")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast("✔ Fleet is clear: 0 agents blocked.", "success")
|
|
|
|
|
threading.Thread(target=self.data._fetch_approvals, daemon=True).start()
|
|
|
|
|
return
|
|
|
|
|
elif cmd in ("approve", "allow"):
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("allow",), daemon=True).start()
|
|
|
|
|
elif cmd == "always":
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("always",), daemon=True).start()
|
|
|
|
|
elif cmd == "deny":
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("deny",), daemon=True).start()
|
|
|
|
|
elif cmd in ("auto", "autopilot"):
|
|
|
|
|
threading.Thread(target=self._async_resolve_approval, args=("auto",), daemon=True).start()
|
|
|
|
|
elif cmd in ("pin", "unpin"):
|
|
|
|
|
tid = self.data.active_thread_id
|
|
|
|
|
node = self.data.active_node
|
|
|
|
|
if tid:
|
|
|
|
|
new_pin = self.data.toggle_pin_thread(node, tid)
|
|
|
|
|
state = "Pinned" if new_pin else "Unpinned"
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast("No active chat to pin/unpin", "error")
|
|
|
|
|
elif cmd in ("prompt", "prompts", "p", "skill", "skills"):
|
|
|
|
|
if not arg:
|
|
|
|
|
self.modal = "prompts"
|
|
|
|
|
self.prompt_sel_idx = 0
|
|
|
|
|
elif arg.startswith("save "):
|
|
|
|
|
title = arg[5:].strip()
|
|
|
|
|
body = self.input_buf.strip() or title
|
|
|
|
|
p = self.prompt_manager.add_prompt(title, body, tags=["custom", "saved"], pinned=True)
|
|
|
|
|
self.prompts = self.prompt_manager.get_sorted_prompts()
|
|
|
|
|
self.set_toast(f"✔ Stored prompt skill: '{p['title']}'", "success")
|
|
|
|
|
elif arg.startswith("add "):
|
|
|
|
|
content = arg[4:].strip()
|
|
|
|
|
parts = content.split("|", 1)
|
|
|
|
|
title = parts[0].strip()
|
|
|
|
|
body = parts[1].strip() if len(parts) > 1 else title
|
|
|
|
|
p = self.prompt_manager.add_prompt(title, body, tags=["custom"], pinned=True)
|
|
|
|
|
self.prompts = self.prompt_manager.get_sorted_prompts()
|
|
|
|
|
self.set_toast(f"✔ Added prompt skill: '{p['title']}'", "success")
|
|
|
|
|
elif arg.startswith("del ") or arg.startswith("delete "):
|
|
|
|
|
pid = arg.split(" ", 1)[1].strip()
|
|
|
|
|
if self.prompt_manager.delete_prompt(pid):
|
|
|
|
|
self.prompts = self.prompt_manager.get_sorted_prompts()
|
|
|
|
|
self.set_toast(f"Deleted prompt '{pid}'", "warn_banner")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Prompt '{pid}' not found", "error")
|
|
|
|
|
else:
|
|
|
|
|
self.modal = "prompts"
|
|
|
|
|
self.prompt_sel_idx = 0
|
|
|
|
|
elif cmd in ("sends", "history", "search"):
|
|
|
|
|
self.modal = "history_search"
|
|
|
|
|
self.modal_input_buf = arg
|
|
|
|
|
self.modal_input_cursor = len(arg)
|
|
|
|
|
self.history_search_sel = 0
|
|
|
|
|
self.history_search_scroll = 0
|
|
|
|
|
elif cmd in ("copy", "yank", "cp"):
|
|
|
|
|
if arg.lower() in ("last", "msg", "message"):
|
|
|
|
|
self.copy_message_to_clipboard()
|
|
|
|
|
else:
|
|
|
|
|
self.copy_transcript_to_clipboard()
|
|
|
|
|
elif cmd in ("clean", "boxed", "box", "view", "style", "compact"):
|
|
|
|
|
if cmd == "clean" or arg.lower() in ("clean", "compact"):
|
|
|
|
|
if self.transcript_style != "clean":
|
|
|
|
|
self.set_transcript_style("clean")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast("Transcript style is already Clean (line-by-line).", "info")
|
|
|
|
|
elif cmd in ("boxed", "box") or arg.lower() in ("boxed", "box"):
|
|
|
|
|
if self.transcript_style != "boxed":
|
|
|
|
|
self.set_transcript_style("boxed")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast("Transcript style is already Boxed (classic frames).", "info")
|
|
|
|
|
else:
|
|
|
|
|
self.toggle_transcript_style()
|
|
|
|
|
elif cmd in ("context", "menu", "actions"):
|
|
|
|
|
if arg.lower() in ("agent", "fleet", "node"):
|
|
|
|
|
st_info = self.data.node_status.get(self.data.active_node, {})
|
|
|
|
|
self.context_agent = {
|
|
|
|
|
"node": self.data.active_node,
|
|
|
|
|
"status": st_info,
|
|
|
|
|
"index": 0,
|
|
|
|
|
}
|
|
|
|
|
self.agent_context_sel = 0
|
|
|
|
|
self.modal = "agent_context"
|
|
|
|
|
elif arg.lower() in ("msg", "message"):
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
msgs = list(self.data.history_cache.get((self.data.active_node, self.data.active_thread_id), []))
|
|
|
|
|
if msgs:
|
|
|
|
|
self.context_message = msgs[-1]
|
|
|
|
|
self.message_context_sel = 0
|
|
|
|
|
self.modal = "message_context"
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast("No message to show context menu for", "warn_banner")
|
|
|
|
|
else:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
threads = list(self.data.threads_cache.get(self.data.active_node, []))
|
|
|
|
|
t = None
|
|
|
|
|
if threads:
|
|
|
|
|
for th in threads:
|
|
|
|
|
if th.get("session_id") == self.data.active_thread_id:
|
|
|
|
|
t = th
|
|
|
|
|
break
|
|
|
|
|
if not t:
|
|
|
|
|
t = threads[0]
|
|
|
|
|
if t:
|
|
|
|
|
self.context_chat = {
|
|
|
|
|
"node": self.data.active_node,
|
|
|
|
|
"thread": t,
|
|
|
|
|
"index": 0,
|
|
|
|
|
}
|
|
|
|
|
self.chat_context_sel = 0
|
|
|
|
|
self.modal = "chat_context"
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast("No active chat for context menu", "warn_banner")
|
|
|
|
|
else:
|
|
|
|
|
if "\n" in line or "/" in cmd or line.startswith("//"):
|
|
|
|
|
pass # Pass through to deliver as chat message
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Unknown slash command: /{cmd}. Type /help for cheat-sheet.", "error")
|
|
|
|
|
return
|
|
|
|
|
if not ("\n" in line or "/" in cmd or line.startswith("//")):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Direct standard message to active thread
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
cur_node = self.data.active_node
|
|
|
|
|
cur_thread = self.data.active_thread_id
|
|
|
|
|
cur_title = self.data.active_thread_title
|
|
|
|
|
node_threads = self.data.threads_cache.get(cur_node, [])
|
|
|
|
|
cur_valid = any(t.get("session_id") == cur_thread for t in node_threads)
|
|
|
|
|
if not cur_thread or not cur_valid:
|
|
|
|
|
cur_thread, cur_title = self.data.select_main_chat(cur_node)
|
|
|
|
|
|
|
|
|
|
# Quote target message if replying
|
|
|
|
|
if getattr(self, "reply_target", None):
|
|
|
|
|
rep = self.reply_target
|
|
|
|
|
r_role = rep.get("role", "agent").upper()
|
|
|
|
|
r_ref = f"seq:{rep.get('seq')}" if rep.get("seq") else f"id:{rep.get('msg_id', '')[:8]}"
|
|
|
|
|
r_text = rep.get("text", "").strip()
|
|
|
|
|
if len(r_text) > 280:
|
|
|
|
|
q_text = r_text[:280].rstrip() + "..."
|
|
|
|
|
else:
|
|
|
|
|
q_text = r_text
|
|
|
|
|
quoted_lines = "\n".join(f"> {l}" for l in q_text.split("\n"))
|
|
|
|
|
line = f"> Replying to {r_role} ({r_ref}):\n{quoted_lines}\n\n{line}"
|
|
|
|
|
self.reply_target = None
|
|
|
|
|
|
|
|
|
|
if not cur_title:
|
|
|
|
|
cur_title = "Main Chat"
|
|
|
|
|
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
self.data.record_interaction(cur_node)
|
|
|
|
|
self.data.record_activity()
|
|
|
|
|
self.data.add_optimistic_message(cur_node, cur_thread, line)
|
|
|
|
|
self.set_toast(f"Sending message to {cur_node.upper()} ({cur_title})...", "info")
|
|
|
|
|
threading.Thread(target=self._async_send_message, args=(cur_node, cur_thread, line), daemon=True).start()
|
|
|
|
|
|
|
|
|
|
# Async action helpers
|
|
|
|
|
def _async_send_message(self, node: str, thread_id: str, text: str):
|
|
|
|
|
ok, res = self.data.send_chat_message(node, thread_id, text)
|
|
|
|
|
if ok:
|
|
|
|
|
self.set_toast(f"✔ Delivered to {node.upper()}.", "success")
|
|
|
|
|
threading.Thread(target=self._post_send_sync, args=(node, thread_id), daemon=True).start()
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Failed to deliver: {res[:40]}", "error")
|
|
|
|
|
|
|
|
|
|
def _post_send_sync(self, node: str, thread_id: str):
|
2026-10-06 19:29:22 +00:00
|
|
|
# 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
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
def _async_create_sidechat(self, title: str):
|
|
|
|
|
node = self.data.active_node
|
|
|
|
|
ok, sid, msg = self.data.create_sidechat(node, title)
|
|
|
|
|
if ok:
|
|
|
|
|
self.set_toast(f"✔ Created sidechat '{title}' [{sid[:6]}]", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Failed to create sidechat: {msg[:40]}", "error")
|
|
|
|
|
|
|
|
|
|
def _async_issue_wo(self, title: str, body: str):
|
|
|
|
|
node = self.data.active_node
|
|
|
|
|
self.data.record_interaction(node)
|
|
|
|
|
tid = self.data.active_thread_id
|
|
|
|
|
if not tid:
|
|
|
|
|
self.set_toast("Cannot issue Work Order: No active sidechat.", "error")
|
|
|
|
|
return
|
|
|
|
|
self.set_toast(f"Issuing Work Order '{title}'...", "info")
|
|
|
|
|
ok, res = self.data.issue_work_order(node, tid, title, body)
|
|
|
|
|
if ok:
|
|
|
|
|
self.set_toast(f"✔ Work Order '{title}' delivered to {node.upper()}.", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Work Order delivery failed: {res[:40]}", "error")
|
|
|
|
|
|
|
|
|
|
def _async_resolve_approval(self, action: str, node: str = None, text: str = ""):
|
|
|
|
|
node = node or self.data.active_node
|
|
|
|
|
if action == "clear_all":
|
|
|
|
|
ok, msg = self.data.clear_all_waits()
|
|
|
|
|
if ok:
|
|
|
|
|
self.set_toast("✔ All pending task waits cleared across fleet.", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Clear failed: {msg[:40]}", "error")
|
|
|
|
|
return
|
|
|
|
|
elif action == "dismiss":
|
|
|
|
|
ok, msg = self.data.dismiss_task(node)
|
|
|
|
|
if ok:
|
|
|
|
|
self.set_toast(f"✔ Dismissed active task wait on {node.upper()}.", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Dismiss failed: {msg[:40]}", "error")
|
|
|
|
|
return
|
|
|
|
|
elif action == "reply":
|
|
|
|
|
msg_text = text or "Proceed with standard execution."
|
|
|
|
|
self.data.record_interaction(node)
|
|
|
|
|
ok, msg = self.data.reply_task(node, msg_text)
|
|
|
|
|
if ok:
|
|
|
|
|
self.set_toast(f"✔ Responded to task wait on {node.upper()}.", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Reply failed: {msg[:40]}", "error")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
ok, msg = self.data.resolve_approval(node, action)
|
|
|
|
|
if ok:
|
|
|
|
|
self.set_toast(f"✔ Approval '{action}' applied to {node.upper()}.", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Approval failed: {msg[:40]}", "error")
|
|
|
|
|
|
|
|
|
|
def _open_job_conversation(self, job_name: str, agent: str):
|
|
|
|
|
"""Link to conversation thread for the selected job in Agent Chat 1 for reading / entry / scrollback."""
|
|
|
|
|
target_agent = agent if agent in self.data.nodes else self.data.active_node
|
|
|
|
|
target_thread_id = None
|
|
|
|
|
target_title = None
|
|
|
|
|
|
|
|
|
|
# 1. Check job-sidechats.json
|
|
|
|
|
sidechats_path = REPO_ROOT / "job-sidechats.json"
|
|
|
|
|
if sidechats_path.exists():
|
|
|
|
|
try:
|
|
|
|
|
with open(sidechats_path, "r", encoding="utf-8") as f:
|
|
|
|
|
sc_data = json.load(f)
|
|
|
|
|
|
|
|
|
|
keys_to_try = [
|
|
|
|
|
job_name,
|
|
|
|
|
f"{job_name}@{target_agent}",
|
|
|
|
|
f"{job_name}@{agent}",
|
|
|
|
|
f"{agent} tasks",
|
|
|
|
|
f"{agent}-tasks",
|
|
|
|
|
]
|
|
|
|
|
for k in keys_to_try:
|
|
|
|
|
if k in sc_data:
|
|
|
|
|
match = sc_data[k]
|
|
|
|
|
target_thread_id = match.get("thread_uuid") or match.get("session_id")
|
|
|
|
|
target_agent = match.get("agent", target_agent)
|
|
|
|
|
target_title = match.get("title") or match.get("description") or k
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not target_thread_id:
|
|
|
|
|
for k, match in sc_data.items():
|
|
|
|
|
if job_name in k or k in job_name:
|
|
|
|
|
target_thread_id = match.get("thread_uuid") or match.get("session_id")
|
|
|
|
|
target_agent = match.get("agent", target_agent)
|
|
|
|
|
target_title = match.get("title") or match.get("description") or k
|
|
|
|
|
break
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# 2. Check threads cache for the agent
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
threads = list(self.data.threads_cache.get(target_agent, []))
|
|
|
|
|
if not target_thread_id and threads:
|
|
|
|
|
for t in threads:
|
|
|
|
|
t_title = (t.get("title") or "").lower()
|
|
|
|
|
if job_name.lower() in t_title or t_title in job_name.lower():
|
|
|
|
|
target_thread_id = t.get("session_id")
|
|
|
|
|
target_title = t.get("title")
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not target_title:
|
|
|
|
|
target_title = f"job:{job_name}"
|
|
|
|
|
|
|
|
|
|
# Set active target
|
|
|
|
|
self.data.set_active_target(target_agent, target_thread_id, target_title)
|
|
|
|
|
|
|
|
|
|
# Trigger async fetch for threads & history
|
|
|
|
|
threading.Thread(target=self.data._fetch_threads, args=(target_agent,), daemon=True).start()
|
|
|
|
|
if target_thread_id:
|
|
|
|
|
threading.Thread(target=self.data._fetch_history, args=(target_agent, target_thread_id), daemon=True).start()
|
|
|
|
|
|
|
|
|
|
# Switch to Agent Chat (Tab 0)
|
|
|
|
|
self.box_tab = 0
|
|
|
|
|
self.focus_pane = "transcript"
|
|
|
|
|
self.auto_scroll = False
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
if target_thread_id:
|
|
|
|
|
self.set_toast(f"✔ Linked to conversation for '{job_name}' [{target_thread_id[:8]}] on {target_agent.upper()}", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"✔ Switched to {target_agent.upper()} for job '{job_name}'", "info")
|
|
|
|
|
|
|
|
|
|
def _async_trigger_job(self, job_name: str):
|
|
|
|
|
self.set_toast(f"Triggering job '{job_name}'...", "info")
|
|
|
|
|
ok, msg = self.data.trigger_job(job_name)
|
|
|
|
|
if ok:
|
|
|
|
|
self.set_toast(f"✔ Job '{job_name}' triggered successfully.", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Job trigger failed: {msg[:40]}", "error")
|
|
|
|
|
|
|
|
|
|
def _async_kill_tmux(self, sess_name: str):
|
|
|
|
|
self.set_toast(f"Terminating tmux session '{sess_name}'...", "info")
|
|
|
|
|
ok, msg = self.data.kill_tmux_session(sess_name)
|
|
|
|
|
if ok:
|
|
|
|
|
self.set_toast(f"✔ Session '{sess_name}' killed.", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Kill failed: {msg[:40]}", "error")
|
|
|
|
|
|
|
|
|
|
def _execute_chat_action(self, action_id: str):
|
|
|
|
|
"""Execute selected contextual action on the targeted chat thread."""
|
|
|
|
|
chat_info = getattr(self, "context_chat", None) or {}
|
|
|
|
|
t = chat_info.get("thread", {})
|
|
|
|
|
node = chat_info.get("node", self.data.active_node)
|
|
|
|
|
sid = t.get("session_id", "")
|
|
|
|
|
title = t.get("title") or (sid[:8] if sid else "Main Chat")
|
|
|
|
|
is_main = t.get("is_main", False)
|
|
|
|
|
|
|
|
|
|
if action_id == "open":
|
|
|
|
|
self.data.set_active_target(node, sid, title)
|
|
|
|
|
self.focus_pane = "transcript"
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
self.set_toast(f"Opened thread '{title}'", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "pin":
|
|
|
|
|
if sid:
|
|
|
|
|
new_pin = self.data.toggle_pin_thread(node, sid)
|
|
|
|
|
state = "Pinned" if new_pin else "Unpinned"
|
|
|
|
|
self.set_toast(f"📌 {state} chat: {title[:25]}", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "read":
|
|
|
|
|
if sid:
|
|
|
|
|
self.data.mark_thread_read(node, sid)
|
|
|
|
|
self.set_toast(f"✔ Marked '{title[:25]}' as read.", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "copy_id":
|
|
|
|
|
if sid:
|
|
|
|
|
copy_to_clipboard(sid)
|
|
|
|
|
self.set_toast(f"📋 Copied session ID: {sid}", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "copy_url":
|
|
|
|
|
if sid:
|
|
|
|
|
url = f"https://muse.ai/thread/{sid}"
|
|
|
|
|
copy_to_clipboard(url)
|
|
|
|
|
self.set_toast(f"🔗 Copied thread URL: {url}", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "reply":
|
|
|
|
|
self.data.set_active_target(node, sid, title)
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
msgs = list(self.data.history_cache.get((node, sid), []))
|
|
|
|
|
if msgs:
|
|
|
|
|
self.trigger_reply_to_message(msgs[-1])
|
|
|
|
|
else:
|
|
|
|
|
self.editor_mode = "INSERT"
|
|
|
|
|
self.set_toast(f"Switched to {title} (no messages to reply to)", "info")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "new":
|
|
|
|
|
self.data.set_active_target(node)
|
|
|
|
|
self.modal = "new_sidechat"
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
elif action_id == "rename":
|
|
|
|
|
self.modal = "rename_sidechat"
|
|
|
|
|
self.modal_input_buf = title
|
|
|
|
|
self.modal_input_cursor = len(title)
|
|
|
|
|
self.modal_extra = {"node": node, "session_id": sid}
|
|
|
|
|
return
|
|
|
|
|
elif action_id == "fork":
|
|
|
|
|
fork_title = f"{title[:20]} (fork)"
|
|
|
|
|
ok, new_sid, msg = self.data.create_sidechat(node, fork_title)
|
|
|
|
|
if ok and new_sid:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
orig_msgs = list(self.data.history_cache.get((node, sid), []))
|
|
|
|
|
if orig_msgs:
|
|
|
|
|
self.data.history_cache[(node, new_sid)] = [dict(m) for m in orig_msgs]
|
|
|
|
|
self.data.set_active_target(node, new_sid, fork_title)
|
|
|
|
|
self.set_toast(f"🌱 Forked sidechat: '{fork_title}'", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"Failed to fork sidechat: {msg[:40]}", "error")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "close":
|
|
|
|
|
if is_main:
|
|
|
|
|
self.set_toast("⚠️ Cannot close Main Chat.", "warn_banner")
|
|
|
|
|
else:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
threads = self.data.threads_cache.get(node, [])
|
|
|
|
|
self.data.threads_cache[node] = [th for th in threads if th.get("session_id") != sid]
|
|
|
|
|
if self.data.active_thread_id == sid:
|
|
|
|
|
self.data.select_main_chat(node)
|
|
|
|
|
self.set_toast(f"🗑 Closed sidechat '{title}'", "info")
|
|
|
|
|
self.modal = None
|
|
|
|
|
|
|
|
|
|
def _execute_agent_action(self, action_id: str):
|
|
|
|
|
"""Execute selected contextual action on the targeted fleet agent."""
|
|
|
|
|
agent_info = getattr(self, "context_agent", None) or {}
|
|
|
|
|
node = agent_info.get("node", self.data.active_node)
|
|
|
|
|
|
|
|
|
|
if action_id == "open":
|
|
|
|
|
self.data.set_active_target(node)
|
|
|
|
|
self.focus_pane = "transcript"
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
self.set_toast(f"Switched to agent: {node.upper()}", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "sync":
|
|
|
|
|
cd = self.data.get_node_cooldown_remaining(node)
|
|
|
|
|
if cd > 0:
|
|
|
|
|
now = time.time()
|
|
|
|
|
last_force = getattr(self, "_last_force_sync_time", 0.0)
|
|
|
|
|
if (now - last_force) <= 2.0:
|
|
|
|
|
self.data.clear_node_rate_limit(node)
|
|
|
|
|
self.data.lazy_fetch_threads(node, force=True)
|
|
|
|
|
self.data.trigger_refresh()
|
|
|
|
|
self.set_toast(f"⚡ Force-syncing {node.upper()} (cooldown bypassed)...", "info")
|
|
|
|
|
self._last_force_sync_time = 0.0
|
|
|
|
|
self.modal = None
|
|
|
|
|
else:
|
|
|
|
|
self._last_force_sync_time = now
|
|
|
|
|
self.set_toast(f"⏳ {node.upper()} cooling down ({cd}s left). Select Sync again within 2s to force.", "warn")
|
|
|
|
|
else:
|
|
|
|
|
self.data.lazy_fetch_threads(node, force=True)
|
|
|
|
|
self.data.trigger_refresh()
|
|
|
|
|
self.set_toast(f"Syncing threads for {node.upper()}...", "info")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "approvals":
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = [it for it in self.data.approvals_cache if it.get("node") == node]
|
|
|
|
|
self.modal = "approvals"
|
|
|
|
|
if apps:
|
|
|
|
|
self.set_toast(f"⚡ {len(apps)} pending approval(s) for {node.upper()}", "warn_banner")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"✔ 0 pending approvals for {node.upper()}", "success")
|
|
|
|
|
elif action_id == "read_all":
|
|
|
|
|
self.data.mark_node_read(node)
|
|
|
|
|
self.set_toast(f"✔ Marked all chats on {node.upper()} as read.", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "tmux":
|
|
|
|
|
cmd = f"tmux -S /tmp/tmux-muse.sock attach -t agent-{node}"
|
|
|
|
|
copy_to_clipboard(cmd)
|
|
|
|
|
self.set_toast(f"📋 Copied tmux command: {cmd}", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "wo":
|
|
|
|
|
self.modal = "work_order"
|
|
|
|
|
self.modal_input_buf = f"[{node.upper()}] "
|
|
|
|
|
self.modal_input_cursor = len(self.modal_input_buf)
|
|
|
|
|
elif action_id == "new":
|
|
|
|
|
self.data.set_active_target(node)
|
|
|
|
|
self.modal = "new_sidechat"
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
elif action_id == "focus":
|
|
|
|
|
self.data.set_active_target(node)
|
|
|
|
|
self.focus_pane = "sidebar"
|
|
|
|
|
self.set_toast(f"Focused sidebar on {node.upper()}", "info")
|
|
|
|
|
self.modal = None
|
|
|
|
|
|
|
|
|
|
def _execute_message_action(self, action_id: str):
|
|
|
|
|
"""Execute selected contextual action on the targeted message."""
|
|
|
|
|
m = getattr(self, "context_message", None) or {}
|
|
|
|
|
if not m:
|
|
|
|
|
self.modal = None
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if action_id == "reply":
|
|
|
|
|
self.trigger_reply_to_message(m)
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "copy":
|
|
|
|
|
self.copy_message_to_clipboard(m.get("text", ""), with_context=False)
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "copy_context":
|
|
|
|
|
self.copy_message_to_clipboard(m.get("text", ""), with_context=True)
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "pin":
|
|
|
|
|
text = m.get("text", "")
|
|
|
|
|
if text:
|
|
|
|
|
p, is_new = self.prompt_manager.pin_chat_send(text, node=self.data.active_node, toggle=True)
|
|
|
|
|
self.prompts = self.prompt_manager.get_sorted_prompts()
|
|
|
|
|
state = "Pinned to" if is_new else "Pinned"
|
|
|
|
|
self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
elif action_id == "copy_id":
|
|
|
|
|
mid = m.get("message_id") or str(m.get("seq", ""))
|
|
|
|
|
if mid:
|
|
|
|
|
copy_to_clipboard(mid)
|
|
|
|
|
self.set_toast(f"📋 Copied message ID: {mid}", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
|
|
|
|
|
def _handle_modal_key(self, ch: int) -> bool:
|
|
|
|
|
# Close on Esc or 'q' (except in text entry modals)
|
|
|
|
|
if ch in (27, ord('q'), ord('Q')) and self.modal not in ("new_sidechat", "work_order", "agent_search"):
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if self.modal in ("help",):
|
|
|
|
|
# Double-press ? or F1 or Space or Enter closes help
|
|
|
|
|
if ch in (curses.KEY_ENTER, 10, 13, ord(' '), ord('?'), curses.KEY_F1):
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.modal == "chat_context":
|
|
|
|
|
actions = getattr(self, "_chat_context_actions", [
|
|
|
|
|
("open", "", ""), ("pin", "", ""), ("read", "", ""),
|
|
|
|
|
("copy_id", "", ""), ("copy_url", "", ""), ("reply", "", ""),
|
|
|
|
|
("new", "", ""), ("close", "", "")
|
|
|
|
|
])
|
|
|
|
|
if ch in (27, ord('q'), ord('Q')):
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (ord('j'), ord('J'), curses.KEY_DOWN):
|
|
|
|
|
self.chat_context_sel = min(len(actions) - 1, getattr(self, "chat_context_sel", 0) + 1)
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('k'), ord('K'), curses.KEY_UP):
|
|
|
|
|
self.chat_context_sel = max(0, getattr(self, "chat_context_sel", 0) - 1)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (curses.KEY_ENTER, 10, 13, ord(' ')):
|
|
|
|
|
idx = max(0, min(getattr(self, "chat_context_sel", 0), len(actions) - 1))
|
|
|
|
|
self._execute_chat_action(actions[idx][0])
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
key_map = {
|
|
|
|
|
ord('1'): "open", ord('o'): "open", ord('O'): "open",
|
|
|
|
|
ord('2'): "pin", ord('p'): "pin", ord('P'): "pin",
|
|
|
|
|
ord('3'): "read", ord('m'): "read", ord('M'): "read",
|
|
|
|
|
ord('4'): "copy_id", ord('y'): "copy_id", ord('Y'): "copy_id",
|
|
|
|
|
ord('5'): "copy_url", ord('u'): "copy_url", ord('U'): "copy_url",
|
|
|
|
|
ord('6'): "reply", ord('r'): "reply", ord('R'): "reply",
|
|
|
|
|
ord('7'): "rename", ord('e'): "rename", ord('E'): "rename",
|
|
|
|
|
ord('8'): "fork", ord('b'): "fork", ord('B'): "fork",
|
|
|
|
|
ord('9'): "close", ord('d'): "close", ord('D'): "close",
|
|
|
|
|
}
|
|
|
|
|
if ch in key_map:
|
|
|
|
|
self._execute_chat_action(key_map[ch])
|
|
|
|
|
return True
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.modal == "rename_sidechat":
|
|
|
|
|
if ch == 27:
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
if ch in (curses.KEY_ENTER, 10, 13):
|
|
|
|
|
new_title = self.modal_input_buf.strip()
|
|
|
|
|
node = self.modal_extra.get("node", self.data.active_node)
|
|
|
|
|
sid = self.modal_extra.get("session_id", self.data.active_thread_id)
|
|
|
|
|
if new_title and sid:
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
threads = self.data.threads_cache.get(node, [])
|
|
|
|
|
for th in threads:
|
|
|
|
|
if th.get("session_id") == sid:
|
|
|
|
|
th["title"] = new_title
|
|
|
|
|
break
|
|
|
|
|
if self.data.active_thread_id == sid:
|
|
|
|
|
self.data.active_thread_title = new_title
|
|
|
|
|
self.data._save_threads_cache(node)
|
|
|
|
|
self.set_toast(f"✏ Renamed chat to '{new_title}'", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
if ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
|
|
|
if self.modal_input_cursor > 0:
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:]
|
|
|
|
|
self.modal_input_cursor -= 1
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_DC:
|
|
|
|
|
if self.modal_input_cursor < len(self.modal_input_buf):
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:]
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_LEFT:
|
|
|
|
|
if self.modal_input_cursor > 0:
|
|
|
|
|
self.modal_input_cursor -= 1
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_RIGHT:
|
|
|
|
|
if self.modal_input_cursor < len(self.modal_input_buf):
|
|
|
|
|
self.modal_input_cursor += 1
|
|
|
|
|
return True
|
|
|
|
|
elif 32 <= ch <= 126:
|
|
|
|
|
char = chr(ch)
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:]
|
|
|
|
|
self.modal_input_cursor += 1
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.modal == "agent_context":
|
|
|
|
|
actions = getattr(self, "_agent_context_actions", [
|
|
|
|
|
("open", "", ""), ("approvals", "", ""), ("tmux", "", ""),
|
|
|
|
|
("wo", "", ""), ("sync", "", ""), ("read_all", "", "")
|
|
|
|
|
])
|
|
|
|
|
if ch in (27, ord('q'), ord('Q')):
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (ord('j'), ord('J'), curses.KEY_DOWN):
|
|
|
|
|
self.agent_context_sel = min(len(actions) - 1, getattr(self, "agent_context_sel", 0) + 1)
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('k'), ord('K'), curses.KEY_UP):
|
|
|
|
|
self.agent_context_sel = max(0, getattr(self, "agent_context_sel", 0) - 1)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (curses.KEY_ENTER, 10, 13, ord(' ')):
|
|
|
|
|
idx = max(0, min(getattr(self, "agent_context_sel", 0), len(actions) - 1))
|
|
|
|
|
self._execute_agent_action(actions[idx][0])
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
key_map = {
|
|
|
|
|
ord('1'): "open", ord('o'): "open", ord('O'): "open",
|
|
|
|
|
ord('2'): "approvals", ord('a'): "approvals", ord('A'): "approvals",
|
|
|
|
|
ord('3'): "tmux", ord('t'): "tmux", ord('T'): "tmux",
|
|
|
|
|
ord('4'): "wo", ord('w'): "wo", ord('W'): "wo",
|
|
|
|
|
ord('5'): "sync", ord('s'): "sync", ord('S'): "sync",
|
|
|
|
|
ord('6'): "read_all", ord('m'): "read_all", ord('M'): "read_all",
|
|
|
|
|
}
|
|
|
|
|
if ch in key_map:
|
|
|
|
|
self._execute_agent_action(key_map[ch])
|
|
|
|
|
return True
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.modal == "message_context":
|
|
|
|
|
actions = getattr(self, "_message_context_actions", [
|
|
|
|
|
("reply", "", ""), ("copy", "", ""), ("copy_context", "", ""), ("pin", "", ""), ("copy_id", "", "")
|
|
|
|
|
])
|
|
|
|
|
if ch in (27, ord('q'), ord('Q')):
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (ord('j'), ord('J'), curses.KEY_DOWN):
|
|
|
|
|
self.message_context_sel = min(len(actions) - 1, getattr(self, "message_context_sel", 0) + 1)
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('k'), ord('K'), curses.KEY_UP):
|
|
|
|
|
self.message_context_sel = max(0, getattr(self, "message_context_sel", 0) - 1)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (curses.KEY_ENTER, 10, 13, ord(' ')):
|
|
|
|
|
idx = max(0, min(getattr(self, "message_context_sel", 0), len(actions) - 1))
|
|
|
|
|
self._execute_message_action(actions[idx][0])
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
key_map = {
|
|
|
|
|
ord('1'): "reply", ord('r'): "reply", ord('R'): "reply",
|
|
|
|
|
ord('2'): "copy", ord('y'): "copy",
|
|
|
|
|
ord('3'): "copy_context", ord('Y'): "copy_context",
|
|
|
|
|
ord('4'): "pin", ord('p'): "pin", ord('P'): "pin",
|
|
|
|
|
ord('5'): "copy_id", ord('c'): "copy_id", ord('C'): "copy_id",
|
|
|
|
|
}
|
|
|
|
|
if ch in key_map:
|
|
|
|
|
self._execute_message_action(key_map[ch])
|
|
|
|
|
return True
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.modal == "approvals":
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
apps = [it for it in self.data.approvals_cache if it.get("status") in ("PENDING", "INPUT_WAIT", "KEY_APPROVAL")]
|
|
|
|
|
|
|
|
|
|
# Pressing 'a' or F2 again closes approvals modal
|
|
|
|
|
if ch in (ord('a'), ord('A'), curses.KEY_F2):
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (ord('j'), ord('J'), curses.KEY_DOWN):
|
|
|
|
|
if apps:
|
|
|
|
|
self.approvals_sel_idx = min(len(apps) - 1, getattr(self, "approvals_sel_idx", 0) + 1)
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('k'), ord('K'), curses.KEY_UP):
|
|
|
|
|
if apps:
|
|
|
|
|
self.approvals_sel_idx = max(0, getattr(self, "approvals_sel_idx", 0) - 1)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
self.approvals_sel_idx = max(0, min(getattr(self, "approvals_sel_idx", 0), len(apps) - 1)) if apps else 0
|
|
|
|
|
sel_node = apps[self.approvals_sel_idx].get("node") if apps else self.data.active_node
|
|
|
|
|
|
|
|
|
|
if ch == ord('1'):
|
|
|
|
|
self._async_resolve_approval("allow", sel_node)
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
elif ch == ord('2'):
|
|
|
|
|
self._async_resolve_approval("always", sel_node)
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
elif ch == ord('3'):
|
|
|
|
|
self._async_resolve_approval("deny", sel_node)
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('r'), ord('R')):
|
|
|
|
|
self._async_resolve_approval("reply", sel_node)
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('x'), ord('X')):
|
|
|
|
|
self._async_resolve_approval("dismiss", sel_node)
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('c'), ord('C')):
|
|
|
|
|
self._async_resolve_approval("clear_all")
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('p'), ord('P')):
|
|
|
|
|
self._async_resolve_approval("auto")
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.modal == "agent_search":
|
|
|
|
|
q = self.modal_input_buf.strip().lower()
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
all_nodes = list(self.data.nodes)
|
|
|
|
|
matches = [n for n in all_nodes if q in n.lower()] if q else all_nodes
|
|
|
|
|
|
|
|
|
|
# Esc closes
|
|
|
|
|
if ch == 27:
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Enter selects
|
|
|
|
|
if ch in (curses.KEY_ENTER, 10, 13):
|
|
|
|
|
if matches:
|
|
|
|
|
idx = max(0, min(getattr(self, "agent_search_sel", 0), len(matches) - 1))
|
|
|
|
|
target_node = matches[idx]
|
|
|
|
|
self.data.set_active_target(target_node)
|
|
|
|
|
self.auto_scroll = True
|
|
|
|
|
self.transcript_scroll_offset = 0
|
|
|
|
|
self.set_toast(f"Switched to agent: {target_node.upper()}", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Navigation Up / Down
|
|
|
|
|
if ch in (curses.KEY_UP, 16): # Up or Ctrl+P
|
|
|
|
|
if matches:
|
|
|
|
|
self.agent_search_sel = max(0, getattr(self, "agent_search_sel", 0) - 1)
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (curses.KEY_DOWN, 14): # Down or Ctrl+N
|
|
|
|
|
if matches:
|
|
|
|
|
self.agent_search_sel = min(len(matches) - 1, getattr(self, "agent_search_sel", 0) + 1)
|
|
|
|
|
return True
|
|
|
|
|
elif ch == ord('\t'): # Tab cycles forward
|
|
|
|
|
if matches:
|
|
|
|
|
self.agent_search_sel = (getattr(self, "agent_search_sel", 0) + 1) % len(matches)
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_BTAB: # Shift-Tab cycles backward
|
|
|
|
|
if matches:
|
|
|
|
|
self.agent_search_sel = (getattr(self, "agent_search_sel", 0) - 1) % len(matches)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Text input handling
|
|
|
|
|
if ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
|
|
|
if self.modal_input_cursor > 0:
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:]
|
|
|
|
|
self.modal_input_cursor -= 1
|
|
|
|
|
self.agent_search_sel = 0
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_DC:
|
|
|
|
|
if self.modal_input_cursor < len(self.modal_input_buf):
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:]
|
|
|
|
|
self.agent_search_sel = 0
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_LEFT:
|
|
|
|
|
if self.modal_input_cursor > 0:
|
|
|
|
|
self.modal_input_cursor -= 1
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_RIGHT:
|
|
|
|
|
if self.modal_input_cursor < len(self.modal_input_buf):
|
|
|
|
|
self.modal_input_cursor += 1
|
|
|
|
|
return True
|
|
|
|
|
elif 32 <= ch <= 126:
|
|
|
|
|
char = chr(ch)
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:]
|
|
|
|
|
self.modal_input_cursor += 1
|
|
|
|
|
self.agent_search_sel = 0
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.modal in ("new_sidechat", "work_order"):
|
|
|
|
|
if ch in (curses.KEY_ENTER, 10, 13):
|
|
|
|
|
val = self.modal_input_buf.strip()
|
|
|
|
|
if self.modal == "new_sidechat":
|
|
|
|
|
if val:
|
|
|
|
|
threading.Thread(target=self._async_create_sidechat, args=(val,), daemon=True).start()
|
|
|
|
|
elif self.modal == "work_order":
|
|
|
|
|
if val:
|
|
|
|
|
parts = val.split(" ", 1)
|
|
|
|
|
title = parts[0]
|
|
|
|
|
body = parts[1] if len(parts) > 1 else title
|
|
|
|
|
threading.Thread(target=self._async_issue_wo, args=(title, body), daemon=True).start()
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
|
|
|
if self.modal_input_cursor > 0:
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:]
|
|
|
|
|
self.modal_input_cursor -= 1
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_DC:
|
|
|
|
|
if self.modal_input_cursor < len(self.modal_input_buf):
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:]
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_LEFT:
|
|
|
|
|
if self.modal_input_cursor > 0:
|
|
|
|
|
self.modal_input_cursor -= 1
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_RIGHT:
|
|
|
|
|
if self.modal_input_cursor < len(self.modal_input_buf):
|
|
|
|
|
self.modal_input_cursor += 1
|
|
|
|
|
return True
|
|
|
|
|
elif 32 <= ch <= 126:
|
|
|
|
|
char = chr(ch)
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:]
|
|
|
|
|
self.modal_input_cursor += 1
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.modal == "prompts":
|
|
|
|
|
if ch in (27, ord('q')):
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (curses.KEY_ENTER, 10, 13):
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
prompts = list(self.prompts)
|
|
|
|
|
if prompts and 0 <= self.prompt_sel_idx < len(prompts):
|
|
|
|
|
p = prompts[self.prompt_sel_idx]
|
|
|
|
|
self.input_buf = p.get("prompt", "")
|
|
|
|
|
self.input_cursor = len(self.input_buf)
|
|
|
|
|
self.editor_mode = "INSERT"
|
|
|
|
|
self.set_toast(f"✔ Filled prompt: '{p.get('title')}'", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (curses.KEY_UP, ord('k')):
|
|
|
|
|
self.prompt_sel_idx = max(0, self.prompt_sel_idx - 1)
|
|
|
|
|
self.prompt_preview_scroll = 0
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (curses.KEY_DOWN, ord('j')):
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
prompts_len = len(self.prompts)
|
|
|
|
|
self.prompt_sel_idx = min(max(0, prompts_len - 1), self.prompt_sel_idx + 1)
|
|
|
|
|
self.prompt_preview_scroll = 0
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (curses.KEY_RIGHT, ord('l')):
|
|
|
|
|
self.prompt_preview_scroll += 1
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (curses.KEY_LEFT, ord('h')):
|
|
|
|
|
self.prompt_preview_scroll = max(0, self.prompt_preview_scroll - 1)
|
|
|
|
|
return True
|
|
|
|
|
elif ch == ord('g'):
|
|
|
|
|
self.prompt_sel_idx = 0
|
|
|
|
|
self.prompt_preview_scroll = 0
|
|
|
|
|
return True
|
|
|
|
|
elif ch == ord('G'):
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
prompts_len = len(self.prompts)
|
|
|
|
|
self.prompt_sel_idx = max(0, prompts_len - 1)
|
|
|
|
|
self.prompt_preview_scroll = 0
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('p'), ord('P')):
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
prompts = list(self.prompts)
|
|
|
|
|
if prompts and 0 <= self.prompt_sel_idx < len(prompts):
|
|
|
|
|
p = prompts[self.prompt_sel_idx]
|
|
|
|
|
new_pin = self.prompt_manager.toggle_pin_prompt(p["id"])
|
|
|
|
|
self.prompts = self.prompt_manager.get_sorted_prompts()
|
|
|
|
|
state = "Pinned" if new_pin else "Unpinned"
|
|
|
|
|
self.set_toast(f"📌 {state} prompt: '{p['title']}'", "success")
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('d'), ord('x')):
|
|
|
|
|
with self.data.lock:
|
|
|
|
|
prompts = list(self.prompts)
|
|
|
|
|
if prompts and 0 <= self.prompt_sel_idx < len(prompts):
|
|
|
|
|
p = prompts[self.prompt_sel_idx]
|
|
|
|
|
self.prompt_manager.delete_prompt(p["id"])
|
|
|
|
|
self.prompts = self.prompt_manager.get_sorted_prompts()
|
|
|
|
|
self.prompt_sel_idx = max(0, min(self.prompt_sel_idx, len(self.prompts) - 1))
|
|
|
|
|
self.set_toast(f"Deleted prompt: '{p['title']}'", "warn_banner")
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('n'), ord('a')):
|
|
|
|
|
self.modal = "prompt_formulate"
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (ord('s'), ord('S')):
|
|
|
|
|
self.modal = "history_search"
|
|
|
|
|
self.modal_input_buf = ""
|
|
|
|
|
self.modal_input_cursor = 0
|
|
|
|
|
self.history_search_sel = 0
|
|
|
|
|
self.history_search_scroll = 0
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.modal == "history_search":
|
|
|
|
|
q = self.modal_input_buf.strip().lower()
|
|
|
|
|
sends = self.get_chat_sends(q)
|
|
|
|
|
|
|
|
|
|
if ch == 27:
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (curses.KEY_ENTER, 10, 13):
|
|
|
|
|
if sends and 0 <= self.history_search_sel < len(sends):
|
|
|
|
|
send = sends[self.history_search_sel]
|
|
|
|
|
self.input_buf = send.get("text", "")
|
|
|
|
|
self.input_cursor = len(self.input_buf)
|
|
|
|
|
self.editor_mode = "INSERT"
|
|
|
|
|
self.set_toast(f"✔ Filled from chat history: '{send.get('text', '')[:25]}...'", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (ord('p'), ord('P'), 16):
|
|
|
|
|
if sends and 0 <= self.history_search_sel < len(sends):
|
|
|
|
|
send = sends[self.history_search_sel]
|
|
|
|
|
p, is_new = self.prompt_manager.pin_chat_send(send.get("text", ""), node=send.get("node", "muse"), toggle=True)
|
|
|
|
|
self.prompts = self.prompt_manager.get_sorted_prompts()
|
|
|
|
|
send["is_pinned"] = p.get("pinned", False)
|
|
|
|
|
if p.get("pinned"):
|
|
|
|
|
state = "Pinned to" if is_new else "Pinned"
|
|
|
|
|
self.set_toast(f"📌 {state} prompt fill: '{p['title']}'", "success")
|
|
|
|
|
else:
|
|
|
|
|
self.set_toast(f"📌 Unpinned from prompt fill: '{p['title']}'", "warn_banner")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (curses.KEY_UP,):
|
|
|
|
|
if sends:
|
|
|
|
|
self.history_search_sel = max(0, self.history_search_sel - 1)
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (curses.KEY_DOWN, 14):
|
|
|
|
|
if sends:
|
|
|
|
|
self.history_search_sel = min(len(sends) - 1, self.history_search_sel + 1)
|
|
|
|
|
return True
|
|
|
|
|
elif ch == ord('\t'):
|
|
|
|
|
if sends:
|
|
|
|
|
self.history_search_sel = (self.history_search_sel + 1) % len(sends)
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_BTAB:
|
|
|
|
|
if sends:
|
|
|
|
|
self.history_search_sel = (self.history_search_sel - 1) % len(sends)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
|
|
|
if self.modal_input_cursor > 0:
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:]
|
|
|
|
|
self.modal_input_cursor -= 1
|
|
|
|
|
self.history_search_sel = 0
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_DC:
|
|
|
|
|
if self.modal_input_cursor < len(self.modal_input_buf):
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:]
|
|
|
|
|
self.history_search_sel = 0
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_LEFT:
|
|
|
|
|
if self.modal_input_cursor > 0:
|
|
|
|
|
self.modal_input_cursor -= 1
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_RIGHT:
|
|
|
|
|
if self.modal_input_cursor < len(self.modal_input_buf):
|
|
|
|
|
self.modal_input_cursor += 1
|
|
|
|
|
return True
|
|
|
|
|
elif 32 <= ch <= 126:
|
|
|
|
|
char = chr(ch)
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:]
|
|
|
|
|
self.modal_input_cursor += 1
|
|
|
|
|
self.history_search_sel = 0
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
elif self.modal == "prompt_formulate":
|
|
|
|
|
if ch == 27:
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (curses.KEY_ENTER, 10, 13):
|
|
|
|
|
title = self.modal_input_buf.strip() or "Custom Prompt"
|
|
|
|
|
body = self.modal_extra.get("body") or self.input_buf.strip() or title
|
|
|
|
|
p = self.prompt_manager.add_prompt(title, body, tags=["custom", "formulated"], pinned=True)
|
|
|
|
|
self.prompts = self.prompt_manager.get_sorted_prompts()
|
|
|
|
|
self.set_toast(f"✔ Stored prompt skill: '{p['title']}'", "success")
|
|
|
|
|
self.modal = None
|
|
|
|
|
return True
|
|
|
|
|
elif ch in (curses.KEY_BACKSPACE, 127, 8):
|
|
|
|
|
if self.modal_input_cursor > 0:
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor - 1] + self.modal_input_buf[self.modal_input_cursor:]
|
|
|
|
|
self.modal_input_cursor -= 1
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_DC:
|
|
|
|
|
if self.modal_input_cursor < len(self.modal_input_buf):
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + self.modal_input_buf[self.modal_input_cursor + 1:]
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_LEFT:
|
|
|
|
|
if self.modal_input_cursor > 0:
|
|
|
|
|
self.modal_input_cursor -= 1
|
|
|
|
|
return True
|
|
|
|
|
elif ch == curses.KEY_RIGHT:
|
|
|
|
|
if self.modal_input_cursor < len(self.modal_input_buf):
|
|
|
|
|
self.modal_input_cursor += 1
|
|
|
|
|
return True
|
|
|
|
|
elif 32 <= ch <= 126:
|
|
|
|
|
char = chr(ch)
|
|
|
|
|
self.modal_input_buf = self.modal_input_buf[:self.modal_input_cursor] + char + self.modal_input_buf[self.modal_input_cursor:]
|
|
|
|
|
self.modal_input_cursor += 1
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# CLI Entrypoint
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
def main():
|
|
|
|
|
import argparse
|
|
|
|
|
parser = argparse.ArgumentParser(description="Muse & Box Interactive TUI")
|
|
|
|
|
parser.add_argument("--account", "-a", choices=VALID_NODES, default=None, help="Initial agent account (default: top of list)")
|
|
|
|
|
parser.add_argument("--thread", "-t", help="Initial thread UUID to open")
|
|
|
|
|
parser.add_argument("--mode", "-m", choices=["muse", "box"], default="muse", help="TUI mode (default: muse)")
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
curses.wrapper(lambda stdscr: MuseTUI(
|
|
|
|
|
stdscr,
|
|
|
|
|
initial_mode=args.mode,
|
|
|
|
|
initial_node=args.account,
|
|
|
|
|
initial_thread=args.thread
|
|
|
|
|
).run())
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
pass
|
|
|
|
|
except Exception as e:
|
|
|
|
|
crash_log = REPO_ROOT / "logs" / "tui-crash.log"
|
|
|
|
|
print(f"\n[!] Muse TUI error: {e}", file=sys.stderr)
|
|
|
|
|
print(f" Traceback logged to: {crash_log}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|