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
3 changes: 3 additions & 0 deletions .chezmoiignore.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions Brewfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
61 changes: 55 additions & 6 deletions docs/agent-live-systems.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
13 changes: 7 additions & 6 deletions dot_agents/rules/live-interaction-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
77 changes: 50 additions & 27 deletions dot_config/nvim/lua/custom/plugins/markdown.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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,
},

{
Expand Down
94 changes: 94 additions & 0 deletions dot_local/bin/executable_paste-image-agent.sh
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions dot_local/bin/executable_wl-paste
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading