Skip to content

Commit 0bdcd37

Browse files
authored
fix: retire kitty ctrl+v interception — XWayland breaks router tmux detection (#2)
* fix: retire kitty ctrl+v interception — XWayland breaks router tmux detection Under XWayland kitty, `kitten @ ls` reports is_focused=false for every OS window and empty foreground_processes, so kitty-paste-router.sh's focused_window_is_tmux() can never succeed. Every Ctrl+V took the slow fallthrough (background launch, seconds late under load) and the late second fire landed outside the daemon's 2.5s dedup window — journal shows identical 162-byte text pastes 4s apart (pane %23, 15:14:50 + 15:14:54). Fix: kitty must not bind ctrl+v at all. tmux's own `bind -n C-v` is the single handler (~5ms, pane id from the pressing client — also kills the wrong-pane hazard of the router's target-less `tmux display-message`). Non-tmux kitty windows: raw 0x16 reaches Claude Code, which reads the clipboard itself via the wl-paste shim; plain shells use ctrl+shift+v. - keybindings.canonical: kitty ctrl+v -> UNBOUND (new keyword) - flashpaste-keybindings-check.sh: support UNBOUND (binding must not exist) - kitty-paste-router.sh: retired-status header + clip-pipeline-log tracing (it was the only paste path with zero logging) - tests/keybindings-check.test.sh: cover UNBOUND pass/violation + keep positive-rule and literal-+ coverage via fixture canonicals * fix: SC1087 in keybindings-check — brace ${key_re} before [[:space:]] shellcheck -S warning (CI gate) parses $key_re[[:space:]] as an array expansion and errors. Introduced with the checker in PR #1; this was the shellcheck regression that turned main's Lint job red. Braces quiet it with no behavior change (tests 6/6, live check consistent).
1 parent d81ae3c commit 0bdcd37

5 files changed

Lines changed: 72 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ Release-tag policy: every `vX.Y` commit on `main` must be tagged and have a matc
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- Double-paste under XWayland kitty, round two. The kitty `ctrl+v` interception is retired: under XWayland, `kitten @ ls` reports `is_focused=false` for every OS window and empty `foreground_processes`, so `kitty-paste-router.sh`'s tmux detection could never succeed. Every paste took the slow fallthrough (a background `launch` that lands seconds late under load), and the late second fire fell outside the daemon's 2.5 s dedup window — observed as identical 162-byte pastes 4 s apart. `config/keybindings.canonical` now requires kitty `ctrl+v` to be `UNBOUND` (new canonical keyword, enforced by `flashpaste-keybindings-check.sh`); tmux's `bind -n C-v` is the single Ctrl+V handler (~5 ms, pane id always from the pressing client). In non-tmux kitty windows the raw `0x16` reaches Claude Code, which reads the clipboard itself via the `wl-paste` shim; plain shells use kitty's native `ctrl+shift+v`.
12+
13+
### Added
14+
15+
- `flashpaste-keybindings-check.sh`: `UNBOUND` keyword in `keybindings.canonical` asserts a key must NOT be bound on a surface (previously only positive "must contain" rules existed).
16+
- `kitty-paste-router.sh` now traces to the shared clipboard-pipeline log (`paste-router` tag: invoked / suppressed / fallthrough / pane-resolved / done / image-fallback), closing the observability gap that made this regression invisible — the router was the only paste path that wrote no logs.
17+
918
## [1.34] - 2026-05-21
1019

1120
### Fixed

bin/flashpaste-keybindings-check.sh

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,20 @@ while read -r surface key want; do
2323
# Escape ERE metacharacters in the key (e.g. the '+' in ctrl+v).
2424
key_re=$(printf '%s' "$key" | sed 's/[][\\.^$*+?(){}|]/\\&/g')
2525
case "$surface" in
26-
kitty) conf="$KITTY_CONF"; line=$(grep -E "^[[:space:]]*map[[:space:]]+$key_re[[:space:]]" "$conf" 2>/dev/null | tail -1) ;;
27-
tmux) conf="$TMUX_CONF"; line=$(grep -E "^[[:space:]]*bind[[:space:]]+-n[[:space:]]+$key_re[[:space:]]" "$conf" 2>/dev/null | tail -1) ;;
26+
kitty) conf="$KITTY_CONF"; line=$(grep -E "^[[:space:]]*map[[:space:]]+${key_re}[[:space:]]" "$conf" 2>/dev/null | tail -1) ;;
27+
tmux) conf="$TMUX_CONF"; line=$(grep -E "^[[:space:]]*bind[[:space:]]+-n[[:space:]]+${key_re}[[:space:]]" "$conf" 2>/dev/null | tail -1) ;;
2828
*) echo " ? unknown surface '$surface' in canonical"; drift=1; continue ;;
2929
esac
30+
if [ "$want" = "UNBOUND" ]; then
31+
if [ -z "$line" ]; then
32+
echo "$surface $key -> unbound (as required)"
33+
else
34+
echo "$surface $key: must be UNBOUND but a live binding exists"
35+
echo " live: $line"
36+
drift=1
37+
fi
38+
continue
39+
fi
3040
if [ -z "$line" ]; then
3141
echo "$surface $key: no binding found in $conf"
3242
drift=1

