Make the terminal's right button copy and paste, and never open a menu
ci / build and test (push) Successful in 5m43s
ci / android head (push) Successful in 4m4s
ci / desktop nightly (push) Successful in 1m28s
ci / api image (push) Successful in 26s

Selecting output and right-clicking it opened the WebView's own context menu with
Copy on it, which is correct behaviour for a web page and wrong for a terminal.
What a terminal's right button has meant since the Windows console got QuickEdit,
and since PuTTY shipped, is this: with something selected it copies and drops the
selection, with nothing selected it pastes. No menu, no second click.

The gesture can only live in the page. The selection is drawn into a canvas the
host never sees, and once a terminal has focus the host's window receives no input
events at all — the same reason the Ctrl+plus chords are handled there.

◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY.

navigator.clipboard.readText() is gated behind the clipboard-read permission in
every Chromium, and an embedded WebView with no PermissionRequested handler
answers a permission request by putting a prompt on the screen. Trading a context
menu for a permission dialog is not this change. So the page asks and the host
answers, exactly as requestFontSizeStep does.

Writing does not go the other way either, although navigator.clipboard.writeText
would have worked from the page on Windows: a clipboard that is the host's in one
direction and the page's in the other is two mechanisms to keep working across
WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate
since the keychain grew its COPY buttons, so each App.axaml.cs grows a
ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it.

Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard.

◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING.

Paste carries a snippet off the sidebar, and the page deliberately *types* a
single-line one rather than bracketing it, because bash marks a bracketed paste as
an active region and a snippet should read as though it had been typed. A clipboard
paste is the case those markers are for — readline uses them to decide it has been
pasted into — so Clipboard is always bracketed and never followed by an Enter. One
opcode for both would have needed a flag meaning "which of the two kinds of paste
is this", on a payload whose existing flag byte means something else.

Both client frames are dispatched before the session lookup, which is what keeps
the gesture working in a pane whose shell has just ended: squinting at the message
a remote left behind is exactly when somebody copies the line. Dispatch was past
MA0051's length once it grew a second such branch, so the frames no pump needs to
see moved into Announce() and the font-size chord went with them.

◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.

preventDefault alone suppresses the menu and still lets xterm's own contextmenu
handler run, which moves its helper textarea under the pointer at z-index 1000 and
leaves the selection sitting in it, selected. Both of those exist to serve the
native menu. Taking the event in the capture phase on the pane and stopping it
there is what keeps the gesture wholly the page's.

rightClickSelectsWord is now false, which on macOS is a change of default rather
than a restatement of it: xterm sets it true there alone, so a right-click would
have selected the word under the pointer and copied that instead of pasting —
wrong on one platform only, which is the worst way for it to be wrong.

The renderer harness grows the pane it has been inviting since
RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec,
because both directions run through the page's own TextEncoder and TextDecoder and
a Latin-1 stub would pass a test with an em dash in the selection. Its warning
about declaring names terminal.js declares now covers the half that does not
SyntaxError — a harness frame() was silently replaced by the page's frame() and
failed six tests on a type error a long way from the cause.

Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu
event reaches a capture listener, that no native menu is drawn once it is
prevented, and that xterm's real paste() brackets. 11.13 also records what is
unverified — whether Android's WebView delivers a pointer's secondary click as a
contextmenu event at all. ClipboardReader is wired on that head regardless,
because the alternative is a head where the paste half silently does nothing and
nobody finds out; that head is not compiled here either, this machine having no
android workload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
laurensandClaude Opus 5 committed 2026-08-25 14:46:36 +02:00
1 parent 2810f7c12b
commit 1908189100
16 files changed
+1557 -22

No files matched your search

@@ -105,6 +105,23 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>Raised when the page has text for the system clipboard.</summary>
/// <remarks>
/// A right-click over a selection. Raised on the socket's receive loop, like
/// <see cref="FontSizeStepRequested"/> and with the same obligation on a handler that touches a view
/// model — and the clipboard itself is a UI-thread object on more than one platform, so this one has to
/// be marshalled even where nothing is bound to.
/// </remarks>
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
/// <summary>Raised when the page wants the system clipboard's text pasted into one of its panes.</summary>
/// <remarks>
/// A right-click with nothing selected. Raised on the socket's receive loop; see
/// <see cref="ClipboardCopyRequested"/>. The answer goes back as
/// <see cref="TerminalServerOpcode.Clipboard"/>, which is <see cref="TerminalWorkspace"/>'s to send.
/// </remarks>
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
/// <summary>
/// Raised after a socket attaches — the first one, and every later takeover.
/// </summary>
@@ -463,16 +480,8 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
return;
}
// Answered before the session lookup, because it is the one frame that is not about a session. The
// page sends whichever id it had to hand, and a chord pressed in a terminal whose shell has just
// ended is still a request to make the text bigger.
if ((TerminalClientOpcode)opcode is TerminalClientOpcode.FontSizeStep)
if (Announce(opcode, sessionId, payload))
{
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
{
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
}
return;
}
@@ -518,6 +527,59 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
}
}
/// <summary>
/// Raises the frames no pump needs to see, and says whether this was one of them.
/// </summary>
/// <remarks>
/// <para>
/// Answered before the session lookup, because none of these three touches a pump. A text-size chord is
/// about the person reading rather than about one terminal, and the page sends whichever id it had to
/// hand. A copy is about the machine's clipboard. A paste request is answered by sending a frame back
/// rather than by writing to a pty, and <see cref="TerminalWorkspace.SendClipboardAsync"/> does its own
/// liveness check — which is the one that can honestly refuse.
/// </para>
/// <para>
/// Which is also what keeps all three working in a pane whose shell has just ended, and that is when
/// they are most wanted: squinting at the message a remote left behind is exactly when somebody makes
/// the text bigger or copies the line. Dropping the frame at the pump lookup would have made a dead tab
/// the one place the gesture silently did nothing.
/// </para>
/// </remarks>
private bool Announce(byte opcode, uint sessionId, ReadOnlySpan<byte> payload)
{
switch ((TerminalClientOpcode)opcode)
{
case TerminalClientOpcode.FontSizeStep:
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
{
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
}
return true;
case TerminalClientOpcode.ClipboardCopy:
// An empty copy is dropped rather than forwarded. The clipboard is shared with everything
// else on the machine, so "copy nothing" would be a gesture that quietly destroys whatever
// was on it — and the page does not send one, which makes this a guard against a frame
// arriving from something other than the page we shipped.
if (payload.Length > 0)
{
ClipboardCopyRequested?.Invoke(
this,
new TerminalClipboardCopyEventArgs(Encoding.UTF8.GetString(payload)));
}
return true;
case TerminalClientOpcode.ClipboardPaste:
ClipboardPasteRequested?.Invoke(this, new TerminalClipboardPasteEventArgs(sessionId));
return true;
default:
return false;
}
}
/// <summary>
/// Computes the <c>Sec-WebSocket-Accept</c> value RFC 6455 §4.2.2 requires.
/// </summary>