diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e0e6f9..40539ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Release-tag policy: every `vX.Y` commit on `main` must be tagged and have a matc ## [Unreleased] +### Fixed + +- 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`. + +### Added + +- `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). +- `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. + ## [1.34] - 2026-05-21 ### Fixed diff --git a/bin/flashpaste-keybindings-check.sh b/bin/flashpaste-keybindings-check.sh index 8a211a7..e4235af 100755 --- a/bin/flashpaste-keybindings-check.sh +++ b/bin/flashpaste-keybindings-check.sh @@ -23,10 +23,20 @@ while read -r surface key want; do # Escape ERE metacharacters in the key (e.g. the '+' in ctrl+v). key_re=$(printf '%s' "$key" | sed 's/[][\\.^$*+?(){}|]/\\&/g') case "$surface" in - kitty) conf="$KITTY_CONF"; line=$(grep -E "^[[:space:]]*map[[:space:]]+$key_re[[:space:]]" "$conf" 2>/dev/null | tail -1) ;; - tmux) conf="$TMUX_CONF"; line=$(grep -E "^[[:space:]]*bind[[:space:]]+-n[[:space:]]+$key_re[[:space:]]" "$conf" 2>/dev/null | tail -1) ;; + kitty) conf="$KITTY_CONF"; line=$(grep -E "^[[:space:]]*map[[:space:]]+${key_re}[[:space:]]" "$conf" 2>/dev/null | tail -1) ;; + tmux) conf="$TMUX_CONF"; line=$(grep -E "^[[:space:]]*bind[[:space:]]+-n[[:space:]]+${key_re}[[:space:]]" "$conf" 2>/dev/null | tail -1) ;; *) echo " ? unknown surface '$surface' in canonical"; drift=1; continue ;; esac + if [ "$want" = "UNBOUND" ]; then + if [ -z "$line" ]; then + echo " ✓ $surface $key -> unbound (as required)" + else + echo " ✗ $surface $key: must be UNBOUND but a live binding exists" + echo " live: $line" + drift=1 + fi + continue + fi if [ -z "$line" ]; then echo " ✗ $surface $key: no binding found in $conf" drift=1 diff --git a/bin/kitty-paste-router.sh b/bin/kitty-paste-router.sh index e9f89e0..37af778 100755 --- a/bin/kitty-paste-router.sh +++ b/bin/kitty-paste-router.sh @@ -22,8 +22,22 @@ # this adds zero latency to the actual paste. When NOT in tmux, kitty's # handler is the only one and ~30ms there is unnoticeable. # ───────────────────────────────────────────────────────────────────── +# STATUS (2026-06-10): RETIRED from the default keybindings. Under XWayland +# kitty, `kitten @ ls` reports is_focused=false for every window and empty +# foreground_processes, so focused_window_is_tmux() can never succeed; every +# paste took the slow fallthrough (background launch, seconds under load) and +# a late second fire landed outside the daemon's dedup window -> double paste. +# config/keybindings.canonical now requires kitty ctrl+v to be UNBOUND; tmux's +# own `bind -n C-v` is the single handler. This script remains for +# native-Wayland setups that still map it explicitly. set -u +# Trace to the shared clipboard-pipeline log (same stream as paste_image.sh / +# tmux-paste-dispatch.sh) so a real Ctrl+V shows which branch fired here. +. "$(dirname -- "$0")/clip-pipeline-log.sh" 2>/dev/null || true +type clog >/dev/null 2>&1 || clog() { :; } +clog "paste-router" "event=invoked" "KITTY_LISTEN_ON='${KITTY_LISTEN_ON:-}'" "KITTY_WINDOW_ID='${KITTY_WINDOW_ID:-}'" + PASTE_IMAGE_FALLBACK="${FLASHPASTE_IMAGE_FALLBACK:-/home/deadpool/paste_image.sh}" # Resolve kitty's remote-control socket. # @@ -72,8 +86,10 @@ focused_window_is_tmux() { if focused_window_is_tmux; then # tmux's `bind -n C-v` owns this paste. Do nothing. + clog "paste-router" "event=suppressed" "reason=focused-window-is-tmux" "sock='$KITTY_SOCK'" exit 0 fi +clog "paste-router" "event=fallthrough" "reason=tmux-not-detected" "sock='$KITTY_SOCK'" # Not in tmux (or undetectable) — run the original kitty paste path. The # daemon dedups if this turns out to be a duplicate of tmux's fire. @@ -81,7 +97,10 @@ pane="$(tmux display-message -p "#{pane_id}" 2>/dev/null)" # Only call the daemon trigger when we actually have a pane id. An empty # pane (no tmux at all) can't be a paste target, so go straight to the # image fallback instead of handing the daemon a blank pane. +clog "paste-router" "event=pane-resolved" "pane='$pane'" if [ -n "$pane" ] && flashpaste-trigger "$pane" 2>/dev/null; then + clog "paste-router" "event=done" "via=flashpaste-trigger" "pane='$pane'" exit 0 fi +clog "paste-router" "event=image-fallback" "exec='$PASTE_IMAGE_FALLBACK'" exec "$PASTE_IMAGE_FALLBACK" diff --git a/config/keybindings.canonical b/config/keybindings.canonical index e0e2f02..060a0af 100644 --- a/config/keybindings.canonical +++ b/config/keybindings.canonical @@ -8,10 +8,18 @@ # Format: one rule per non-comment line: # The check passes when the live config's binding for contains the # substring. Keep substrings stable (script basenames), not full command lines. +# The special substring UNBOUND inverts the rule: the surface must NOT bind +# the key at all. -# kitty's Ctrl+V must route through the tmux-aware router (suppresses kitty's -# handler inside tmux so only tmux's bind fires — see kitty-paste-router.sh). -kitty ctrl+v kitty-paste-router.sh +# kitty must NOT intercept Ctrl+V (retired 2026-06-10). The tmux-aware router +# needed `kitten @ ls` focus + foreground-process detection, which XWayland +# kitty does not provide (every window reports is_focused=false and empty +# foreground_processes), so the router always took the slow fallthrough +# (background launch, seconds under load) and its late second fire landed +# outside the daemon's dedup window -> double paste. With no kitty map the +# key reaches the application: tmux's own bind handles tmux windows (~5ms, +# correct pane), and Claude Code reads the clipboard itself elsewhere. +kitty ctrl+v UNBOUND # tmux's root Ctrl+V must hit the fast Rust trigger (daemon), falling back to # the bash dispatcher when the daemon is down. diff --git a/tests/keybindings-check.test.sh b/tests/keybindings-check.test.sh index b43d972..484ec7a 100755 --- a/tests/keybindings-check.test.sh +++ b/tests/keybindings-check.test.sh @@ -16,26 +16,40 @@ check() { # desc want_rc actual_rc else echo "FAIL - $1 : want rc=$2 got rc=$3"; fail=$((fail+1)); fi } -# Consistent fixtures. -printf 'map ctrl+v launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.ok" +# Consistent fixtures: kitty leaves ctrl+v unbound (canonical: UNBOUND since +# 2026-06-10 — XWayland kitty broke the router's tmux detection), tmux binds +# C-v to the trigger. +printf '# ctrl+v intentionally unmapped\nmap ctrl+shift+v paste_from_clipboard\n' > "$T/kitty.ok" printf 'bind -n C-v run-shell -b "flashpaste-trigger %%pane"\n' > "$T/tmux.ok" CANONICAL="$CANON" KITTY_CONF="$T/kitty.ok" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1 check "consistent configs -> rc 0" 0 $? -# Drift: kitty routes through the OLD inline path, not the router. -printf 'map ctrl+v launch -- sh -c flashpaste-trigger\n' > "$T/kitty.drift" +# Drift: kitty still intercepts ctrl+v (any binding violates UNBOUND). +printf 'map ctrl+v launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.drift" CANONICAL="$CANON" KITTY_CONF="$T/kitty.drift" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1 -check "kitty drift (no router) -> rc 1" 1 $? +check "kitty ctrl+v bound despite UNBOUND -> rc 1" 1 $? # Missing tmux binding entirely. printf '# no C-v here\n' > "$T/tmux.missing" CANONICAL="$CANON" KITTY_CONF="$T/kitty.ok" TMUX_CONF="$T/tmux.missing" bash "$CHECK" >/dev/null 2>&1 check "missing tmux binding -> rc 1" 1 $? -# The literal '+' in ctrl+v must match literally, not as an ERE quantifier. +# The literal '+' in ctrl+v must match literally, not as an ERE quantifier: +# a 'ctrlv' binding must NOT count as a ctrl+v binding, so UNBOUND still +# passes (rc 0) — proving the key regex didn't degrade into 'ctrl.v'. printf 'map ctrlv launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.plusbug" CANONICAL="$CANON" KITTY_CONF="$T/kitty.plusbug" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1 -check "ctrl+v not matched by 'ctrlv' (literal +) -> rc 1" 1 $? +check "literal + : 'ctrlv' binding does not violate ctrl+v UNBOUND -> rc 0" 0 $? + +# Positive-substring rules still work: a canonical that REQUIRES the router +# must fail when kitty routes elsewhere, and pass when it matches. +printf 'kitty ctrl+v kitty-paste-router.sh\ntmux C-v flashpaste-trigger\n' > "$T/canon.positive" +printf 'map ctrl+v launch -- /x/kitty-paste-router.sh\n' > "$T/kitty.router" +CANONICAL="$T/canon.positive" KITTY_CONF="$T/kitty.router" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1 +check "positive rule: router binding matches -> rc 0" 0 $? +printf 'map ctrl+v launch -- sh -c flashpaste-trigger\n' > "$T/kitty.inline" +CANONICAL="$T/canon.positive" KITTY_CONF="$T/kitty.inline" TMUX_CONF="$T/tmux.ok" bash "$CHECK" >/dev/null 2>&1 +check "positive rule: non-router binding drifts -> rc 1" 1 $? echo "--- keybindings-check: PASS=$pass FAIL=$fail" [ "$fail" = "0" ]