Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ jobs:
-e SC1090,SC2034,SC2155 \
"${shell_files[@]}"

behavior-tests:
name: paste-correctness behavioral tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Run behavioral regression suite (headless, mocked clipboard)
run: bash bin/flashpaste-selftest.sh

rust:
name: rust fmt + clippy + unit tests
runs-on: ubuntu-24.04
Expand Down
31 changes: 31 additions & 0 deletions bin/flashpaste-doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,37 @@ for f in $(ls "$RDIR" | sort -n); do
esac
done

# ── Behavioral regression suite (paste-correctness guards) ────────────
# Runs the headless bash test suite (wl-paste image-coexistence guard,
# kitty/tmux detector). A failure means a known paste bug could regress,
# so it counts as a doctor failure.
_selftest="$(dirname -- "$0")/flashpaste-selftest.sh"
if [ -x "$_selftest" ]; then
echo
hdr "paste-correctness self-test"
if "$_selftest" >/dev/null 2>&1; then
ok "self-test" "behavioral paste-correctness suite passed"
else
fail "self-test" "behavioral paste-correctness suite FAILED — run $_selftest"
fails=$((fails + 1))
fi
fi

# ── Keybinding drift check (kitty.conf vs tmux.conf vs canonical) ──────
# The double-paste bug was Ctrl+V drifting between the two configs. Warn
# (not fail) on drift: it degrades to the daemon's dedup, not a breakage.
_kbcheck="$(dirname -- "$0")/flashpaste-keybindings-check.sh"
if [ -x "$_kbcheck" ]; then
echo
hdr "keybinding drift"
if "$_kbcheck" >/dev/null 2>&1; then
ok "keybindings" "kitty + tmux Ctrl+V consistent with canonical source"
else
warn "keybindings" "Ctrl+V drift between kitty/tmux/canonical — run $_kbcheck"
warns=$((warns + 1))
fi
fi

echo
if [ "$fails" -eq 0 ] && [ "$warns" -eq 0 ]; then
printf "${GREEN}All $core_checks core checks passed.${RESET} $optional_checks optional probe(s) also passed. flashpaste should work out of the box.\n"
Expand Down
47 changes: 47 additions & 0 deletions bin/flashpaste-keybindings-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# flashpaste keybinding drift check — read-only.
#
# Verifies the live kitty.conf and tmux.conf bind Ctrl+V the way the canonical
# source (config/keybindings.canonical) says they must. The double-paste bug
# was these two drifting apart; this catches the drift instead of silently
# pasting twice. Read-only: it never edits your dotfiles.
#
# Env overrides (for testing): KITTY_CONF, TMUX_CONF, CANONICAL.
# Exit 0 = consistent, 1 = drift/missing, 2 = canonical unreadable.
set -u

ROOT="$(cd "$(dirname -- "$0")/.." && pwd)"
CANONICAL="${CANONICAL:-$ROOT/config/keybindings.canonical}"
KITTY_CONF="${KITTY_CONF:-$HOME/.config/kitty/kitty.conf}"
TMUX_CONF="${TMUX_CONF:-$HOME/.tmux.conf}"

[ -r "$CANONICAL" ] || { echo "FATAL: canonical not readable: $CANONICAL"; exit 2; }

drift=0
while read -r surface key want; do
case "$surface" in ""|\#*) continue ;; esac
# 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) ;;
*) echo " ? unknown surface '$surface' in canonical"; drift=1; continue ;;
esac
if [ -z "$line" ]; then
echo " ✗ $surface $key: no binding found in $conf"
drift=1
elif printf '%s' "$line" | grep -Fq -- "$want"; then
echo " ✓ $surface $key -> $want"
else
echo " ✗ $surface $key: binding does not route through '$want'"
echo " live: $line"
drift=1
fi
done < "$CANONICAL"

