#!/usr/bin/env bash # Description: # View and manage clipboard history using fzf, with support for # image preview in compatible terminals. # Requirements: # - fzf # - cliphist # - wl-clipboard (including wl-copy and wl-paste) # - python3 with urllib (for URL quoting/unquoting) # - chafa (optional, for image preview) # - ffmpegthumbnailer (optional, for video thumbnails) # Credits: # - Original idea and some code adapted from https://github.com/SHORiN-KiWATA/shorinclip # License: # # MIT License # # # # Copyright (c) 2026 shorinkiwata # # # # Permission is hereby granted, free of charge, to any person obtaining a copy # # of this software and associated documentation files (the "Software"), to deal # # in the Software without restriction, including without limitation the rights # # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # # copies of the Software, and to permit persons to whom the Software is # # furnished to do so, subject to the following conditions: # # # # The above copyright notice and this permission notice shall be included in all # # copies or substantial portions of the Software. # # # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # # SOFTWARE. set -euo pipefail SHELL=$(command -v bash) export SHELL _cleanup() { if [ -n "${CACHE_DIR:-}" ] && [ -d "$CACHE_DIR" ]; then rm -rf "$CACHE_DIR" fi if [ -n "${WATCH_PID:-}" ]; then kill "$WATCH_PID" 2>/dev/null || true fi if [ -n "${FZF_SOCKET:-}" ] && [ -S "$FZF_SOCKET" ]; then rm -f "$FZF_SOCKET" fi } trap _cleanup EXIT _check_dependencies() { local missing=() for cmd in fzf cliphist wl-copy wl-paste python3; do if ! type "$cmd" &>/dev/null; then missing+=("$cmd") fi done if [ ${#missing[@]} -ne 0 ]; then echo "Error: Missing dependencies: ${missing[*]}" >&2 exit 1 fi for cmd in chafa ffmpegthumbnailer; do if ! type "$cmd" &>/dev/null; then echo "Warning: Optional dependency '$cmd' not found. Some features may be unavailable." >&2 fi done } _check_dependencies CACHE_DIR=$(mktemp -d) export CACHE_DIR export C_TERTIARY='\x1b[1;35m' export C_PRIMARY='\x1b[1;34m' export C_CYAN='\x1b[1;36m' export C_RESET='\x1b[0m' export C_PATTERN='\x1b\[[0-9];?([0-9]+)?m' # Check for terminal graphics support and set environment variables accordingly _graphics_query() { # Port of [graphics-query](https://github.com/Uyanide/dotfiles/blob/main/config/scripts/.local/scripts/graphics-query) # Ensure in a interactive terminal [ ! -t 0 ] && return ( # Construct query KGP_QUERY_ID=$RANDOM KGP_QUERY_CODE=$(printf "\033_Gi=%d,s=1,v=1,a=q,t=d,f=24;AAAA\033\\" "$KGP_QUERY_ID") ITERM2_QUERY_CODE=$(printf "\033]1337;ReportCellSize\a") KGP_EXPECTED_RESPONSE=$(printf "\033_Gi=%d;OK\033\\" "$KGP_QUERY_ID") ITERM2_EXPECTED_RESPONSE=$(printf "\033]1337;") # followed by "ReportCellSize=...", but only the prefix is enough FENCE_CODE=$(printf "\033[c") # Set terminal to raw mode with timeout stty_orig=$(stty -g) trap 'stty "$stty_orig"' EXIT stty -echo -icanon min 1 time 0 printf "%s%s%s" "$ITERM2_QUERY_CODE" "$KGP_QUERY_CODE" "$FENCE_CODE" >/dev/tty support_kgp=0 support_iterm2=0 support_sixel=0 response="" while true; do IFS= read -r -N 1 -t 0.3 char || { [ -z "$char" ] && break } response+="$char" if [[ "$response" == *"$KGP_EXPECTED_RESPONSE"* ]]; then support_kgp=1 fi if [[ "$response" == *"$ITERM2_EXPECTED_RESPONSE"* ]]; then support_iterm2=1 fi if [[ "$response" == *$'\033['*'c' ]]; then break fi if [ ${#response} -gt 1024 ]; then break fi done if [[ "$response" =~ $'\x1b'\[\?([0-9;]*)c ]]; then params="${BASH_REMATCH[1]}" IFS=';' read -ra codes <<<"$params" for code in "${codes[@]}"; do if [[ "$code" == "4" ]]; then support_sixel=1 break fi done fi if [ "$support_kgp" -eq 1 ]; then echo "kitty" fi if [ "$support_iterm2" -eq 1 ]; then echo "iterm" fi if [ "$support_sixel" -eq 1 ]; then echo "sixels" fi ) } SUPPORT_ICAT=0 SUPPORT_SIXEL=0 SUPPORT_ITERM2=0 ENABLE_ICAT=0 ENABLE_SIXEL=0 ENABLE_ITERM2=0 _check_graphics_support() { local result result=$(_graphics_query) if [[ "$result" == *"kitty"* ]]; then SUPPORT_ICAT=1 fi if [[ "$result" == *"sixels"* ]]; then SUPPORT_SIXEL=1 fi if [[ "$result" == *"iterm"* ]]; then SUPPORT_ITERM2=1 fi } _check_kitty_icat() { # # workaround for WezTerm if [ -n "${WEZTERM_EXECUTABLE:-}" ]; then return 1 fi [[ "$SUPPORT_ICAT" -eq 1 ]] } _check_sixel() { # workaround for Zellij if [ -n "${ZELLIJ_SESSION_NAME:-}" ]; then return 1 # same for tmux, unless otherwise configured elif [ -n "${TMUX:-}" ]; then return 1 fi [[ "$SUPPORT_SIXEL" -eq 1 ]] } _check_iterm2() { [[ "$SUPPORT_ITERM2" -eq 1 ]] } # Priority: KGP > sixel > iterm2 _check_graphics_support if _check_kitty_icat; then ENABLE_ICAT=1 elif _check_sixel; then ENABLE_SIXEL=1 elif _check_iterm2; then ENABLE_ITERM2=1 fi export ENABLE_ICAT export ENABLE_SIXEL export ENABLE_ITERM2 # URL handling (for file:// URLs) url_unquote() { if type python3 &>/dev/null; then python3 -c "import sys, urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()), end='')" else cat fi } url_quote() { if type python3 &>/dev/null; then python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()), end='')" else cat fi } export -f url_unquote export -f url_quote # Preview functions _clear_preview() { # chafa --clear will simply send '\x1b[H\x1b[2J' which may not work # for images displayed with KGP, so we send the specific clear sequence # manually in this case. if [ "$ENABLE_ICAT" -eq 1 ]; then printf "\x1b_Ga=d\x1b\\" fi } export -f _clear_preview _preview_image() { local file="$1" if ! type chafa >/dev/null 2>&1; then text="Preview not available (chafa not found)."$'\n' text+="Image: $file" _preview_text "$text" return fi # Though chafa is able to detect which output format to use based on the terminal capabilities, # it is yet not always reliable, so we force it based on our checks. if [ "$ENABLE_ICAT" -eq 1 ]; then chafa -f kitty --size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file" elif [ "$ENABLE_SIXEL" -eq 1 ]; then chafa -f sixels --size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file" elif [ "$ENABLE_ITERM2" -eq 1 ]; then chafa -f iterm2 -size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file" else chafa -f symbols --size="${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}" "$file" fi } export -f _preview_image _preview_text() { local content="$1" printf "%s" "$content" | head -n 100 } export -f _preview_text _preview_video() { local video_hash thumb_file path path="$1" video_hash=$(echo -n "$path" | md5sum | cut -d" " -f1) thumb_file="$CACHE_DIR/$video_hash.png" if [ ! -f "$thumb_file" ]; then if type ffmpegthumbnailer &>/dev/null; then ffmpegthumbnailer -i "$path" -o "$thumb_file" -s 480 -t 0 >/dev/null 2>&1 else _preview_text "Thumbnail not available (ffmpegthumbnailer not found)."$'\n' fi fi if [ -s "$thumb_file" ]; then _preview_image "$thumb_file" else _preview_text "Video: $path" fi } export -f _preview_video # Kinda buggy right now # _preview_audio() { # local path="$1" # if type mpv &>/dev/null; then # echo "Playing audio: $path" >&2 # exec mpv --no-video --keep-open=no --loop-file=no --loop-playlist=no "$path" &>/dev/null # else # _preview_text "Audio: $path" # fi # } # export -f _preview_audio _preview_file() { path="$1" path_mime=$(file -b --mime-type "$path") if [[ $path_mime =~ image ]]; then _preview_image "$path" elif [[ "$path_mime" =~ video ]]; then _preview_video "$path" # elif [[ "$path_mime" =~ audio ]]; then # _preview_audio "$path" else _preview_text "$path" fi } export -f _preview_file preview() { _clear_preview entry="$1" mimeType=$(echo -n "$entry" | cliphist decode | file -b --mime-type -) ext=$(echo -n "$mimeType" | awk -F"/" "{print \$2}") if [[ $mimeType =~ image ]]; then img_hash=$(echo -n "$entry" | cliphist decode | md5sum | cut -d" " -f1) cache_file="$CACHE_DIR/$img_hash.$ext" [ -f "$cache_file" ] || echo -n "$entry" | cliphist decode >"$cache_file" _preview_image "$cache_file" elif path=$(echo -n "$entry" | cliphist decode) && [[ "$path" == /* ]]; then if [ -e "$path" ]; then _preview_file "$path" else _preview_text "$path does not exist." fi elif decoded=$(echo -n "$entry" | cliphist decode) && [[ "$decoded" == file://* ]]; then paths=() for path in $decoded; do raw_path="${path#file://}" raw_path=$(echo -n "$raw_path" | url_unquote) paths+=("$raw_path") done if [ "${#paths[@]}" -eq 1 ] && [ -e "${paths[0]}" ]; then _preview_file "${paths[0]}" else text="Multiple files:"$'\n' for p in "${paths[@]}"; do text+="$p"$'\n' done _preview_text "$text" fi else if [ "$ENABLE_ICAT" = 1 ]; then printf "\x1b_Ga=d\x1b\\" fi _preview_text "$(echo -n "$entry" | cliphist decode)" fi } export -f preview # Optimize entry formatting format_clip_list() { sed -E \ -e "s/(\t).*\.(mp4|mkv|webm|avi|mov|flv|wmv)$/\1${C_TERTIARY}[VIDEO]File.\2${C_RESET}/" \ -e "s/(\t)file:\/\/.*\.(mp4|mkv|webm|avi|mov|flv|wmv)$/\1${C_TERTIARY}[URL]Video.\2${C_RESET}/" \ -e "s/(\t)file:\/\/.*\.gif$/\1${C_PRIMARY}[URL]Image.gif${C_RESET}/" \ -e "s/(\t)file:\/\/.*\.(png|jpg|jpeg|webp|bmp)$/\1${C_TERTIARY}[URL]Image.\2${C_RESET}/" \ -e "s/(\t)file:\/\/.*/\1${C_CYAN}[URL]File${C_RESET}/" \ -e "s/(\t)\/.*\.gif$/\1${C_PRIMARY}[PATH]Image.gif${C_RESET}/" \ -e "s/(\t)\/.*\.(png|jpg|jpeg|webp|bmp)$/\1${C_TERTIARY}[PATH]Image.\2${C_RESET}/" \ -e "s/\[\[ binary data .* (png|jpg|jpeg|gif|webp) .*\]\]/${C_TERTIARY}[IMG]Bin.\1${C_RESET}/" \ -e "s/\[\[ binary data .* \]\]/${C_CYAN}[BINARY]${C_RESET}/" } export -f format_clip_list add_num() { awk -F '\t' '{printf "%s\t\x1b[90m%-2d \x1b[0m%s\n", $1, NR, $2}' } export -f add_num # Action when confirmed copy_selection() { local input="$1" local content content=$(echo -n "$input" | awk "{print \$3}") if [[ "$content" == "[URL]"* ]]; then echo -n "$input" | cliphist decode | wl-copy --type text/uri-list else echo -n "$input" | cliphist decode | wl-copy fi echo -n "$input" | cliphist delete || true } export -f copy_selection # Reload mechanism RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp}" FZF_SOCKET=$(mktemp -u "$RUNTIME_DIR/fzfclip.XXXXXX.sock") RELOAD_CMD="cliphist list | format_clip_list | add_num" wl-paste --watch bash -c "curl -s --unix-socket '$FZF_SOCKET' -X POST -d 'reload($RELOAD_CMD)' http://localhost" &>/dev/null & WATCH_PID=$! # Ensure terminal is large enough wait_timeout=50 while [[ $(tput cols) -lt 35 || $(tput lines) -lt 25 ]]; do printf "\rWaiting for terminal size at least 35x25... %d" "$wait_timeout" sleep 1 ((wait_timeout--)) [ "$wait_timeout" -eq 0 ] && exit 1 done $RELOAD_CMD | fzf \ --ansi \ --listen "$FZF_SOCKET" \ --bind "ctrl-r:reload($RELOAD_CMD)" \ --bind "ctrl-x:execute-silent(bash -c 'cliphist delete <<< \"\$1\"' -- {})+reload($RELOAD_CMD)" \ --prompt="󰅍 > " \ --header='CTRL-X: Delete | CTRL-R: Reload | ENTER: Paste' \ --color='header:italic:yellow,prompt:blue,pointer:blue' \ --info=hidden \ --no-sort \ --layout=reverse \ --with-nth 2.. \ --delimiter '\t' \ --preview-window=down:60%,wrap \ --preview "preview {}" \ --bind "enter:execute-silent(bash -c 'copy_selection \"\$1\"' -- {})+accept" \ >/dev/null || [ $? -eq 141 ]