b7e45010c3
- Add clean line-by-line transcript style toggle ('b' key / /clean / /boxed)
- Implement right-click context menus for chat list, fleet agent panel, and transcript
- Add cooldown mode lock bypass (2-second double-confirm force sync)
- Implement dual copy support: clean text (strip reply metadata) vs full context
- Add clickable [📋 Copy] and [📑+ Context] buttons to message headers
- Add unit test suites for transcript cleaning, context menus, rate limits, and copy actions
375 lines
17 KiB
Python
375 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
test_copy_actions.py — Unit and integration tests for:
|
|
1. Multi-environment clipboard engine (desktop utilities, OSC 52, internal buffer).
|
|
2. Transcript formatting engine (Markdown structured headers, roles, seq, id).
|
|
3. UI-first button approach:
|
|
- Clickable [📋 Copy All] in thread header.
|
|
- Clickable [📋 Copy] button on each message header.
|
|
4. Normal mode hotkeys:
|
|
- 'y': copy latest/active message to clipboard.
|
|
- 'Y': copy entire chat transcript to clipboard.
|
|
5. Slash commands:
|
|
- '/copy', '/yank', '/cp': copy full transcript.
|
|
- '/copy last', '/copy message': copy latest message.
|
|
"""
|
|
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
|
|
copy_to_clipboard = muse_tui.copy_to_clipboard
|
|
strip_message_metadata = muse_tui.strip_message_metadata
|
|
|
|
|
|
class TestCopyActions(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 conversation history for testing
|
|
self.messages = [
|
|
{
|
|
"role": "user",
|
|
"text": "Hello muse, check system status",
|
|
"message_id": "usr-msg-12345678",
|
|
"seq": 1,
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"text": "All services nominal.\nCDP port 9222 active.",
|
|
"message_id": "ast-msg-87654321",
|
|
"seq": 2,
|
|
},
|
|
{
|
|
"role": "user",
|
|
"text": "Please summarize logs",
|
|
"message_id": "usr-msg-99999999",
|
|
"seq": 3,
|
|
},
|
|
]
|
|
self.tui.data.history_cache[("muse", "thread-abc")] = list(self.messages)
|
|
self.tui.data.active_node = "muse"
|
|
self.tui.data.active_thread_id = "thread-abc"
|
|
self.tui.data.active_thread_title = "Diagnostics"
|
|
|
|
def test_strip_message_metadata(self):
|
|
"""Verify strip_message_metadata strips reply headers and unquotes quoted lines."""
|
|
raw = (
|
|
"> Replying to ASSISTANT (seq:12620):\n"
|
|
"> Relevant to the timer question: the loop scout just surfaced 6 unanswered jobs.\n"
|
|
"> So part of getting work done is already queued.\n\n"
|
|
"yes, proceed with spawning 5 sub agents to assist you ; continue at all costs"
|
|
)
|
|
expected = (
|
|
"Relevant to the timer question: the loop scout just surfaced 6 unanswered jobs.\n"
|
|
"So part of getting work done is already queued.\n\n"
|
|
"yes, proceed with spawning 5 sub agents to assist you ; continue at all costs"
|
|
)
|
|
self.assertEqual(strip_message_metadata(raw), expected)
|
|
|
|
# Message without reply tags remains unchanged
|
|
no_tags = "Standard message without reply headers."
|
|
self.assertEqual(strip_message_metadata(no_tags), no_tags)
|
|
|
|
def test_copy_to_clipboard_osc52(self):
|
|
"""Verify copy_to_clipboard formats and flushes OSC 52 sequence."""
|
|
with patch("sys.stdout.write") as mock_write, patch("sys.stdout.flush"):
|
|
ok = copy_to_clipboard("test-copy-payload")
|
|
self.assertTrue(ok)
|
|
# Verify OSC 52 sequence was written
|
|
args = [call.args[0] for call in mock_write.call_args_list]
|
|
osc_written = any("\033]52;c;" in a for a in args)
|
|
self.assertTrue(osc_written)
|
|
|
|
def test_get_formatted_transcript_content(self):
|
|
"""Verify structured Markdown output contains thread title, roles, seq, id, and text."""
|
|
formatted = self.tui.get_formatted_transcript()
|
|
self.assertIn("# Chat Transcript: Diagnostics", formatted)
|
|
self.assertIn("Node: MUSE | Thread ID: thread-abc | Total Messages: 3", formatted)
|
|
self.assertIn("### [YOU / OPERATOR] id:usr-msg-", formatted)
|
|
self.assertIn("Hello muse, check system status", formatted)
|
|
self.assertIn("### [MUSE / MUSE AGENT] seq:2 id:ast-msg-", formatted)
|
|
self.assertIn("All services nominal.", formatted)
|
|
self.assertIn("Please summarize logs", formatted)
|
|
|
|
def test_copy_transcript_to_clipboard(self):
|
|
"""Verify copy_transcript_to_clipboard updates internal buffer and sets success toast."""
|
|
with patch.object(muse_tui, "copy_to_clipboard", return_value=True):
|
|
ok = self.tui.copy_transcript_to_clipboard()
|
|
self.assertTrue(ok)
|
|
self.assertIn("# Chat Transcript: Diagnostics", self.tui.clipboard_buf)
|
|
self.assertIn("Copied full transcript (3 msgs", self.tui.toast_msg)
|
|
|
|
def test_copy_message_to_clipboard_latest(self):
|
|
"""Verify copy_message_to_clipboard defaults to latest message."""
|
|
with patch.object(muse_tui, "copy_to_clipboard", return_value=True):
|
|
ok = self.tui.copy_message_to_clipboard()
|
|
self.assertTrue(ok)
|
|
self.assertEqual(self.tui.clipboard_buf, "Please summarize logs")
|
|
self.assertIn("Copied clean message to clipboard", self.tui.toast_msg)
|
|
|
|
def test_copy_message_to_clipboard_with_and_without_context(self):
|
|
"""Verify with_context toggles between metadata stripping and raw text."""
|
|
raw = "> Replying to ASSISTANT (seq:1):\n> Quoted header\n\nActual response body"
|
|
with patch.object(muse_tui, "copy_to_clipboard", return_value=True):
|
|
# Clean text (without context)
|
|
self.tui.copy_message_to_clipboard(raw, with_context=False)
|
|
self.assertEqual(self.tui.clipboard_buf, "Quoted header\n\nActual response body")
|
|
self.assertIn("clean message", self.tui.toast_msg)
|
|
|
|
# Raw text (with context)
|
|
self.tui.copy_message_to_clipboard(raw, with_context=True)
|
|
self.assertEqual(self.tui.clipboard_buf, raw)
|
|
self.assertIn("message with context", self.tui.toast_msg)
|
|
|
|
def test_copy_message_to_clipboard_explicit(self):
|
|
"""Verify copy_message_to_clipboard with explicit message text."""
|
|
with patch.object(muse_tui, "copy_to_clipboard", return_value=True):
|
|
custom_text = "Specific arbitrary message payload"
|
|
ok = self.tui.copy_message_to_clipboard(custom_text)
|
|
self.assertTrue(ok)
|
|
self.assertEqual(self.tui.clipboard_buf, custom_text)
|
|
|
|
def test_render_transcript_buttons(self):
|
|
"""Verify [📋 Copy All], [📋 Copy] and [📑+ Context] buttons are rendered and registered."""
|
|
self.tui._render_transcript(y=1, x=24, h=25, w=76)
|
|
|
|
# Check [📋 Copy All] bounds registered
|
|
self.assertIsNotNone(self.tui._btn_copy_all_bounds)
|
|
btn_y, x_start, x_end = self.tui._btn_copy_all_bounds
|
|
self.assertEqual(btn_y, 1)
|
|
self.assertGreater(x_end, x_start)
|
|
|
|
# Verify button string was rendered to screen at btn coordinates
|
|
calls = [c for c in self.tui.safe_addstr.call_args_list if "[📋 Copy All]" in str(c)]
|
|
self.assertTrue(len(calls) > 0, "[📋 Copy All] must be rendered to stdscr")
|
|
win_arg, call_y, call_x, call_text, call_attr = calls[0].args
|
|
self.assertEqual(call_y, 1)
|
|
self.assertEqual(call_x, x_start)
|
|
|
|
# Check individual message copy buttons registered
|
|
self.assertGreater(len(self.tui._msg_copy_buttons), 0)
|
|
self.assertGreater(len(self.tui._msg_copy_context_buttons), 0)
|
|
for (my, mx1, mx2, raw_text) in self.tui._msg_copy_buttons:
|
|
self.assertGreater(mx2, mx1)
|
|
self.assertIn(raw_text, [m["text"] for m in self.messages])
|
|
for (my, mx1, mx2, raw_text) in self.tui._msg_copy_context_buttons:
|
|
self.assertGreater(mx2, mx1)
|
|
self.assertIn(raw_text, [m["text"] for m in self.messages])
|
|
|
|
def test_render_transcript_narrow_screen_retains_copy_button(self):
|
|
"""Verify [📋 Copy All] is retained next to message count even with long title & narrow width."""
|
|
self.tui.data.active_thread_title = "Extremely Long Comprehensive Diagnostics Thread Title Exceeding Width"
|
|
self.tui._render_transcript(y=1, x=10, h=20, w=48)
|
|
|
|
self.assertIsNotNone(self.tui._btn_copy_all_bounds)
|
|
btn_y, x_start, x_end = self.tui._btn_copy_all_bounds
|
|
self.assertEqual(btn_y, 1)
|
|
self.assertLessEqual(x_end, 10 + 48, "Button must stay within transcript boundary")
|
|
calls = [c for c in self.tui.safe_addstr.call_args_list if "[📋 Copy All]" in str(c)]
|
|
self.assertTrue(len(calls) > 0)
|
|
|
|
def test_mouse_click_copy_all(self):
|
|
"""Verify clicking [📋 Copy All] copies full transcript to clipboard."""
|
|
self.tui._render_transcript(y=1, x=24, h=25, w=76)
|
|
btn_y, x_start, x_end = self.tui._btn_copy_all_bounds
|
|
click_x = (x_start + x_end) // 2
|
|
|
|
with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all:
|
|
handled = self.tui._handle_mouse(click_x, btn_y, curses.BUTTON1_CLICKED)
|
|
self.assertTrue(handled)
|
|
mock_copy_all.assert_called_once()
|
|
|
|
def test_mouse_click_message_copy(self):
|
|
"""Verify clicking an individual [📋 Copy] button copies that specific message without context."""
|
|
self.tui._render_transcript(y=1, x=24, h=25, w=76)
|
|
self.assertGreater(len(self.tui._msg_copy_buttons), 0)
|
|
first_btn = self.tui._msg_copy_buttons[0]
|
|
msg_y, x_start, x_end, raw_text = first_btn
|
|
click_x = (x_start + x_end) // 2
|
|
|
|
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg:
|
|
handled = self.tui._handle_mouse(click_x, msg_y, curses.BUTTON1_CLICKED)
|
|
self.assertTrue(handled)
|
|
mock_copy_msg.assert_called_once_with(raw_text, with_context=False)
|
|
|
|
def test_mouse_click_message_copy_context(self):
|
|
"""Verify clicking an individual [📑+ Context] button copies that message with full context."""
|
|
self.tui._render_transcript(y=1, x=24, h=25, w=76)
|
|
self.assertGreater(len(self.tui._msg_copy_context_buttons), 0)
|
|
first_btn = self.tui._msg_copy_context_buttons[0]
|
|
msg_y, x_start, x_end, raw_text = first_btn
|
|
click_x = (x_start + x_end) // 2
|
|
|
|
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg:
|
|
handled = self.tui._handle_mouse(click_x, msg_y, curses.BUTTON1_CLICKED)
|
|
self.assertTrue(handled)
|
|
mock_copy_msg.assert_called_once_with(raw_text, with_context=True)
|
|
|
|
def test_normal_hotkey_y(self):
|
|
"""Verify 'y' in Normal mode copies latest message."""
|
|
self.tui.editor_mode = "NORMAL"
|
|
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg:
|
|
handled = self.tui._handle_normal_key(ord("y"))
|
|
self.assertTrue(handled)
|
|
mock_copy_msg.assert_called_once()
|
|
|
|
def test_normal_hotkey_Y(self):
|
|
"""Verify 'Y' in Normal mode copies full transcript."""
|
|
self.tui.editor_mode = "NORMAL"
|
|
with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all:
|
|
handled = self.tui._handle_normal_key(ord("Y"))
|
|
self.assertTrue(handled)
|
|
mock_copy_all.assert_called_once()
|
|
|
|
def test_slash_command_copy_all(self):
|
|
"""Verify '/copy' or '/yank' copies full transcript."""
|
|
with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all:
|
|
self.tui._execute_input_line("/copy")
|
|
mock_copy_all.assert_called_once()
|
|
|
|
with patch.object(self.tui, "copy_transcript_to_clipboard") as mock_copy_all:
|
|
self.tui._execute_input_line("/yank")
|
|
mock_copy_all.assert_called_once()
|
|
|
|
def test_slash_command_copy_last(self):
|
|
"""Verify '/copy last' copies latest message."""
|
|
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy_msg:
|
|
self.tui._execute_input_line("/copy last")
|
|
mock_copy_msg.assert_called_once()
|
|
|
|
def test_enter_visual_copy_mode(self):
|
|
"""Pressing 'v' enters Visual Copy Mode with selection on visible line."""
|
|
self.tui.editor_mode = "NORMAL"
|
|
self.tui.copy_mode = False
|
|
self.tui._render_transcript(y=1, x=24, h=25, w=76)
|
|
|
|
ret = self.tui._handle_normal_key(ord('v'))
|
|
self.assertTrue(ret)
|
|
self.assertTrue(self.tui.copy_mode)
|
|
self.assertEqual(self.tui.focus_pane, "transcript")
|
|
self.assertIsNotNone(self.tui.visual_sel_start)
|
|
self.assertEqual(self.tui.visual_sel_start, self.tui.copy_cursor_line)
|
|
self.assertIn("VISUAL line copy mode", self.tui.toast_msg)
|
|
|
|
def test_enter_visual_copy_mode_V(self):
|
|
"""Pressing 'V' on transcript enters visual copy mode."""
|
|
self.tui.editor_mode = "NORMAL"
|
|
self.tui.focus_pane = "transcript"
|
|
self.tui.copy_mode = False
|
|
self.tui._render_transcript(y=1, x=24, h=25, w=76)
|
|
|
|
ret = self.tui._handle_normal_key(ord('V'))
|
|
self.assertTrue(ret)
|
|
self.assertTrue(self.tui.copy_mode)
|
|
self.assertIsNotNone(self.tui.visual_sel_start)
|
|
self.assertIn("VISUAL line copy mode", self.tui.toast_msg)
|
|
|
|
def test_message_context_menu_copy_clean_and_context(self):
|
|
"""Verify message_context modal options 2/y (clean) and 3/Y (context) work."""
|
|
target_msg = {
|
|
"role": "assistant",
|
|
"text": "> Replying to USER (seq:4):\n> Previous line\n\nClean response text",
|
|
"message_id": "msg-xyz-123",
|
|
"seq": 5,
|
|
}
|
|
self.tui.context_message = target_msg
|
|
self.tui.modal = "message_context"
|
|
|
|
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy:
|
|
# Key '2' or 'y' copies clean text (with_context=False)
|
|
self.tui._handle_modal_key(ord('2'))
|
|
mock_copy.assert_called_with(target_msg["text"], with_context=False)
|
|
|
|
self.tui.context_message = target_msg
|
|
self.tui.modal = "message_context"
|
|
with patch.object(self.tui, "copy_message_to_clipboard") as mock_copy:
|
|
# Key '3' or 'Y' copies with context (with_context=True)
|
|
self.tui._handle_modal_key(ord('3'))
|
|
mock_copy.assert_called_with(target_msg["text"], with_context=True)
|
|
|
|
def test_copy_mode_navigation_and_yank_with_border_stripping(self):
|
|
"""Navigating with 'j'/'k' and yanking with 'y' copies clean text without box borders."""
|
|
self.tui._render_transcript(y=1, x=24, h=25, w=76)
|
|
self.tui.copy_mode = True
|
|
self.tui.visual_sel_start = 2
|
|
self.tui.copy_cursor_line = 2
|
|
|
|
# Move down to line 4
|
|
self.tui._handle_copy_mode_key(ord('j'))
|
|
self.assertEqual(self.tui.copy_cursor_line, 3)
|
|
self.tui._handle_copy_mode_key(ord('j'))
|
|
self.assertEqual(self.tui.copy_cursor_line, 4)
|
|
|
|
# Yank selection with 'y'
|
|
with patch.object(muse_tui, "copy_to_clipboard", return_value=True) as mock_clip:
|
|
self.tui._handle_copy_mode_key(ord('y'))
|
|
self.assertFalse(self.tui.copy_mode)
|
|
self.assertIsNone(self.tui.visual_sel_start)
|
|
mock_clip.assert_called_once()
|
|
copied = mock_clip.call_args[0][0]
|
|
# Ensure vertical box lines '│ ' are stripped
|
|
self.assertNotIn("│ ", copied)
|
|
self.assertIn("Copied", self.tui.toast_msg)
|
|
|
|
def test_mouse_drag_select_and_release_copy(self):
|
|
"""Dragging mouse over transcript lines highlights and copies text on release."""
|
|
self.tui._render_transcript(y=1, x=24, h=25, w=76)
|
|
|
|
# 1. Mouse down at screen y=5, x=30 (inside transcript)
|
|
self.tui._handle_mouse(mx=30, my=5, bstate=curses.BUTTON1_PRESSED)
|
|
self.assertTrue(self.tui.mouse_dragging)
|
|
self.assertTrue(self.tui.copy_mode)
|
|
start_line = self.tui.visual_sel_start
|
|
self.assertIsNotNone(start_line)
|
|
|
|
# 2. Mouse drag to y=8
|
|
self.tui._handle_mouse(mx=30, my=8, bstate=curses.REPORT_MOUSE_POSITION)
|
|
self.assertEqual(self.tui.visual_sel_start, start_line)
|
|
self.assertNotEqual(self.tui.copy_cursor_line, start_line)
|
|
|
|
# 3. Mouse release
|
|
with patch.object(muse_tui, "copy_to_clipboard", return_value=True) as mock_clip:
|
|
self.tui._handle_mouse(mx=30, my=8, bstate=curses.BUTTON1_RELEASED)
|
|
self.assertFalse(self.tui.mouse_dragging)
|
|
self.assertFalse(self.tui.copy_mode)
|
|
mock_clip.assert_called_once()
|
|
copied = mock_clip.call_args[0][0]
|
|
self.assertGreater(len(copied), 0)
|
|
self.assertIn("Copied selection", self.tui.toast_msg)
|
|
|
|
def test_copy_mode_escape_exits(self):
|
|
"""Esc or 'q' clears selection and exits copy mode."""
|
|
self.tui.copy_mode = True
|
|
self.tui.visual_sel_start = 5
|
|
self.tui.copy_cursor_line = 8
|
|
|
|
# First Esc clears selection
|
|
self.tui._handle_copy_mode_key(27)
|
|
self.assertIsNone(self.tui.visual_sel_start)
|
|
self.assertTrue(self.tui.copy_mode)
|
|
|
|
# Second Esc exits copy mode
|
|
self.tui._handle_copy_mode_key(27)
|
|
self.assertFalse(self.tui.copy_mode)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|