if [ "$drift" = "0" ]; then
echo "keybindings: consistent with canonical source"
else
echo "keybindings: DRIFT detected — fix the live config(s) above"
fi
exit "$drift"
39 changes: 39 additions & 0 deletions bin/flashpaste-selftest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# flashpaste self-test — one command that proves the paste pipeline's
# correctness guards still hold. Runs the behavioral regression suite (the
# net under the image-bytes-as-text / blob-markup bug class) plus, with
# --rust, the daemon's unit tests.
#
# flashpaste-selftest.sh behavioral bash tests only (fast, no build)
# flashpaste-selftest.sh --rust also `cargo test -p flashpasted`
#
# Exits non-zero if any test fails. Headless-safe: the bash tests mock the
# clipboard, so this never touches the user's real selection and needs no
# display. Wired into CI (.github/workflows/lint.yml) and callable from
# flashpaste-doctor.
set -u

ROOT="$(cd "$(dirname -- "$0")/.." && pwd)"
fail=0

echo "── flashpaste self-test ───────────────────────────────────"
for t in "$ROOT"/tests/*.test.sh; do
[ -f "$t" ] || continue
echo
echo "▶ $(basename "$t")"
if bash "$t"; then :; else fail=1; fi
done

if [ "${1:-}" = "--rust" ]; then
echo
echo "▶ cargo test -p flashpasted"
if cargo test --manifest-path "$ROOT/rs/Cargo.toml" -p flashpasted 2>&1 | tail -3; then :; else fail=1; fi
fi

echo
if [ "$fail" = "0" ]; then
echo "✓ flashpaste self-test: ALL PASSED"
else
echo "✗ flashpaste self-test: FAILURES (see above)"
fi
exit "$fail"
54 changes: 54 additions & 0 deletions bin/kitty-focused-is-tmux.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Read `kitten @ ls --match state:focused` JSON on stdin; exit 0 iff the
focused kitty window's foreground process is tmux.

Used by kitty-paste-router.sh to suppress kitty's redundant ctrl+v handler
when tmux's own `bind -n C-v` will handle the paste. Exit non-zero on ANY
uncertainty (no data, parse error, no tmux) so the caller falls through to
the normal paste path — a miss must degrade to "paste anyway", never to a
silent no-op.

Why the foreground process: when tmux runs inside a kitty window, that
window's child IS the tmux client; the inner shells live under the tmux
server's process tree, not the kitty window. So a focused window whose
foreground process is `tmux` reliably means "this Ctrl+V is inside tmux".
"""
import json
import sys


def _is_tmux_cmdline(cmdline):
if not cmdline:
return False
exe = cmdline[0].rsplit("/", 1)[-1]
return exe == "tmux" or exe.startswith("tmux")


def main():
try:
data = json.load(sys.stdin)
except Exception:
return 1
if not isinstance(data, list):
return 1
for os_window in data:
for tab in os_window.get("tabs", []):
for window in tab.get("windows", []):
# Only consider a window we can POSITIVELY confirm is focused.
# `--match state:focused` already filters to it (and sets
# is_focused=true), so requiring True here is exact on modern
# kitty. On older kitty that ignores the match and returns
# every window WITHOUT is_focused, no window qualifies -> we
# report "not tmux" -> the router falls through and the paste
# still happens. That is the fail-safe direction: never
# suppress a paste on a window we cannot prove is focused.
if window.get("is_focused") is not True:
continue
for proc in window.get("foreground_processes", []):
if _is_tmux_cmdline(proc.get("cmdline")):
return 0
return 1


if __name__ == "__main__":
sys.exit(main())
87 changes: 87 additions & 0 deletions bin/kitty-paste-router.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────
# kitty ctrl+v router — eliminate the dual-handler double-paste at source.
#
# Problem: a single Ctrl+V inside kitty+tmux fires TWO handlers that both
# call flashpaste-trigger for the same pane — kitty's `map ctrl+v` AND
# tmux's `bind -n C-v`. They land hundreds of ms apart, so the daemon's
# (pane, content) dedup window has to race them. This router removes the
# redundant kitty fire entirely: if the FOCUSED kitty window is running
# tmux, tmux's own bind already handles the paste, so kitty does nothing.
#
# Safety: this is a BELT on top of the daemon's dedup SUSPENDERS. We only
# short-circuit when we can CONFIDENTLY detect tmux in the focused window
# (kitty remote control works AND its foreground process is tmux). On any
# uncertainty — remote control off, kitten missing, parse failure — we fall
# through to the original path, and the daemon's dedup still absorbs the
# duplicate. So a detection miss degrades to today's behaviour, never to a
# broken paste.
#
# Latency: the kitten round-trip (~30ms) only delays kitty's REDUNDANT
# handler. The user-visible paste already happened via tmux's fast bind, so
# this adds zero latency to the actual paste. When NOT in tmux, kitty's
# handler is the only one and ~30ms there is unnoticeable.
# ─────────────────────────────────────────────────────────────────────
set -u

PASTE_IMAGE_FALLBACK="${FLASHPASTE_IMAGE_FALLBACK:-/home/deadpool/paste_image.sh}"
# Resolve kitty's remote-control socket.
#
# kitty appends "-<pid>" to the `listen_on` path, so `listen_on
# unix:.../kitty-main` actually opens `.../kitty-main-<pid>`. Crucially,
# KITTY_LISTEN_ON is NOT in the env this script inherits: kitty's `map
# ctrl+v launch --copy-env` copies kitty's OWN process env, which does not
# carry KITTY_LISTEN_ON (only child windows get it). So the old bare
# `kitty-main` fallback never matched the real `kitty-main-<pid>` socket —
# `kitten @ ls` failed every time, focused_window_is_tmux() always returned
# false, and this router NEVER suppressed kitty's redundant fire. Net effect:
# a single Ctrl+V pasted twice (tmux's bind immediately + this router ~1s
# later). Resolve robustly: trust KITTY_LISTEN_ON when present, else glob the
# real socket (newest wins if several kitty instances are running).
resolve_kitty_sock() {
if [ -n "${KITTY_LISTEN_ON:-}" ]; then
printf '%s\n' "$KITTY_LISTEN_ON"
return 0
fi
local base s
base="/run/user/$(id -u)/kitty-main"
# Direct shell glob — no `ls`, no word-splitting (paths with spaces stay
# intact). kitty creates `kitty-main-<pid>`; with one instance there's
# exactly one match. With several this picks the first lexically — good
# enough for the common single-instance case, and a wrong guess only falls
# through to the normal paste path (the daemon's dedup is the backstop). If
# the glob matches nothing it stays literal and fails the `-S` test below.
for s in "$base"-* "$base"; do
[ -S "$s" ] && { printf 'unix:%s\n' "$s"; return 0; }
done
# Last-ditch: hand back the bare path; kitten will fail and we fall through
# to the normal paste path (the daemon's dedup is the remaining safety net).
printf 'unix:%s\n' "$base"
}
KITTY_SOCK="$(resolve_kitty_sock)"

# Confidently true (exit 0) ONLY when the focused kitty window's foreground
# process is tmux. Any failure to determine that returns non-zero, so the
# caller falls through to the normal paste path.
focused_window_is_tmux() {
command -v kitten >/dev/null 2>&1 || return 1
command -v python3 >/dev/null 2>&1 || return 1
kitten @ --to "$KITTY_SOCK" ls --match state:focused 2>/dev/null \
| python3 "$(dirname -- "$0")/kitty-focused-is-tmux.py" 2>/dev/null
}

if focused_window_is_tmux; then
# tmux's `bind -n C-v` owns this paste. Do nothing.
exit 0
fi

# 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.
if [ -n "$pane" ] && flashpaste-trigger "$pane" 2>/dev/null; then
exit 0
fi
exec "$PASTE_IMAGE_FALLBACK"
18 changes: 18 additions & 0 deletions config/keybindings.canonical
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# flashpaste — canonical keybinding source of truth
#
# The double-paste bug was config DRIFT: kitty.conf and tmux.conf each bound
# Ctrl+V independently and got out of sync. This file is the single place that
# declares what each side MUST bind. bin/flashpaste-keybindings-check.sh reads
# it and verifies the live configs match, flagging drift (run by doctor).
#
# 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.

# 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

# tmux's root Ctrl+V must hit the fast Rust trigger (daemon), falling back to
# the bash dispatcher when the daemon is down.
tmux C-v flashpaste-trigger
66 changes: 66 additions & 0 deletions docs/adr/0006-reduce-bash-surface-bounded-by-tier1-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# ADR 0006 — Reduce the bash surface, bounded by the Tier-1 fallback guarantee

- **Status:** Proposed
- **Date:** 2026-06-10
- **Deciders:** maintainers
- **Tags:** architecture, maintainability, scope

## Context and problem statement

flashpaste carries ~3.7k lines of bash across 17 tracked scripts alongside
~4k lines of Rust. Bash is hard to test and each script has been a breakage
source (the `wl-paste` shim, `clipboard-set.sh`, `tmux-paste-dispatch.sh`).
A tempting "improvement" is to fold the shell into the Rust daemon so the
logic is testable and typed.

## The constraint that makes this an ocean

Most of the bash is **load-bearing by design**, not accident:

- `bin/tmux-paste-dispatch.sh` (657 lines) is the **Tier-1 canonical path**
per [ADR 0001](0001-three-progressive-tiers.md). It MUST run with zero
daemon, zero Rust toolchain, zero systemd. The daemon (Tier 3) execs it on
any failure. Folding it into the daemon would delete the very fallback that
makes "flashpasted is not running" a non-event.
- `bin/clipboard-set.sh` is invoked by tmux's `@clip` hook in the user's
shell, before any daemon round-trip — it has to be a script tmux can exec.
- `~/.local/bin/wl-paste` is a PATH shim that Claude Code reads through
directly; it only works because it IS a `wl-paste`-named executable. It
cannot become daemon-internal without changing how Claude reads the
clipboard.

So "move the bash into Rust" is partly a **non-goal**: the bash surface is
the zero-dependency tier. This downgrades the ROI estimate the improvement
list gave #3 (friction +50% 🟠) — the genuinely reducible part is smaller.

## What IS reducible (the real first slices)

1. **Duplication between shims and daemon.** The daemon already has typed
probes (`read_clipboard_text_if_present`, `read_clipboard_image_if_present`,
`looks_like_text`) that re-implement logic also living in
`get-clipboard-text.sh` (169 lines) and parts of `tmux-paste-dispatch.sh`.
Where the daemon is up, those shells should call the daemon (one op) rather
than re-deriving. First slice: a `flashpaste-trigger --get-text` op that
`get-clipboard-text.sh` shells to when the socket exists, falling back to
its current logic otherwise. Removes ~100 lines of duplicated probe logic
without touching the Tier-1 guarantee.
2. **Behavioral test coverage for the bash that must stay.** Tier-1 is
permanent, so test it instead of deleting it. The `tests/*.test.sh` harness
(mocked clipboard, headless, in CI as of this session) is the pattern; grow
it to cover `tmux-paste-dispatch.sh`'s text-vs-image decision.

## Decision

**Proposed:** do NOT pursue a wholesale bash→Rust rewrite. Treat the Tier-1
bash as a permanent, first-class artifact and invest in (a) deleting only the
*duplication* between shims and the daemon, slice by slice, and (b) behavioral
tests for the bash that must remain. Each slice ships independently and keeps
the no-daemon guarantee intact.

## Consequences

- The ~657-line Tier-1 dispatcher stays. That's correct, not debt.
- "Shrink the bash" becomes a bounded, test-first cleanup, not a multi-day
rewrite that risks the fallback path.
- Open question: if a future ADR retires the three-tier model (daemon becomes
mandatory), this constraint lifts and a fuller consolidation reopens.
Loading
Loading