diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index c8f962e..19ea60f 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -138,6 +138,7 @@ class ChatScreen(Screen): self._status = "" self._stick = True # keep the newest message in view until the user scrolls up self._paths_open = False # one paths dialog at a time + self._paste_open = False # one paste-confirm dialog at a time # The pick: index of the highlighted message (or None when the compose line is # focused), plus the body line it rendered on so the frame keeps it in view. self._selected: Optional[int] = None @@ -434,7 +435,9 @@ class ChatScreen(Screen): """Dispatch a key: pick and act on messages, edit the compose line, or leave. Both chat kinds share one model. With no message picked, Enter sends and typing - edits the compose line. ↑ picks the newest message; the pick then walks with + edits the compose line; a paste (Ctrl-V, or a terminal's bracketed paste) is + confirmed on an amber dialog before it lands there (see :meth:`_begin_paste`). ↑ + picks the newest message; the pick then walks with ↑↓, PgUp/PgDn (a screenful), Ctrl+Home (the very first message), and Ctrl+PgUp/PgDn (day dividers), carrying the view with it. Enter on a picked message primes a reply ``@mention`` in a channel and opens the delivery paths @@ -450,6 +453,8 @@ class ChatScreen(Screen): self._open_paths(self._selected) else: self._submit() + elif action == "paste": + self._begin_paste(data) elif action == "paths": target = self._selected if self._selected is not None else len(self._messages) - 1 self._open_paths(target) @@ -504,6 +509,52 @@ class ChatScreen(Screen): asyncio.ensure_future(run()) + def _begin_paste(self, data: str) -> None: + """Confirm a clipboard paste on an amber dialog, then insert it into the compose line. + + A chat message goes out over the air, so a paste — which can be far larger than a + keystroke, or carry content the user didn't mean to broadcast — is gated behind a + Cancel/Paste confirm (the amber ``danger`` tier: disruptive, not data loss) rather + than dropped straight in. Newlines and control characters fold to spaces (a message + is one line); an empty result never opens the dialog. One paste dialog at a time, + scheduled off the key handler like the paths view. + """ + if self._paste_open: + return + clean = "".join(ch if ch.isprintable() else " " for ch in data).strip() + if not clean: + return + self._paste_open = True + count = len(clean) + + async def run() -> None: + try: + confirmed = await self._session.button_dialog( + Text( + f"Paste {count} character{'s' if count != 1 else ''} " + "into your message?", + style="warn", + ), + [("Cancel", False), ("Paste", True)], + title="Paste", + default=1, + border_style="warn", + footer_hint="←→ choose · Enter select · Esc cancel", + ) + finally: + self._paste_open = False + if confirmed: + # Land it in the compose line: drop any message pick, snap back to the tail, + # and insert the run through the shared editor. An over-budget result is + # flagged by the byte gauge, exactly as typing past the limit already is. + self._clear_selection() + self._stick = True + self._editor.edit("text", clean) + self._status = "" + self._session.invalidate() + + asyncio.ensure_future(run()) + def _move_selection(self, delta: int) -> None: """Move the reply selection by ``delta`` messages (negative = toward older). diff --git a/meshterm/ui/tui/prompt.py b/meshterm/ui/tui/prompt.py index fa69124..b36595a 100644 --- a/meshterm/ui/tui/prompt.py +++ b/meshterm/ui/tui/prompt.py @@ -102,11 +102,20 @@ class _LineEditor: Args: action: The normalized key action. - data: The character to insert when ``action`` is ``text``. + data: The character to insert when ``action`` is ``text`` — or the whole run + pasted when ``action`` is ``paste``. Returns: ``True`` if the action was an editing action handled here. """ + if action == "paste": + # A paste arrives as one multi-character run. A single-line field can't hold + # newlines or control characters, so fold them to spaces, then insert the run + # exactly like typed text (the max-length trim below still applies). + data = "".join(ch if ch.isprintable() else " " for ch in data) + if not data: + return False + action = "text" if action == "text" and data.isprintable(): if self._max_length is not None: room = self._max_length - len(self.text) diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index eef50f0..942117d 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -124,6 +124,48 @@ def _right_ctrl_down() -> bool: return False +def _read_clipboard() -> str: + """Best-effort read of the OS clipboard's Unicode text (Windows; ``""`` elsewhere). + + The fallback behind Ctrl-V on a terminal that delivers the key literally rather than as a + bracketed paste (see :meth:`TuiSession._key_bindings`): it pulls the clipboard's text + straight from the Win32 API. Every failure — a non-Windows platform, an empty or + non-text clipboard, a clipboard busy elsewhere we couldn't open — collapses to ``""``, so + the paste simply does nothing rather than raising into the key handler. Pointer-returning + calls declare a ``c_void_p`` result so a 64-bit handle isn't truncated to an int. + """ + if sys.platform != "win32": + return "" + try: + import ctypes + + CF_UNICODETEXT = 13 + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + user32.GetClipboardData.restype = ctypes.c_void_p + user32.GetClipboardData.argtypes = [ctypes.c_uint] + kernel32.GlobalLock.restype = ctypes.c_void_p + kernel32.GlobalLock.argtypes = [ctypes.c_void_p] + kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p] + if not user32.OpenClipboard(None): + return "" + try: + handle = user32.GetClipboardData(CF_UNICODETEXT) + if not handle: + return "" + ptr = kernel32.GlobalLock(handle) + if not ptr: + return "" + try: + return ctypes.wstring_at(ptr) + finally: + kernel32.GlobalUnlock(handle) + finally: + user32.CloseClipboard() + except Exception: # noqa: BLE001 - a clipboard hiccup must never break a keypress + return "" + + #: Reclaim the terminal's final column. Some terminals (and prompt_toolkit's size probe on #: them) report the window one column narrower than it really is, so the frame is drawn to #: ``columns - 1`` and the true last column sits unused — visibly selectable to the right of @@ -1144,11 +1186,28 @@ class TuiSession: if self._app is not None: self._app.exit() + @kb.add(Keys.ControlV) + def _paste_clipboard(event: Any) -> None: # noqa: ANN401 + # Some terminals deliver Ctrl-V as the literal control key — no bracketed-paste + # sequence, so no text on the event. Read the OS clipboard ourselves and hand the + # run to the top screen as a paste. Terminals that instead translate Ctrl-V into a + # bracketed paste never reach here — that lands in _typed below as a multi-char run. + text = _read_clipboard() + if text: + self._dispatch("paste", text) + @kb.add(Keys.Any) def _typed(event: Any) -> None: # noqa: ANN401 data = event.data - if data and len(data) == 1 and data.isprintable(): + if not data: + return + if len(data) == 1 and data.isprintable(): self._dispatch("text", data) + elif len(data) > 1: + # A bracketed paste (Ctrl-V, right-click, Ctrl-Shift-V) arrives as one + # multi-character run — hand the whole thing to the top screen as a paste it + # can confirm and insert, rather than dropping it as the old len==1 guard did. + self._dispatch("paste", data) return kb diff --git a/tests/test_chat.py b/tests/test_chat.py index fa829a5..d248dda 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -519,6 +519,51 @@ def test_chat_screen_renders_transcript_and_input() -> None: assert "›" in joined # the input editor's prompt marker +class _PasteSession(_StubSession): + """A stub session whose paste confirm answers a scripted Cancel/Paste.""" + + def __init__(self, answer: bool) -> None: + super().__init__() + self.answer = answer + self.dialogs: list = [] + + async def button_dialog(self, prompt, buttons, **kwargs): # noqa: ANN001, ANN201 + self.dialogs.append((prompt, buttons, kwargs)) + return self.answer + + +async def _drain_paste(screen: ChatScreen) -> None: + """Let the scheduled paste-confirm task run to completion.""" + for _ in range(100): + await asyncio.sleep(0) + if not screen._paste_open: + return + + +async def test_chat_paste_confirms_amber_then_inserts() -> None: + """Ctrl-V paste asks first on an amber Cancel/Paste dialog, then lands the run in compose.""" + session = _PasteSession(answer=True) + screen = _screen(session, send=None) + screen.handle("paste", "hello\nworld") + await _drain_paste(screen) + + # The newline folded to a space and the run dropped into the compose line… + assert screen._editor.text == "hello world" + prompt, buttons, kwargs = session.dialogs[0] + assert "Paste 11 characters" in prompt.plain # the folded, stripped run's length + assert kwargs.get("border_style") == "warn" # the amber (danger) tier — "yellow" + assert [label for label, _ in buttons] == ["Cancel", "Paste"] # safe way out on the left + + +async def test_chat_paste_declined_leaves_compose_untouched() -> None: + """Choosing Cancel on the paste confirm inserts nothing.""" + session = _PasteSession(answer=False) + screen = _screen(session, send=None) + screen.handle("paste", "unwanted") + await _drain_paste(screen) + assert screen._editor.text == "" + + async def test_chat_screen_enter_sends_and_appends() -> None: """Pressing Enter sends the line and appends the returned message to the transcript.""" session = _StubSession() diff --git a/tests/test_tui.py b/tests/test_tui.py index cc776df..f377f6e 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -706,6 +706,22 @@ def test_line_editor_caps_length_and_truncates_paste() -> None: assert pasted.text == "123456" # filled only the six available slots +def test_line_editor_paste_folds_controls_and_respects_max_length() -> None: + """A ``paste`` action folds newlines/controls to spaces and inserts the run at the cursor.""" + from meshterm.ui.tui.prompt import _LineEditor + + editor = _LineEditor("ab") + editor.cursor = 1 + assert editor.edit("paste", "X\nY") is True + assert editor.text == "aX Yb" # the newline became a space, inserted mid-buffer + + capped = _LineEditor("", max_length=3) + capped.edit("paste", "hello") + assert capped.text == "hel" # a paste is trimmed to the remaining room, like a big insert + + assert _LineEditor("z").edit("paste", "") is False # nothing to paste leaves the buffer + + def test_pin_dialog_shows_six_slots_with_dots_for_blanks() -> None: """The PIN field is six fixed slots: bullets for typed digits, centre dots for blanks.""" from meshterm.ui.tui.prompt import PinDialog