bin/kitty-paste-router.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,22 @@
2222
# this adds zero latency to the actual paste. When NOT in tmux, kitty's
2323
# handler is the only one and ~30ms there is unnoticeable.
2424
# ─────────────────────────────────────────────────────────────────────
25+
# STATUS (2026-06-10): RETIRED from the default keybindings. Under XWayland
26+
# kitty, `kitten @ ls` reports is_focused=false for every window and empty
27+
# foreground_processes, so focused_window_is_tmux() can never succeed; every
28+
# paste took the slow fallthrough (background launch, seconds under load) and
29+
# a late second fire landed outside the daemon's dedup window -> double paste.
30+
# config/keybindings.canonical now requires kitty ctrl+v to be UNBOUND; tmux's
31+
# own `bind -n C-v` is the single handler. This script remains for
32+
# native-Wayland setups that still map it explicitly.
2533
set -u
2634

35+
# Trace to the shared clipboard-pipeline log (same stream as paste_image.sh /
36+
# tmux-paste-dispatch.sh) so a real Ctrl+V shows which branch fired here.
37+
. "$(dirname -- "$0")/clip-pipeline-log.sh" 2>/dev/null || true
38+
type clog >/dev/null 2>&1 || clog() { :; }
39+
clog "paste-router" "event=invoked" "KITTY_LISTEN_ON='${KITTY_LISTEN_ON:-}'" "KITTY_WINDOW_ID='${KITTY_WINDOW_ID:-}'"
40+
2741
PASTE_IMAGE_FALLBACK="${FLASHPASTE_IMAGE_FALLBACK:-/home/deadpool/paste_image.sh}"
2842
# Resolve kitty's remote-control socket.
2943
#
@@ -72,16 +86,21 @@ focused_window_is_tmux() {
7286

7387
if focused_window_is_tmux; then
7488
# tmux's `bind -n C-v` owns this paste. Do nothing.
89+
clog "paste-router" "event=suppressed" "reason=focused-window-is-tmux" "sock='$KITTY_SOCK'"
7590
exit 0
7691
fi
92+
clog "paste-router" "event=fallthrough" "reason=tmux-not-detected" "sock='$KITTY_SOCK'"
7793

7894
# Not in tmux (or undetectable) — run the original kitty paste path. The
7995
# daemon dedups if this turns out to be a duplicate of tmux's fire.
8096
pane="$(tmux display-message -p "#{pane_id}" 2>/dev/null)"
8197
# Only call the daemon trigger when we actually have a pane id. An empty
8298
# pane (no tmux at all) can't be a paste target, so go straight to the
8399
# image fallback instead of handing the daemon a blank pane.
100+
clog "paste-router" "event=pane-resolved" "pane='$pane'"
84101
if [ -n "$pane" ] && flashpaste-trigger "$pane" 2>/dev/null; then
102+
clog "paste-router" "event=done" "via=flashpaste-trigger" "pane='$pane'"
85103
exit 0
86104
fi
105+
clog "paste-router" "event=image-fallback" "exec='$PASTE_IMAGE_FALLBACK'"
87106
exec "$PASTE_IMAGE_FALLBACK"

config/keybindings.canonical

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@
88
# Format: one rule per non-comment line: <surface> <key> <must-contain-substring>
99
# The check passes when the live config's binding for <key> contains the
1010
# substring. Keep substrings stable (script basenames), not full command lines.
11+
# The special substring UNBOUND inverts the rule: the surface must NOT bind
12+
# the key at all.
1113

12-
# kitty's Ctrl+V must route through the tmux-aware router (suppresses kitty's
13-
# handler inside tmux so only tmux's bind fires — see kitty-paste-router.sh).
14-
kitty ctrl+v kitty-paste-router.sh
14+
# kitty must NOT intercept Ctrl+V (retired 2026-06-10). The tmux-aware router
15+
# needed `kitten @ ls` focus + foreground-process detection, which XWayland
16+
# kitty does not provide (every window reports is_focused=false and empty
17+
# foreground_processes), so the router always took the slow fallthrough
18+
# (background launch, seconds under load) and its late second fire landed
19+
# outside the daemon's dedup window -> double paste. With no kitty map the
20+
# key reaches the application: tmux's own bind handles tmux windows (~5ms,
21+
# correct pane), and Claude Code reads the clipboard itself elsewhere.
22+
kitty ctrl+v UNBOUND
1523

1624
# tmux's root Ctrl+V must hit the fast Rust trigger (daemon), falling back to
1725
# the bash dispatcher when the daemon is down.

tests/keybindings-check.test.sh

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,40 @@ check() { # desc want_rc actual_rc
1616
else echo "FAIL - $1 : want rc=$2 got rc=$3"; fail=$((fail+1)); fi
1717
}
1818

19-
# Consistent fixtures.
20-
printf 'map ctrl+v launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.ok"
19+
# Consistent fixtures: kitty leaves ctrl+v unbound (canonical: UNBOUND since
20+
# 2026-06-10 — XWayland kitty broke the router's tmux detection), tmux binds
21+
# C-v to the trigger.
22+
printf '# ctrl+v intentionally unmapped\nmap ctrl+shift+v paste_from_clipboard\n' > "$T/kitty.ok"
2123
printf 'bind -n C-v run-shell -b "flashpaste-trigger %%pane"\n' > "$T/tmux.ok"
2224
CANONICAL="$CANON" KITTY_CONF="$T/kitty.ok" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1
2325
check "consistent configs -> rc 0" 0 $?
2426

25-
# Drift: kitty routes through the OLD inline path, not the router.
26-
printf 'map ctrl+v launch -- sh -c flashpaste-trigger\n' > "$T/kitty.drift"
27+
# Drift: kitty still intercepts ctrl+v (any binding violates UNBOUND).
28+
printf 'map ctrl+v launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.drift"
2729
CANONICAL="$CANON" KITTY_CONF="$T/kitty.drift" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1
28-
check "kitty drift (no router) -> rc 1" 1 $?
30+
check "kitty ctrl+v bound despite UNBOUND -> rc 1" 1 $?
2931

3032
# Missing tmux binding entirely.
3133
printf '# no C-v here\n' > "$T/tmux.missing"
3234
CANONICAL="$CANON" KITTY_CONF="$T/kitty.ok" TMUX_CONF="$T/tmux.missing" bash "$CHECK" >/dev/null 2>&1
3335
check "missing tmux binding -> rc 1" 1 $?
3436

35-
# The literal '+' in ctrl+v must match literally, not as an ERE quantifier.
37+
# The literal '+' in ctrl+v must match literally, not as an ERE quantifier:
38+
# a 'ctrlv' binding must NOT count as a ctrl+v binding, so UNBOUND still
39+
# passes (rc 0) — proving the key regex didn't degrade into 'ctrl.v'.
3640
printf 'map ctrlv launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.plusbug"
3741
CANONICAL="$CANON" KITTY_CONF="$T/kitty.plusbug" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1
38-
check "ctrl+v not matched by 'ctrlv' (literal +) -> rc 1" 1 $?
42+
check "literal + : 'ctrlv' binding does not violate ctrl+v UNBOUND -> rc 0" 0 $?
43+
44+
# Positive-substring rules still work: a canonical that REQUIRES the router
45+
# must fail when kitty routes elsewhere, and pass when it matches.
46+
printf 'kitty ctrl+v kitty-paste-router.sh\ntmux C-v flashpaste-trigger\n' > "$T/canon.positive"
47+
printf 'map ctrl+v launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.router"
48+
CANONICAL="$T/canon.positive" KITTY_CONF="$T/kitty.router" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1
49+
check "positive rule: router binding matches -> rc 0" 0 $?
50+
printf 'map ctrl+v launch -- sh -c flashpaste-trigger\n' > "$T/kitty.inline"
51+
CANONICAL="$T/canon.positive" KITTY_CONF="$T/kitty.inline" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1
52+
check "positive rule: non-router binding drifts -> rc 1" 1 $?
3953

4054
echo "--- keybindings-check: PASS=$pass FAIL=$fail"
4155
[ "$fail" = "0" ]

0 commit comments

Comments
 (0)