#!/usr/bin/env python3 """ test_context_menus.py — Unit and integration tests for Right-Click function(s) and Context Action Menus in NetVM/Muse TUI: 1. Right-click on Chat List (sidebar sidechats/threads) opens `chat_context` menu. 2. Right-click on Fleet Agents panel opens `agent_context` menu. 3. Right-click on Transcript message opens `message_context` menu. 4. Chat Context Menu actions (Open, Pin/Unpin, Mark as Read, Copy ID, Copy URL, Reply, Close). 5. Fleet Agent Context Menu actions (Open, Sync, Approvals, Mark All Read, Tmux, Work Order). 6. Message Context Menu actions (Reply, Copy Text, Pin to Prompts, Copy ID). 7. Keyboard navigation (j/k, 1-8, Enter) and mouse click selection inside context menus. 8. Normal mode hotkeys ('x' / 'c') and slash commands (/context chat, /context agent). """ import curses import importlib.util import os import sys import unittest from pathlib import Path from unittest.mock import MagicMock, patch REPO_ROOT = Path(__file__).resolve().parent.parent MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py" spec = importlib.util.spec_from_file_location("muse_tui", MUSE_TUI_PATH) muse_tui = importlib.util.module_from_spec(spec) sys.modules["muse_tui"] = muse_tui spec.loader.exec_module(muse_tui) MuseTUI = muse_tui.MuseTUI class TestContextMenus(unittest.TestCase): def setUp(self): self.mock_stdscr = MagicMock() self.mock_stdscr.getmaxyx.return_value = (30, 100) self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse") self.tui.safe_addstr = MagicMock() # Seed mock threads self.threads = [ {"session_id": "sess-main-1234", "title": "Main Chat", "is_main": True, "pinned": False}, {"session_id": "sess-side-5678", "title": "Audit Sidechat", "is_main": False, "pinned": True}, {"session_id": "sess-side-9999", "title": "Dev Sidechat", "is_main": False, "pinned": False}, ] with self.tui.data.lock: self.tui.data.pinned_threads["muse"] = set() self.tui.data.threads_cache["muse"] = list(self.threads) self.tui.data.active_thread_id = "sess-main-1234" self.tui.data.active_thread_title = "Main Chat" self.tui.data.history_cache[("muse", "sess-main-1234")] = [ {"role": "user", "text": "Hello muse", "seq": 1, "message_id": "mid-user-1"}, {"role": "assistant", "text": "System ready", "seq": 2, "message_id": "mid-agent-2"}, ] self.tui.data.history_cache[("muse", "sess-side-5678")] = [ {"role": "user", "text": "Review log", "seq": 3, "message_id": "mid-user-3"}, ] def test_right_click_on_chat_list_opens_chat_context(self): """Right-clicking a thread row in sidebar opens chat_context modal.""" # Screen layout: content_y = 1, visible_agents = 6, divider_y = 8, threads_hdr_y = 9, list_y = 10 # Right click on thread index 1 (sess-side-5678) at my = 11, mx = 10 bstate = curses.BUTTON3_CLICKED self.tui._handle_mouse(mx=10, my=11, bstate=bstate) self.assertEqual(self.tui.modal, "chat_context") self.assertIsNotNone(self.tui.context_chat) self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678") self.assertEqual(self.tui.context_chat["thread"]["title"], "Audit Sidechat") def test_right_click_on_fleet_agent_opens_agent_context(self): """Right-clicking an agent row in sidebar opens agent_context modal.""" # Row content_y + 1 = 2 is first agent (index 0 = 'muse') # Row content_y + 2 = 3 is second agent bstate = getattr(curses, "BUTTON3_PRESSED", 0x800) with self.tui.data.lock: second_agent = self.tui.data.nodes[1] if len(self.tui.data.nodes) > 1 else "muse" self.tui._handle_mouse(mx=10, my=3, bstate=bstate) self.assertEqual(self.tui.modal, "agent_context") self.assertIsNotNone(self.tui.context_agent) self.assertEqual(self.tui.context_agent["node"], second_agent) def test_right_click_on_transcript_opens_message_context(self): """Right-clicking a message in transcript opens message_context modal.""" # Populate rendered lines in transcript self.tui._render_transcript(1, 26, 25, 74) # Click on transcript area mx = 50, my = 5 bstate = curses.BUTTON3_CLICKED self.tui._handle_mouse(mx=50, my=5, bstate=bstate) self.assertEqual(self.tui.modal, "message_context") self.assertIsNotNone(self.tui.context_message) self.assertIn("text", self.tui.context_message) def test_chat_context_pin_action(self): """Executing 'pin' action in chat_context toggles pin state.""" t = {"session_id": "sess-side-9999", "title": "Dev Sidechat", "pinned": False} self.tui.context_chat = {"node": "muse", "thread": t, "index": 2} self.tui.modal = "chat_context" # Press 'p' self.tui._handle_modal_key(ord('p')) self.assertIsNone(self.tui.modal) self.assertIn("📌 Pinned chat", self.tui.toast_msg) self.assertIn("sess-side-9999", self.tui.data.pinned_threads.get("muse", set())) def test_chat_context_mark_read_action(self): """Executing 'read' action marks thread as read.""" t = {"session_id": "sess-side-5678", "title": "Audit Sidechat"} self.tui.context_chat = {"node": "muse", "thread": t, "index": 1} self.tui.modal = "chat_context" self.tui._handle_modal_key(ord('m')) self.assertIsNone(self.tui.modal) self.assertIn("Marked 'Audit Sidechat' as read", self.tui.toast_msg) self.assertEqual(self.tui.data.get_unread_count("muse", "sess-side-5678"), 0) def test_chat_context_copy_id_action(self): """Executing 'copy_id' action copies session ID to clipboard.""" t = {"session_id": "sess-side-5678", "title": "Audit Sidechat"} self.tui.context_chat = {"node": "muse", "thread": t, "index": 1} self.tui.modal = "chat_context" mod = sys.modules.get(MuseTUI.__module__, muse_tui) with patch.object(mod, "copy_to_clipboard") as mock_cp1, \ patch.object(muse_tui, "copy_to_clipboard") as mock_cp2: self.tui._handle_modal_key(ord('y')) self.assertTrue(mock_cp1.called or mock_cp2.called) self.assertIn("Copied session ID", self.tui.toast_msg) def test_chat_context_reply_action(self): """Executing 'reply' action switches to chat and quotes latest message.""" t = {"session_id": "sess-main-1234", "title": "Main Chat"} self.tui.context_chat = {"node": "muse", "thread": t, "index": 0} self.tui.modal = "chat_context" self.tui._handle_modal_key(ord('r')) self.assertIsNone(self.tui.modal) self.assertIsNotNone(self.tui.reply_target) self.assertEqual(self.tui.reply_target["role"], "assistant") self.assertIn("System ready", self.tui.reply_target["text"]) def test_agent_context_actions(self): """Agent context menu hotkeys execute appropriate agent actions.""" self.tui.context_agent = {"node": "muse"} self.tui.modal = "agent_context" # Mark all chats on agent as read ('m') self.tui._handle_modal_key(ord('m')) self.assertIsNone(self.tui.modal) self.assertIn("Marked all chats on MUSE as read", self.tui.toast_msg) # Open chat with agent ('1') self.tui.context_agent = {"node": "muse"} self.tui.modal = "agent_context" self.tui._handle_modal_key(ord('1')) self.assertIsNone(self.tui.modal) self.assertIn("Switched to agent", self.tui.toast_msg) def test_normal_mode_x_opens_chat_context(self): """Pressing 'x' in normal mode when focused on sidebar opens chat_context.""" self.tui.editor_mode = "NORMAL" self.tui.focus_pane = "sidebar" self.tui.thread_sel_idx = 1 handled = self.tui._handle_key(ord('x')) self.assertTrue(handled) self.assertEqual(self.tui.modal, "chat_context") self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678") def test_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) self.assertEqual(self.tui.modal, "chat_context") def test_message_menu_button_opens_message_context(self): """Left-clicking the [⚡ Menu] button on a message row opens message_context.""" self.tui.modal = None # 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") if __name__ == "__main__": unittest.main()