Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions bin/flashpaste-keybindings-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ while read -r surface key want; do
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
Expand Down
19 changes: 19 additions & 0 deletions bin/kitty-paste-router.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -72,16 +86,21 @@ 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.
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"
14 changes: 11 additions & 3 deletions config/keybindings.canonical
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,18 @@
# Format: one rule per non-comment line: <surface> <key> <must-contain-substring>
# The check passes when the live config's binding for <key> 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.
Expand Down
28 changes: 21 additions & 7 deletions tests/keybindings-check.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
Loading