Files
branderson 123c68dbae Fix multi-line paste corruption in nested tmux on 3.5/3.5a
Pasting multi-line text into a tmux session reached over ssh from inside
another tmux replaced every newline with a literal CSI 27;5;106~, i.e.
C-j -- the pasted newline -- rendered in modifyOtherKeys form rather than
sent as a newline.

tmux 3.5 rewrote extended-key handling and encodes keys inside a
bracketed paste; the layer further in passes those bytes through verbatim
instead of decoding them, so the application receives the escape as text.
A hop only corrupts when whatever sits downstream of it has asked for
modifyOtherKeys mode 2. A nested tmux always asks, because of the
"*:extkeys" terminal-feature above it; an application running directly
under screen-256color generally does not. That is why one tmux in the
chain was fine and two were not.

Confirmed by building 3.2a, 3.3a, 3.4, 3.5a, 3.6b and 3.7c and driving a
real bracketed paste through a pty into every outer/inner combination:
only 3.5 and 3.5a corrupt, and only on the requesting hop. Fixed upstream
in 3.6, so upgrading is the real answer where it is available.

For hosts pinned to a distro tmux, disable extended keys on exactly those
two versions. Nothing narrower works: extended-keys-format csi-u only
changes which escape lands in the text, and withholding the extkeys
terminal-feature stops a tmux asking its own parent for extended keys
without stopping it encoding them for its pane. With the guard in place
all sixteen version combinations paste correctly, and extended keys stay
on everywhere except 3.5/3.5a.

The guard tests the version with an awk END block rather than a bare
exit, so that empty input -- a tmux -V that failed for any reason --
leaves extended keys on instead of silently disabling them.

Also corrects the comment that attributed this to a Claude Code paste
tokenizer bug. It reproduces just as readily pasting into neovim.
2026-08-17 14:20:42 -07:00

199 lines
11 KiB
Bash

