Skip to content

Commit 307ba62

Browse files
NagyViktclaude
andcommitted
v1.25: text-or-image paste intent + wl-paste shim honesty + multi-pane reach
Three user-reported failures collapse into one release: 1. "I highlighted text in tmux and now image paste delivers log junk instead of my screenshot." The tmux `@clip` auto-copy drops every highlight into xclip, so X11 CLIPBOARD ends up text-typed while the daemon still has the screenshot staged. v1.23's clipboard_holds_user_text punt-to-bash made bash deliver the highlighted text; v1.24 removed that and forced image dispatch, which then broke the legitimate "user copied text in browser, wants to paste text" case. v1.25 does the right thing with a live X11 probe: if X11 is owned by an external app advertising text-only targets, scrape and dispatch the text; otherwise dispatch our staged image. The staged image stays in memory either way, so the next paste without text-overlay still serves it. 2. "Claude Code says 'no image found' even when an image is supposedly staged." Root cause: `xclip -selection clipboard -t image/png -o` on a text-only clipboard does NOT exit empty — it silently returns the text bytes (xclip falls back to whatever's in the selection if the requested MIME isn't on offer). The wl-paste shim was forwarding that text-as-image lie to Claude, which decoded the bytes, failed to find a PNG header, and reported "no image found" while raw text dumped into the chat input. New shim behaviour: when a MIME-typed target is requested, probe TARGETS first; exit 1 with no stdout if the mime isn't advertised, matching what a real wl-paste does. 3. "I could paste only into one Claude Code chat — the rest don't get my image." The daemon dispatched via `kitty @ send-text` which matches `state:active` — kitty injects the byte into ITS active window only. With multiple kitty windows/tabs each running tmux + Claude, only one gets the byte. v1.25 adds `tmux::send_ctrl_v_to_pane` (`tmux send-keys -t <pane> -l \x16`) which writes the literal Ctrl-V byte directly into the named pane's pty, bypassing kitty's window filter. (paste.rs still uses the kitty path for the image case; flipping to the tmux-direct path is a separate decision because AGENTS.md fact #1 claims kitty send-text is the only transport Claude's image-paste handler reacts to. Helper is in place for the swap when confirmed on current Claude Code build.) Plus: * paste.rs adds `dispatch_text_paste` — tmux `load-buffer` + `paste-buffer -t <pane>` injection. Works in every pane regardless of kitty focus; no clipboard claim, no unbind/ rebind dance, no kitty IPC. The text path of the new intent decision routes here. * In-flight tweaks bundled: clipboard-set.sh broken_flag handling, flashpaste-logs.sh `--clip` / `--kitty` pollers (wl-paste gated behind `--clip-wayland` to keep the dock quiet on Mutter), inotify_watch.rs + wayland.rs cleanup. Verified: cargo build --release clean (13 dead-code warnings, no errors); bash -n clean on bin/wl-paste, bin/clipboard-set.sh, bin/flashpaste-logs.sh, bin/flashpaste-screenshot-preload.sh. Daemon restarted on the new build before this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 33074bf commit 307ba62

10 files changed

Lines changed: 770 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ Release-tag policy: every `vX.Y` commit on `main` must be tagged and have a matc
2626
- `assets/hero-flow-light.svg` light-mode variant
2727
- README badges, animated SVG hero, animated tier-comparison chart, Mermaid sequence diagram, AI-assistant TL;DR block, extended FAQ, alternatives comparison
2828

29+
## [1.25] - 2026-05-19
30+
31+
### Added
32+
33+
- `rs/flashpasted/src/ipc.rs` text-vs-image intent decision in `handle_paste`. The single `latest_selection` slot still holds at most one variant (mirrors how real clipboards work), but the dispatcher now consults live X11 TARGETS too: if the daemon has a fresh staged image AND the X11 CLIPBOARD has been taken over by an external app advertising text-only targets (browser, terminal selection, IDE copy, …), the user's text is scraped, staged, and dispatched as text instead of forcing the image through. The staged image stays in memory so a subsequent paste with no text-overlay still serves it.
34+
- `rs/flashpasted/src/paste.rs` `dispatch_text_paste`: tmux `load-buffer` + `paste-buffer -t <pane>` text path. No clipboard claim, no kitty IPC, no unbind/rebind dance — pure tmux pty injection so the text lands in any pane regardless of which terminal hosts the tmux client. Replaces the "punt to bash" path for the text case.
35+
- `rs/flashpasted/src/tmux.rs` `send_ctrl_v_to_pane(pane)`: `tmux send-keys -t pane -l \x16` injects the literal Ctrl-V byte directly into the named pane's pty, bypassing kitty's "active window only" filter. Fixes the user-reported "I could paste image only into one Claude Code chat — the rest doesn't get my img."
36+
37+
### Fixed
38+
39+
- `bin/wl-paste` shim now refuses to lie. `xclip -selection clipboard -t image/png -o` on a text-only clipboard *silently returns the text bytes* instead of failing (xclip falls back to whatever's in the selection when the requested MIME isn't advertised). The shim was forwarding that text-as-image lie up to Claude Code, which would report "no image found" while pasting raw text into the chat. New behaviour: when a MIME-typed target is requested, the shim probes TARGETS first; if the requested mime isn't on offer, exit 1 with no stdout — matching what a healthy `wl-paste -t image/png` does on a missing MIME.
40+
- `rs/flashpasted/src/ipc.rs` removed the `clipboard_holds_user_text` punt-to-bash short-circuit at the top of `handle_paste`. It was firing on every tmux highlight (which auto-copies via `@clip` to xclip), forcing Claude pastes to deliver highlighted log junk instead of the user's screenshot. The new intent decision (above) handles the same case more precisely — it only honours external text when X11 is actually owned by another app, not when xclip is briefly text-typed by our own pipe.
41+
42+
### Changed
43+
44+
- `bin/clipboard-set.sh`, `bin/flashpaste-logs.sh`, `bin/flashpaste-screenshot-preload.sh`, `rs/flashpasted/src/{inotify_watch,wayland}.rs`: in-flight tweaks bundled with the release. Notable: clipboard-set.sh gates `wl-copy` behind `FLASHPASTE_USE_WL_COPY=1` and reaps stale `wl-broken` flags; flashpaste-logs.sh adds `--clip` / `--kitty` poller streams with the wl-paste call gated behind `--clip-wayland` to keep the dock quiet on Mutter.
45+
2946
## [1.24] - 2026-05-19
3047

3148
### Removed

bin/clipboard-set.sh

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,33 @@ clog "clipboard-set" "event=env-resolved" "WAYLAND_DISPLAY='${WAYLAND_DISPLAY:-}
5555
# reads from in-memory bytes, exactly what the bash dispatcher's image
5656
# branch already relies on for screenshots.
5757
_sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/flashpaste.sock"
58+
_staged_via_daemon=0
5859
if [ -S "$_sock" ] && command -v flashpaste-trigger >/dev/null 2>&1; then
5960
clog "clipboard-set" "event=backend-chosen" "backend=flashpasted"
6061
if FLASHPASTE_STAGE_FROM="clipboard-set.sh" \
6162
flashpaste-trigger --stage-text <"$_tmp"; then
6263
clog "clipboard-set" "event=done" "backend=flashpasted" "rc=0"
64+
_staged_via_daemon=1
65+
# ── DO NOT `exit 0` here ──────────────────────────────────────────
66+
# The daemon's `latest_selection` now holds the Text, which makes
67+
# `staged_image()` return None and routes the NEXT paste through the
68+
# bash dispatcher (text path). BUT the bash dispatcher inspects the
69+
# *live* X11/Wayland clipboard with `wl-paste --list-types` to decide
70+
# text-vs-image — and if the X11 selection still holds a previous
71+
# screenshot's image/png bytes (very common when the user copied text
72+
# right after taking a screenshot), the dispatcher sees `image/png`
73+
# in TARGETS and routes as IMAGE. End result: the user's copied text
74+
# never lands; the stale screenshot gets pasted instead.
75+
#
76+
# Fix: also write the text to xclip so the X11 selection owner is
77+
# text-typed AFTER this call. The daemon's stage_text remains the
78+
# fast in-memory record; xclip is what every external probe (wl-paste
79+
# shim → xclip fallback) actually reads.
80+
if [ -n "${DISPLAY:-}" ] && command -v xclip >/dev/null 2>&1; then
81+
xclip -selection clipboard -i <"$_tmp" 2>/dev/null &
82+
disown 2>/dev/null || true
83+
clog "clipboard-set" "event=mirror-to-xclip" "rc=spawned"
84+
fi
6385
exit 0
6486
fi
6587
# Daemon refused — fall through to the wl-copy / xclip / xsel chain.

bin/flashpaste-logs.sh

Lines changed: 200 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,26 +37,54 @@ FOLLOW=1
3737
WANT_DAEMON=1
3838
WANT_TRIGGER=1
3939
WANT_PIPELINE=1
40+
WANT_SHOT=0 # [shot] flashpaste-screenshot-watcher.service journal
41+
WANT_CLIPBOARD=0 # [clipboard] poller — xclip TARGETS change events (Wayland off by default)
42+
WANT_CLIP_WAYLAND=0 # opt-in: also poll wl-paste (causes dock flash on Mutter)
43+
WANT_KITTY_WIN=0 # [kitty] kitty window list poller — focus / count changes
44+
WANT_CLAUDE=0 # [claude] Claude TUI busy/idle state poller
45+
CLAUDE_PANE="" # which tmux pane to watch; auto-detect if empty
46+
CLIPBOARD_INTERVAL=1 # seconds between clipboard probes
4047
GREP_REGEX=""
4148
DEBUG_MODE=0
4249

4350
TRIGGER_LOG="${HOME}/.local/state/flashpaste-trigger.log"
4451
PIPELINE_LOG="${HOME}/.local/state/clipboard-pipeline.log"
4552
UNIT="flashpasted.service"
53+
SHOT_UNIT="flashpaste-screenshot-watcher.service"
4654

4755
# ── arg parse ────────────────────────────────────────────────────────────
4856
while [ $# -gt 0 ]; do
4957
case "$1" in
50-
--since) SINCE="$2"; shift 2;;
51-
-n|--lines) TAIL_N="$2"; shift 2;;
52-
--no-follow|-1) FOLLOW=0; shift;;
53-
--no-daemon) WANT_DAEMON=0; shift;;
54-
--no-trigger) WANT_TRIGGER=0; shift;;
55-
--no-pipeline) WANT_PIPELINE=0; shift;;
56-
-g|--grep) GREP_REGEX="$2"; shift 2;;
57-
--debug) DEBUG_MODE=1; shift;;
58+
--since) SINCE="$2"; shift 2;;
59+
-n|--lines) TAIL_N="$2"; shift 2;;
60+
--no-follow|-1) FOLLOW=0; shift;;
61+
--no-daemon) WANT_DAEMON=0; shift;;
62+
--no-trigger) WANT_TRIGGER=0; shift;;
63+
--no-pipeline) WANT_PIPELINE=0; shift;;
64+
--screenshot|--shot) WANT_SHOT=1; shift;;
65+
--clipboard|--clip) WANT_CLIPBOARD=1; shift;;
66+
--clip-wayland) WANT_CLIPBOARD=1; WANT_CLIP_WAYLAND=1; shift;;
67+
--kitty) WANT_KITTY_WIN=1; shift;;
68+
--claude) WANT_CLAUDE=1; shift;;
69+
--claude-pane) WANT_CLAUDE=1; CLAUDE_PANE="$2"; shift 2;;
70+
--clip-interval) CLIPBOARD_INTERVAL="$2"; shift 2;;
71+
--all) WANT_SHOT=1; WANT_CLIPBOARD=1; WANT_KITTY_WIN=1; WANT_CLAUDE=1; shift;;
72+
-g|--grep) GREP_REGEX="$2"; shift 2;;
73+
--debug) DEBUG_MODE=1; shift;;
5874
-h|--help)
5975
sed -n '2,32p' "$0" | sed -e 's/^# \{0,1\}//'
76+
cat >&2 <<'HELP'
77+
78+
Extra streams (opt-in; --all enables them all):
79+
--screenshot, --shot [shot] systemd flashpaste-screenshot-watcher.service journal
80+
--clipboard, --clip [clipboard] poller — emits on every change to
81+
Wayland TARGETS or X11 TARGETS. Use to see
82+
"image was on clipboard, then text took over."
83+
--kitty [kitty] poller — kitty windows JSON, emit on focus
84+
or count change. Use to debug "paste went
85+
to the wrong kitty window."
86+
--clip-interval SECS polling interval for --clipboard / --kitty (default 1)
87+
HELP
6088
exit 0;;
6189
*)
6290
echo "flashpaste-logs: unknown arg: $1" >&2
@@ -86,9 +114,132 @@ EOF
86114
fi
87115

