Skip to content

Commit 641a2ac

Browse files
NagyViktclaude
andcommitted
v1.20: per-invocation logging + re-assert clipboard before each paste
Diagnosing the user-reported "right-click → Paste does nothing for image-paste, Ctrl+V works" required two things this commit ships: 1. Per-invocation log in flashpaste-trigger Every call appends one line to ~/.local/state/flashpaste-trigger.log (override with FLASHPASTE_TRIGGER_LOG). Records timestamp, pid, op (paste or stage-text), pane, phase (start / handled / exec-bash / exit), and the daemon outcome. Combined with the daemon's journalctl (also bumped to INFO for the punt reasons below), you can answer "what happened when I clicked Paste at 13:47:22?" without strace. tmux.conf.snippet now sets TMUX_PASTE_TRIGGER=right-click-menu on the right-click path so the log distinguishes it from the ctrl-v path the C-v binding tags. Users with their own MouseUp3Pane menu should do the same in their Paste item. Suppress with FLASHPASTE_QUIET=1 (matches the bash dispatcher's FLASHPASTE_QUIET semantics). 2. Re-assert clipboard ownership before each paste (paste.rs) Root cause of the user's symptom: between two pastes the user can copy text. The v1.19 OSC 52 path makes kitty the live Wayland owner with text/plain. The daemon's `latest_image` is still cached in memory but the *live* clipboard owner has changed. When the daemon dispatches send-text \026 and Claude calls `wl-paste -t image/png`, kitty serves the (text) selection and Claude reads 0 image bytes — silent no-op. Fix: bump the stage_notifier and sleep 40 ms before dispatch. That wakes the Wayland + X11 owner tasks; they re-claim the selection with the staged image bytes before the kitty send-text fires. On mutter (where wl-clipboard-rs's wlr-data-control claim is rejected outright — separate ext-data-control TODO), the X11 re-claim still succeeds and the wl-paste shim's xclip fallback delivers the image to Claude. 3. Bumped DEBUG → INFO for the daemon's paste lifecycle events `paste: request received`, `paste: dedupe …`, `paste: no staged image`, `paste: staged image too old` are now visible at the default RUST_LOG=info level via journalctl --user -fu flashpasted. That was the actual show-stopper for v1.19 debugging: the user's journalctl was silent because everything interesting was at debug. Verified locally: - `cargo build --release` clean (only pre-existing dead-code warnings) - Smoke test: `flashpaste-trigger %1` writes a `start` then either `handled :: daemon` or `exec-bash :: daemon-declined` line to ~/.local/state/flashpaste-trigger.log; journalctl shows the matching `paste: request received` + decision. - Daemon restarted on dev box; logs flowing as expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 389d6a1 commit 641a2ac

4 files changed

Lines changed: 107 additions & 10 deletions

File tree

examples/tmux.conf.snippet

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,15 @@ bind -n C-v run-shell -b "TMUX_PASTE_TRIGGER=ctrl-v flashpaste-trigger '#{pane_i
4242
bind -n MouseDown2Pane run-shell -b "buf=$(sh -c '#{@paste}'); if [ -n \"$buf\" ]; then printf '%s' \"$buf\" | tmux load-buffer -b mid_paste - 2>/dev/null && tmux paste-buffer -d -p -b mid_paste -t '#{pane_id}' 2>/dev/null; fi"
4343

4444
# Right-click pane menu with Paste item — same tier-3 trigger path.
45+
# Note: many tmux configs override this with `MouseUp3Pane`. If you
46+
# already have a custom right-click menu, just make sure its Paste item
47+
# invokes `flashpaste-trigger '#{pane_id}'` with the TMUX_PASTE_TRIGGER
48+
# env var so ~/.local/state/flashpaste-trigger.log can distinguish the
49+
# right-click path from the C-v path during debugging.
4550
bind -n MouseDown3Pane display-menu -O -x M -y M \
4651
-T "#[align=centre,fg=#FFFFFF,bold] pane #{pane_index} " \
4752
"#[fg=#34C759]↳ Paste" p \
48-
"run-shell -b \"flashpaste-trigger '#{pane_id}' 2>/dev/null || /home/$USER/.local/bin/tmux-paste-dispatch.sh '#{pane_id}'\""
53+
"run-shell -b \"TMUX_PASTE_TRIGGER=right-click-menu flashpaste-trigger '#{pane_id}' 2>/dev/null || TMUX_PASTE_TRIGGER=right-click-menu /home/$USER/.local/bin/tmux-paste-dispatch.sh '#{pane_id}'\""
4954

5055
# Recursion-guard note: the C-v binding above intentionally re-invokes
5156
# the dispatcher when kitty `send-text` injects a \026 byte. The

rs/flashpaste-trigger/src/main.rs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ fn main() -> ! {
9898
if args.stage_text {
9999
// Decoupled path — no pane needed, no bash fallback. The caller
100100
// (clipboard-set.sh) handles its own fallback if we exit non-zero.
101-
std::process::exit(stage_text_main());
101+
trigger_log("stage-text", "-", "start", "reading stdin");
102+
let code = stage_text_main();
103+
trigger_log("stage-text", "-", "exit", &format!("code={code}"));
104+
std::process::exit(code);
102105
}
103106

104107
let pane = match &args.pane {
@@ -109,16 +112,75 @@ fn main() -> ! {
109112
}
110113
};
111114

115+
let trigger_source = std::env::var("TMUX_PASTE_TRIGGER")
116+
.unwrap_or_else(|_| "unset".to_string());
117+
trigger_log("paste", &pane, "start", &format!("trigger={trigger_source}"));
118+
112119
if args.force_fallback {
120+
trigger_log("paste", &pane, "exec-bash", "force-fallback");
113121
exec_bash_fallback(&pane);
114122
}
115123

116124
let paste_args = PasteArgs { pane: pane.clone(), op: args.op };
117125
match try_daemon(&paste_args) {
118-
Ok(DaemonOutcome::Handled) => std::process::exit(0),
119-
Ok(DaemonOutcome::FallbackRequested) => exec_bash_fallback(&pane),
120-
Err(_) => exec_bash_fallback(&pane),
126+
Ok(DaemonOutcome::Handled) => {
127+
trigger_log("paste", &pane, "handled", "daemon");
128+
std::process::exit(0);
129+
}
130+
Ok(DaemonOutcome::FallbackRequested) => {
131+
trigger_log("paste", &pane, "exec-bash", "daemon-declined");
132+
exec_bash_fallback(&pane);
133+
}
134+
Err(e) => {
135+
trigger_log("paste", &pane, "exec-bash", &format!("daemon-error: {e}"));
136+
exec_bash_fallback(&pane);
137+
}
138+
}
139+
}
140+
141+
/// Per-invocation log written to `$FLASHPASTE_TRIGGER_LOG` or
142+
/// `~/.local/state/flashpaste-trigger.log`. The whole point of this log
143+
/// is debugging "right-click Paste doesn't paste but Ctrl+V does":
144+
/// every invocation appends one line so you can see when each handler
145+
/// fires, what the trigger source was, and which path the daemon chose.
146+
///
147+
/// Suppress with `FLASHPASTE_QUIET=1`.
148+
fn trigger_log(op: &str, pane: &str, phase: &str, detail: &str) {
149+
if std::env::var_os("FLASHPASTE_QUIET").is_some() {
150+
return;
151+
}
152+
let path = log_path();
153+
if let Some(parent) = path.parent() {
154+
let _ = std::fs::create_dir_all(parent);
155+
}
156+
let Ok(mut f) = std::fs::OpenOptions::new()
157+
.create(true)
158+
.append(true)
159+
.open(&path)
160+
else {
161+
return;
162+
};
163+
let ts = iso8601_utc_now();
164+
let pid = std::process::id();
165+
let _ = writeln!(
166+
f,
167+
"{ts} pid={pid} op={op} pane={pane} phase={phase} :: {detail}"
168+
);
169+
}
170+
171+
fn log_path() -> PathBuf {
172+
if let Ok(p) = std::env::var("FLASHPASTE_TRIGGER_LOG") {
173+
if !p.is_empty() {
174+
return PathBuf::from(p);
175+
}
176+
}
177+
if let Ok(home) = std::env::var("HOME") {
178+
return PathBuf::from(home)
179+
.join(".local")
180+
.join("state")
181+
.join("flashpaste-trigger.log");
121182
}
183+
PathBuf::from("/tmp/flashpaste-trigger.log")
122184
}
123185

124186
struct PasteArgs {

rs/flashpasted/src/ipc.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,22 @@ async fn write_response(stream: &mut UnixStream, value: &Value) -> Result<()> {
157157
}
158158

159159
async fn handle_paste(state: &Arc<SharedState>, pane: &str, started: Instant) -> Value {
160+
info!(pane, "paste: request received");
161+
160162
// ─── Recursion guard ─────────────────────────────────────────────
161163
// Fact #2 from the spec: tmux's `bind -n C-v` re-fires when the kitty
162164
// `send_text \026` byte reaches tmux. The trigger binary calls us
163165
// again; we dedupe based on the last-paste timestamp.
164166
let now = now_unix_ms();
165167
let last = state.last_paste_ms.load(Ordering::Relaxed);
166168
if now.saturating_sub(last) < RECURSION_DEDUPE_MS {
167-
debug!(
169+
// INFO not DEBUG: this fires every paste (the recursive C-v echo
170+
// is normal) and v1.20 user-reported "right-click Paste does
171+
// nothing" debugging needs this visible in journalctl.
172+
info!(
173+
pane,
168174
delta_ms = now - last,
169-
"paste dedupe — within recursion window"
175+
"paste: dedupe — within recursion window (this is normal for the C-v echo back from kitty send-text)"
170176
);
171177
return json!({ "ok": true, "deduped": true });
172178
}
@@ -181,15 +187,18 @@ async fn handle_paste(state: &Arc<SharedState>, pane: &str, started: Instant) ->
181187
let staged = match state.staged_image().await {
182188
Some(img) if img.is_fresh() => img,
183189
Some(_) => {
184-
warn!("staged image too old; daemon punting to bash");
190+
warn!(pane, "paste: staged image too old; punting to bash");
185191
return json!({
186192
"ok": false,
187193
"reason": "stale-image",
188194
"fallback": "bash",
189195
});
190196
}
191197
None => {
192-
debug!("no staged image (clipboard is empty or holds text); daemon punting to bash");
198+
info!(
199+
pane,
200+
"paste: no staged image (clipboard empty or holds text); punting to bash"
201+
);
193202
return json!({
194203
"ok": false,
195204
"reason": "no-image",

rs/flashpasted/src/paste.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use anyhow::{Context, Result};
2121
use tracing::{debug, info, warn};
2222

2323
use crate::kitty;
24-
use crate::state::{SharedState, StagedImage};
24+
use crate::state::{now_unix_ms, SharedState, StagedImage};
2525
use crate::tmux;
2626

2727
/// How long to wait before re-binding `C-v` in tmux. The bash dispatcher
@@ -54,6 +54,27 @@ pub async fn dispatch_image_paste(
5454
};
5555
debug!(kitty_sock = %kitty_sock.display(), "resolved kitty socket");
5656

57+
// Step 0: re-assert clipboard ownership.
58+
//
59+
// Why: between two pastes, the user can have copied text (the v1.19
60+
// OSC 52 path makes kitty the live Wayland selection owner with
61+
// text/plain bytes). The daemon's `latest_image` is still cached in
62+
// memory, but the *live* clipboard owner has changed. When we
63+
// send-text \026 and Claude calls `wl-paste -t image/png`, kitty
64+
// serves the (text) selection — Claude reads 0 image bytes and
65+
// silently does nothing. Symptom: "right-click → Paste doesn't
66+
// paste the image; Ctrl+V right after a screenshot does."
67+
//
68+
// Bumping the stage notifier wakes the wayland.rs + x11.rs owner
69+
// tasks, which re-claim the selection with the staged image bytes.
70+
// The brief sleep lets the round-trip land before we send-text.
71+
// On mutter where the Wayland claim is rejected outright (no
72+
// ext-data-control / wlr-data-control), the X11 re-claim still
73+
// succeeds and the wl-paste shim's xclip fallback picks it up.
74+
info!(pane, "paste: re-asserting clipboard ownership before dispatch");
75+
let _ = state.stage_notifier_tx.send(now_unix_ms());
76+
tokio::time::sleep(Duration::from_millis(40)).await;
77+
5778
// Step 1: select pane. Best-effort.
5879
tmux::select_pane(&pane).await;
5980

0 commit comments

Comments
 (0)