diff --git a/.chezmoiignore.tmpl b/.chezmoiignore.tmpl index 5c48616..1e757a3 100644 --- a/.chezmoiignore.tmpl +++ b/.chezmoiignore.tmpl @@ -40,4 +40,7 @@ OneDrive/Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1 {{- if and (eq .chezmoi.os "linux") (ne (env "WSL_DISTRO_NAME") "") }} # WSL uses Windows OpenSSH config via core.sshCommand. .ssh/config +{{- else }} +# WSL-only wl-paste shim for Windows clipboard images in agent Ctrl+V. +dot_local/bin/executable_wl-paste {{- end }} diff --git a/AGENTS.md b/AGENTS.md index ad118fd..aef69d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,11 @@ do not disrupt operator sessions while editing dotfiles. full policy: minimum bar when touching `dot_tmux.conf*`, shell rc, or `windows/terminal/settings.json`: -- never run `tmux kill-server` or `kill-session` to "validate" config. +- never run `tmux kill-server` or `kill-session` on the **default** socket to + "validate" config. +- for tmux runtime experiments, use `-L agent-debug-…` and `kill-server` on that + socket when done; if you must use the default socket, kill **only** your own + debug session before ending the task (see `docs/agent-live-systems.md`). - render templates with `chezmoi execute-template` before apply. - do not `chezmoi apply --force` interactive files unless the operator asked to apply; prefer dry-run first. diff --git a/Brewfile b/Brewfile index c1eca77..0b9ba63 100644 --- a/Brewfile +++ b/Brewfile @@ -209,6 +209,8 @@ brew "newrelic-cli" brew "node" # Swiss-army knife of markup format conversion brew "pandoc" +# Save clipboard images to files (agent CLI paste-image-agent on macOS) +brew "pngpaste" # Shell command parallelization utility brew "parallel" # PDF rendering library (based on the xpdf-3.0 code base) diff --git a/docs/agent-live-systems.md b/docs/agent-live-systems.md index 555476c..abe2a1d 100644 --- a/docs/agent-live-systems.md +++ b/docs/agent-live-systems.md @@ -24,8 +24,49 @@ chezmoi execute-template < dot_tmux.conf.tmpl > /tmp/tmux.conf.check tmux -f /tmp/tmux.conf.check start-server \; display-message "config ok" 2>&1 ``` -Do **not** append `kill-server`. A second `start-server` against an existing -socket is harmless; killing the server ends every session. +Do **not** append `kill-server` on the **default** socket. A second +`start-server` against an existing socket is harmless; killing the default +server ends every operator session. + +### tmux experiments and debug hygiene + +When testing graphics, passthrough, keybindings, or other runtime behavior, do +**not** leave detached sessions on the operator's default tmux server. + +**Prefer an isolated socket** so teardown cannot touch live work: + +```bash +SOCK="agent-debug-$$" +CONF=/tmp/tmux.conf.check # or a minimal scratch config + +tmux -L "$SOCK" -f "$CONF" new-session -d -s probe 'sleep 300' +# … run diagnostics against -L "$SOCK" … +tmux -L "$SOCK" kill-server +``` + +`kill-server` on `-L agent-debug-…` is safe: it only stops that temporary +server. + +If you must use the **default** socket (e.g. to mirror the operator's live +`allow-passthrough` / terminal overrides), create a clearly named debug session +and **always** remove it before ending the task—even on failure: + +```bash +DBG=sixel-probe-$$ +cleanup() { tmux kill-session -t "$DBG" 2>/dev/null || true; } +trap cleanup EXIT +tmux kill-session -t "$DBG" 2>/dev/null || true +tmux new-session -d -s "$DBG" +# … work … +cleanup +``` + +Use a unique `$DBG` name (PID suffix, timestamp). Never reuse generic names like +`sixel-diag`, `wraptest`, or `wraprun` on the default socket; they are easy to +orphan across agent turns. + +Before closing a task that touched tmux at runtime, run `tmux list-sessions` and +confirm no agent-owned debug names remain. ### Unsafe (require explicit operator request) @@ -77,13 +118,21 @@ then let the operator apply or explicitly delegate apply + reload. ## Windows Terminal `windows/terminal/settings.json` is a tracked backup/export, not always the live -file. Changing Sixel or keybindings requires merge into live WT settings and a -**full WT restart** (all windows). Agents should describe that step, not assume -`chezmoi apply` updates the running terminal. +file. Enabling Sixel (`experimental.sixelSupport` in profile defaults) requires +merge into live WT settings and a **full WT restart** (all windows). Agents +should describe that step, not assume `chezmoi apply` updates the running +terminal. + +Tracked defaults **do not** rebind Ctrl+V; text paste stays on Ctrl+V. Agent +clipboard images use tmux `prefix + p` (`paste-image-agent.sh` in +`dot_tmux.conf.tmpl`), not WT keybindings. ## Checklist before closing a dotfiles task - [ ] Rendered config parses (`tmux -f …`, `bash -n`, JSON valid for WT) -- [ ] No `kill-server` / `pkill` used during validation +- [ ] No `kill-server` / `pkill` on the **default** tmux socket during + validation +- [ ] Agent-owned tmux debug sessions removed (`tmux list-sessions`; prefer `-L` + scratch sockets killed with `tmux -L … kill-server`) - [ ] Operator told if reload, new tab, or WT restart is needed - [ ] Template whitespace checked around `{{ if }}` / variable assignments diff --git a/dot_agents/rules/live-interaction-safety.md b/dot_agents/rules/live-interaction-safety.md index b7c60df..1cb073f 100644 --- a/dot_agents/rules/live-interaction-safety.md +++ b/dot_agents/rules/live-interaction-safety.md @@ -23,12 +23,13 @@ actively working. ## Allowed verification patterns -| Goal | Prefer | Avoid | -| ------------------------ | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | -| Syntax-check tmux config | Render to a temp file; `tmux -f /tmp/tmux.conf start-server \; display-message ok` **without** `kill-server` | `kill-server` as part of "validation" | -| Syntax-check shell | `bash -n`, `zsh -n`, `shellcheck` | `source ~/.bashrc` in the agent's shell to "test" | -| Chezmoi template | `chezmoi execute-template < file.tmpl`; `chezmoi apply --dry-run --verbose` | `chezmoi apply --force` on live interactive files while operator is in tmux | -| Reload after fix | Tell operator to run `prefix + r` or open a **new** terminal tab | Reloading from an agent mid-task without warning | +| Goal | Prefer | Avoid | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| Syntax-check tmux config | Render to a temp file; `tmux -f /tmp/tmux.conf start-server \; display-message ok` **without** `kill-server` on the default socket | `kill-server` on the default socket as part of "validation" | +| tmux runtime experiments | `tmux -L agent-debug-…` scratch server; `tmux -L … kill-server` when done. On the default socket: unique debug session name + `trap`/`kill-session` for **only** that name before task end | Detached `tmux new-session -d -s sixel-diag` (etc.) left on the operator's server | +| Syntax-check shell | `bash -n`, `zsh -n`, `shellcheck` | `source ~/.bashrc` in the agent's shell to "test" | +| Chezmoi template | `chezmoi execute-template < file.tmpl`; `chezmoi apply --dry-run --verbose` | `chezmoi apply --force` on live interactive files while operator is in tmux | +| Reload after fix | Tell operator to run `prefix + r` or open a **new** terminal tab | Reloading from an agent mid-task without warning | ## When changing interactive config diff --git a/dot_config/nvim/lua/custom/plugins/markdown.lua b/dot_config/nvim/lua/custom/plugins/markdown.lua index 2bb8265..d82c255 100644 --- a/dot_config/nvim/lua/custom/plugins/markdown.lua +++ b/dot_config/nvim/lua/custom/plugins/markdown.lua @@ -12,19 +12,17 @@ end local function is_macos() return (vim.uv or vim.loop).os_uname().sysname == 'Darwin' end -local function terminal_graphics_supported() +local function kitty_graphics_terminal() local term = (vim.env.TERM or ''):lower() local term_program = (vim.env.TERM_PROGRAM or ''):lower() - return vim.env.NVIM_IMAGE_SUPPORT == '1' - or vim.env.KITTY_WINDOW_ID ~= nil + return vim.env.KITTY_WINDOW_ID ~= nil or vim.env.WEZTERM_PANE ~= nil or term:find('kitty', 1, true) ~= nil or term:find('ghostty', 1, true) ~= nil or term:find('wezterm', 1, true) ~= nil or term_program:find('ghostty', 1, true) ~= nil or term_program:find('wezterm', 1, true) ~= nil - or (is_macos() and vim.env.TMUX ~= nil) or ( vim.env.TMUX ~= nil and vim.fn.executable 'tmux' == 1 @@ -35,6 +33,53 @@ local function terminal_graphics_supported() ) end +local function sixel_graphics_terminal() return is_wsl() and vim.env.WT_SESSION ~= nil end + +---@return 'kitty'|'sixel'|nil +local function graphics_backend() + if vim.env.NVIM_IMAGE_BACKEND == 'kitty' or vim.env.NVIM_IMAGE_BACKEND == 'sixel' then return vim.env.NVIM_IMAGE_BACKEND end + if sixel_graphics_terminal() then return 'sixel' end + if kitty_graphics_terminal() then return 'kitty' end + if is_macos() and vim.env.TMUX ~= nil then return 'kitty' end + return nil +end + +local function terminal_graphics_supported() return vim.env.NVIM_IMAGE_SUPPORT == '1' or graphics_backend() ~= nil end + +local function image_nvim_opts() + local backend = graphics_backend() + if backend == nil then backend = is_wsl() and 'sixel' or 'kitty' end + -- Sixel popup mode opens a float (WinNew) that races image.nvim's global + -- schedule_all_window_render clear/repaint cycle: image flashes, then :mode + -- wipes pixels. Inline at cursor avoids the float and cleans up on leave. + local cursor_image_mode = backend == 'sixel' and 'inline' or 'popup' + + return { + backend = backend, + processor = 'magick_cli', + integrations = { + markdown = { + enabled = true, + clear_in_insert_mode = false, + download_remote_images = true, + only_render_image_at_cursor = true, + only_render_image_at_cursor_mode = cursor_image_mode, + filetypes = { 'markdown' }, + }, + asciidoc = { enabled = false }, + neorg = { enabled = false }, + rst = { enabled = false }, + typst = { enabled = false }, + html = { enabled = false }, + css = { enabled = false }, + }, + max_height_window_percentage = 45, + -- Sixel uses async :mode clears; tmux focus toggles race with repaint. + tmux_show_only_in_active_window = vim.env.TMUX ~= nil and backend ~= 'sixel', + hijack_file_patterns = { '*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.avif' }, + } +end + local function configure_markdown_preview_browser() if not is_wsl() then return end @@ -132,29 +177,7 @@ return { cond = terminal_graphics_supported, event = 'VeryLazy', build = false, - opts = { - backend = 'kitty', - processor = 'magick_cli', - integrations = { - markdown = { - enabled = true, - clear_in_insert_mode = false, - download_remote_images = true, - only_render_image_at_cursor = true, - only_render_image_at_cursor_mode = 'popup', - filetypes = { 'markdown' }, - }, - asciidoc = { enabled = false }, - neorg = { enabled = false }, - rst = { enabled = false }, - typst = { enabled = false }, - html = { enabled = false }, - css = { enabled = false }, - }, - max_height_window_percentage = 45, - tmux_show_only_in_active_window = vim.env.TMUX ~= nil, - hijack_file_patterns = { '*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.avif' }, - }, + opts = image_nvim_opts, }, { diff --git a/dot_local/bin/executable_paste-image-agent.sh b/dot_local/bin/executable_paste-image-agent.sh new file mode 100644 index 0000000..9b0bdcb --- /dev/null +++ b/dot_local/bin/executable_paste-image-agent.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Attach a clipboard image to an agent CLI prompt (Cursor, Claude Code) via @path. +# +# Minimal tmux integration: prefix + p runs this with --inject (see dot_tmux.conf). +# Native Ctrl+V / Alt+V in agent still preferred when the terminal passes the key. +# +# WSL: optional ~/.local/bin/wl-paste shim so Cursor agent Ctrl+V can read the +# Windows clipboard when the terminal forwards the key. +# +# Usage: +# paste-image-agent.sh --inject [pane_id] # tmux: inject @path into agent prompt +# paste-image-agent.sh --print-attachment # print @path to stdout + +set -euo pipefail + +lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +. "$lib_dir/lib/clipboard-image.sh" + +mode=print +target_pane="" + +while [ $# -gt 0 ]; do + case "$1" in + --inject) + mode=inject + shift + [ $# -gt 0 ] || break + target_pane="$1" + shift + ;; + --print-attachment | --print) + mode=print + shift + ;; + -h | --help) + sed -n '2,14p' "$0" + exit 0 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + exit 2 + ;; + esac +done + +notify() { + if [ -n "${TMUX:-}" ]; then + tmux display-message "$1" + else + printf '%s\n' "$1" >&2 + fi +} + +tmpdir="" +cleanup() { + if [ -n "$tmpdir" ]; then + rm -rf "$tmpdir" + fi +} +trap cleanup EXIT + +tmpdir="$(mktemp -d)" +image_path="$tmpdir/clipboard.png" + +if ! clipboard_image_fetch_to_file "$image_path"; then + notify 'clipboard has no image' + exit 1 +fi + +attach_path="$(mktemp "${TMPDIR:-/tmp}/agent-clipboard-XXXXXX.png")" +cp "$image_path" "$attach_path" +trap - EXIT +cleanup + +attachment="@${attach_path}" + +case "$mode" in + print) + printf '%s\n' "$attachment" + ;; + inject) + if [ -z "$target_pane" ]; then + target_pane="${TMUX_PANE:-}" + fi + if [ -z "$target_pane" ] || ! tmux display-message -p -t "$target_pane" '#{pane_id}' >/dev/null 2>&1; then + printf '%s\n' "$attachment" + printf 'No tmux pane to inject into; paste the @path above into agent.\n' >&2 + exit 1 + fi + tmux send-keys -l -t "$target_pane" "$attachment " + notify "attached image" + ;; +esac diff --git a/dot_local/bin/executable_wl-paste b/dot_local/bin/executable_wl-paste new file mode 100644 index 0000000..04dd337 --- /dev/null +++ b/dot_local/bin/executable_wl-paste @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# WSL shim: satisfy Cursor agent CLI image paste (Ctrl+V) via Windows clipboard. +# On native Linux with wl-paste installed, delegates to the real binary. + +set -euo pipefail + +lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +. "$lib_dir/lib/clipboard-image.sh" + +real_wl_paste="" +for candidate in /usr/bin/wl-paste /usr/local/bin/wl-paste; do + if [ -x "$candidate" ] && [ "$candidate" != "$lib_dir/wl-paste" ]; then + real_wl_paste="$candidate" + break + fi +done + +delegate_real_wl_paste() { + if [ -z "$real_wl_paste" ]; then + printf 'wl-paste: command not found\n' >&2 + return 127 + fi + exec "$real_wl_paste" "$@" +} + +if ! clipboard_image_wsl_bridge_available; then + delegate_real_wl_paste "$@" +fi + +case "${1:-}" in + --list-types) + if clipboard_image_has_image; then + printf 'image/png\n' + fi + if [ -n "$real_wl_paste" ]; then + "$real_wl_paste" --list-types 2>/dev/null || true + fi + exit 0 + ;; + --type) + mime="${2:?missing mime type}" + case "$mime" in + image/png | image/jpeg | image/gif | image/webp) + clipboard_image_emit_windows_as_mime "$mime" + exit 0 + ;; + *) + if [ -n "$real_wl_paste" ]; then + exec "$real_wl_paste" "$@" + fi + printf 'wl-paste: no image for type %s\n' "$mime" >&2 + exit 1 + ;; + esac + ;; + -h | --help) + cat <<'EOF' +wl-paste (WSL Windows clipboard bridge) + +On WSL, image/* types are read from the Windows clipboard for Cursor agent Ctrl+V. +Other requests delegate to /usr/bin/wl-paste when installed. +EOF + exit 0 + ;; + *) + delegate_real_wl_paste "$@" + ;; +esac diff --git a/dot_local/bin/lib/clipboard-image.sh b/dot_local/bin/lib/clipboard-image.sh new file mode 100644 index 0000000..2427a61 --- /dev/null +++ b/dot_local/bin/lib/clipboard-image.sh @@ -0,0 +1,268 @@ +# shellcheck shell=bash +# Clipboard image helpers for agent CLI paste (paste-image-agent, WSL wl-paste shim). +# Platform backends: WSL/Windows, macOS, Linux (wl-paste / xclip). + +clipboard_image_resolve_powershell() { + if command -v powershell.exe >/dev/null 2>&1; then + command -v powershell.exe + return 0 + fi + + local candidate + for candidate in \ + /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe \ + /mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/powershell.exe; do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +clipboard_image_wsl_bridge_available() { + [ -n "${WSL_DISTRO_NAME:-}" ] && clipboard_image_resolve_powershell >/dev/null 2>&1 +} + +clipboard_image_fetch_windows_to_file() { + local dest="$1" + + local powershell ps_script win_script base64_out status + powershell="$(clipboard_image_resolve_powershell)" || return 1 + command -v wslpath >/dev/null 2>&1 || return 1 + command -v base64 >/dev/null 2>&1 || return 1 + + ps_script="$(mktemp --suffix=.ps1)" + cat >"$ps_script" <<'EOF' +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +function Get-ClipboardImage { + $img = [System.Windows.Forms.Clipboard]::GetImage() + if ($null -ne $img) { return $img } + + $obj = [System.Windows.Forms.Clipboard]::GetDataObject() + if ($null -eq $obj) { return $null } + + $formats = @( + 'PNG', + 'image/png', + [System.Windows.Forms.DataFormats]::Bitmap, + [System.Windows.Forms.DataFormats]::Dib, + 'DeviceIndependentBitmap' + ) + foreach ($fmt in $formats) { + if (-not $obj.GetDataPresent($fmt)) { continue } + $data = $obj.GetData($fmt) + if ($null -eq $data) { continue } + if ($data -is [System.Drawing.Image] -or $data -is [System.Drawing.Bitmap]) { + return $data + } + if ($data -is [byte[]] -and $data.Length -gt 0) { + $ms = New-Object System.IO.MemoryStream(,$data) + try { + return [System.Drawing.Image]::FromStream($ms) + } catch { + continue + } finally { + $ms.Dispose() + } + } + if ($data -is [System.IO.MemoryStream]) { + try { + return [System.Drawing.Image]::FromStream($data) + } catch { + continue + } + } + } + + return $null +} + +$img = Get-ClipboardImage +if ($null -eq $img) { exit 1 } + +$ms = New-Object System.IO.MemoryStream +try { + $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + [Console]::Out.Write([Convert]::ToBase64String($ms.ToArray())) +} finally { + $ms.Dispose() + $img.Dispose() +} +EOF + + win_script="$(wslpath -w "$ps_script")" + base64_out="$("$powershell" -NoProfile -STA -ExecutionPolicy Bypass -File "$win_script" 2>/dev/null || true)" + status=$? + rm -f "$ps_script" + + if [ "$status" -ne 0 ] || [ -z "$base64_out" ]; then + return 1 + fi + + if ! printf '%s' "$base64_out" | base64 -d >"$dest" 2>/dev/null; then + return 1 + fi + + [ -s "$dest" ] +} + +clipboard_image_fetch_darwin_to_file() { + local dest="$1" + + if command -v pngpaste >/dev/null 2>&1; then + pngpaste "$dest" 2>/dev/null && [ -s "$dest" ] + return + fi + + command -v osascript >/dev/null 2>&1 || return 1 + osascript - "$dest" <<'APPLESCRIPT' +on run argv + set dest to POSIX path of (item 1 of argv) + try + set pngData to (the clipboard as «class PNGf») + on error + error "clipboard has no image" + end try + set fileRef to open for access dest with write permission + try + write pngData to fileRef + finally + close access fileRef + end try +end run +APPLESCRIPT +} + +clipboard_image_fetch_wayland_to_file() { + local dest="$1" + + command -v wl-paste >/dev/null 2>&1 || return 1 + + local types + types="$(wl-paste --list-types 2>/dev/null || true)" + + if printf '%s\n' "$types" | grep -qx 'image/png'; then + wl-paste --type image/png >"$dest" + [ -s "$dest" ] + return + fi + + if printf '%s\n' "$types" | grep -qx 'image/jpeg'; then + wl-paste --type image/jpeg >"$dest" + [ -s "$dest" ] + return + fi + + if printf '%s\n' "$types" | grep -qx 'image/bmp'; then + if command -v magick >/dev/null 2>&1; then + wl-paste --type image/bmp | magick - "$dest" + [ -s "$dest" ] + return + fi + if command -v convert >/dev/null 2>&1; then + wl-paste --type image/bmp | convert - "$dest" + [ -s "$dest" ] + return + fi + fi + + return 1 +} + +clipboard_image_fetch_xclip_to_file() { + local dest="$1" + + command -v xclip >/dev/null 2>&1 || return 1 + xclip -selection clipboard -t image/png -o >"$dest" 2>/dev/null && [ -s "$dest" ] +} + +clipboard_image_fetch_to_file() { + local dest="$1" + + if clipboard_image_wsl_bridge_available; then + clipboard_image_fetch_windows_to_file "$dest" + return + fi + + case "$(uname -s)" in + Darwin) + clipboard_image_fetch_darwin_to_file "$dest" + ;; + Linux) + clipboard_image_fetch_wayland_to_file "$dest" || + clipboard_image_fetch_xclip_to_file "$dest" + ;; + *) + return 1 + ;; + esac +} + +clipboard_image_emit_windows_as_mime() { + local mime="${1:-image/png}" + local tmp_png + + tmp_png="$(mktemp --suffix=.png)" + if ! clipboard_image_fetch_windows_to_file "$tmp_png"; then + rm -f "$tmp_png" + return 1 + fi + + case "$mime" in + image/png) + cat "$tmp_png" + ;; + image/jpeg) + if command -v magick >/dev/null 2>&1; then + magick "$tmp_png" jpeg:- + elif command -v convert >/dev/null 2>&1; then + convert "$tmp_png" jpeg:- + else + rm -f "$tmp_png" + return 1 + fi + ;; + image/gif) + if command -v magick >/dev/null 2>&1; then + magick "$tmp_png" gif:- + elif command -v convert >/dev/null 2>&1; then + convert "$tmp_png" gif:- + else + rm -f "$tmp_png" + return 1 + fi + ;; + image/webp) + if command -v magick >/dev/null 2>&1; then + magick "$tmp_png" webp:- + elif command -v convert >/dev/null 2>&1; then + convert "$tmp_png" webp:- + else + rm -f "$tmp_png" + return 1 + fi + ;; + *) + rm -f "$tmp_png" + return 1 + ;; + esac + + rm -f "$tmp_png" +} + +clipboard_image_has_image() { + local tmp + tmp="$(mktemp --suffix=.png)" + if clipboard_image_fetch_to_file "$tmp"; then + rm -f "$tmp" + return 0 + fi + rm -f "$tmp" + return 1 +} diff --git a/dot_tmux.conf.tmpl b/dot_tmux.conf.tmpl index 93d9292..68e9dd3 100644 --- a/dot_tmux.conf.tmpl +++ b/dot_tmux.conf.tmpl @@ -5,7 +5,7 @@ set-option -g history-limit 100000 set-option -g mouse on set-option -g focus-events on # Let terminal graphics protocols pass through tmux for tools like image.nvim. -set-option -g allow-passthrough on +set-option -g allow-passthrough all set-option -g visual-activity off set-option -g renumber-windows on set-option -g set-clipboard on @@ -18,6 +18,8 @@ set-option -sg escape-time 0 # Terminal defaults with truecolor support. set-option -g default-terminal "tmux-256color" set-option -ga terminal-overrides ",xterm-256color:Tc" +set-option -as terminal-overrides ',xterm-256color:RGB,TC,Sxl' +set-option -as terminal-overrides ',tmux-256color:RGB,TC,Sxl' # Helps Meta/arrow sequences in xterm-compatible terminals (iTerm2, Kitty, Ghostty). # Enables Option-based bindings below when the GUI app sends Esc-prefixed keys. @@ -136,6 +138,9 @@ set-option -g @resurrect-processes "false" # Install plugins with: prefix + I run "~/.tmux/plugins/tpm/tpm" + +# Paste clipboard image into agent prompt (prefix + p). Injects @path via run-shell. +bind-key p run-shell "paste-image-agent.sh --inject '#{pane_id}'" {{ if stat $tmuxr }} # work agent workspace (tmux-tmuxr). Build: cd ~/dev/projects/tmuxr/work && npm run build diff --git a/linux/apt-packages.txt b/linux/apt-packages.txt index 73a7f97..8c35c23 100644 --- a/linux/apt-packages.txt +++ b/linux/apt-packages.txt @@ -34,6 +34,7 @@ grep gzip highlight hostname +imagemagick htop init inotify-tools @@ -49,6 +50,7 @@ libncurses-dev libodbc2 libopengl-dev libssl-dev +libsixel-bin locales login mawk diff --git a/qa.just b/qa.just index 3a7cf6b..9658cd8 100644 --- a/qa.just +++ b/qa.just @@ -6,10 +6,10 @@ default: # Lint shell scripts and test files. lint-shell: @set -euo pipefail; \ - fmt_files=("{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ + fmt_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ while IFS= read -r _file; do fmt_files+=("$_file"); done < <(find "{{ justfile_directory() }}/tests/shell" -type f \( -name '*.sh' -o -name '*.bash' -o -name '*.bats' \) 2>/dev/null | sort); \ mise exec --locked -- shfmt -d -i 2 -ci "${fmt_files[@]}"; \ - sc_files=("{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ + sc_files=("{{ justfile_directory() }}/dot_local/bin/executable_paste-image-agent.sh" "{{ justfile_directory() }}/dot_local/bin/executable_wl-paste" "{{ justfile_directory() }}/dot_local/bin/lib/clipboard-image.sh" "{{ justfile_directory() }}/scripts/bookmark-pr-hygiene.sh" "{{ justfile_directory() }}/scripts/repos-smoke.sh"); \ while IFS= read -r _file; do sc_files+=("$_file"); done < <(find "{{ justfile_directory() }}/tests/shell" -type f \( -name '*.sh' -o -name '*.bash' \) 2>/dev/null | sort); \ mise exec --locked -- shellcheck "${sc_files[@]}" diff --git a/tests/nvim/spec/custom_plugins_markdown_spec.lua b/tests/nvim/spec/custom_plugins_markdown_spec.lua index ec324eb..0c0e1c9 100644 --- a/tests/nvim/spec/custom_plugins_markdown_spec.lua +++ b/tests/nvim/spec/custom_plugins_markdown_spec.lua @@ -9,6 +9,11 @@ local function spec_by_plugin(specs, plugin) end end +local function image_opts(spec) + if type(spec.opts) == 'function' then return spec.opts() end + return spec.opts +end + local function with_os_release(release, fn) local original_os_uname = vim.uv.os_uname vim.uv.os_uname = function() return { release = release } end @@ -287,14 +292,16 @@ describe('custom.plugins.markdown', function() TERM = 'xterm-256color', TERM_PROGRAM = 'ghostty', WEZTERM_PANE = '', + WT_SESSION = '', }, function() local image = spec_by_plugin(reload_markdown_specs(), '3rd/image.nvim') asserts.truthy(image) asserts.equals(true, image.cond()) - asserts.equals('kitty', image.opts.backend) - asserts.equals('magick_cli', image.opts.processor) - asserts.equals(true, image.opts.integrations.markdown.only_render_image_at_cursor) + asserts.equals('kitty', image_opts(image).backend) + asserts.equals('magick_cli', image_opts(image).processor) + asserts.equals(true, image_opts(image).integrations.markdown.only_render_image_at_cursor) + asserts.equals('popup', image_opts(image).integrations.markdown.only_render_image_at_cursor_mode) end) end) end) @@ -311,6 +318,7 @@ describe('custom.plugins.markdown', function() TERM_PROGRAM = 'tmux', TMUX = '/private/tmp/tmux-501/default,123,0', WEZTERM_PANE = '', + WT_SESSION = '', }, function() local image = spec_by_plugin(reload_markdown_specs(), '3rd/image.nvim') @@ -330,10 +338,35 @@ describe('custom.plugins.markdown', function() TERM_PROGRAM = 'tmux', TMUX = '', WEZTERM_PANE = '', + WT_SESSION = '', + }, function() + local image = spec_by_plugin(reload_markdown_specs(), '3rd/image.nvim') + + asserts.equals(true, image.cond()) + asserts.equals('sixel', image_opts(image).backend) + asserts.equals('inline', image_opts(image).integrations.markdown.only_render_image_at_cursor_mode) + end) + end) + end) + + it('enables image.nvim under WSL Windows Terminal with sixel backend', function() + with_os_release('6.18.33.1-microsoft-standard-WSL2', function() + with_env({ + KITTY_WINDOW_ID = '', + NVIM_IMAGE_SUPPORT = '', + TERM = 'tmux-256color', + TERM_PROGRAM = 'tmux', + TMUX = '/tmp/tmux-501/default,123,0', + WEZTERM_PANE = '', + WT_SESSION = '4c8f0c3e-1234-5678-9abc-def012345678', }, function() local image = spec_by_plugin(reload_markdown_specs(), '3rd/image.nvim') + local opts = image_opts(image) asserts.equals(true, image.cond()) + asserts.equals('sixel', opts.backend) + asserts.equals('inline', opts.integrations.markdown.only_render_image_at_cursor_mode) + asserts.equals(false, opts.tmux_show_only_in_active_window) end) end) end) @@ -346,10 +379,12 @@ describe('custom.plugins.markdown', function() TERM = 'xterm-kitty', TERM_PROGRAM = 'ghostty', WEZTERM_PANE = '', + WT_SESSION = '', }, function() local image = spec_by_plugin(reload_markdown_specs(), '3rd/image.nvim') asserts.equals(true, image.cond()) + asserts.equals('kitty', image_opts(image).backend) end) end) end) @@ -363,6 +398,7 @@ describe('custom.plugins.markdown', function() TERM_PROGRAM = 'tmux', TMUX = '', WEZTERM_PANE = '', + WT_SESSION = '', }, function() local image = spec_by_plugin(reload_markdown_specs(), '3rd/image.nvim') diff --git a/windows/terminal/settings.json b/windows/terminal/settings.json index af9c136..1302eb8 100644 --- a/windows/terminal/settings.json +++ b/windows/terminal/settings.json @@ -54,6 +54,7 @@ ], "profiles": { "defaults": { + "experimental.sixelSupport": true, "font": { "face": "MesloLGM Nerd Font, Noto Sans Symbols 2", "size": 12