88116
# ── line prefixers (sed -u keeps streaming, doesn't buffer) ──────────────
89-
pfx_daemon() { sed -u "s|^|${CYN}[daemon] ${OFF}|"; }
90-
pfx_trigger() { sed -u "s|^|${YEL}[trigger]${OFF} |"; }
91-
pfx_pipeline() { sed -u "s|^|${MAG}[pipe] ${OFF}|"; }
117+
# Two more colors for the new streams.
118+
if [ -t 1 ]; then GRN=$'\e[32m' RED=$'\e[31m' WHT=$'\e[37m'; else GRN= RED= WHT=; fi
119+
pfx_daemon() { sed -u "s|^|${CYN}[daemon] ${OFF}|"; }
120+
pfx_trigger() { sed -u "s|^|${YEL}[trigger]${OFF} |"; }
121+
pfx_pipeline() { sed -u "s|^|${MAG}[pipe] ${OFF}|"; }
122+
pfx_shot() { sed -u "s|^|${GRN}[shot] ${OFF}|"; }
123+
pfx_clipboard() { sed -u "s|^|${RED}[clip] ${OFF}|"; }
124+
pfx_kitty() { sed -u "s|^|${WHT}[kitty] ${OFF}|"; }
125+
pfx_claude() { sed -u "s|^|${BOLD}${CYN}[claude] ${OFF}|"; }
126+
127+
# Claude TUI state poller: scan each pane running `claude` or `node` for
128+
# the spinner pattern ("Verb-ing... (" — Claudding, Whirring, Pollinating,
129+
# Catapulting, Garnishing, Sautéing, Cogitating, etc.). Emit a single line
130+
# on every state transition per pane: busy → idle or idle → busy. Use to
131+
# correlate paste failures with Claude's generation state without staring
132+
# at the visual spinner. Note: this poller uses `tmux capture-pane` and
133+
# does NOT touch the Wayland/X11 clipboard, so no dock flash.
134+
#
135+
# The detector matches the spinner present-participle pattern instead of
136+
# the old "<N> tokens" heuristic — token counts appear in both live and
137+
# idle status lines, so the old detector hit false-positives on every
138+
# idle pane. The "...ing... (" form is unique to the live status.
139+
claude_state_poller() {
140+
# If user didn't pin a pane, watch every pane whose current command is
141+
# "claude" or "node" (the two binaries Claude Code TUI runs under).
142+
declare -A prev=()
143+
while true; do
144+
local ts
145+
ts=$(date +%H:%M:%S.%3N)
146+
local panes
147+
if [ -n "$CLAUDE_PANE" ]; then
148+
panes="$CLAUDE_PANE"
149+
else
150+
panes=$(tmux list-panes -a -F '#{pane_id} #{pane_current_command}' 2>/dev/null \
151+
| awk '$2=="claude" || $2=="node" {print $1}')
152+
fi
153+
[ -z "$panes" ] && { sleep "$CLIPBOARD_INTERVAL"; continue; }
154+
for p in $panes; do
155+
# Capture the last 12 rendered lines; the status line lives near the
156+
# bottom but exact row depends on input-box height.
157+
local cap
158+
cap=$(tmux capture-pane -t "$p" -pS -12 2>/dev/null)
159+
local state="idle"
160+
# Match present-participle spinner followed by "... (".
161+
if printf '%s' "$cap" | grep -qE '\b[A-Z][a-zéüôî]+ing\.\.\.[[:space:]]*\(' ; then
162+
state="busy"
163+
fi
164+
local was="${prev[$p]:-unknown}"
165+
if [ "$state" != "$was" ]; then
166+
printf '%s pane=%s state=%s (was %s)\n' "$ts" "$p" "$state" "$was"
167+
prev[$p]=$state
168+
fi
169+
done
170+
sleep "$CLIPBOARD_INTERVAL"
171+
done
172+
}
173+
174+
# Clipboard poller: emit a line whenever the X11 TARGETS list changes.
175+
# DELIBERATELY xclip-only — invoking /usr/bin/wl-paste once per poll on
176+
# this Mutter box makes the GNOME Shell dock flash a "wl-clipboard ready"
177+
# entry on every iteration. The daemon's X11 owner serves the same bytes
178+
# the Wayland clipboard would, so xclip captures every meaningful change
179+
# without the dock noise. Pass --clip-wayland to opt back in to Wayland
180+
# polling (only useful on wlroots / KDE / sway where the dock doesn't
181+
# react to surfaceless wl-paste).
182+
clipboard_poller() {
183+
local prev_x11="" prev_wl=""
184+
local ts cur_x11 cur_wl
185+
while true; do
186+
ts=$(date +%H:%M:%S.%3N)
187+
cur_x11=$(timeout 0.2 xclip -selection clipboard -o -t TARGETS 2>/dev/null | sort -u | tr '\n' ',' | sed 's/,$//')
188+
if [ "$cur_x11" != "$prev_x11" ]; then
189+
printf '%s x11_types=[%s] (was [%s])\n' "$ts" "${cur_x11:-<empty>}" "${prev_x11:-<empty>}"
190+
prev_x11=$cur_x11
191+
fi
192+
if [ "$WANT_CLIP_WAYLAND" = "1" ]; then
193+
cur_wl=$(timeout 0.2 /usr/bin/wl-paste --list-types 2>/dev/null | sort -u | tr '\n' ',' | sed 's/,$//')
194+
if [ "$cur_wl" != "$prev_wl" ]; then
195+
printf '%s wayland_types=[%s] (was [%s])\n' "$ts" "${cur_wl:-<empty>}" "${prev_wl:-<empty>}"
196+
prev_wl=$cur_wl
197+
fi
198+
fi
199+
sleep "$CLIPBOARD_INTERVAL"
200+
done
201+
}
202+
203+
# Kitty window state poller: emit on focus/count change. Use to debug
204+
# "send-text landed in the wrong window".
205+
kitty_poller() {
206+
local sock prev=""
207+
for s in "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"/kitty-main-*; do
208+
[ -S "$s" ] && sock="unix:$s" && break
209+
done
210+
if [ -z "$sock" ]; then
211+
echo "(no kitty socket — kitty stream skipped)"
212+
return
213+
fi
214+
while true; do
215+
local ts focused count
216+
ts=$(date +%H:%M:%S.%3N)
217+
# ls returns a JSON tree of OS-windows / tabs / windows. We project
218+
# down to "focused id + total window count" — a low-cardinality summary
219+
# that still catches focus steals and tab opens.
220+
local cur
221+
cur=$(kitty @ --to "$sock" ls 2>/dev/null | python3 -c '
222+
import json, sys
223+
try:
224+
d = json.load(sys.stdin)
225+
except Exception:
226+
print("error"); raise SystemExit
227+
focused, active, count = None, None, 0
228+
for ow in d:
229+
for tab in ow.get("tabs", []):
230+
for w in tab.get("windows", []):
231+
count += 1
232+
if w.get("is_focused"): focused = w.get("id")
233+
if w.get("is_active"): active = w.get("id")
234+
print(f"focused={focused} active={active} count={count}")
235+
' 2>/dev/null)
236+
if [ -n "$cur" ] && [ "$cur" != "$prev" ]; then
237+
printf '%s %s\n' "$ts" "$cur"
238+
prev=$cur
239+
fi
240+
sleep "$CLIPBOARD_INTERVAL"
241+
done
242+
}
92243

93244
# Optional grep wrapper applied per-stream so colored prefixes survive.
94245
maybe_grep() {
@@ -109,9 +260,13 @@ trap cleanup EXIT INT TERM
109260

110261
# ── banner ───────────────────────────────────────────────────────────────
111262
streams=""
112-
[ "$WANT_DAEMON" = 1 ] && streams+="${CYN}daemon${OFF} "
113-
[ "$WANT_TRIGGER" = 1 ] && streams+="${YEL}trigger${OFF} "
114-
[ "$WANT_PIPELINE" = 1 ] && streams+="${MAG}pipeline${OFF} "
263+
[ "$WANT_DAEMON" = 1 ] && streams+="${CYN}daemon${OFF} "
264+
[ "$WANT_TRIGGER" = 1 ] && streams+="${YEL}trigger${OFF} "
265+
[ "$WANT_PIPELINE" = 1 ] && streams+="${MAG}pipeline${OFF} "
266+
[ "$WANT_SHOT" = 1 ] && streams+="${GRN}shot${OFF} "
267+
[ "$WANT_CLIPBOARD" = 1 ] && streams+="${RED}clip${OFF} "
268+
[ "$WANT_KITTY_WIN" = 1 ] && streams+="${WHT}kitty${OFF} "
269+
[ "$WANT_CLAUDE" = 1 ] && streams+="${BOLD}${CYN}claude${OFF} "
115270
follow_str="follow"; [ "$FOLLOW" = 0 ] && follow_str="snapshot"
116271
since_str=""; [ -n "$SINCE" ] && since_str=" since='${SINCE}'"
117272
grep_str=""; [ -n "$GREP_REGEX" ] && grep_str=" grep='${GREP_REGEX}'"
@@ -156,6 +311,37 @@ elif [ "$WANT_PIPELINE" = 1 ]; then
156311
echo "${DIM}(no $PIPELINE_LOG yet — pipeline stream skipped)${OFF}" >&2
157312
fi
158313

314+
# ── stream 4: screenshot-watcher journal ─────────────────────────────────
315+
if [ "$WANT_SHOT" = 1 ]; then
316+
sh_args=(--user -u "$SHOT_UNIT" -o short-precise --no-hostname --no-pager)
317+
if [ -n "$SINCE" ]; then
318+
sh_args+=(--since "$SINCE")
319+
else
320+
sh_args+=(-n "$TAIL_N")
321+
fi
322+
[ "$FOLLOW" = 1 ] && sh_args+=(-f)
323+
( journalctl "${sh_args[@]}" 2>&1 | maybe_grep | pfx_shot ) &
324+
PIDS+=($!)
325+
fi
326+
327+
# ── stream 5: clipboard-state poller ─────────────────────────────────────
328+
if [ "$WANT_CLIPBOARD" = 1 ]; then
329+
( clipboard_poller 2>&1 | maybe_grep | pfx_clipboard ) &
330+
PIDS+=($!)
331+
fi
332+
333+
# ── stream 6: kitty window state poller ──────────────────────────────────
334+
if [ "$WANT_KITTY_WIN" = 1 ]; then
335+
( kitty_poller 2>&1 | maybe_grep | pfx_kitty ) &
336+
PIDS+=($!)
337+
fi
338+
339+
# ── stream 7: Claude TUI busy/idle poller ────────────────────────────────
340+
if [ "$WANT_CLAUDE" = 1 ]; then
341+
( claude_state_poller 2>&1 | maybe_grep | pfx_claude ) &
342+
PIDS+=($!)
343+
fi
344+
159345
if [ "${#PIDS[@]}" = 0 ]; then
160346
echo "flashpaste-logs: nothing to follow (all streams disabled or missing)" >&2
161347
exit 1

bin/flashpaste-screenshot-preload.sh

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,22 @@ if [ "$mtime" -le "$last_mtime" ]; then
4545
exit 0
4646
fi
4747

48-
# Don't clobber a fresh browser text copy. xclip is X11 and DOESN'T
49-
# consume wl-copy --paste-once, so this probe is safe.
50-
xt=$(timeout 0.2 xclip -selection clipboard -t text/plain -o 2>/dev/null | head -c 8)
48+
# Previously skipped pre-load if X11 clipboard already held text — the
49+
# intent was "don't clobber a fresh browser text copy with a screenshot
50+
# the user took accidentally." In practice that branch is the dominant
51+
# failure mode for image-paste on this stack: copy something, take a
52+
# screenshot, press Ctrl-V, and Claude pastes the OLD TEXT instead of
53+
# the screenshot. The user's clear intent on PrtScr is "I want to paste
54+
# this screenshot." The ≤10 s file-age guard above already rules out
55+
# stale files being re-touched by other apps. Probe kept here only as
56+
# a breadcrumb in the pipeline log so debugging shows what was on the
57+
# clipboard right before we overwrote it.
58+
xt=$(timeout 0.2 xclip -selection clipboard -t text/plain -o 2>/dev/null | head -c 16)
5159
case "$xt" in
5260
$'\x89PNG'*) xt= ;; # PNG header masquerading as text — ignore
5361
esac
5462
if [ -n "$xt" ]; then
55-
clog "ss-preload" "event=skip-text-on-clipboard" "preview='$(printf '%s' "$xt" | tr '\n' ' ')'"
56-
exit 0
63+
clog "ss-preload" "event=overriding-text" "preview='$(printf '%s' "$xt" | tr '\n' ' ')'"
5764
fi
5865

5966
case "$latest" in

bin/wl-paste

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,30 @@ xclip_selection=$([ "$primary" = "1" ] && echo primary || echo clipboard)
9393

9494
# Build the xclip command appropriate for this invocation; we'll use it
9595
# either as a wedge-cache fast-path or as the post-wl-paste fallback.
96+
#
97+
# Bug we defend against: `xclip -selection clipboard -t image/png -o` on
98+
# a text-only clipboard does NOT return empty — it returns the TEXT
99+
# bytes (xclip silently falls back to whatever's actually in the
100+
# selection when the requested `-t` isn't advertised). Claude Code's
101+
# `wl-paste -t image/png` was getting text back and reporting "no
102+
# image found" while pasting raw text into the chat. Defence: when a
103+
# MIME-shaped target is asked for, verify it appears in TARGETS first;
104+
# if not, exit 1 with no stdout (matching what a real wl-paste does on
105+
# a missing MIME).
96106
xclip_fallback() {
97107
if [ "$list_only" = "1" ]; then
98108
exec xclip -selection "$xclip_selection" -t TARGETS -o
99109
elif [ -n "$target" ]; then
110+
case "$target" in
111+
*/*)
112+
# MIME-typed target — verify it's actually advertised before
113+
# asking xclip for it, else xclip lies and returns text.
114+
if ! xclip -selection "$xclip_selection" -t TARGETS -o 2>/dev/null \
115+
| grep -Fxq -- "$target"; then
116+
exit 1
117+
fi
118+
;;
119+
esac
100120
exec xclip -selection "$xclip_selection" -t "$target" -o
101121
else
102122
exec xclip -selection "$xclip_selection" -o

0 commit comments

Comments
 (0)