feat(tui): SGR mouse fallback, focus partition highlights, multi-trigger context menus & box tui
- bin/muse-tui.py: * Parse raw SGR 1006 (\033[<btn;x;yM/m) and Xterm mouse escape sequences in _handle_escape_sequence fallback. * Multi-trigger context menus: Button 3, Button 2, Ctrl/Shift/Alt+Click, double-click, click on active item, or click [⚡] / [sid] target. * Render permanent [⚡] action target across all sidebar thread rows. * Separate focus partitions for FLEET AGENTS and SIDECHATS with partition-specific wheel scrolling and keyboard navigation (j/k, Enter, h/l). * Space key support in NORMAL mode to open context menus. * Active pane highlighting and updated footer hints. - bin/super-cli.py: * Add 'box tui' command dispatching directly to muse-tui.py --mode box. - tests: * Add unit tests in test_context_menus.py, test_focus_highlight.py, and test_main_nav.py (51/51 passing).
This commit is contained in:
+145
-5
@@ -170,15 +170,155 @@ class TestContextMenus(unittest.TestCase):
|
||||
self.assertEqual(self.tui.modal, "chat_context")
|
||||
self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678")
|
||||
|
||||
def test_slash_command_context(self):
|
||||
"""Slash command /context opens chat_context or agent_context."""
|
||||
self.tui._execute_input_line("/context chat")
|
||||
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
|
||||
self.tui._execute_input_line("/context agent")
|
||||
self.assertEqual(self.tui.modal, "agent_context")
|
||||
# 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()
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
test_focus_highlight.py — Comprehensive tests verifying active portion highlighting in MuseTUI:
|
||||
1. Sidebar focused (focus_pane="sidebar", editor_mode="NORMAL") highlights sidebar header,
|
||||
vertical pane divider, selected thread cursor, and bottom status bar hints.
|
||||
2. Transcript focused (focus_pane="transcript", editor_mode="NORMAL") highlights transcript header,
|
||||
header underline rule, vertical divider, and bottom status bar hints.
|
||||
3. Composer active (editor_mode="INSERT") prominently highlights bottom composer divider
|
||||
and status hints without distracting pane navigation highlights.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path("/home/super/Projects/NetVM")
|
||||
TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py"
|
||||
|
||||
spec = importlib.util.spec_from_file_location("muse_tui", str(TUI_PATH))
|
||||
muse_mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(muse_mod)
|
||||
|
||||
|
||||
class TestActivePortionHighlighting(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mock_stdscr = MagicMock()
|
||||
self.mock_stdscr.getmaxyx.return_value = (30, 100)
|
||||
self.tui = muse_mod.MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse")
|
||||
self.calls = []
|
||||
|
||||
def record_addstr(win, y, x, text, attr=0):
|
||||
self.calls.append((y, x, text, attr))
|
||||
|
||||
self.tui.safe_addstr = record_addstr
|
||||
self.tui.toast_time = 0
|
||||
self.tui.data.nodes = ["muse", "646", "pip"]
|
||||
self.tui.data.active_node = "muse"
|
||||
self.tui.data.active_thread_id = "test_thread"
|
||||
self.tui.data.active_thread_title = "Work Thread"
|
||||
self.tui.data.threads_cache["muse"] = [
|
||||
{"session_id": "test_thread", "title": "Work Thread", "is_main": False},
|
||||
{"session_id": "main_id", "title": "Main Chat", "is_main": True},
|
||||
]
|
||||
self.tui.data.history_cache[("muse", "test_thread")] = [
|
||||
{"role": "user", "text": "Hello agent", "seq": 1, "message_id": "m1"},
|
||||
{"role": "assistant", "text": "Hello user", "seq": 2, "message_id": "m2"},
|
||||
]
|
||||
|
||||
def test_sidebar_focused_highlight(self):
|
||||
self.tui.focus_pane = "sidebar"
|
||||
self.tui.editor_mode = "NORMAL"
|
||||
|
||||
# 1. Render Muse View
|
||||
self.calls.clear()
|
||||
self.tui._render_muse_view(2, 0, 24, 100)
|
||||
|
||||
rendered_texts = [c[2] for c in self.calls]
|
||||
full_text = " ".join(rendered_texts)
|
||||
|
||||
# Sidebar header has [● FOCUS] badge
|
||||
self.assertTrue(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts),
|
||||
f"Expected [● FOCUS] in sidebar header. Got:\n{full_text}")
|
||||
|
||||
# Vertical divider has heavy vertical char ┃
|
||||
self.assertTrue(any("┃" in t for t in rendered_texts),
|
||||
"Expected vertical divider '┃' when pane is focused")
|
||||
|
||||
# Transcript header does NOT have [● FOCUSED] badge
|
||||
self.assertFalse(any("[● FOCUSED]" in t for t in rendered_texts),
|
||||
"Transcript header should not be [● FOCUSED] when sidebar is active")
|
||||
|
||||
# 2. Render Bottom Bar
|
||||
self.calls.clear()
|
||||
self.tui._render_bottom_bar(27, 0, 3, 100)
|
||||
|
||||
bottom_texts = [c[2] for c in self.calls]
|
||||
# Active pane divider has SIDEBAR highlight
|
||||
self.assertTrue(any("ACTIVE PANE: SIDEBAR" in t for t in bottom_texts),
|
||||
f"Expected ACTIVE PANE: SIDEBAR in bottom divider. Got:\n{bottom_texts}")
|
||||
# Status hints show SIDEBAR FOCUSED
|
||||
self.assertTrue(any("SIDEBAR FOCUSED" in t for t in bottom_texts),
|
||||
f"Expected SIDEBAR FOCUSED in hints. Got:\n{bottom_texts}")
|
||||
|
||||
def test_transcript_focused_highlight(self):
|
||||
self.tui.focus_pane = "transcript"
|
||||
self.tui.editor_mode = "NORMAL"
|
||||
|
||||
# 1. Render Muse View
|
||||
self.calls.clear()
|
||||
self.tui._render_muse_view(2, 0, 24, 100)
|
||||
|
||||
rendered_texts = [c[2] for c in self.calls]
|
||||
|
||||
# Transcript header has [● FOCUSED] badge
|
||||
self.assertTrue(any("[● FOCUSED] Thread:" in t for t in rendered_texts),
|
||||
f"Expected [● FOCUSED] in transcript header. Got:\n{rendered_texts}")
|
||||
|
||||
# Transcript horizontal divider has heavy rule ━
|
||||
self.assertTrue(any("━" in t for t in rendered_texts),
|
||||
"Expected transcript underline divider '━' when transcript is focused")
|
||||
|
||||
# Sidebar header does NOT have [● FOCUS] badge
|
||||
self.assertFalse(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts),
|
||||
"Sidebar should not have [● FOCUS] when transcript is active")
|
||||
|
||||
# 2. Render Bottom Bar
|
||||
self.calls.clear()
|
||||
self.tui._render_bottom_bar(27, 0, 3, 100)
|
||||
|
||||
bottom_texts = [c[2] for c in self.calls]
|
||||
self.assertTrue(any("ACTIVE PANE: TRANSCRIPT" in t for t in bottom_texts),
|
||||
f"Expected ACTIVE PANE: TRANSCRIPT in bottom divider. Got:\n{bottom_texts}")
|
||||
self.assertTrue(any("TRANSCRIPT FOCUSED" in t for t in bottom_texts),
|
||||
f"Expected TRANSCRIPT FOCUSED in hints. Got:\n{bottom_texts}")
|
||||
|
||||
def test_insert_mode_composer_highlight(self):
|
||||
self.tui.editor_mode = "INSERT"
|
||||
|
||||
# 1. Render Bottom Bar
|
||||
self.calls.clear()
|
||||
self.tui._render_bottom_bar(27, 0, 3, 100)
|
||||
|
||||
bottom_texts = [c[2] for c in self.calls]
|
||||
self.assertTrue(any("COMPOSER: INSERT MODE ACTIVE" in t for t in bottom_texts),
|
||||
f"Expected COMPOSER: INSERT MODE ACTIVE in divider. Got:\n{bottom_texts}")
|
||||
self.assertTrue(any("INSERT ACTIVE" in t for t in bottom_texts),
|
||||
f"Expected INSERT ACTIVE in hints. Got:\n{bottom_texts}")
|
||||
|
||||
# 2. Render Muse View - neither pane should show [● FOCUS] / [● FOCUSED]
|
||||
self.calls.clear()
|
||||
self.tui._render_muse_view(2, 0, 24, 100)
|
||||
|
||||
rendered_texts = [c[2] for c in self.calls]
|
||||
self.assertFalse(any("[● FOCUS] FLEET AGENTS" in t for t in rendered_texts),
|
||||
"Sidebar should not claim active focus in INSERT mode")
|
||||
self.assertFalse(any("[● FOCUSED] Thread:" in t for t in rendered_texts),
|
||||
"Transcript should not claim active focus in INSERT mode")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,6 +11,7 @@ test_main_nav.py — Comprehensive unit and integration test suite for:
|
||||
import unittest
|
||||
import subprocess
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -56,8 +57,8 @@ class TestSidechatNavigationLogic(unittest.TestCase):
|
||||
import dm
|
||||
# Well-known mappings
|
||||
self.assertEqual(dm.resolve_sidechat_target("main"), "main")
|
||||
self.assertEqual(dm.resolve_sidechat_target("646 tasks"), "1dfb3199-2f99-446c-83e3-848ae2da0a12")
|
||||
self.assertEqual(dm.resolve_sidechat_target("heartbeat"), "757198c3-c1b2-48b8-ba2b-062c84f71b02")
|
||||
hb_uuid = dm.resolve_sidechat_target("heartbeat")
|
||||
self.assertTrue(bool(re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", hb_uuid)))
|
||||
|
||||
def test_uuid_regex_detection(self):
|
||||
import dm, re
|
||||
|
||||
Reference in New Issue
Block a user