|
| 1 | +# Clipboard, Copy & Paste |
| 2 | + |
| 3 | +SharpConsoleUI's clipboard is designed to work **both on a local desktop and over SSH** — a copy |
| 4 | +inside an app running on a remote server can land on the operator's **local** clipboard. This guide |
| 5 | +explains how copy and paste work, the remote/SSH behavior, and the configuration knobs. |
| 6 | + |
| 7 | +## TL;DR |
| 8 | + |
| 9 | +- **Copy** writes the system clipboard via the local tool (`xclip`/`wl-copy`/`pbcopy`/`clip.exe`) |
| 10 | + **and** emits an [OSC 52](#osc-52-the-remote-clipboard-escape) escape so the text also reaches the |
| 11 | + **local** clipboard over SSH. Belt-and-braces — it works whether the session is local or remote. |
| 12 | +- **Paste** uses the terminal's own paste. [Bracketed paste](#bracketed-paste) is enabled so a |
| 13 | + multi-line paste inserts as one atomic block (no runaway auto-indent), and `Ctrl+V` reads the |
| 14 | + clipboard for local sessions. |
| 15 | +- **Backward compatible:** the local-tool path is unchanged; everything new is additive and on by |
| 16 | + default in a way that never breaks existing local behavior. |
| 17 | + |
| 18 | +## The `ClipboardHelper` API |
| 19 | + |
| 20 | +All copy/paste flows through `SharpConsoleUI.Helpers.ClipboardHelper`: |
| 21 | + |
| 22 | +```csharp |
| 23 | +ClipboardHelper.SetText("hello"); // copy (local tool + OSC 52) |
| 24 | +string text = ClipboardHelper.GetText(); // read back (local tool / in-process buffer) |
| 25 | +``` |
| 26 | + |
| 27 | +`SetText` never throws — clipboard operations are best-effort. |
| 28 | + |
| 29 | +## How copy works |
| 30 | + |
| 31 | +`SetText` does two things, in order: |
| 32 | + |
| 33 | +1. **Emit OSC 52** (when enabled — see below) through the terminal's output stream. This is what |
| 34 | + reaches the **local** clipboard over SSH. |
| 35 | +2. **Run the local clipboard tool** (unchanged from earlier versions): `clip.exe` (Windows), |
| 36 | + `pbcopy` (macOS), `wl-copy` / `xclip` / `xsel` (Linux), or an in-process buffer when none is |
| 37 | + found. |
| 38 | + |
| 39 | +Both run on every copy. On a local desktop the OSC 52 is harmless (the terminal either honors it — |
| 40 | +setting the same clipboard the local tool just set — or silently ignores an unknown escape). Over |
| 41 | +SSH the local tool typically finds no clipboard on the (often headless) server, and OSC 52 carries |
| 42 | +the text to your machine. |
| 43 | + |
| 44 | +> OSC 52 is only emitted when running inside a live `ConsoleWindowSystem` (the console driver |
| 45 | +> registers the emitter at startup). In tests or non-driver contexts no escape is written. |
| 46 | +
|
| 47 | +### OSC 52: the remote-clipboard escape |
| 48 | + |
| 49 | +[OSC 52](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) is a |
| 50 | +terminal escape sequence (`ESC ] 52 ; c ; <base64> BEL`) that asks the **terminal** — i.e. the |
| 51 | +program on your local machine — to set its clipboard. Because the terminal runs locally, this is the |
| 52 | +mechanism that crosses an SSH boundary. Most modern terminals support it (iTerm2, WezTerm, kitty, |
| 53 | +Alacritty, Windows Terminal, foot, …); some disable it by default for security and must be opted in. |
| 54 | + |
| 55 | +Whether OSC 52 actually lands is therefore a property of **your terminal**, not the library. The |
| 56 | +library always emits a correct sequence when enabled; the terminal decides whether to honor it. |
| 57 | + |
| 58 | +## Behavior by session type |
| 59 | + |
| 60 | +| Session | OSC 52 | Where a copy lands | |
| 61 | +|---|---|---| |
| 62 | +| Local (X11 / Wayland / macOS / Windows) | emitted (redundant) | Local clipboard via the local tool (and OSC 52 if the terminal honors it) | |
| 63 | +| SSH, plain terminal | emitted | **Local** clipboard (if the terminal supports OSC 52) | |
| 64 | +| SSH + **tmux** | emitted, **passthrough-wrapped** | **Local** clipboard — no `~/.tmux.conf` change needed | |
| 65 | +| SSH + **screen** (`STY` set) | **skipped** | Server-side tool / in-process buffer (OSC 52 is unreliable under screen) | |
| 66 | +| Copy larger than ~74 KB | **skipped** | Local tool only (OSC 52 has terminal size limits) | |
| 67 | + |
| 68 | +Session detection happens once at startup and is exposed (read-only) on |
| 69 | +`SharpConsoleUI.Helpers.TerminalCapabilities`: |
| 70 | + |
| 71 | +```csharp |
| 72 | +TerminalCapabilities.IsRemoteSession // SSH_TTY or SSH_CONNECTION set |
| 73 | +TerminalCapabilities.IsTmux // TMUX set — OSC 52 is passthrough-wrapped |
| 74 | +TerminalCapabilities.IsScreen // STY set — OSC 52 skipped |
| 75 | +TerminalCapabilities.SupportsOsc52 // whether OSC 52 will be attempted (false under screen) |
| 76 | +``` |
| 77 | + |
| 78 | +## Configuration |
| 79 | + |
| 80 | +OSC 52 emission is controlled by `ClipboardHelper.Osc52Mode` (default `Auto`): |
| 81 | + |
| 82 | +```csharp |
| 83 | +// Default: emit when the session is believed to support OSC 52. |
| 84 | +ClipboardHelper.Osc52Mode = Osc52Mode.Auto; |
| 85 | + |
| 86 | +// Always emit, regardless of detection (e.g. a terminal you know supports it). |
| 87 | +ClipboardHelper.Osc52Mode = Osc52Mode.Enabled; |
| 88 | + |
| 89 | +// Never emit — local tools / in-process buffer only. |
| 90 | +ClipboardHelper.Osc52Mode = Osc52Mode.Disabled; |
| 91 | + |
| 92 | +// Override capability detection (e.g. force-enable under screen, or disable for a known-bad terminal). |
| 93 | +TerminalCapabilities.SetOsc52Override(true); // force on |
| 94 | +TerminalCapabilities.SetOsc52Override(false); // force off |
| 95 | +TerminalCapabilities.SetOsc52Override(null); // restore auto-detection |
| 96 | +
|
| 97 | +// Adjust the OSC 52 size cap (base64 payload length). Larger copies skip OSC 52. |
| 98 | +ClipboardHelper.MaxOsc52Bytes = Osc52.DefaultMaxBytes; // 74000 |
| 99 | +``` |
| 100 | + |
| 101 | +## How paste works |
| 102 | + |
| 103 | +### Bracketed paste |
| 104 | + |
| 105 | +When the app starts it enables **bracketed paste** (`ESC[?2004h`). The terminal then wraps pasted |
| 106 | +content in `ESC[200~` … `ESC[201~`, so the app can recognize a paste as one block instead of a flood |
| 107 | +of individual keystrokes. Without it, a multi-line paste would be processed key-by-key — newlines |
| 108 | +running as Enter, auto-indent corrupting the text. With it, the block is delivered atomically and |
| 109 | +inserted as content. |
| 110 | + |
| 111 | +This is also **the paste path that works over SSH**: when you press your terminal's paste |
| 112 | +(Cmd/Ctrl+Shift+V, middle-click, etc.), the terminal injects your **local** clipboard into the SSH |
| 113 | +stream, and bracketed paste lets the app insert it correctly. |
| 114 | + |
| 115 | +### `Ctrl+V` and `IPasteTarget` |
| 116 | + |
| 117 | +Paste is centralized: the window routes **both** a bracketed-paste block and `Ctrl+V` to the focused |
| 118 | +control's `IPasteTarget.Paste(string)`: |
| 119 | + |
| 120 | +```csharp |
| 121 | +public interface IPasteTarget |
| 122 | +{ |
| 123 | + void Paste(string text); // insert text at the current position as a single block |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +Built-in editors implement it (`MultilineEditControl`, `PromptControl`, `TableControl`). `Ctrl+V` |
| 128 | +reads `ClipboardHelper.GetText()` (the local-session source); bracketed paste uses the |
| 129 | +terminal-delivered text. A read-only editor's `Paste` is a no-op; `PromptControl` (single-line) |
| 130 | +flattens embedded newlines to spaces. |
| 131 | + |
| 132 | +> **Remote paste note:** reading the clipboard *back* (`GetText`, the `Ctrl+V` source) reads the |
| 133 | +> **server-side** clipboard over SSH — most terminals disable OSC 52 clipboard *reads* for security, |
| 134 | +> so there is no reliable app-driven remote read. Use your **terminal's** paste over SSH; bracketed |
| 135 | +> paste makes it insert correctly. |
| 136 | +
|
| 137 | +## Verifying it works |
| 138 | + |
| 139 | +Automated tests cover the encoder (byte-exact OSC 52 + tmux wrap), session detection, the layered |
| 140 | +`SetText`, the bracketed-paste parser, and end-to-end paste routing. The real round-trip depends on |
| 141 | +your terminal, so confirm it manually: |
| 142 | + |
| 143 | +```bash |
| 144 | +# Local |
| 145 | +dotnet run --project Examples/DemoApp |
| 146 | +# → Controls → Selectable Text: drag-select, Ctrl+C, paste into a local editor. |
| 147 | + |
| 148 | +# Remote (the real test) — from an OSC 52-capable terminal |
| 149 | +ssh you@server |
| 150 | +dotnet run --project Examples/DemoApp |
| 151 | +# → select + Ctrl+C in the app → paste on your LOCAL machine. |
| 152 | +# → paste a multi-line block into the editor → it inserts as one block. |
| 153 | +``` |
| 154 | + |
| 155 | +## Backward compatibility |
| 156 | + |
| 157 | +- The local clipboard tool still runs on every copy exactly as before; OSC 52 is added in front of |
| 158 | + it and is a no-op on terminals that don't support it. |
| 159 | +- `GetText` is unchanged. |
| 160 | +- All new surface (`Osc52Mode`, `MaxOsc52Bytes`, the `TerminalCapabilities` session flags, |
| 161 | + `IPasteTarget`) is additive; nothing existing was removed or changed in default behavior. |
| 162 | + |
| 163 | +## See also |
| 164 | + |
| 165 | +- [MultilineEditControl](controls/MultilineEditControl.md) — multi-line editor (Ctrl+C/X/V) |
| 166 | +- [PromptControl](controls/PromptControl.md) — single-line input with clipboard support |
| 167 | +- [MarkupControl](controls/MarkupControl.md) — opt-in selectable/copyable display text |
| 168 | + |
| 169 | +--- |
| 170 | + |
| 171 | +[Back to Documentation](../README.md#documentation) |
0 commit comments