Skip to content

client: drop drag-select clipboard trim — no tmux OSC 52 off-by-one exists#68

Merged
dolonet merged 2 commits into
dolonet:mainfrom
gorevds:fix/drop-clipboard-trim
May 20, 2026
Merged

client: drop drag-select clipboard trim — no tmux OSC 52 off-by-one exists#68
dolonet merged 2 commits into
dolonet:mainfrom
gorevds:fix/drop-clipboard-trim

Conversation

@gorevds
Copy link
Copy Markdown
Collaborator

@gorevds gorevds commented May 15, 2026

4703bc1 (PR #54) added a one-character trimDragTail to the OSC 52 and
onSelectionChange clipboard paths, on the premise that tmux includes an
extra trailing cursor cell in OSC 52 payloads. That premise was never
verified against the wire — it was inferred from a visual artifact
(xterm.js painting the cursor bar over the trailing edge of tmux's
yellow selection) and written into the commit message as fact.

Wire-level measurement shows the premise is false:

  • tmux 3.4, SGR mouse drag injected over a pty.fork'd tmux attach,
    OSC 52 captured off the stream: press@1 release@9 yields exactly
    012345678 (9 cells) — the selection is [press, release] with no
    extra cursor cell.
  • tmux 3.2a (Ubuntu 22.04) measured three independent ways — browser,
    raw-PTY SGR injection, keyboard copy-mode — is byte-identical to 3.4.
  • The tmux CHANGES log has no entry between 3.2a and 3.4 that alters
    OSC 52 selection content; the sole 3.4 OSC 52 change is passing the
    clipboard kind argument through, unrelated to content.

tmux's selection is [start, end) exclusive on every version. The
trim therefore always dropped a real visible character — silently on
every target. That is the paste-too-short bug this branch set out to
fix; the fix is simply to remove the trim, not to gate it on a tmux
version that has no behavioral difference.

This supersedes the earlier conditional-trim approach on this branch
(server OSC 1338 version probe + client _tmuxNeedsTrim gate): with no
off-by-one to compensate for, version detection has nothing to do, so
the probe and handler are dropped entirely (server.py and test_server.py
return to their upstream state).

Changes:

  • Remove trimDragTail, DRAG_TRIM_WINDOW_MS, _recentDragSelectAt;
    both clipboard paths now pass tmux's payload through unmodified.
  • Keep the CURSOR_HIDE machinery intact — it fixes the real,
    independent visual artifact (cursor bar/blink over the selection's
    trailing cell) via cursorInactiveStyle:'none' + drag-blur +
    deferred focus restore. Rename the SELECTION_TRIM: marker to
    CURSOR_HIDE: since the trim it referred to is gone.
  • Frontend: drop the trimDragTail unit test; add two passthrough
    regression tests pinning that OSC 52 and onSelectionChange payloads
    reach the clipboard byte-identical, so the trim can't be
    reintroduced silently.
  • README / README.ru / docs: drop the incorrect OSC 52 off-by-one
    rationale and the tmux 3.4+ version floor — the clipboard payload
    is byte-identical across tmux versions, so the persistent-sessions
    note reads "any reasonably recent version", in line with the
    project's other inclusive compat floors.

@gorevds gorevds requested a review from dolonet May 16, 2026 13:55
@dolonet
Copy link
Copy Markdown
Owner

dolonet commented May 16, 2026

Thanks for the careful diagnosis and the regression tests — the bug is
real (consistent with the PR description and the new regression tests)
and the fix direction is correct. Two small fixes and one concept-level
concern worth discussing before merge.

Small fixes

1. Broken Russian phrase in README.ru.md

The new persistent-sessions bullet contains копирование выделения паст ит на один символ длиннеепаст ит (with the stray space) reads as a
literal char-by-char transliteration of "paste it" rather than a Russian
word.

Suggested replacement (matches the impersonal compact tone of the
surrounding bullets):

В старых версиях tmux в OSC 52 payload попадает лишняя клетка под
курсором, и при вставке скопированного выделения получается на один
символ больше видимого.

websh/README.ru.md

Lines 41 to 46 in e2c2705

- **Браузер.** Любой современный. xterm.js грузится с CDN.
- **Постоянные сессии (опционально).** `tmux 3.4+` на удалённом хосте.
В старых версиях tmux OSC 52 payload содержит лишнюю клетку под
курсором, и копирование выделения паст ит на один символ длиннее
визуального выделения. См. [`docs/persistent-sessions.md`](docs/persistent-sessions.md).
- **Опциональный proxy на shared-хостинге.** PHP 5.3+ с расширением `curl`.

2. Stale "trim window" reference in createPane comment block

websh.js line 237-238 still says "bare clicks skip both the arm and the
trim window so a programmatic OSC 52 right after a focus-click isn't
truncated". The trim-window mechanism is gone in this PR; only the
deferred-restore arm remains. The e2c2705 polish commit caught the
SELECTION_TRIM:CURSOR_HIDE: rename but missed this inner prose.

Suggested rewrite of the trailing clause:

...Click-vs-drag is decided by mousemove > 3px — bare clicks skip the
deferred-restore arm and re-focus xterm immediately.

websh/websh.js

Lines 228 to 240 in e2c2705

// CURSOR_HIDE: focus tracking + drag-blur for cursor hiding.
// Blur the xterm terminal on left-mousedown so the cursor cell that
// ends up at the trailing edge of tmux's orange selection renders
// without the bar/blink (via cursorInactiveStyle:'none'). The
// companion document-level mouseup defers `focus()` restoration until
// tmux moves the cursor back to the prompt (onCursorMove) or 500ms
// elapsed, so the bar doesn't flicker back in the gap. Click-vs-drag
// is decided by mousemove > 3px — bare clicks skip both the arm and
// the trim window so a programmatic OSC 52 right after a focus-click
// isn't truncated.
el.addEventListener('mousedown', (e) => {
activatePane(id);

Concept-level: tmux 3.4+ floor is the tightest in the project

The README's other compat floors are intentionally inclusive — Python
3.5+ (2015), PHP 5.3+ (2009), "any modern browser". This PR introduces
tmux 3.4+ (April 2024) as the new floor for the persistent-sessions
feature, which iPersistent defaults checked in index.html — so
it's the path every new connect lands on.

As of May 2026, common LTS targets ship tmux below 3.4:

  • Ubuntu 22.04 LTS jammy: tmux 3.2a (standard support through 2027, ESM
    through 2032)
  • Debian 12 bookworm: tmux 3.3a
  • RHEL 9 / Rocky 9 / Alma 9: tmux 3.2a (maintenance through 2027)
  • Ubuntu 20.04 LTS focal: tmux 3.0a
  • Amazon Linux 2: tmux 1.8

On those targets the post-PR symptom is paste-too-long with no in-product
diagnostic — the user discovers it when a pasted command does the wrong
thing. 4703bc1 was a half-fix and this PR correctly removes the new
regression it introduced on 3.4+ — but an approach more aligned with the
project's graceful-degradation pattern would be conditional trim
based on detected tmux version, rather than hard removal. I get the
"cleaner contract" framing from the PR description; the trade-off is
real (simplicity vs. inclusiveness) and reasonable people land on either
side. My push is toward the inclusiveness side, given the tone the rest
of the README's compat-floor list sets.

(Note: dynamic compare via term.getSelection().length vs OSC 52 payload
length doesn't work — old-tmux corrupts both pathways symmetrically so
there's no detectable mismatch. The signal has to come from version
detection.)

Proposed mechanism (~25 LoC)

In _build_remote_command (server.py), prepend one echo to the
persistent-mode shell wrapper before exec tmux new-session ...:

echo "WEBSH_TMUX_VERSION=$(tmux -V 2>&1)"
exec tmux new-session -A -D -s websh-<slot> \; set -g mouse on \; ...

That line is the first byte on the SSE pipe. On the client SSE handler:

let m = /WEBSH_TMUX_VERSION=tmux (\d+)\.(\d+)/.exec(chunk);
if (m) {
  let major = +m[1], minor = +m[2];
  p._tmuxNeedsTrim = major < 3 || (major === 3 && minor < 4);
  // Strip the marker line before forwarding to term.write(); tmux's
  // attach-time screen-clear usually overwrites it, but not reliably
  // (small terminals, alt-screen apps that take over before the clear),
  // so make it invisible explicitly.
  chunk = chunk.replace(/WEBSH_TMUX_VERSION=tmux [^\r\n]*[\r\n]+/, '');
}

Reapply the conditional in term.onSelectionChange and the OSC 52
handler:

term.onSelectionChange(() => {
  let sel = term.getSelection();
  if (!sel) return;
  if (p._tmuxNeedsTrim) sel = sel.slice(0, -1);
  if (sel) copyText(sel);
});

Optionally, surface a one-time info bar when _tmuxNeedsTrim is true:
"tmux X.Y on target — drag-select auto-trimmed for compatibility; tmux
3.4+ recommended for native handling."

Non-persistent connects, missing tmux, or unparseable version output all
naturally fall through to _tmuxNeedsTrim = undefined → no trim, which
is the correct default for those paths. The existing showTmuxBar
already covers "tmux not found".

This keeps every still-supported LTS working with clean drag-select copy,
preserves the modern-tmux fix from this PR, and the README compat-floor
bullet then reads in tone with the other "X+, older works degraded"
floors:

Optional persistent sessions. Recommends tmux 3.4+ on the target
host for clean drag-select copy. Older versions work but paste one
trailing character past the visible selection; websh detects and
auto-trims when possible. See docs/persistent-sessions.md.

If you go this route, the new regression tests need updating

The two CURSOR_HIDE: ... reaches clipboard unmodified (no trim) tests
pin a "never trim" contract that conflicts with conditional logic.
Extend each to assert both branches:

  • p._tmuxNeedsTrim = false (or unset) → payload unmodified (current)
  • p._tmuxNeedsTrim = true → payload trimmed by 1 char

That also fixes the post-drag branch of the OSC 52 test, which currently
duplicates the drag-blurred assertion without exercising a meaningfully
different state.

Alternative: ship as-is, version-detect as follow-up

Defensible: ship this PR, open a follow-up issue, accept the symmetric
regression on <3.4 for the gap. Strictly worse UX than landing
version-detect now (silent paste-too-long is exactly what 4703bc1 was
originally trying to solve), but if the priority is keeping the surgical
3.4+ fix focused, it's a reasonable call.

Let me know which direction makes sense for this PR.

@dolonet
Copy link
Copy Markdown
Owner

dolonet commented May 17, 2026

Updated the comment above @gorevds

@gorevds gorevds force-pushed the fix/drop-clipboard-trim branch 2 times, most recently from 55507ca to e72de34 Compare May 18, 2026 18:16
@gorevds
Copy link
Copy Markdown
Collaborator Author

gorevds commented May 18, 2026

Conditional trim landed. Both small fixes folded in, history squashed
to one commit so the narrative reads coherently end-to-end ("legacy
tmux pads OSC 52 → detect → conditionally trim") rather than "remove
trim; oh wait, put it back conditionally".

Signaling channel: OSC 1338 over regex-strip

Picked the OSC route instead of the visible echo + regex-strip:

printf '\033]1338;websh-tmux-version=%s\033\\' "$(tmux -V 2>&1)"
exec tmux new-session ...

xterm.js's OSC parser buffers escape sequences across input boundaries
natively, so SSE chunk-splitting can't half-eat the marker — the
/WEBSH_TMUX_VERSION=...[\r\n]/ regex was the one thing in the
proposed mechanism that was probabilistic. The handler returns true
to consume the sequence, so the marker never reaches term.write()
regardless of whether tmux's attach-time clear-screen wins the race
or some alt-screen app takes over first. Same ~25 LoC, no race.

Probe lands in both wrapper branches: TTL=0 → probe + "exec " + attach,
TTL>0 → between the watchdog spawn and the exec (after the
backgrounded nohup sh -c '...' so it doesn't compete for the PTY
with the watchdog body — which redirects to /dev/null anyway). Test
test_osc_1338_probe_emitted_before_exec_for_ttl_{zero,positive} pins
the ordering invariant.

Detection threshold

p._tmuxNeedsTrim = major < 3 || (major === 3 && minor < 4);
  • tmux 3.2a → trim (the Ubuntu 22.04 / RHEL 9 case)
  • tmux 3.3a → trim (Debian 12)
  • tmux 3.4 and 3.5 → no trim
  • tmux 2.9 → trim (the major < 3 branch)
  • Unparseable (no tmux X.Y substring, custom builds, tmux next-3.5)
    → flag stays undefined → no trim. Correct default — those targets
    either don't have the off-by-one or we can't reason about them, and
    silent paste-too-long on legacy is strictly less surprising than
    silent paste-minus-one on modern. Existing showTmuxBar handles
    "tmux not found" elsewhere.

a suffix on 3.2a / 3.3a doesn't matter — the regex only captures
the leading \d+\.\d+, so tmux 3.2a matches as 3.2. Same for
3.3a (3.3), tmux 3.0a-rcN (3.0), etc.

UX: silent

No toast on detect — per the README's "X+ recommended; older auto-trims"
tone, the auto-trim is meant to be invisible. Docs cover it for the
curious. Easy to add a one-time bar later if support traffic comes in.

Trim contract: single-char short-circuit

Both paths slice with text.slice(0, -1), then gate the actual copy:

if (text) copyText(text);  // OSC 52
// and
if (sel) copyText(sel);    // onSelectionChange

A length-1 selection (bare focus-click on legacy tmux) trims to empty
and skips the call — no clobbering an existing clipboard with "".
CURSOR_HIDE: OSC 52 — legacy tmux ... trims trailing char second
half pins this.

Small fixes

README.ru.md rewritten — kept your phrasing as the base but
parameterized to the new "auto-detect at attach" reality:

В старых версиях tmux в OSC 52 payload попадает лишняя клетка под
курсором; websh определяет версию на attach (OSC 1338 от обёртки)
и автотриммит trailing-символ при копировании.

websh.js:236-238 stale prose rewritten to your suggestion verbatim:

...Click-vs-drag is decided by mousemove > 3px — bare clicks skip
the deferred-restore arm and re-focus xterm immediately.

Tests

Frontend +8 (519 / 0 total, was 511):

  • OSC 52 × {modern flag undefined / legacy flag=true}
  • onSelectionChange × {modern / legacy}
  • OSC 1338: 3.2a → trim, 3.4 → no-trim, 2.9 → trim (major<3 branch),
    unparseable input leaves flag undefined

Server +3 (423 / 0 total, was 420):

  • test_ttl_zero_returns_probe_then_exec pins exact wrapper layout
  • test_osc_1338_probe_emitted_before_exec_for_ttl_{zero,positive}
    pin probe-before-exec ordering in both TTL branches
  • test_osc_1338_probe_uses_configured_tmux_cmd pins custom tmux path
    threading

sh -n syntax-check tests on the generated wrapper continue to pass
across all TTL combinations, including the new printf line.

Live verification

Deployed to https://websh.gorev.space (Ubuntu 24.04 → tmux 3.4-1
target). OSC 1338 fires at attach (visible in xterm.js parser
instrumentation), p._tmuxNeedsTrim === false, drag-select copy
matches visible selection char-for-char. Will retest on a 22.04 target
once I get hands on one.

@gorevds gorevds force-pushed the fix/drop-clipboard-trim branch from e72de34 to ea74b33 Compare May 18, 2026 19:33
@dolonet
Copy link
Copy Markdown
Owner

dolonet commented May 19, 2026

Spent some time live-verifying this end-to-end on stock Ubuntu LTS
targets and the conclusion didn't come out where I expected — the
conditional trim direction is wrong on <3.4, not right. Want to
surface the data before this lands.

The OSC 1338-over-regex-strip swap is a clear improvement on what I
suggested (xterm's parser handling chunk boundaries beats my
probabilistic regex strip), and the "detect version + conditionally
trim" framing is the right shape for a version-gated fix — the
underlying motivation just doesn't hold up to wire-level inspection
on the targets I tried.

Smoking gun

Stock Ubuntu 22.04 Docker (tmux 3.2a-4ubuntu0.2, jammy-security — no
OSC 52 related Ubuntu patches per the changelog) and Ubuntu 24.04
Docker (tmux 3.4). Same pixel drag in both, browser test, mid-text
selection so the right edge lands on a non-whitespace cell tmux
wouldn't strip:

tmux Highlighted cells via getCell().getBgColor() Raw OSC 52 (atob hook) Final clipboard
PR ea74b33 3.2a 012345678 (9) 012345678 (9) 01234567 (8 — trim dropped the visible 8)
PR ea74b33 3.4 012345678 (9) 012345678 (9) 012345678 (9 ✓)
trim sites commented out 3.2a 012345678 (9) 012345678 (9) 012345678 (9 ✓)
trim sites commented out 3.4 012345678 (9) 012345678 (9) 012345678 (9 ✓)

Raw OSC 52 payload is bit-identical between tmux 3.2a and 3.4 for the
same selection. The visible highlight matches the OSC 52 payload
char-for-char on both versions. The trim on <3.4 therefore drops a
real visible character — the failure mode is paste-too-short on
legacy, not paste-too-long.

Verifying outside websh / xterm.js too

To rule out anything client-side, ran the same selection via two more
harnesses:

Pure-tmux mouse via PTY injection (no websh, no xterm.js)

pty.fork'd tmux attach, wrote raw SGR mouse sequences
(\e[<0;col;row M press → \e[<32;col;row M drags →
\e[<0;col;row m release) into the client's PTY, parsed OSC 52 out
of the recv stream:

3.2a, drag cells [1, 9):   highlight `01234567`        (8),  OSC 52 `01234567`        (8)
3.2a, drag cells [1, 10):  highlight `012345678`       (9),  OSC 52 `012345678`       (9)
3.2a, drag cells [1, 16):  highlight `0123456789ABCDE` (15), OSC 52 `0123456789ABCDE` (15)
3.4, identical inputs → byte-identical outputs in every case.

tmux selection model on both versions: [start_cell, end_cell). Cursor
cell at end_cell isn't in the selection or in OSC 52.

Pure-tmux keyboard copy-mode

tmux copy-mode -t pure; send-keys -X start-of-line; -X begin-selection; -X cursor-right ×N; -X copy-selection-and-cancel. With
set-clipboard on so OSC 52 fires regardless of terminfo Ms cap
(xterm-256color terminfo on jammy doesn't ship Ms):

3.2a, cursor-right ×8: save-buffer `01234567` (8), OSC 52 `01234567` (8)
3.4,  cursor-right ×8: save-buffer `01234567` (8), OSC 52 `01234567` (8)

tmux 3.4 CHANGES doesn't claim an OSC 52 selection fix between 3.2a and 3.4

Only OSC 52 mention in 3.4 (since 3.3a):

Pass through first argument to OSC 52 (which clipboards to set) if the
application provides it.

That's about the kind parameter (c/p/s/q), not content. 3.3
(since 3.2a) added allow-passthrough — unrelated. I couldn't locate
an upstream commit that matches "tmux ≤3.3 includes the cursor cell
in OSC 52, 3.4 doesn't" — so the "tmux 3.4 fixed this upstream"
framing in the PR description and 4703bc1 doesn't have an obvious
corresponding upstream change.

My read on what likely happened in 4703bc1

Hypothesis — I can't test the counterfactual cleanly, but it's the
shape that fits the evidence:

The visual observation that motivated 4703bc1 was real and worth
fixing — xterm.js was painting the terminal cursor bar over the
trailing-edge cell of tmux's yellow selection, so the user saw a
"stray symbol after the orange". The cursorInactiveStyle:'none' +
drag-time blur trick (CURSOR_HIDE) addresses that visual cleanly,
and that part of the PR should stay regardless.

The jump from "visible cursor cell artifact" to "therefore OSC 52
includes a trailing cursor cell" was the step that didn't get checked
against the wire. On what I'm seeing, OSC 52 never includes the cursor
cell — selection is [start, end) exclusive on both 3.2a and 3.4.
Under mouse on, term.onSelectionChange doesn't fire at all (tmux
owns the selection — xterm just forwards mouse events and never builds
a local selection), so the original trimDragTail only ever ran on
the OSC 52 path, where it was always wrong on <3.4 and silently
wrong on ≥3.4 until b3b7c67 made it loud.

What I'd propose

Revert to PR-attempt-1 shape:

  • Keep CURSOR_HIDE entirely — it fixes a real user-visible cursor
    flicker and is independent of clipboard semantics. The renamed
    marker, the deferred-restore arm, the _destroyPane cleanup, the
    regression tests — all stay.
  • Drop the _tmuxNeedsTrim flag and the two slice(0, -1) sites
    in websh.js.
  • Drop (or keep — minor) the OSC 1338 wiring. If the conditional
    trim goes, the OSC 1338 handler + server probe have no consumer; the
    clean option is to drop them along with their tests
    (test_osc_1338_probe_emitted_before_exec_for_ttl_*,
    test_osc_1338_probe_uses_configured_tmux_cmd, the four frontend
    OSC 1338 detection tests). If you'd rather leave the
    detection-as-scaffolding for a future tmux-version-dependent
    behavior, that's a smaller debate — the part I'm sure about is the
    trim itself.
  • Reframe the kept frontend clipboard tests as "passthrough,
    no conditional" — CURSOR_HIDE: OSC 52 payload reaches clipboard unmodified / ... onSelectionChange payload reaches clipboard unmodified, no _tmuxNeedsTrim branches.
  • Revert the test_ttl_zero_returns_probe_then_exec rename in
    test_server.py back to test_ttl_zero_returns_plain_exec (or
    keep both branches if OSC 1338 stays as scaffolding).
  • The README / docs note "tmux 3.4+ recommended" is fine to keep as
    general guidance; the implementation just doesn't have to detect or
    compensate.

End result: clean contract, both <3.4 and ≥3.4 paste matches
visible char-for-char, no version-detection machinery.

How to verify on your end (single tab, no infra)

Open DevTools Console on a websh tab connected to any tmux target in
persistent mode, paste:

window.atob = (orig => s => {
  const r = orig(s);
  if (r && /^\d/.test(r)) console.log('OSC52 raw:', JSON.stringify(r), 'len:', r.length);
  return r;
})(window.atob);

Then echo 0123456789ABCDEF, mouse-drag-select the digits, watch the
console for the OSC 52 raw payload, and eyeball the highlight extent
on screen. If raw length == visible cell count, no trim needed. If
raw length == visible cell count + 1, there's a trigger I haven't
hit, in which case I'd want to chase what specifically.

Caveats — what I haven't covered

  • Multi-line wrapped selection (single-line ASCII only)
  • Wide / CJK / emoji chars
  • Non-default selection-mode / mode-style / default-terminal
    (jammy defaults default-terminal screen, noble defaults
    tmux-256color — both gave the same OSC 52 in my tests, but I
    didn't sweep every combination)
  • Real production hosts — only Docker containers with stock Ubuntu
    packages

If you've directly observed paste-too-long on a <3.4 target without
the trim — share tmux -V on the target plus the specific condition
that triggers it (the atob hook above is the cleanest capture). That
would change the picture; the version-gate would still be the wrong
axis, but the trim might need to gate on something specific to that
condition.

…xists

4703bc1 (PR dolonet#54) added a one-character `trimDragTail` to the OSC 52 and
onSelectionChange clipboard paths, on the premise that tmux includes an
extra trailing cursor cell in OSC 52 payloads. That premise was never
verified against the wire — it was inferred from a *visual* artifact
(xterm.js painting the cursor bar over the trailing edge of tmux's
yellow selection) and written into the commit message as fact.

Wire-level measurement shows the premise is false:

- tmux 3.4, SGR mouse drag injected over a pty.fork'd `tmux attach`,
  OSC 52 captured off the stream: `press@1 release@9` yields exactly
  `012345678` (9 cells) — the selection is `[press, release]` with no
  extra cursor cell.
- tmux 3.2a (Ubuntu 22.04) measured three independent ways — browser,
  raw-PTY SGR injection, keyboard copy-mode — is byte-identical to 3.4.
- The tmux CHANGES log has no entry between 3.2a and 3.4 that alters
  OSC 52 selection content; the sole 3.4 OSC 52 change is passing the
  clipboard *kind* argument through, unrelated to content.

tmux's selection is `[start, end)` exclusive on every version. The
trim therefore always dropped a real visible character — silently on
every target. That is the paste-too-short bug this branch set out to
fix; the fix is simply to remove the trim, not to gate it on a tmux
version that has no behavioral difference.

This supersedes the earlier conditional-trim approach on this branch
(server OSC 1338 version probe + client `_tmuxNeedsTrim` gate): with no
off-by-one to compensate for, version detection has nothing to do, so
the probe and handler are dropped entirely (server.py and test_server.py
return to their upstream state).

Changes:
- Remove `trimDragTail`, `DRAG_TRIM_WINDOW_MS`, `_recentDragSelectAt`;
  both clipboard paths now pass tmux's payload through unmodified.
- Keep the CURSOR_HIDE machinery intact — it fixes the real,
  independent visual artifact (cursor bar/blink over the selection's
  trailing cell) via `cursorInactiveStyle:'none'` + drag-blur +
  deferred focus restore. Rename the `SELECTION_TRIM:` marker to
  `CURSOR_HIDE:` since the trim it referred to is gone.
- Frontend: drop the `trimDragTail` unit test; add two passthrough
  regression tests pinning that OSC 52 and onSelectionChange payloads
  reach the clipboard byte-identical, so the trim can't be
  reintroduced silently.
- README / README.ru / docs: keep a plain "tmux 3.4+ recommended"
  note; drop the (incorrect) OSC 52 off-by-one rationale.
@gorevds gorevds force-pushed the fix/drop-clipboard-trim branch from ea74b33 to de66729 Compare May 20, 2026 17:41
@gorevds
Copy link
Copy Markdown
Collaborator Author

gorevds commented May 20, 2026

Reworked — force-pushed de66729. You're right; the conditional trim
is gone along with the whole version-detection apparatus.

Independent verification

Re-ran the measurement before reverting, two ways I could do without
your 3.2a containers:

tmux 3.4, OSC 52 off the wire. pty.fork'd tmux attach, injected
raw SGR mouse (\e[<0;col;1M press → \e[<32;col;1M drags →
\e[<0;col;1m release), parsed OSC 52 out of the recv stream:

press@1 release@8  → OSC52 '01234567'         (8)
press@1 release@9  → OSC52 '012345678'        (9)
press@1 release@16 → OSC52 '0123456789ABCDEF' (16)

Exactly [press, release], no extra cursor cell — byte-for-byte your
3.4 row.

tmux CHANGES, 3.2a → 3.4. Pulled CHANGES from the tmux repo and
read every selection/clipboard/copy-mode entry across the 3.2a→3.3,
3.3→3.3a, 3.3a→3.4 sections. Nothing touches OSC 52 selection content.
The only OSC 52 entry is the one you quoted (3.4: pass through the
first argument — the clipboard kind). So there is no upstream commit
that could make 3.2a differ from 3.4 here.

What I could not do: measure 3.2a directly — no Docker in my
environment and no way to build tmux from source. So the 3.2a data
point is still yours. But: 3.2a == 3.4 by your three harnesses, and
the CHANGES log leaves no code path that could differentiate them, and
my own 3.4 numbers match yours exactly. The conclusion holds — the
trim was wrong on every version, loudly on <3.4 after b3b7c67,
silently on ≥3.4 before it. 4703bc1's "OSC 52 includes the cursor
cell" was the unverified inference; the wire says [start, end)
exclusive throughout.

What changed in de66729

  • Dropped trimDragTail, DRAG_TRIM_WINDOW_MS,
    _recentDragSelectAt, _tmuxNeedsTrim, both slice(0,-1) sites.
    OSC 52 and onSelectionChange now pass tmux's payload through
    unmodified.
  • Dropped the OSC 1338 wiring entirely — server printf probe and
    client handler. With no off-by-one to compensate for, version
    detection has no consumer (YAGNI). server.py and test_server.py
    are byte-identical to upstream a2e050a again — git diff a2e050a..de66729 -- server.py test_server.py is empty.
  • Kept CURSOR_HIDE intact — drag-blur on mousedown,
    cursorInactiveStyle:'none', deferred focus restore via
    onCursorMove + 500 ms fallback. It fixes the real visual artifact
    (cursor bar/blink painting the selection's trailing cell) and is
    independent of clipboard semantics. The SELECTION_TRIM: marker is
    renamed to CURSOR_HIDE: since the trim it named is gone.
  • Tests. Dropped the trimDragTail unit test and the
    conditional-trim / OSC 1338 tests. Kept two passthrough regression
    tests pinning OSC 52 and onSelectionChange to byte-identical
    clipboard delivery, and extended the OSC 52 one to also pin the
    three return false decline paths (no ;, ? read-request,
    non-base64) so the handler contract is fully covered. 507 frontend
    tests pass, 420 server.
  • README / README.ru / docs. Kept a plain "tmux 3.4+ recommended"
    note per your "fine as general guidance"; removed the incorrect
    OSC 52 off-by-one / auto-detect / auto-trim rationale.

End state matches your proposal: <3.4 and ≥3.4 both paste
char-for-char with the visible selection, no version machinery.

Deployed to https://websh.gorev.space for live re-check.

The 3.4+ note was residue from the OSC 52 off-by-one hypothesis this
branch debunked. With the clipboard payload byte-identical across tmux
versions there is nothing 3.4+ buys; the persistent-sessions note now
reads "any reasonably recent version", consistent with the project's
other inclusive compat floors.
@dolonet dolonet changed the title client: drop drag-select clipboard trim, require tmux 3.4+ on target client: drop drag-select clipboard trim — no tmux OSC 52 off-by-one exists May 20, 2026
@dolonet
Copy link
Copy Markdown
Owner

dolonet commented May 20, 2026

@gorevds — folded in the last touches so this doesn't need another round:

  • 70ca537: dropped the tmux 3.4+ note from README.md / README.ru.md / docs/persistent-sessions.md. With OSC 52 byte-identical across tmux versions there's nothing to recommend 3.4+ for — the persistent-sessions note now reads "any reasonably recent version", in line with the project's other compat floors. (de66729's message still says "keep a plain 3.4+ note" — correct under the earlier review guidance; 70ca537 supersedes it, and the squash-merge commit will carry the corrected wording.)
  • Retitled the PR and rewrote the description — both still carried the original "tmux 3.4 fixed this upstream" framing that de66729 itself debunks.

de66729 verified against the diff: trimDragTail / DRAG_TRIM_WINDOW_MS / _recentDragSelectAt and the OSC 1338 wiring all gone, CURSOR_HIDE intact, server.py + test_server.py byte-identical to upstream, both passthrough regression tests in place. Thanks for the wire-level rigor across all three rounds — the joint 3.2a + 3.4 measurement and the CHANGES audit make the conclusion solid.

@dolonet dolonet merged commit 0370666 into dolonet:main May 20, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants