Chat: Ctrl-V pastes into a message, behind an amber confirm
Pasted text never reached a screen: the key dispatch forwarded only single
printable characters, so a bracketed paste (what a terminal sends for
Ctrl-V) was dropped whole. Route a multi-character run to a new "paste"
action instead, and add a Ctrl-V binding that reads the OS clipboard for
terminals that deliver the key literally rather than as a bracketed paste.
In chat, a paste is confirmed before it lands: a chat message goes out over
the air, so an amber Cancel/Paste dialog ("Paste N characters into your
message?") guards it — the disruptive (danger) tier, not data loss. On
confirm the run drops into the compose line, newlines folded to spaces
(a message is one line) and the byte gauge flagging an over-budget result
exactly as typing past the limit already does. Plain text prompts gain
silent multi-character paste through the shared line editor; only chat adds
the confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
8daf3f1294
commit
ee85286330
5 files changed
+183
-3
No files matched your search
+52
-1
@@ -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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user