# ----------------------------------------------------------------------------
# Prefix
# ----------------------------------------------------------------------------
set -g prefix C-a
unbind C-b
bind C-a send-prefix
bind Tab last-window
# ----------------------------------------------------------------------------
# General behavior
# ----------------------------------------------------------------------------
set-option -g set-titles on
set -g default-terminal "screen-256color"
set -g history-limit 10000
set -sg escape-time 1
set-option -g base-index 1
setw -g pane-base-index 1
setw -g aggressive-resize on
# ----------------------------------------------------------------------------
# Windows and panes
# ----------------------------------------------------------------------------
bind T swap-window -t 1
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
# Smart pane switching with awareness of vim splits
is_vim='echo "#{pane_current_command}" | grep -iqE "(^|\/)g?(view|n?vim?)(diff)?$"'
# Claude Code also wants its own use of C-j (insert newline), same as vim
# wants C-h/j/k/l for its own window navigation -- forward it raw instead of
# letting tmux swallow it for pane-switching.
is_vim_or_claude='echo "#{pane_current_command}" | grep -iqE "(^|\/)(g?(view|n?vim?)(diff)?|claude)$"'
bind -n C-h if-shell "$is_vim" "send-keys C-h" "select-pane -L"
bind -n C-j if-shell "$is_vim_or_claude" "send-keys C-j" "select-pane -D"
bind -n C-k if-shell "$is_vim" "send-keys C-k" "select-pane -U"
bind -n C-l if-shell "$is_vim" "send-keys C-l" "select-pane -R"
bind -n 'C-\' if-shell "$is_vim" "send-keys 'C-\\'" "select-pane -l"
bind -r C-h select-window -t :-
bind -r C-l select-window -t :+
bind a setw synchronize-panes
# Fuzzy pickers over every session/window/pane, each with a live preview of
# what it's running (tmux-fzf). prefix+F opens the plugin's own top-level
# menu; these skip straight to the panels used most -- pane.sh is where
# break/join/swap live, i.e. moving a pane into another window without
# hand-typing a target the way join-pane needs.
#
# TMUX_FZF_CLIENT pins the action to the client that opened it, exactly as
# the plugin's own binding does: a command run via run-shell has no client
# context of its own, so tmux otherwise falls back to the most recently
# active client -- the wrong terminal whenever a second client (e.g. a
# browser one over ttyd) is attached to the same session.
bind P run-shell -b "TMUX_FZF_CLIENT='#{client_tty}' ~/.tmux/plugins/tmux-fzf/scripts/pane.sh"
bind W run-shell -b "TMUX_FZF_CLIENT='#{client_tty}' ~/.tmux/plugins/tmux-fzf/scripts/window.sh"
bind S run-shell -b "TMUX_FZF_CLIENT='#{client_tty}' ~/.tmux/plugins/tmux-fzf/scripts/session.sh"
# ----------------------------------------------------------------------------
# Copy mode and mouse
# ----------------------------------------------------------------------------
setw -g mode-keys vi
set-option -g -q mouse on
bind y run 'tmux save-buffer - | $HOME/dotfiles/bin/clipboard-copy'
bind C-y run 'tmux save-buffer - | $HOME/dotfiles/bin/clipboard-copy'
# Let escape sequences a pane explicitly DCS-wraps for passthrough (e.g.
# sixel image data, see the iTerm2 terminal-features line below) reach the
# real terminal, even when nested behind ssh/tmux layers. Not used for the
# clipboard -- that has its own relay via the pane-set-clipboard hook above.
# allow-passthrough was added in tmux 3.3; older tmux doesn't understand the
# option, so only set it when the running tmux is new enough.
if-shell "tmux -V | awk '{print $2}' | awk -F. '{if ($1*1000+$2 >= 3003) exit 0; else exit 1}'" 'set -g allow-passthrough on'
# Let tmux tell Shift+Enter apart from plain Enter and forward that
# distinction inward, so Claude Code's Shift+Enter-inserts-newline shortcut
# works. extended-keys/terminal-features need tmux >= 3.2. The terminal
# name is matched with '*' rather than the commonly-suggested 'xterm*',
# because a nested tmux's "terminal" is the outer tmux, which reports
# itself using default-terminal above (screen-256color) -- 'xterm*' would
# never match that, breaking the nested case this is meant to support.
#
# Not the cause of multi-line paste arriving with literal CSI 27;5;106~ in
# place of each newline (that being C-j, the pasted newline, in
# modifyOtherKeys form). That is a tmux 3.5/3.5a bug: 3.5 rewrote extended-key
# handling and encodes keys inside a bracketed paste, which the layer further
# in then passes through verbatim rather than decoding. Reproduced against
# 3.2a/3.3a/3.4/3.5a/3.6b/3.7c -- only 3.5 and 3.5a corrupt, and only on a hop
# whose downstream has asked for modifyOtherKeys mode 2, which a nested tmux
# always does and a bare application under screen-256color generally doesn't.
# Hence it shows up with tmux at both ends of the ssh and not with one.
# Fixed upstream in 3.6, so upgrade the offending layer where that's possible.
if-shell "tmux -V | awk '{print $2}' | awk -F. '{if ($1*1000+$2 >= 3002) exit 0; else exit 1}'" 'set -s extended-keys on; set -as terminal-features "*:extkeys"'
# Where it isn't -- a host pinned to a distro tmux -- give the paste back on
# just those two versions. Nothing narrower helps: csi-u format only changes
# which escape lands in the text, and withholding the extkeys feature above
# stops this tmux asking its own parent for extended keys without stopping it
# encoding them for the pane. Turning them off is the whole of the workaround,
# and confining it to 3.5/3.5a keeps them everywhere else.
if-shell "tmux -V | awk '{hit = ($2 ~ /^3[.]5/)} END {exit hit ? 0 : 1}'" 'set -s extended-keys off'
# Accept pane-originated OSC 52 clipboard-set attempts into a real tmux
# buffer. Only on-vs-off matters here -- off would refuse to even look at
# these escapes; "on" (rather than "external") is needed so the pane-set-
# clipboard hook below has an actual buffer to read.
set -g set-clipboard on
# tmux's own mechanism for forwarding an accepted clipboard-set (or its own
# copy-mode copies) to the real terminal is generating a fresh OSC 52 write
# via the "Ms" terminfo capability -- confirmed unreliable here (both
# pane-forwarded and tmux-generated OSC 52 silently failed to reach the
# clipboard even with Ms correctly registered against Alacritty). Relay it
# ourselves instead: whenever any pane sets the clipboard, grab what was
# just captured above and write it straight to this client's own terminal
# device. If that's a real terminal, this is the final hop and we're done.
# If it's actually another tmux one layer further out (e.g. this tmux is
# itself running inside an outer tmux over ssh), that data becomes pane
# output there, and the *same* hook -- present there too, since it's the
# same dotfiles -- fires again. That recurses cleanly through however many
# tmux layers are actually in the chain, without any of them needing to
# know the total count in advance.
set-hook -g pane-set-clipboard 'run-shell "tmux save-buffer - | $HOME/dotfiles/bin/tmux-clipboard-relay #{client_tty}"'
# The default/no-argument form of copy-pipe (used by double/triple-click and
# a couple of default bindings) pipes to this option instead of a literal
# command -- route it through the same working script as the explicit
# copy-pipe bindings in tmux/modern.conf.
set -g copy-command '$HOME/dotfiles/bin/clipboard-copy'
# iTerm2 supports SIXEL images (since 3.3.0) but doesn't set a distinct TERM
# to identify itself, so this matches on its usual "xterm-256color" report --
# any other client reporting that same TERM will also be treated as
# sixel-capable. Alacritty is deliberately left off: upstream Alacritty has
# no sixel support, so telling tmux otherwise would replace today's clean
# text placeholder with garbled escape data.
set -as terminal-features ",xterm-256color:sixel"
# tmux renamed the copy-mode key table (vi-copy -> copy-mode-vi) and changed
# bind-key's table flag (-t -> -T) in 2.4. Source the file matching the
# running tmux so old tmux never parses syntax it doesn't understand.
if-shell "tmux -V | awk '{print $2}' | awk -F. '{if ($1*1000+$2 < 2004) exit 0; else exit 1}'" 'source-file ~/.tmux/legacy.conf' 'source-file ~/.tmux/modern.conf'
# ----------------------------------------------------------------------------
# Misc bindings
# ----------------------------------------------------------------------------
bind-key C run-shell "cal $(date +%Y)"
bind r source-file ~/.tmux.conf \; display "Reloaded config"
# A pane whose connection died mid-redraw (e.g. an SSH session killed by the
# laptop sleeping) can get stuck with tmux thinking the cursor is hidden,
# since that's tracked from the pane's own output and nothing ever sent the
# matching show-cursor sequence. Re-run it in the active pane to fix it.
bind R send-keys "printf '\\033[?25h'" Enter
# ----------------------------------------------------------------------------
# Powerline (guarded so machines without it don't error on load)
# ----------------------------------------------------------------------------
run-shell "command -v powerline-daemon >/dev/null 2>&1 && powerline-daemon -q; true"
run-shell "command -v powerline-config >/dev/null 2>&1 && powerline-config tmux setup; true"
# ----------------------------------------------------------------------------
# Status bar
# ----------------------------------------------------------------------------
# Flash the status bar when this client's prefix key is armed, so nested
# tmux sessions (e.g. laptop -> ssh -> devbox tmux) make it obvious which
# layer is about to receive the next keystroke. Falls back to whatever
# status-style powerline set above (captured here) the rest of the time.
# Colors match gruvbox's own visual/selection convention (bg0 on orange).
set -g @status_style_armed "bg=#fe8019,fg=#282828"
run-shell 'tmux set-option -g @status_style_normal "$(tmux show-options -gv status-style)"'
set -g status-style "#{?client_prefix,#{@status_style_armed},#{@status_style_normal}}"
# ----------------------------------------------------------------------------
# Plugins (TPM)
# ----------------------------------------------------------------------------
set -g @plugin 'tmux-plugins/tpm'
set -g @plugin 'tmux-plugins/tmux-sensible'
# Checked out as a submodule under config/tmux/plugins rather than left for
# TPM to clone on first prefix+I: ~/.tmux is a symlink to that directory, so
# a TPM-installed plugin lands inside this repo as untracked files. The
# submodule pins a version, installs with the existing "git submodule
# update --init" in install.sh, and keeps git status clean. TPM still sources
# it from the @plugin line below and can still update it.
set -g @plugin 'sainnhe/tmux-fzf'
# Local overrides
if-shell "[ -f ~/.tmux_local.conf ]" 'source ~/.tmux_local.conf'
# Initialize TPM (must be the last line)
run '~/.tmux/plugins/tpm/tpm'