feat(tui): clean transcript style, right-click context menus, rate limits & dual copy

- 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
This commit is contained in:
operator
2026-10-06 07:55:27 +00:00
parent f2640397ed
commit b7e45010c3
14 changed files with 8778 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
test_paste_handling.py — Unit tests for long string / chunk paste handling in MuseTUI:
1. Multi-line chunk pasting preserves newlines and does not send immediately.
2. Enter key submits the entire chunk as a single message and single history item.
3. Bracketed paste sequences (\033[200~ ... \033[201~) are correctly parsed and captured.
4. Alt+Enter (ESC + Enter) inserts newlines for multiline composition.
5. Unbracketed rapid paste bursts preserve newlines without line-by-line send.
6. Arrow keys navigate lines within multiline buffer.
7. UTF-8 multibyte characters are correctly decoded and inserted into buffer.
8. Multiline messages or file paths starting with '/' are delivered as messages.
"""
import unittest
from unittest.mock import MagicMock, patch
import importlib.util
from pathlib import Path
import curses
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 TestPasteHandling(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.tui.safe_addstr = MagicMock()
self.tui.data.running = False # Keep background thread stopped
self.tui.data.active_node = "muse"
self.tui.data.active_thread_id = "test-session"
self.tui.data.active_thread_title = "Main Chat"
self.tui.data.threads_cache["muse"] = [{"session_id": "test-session", "is_main": True}]
def test_insert_pasted_text_multiline(self):
"""Pasting a multiline chunk enters input_buf in full without sending."""
chunk = "Line 1: Hello\nLine 2: World\nLine 3: Agent instruction"
self.tui.editor_mode = "NORMAL"
self.tui.input_buf = ""
self.tui.input_cursor = 0
self.tui._insert_pasted_text(chunk)
self.assertEqual(self.tui.editor_mode, "INSERT")
self.assertEqual(self.tui.input_buf, chunk)
self.assertEqual(self.tui.input_cursor, len(chunk))
self.assertIn("3 lines", self.tui.toast_msg)
# Verify nothing was sent yet
self.assertEqual(len(self.tui.input_history), 0)
def test_multiline_send_as_single_chunk(self):
"""Submitting a multiline buffer records ONE history entry and sends ONCE."""
chunk = "Line 1: def test():\n print('NetVM')\nLine 3: return True"
self.tui.editor_mode = "INSERT"
self.tui.input_buf = chunk
self.tui.input_cursor = len(chunk)
sent_messages = []
self.tui._async_send_message = lambda node, tid, text: sent_messages.append((node, tid, text))
# Simulate user pressing Enter on the completed multiline chunk
# With no characters waiting in stdin (peek_c = -1)
self.mock_stdscr.getch.return_value = -1
ret = self.tui._handle_insert_key(10)
self.assertTrue(ret)
self.assertEqual(self.tui.input_buf, "")
self.assertEqual(len(self.tui.input_history), 1)
self.assertEqual(self.tui.input_history[0], chunk)
self.assertEqual(len(sent_messages), 1)
self.assertEqual(sent_messages[0][0], "muse")
self.assertEqual(sent_messages[0][1], "test-session")
self.assertEqual(sent_messages[0][2], chunk)
def test_bracketed_paste_capture(self):
"""\033[200~ bracketed paste reads until \033[201~ and captures content."""
payload = "Multi-line code snippet:\n```python\nx = 42\nprint(x)\n```"
# Simulate stream of getch returns: payload bytes followed by \033[201~ then -1
stream = list(payload.encode("utf-8")) + [27, ord('['), ord('2'), ord('0'), ord('1'), ord('~')]
call_idx = 0
def mock_getch():
nonlocal call_idx
if call_idx < len(stream):
val = stream[call_idx]
call_idx += 1
return val
return -1
self.mock_stdscr.getch.side_effect = mock_getch
self.tui.input_buf = ""
self.tui.input_cursor = 0
handled = self.tui._handle_bracketed_paste()
self.assertTrue(handled)
self.assertEqual(self.tui.input_buf, payload)
self.assertEqual(self.tui.input_cursor, len(payload))
self.assertEqual(self.tui.editor_mode, "INSERT")
def test_alt_enter_inserts_newline(self):
"""Alt+Enter (ESC + 10 or 13) inserts newline into input_buf."""
self.tui.editor_mode = "INSERT"
self.tui.input_buf = "First line"
self.tui.input_cursor = len(self.tui.input_buf)
# Mock _read_escape_sequence returning [10] (Enter)
with patch.object(self.tui, "_read_escape_sequence", return_value=[10]):
res = self.tui._handle_escape_sequence()
self.assertTrue(res)
self.assertEqual(self.tui.input_buf, "First line\n")
self.assertEqual(self.tui.input_cursor, len("First line\n"))
def test_multiline_cursor_navigation(self):
"""Up and Down arrow navigate across lines inside multiline input_buf."""
self.tui.editor_mode = "INSERT"
self.tui.input_buf = "Line A\nLine B\nLine C"
# Place cursor at end of "Line C"
self.tui.input_cursor = len(self.tui.input_buf)
# Press Up: cursor should move to "Line B"
self.tui._handle_insert_key(curses.KEY_UP)
curr_text = self.tui.input_buf[:self.tui.input_cursor]
self.assertTrue(curr_text.endswith("Line B") or "Line B" in curr_text)
# Press Up again: cursor should move to "Line A"
self.tui._handle_insert_key(curses.KEY_UP)
curr_text = self.tui.input_buf[:self.tui.input_cursor]
self.assertTrue(curr_text.startswith("Line A"))
# Press Down: cursor should move back down to "Line B"
self.tui._handle_insert_key(curses.KEY_DOWN)
curr_text = self.tui.input_buf[:self.tui.input_cursor]
self.assertIn("Line B", curr_text)
def test_unicode_multibyte_input(self):
"""Multi-byte UTF-8 bytes assemble into correct unicode characters."""
self.tui.editor_mode = "INSERT"
self.tui.input_buf = "Test: "
self.tui.input_cursor = len(self.tui.input_buf)
# 'é' is 0xC3, 0xA9 (195, 169)
self.tui._handle_insert_key(195)
self.assertEqual(self.tui.input_buf, "Test: ") # incomplete byte, not added yet
self.tui._handle_insert_key(169)
self.assertEqual(self.tui.input_buf, "Test: é")
self.assertEqual(self.tui.input_cursor, len("Test: é"))
def test_path_starting_with_slash_delivers_as_message(self):
"""A string starting with a file path like '/var/log/syslog' is sent as message."""
line = "/var/log/syslog contains error details"
sent = []
self.tui._async_send_message = lambda node, tid, text: sent.append(text)
self.tui._execute_input_line(line)
self.assertEqual(len(sent), 1)
self.assertEqual(sent[0], line)
if __name__ == "__main__":
unittest.main()