Terminal: smooth copy-paste for remote users (PTY/WebSocket) #69
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_router#69
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Copy-paste between a remote user and the hero_router terminal (PTY over
WebSocket) is not working smoothly. This was a blocker when using tmux
remotely and is a hard requirement for replacing TeamWorks.
Requirements
the remote terminal session
Relevant code
crates/hero_router_ui/— web admin dashboard (Axum + Askama)Acceptance Criteria
Implementation Spec for Issue #69 — Terminal: smooth copy/paste for remote users (PTY/WebSocket)
Objective
Make copy/paste between the user's local browser clipboard and the remote PTY session served by
hero_routerwork reliably and with negligible lag, including when tmux runs inside the session. The fix stays inside the existing xterm.js + WebSocket pipeline, adds no new dependencies, and uses tmux's native OSC 52 channel socopy-pipe/copy-selectionyanks land in the browser clipboard.Requirements
ESC ] 52 ; c ; <base64> BEL) — currently dropped, must be parsed and pushed tonavigator.clipboard.writeText.user_inputJSON-RPC frame.term.onData.navigator.clipboard.writeText/readTextis unavailable (insecure context, Firefox readText off): hidden<textarea>+document.execCommand.ESC ] 52 ; c ; ? BEL) are explicitly ignored — never expose the user's clipboard back to the PTY.ESC[6nandESC]52share only the ESC byte — but verify and pin with a unit test).Files to Modify/Create
crates/hero_router/static/js/terminal.js— registerterm.parser.registerOscHandler(52, ...), switch paste path toterm.paste(text)so bracketed-paste is honoured, addrightClickSelectsWord: trueto theTerminaloptions, addcopyToClipboard/readFromClipboardhelpers withexecCommandfallback, reuse them from both the keyboard handler and the right-click menu.crates/hero_router/templates/terminal.html— add a "Shift+drag" row to the shortcuts modal so users know how to bypass tmux mouse mode for native selection.crates/hero_router/src/server/terminal.rs— add a#[cfg(test)] mod testswith two regression tests pinning thatstrip_dsr_cpr_queriesdoes not eat OSC 52 frames. No production-code change here.crates/hero_router/README.md— append a "Clipboard & tmux" section documenting the recommended tmux config (set -g set-clipboard on,set -g mouse on), the Shift-drag gotcha, and the Ctrl+Shift+C/V keys.Implementation Plan
Step 1 — Add clipboard helper + OSC 52 handler in
terminal.jsFiles:
crates/hero_router/static/js/terminal.jsSubtasks:
bytesToB64(around line 27):copyToClipboard(text): trynavigator.clipboard.writeText(text); on rejection or absence, fall back to a hidden<textarea>+document.execCommand('copy').readFromClipboard(): trynavigator.clipboard.readText(); on failure, returnPromise.reject(...)so callers can show a non-fatal hint.createPane, afterterm.open(bodyEl)but before theattachCustomKeyEventHandlercall, register an OSC 52 handler that decodes the base64 payload and callscopyToClipboard(decoded). Ignore queries (?payload), ignore non-c/stargets, and cap decoded size at ~1 MB.Dependencies: none.
Step 2 — Use
term.paste(text)and tightenTerminaloptionsFiles:
crates/hero_router/static/js/terminal.jsSubtasks:
new Terminal({...})block (around line 613), addrightClickSelectsWord: trueand keepallowProposedApi: true(already present at line 619 — required byregisterOscHandler).attachCustomKeyEventHandler(lines ~654-664) — so both call:term.paste(text)automatically wraps inESC[200~ ... ESC[201~when the underlying app has enabled bracketed paste, then emits the wrapped bytes through the sameonDatapath the existing client→server scanner already consumes — no WS framing change required.copyToClipboardso behaviour is consistent across menus and key handler.Dependencies: Step 1 (uses the helpers).
Step 3 — Document the tmux gotcha in the shortcuts modal
Files:
crates/hero_router/templates/terminal.htmlSubtasks:
<table>:<tr><td><kbd>Shift</kbd>+drag</td><td>Force native selection (bypass tmux mouse mode)</td></tr>Ctrl+Shift+C / Vrow's description to:Copy selection / paste from clipboard (works under tmux via OSC 52).Dependencies: none.
Step 4 — Server-side regression test for OSC 52 vs DSR stripper
Files:
crates/hero_router/src/server/terminal.rsSubtasks:
#[cfg(test)] mod tests { ... }covering:strip_dsr_cpr_queries_leaves_osc52_intact— input contains an OSC 52 frame, expect zero strips and bytes unchanged.strip_dsr_cpr_queries_strips_only_dsr— input contains bothESC[6nand an OSC 52 frame, expect the DSR-CPR removed and the OSC 52 frame untouched.Dependencies: none.
Step 5 — User-facing tmux configuration docs
Files:
crates/hero_router/README.mdSubtasks:
set -g set-clipboard on(orexternalon tmux ≥ 3.2). For older tmux, addset -ga terminal-overrides ',*:Ms=\\E]52;c;%p2%s\\7'.set -g mouse on. To bypass tmux's mouse capture and use native xterm.js selection, hold Shift while click-dragging.Ctrl+Shift+Cto copy current selection,Ctrl+Shift+Vto paste. Right-click menu offers the same.Dependencies: none.
Acceptance Criteria
set-clipboard on.cargo test -p hero_routerpasses including the two new DSR-vs-OSC52 tests.Notes
set -g set-clipboard on. Without it tmux never emits OSC 52, so the OSC 52 handler is a no-op and copy from inside tmux only works via xterm.js mouse selection + Ctrl+Shift+C. Documented in Step 5.navigator.clipboard.readText()outside a user gesture. Both call sites (keypress and contextmenu) are user gestures, so this is fine. Do not move paste into a timer / async callback chain.set -g mouse on) consumes click events. Holding Shift bypasses it — documented in the shortcuts modal (Step 3) and README (Step 5).<iframe>, the embedding page must setallow="clipboard-read; clipboard-write". For the standalone admin dashboard at:9997the page is top-level so no Permissions Policy work is needed.strip_dsr_cpr_queries) only matches the exact 4-byte sequenceESC [ 6 n. OSC 52 starts withESC ](different intermediate byte), so the two cannot collide. Step 4 pins this with regression tests.allowProposedApi: true;term.parser.registerOscHandleris gated on it — keep the flag on.pendingreply-buffer logic interm.onDataonly buffers prefixes that start withESC [. OSC 52 (ESC ]) is unaffected, so client→server framing of paste data is correct.Test Results
cargo test -p hero_routerIncluding two new regression tests added by this change in
crates/hero_router/src/server/terminal.rs:server::terminal::tests::strip_dsr_cpr_queries_leaves_osc52_intactserver::terminal::tests::strip_dsr_cpr_queries_strips_only_dsrThese pin that the DSR cursor-position stripper does not consume OSC 52 frames (
ESC ] 52 ; c ; <base64> BEL) emitted by tmux yanks — that is the channel the new browser-side OSC 52 handler relies on.Implementation Summary
Branch:
development_terminal_clipboard_tmuxChanges
crates/hero_router/static/js/terminal.js— main fixcopyToClipboard(text),readFromClipboard(), with a hidden-<textarea>+document.execCommandfallback when the async Clipboard API is unavailable (insecure context, Firefox readText off).term.parser.registerOscHandler(52, ...)— decodes tmux yanks (ESC ] 5 2 ; c ; <base64> BEL) and routes them to the local clipboard. Targets restricted tocands; queries (?payload) deliberately ignored to prevent the remote PTY from reading the user's clipboard. Decoded payloads capped at ~1 MB.term.paste(text)so xterm.js wraps it inESC[200~ ... ESC[201~when bracketed-paste mode is on. tmux, vim, and bracketed-paste-aware shells now treat multi-line pastes as paste, not typing — that is the lag/edge-case the issue called out.rightClickSelectsWord: trueto theTerminalconstructor.crates/hero_router/templates/terminal.htmlCtrl+Shift+C / Vrow description, added aShift+dragrow documenting how to bypass tmux mouse mode for native xterm.js selection.crates/hero_router/src/server/terminal.rs#[cfg(test)] mod testswith two regression tests pinning thatstrip_dsr_cpr_queriesdoes not consume OSC 52 frames. No production-code change.README.mdset -g mouse on,set -g set-clipboard on, terminal-overrides line for older tmux), key bindings, and the Shift-drag tip.Test Results
cargo test -p hero_router— 81 passed, 0 failed, 0 ignored. Includes both new regression tests.Manual Verification Checklist
tmuxsession: select with mouse, hitprefix + ]after copy-mode yank — text appears in local clipboard.Ctrl+Shift+Vand right-click → Paste; verify multi-line paste does not auto-execute under bash 4.4+ / zsh / vim insert-mode.Ctrl+Cstill sends SIGINT when no selection is active.Shift+drag selects text natively even withset -g mouse on.Notes
user_inputJSON frame.allow="clipboard-read; clipboard-write"on the embedding<iframe>. The standalone admin dashboard at:9997is top-level and does not need any Permissions Policy work.Pull request opened: #75
This PR implements the changes discussed in this issue.
Implementation Spec for Issue #69 (continuation) — yanking still not reaching local clipboard
Root cause
The PR shipped on this branch already wires up:
term.parser.registerOscHandler(52, ...)decoding tmux yanks.term.paste(text).set-clipboard onon the launched tmux.mode-keys vi, withy/Enter/MouseDragEnd1Panerebound tocopy-pipe-no-clearso OSC 52 is the only sink.getActiveSelection(term)falling back towindow.getSelection()for Shift+drag under tmux mouse mode.The remaining failure is one layer below: from
man tmux,tmux 3.4 — even with
set-clipboard onorexternal— only emits OSC 52 when terminfo advertises theMsextended capability. The TERM values commonly used with xterm.js (xterm-256color,xterm,screen-256color) do not carryMsin the host terminfo. So tmux silently drops every yank — no OSC 52 ever leaves tmux, the browser handler never fires, and the user perceives "I can select but yanking is not working".The user-pasted console errors are unrelated:
content.js:1 ... FILE_ERROR_NO_SPACE (... WritableFileAppend)is from a Chrome extension's IndexedDB (content scripts namedcontent.js), not from the page.unpoly.min.js ... Cannot read properties of undefined (reading 'closest')fires inside asetInterval-drivenup.reloadonterminal:704. It is a separate live-reload edge case on the terminal page; out of scope for clipboard.Files to Modify
crates/hero_router/src/server/terminal.rs— extend the bootstrap tmux argv to injectMsviaterminal-overridesbeforenew-session. Extract the argv into a constant + helper, add a regression test pinning the line.crates/hero_router/README.md— append one paragraph to the existing "Clipboard & tmux" section explaining theMs/terminal-overrides requirement.Implementation Plan
Step 1 — Inject
Msviaterminal-overridesin the bootstrap tmux argvFiles:
crates/hero_router/src/server/terminal.rsSubtasks:
ShellType::TmuxActionBuilder::newargv (lines ~274-287), insert one clause beforenew-session:-ga= "global, append" so any existing per-host override is preserved.\Eand\7are tmux's own format-string escapes (parsed by tmux, not the shell) so they surviveinterpreter("exec")whitespace argv-splitting as literal characters.set-clipboard on,mouse on,mode-keys vi, the three rebound copy-mode keys).Msinjection and link to issue #69.const TMUX_BOOTSTRAP_ARGV: &str = "..."near the top of the module so the test in Step 2 can reference the same source-of-truth.Dependencies: none.
Step 2 — Regression test pinning the bootstrap argv
Files:
crates/hero_router/src/server/terminal.rsSubtasks:
#[cfg(test)] mod teststhat assertsTMUX_BOOTSTRAP_ARGVcontains exactly:set-clipboard onterminal-overrides ,*:Ms=\\E]52;c;%p2%s\\7(with\\as Rust string escape — literal backslash + capital E)mode-keys vinew-session(and that it appears last)Msinjection.Dependencies: Step 1.
Step 3 — README note
Files:
crates/hero_router/README.mdSubtasks:
set-clipboardonly emits OSC 52 when terminfo advertisesMs. hero_router-launched tmux sessions injectMsviaterminal-overrides, so OSC 52 yanks work regardless of the host's terminfo. If youtmux attachfrom outside hero_router, ensure your own tmux config does the same.Dependencies: none.
Acceptance Criteria
prefix + [, thenv/ motion /y), the yank lands in the local browser clipboard.cargo test -p hero_routerpasses, including the newtmux_bootstrap_contains_ms_terminal_overrideregression test.Notes
development_terminal_clipboard_tmux. InjectingMsviaterminal-overridesis what unblocks tmux's OSC 52 emission.set-clipboard ontoexternal? SameMsrequirement applies —externalonly changes whether tmux also accepts app-sideOSC 52to populate its own buffers, not whether tmux emits. Both modes needMsto emit.FILE_ERROR_NO_SPACE, unpolyclosest) are unrelated to clipboard and out of scope. Happy to open a separate issue for the unpoly periodic-reload error if it's worth chasing.