tmux: touch scrolling for terminals that send a swipe as a click pair (a-Shell on iPhone) - #88
Conversation
Two of the repo's own gates went red on #88, both on true claims reported as the wrong kind of lie: - doc gate: `@clikae_touch_scroll` / `@clikae_touch_scroll_lines` in docs/usage.md matched the FUNCTION extractor (\b matches after `@`) and were reported as 「defined nowhere」. They are tmux user options; the doc's claim is that lib/core/tmux.sh sets them. The gate now holds `@`-names to that claim instead, with a probe that it fires on an unset one. - exact-target lint: `send-keys -t "$pane"` in touch_scroll.sh. The value is #{pane_id} from the binding — an ID, already exact — and tmux 3.4 rejects `-t =%0` (measured: can't find pane), so the `=` the lint wants is wrong here. Renamed to TS_PANE_ID and added it to the lint's named exceptions next to $TMUX_PANE, with the receipt.
Measured 2026-09-13 with a-Shell on iPhone attached over ssh: a swipe arrives as one SGR left-button press at y1 and one release at y2 — never a wheel event — and because the Claude Code pane has mouse tracking on, tmux hands the click to the app and nothing scrolls. MouseDown1Pane now records the press row (root and copy-mode tables, keeping the default actions), MouseUp1Pane runs lib/core/touch_scroll.sh: a movement of two or more rows enters copy-mode and scrolls by @clikae_touch_scroll_lines × |dy| (default 2) in the finger's direction; a tap while in copy-mode returns to the live view; @clikae_touch_scroll off disables it. Desktop wheel and drag-selection are untouched (drags arrive as MouseDrag*Pane). Verified by the operator on the phone against a live prototype of the same bindings. Built by codex (gpt-6-astra) on tank crazy; report in the lane worktree.
Two of the repo's own gates went red on #88, both on true claims reported as the wrong kind of lie: - doc gate: `@clikae_touch_scroll` / `@clikae_touch_scroll_lines` in docs/usage.md matched the FUNCTION extractor (\b matches after `@`) and were reported as 「defined nowhere」. They are tmux user options; the doc's claim is that lib/core/tmux.sh sets them. The gate now holds `@`-names to that claim instead, with a probe that it fires on an unset one. - exact-target lint: `send-keys -t "$pane"` in touch_scroll.sh. The value is #{pane_id} from the binding — an ID, already exact — and tmux 3.4 rejects `-t =%0` (measured: can't find pane), so the `=` the lint wants is wrong here. Renamed to TS_PANE_ID and added it to the lint's named exceptions next to $TMUX_PANE, with the receipt.
3f53fd5 to
298718e
Compare
…(P1-1) Stock tmux's root table has no MouseUp1Pane binding at all, so a click's release falls through to the pane's program by default. This PR's MouseUp1Pane bound run-shell ONLY, consuming the release instead of forwarding it — any mouse-tracking app (Claude Code included) got the press and never the release, turning every desktop click into a stuck button. Measured (tmux 3.4, throwaway socket, `mouse on`, pane running `printf '\e[?1006h\e[?1000h'; cat -v`): stock tmux delivers both the SGR press (M) and release (m) sequences to the pane; the un-fixed binding delivered only the press. Fixed by binding "forward first, translate second" — `send-keys -M` before `run-shell` — so a mouse-tracking app always receives both press and release. A swipe now also reaches the app as a click at the release row (acceptable and documented: the app already got the press on the way down). 2026-09 R1 review, P1-1.
…_touch_y impossible (P1-2) tmux enters copy-mode OR copy-mode-vi at copy-mode-entry time depending on the `mode-keys` option, and clikae has never set that option — the same lesson docs/DESIGN-tmux.md:74 already draws about `base-index`: not setting an option does not mean the default applies, it means whatever the human's ~/.tmux.conf says applies, and `set -g mode-keys vi` is a very common line. Binding only `copy-mode` left `copy-mode-vi` without its own Down, so a tap there reused whatever @clikae_touch_y an earlier, unrelated Down had written. Measured (tmux 3.4, `set -g mode-keys vi`): a tap after a swipe that had already entered copy-mode-vi kept scrolling further back instead of cancelling — the only escape a phone (no scroll wheel) has, going backwards. Instrumented the helper's own tmux calls: the un-fixed script issued a second `scroll-up` on a same-row tap; the fixed one issues `cancel`. Fixed two ways, together: (a) mirror the copy-mode Down/Up pair onto copy-mode-vi so it gets its own fresh @clikae_touch_y; (b) touch_scroll.sh now UNSETS @clikae_touch_y (`set-option -pu`) the moment it reads it, so an Up with no Down on its own table is structurally incapable of reusing a value some earlier Down left behind. choose-mode / choose-mode-vi are deliberately left unbound — a tap there must select, not cancel, which is a different design left out of scope for this fix. 2026-09 R1 review, P1-2.
`[ "$inmode" = 1 ]` compared a literal against `#{pane_in_mode}`,
which is how many mode layers are stacked on a pane, not a boolean. A
run-shell that produces output stacks its own view-mode over an
existing copy-mode, so a pane already in copy-mode can read inmode=2 —
measured in choose-tree, reachable by this PR's own root-table
fallback (P1-2's review).
With inmode=2: a swipe's `= 1` check was false, so it re-entered
copy-mode on a pane already in one; a tap's `elif` was also false, so
it silently did nothing instead of cancelling — the user could not tap
their way out. Fixed with a numeric guard, `-gt 0`, which treats any
nonzero depth as already-in-mode; the raw arg is also normalized to 0
first so a non-numeric #{pane_in_mode} cannot crash the comparison.
2026-09 R1 review, P2-2.
…on (P2-1) The new @-option branch in scripts/doc-names-exist.sh:118-122 used an unconditional `continue` once a name occurred anywhere as `@name`, which skipped the function-existence check for that name everywhere else in the docs too. A name can be named both ways in the same doc set: once `@clikae_touch_scroll` occurred (a real tmux option), a DIFFERENT, later sentence naming `clikae_touch_scroll` bare — backtick-wrapped, no `@` — a claim that a function of that name exists, went silently unchecked. Negative control from the review: a doc naming `clikae_touch_scroll` bare, alongside its existing `@clikae_touch_scroll` mentions, stayed green. Fixed by only skipping the function check when EVERY occurrence of the name in the docs is @-prefixed; `@?\bname\b` captures each occurrence with or without its `@`, so any match missing the `@` proves a bare, function-shaped mention exists and must still resolve to a real function. Added the review's exact negative control as a probe in tests/bats/doc-contract.bats; the existing probes stay green. 2026-09 R1 review, P2-1.
…to match (P3) Three P3s, together because none is independently review-blocking but all three touch the same install chain: - touch_scroll.sh's reads only ever asked the GLOBAL scope (`show-options -gqv`), so `set @clikae_touch_scroll off` in one session (no -g) left the helper reading the global "on" — the override was silently unreachable. New _touch_opt() walks the real scope chain by hand (tmux's own -A only chains pane -> window -> global-window, a SEPARATE namespace from session -> global-session): pane (-p -A), then this session, then global. Measured (real tmux, two sessions, one instrumented): the un-fixed helper scrolled in BOTH the overridden and the untouched session; the fixed helper disables translation in the overridden session only. - No tmux version floor was declared anywhere in the repo, and bind-key validates its own command at bind time — a tmux command list aborts at the first failure, so on an old tmux the touch-scroll chain used to stop at the first rejected line and install whatever came before it, all-or-nothing only by the luck of which bind-key was written first. `set-option -p`/`-pu` (used by every binding) needs tmux >= 3.1; the copy-mode-vi key table needs only >= 2.4. New _tmux_touch_scroll_floor_met() (grep tmux -V, bash 3.2-safe per-segment numeric compare) gates the whole chain explicitly. `mouse on` stays outside the gate — it predates this feature and needs none of these flags, so it must not regress on an old tmux for a convenience feature that tmux can't run anyway. - docs/usage.md corrected: the release is now always forwarded (P1-1), a swipe reaches the app as a click at the release row too, the bindings are server-wide (every session on the tmux server, not only clikae's), and dropping `-g` overrides per-session (P3-2). CHANGELOG.md: removed the blank line that split the Unreleased ### Added list under a strict Markdown renderer. 2026-09 R1 review, P3-1/P3-2/P3-4.
|
P1-1 fixed ( |
|
P1-2 fixed ( |
|
P2-2 fixed ( |
|
P2-1 fixed ( |
|
P3-1, P3-2, P3-4 fixed ( |
|
P3-3 not fixed this round (out of scope by design): the |
…unt (P2-1)
The binding now passes #{pane_mode} (the mode tmux is actually in) instead of
#{pane_in_mode} (a stack-depth count) as touch_scroll.sh's third argument.
tree-mode (choose-tree), clock-mode, and any other mode this helper does not
know how to talk to now exit before making a single tmux call; previously a
nonzero pane_in_mode routed them into a send-keys -X call with no command
table to match, which failed with tmux's own "not in a mode", and because
that call was one of the only two in the file without 2>/dev/null, run-shell
rendered the stderr as a view-mode box stacked over the picker/clock.
Measured on real tmux 3.4 with a real pty client (throwaway socket, PATH
shim pinned to it): Ctrl-b w -> tap leaves tree-mode untouched and prints
nothing; Ctrl-b t -> tap is a tmux-native any-key exit with nothing printed;
copy-mode tap still cancels and copy-mode swipe still scrolls (r1 regression
check). Direct-helper receipts confirm mode=clock-mode/tree-mode/choose-mode/
choose-mode-vi all rc=0 with empty stdout/stderr.
lib/core/tmux.sh's comment at the choose-mode/clock-mode note is rewritten to
say what the old "stacked layer" actually was (an error message, not a real
mode transition) and that clock-mode is now covered by the same guard.
tests/bats/tmux.bats: the third release() argument is now a mode NAME
('' / copy-mode / view-mode / tree-mode / clock-mode / garbage) instead of a
count; new coverage for tree-mode+clock-mode no-op and an unrecognized mode
string no-op.
…vely (P3-1)
`Ctrl-b :` is hand-typed, and the old case list (off|0|no|false) only matched
those exact strings — OFF/Off/FALSE scrolled right on past it, silently: no
error, no indication the setting did not apply. Fold the value through `tr
'[:upper:]' '[:lower:]'` before matching; bash 3.2 has no ${var,,}.
Measured on real tmux 3.4 (throwaway socket): 11 case variants of
off/0/no/false all disable (pane_mode stays empty, scroll_position stays
unset) and on/nope (values that must NOT match) still enter copy-mode and
scroll (scroll_position=20). bats: two new tests, the case-insensitive matches
and a negative check that only-resembles-off values (offline, ' off', 'off ')
keep scrolling, so folding did not become substring matching.
…g/option inventory (P3-2, P3-3, P3-4, P3-5) P3-2: no doc anywhere said touch-scroll needs tmux >= 3.1 (`set-option -p`, pane-scoped options — CHANGES FROM 3.0 TO 3.1). A 3.0 user followed docs/usage.md's recipe and nothing happened, with no explanation why. docs/usage.md now states the floor; README.md links to that section from the existing tmux-persistence paragraph; docs/DESIGN-tmux.md's Rule 1 states it as part of the new inventory entry (below) since DESIGN-tmux is this repo's declared single source of truth for tmux interactions. P3-4: DESIGN-tmux.md's Rule 1 (global settings) and Rule 9 (mouse on / set-clipboard) never got the six key bindings and two `@clikae_touch_*` options this feature added — the diff that shipped them only touched docs/usage.md. Both rules now carry an inventory entry for the touch-scroll chain, cross-referenced to each other and to lib/core/tmux.sh's own comments. P3-5: docs/usage.md now says plainly that clikae's `bind-key` on root/copy-mode/copy-mode-vi MouseDown1Pane/MouseUp1Pane overwrites a user's own binding on those keys (no `-o`, no per-key opt-out) and that `@clikae_touch_scroll off` silences the translation without restoring the original binding. docs/usage.md also picks up the two loose ends from the last two commits while in the neighborhood: the off value is case-insensitive (P3-1), and the version floor this commit documents (P3-2/P3-3). P3-3: tests/bats/tmux.bats' floor test only proved the gate fires SOMEWHERE (3.0 skip, the 3.4 default install) — any floor anywhere in (3.0, 3.4] made both pass, wrong ones included. A new test pins the actual boundary: 3.0 is RED (chain skipped), 3.1 is GREEN (chain installed) — the same boundary lib/core/tmux.sh's _tmux_touch_scroll_floor_met and the new doc text declare, backed by upstream tmux's own CHANGES file.
|
Round-2 review (0 P1, 1 P2, 7 P3) addressed: P2-1 — the binding now passes Not fixed this round (out of scope by the decision list): P3-6 (per-click subprocess cost, not a defect) and P3-7 (same as round 1's P3-3 — the All measured on real tmux 3.4 with a real pty client (throwaway socket, PATH-shimmed, never the shared/default server). Full receipts in the round-2 fix report. |
Why
From a-Shell (iPhone) attached over ssh to a clikae session you could not scroll back. Probe (
printf '\e[?1000h\e[?1006h'; cat -v, swipe): a-Shell sends a swipe as one SGR press + one release at different rows, never a wheel event; the Claude Code pane has mouse tracking on, so tmux hands the click to the app and nothing scrolls.What
lib/core/touch_scroll.sh— standalone helper: reads the press row from a pane-local option (walking the real scope chain: pane → window → this session → global — session-levelset @clikae_touch_scroll offnow reaches it, see round 1 below), scrolls copy-mode by@clikae_touch_scroll_lines × |dy|(default 2) when |dy| ≥ 2 (finger up ⇒ scroll-up), a tap in copy-mode ⇒cancel;@clikae_touch_scroll offdisables.@clikae_touch_yis unset the instant it is read, so an Up with no matching Down can never reuse a stale value.lib/core/tmux.sh—mouse onstays a standalone call; six bindings (root + copy-mode + copy-mode-viMouseDown1Pane/MouseUp1Pane) install after it, gated on a declared tmux floor (>= 3.1, needed forset-option -p/-pu). Root'sMouseUp1Paneforwards the release before translating (send-keys -M; run-shell …), so a mouse-tracking app always receives both press and release — the default press action was already kept; round 1 fixed the release. Desktop wheel and drag-selection unaffected (drags arrive asMouseDrag*Pane). choose-mode / choose-mode-vi are deliberately left unbound.tests/bats/tmux.bats(14 cases): chain includes the bindings, the version-floor skip, and the copy-mode-vi mirror; helper against a stubtmux— up/down/tap/no-op/multiplier/off/stacked-mode-count/stale-value-unset.Verified on the phone by the operator against a live prototype with identical bindings (swipe scrolls, tap returns).
🤖 Generated with Claude Code
Fix round 1 (2026-09-13)
Round-1 adversarial review (2 P1, 2 P2, 4 P3) found every P1/P2 only reproduces on a real tmux server — the bats suite above is stub-only. Fixed 7 of 8 findings, each proven red→green on a real tmux 3.4 throwaway socket (never the shared/default server) the way the review did; commits in order:
6670ec6— root'sMouseUp1Paneboundrun-shellonly, consuming the release instead of forwarding it (stock tmux's root table has noMouseUp1Paneat all). Every mouse-tracking app — Claude Code included — got the press and never the release. Fixed:send-keys -Mbeforerun-shell. Measured with a real client reading raw SGR bytes off a pty: un-fixed delivered only the press (M); fixed delivers both press and release (Mthenm).b94c688—copy-mode-vi(what tmux actually uses underset -g mode-keys vi, a common~/.tmux.confline) had no bindings of its own, so a tap there reused a stale@clikae_touch_yand kept scrolling backward instead of cancelling — the only way back on a device with no scroll wheel. Fixed: mirrored the copy-mode Down/Up pair onto copy-mode-vi, and the helper now unsets@clikae_touch_ythe instant it reads it, so no key table can ever reuse a stale value structurally.f0e109a—#{pane_in_mode}is a stack depth, not a boolean;= 1was false at depth 2 (reachable via this PR's own root-table fallback in choose-tree), so a swipe re-entered copy-mode on top of itself and a tap silently did nothing. Fixed with a numeric-gt 0guard.fcf1ee4—scripts/doc-names-exist.sh's new@-option branch used an unconditionalcontinue, so a name occurring as@nameanywhere exempted it from the function-existence check everywhere, including a different, bare, function-shaped mention of the same name. Fixed: only skip the function check when every occurrence is@-prefixed; added the review's exact negative control as a bats probe.325cd43— no tmux version floor was declared anywhere (bind-key's own command validation made a below-floor install all-or-nothing only by the luck of write order); the helper only ever read the global option scope, so a session-levelset @clikae_touch_scroll off(no-g) was silently unreachable; a blank line split the CHANGELOG's### Addedlist. Fixed: an explicittmux -Vfloor check (>= 3.1) gates the binding chain, a hand-walked scope chain (pane → window → session → global) makes session-level overrides work, and the blank line is gone. docs/usage.md updated to say the release is forwarded and the bindings are server-wide.Not fixed this round (out of scope by design): P3-3 (the
TS_PANE_ID-shape lint exception relies on there being only two call sites today, not on an enforced shape) — flagged by the review as a should-eventually-harden note, not a correctness defect, and wasn't in this round's decision list.Full receipts (real-tmux red→green transcripts, bats, shellcheck, bash-3.2 lint,
doc-names-exist.sh,git merge-tree) in the round-1 fix report.Fix round 2 (2026-09-13)
Round-2 adversarial review (0 P1, 1 P2, 7 P3, all measured on real tmux 3.4) found one real bug and six follow-ups. Fixed the P2 and three of the seven P3s (the decision list for this round); 3 commits in order:
27b67f5— the binding passed#{pane_in_mode}(a stacked-layer COUNT), so tree-mode (Ctrl-b w) and clock-mode (Ctrl-b t) — both nonzero — fell into the same "already in a mode" branch as copy-mode and dispatched asend-keys -Xcall with no command table to match. That failed with tmux's ownnot in a mode, and because it was one of the only two calls in the file without2>/dev/null,run-shellrendered the stderr as a view-mode box stacked over the picker/clock — reachable from any session on the server, not just clikae's. Fixed: the binding now passes#{pane_mode}(the mode NAME); the helper acts only on''/copy-mode/view-modeand exits before any tmux call otherwise. Measured on real tmux 3.4 with a real pty client:Ctrl-b w→ tap leaves tree-mode untouched, nothing printed;Ctrl-b t→ tap is tmux's own any-key exit, nothing printed; copy-mode tap still cancels and copy-mode swipe still scrolls (regression check on round 1's fixes).ab550fa—@clikae_touch_scroll offwas case-sensitive;OFF/Off/FALSEscrolled right past the case list, silently. Fixed: fold throughtrbefore matching (bash 3.2 has no${var,,}). 13 case variants measured on real tmux.aedf4f5— no doc anywhere declared the tmux floor this feature needs (>= 3.1,set-option -p);docs/DESIGN-tmux.md's Rule 1/Rule 9 global-settings inventories never picked up the six bindings and two options this feature added; nothing told a user that their own root-tableMouseDown1Pane/MouseUp1Panebinding gets silently overwritten. Fixed: the floor is now stated indocs/usage.md(with the fallback behavior below it),README.mdlinks to that section, anddocs/DESIGN-tmux.mdcarries the inventory entry in both rules;docs/usage.mdstates the override plainly.tests/bats/tmux.batsalso gained a version-gate test that pins the actual boundary (3.0 red, 3.1 green) instead of only proving the gate fires somewhere in (3.0, 3.4].Not fixed this round (out of scope by this round's decision list): P3-6 (per-click subprocess cost — a cost, not a defect) and P3-7 (the
TS_PANE_IDlint exception's shape is unenforced, same as round 1's P3-3 — the PR already commented that this round would not harden it).Full receipts (real-tmux red→green transcripts with a real pty client, bats, shellcheck, bash-3.2 lint,
doc-names-exist.sh,git merge-tree) in the round-2 fix report.