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

+30 -2
View File
@@ -86,7 +86,13 @@ public sealed partial class DodoSshApp : Avalonia.Application
var shell = new PhoneShell();
var viewModel = ComposeShell(
paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));
paths,
caches,
workspace,
knownHosts,
connections,
ClipboardWriter(shell),
ClipboardReader(shell));
shell.DataContext = viewModel;
@@ -176,6 +182,26 @@ public sealed partial class DodoSshApp : Avalonia.Application
}
};
/// <summary>Reads this phone's clipboard.</summary>
/// <remarks>
/// <para>
/// The other direction, looked up per call for the same reason and with the same fall-through. One
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
/// </para>
/// <para>
/// Wired on this head although a phone has no right button, and not for symmetry: this head runs the same
/// renderer page, and a device with a mouse, a trackpad case or a desktop dock has a secondary click.
/// Whether Android's WebView delivers one as a DOM <c>contextmenu</c> event is unverified — see
/// <c>docs/manual-checks.md</c> 11.13 — and leaving this out in the meantime would make the paste half
/// the one thing that worked on one head and silently did nothing on the other.
/// </para>
/// </remarks>
private static Func<Task<string?>> ClipboardReader(Visual surface) =>
async () => TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard }
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
: null;
/// <remarks>
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
/// the platform graph, and below it is the shell every head shares.
@@ -186,7 +212,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
SshNetConnectionFactory connections,
Func<string, Task> copyToClipboard)
Func<string, Task> copyToClipboard,
Func<Task<string?>> readClipboard)
{
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
// key. A straight implementation of the interface the session layer has always taken.
@@ -236,6 +263,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
deviceName: PhoneEnvironment.DeviceName,
copyToClipboard: copyToClipboard,
readClipboard: readClipboard,
updates: updates);
+12
View File
@@ -72,6 +72,17 @@ internal sealed partial class DodoSshApp : Application
}
};
/// <summary>Reads this machine's clipboard.</summary>
/// <remarks>
/// The other direction, looked up per call for the same reason and with the same fall-through. One
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
/// </remarks>
private static Func<Task<string?>> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) =>
async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard }
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
: null;
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
/// <remarks>
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
@@ -169,6 +180,7 @@ internal sealed partial class DodoSshApp : Application
.ConfigureAwait(false),
copyToClipboard: ClipboardWriter(desktop),
readClipboard: ClipboardReader(desktop),
updates: updates,
openInEditor: OpenInDefaultEditorAsync,
watchFile: WatchFile);
@@ -328,6 +328,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly Func<string, Task>? copyToClipboard;
/// <remarks>
/// The other direction, and it exists for one caller: a right-click in a terminal with nothing selected.
/// The renderer cannot read a clipboard — <see cref="TerminalClientOpcode.ClipboardPaste"/> says why —
/// so the gesture crosses the socket and is answered here. Null on a head that wires none, which is now
/// no head at all: it stays nullable because this class has to be constructible without a visual, which
/// is what every test that drives it does.
/// </remarks>
private readonly Func<Task<string?>>? readClipboard;
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
private readonly Action<Action> post;
@@ -493,6 +502,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
/// the Android head's distribution separately, so it must never acquire one by accident.
/// </param>
/// <param name="readClipboard">
/// Reads this machine's clipboard, or null on a head that cannot. Answers the terminal's right-click
/// paste and nothing else — see the field it is kept in.
/// </param>
/// <param name="openInEditor">
/// Opens a local path with whatever this machine opens that kind of file with, or null on a head that
/// has no such notion. Forwarded straight to <see cref="TransfersViewModel"/>, which is the only thing
@@ -527,6 +540,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null,
Func<Task<string?>>? readClipboard = null,
string? deviceName = null,
IUpdateChannel? updates = null,
Action<Action>? post = null,
@@ -545,6 +559,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.clock = clock;
this.passphraseProfile = passphraseProfile;
this.copyToClipboard = copyToClipboard;
this.readClipboard = readClipboard;
// Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a
// blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail
@@ -575,6 +590,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
this.workspace.ClipboardCopyRequested += OnClipboardCopyRequested;
this.workspace.ClipboardPasteRequested += OnClipboardPasteRequested;
this.workspace.RendererReattached += OnRendererReattached;
settings = new ClientSettingsStore(paths);
@@ -726,6 +743,106 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
/// <summary>
/// A right-click over a selection: the page has sent the text, and this puts it on the clipboard.
/// </summary>
/// <remarks>
/// <para>
/// Marshalled for the reason <see cref="OnFontSizeStepRequested"/> is — this arrives on the data plane's
/// receive loop — plus one of its own, which is the stronger: an Avalonia clipboard is reached through a
/// <c>TopLevel</c>, and a <c>TopLevel</c> is only safe to touch from the thread that owns the window.
/// </para>
/// <para>
/// Through <see cref="post"/> rather than <c>Dispatcher.UIThread</c> directly, unlike the handlers
/// beside it: those predate the delegate and are the ones this shell's suite records as out of its
/// reach. There is no reason to add another.
/// </para>
/// </remarks>
private void OnClipboardCopyRequested(object? sender, TerminalClipboardCopyEventArgs e) =>
post(() => _ = CopyFromTerminalAsync(e.Text));
/// <summary>
/// A right-click with nothing selected: reads the clipboard and hands it to the pane that asked.
/// </summary>
/// <remarks>Marshalled for the reasons <see cref="OnClipboardCopyRequested"/> gives.</remarks>
private void OnClipboardPasteRequested(object? sender, TerminalClipboardPasteEventArgs e) =>
post(() => _ = PasteIntoTerminalAsync(e.SessionId));
/// <remarks>
/// <para>
/// Silence where there is no clipboard, and that is a different answer from the keychain's COPY buttons
/// — which say so, because a button that appears to do nothing is worse than a refusal. There is nowhere
/// to say it from here: the shell's status line is drawn on the enroll, sign-in and unlock cards, none
/// of which is on screen while somebody is right-clicking a terminal. Inventing a channel for a branch
/// no shipped head reaches would be the wrong trade, and both heads wire a clipboard.
/// </para>
/// <para>
/// A throw is swallowed rather than left to become an unobserved task exception. A clipboard is a shared
/// machine resource — on Windows another process holding it open is an ordinary, transient failure — and
/// a copy that did not happen is worth exactly one more right-click.
/// </para>
/// </remarks>
private async Task CopyFromTerminalAsync(string text)
{
if (copyToClipboard is null)
{
return;
}
try
{
await copyToClipboard(text).ConfigureAwait(true);
}
catch (Exception)
{
// Deliberately silent; see the remark for both halves of why. Nothing is left half done — the
// clipboard either took the text or did not.
}
}
/// <remarks>
/// <para>
/// An empty clipboard ends here rather than at the renderer. Sending it would bracket nothing and hand
/// the remote an empty paste, which is a keystroke nobody made; "there was nothing to paste" is a fact
/// about the machine rather than about this terminal, and the honest answer to it is that the
/// right-click did nothing — which is what every other terminal does with an empty clipboard too.
/// </para>
/// <para>
/// The answer for a session whose shell has ended is discarded for the reason
/// <see cref="TerminalWorkspace.SendClipboardAsync"/> gives for refusing it: nothing visible would
/// happen either way, and there is nowhere on this screen to say so.
/// </para>
/// <para>
/// A throw is swallowed, as in <see cref="CopyFromTerminalAsync"/> and for the same reason.
/// </para>
/// </remarks>
private async Task PasteIntoTerminalAsync(uint sessionId)
{
if (readClipboard is null)
{
return;
}
try
{
var text = await readClipboard().ConfigureAwait(true);
if (string.IsNullOrEmpty(text))
{
return;
}
await workspace
.SendClipboardAsync(sessionId, text, CancellationToken.None)
.ConfigureAwait(true);
}
catch (Exception)
{
// Deliberately silent; see the remark. The read and the send are both inside it, because
// whichever of the two failed leaves the same nothing on screen.
}
}
/// <remarks>
/// <para>
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
@@ -3203,6 +3320,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
workspace.ClipboardCopyRequested -= OnClipboardCopyRequested;
workspace.ClipboardPasteRequested -= OnClipboardPasteRequested;
workspace.RendererReattached -= OnRendererReattached;
transfers.PropertyChanged -= OnTransfersPropertyChanged;
@@ -33,11 +33,14 @@ const SERVER_SESSION_ACTIVATED = 4;
const SERVER_SESSION_REMOVED = 5;
const SERVER_PASTE = 6;
const SERVER_FONT_SIZE = 7;
const SERVER_CLIPBOARD = 8;
const CLIENT_INPUT = 1;
const CLIENT_ACKNOWLEDGE = 2;
const CLIENT_RESIZE = 3;
const CLIENT_FONT_SIZE_STEP = 4;
const CLIENT_CLIPBOARD_COPY = 5;
const CLIENT_CLIPBOARD_PASTE = 6;
const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000;
@@ -279,6 +282,66 @@ function handleKey(event) {
return true;
}
/*
── THE RIGHT-CLICK IS A COPY OR A PASTE, AND NEVER A MENU ───────────────────────────────────────────
What a terminal's right button has meant since the Windows console got QuickEdit and since PuTTY
shipped: with something selected it copies and drops the selection, with nothing selected it pastes.
No menu, no second click. This page used to answer with the WebView's own context menu — xterm stuffs
its helper textarea with the selection on contextmenu precisely so that the browser's Copy entry
works — which is correct behaviour for a web page and wrong for a terminal.
◆ 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 the font-size chords do — see requestFontSizeStep. Writing would have
worked from here on Windows, and does not go that way either: 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.
◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.
preventDefault alone would suppress the menu and still let xterm's own contextmenu handler run, which
moves the 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; with no menu they are an invisible textarea
floating over the grid and a DOM selection nothing will clear. Taking the event in the capture phase
and stopping it there is what keeps the gesture wholly ours.
rightClickSelectsWord is off for the same reason from the other side — see createSession.
*/
function handleRightClick(event, sessionId) {
event.preventDefault();
event.stopPropagation();
const session = sessions.get(sessionId);
if (!session) {
return;
}
const selection = session.term.getSelection();
if (selection.length > 0) {
send(CLIENT_CLIPBOARD_COPY, sessionId, new TextEncoder().encode(selection));
/*
And the selection goes, which is the console's behaviour and is load-bearing rather than cosmetic.
It is the only acknowledgement there is that the copy happened — nothing else on screen moves — and
it is what makes the *next* right-click a paste. Left standing, a selection somebody had forgotten
about would turn every later right-click into a copy of it, and the paste would look broken.
*/
session.term.clearSelection();
} else {
send(CLIENT_CLIPBOARD_PASTE, sessionId);
}
// Right-clicking a pane is a way of arriving at it. The caret follows, for the reason the paste frame's
// own focus() call gives.
session.term.focus();
}
/**
* Builds a pane for a session, or returns the one already there.
*
@@ -302,6 +365,19 @@ function createSession(sessionId) {
convertEol: false,
cursorBlink: true,
fontSize,
/*
Off, and on macOS that is a change from the default rather than a restatement of it: xterm defaults
this to true there, so a right-click would select the word under the pointer. With the gesture
meaning copy-or-paste, a right-click that quietly makes a selection is a right-click that copies a
word instead of pasting — and only on one platform, which is the worst way for it to be wrong.
Belt and braces today, because handleRightClick takes the event in the capture phase and xterm never
sees it. It is here for the upgrade that moves xterm's handler to mousedown, which is where it
already is on the Firefox path.
*/
rightClickSelectsWord: false,
scrollback: SCROLLBACK_LINES,
// Matches terminal.css, so the canvas and the page agree on the background. Both are the v2
// design's terminal surface — `TerminalSurface` in Palette.axaml. See the note in terminal.css.
@@ -346,6 +422,10 @@ function createSession(sessionId) {
term.attachCustomKeyEventHandler(handleKey);
// On the pane rather than on xterm's own element, and in the capture phase, which is what puts this
// ahead of xterm's handler on the element inside it. See handleRightClick.
pane.addEventListener('contextmenu', (event) => handleRightClick(event, sessionId), true);
term.onData((data) => {
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
});
@@ -562,6 +642,35 @@ function handleFrame(buffer) {
break;
}
case SERVER_CLIPBOARD: {
const session = sessions.get(sessionId);
if (!session || payload.length < 1) {
break;
}
/*
paste() unconditionally, and that is the whole difference from SERVER_PASTE above. This text came
off the system clipboard, which is the case bracketed paste is *for*: readline uses the markers to
decide it has been pasted into, and a shell that gets a multi-line command inside them treats every
newline as text rather than as "run this". The reasoning that makes a single-line *snippet* worth
typing instead — bash marking a bracketed paste as an active region, so the command sits at the
prompt looking selected — is the reasoning for a snippet reading as though it had been typed. A
clipboard paste is not pretending to be typing, and every other terminal highlights it too.
No execute flag, either: a paste inserts and stops. Pressing Enter for somebody is what the
sidebar's RUN does, and it is a thing they asked for by name.
*/
session.term.paste(new TextDecoder().decode(payload));
// The caret goes where the text landed, for the reason SERVER_PASTE's own focus() call gives — half
// of it, anyway. Here the other half is already right: the gesture that asked for this was a click
// on the pane, so the host's window never took Win32 focus away.
session.term.focus();
break;
}
case SERVER_FONT_SIZE: {
if (payload.length < 1) {
break;
@@ -808,4 +917,15 @@ document.addEventListener('visibilitychange', () => {
window.addEventListener('focus', reconnectIfDown);
window.addEventListener('online', reconnectIfDown);
/*
And nowhere on this page has a context menu, which the panes' own handler nearly finishes on its own: they
are positioned at inset 0, so every right-click over a terminal lands on one and is stopped there before it
reaches here.
What is left is the status banner, which is drawn over the bottom of the pane while it has something to say
— so a right-click on "Reconnecting the terminal view…" would have been the one place in a terminal window
that still opened a browser menu. Prevented and not stopped: there is nothing below this to stop it from.
*/
document.addEventListener('contextmenu', (event) => event.preventDefault());
connect();
+8 -2
View File
@@ -24,5 +24,11 @@ These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon.
1. Download the four files at the new versions and update the table above.
2. Confirm the UMD global names are unchanged.
3. Run the terminal suite, then start the app and open a session: nothing here is covered by the
.NET tests, because the renderer they exercise is a `ClientWebSocket` standing in for this page.
3. Confirm the right-click still belongs to `terminal.js`. Two things about xterm are load-bearing for it
and neither is a global: `rightClickSelectsWord` has to still be an option — the page sets it false, and
an option xterm no longer knows is silently ignored — and xterm has to still register its own right-click
handling on `contextmenu`, which `handleRightClick` gets ahead of in the capture phase. A build that moved
that to `mousedown`, as it already is on the Firefox path, would put xterm back in the gesture.
4. Run the terminal suite, then start the app and open a session: `RendererClipboardTests` runs the page's
own logic against a stubbed terminal, so what is left for a person is everything about the real one —
`docs/manual-checks.md` 1.11 is that list.
@@ -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>
@@ -95,6 +95,31 @@ public enum TerminalServerOpcode : byte
/// </para>
/// </remarks>
FontSize = 7,
/// <summary>
/// The system clipboard's text, for this session's pane to paste. Payload is UTF-8.
/// </summary>
/// <remarks>
/// <para>
/// Sent in answer to <see cref="TerminalClientOpcode.ClipboardPaste"/>, which is the only thing that
/// asks for it: a right-click over a pane with nothing selected. The host reads the clipboard because
/// the page cannot — see that opcode for why not.
/// </para>
/// <para>
/// <b>Not <see cref="Paste"/>, and the difference is the bracketing.</b> That opcode carries a snippet
/// off the sidebar, and the page deliberately <em>types</em> 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 this one is always bracketed. One opcode for both would need a flag
/// meaning "which of the two kinds of paste is this", which is the same distinction spelled less
/// clearly, on a payload that already has a flag byte meaning something else.
/// </para>
/// <para>
/// No flag byte, therefore, and no Enter: a clipboard paste inserts and stops. Pressing Enter for
/// somebody is what the sidebar's RUN does, and it is a thing they asked for by name.
/// </para>
/// </remarks>
Clipboard = 8,
}
/// <summary>Frames the renderer sends to the host.</summary>
@@ -131,6 +156,52 @@ public enum TerminalClientOpcode : byte
/// </para>
/// </remarks>
FontSizeStep = 4,
/// <summary>
/// Text to put on the system clipboard. Payload is UTF-8.
/// </summary>
/// <remarks>
/// <para>
/// A right-click over a pane with something selected. What gets copied is the selection, and only the
/// page knows what that is — xterm draws it into a canvas and the host never sees a glyph of it — so it
/// is the text that travels here rather than a request for it.
/// </para>
/// <para>
/// <b>Through the host rather than through the page's own <c>navigator.clipboard</c>.</b> Writing the
/// clipboard from the page would work on Windows, where the renderer is Blink and
/// <c>clipboard-write</c> is granted to a focused document without asking. It is the <em>reading</em>
/// that cannot be done that way — see <see cref="ClipboardPaste"/> — and 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; this is that one.
/// </para>
/// <para>
/// The session id is the pane the selection came from. Nothing routes on it — a clipboard is not a
/// property of one terminal — and it is carried because every frame carries one.
/// </para>
/// </remarks>
ClipboardCopy = 5,
/// <summary>
/// The page asking for the clipboard's text, to paste into this session. No payload.
/// </summary>
/// <remarks>
/// <para>
/// A right-click over a pane with nothing selected. The answer arrives as
/// <see cref="TerminalServerOpcode.Clipboard"/>, addressed to the session id this frame carried, so the
/// text lands in the pane the pointer was over rather than in whichever one the host believes is
/// selected. They agree in every arrangement that exists today, because only the active pane is
/// visible — but the page is the one that knows, so the page is the one that says.
/// </para>
/// <para>
/// <b>A request rather than the page reading the clipboard itself, and this is the half with no
/// alternative.</b> <c>navigator.clipboard.readText()</c> is gated behind the <c>clipboard-read</c>
/// permission in every Chromium, and an embedded WebView answers a permission request it has no handler
/// for by putting a prompt on the screen. Trading a context menu for a permission dialog is not the
/// change this was.
/// </para>
/// </remarks>
ClipboardPaste = 6,
}
/// <summary>
@@ -72,6 +72,28 @@ public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
public int Step { get; } = step;
}
/// <summary>The renderer handing over text for the system clipboard.</summary>
/// <param name="text">
/// The selection, exactly as the page had it. Never empty — the transport drops an empty copy rather than
/// letting a stray gesture wipe the machine's clipboard.
/// </param>
public sealed class TerminalClipboardCopyEventArgs(string text) : EventArgs
{
/// <summary>What to put on the clipboard.</summary>
public string Text { get; } = text;
}
/// <summary>The renderer asking for the system clipboard's text.</summary>
/// <param name="sessionId">
/// The pane the pointer was over, which is where the text is to land. The page sends it rather than leaving
/// the host to infer it from whichever tab it believes is selected.
/// </param>
public sealed class TerminalClipboardPasteEventArgs(uint sessionId) : EventArgs
{
/// <summary>The terminal to paste into.</summary>
public uint SessionId { get; } = sessionId;
}
/// <summary>
/// Owns the loopback data plane and every live terminal session.
/// </summary>
@@ -148,6 +170,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
// what remembers it between launches.
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
// Forwarded for the same reason and decided no more than the size is. What a clipboard is and how it
// is reached is a property of the platform the shell is running on, and this object has never seen a
// visual.
dataPlane.ClipboardCopyRequested += (_, e) => ClipboardCopyRequested?.Invoke(this, e);
dataPlane.ClipboardPasteRequested += (_, e) => ClipboardPasteRequested?.Invoke(this, e);
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
// handshake handling, and has no business making that wait on however long a replay takes. See
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
@@ -313,6 +341,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>Raised when a right-click over a selection asks for it to be copied.</summary>
/// <remarks>
/// The gesture can only be heard by the page, for the reason <see cref="FontSizeStepRequested"/> gives,
/// and the selection can only be <em>read</em> there: xterm draws it into a canvas. So the text arrives
/// here, and what a clipboard is on this platform is the shell's to know. Raised on the data plane's
/// receive loop; marshal before touching one.
/// </remarks>
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
/// <summary>Raised when a right-click with nothing selected asks for the clipboard to be pasted.</summary>
/// <remarks>
/// Answered with <see cref="SendClipboardAsync"/>, which is the other half: this event carries no text
/// because the page has none to give — reading the clipboard is exactly what it cannot do. Raised on the
/// data plane's receive loop; marshal before touching a view model, and before touching a clipboard,
/// which is a UI-thread object on more than one platform.
/// </remarks>
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
/// <summary>
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
/// </summary>
@@ -359,6 +405,55 @@ public sealed class TerminalWorkspace : IAsyncDisposable
TerminalFrame.CreateFontSizePayload(pixels)),
cancellationToken);
/// <summary>
/// Hands the system clipboard's text to one pane, to be pasted into it.
/// </summary>
/// <remarks>
/// <para>
/// The answer to <see cref="ClipboardPasteRequested"/>, and the only thing that sends this frame. Always
/// bracketed and never followed by an Enter — see <see cref="TerminalServerOpcode.Clipboard"/> for why a
/// clipboard paste is not the same act as inserting a snippet, and why the two do not share an opcode.
/// </para>
/// <para>
/// Refused for a dead session, exactly as <see cref="PasteAsync"/> is refused and for the weaker of its
/// two reasons: nothing visible would happen either way, because a paste is keystrokes rather than
/// output and there is no shell left to echo them. Checked anyway so that the answer is honest, and so
/// that a caller which grows a message one day has something to hang it on.
/// </para>
/// <para>
/// An empty clipboard is the caller's to notice. Sending nothing is not the same as sending an empty
/// string — the page would bracket it and the remote would see an empty paste — and "there was nothing
/// to paste" is a fact about the machine rather than about this terminal.
/// </para>
/// </remarks>
/// <param name="sessionId">The terminal to paste into.</param>
/// <param name="text">The clipboard's text. Sent verbatim.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns><see langword="false"/> when that session's shell is not running.</returns>
public async Task<bool> SendClipboardAsync(
uint sessionId,
string text,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(text);
if (!IsSessionLive(sessionId))
{
return false;
}
await dataPlane
.SendAsync(
TerminalFrame.Create(
(byte)TerminalServerOpcode.Clipboard,
sessionId,
Encoding.UTF8.GetBytes(text)),
cancellationToken)
.ConfigureAwait(false);
return true;
}
/// <summary>
/// Waits until the renderer page has attached its socket.
/// </summary>