feat(multi): tmux keys to toggle broadcast and exclude single panes

In windows opened by netssh multi, prefix S toggles broadcast for all
panes and prefix e excludes the current pane (a pane-level
synchronize-panes off, shown as [SOLO] in the border) or includes it
again. netssh sync --pane does the same from a shell.

S and e are unbound in a default tmux (E would be "spread evenly").
netssh writes the bindings to a small tmux config and sources it, binds
a key only if the user's tmux.conf does not use it (netssh's own earlier
binding is refreshed), and the bindings act only in windows marked
@netssh. multi.sync_key and multi.solo_key change the keys or turn them
off (none); also in the configurator. zellij syncs whole tabs only, so
excluding a pane is tmux-only.

testenv: the render test presses the keys through the xterm client
(send-keys -K) and checks [SOLO], that broadcast skips the excluded
pane, that S toggles, and that other windows are unaffected. The client
gets GNU grep: BusyBox grep -oF counted only one match per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-14 13:32:32 +01:00
1 parent 699b9469b6
commit 396d489187
14 files changed
+342 -31

No files matched your search

+5 -1
View File
@@ -90,7 +90,11 @@ xterm on a virtual X server (Xvfb) — takes screenshots, and reads them back
with OCR (tesseract). It checks what is actually drawn: remote prompts in all
panes, borders and titles with the host names, `[SYNC]` appearing and
disappearing, panes side by side, broadcast output in every pane, input in one
pane only without broadcast, the status bar, and the TUI marks.
pane only without broadcast, the status bar, and the TUI marks. The broadcast
keys are pressed as a user would: `tmux send-keys -K` delivers them to the
xterm client, so they pass through the prefix and key tables (`prefix e` →
`[SOLO]` and the pane drops out of the broadcast; `prefix S` toggles all panes;
other windows stay unaffected).
```sh
testenv/test.sh render # then look at the screenshots:
+1 -1
View File
@@ -14,7 +14,7 @@ FROM alpine:3.23
# xvfb/xterm/imagemagick/tesseract: render panes in a real terminal emulator,
# take screenshots, and read them back with OCR (tests/16-render.sh).
RUN apk add --no-cache openssh-client-default tmux zellij bash zsh fish bash-completion \
curl python3 jq ncurses coreutils \
curl python3 jq ncurses coreutils grep \
xvfb xterm imagemagick tesseract-ocr tesseract-ocr-data-eng font-dejavu \
&& adduser -D -s /bin/bash tester
COPY --from=build /out/netssh /usr/local/bin/netssh
+28 -1
View File
@@ -32,13 +32,34 @@ visible "tmux: borders show host and [SYNC]" tmux-1 "$p1 [SYNC]" "$p2 [SYNC]" "$
side_by_side tmux-1 "$p1" "$p2" && ok "tmux: tiled, first two panes side by side" || bad "tmux: tiled, first two panes side by side" "$(screen_text tmux-1)"
tmux send-keys -t netssh-pve "echo typed-once-\$(hostname)" Enter
visible "tmux: broadcast output in every pane" tmux-2 "typed-once-fsn1-pve1" "typed-once-fsn1-pve2" "typed-once-fsn1-pve3"
tmux set-window-option -t netssh-pve synchronize-panes off
# Keys as typed in xterm (send-keys -K goes through the client's prefix and
# key tables): prefix e excludes the active pane, prefix S toggles all panes.
client=$(tmux list-clients -t netssh-pve -F '#{client_name}' | head -1)
press() { tmux send-keys -K -c "$client" C-b "$1"; sleep 0.5; }
panes=($(tmux list-panes -t netssh-pve -F '#{pane_id}'))
tmux select-pane -t "${panes[2]}"
press e
visible "tmux: prefix e marks the pane [SOLO]" tmux-solo "$p3 [SOLO]" "$p1 [SYNC]" "$p2 [SYNC]"
tmux send-keys -t "${panes[2]}" "echo solo-only" Enter
tmux send-keys -t "${panes[0]}" "echo two-panes" Enter
sleep 1
shot tmux-solo-2
[ "$(screen_count tmux-solo-2 "solo-only")" = 2 ] && ok "tmux: the excluded pane types alone" || bad "tmux: the excluded pane types alone" "$(screen_text tmux-solo-2)"
[ "$(screen_count tmux-solo-2 "two-panes")" = 4 ] && ok "tmux: broadcast skips the excluded pane" || bad "tmux: broadcast skips the excluded pane" "$(screen_text tmux-solo-2)"
press e
visible "tmux: prefix e again includes it" tmux-solo-3 "$p3 [SYNC]"
press S
sync_is() { [ "$(tmux show-window-options -t netssh-pve -v synchronize-panes 2>/dev/null)" = "$1" ]; }
wait_for 5 sync_is off && ok "tmux: prefix S turns broadcast off" || bad "tmux: prefix S turns broadcast off" "$(tmux show-window-options -t netssh-pve)"
tmux send-keys -t netssh-pve "echo just-this-pane" Enter
sleep 1
shot tmux-3
[ "$(screen_count tmux-3 "just-this-pane")" = 2 ] && ok "tmux: without broadcast only one pane gets input" || bad "tmux: without broadcast only one pane gets input" "$(screen_text tmux-3)"
shows_sync() { screen_shows tmux-3 "[SYNC]"; }
shows_sync && bad "tmux: [SYNC] disappears when broadcast is off" "$(screen_text tmux-3)" || ok "tmux: [SYNC] disappears when broadcast is off"
press S
wait_for 5 sync_is on && ok "tmux: prefix S turns it on again" || bad "tmux: prefix S turns it on again" "$(tmux show-window-options -t netssh-pve)"
tmux kill-server
# --- more hosts than panes_per_window: further windows ---
@@ -64,6 +85,12 @@ zellij kill-all-sessions --yes >/dev/null 2>&1
# --- TUI: mark hosts, open them together ---
term tmux new-session -s tui netssh
visible "TUI: search screen" tui-1 "Search hostname"
# The netssh keys do nothing outside netssh multi windows.
tmux source-file "$XDG_CACHE_HOME/netssh/netssh-multi.tmux.conf"
client=$(tmux list-clients -t tui -F '#{client_name}' | head -1)
tmux send-keys -K -c "$client" C-b S
sleep 0.5
[ "$(tmux show-window-options -t tui -v synchronize-panes 2>/dev/null)" != on ] && ok "tmux: prefix S leaves other windows alone" || bad "tmux: prefix S leaves other windows alone" "synchronize-panes turned on in a non-netssh window"
tmux send-keys -t tui "pve"
visible "TUI: results listed" tui-2 "$p1" "$p2" "$p3"
tmux send-keys -t tui C-a
+4 -4
View File
@@ -124,10 +124,10 @@ screen_text() {
for i in "${!OCR_VARIANTS[@]}"; do ocr_variant "$1" "$i"; done
}
# ocr_norm maps characters OCR confuses in DejaVu Sans Mono (1/l/I/|, C/( , 0/O)
# to one form; expected text gets the same mapping, so comparisons stay strict
# about everything else.
ocr_norm() { tr 'lI|(O' '111C0'; }
# ocr_norm maps characters OCR confuses in DejaVu Sans Mono (1/l/I/|, C/( , 0/O,
# typographic dashes for "-") to one form; expected text gets the same mapping,
# so comparisons stay strict about everything else.
ocr_norm() { sed 's/[‐‑‒–—―−]/-/g' | tr 'lI|(O' '111C0'; }
# screen_shows NAME TEXT... — take a screenshot and check that OCR finds every TEXT.
screen_shows() {