2026-10-06 07:55:27 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
test_context_menus.py — Unit and integration tests for Right-Click function(s)
|
|
|
|
|
and Context Action Menus in NetVM/Muse TUI:
|
|
|
|
|
1. Right-click on Chat List (sidebar sidechats/threads) opens `chat_context` menu.
|
|
|
|
|
2. Right-click on Fleet Agents panel opens `agent_context` menu.
|
|
|
|
|
3. Right-click on Transcript message opens `message_context` menu.
|
|
|
|
|
4. Chat Context Menu actions (Open, Pin/Unpin, Mark as Read, Copy ID, Copy URL, Reply, Close).
|
|
|
|
|
5. Fleet Agent Context Menu actions (Open, Sync, Approvals, Mark All Read, Tmux, Work Order).
|
|
|
|
|
6. Message Context Menu actions (Reply, Copy Text, Pin to Prompts, Copy ID).
|
|
|
|
|
7. Keyboard navigation (j/k, 1-8, Enter) and mouse click selection inside context menus.
|
|
|
|
|
8. Normal mode hotkeys ('x' / 'c') and slash commands (/context chat, /context agent).
|
|
|
|
|
"""
|
|
|
|
|
import curses
|
|
|
|
|
import importlib.util
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
|
|
|
|
|
|
|
|
|
|
spec = importlib.util.spec_from_file_location("muse_tui", MUSE_TUI_PATH)
|
|
|
|
|
muse_tui = importlib.util.module_from_spec(spec)
|
|
|
|
|
sys.modules["muse_tui"] = muse_tui
|
|
|
|
|
spec.loader.exec_module(muse_tui)
|
|
|
|
|
|
|
|
|
|
MuseTUI = muse_tui.MuseTUI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestContextMenus(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.mock_stdscr = MagicMock()
|
|
|
|
|
self.mock_stdscr.getmaxyx.return_value = (30, 100)
|
|
|
|
|
self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
|
|
|
|
|
self.tui.safe_addstr = MagicMock()
|
|
|
|
|
|
|
|
|
|
# Seed mock threads
|
|
|
|
|
self.threads = [
|
|
|
|
|
{"session_id": "sess-main-1234", "title": "Main Chat", "is_main": True, "pinned": False},
|
|
|
|
|
{"session_id": "sess-side-5678", "title": "Audit Sidechat", "is_main": False, "pinned": True},
|
|
|
|
|
{"session_id": "sess-side-9999", "title": "Dev Sidechat", "is_main": False, "pinned": False},
|
|
|
|
|
]
|
|
|
|
|
with self.tui.data.lock:
|
|
|
|
|
self.tui.data.pinned_threads["muse"] = set()
|
|
|
|
|
self.tui.data.threads_cache["muse"] = list(self.threads)
|
|
|
|
|
self.tui.data.active_thread_id = "sess-main-1234"
|
|
|
|
|
self.tui.data.active_thread_title = "Main Chat"
|
|
|
|
|
self.tui.data.history_cache[("muse", "sess-main-1234")] = [
|
|
|
|
|
{"role": "user", "text": "Hello muse", "seq": 1, "message_id": "mid-user-1"},
|
|
|
|
|
{"role": "assistant", "text": "System ready", "seq": 2, "message_id": "mid-agent-2"},
|
|
|
|
|
]
|
|
|
|
|
self.tui.data.history_cache[("muse", "sess-side-5678")] = [
|
|
|
|
|
{"role": "user", "text": "Review log", "seq": 3, "message_id": "mid-user-3"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def test_right_click_on_chat_list_opens_chat_context(self):
|
|
|
|
|
"""Right-clicking a thread row in sidebar opens chat_context modal."""
|
|
|
|
|
# Screen layout: content_y = 1, visible_agents = 6, divider_y = 8, threads_hdr_y = 9, list_y = 10
|
|
|
|
|
# Right click on thread index 1 (sess-side-5678) at my = 11, mx = 10
|
|
|
|
|
bstate = curses.BUTTON3_CLICKED
|
|
|
|
|
self.tui._handle_mouse(mx=10, my=11, bstate=bstate)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(self.tui.modal, "chat_context")
|
|
|
|
|
self.assertIsNotNone(self.tui.context_chat)
|
|
|
|
|
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
|
|
|
|
|
self.assertEqual(self.tui.context_chat["thread"]["title"], "Audit Sidechat")
|
|
|
|
|
|
|
|
|
|
def test_right_click_on_fleet_agent_opens_agent_context(self):
|
|
|
|
|
"""Right-clicking an agent row in sidebar opens agent_context modal."""
|
|
|
|
|
# Row content_y + 1 = 2 is first agent (index 0 = 'muse')
|
|
|
|
|
# Row content_y + 2 = 3 is second agent
|
|
|
|
|
bstate = getattr(curses, "BUTTON3_PRESSED", 0x800)
|
|
|
|
|
with self.tui.data.lock:
|
|
|
|
|
second_agent = self.tui.data.nodes[1] if len(self.tui.data.nodes) > 1 else "muse"
|
|
|
|
|
|
|
|
|
|
self.tui._handle_mouse(mx=10, my=3, bstate=bstate)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(self.tui.modal, "agent_context")
|
|
|
|
|
self.assertIsNotNone(self.tui.context_agent)
|
|
|
|
|
self.assertEqual(self.tui.context_agent["node"], second_agent)
|
|
|
|
|
|
|
|
|
|
def test_right_click_on_transcript_opens_message_context(self):
|
|
|
|
|
"""Right-clicking a message in transcript opens message_context modal."""
|
|
|
|
|
# Populate rendered lines in transcript
|
|
|
|
|
self.tui._render_transcript(1, 26, 25, 74)
|
|
|
|
|
# Click on transcript area mx = 50, my = 5
|
|
|
|
|
bstate = curses.BUTTON3_CLICKED
|
|
|
|
|
self.tui._handle_mouse(mx=50, my=5, bstate=bstate)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(self.tui.modal, "message_context")
|
|
|
|
|
self.assertIsNotNone(self.tui.context_message)
|
|
|
|
|
self.assertIn("text", self.tui.context_message)
|
|
|
|
|
|
|
|
|
|
def test_chat_context_pin_action(self):
|
|
|
|
|
"""Executing 'pin' action in chat_context toggles pin state."""
|
|
|
|
|
t = {"session_id": "sess-side-9999", "title": "Dev Sidechat", "pinned": False}
|
|
|
|
|
self.tui.context_chat = {"node": "muse", "thread": t, "index": 2}
|
|
|
|
|
self.tui.modal = "chat_context"
|
|
|
|
|
|
|
|
|
|
# Press 'p'
|
|
|
|
|
self.tui._handle_modal_key(ord('p'))
|
|
|
|
|
self.assertIsNone(self.tui.modal)
|
|
|
|
|
self.assertIn("📌 Pinned chat", self.tui.toast_msg)
|
|
|
|
|
self.assertIn("sess-side-9999", self.tui.data.pinned_threads.get("muse", set()))
|
|
|
|
|
|
|
|
|
|
def test_chat_context_mark_read_action(self):
|
|
|
|
|
"""Executing 'read' action marks thread as read."""
|
|
|
|
|
t = {"session_id": "sess-side-5678", "title": "Audit Sidechat"}
|
|
|
|
|
self.tui.context_chat = {"node": "muse", "thread": t, "index": 1}
|
|
|
|
|
self.tui.modal = "chat_context"
|
|
|
|
|
|
|
|
|
|
self.tui._handle_modal_key(ord('m'))
|
|
|
|
|
self.assertIsNone(self.tui.modal)
|
|
|
|
|
self.assertIn("Marked 'Audit Sidechat' as read", self.tui.toast_msg)
|
|
|
|
|
self.assertEqual(self.tui.data.get_unread_count("muse", "sess-side-5678"), 0)
|
|
|
|
|
|
|
|
|
|
def test_chat_context_copy_id_action(self):
|
|
|
|
|
"""Executing 'copy_id' action copies session ID to clipboard."""
|
|
|
|
|
t = {"session_id": "sess-side-5678", "title": "Audit Sidechat"}
|
|
|
|
|
self.tui.context_chat = {"node": "muse", "thread": t, "index": 1}
|
|
|
|
|
self.tui.modal = "chat_context"
|
|
|
|
|
|
|
|
|
|
mod = sys.modules.get(MuseTUI.__module__, muse_tui)
|
|
|
|
|
with patch.object(mod, "copy_to_clipboard") as mock_cp1, \
|
|
|
|
|
patch.object(muse_tui, "copy_to_clipboard") as mock_cp2:
|
|
|
|
|
self.tui._handle_modal_key(ord('y'))
|
|
|
|
|
self.assertTrue(mock_cp1.called or mock_cp2.called)
|
|
|
|
|
self.assertIn("Copied session ID", self.tui.toast_msg)
|
|
|
|
|
|
|
|
|
|
def test_chat_context_reply_action(self):
|
|
|
|
|
"""Executing 'reply' action switches to chat and quotes latest message."""
|
|
|
|
|
t = {"session_id": "sess-main-1234", "title": "Main Chat"}
|
|
|
|
|
self.tui.context_chat = {"node": "muse", "thread": t, "index": 0}
|
|
|
|
|
self.tui.modal = "chat_context"
|
|
|
|
|
|
|
|
|
|
self.tui._handle_modal_key(ord('r'))
|
|
|
|
|
self.assertIsNone(self.tui.modal)
|
|
|
|
|
self.assertIsNotNone(self.tui.reply_target)
|
|
|
|
|
self.assertEqual(self.tui.reply_target["role"], "assistant")
|
|
|
|
|
self.assertIn("System ready", self.tui.reply_target["text"])
|
|
|
|
|
|
|
|
|
|
def test_agent_context_actions(self):
|
|
|
|
|
"""Agent context menu hotkeys execute appropriate agent actions."""
|
|
|
|
|
self.tui.context_agent = {"node": "muse"}
|
|
|
|
|
self.tui.modal = "agent_context"
|
|
|
|
|
|
|
|
|
|
# Mark all chats on agent as read ('m')
|
|
|
|
|
self.tui._handle_modal_key(ord('m'))
|
|
|
|
|
self.assertIsNone(self.tui.modal)
|
|
|
|
|
self.assertIn("Marked all chats on MUSE as read", self.tui.toast_msg)
|
|
|
|
|
|
|
|
|
|
# Open chat with agent ('1')
|
|
|
|
|
self.tui.context_agent = {"node": "muse"}
|
|
|
|
|
self.tui.modal = "agent_context"
|
|
|
|
|
self.tui._handle_modal_key(ord('1'))
|
|
|
|
|
self.assertIsNone(self.tui.modal)
|
|
|
|
|
self.assertIn("Switched to agent", self.tui.toast_msg)
|
|
|
|
|
|
|
|
|
|
def test_normal_mode_x_opens_chat_context(self):
|
|
|
|
|
"""Pressing 'x' in normal mode when focused on sidebar opens chat_context."""
|
|
|
|
|
self.tui.editor_mode = "NORMAL"
|
|
|
|
|
self.tui.focus_pane = "sidebar"
|
|
|
|
|
self.tui.thread_sel_idx = 1
|
|
|
|
|
|
|
|
|
|
handled = self.tui._handle_key(ord('x'))
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "chat_context")
|
|
|
|
|
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
def test_mouse_motion_does_not_dismiss_context_menu(self):
|
|
|
|
|
"""Moving the mouse (is_drag=True) over screen after context popup opens does NOT close it."""
|
|
|
|
|
# Simulate right click to open message context
|
|
|
|
|
# Transcript area starts around y=4
|
|
|
|
|
handled = self.tui._handle_mouse(50, 6, curses.BUTTON3_CLICKED)
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "message_context")
|
|
|
|
|
|
|
|
|
|
# Now simulate mouse motion event (REPORT_MOUSE_POSITION) anywhere
|
|
|
|
|
handled_motion = self.tui._handle_mouse(10, 10, curses.REPORT_MOUSE_POSITION)
|
|
|
|
|
self.assertTrue(handled_motion)
|
|
|
|
|
# Modal must remain open!
|
|
|
|
|
self.assertEqual(self.tui.modal, "message_context")
|
|
|
|
|
|
|
|
|
|
def test_middle_click_opens_context_menu(self):
|
|
|
|
|
"""Middle click (BUTTON2_CLICKED) also opens context menu for terminals intercepting button 3."""
|
|
|
|
|
self.tui.modal = None
|
|
|
|
|
handled = self.tui._handle_mouse(50, 6, curses.BUTTON2_CLICKED)
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "message_context")
|
|
|
|
|
|
|
|
|
|
def test_header_menu_button_opens_chat_context(self):
|
|
|
|
|
"""Left-clicking the [⚡ Menu] action button in the thread header opens chat_context."""
|
|
|
|
|
self.tui.modal = None
|
|
|
|
|
# Thread header button bounds: (btn_y, btn_x_start, btn_x_end)
|
|
|
|
|
self.tui._btn_menu_bounds = (1, 70, 78)
|
|
|
|
|
handled = self.tui._handle_mouse(72, 1, curses.BUTTON1_CLICKED)
|
|
|
|
|
self.assertTrue(handled)
|
2026-10-06 07:55:27 +00:00
|
|
|
self.assertEqual(self.tui.modal, "chat_context")
|
|
|
|
|
|
2026-10-06 19:29:22 +00:00
|
|
|
def test_message_menu_button_opens_message_context(self):
|
|
|
|
|
"""Left-clicking the [⚡ Menu] button on a message row opens message_context."""
|
2026-10-06 07:55:27 +00:00
|
|
|
self.tui.modal = None
|
2026-10-06 19:29:22 +00:00
|
|
|
# Tuple: (msg_y, btn_x_start, btn_x_end, msg_obj)
|
|
|
|
|
self.tui._msg_menu_buttons = [
|
|
|
|
|
(5, 75, 83, {"role": "user", "text": "Hello muse", "seq": 1})
|
|
|
|
|
]
|
|
|
|
|
handled = self.tui._handle_mouse(78, 5, curses.BUTTON1_CLICKED)
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "message_context")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fleet_and_sidechats_separate_focus_partitions(self):
|
|
|
|
|
"""Fleet and Sidechats operate as separate focus partitions with independent scroll & nav."""
|
|
|
|
|
self.tui.focus_pane = "fleet"
|
|
|
|
|
self.tui.editor_mode = "NORMAL"
|
|
|
|
|
self.tui.fleet_focus_idx = 0
|
|
|
|
|
|
|
|
|
|
# Navigating down with 'j' in fleet partition advances fleet_focus_idx
|
|
|
|
|
self.tui._handle_key(ord('j'))
|
|
|
|
|
self.assertEqual(self.tui.fleet_focus_idx, 1)
|
|
|
|
|
|
|
|
|
|
# 'l' switches from fleet to sidechats partition
|
|
|
|
|
self.tui._handle_key(ord('l'))
|
|
|
|
|
self.assertEqual(self.tui.focus_pane, "sidechats")
|
|
|
|
|
|
|
|
|
|
# 'j' in sidechats advances thread_sel_idx
|
|
|
|
|
self.tui.thread_sel_idx = 0
|
|
|
|
|
self.tui._handle_key(ord('j'))
|
|
|
|
|
self.assertEqual(self.tui.thread_sel_idx, 1)
|
|
|
|
|
|
|
|
|
|
# 'h' switches back from sidechats to fleet
|
|
|
|
|
self.tui._handle_key(ord('h'))
|
|
|
|
|
self.assertEqual(self.tui.focus_pane, "fleet")
|
|
|
|
|
|
|
|
|
|
def test_mouse_wheel_in_fleet_partition_scrolls_fleet_reliably(self):
|
|
|
|
|
"""Mouse wheel scrolling over Fleet Agents partition adjusts fleet scrolling reliably."""
|
|
|
|
|
self.tui.focus_pane = "fleet"
|
|
|
|
|
self.tui.fleet_focus_idx = 0
|
|
|
|
|
|
|
|
|
|
# Scroll down in fleet partition (bstate = BUTTON5_PRESSED)
|
|
|
|
|
bstate_down = getattr(curses, "BUTTON5_PRESSED", 0x200000)
|
|
|
|
|
self.tui._handle_mouse(mx=10, my=2, bstate=bstate_down)
|
|
|
|
|
self.assertGreaterEqual(self.tui.fleet_focus_idx, 1)
|
|
|
|
|
|
|
|
|
|
# Scroll up in fleet partition (bstate = BUTTON4_PRESSED)
|
|
|
|
|
bstate_up = getattr(curses, "BUTTON4_PRESSED", 0x10000)
|
|
|
|
|
self.tui._handle_mouse(mx=10, my=2, bstate=bstate_up)
|
|
|
|
|
self.assertEqual(self.tui.fleet_focus_idx, 0)
|
|
|
|
|
|
|
|
|
|
def test_sidebar_thread_menu_button_click(self):
|
|
|
|
|
"""Left-clicking the [⚡] button on a sidebar thread row opens chat_context."""
|
|
|
|
|
self.tui.modal = None
|
|
|
|
|
# Sidebar width is 26 for w=100.
|
|
|
|
|
# Button [⚡] is rendered around sidebar_w - 9 (col 17)
|
|
|
|
|
# Row 11 is thread 1
|
|
|
|
|
handled = self.tui._handle_mouse(mx=17, my=11, bstate=curses.BUTTON1_CLICKED)
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "chat_context")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sgr_escape_sequence_right_click_opens_chat_context(self):
|
|
|
|
|
"""SGR 1006 escape sequence fallback parses right-click and opens chat_context."""
|
|
|
|
|
self.tui.modal = None
|
|
|
|
|
# Simulate ESC [ < 2 ; 11 ; 12 M (Right click at mx=10, my=11 on thread 1)
|
|
|
|
|
raw_sgr = [ord('['), ord('<'), ord('2'), ord(';'), ord('1'), ord('1'), ord(';'), ord('1'), ord('2'), ord('M')]
|
|
|
|
|
with patch.object(self.tui, "_read_escape_sequence", return_value=raw_sgr):
|
|
|
|
|
handled = self.tui._handle_escape_sequence()
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "chat_context")
|
|
|
|
|
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
|
|
|
|
|
|
|
|
|
|
def test_double_click_on_thread_opens_chat_context(self):
|
|
|
|
|
"""Double-clicking a sidebar thread row opens chat_context."""
|
|
|
|
|
self.tui.modal = None
|
|
|
|
|
bstate = getattr(curses, "BUTTON1_DOUBLE_CLICKED", 0x8)
|
|
|
|
|
handled = self.tui._handle_mouse(mx=10, my=11, bstate=bstate)
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "chat_context")
|
|
|
|
|
|
|
|
|
|
def test_click_on_already_active_thread_opens_chat_context(self):
|
|
|
|
|
"""Left-clicking on the currently active thread in sidechats opens its context menu."""
|
|
|
|
|
self.tui.modal = None
|
|
|
|
|
self.tui.focus_pane = "sidechats"
|
|
|
|
|
# Thread 0 (Main Chat, sess-main-1234) is currently active, located at my = 10
|
|
|
|
|
handled = self.tui._handle_mouse(mx=5, my=10, bstate=curses.BUTTON1_CLICKED)
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "chat_context")
|
|
|
|
|
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-main-1234")
|
|
|
|
|
|
|
|
|
|
def test_click_right_side_of_thread_opens_chat_context(self):
|
|
|
|
|
"""Left-clicking on the right side of a thread row ([⚡] or [sid]) opens chat_context."""
|
|
|
|
|
self.tui.modal = None
|
|
|
|
|
sidebar_w = 26
|
|
|
|
|
# Click at mx = 20 (right side of thread 1 at my = 11)
|
|
|
|
|
handled = self.tui._handle_mouse(mx=20, my=11, bstate=curses.BUTTON1_CLICKED)
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "chat_context")
|
|
|
|
|
|
|
|
|
|
def test_space_key_in_normal_mode_opens_context(self):
|
|
|
|
|
"""Pressing Space in NORMAL mode opens context menu for focused partition."""
|
|
|
|
|
self.tui.modal = None
|
|
|
|
|
self.tui.editor_mode = "NORMAL"
|
|
|
|
|
self.tui.focus_pane = "sidechats"
|
|
|
|
|
self.tui.thread_sel_idx = 1
|
|
|
|
|
handled = self.tui._handle_key(ord(' '))
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "chat_context")
|
|
|
|
|
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
|
|
|
|
|
|
|
|
|
|
def test_shift_and_alt_left_click_opens_chat_context(self):
|
|
|
|
|
"""Shift+Left Click or Alt+Left Click operates as right-click modifier."""
|
|
|
|
|
self.tui.modal = None
|
|
|
|
|
shift_mask = getattr(curses, "BUTTON_SHIFT", 0x4000000)
|
|
|
|
|
handled = self.tui._handle_mouse(mx=10, my=11, bstate=curses.BUTTON1_CLICKED | shift_mask)
|
|
|
|
|
self.assertTrue(handled)
|
|
|
|
|
self.assertEqual(self.tui.modal, "chat_context")
|
2026-10-06 07:55:27 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|
2026-10-06 19:29:22 +00:00
|
|
|
|