From c49dfdf496af9a0d835c37b2e9404b4284644c7e Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Tue, 1 Sep 2026 20:51:20 +0100 Subject: [PATCH 01/38] Regress test for cursor updates. --- regress/sync-output-cursor.sh | 160 ++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 regress/sync-output-cursor.sh diff --git a/regress/sync-output-cursor.sh b/regress/sync-output-cursor.sh new file mode 100644 index 000000000..e805f373f --- /dev/null +++ b/regress/sync-output-cursor.sh @@ -0,0 +1,160 @@ +#!/bin/sh + +# A synchronized update elsewhere in the pane must not hide a stationary +# cursor while tmux waits for the end of the update. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) + +if [ "$#" -eq 0 ]; then + TEST_TMUX="$TEST_TMUX" sh "$0" pane || exit 1 + TEST_TMUX="$TEST_TMUX" sh "$0" prompt || exit 1 + exit 0 +fi +case $1 in +pane|prompt) MODE=$1 ;; +*) echo "usage: $0 [pane|prompt]" >&2; exit 1 ;; +esac + +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR + +INNER="$TEST_TMUX -Li$$ -f/dev/null" +OUTER="$TEST_TMUX -Lo$$ -f/dev/null" +CLIENT_BYTES=$DIR/client-bytes +CONTROL=$DIR/control +EMITTER=$DIR/emitter.pl +ASSERT=$DIR/assert-cursor.pl + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + $INNER list-clients -F '#{client_termfeatures}' 2>/dev/null | + grep -q 'sync' && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "sync-capable client did not attach" +} + +cat >"$EMITTER" <<'PERL' +use strict; +use warnings; + +my $control = $ENV{CONTROL}; +(my $dir = $control) =~ s{/[^/]+$}{}; + +# Draw a prompt and leave its cursor stationary on the last line. +syswrite STDOUT, "\e[2J\e[HWorking .\e[24;1H> "; +open my $ready, '>', "$dir/ready" or die "$dir/ready: $!\n"; +close $ready; +while (!-e $control) { + select undef, undef, undef, 0.01; +} + +# Deliberately split BSU, content, and ESU across pane reads. This mirrors an +# animation producer which begins a frame before it has finished rendering it. +for my $spinner (qw(| / - \\)) { + syswrite STDOUT, "\e[?2026h"; + select undef, undef, undef, 0.05; + syswrite STDOUT, "\e[1;9H$spinner\e[24;3H"; + select undef, undef, undef, 0.01; + syswrite STDOUT, "\e[?2026l"; + select undef, undef, undef, 0.15; +} +select undef, undef, undef, 5; +PERL + +cat >"$ASSERT" <<'PERL' +use strict; +use warnings; + +my $path = shift; +open my $fh, '<:raw', $path or die "$path: $!\n"; +local $/; +my $bytes = <$fh>; +my ($bsu, $esu, $hide, $show) = + ("\e[?2026h", "\e[?2026l", "\e[?25l", "\e[?25h"); +my ($sync, $outside, $hides, $shows) = (0, 0, 0, 0); + +for (my $i = 0; $i < length($bytes);) { + if (substr($bytes, $i, length($bsu)) eq $bsu) { + $sync++; + $i += length($bsu); + next; + } + if (substr($bytes, $i, length($esu)) eq $esu) { + $sync-- if $sync; + $i += length($esu); + next; + } + if (substr($bytes, $i, length($hide)) eq $hide) { + $hides++; + $outside++ unless $sync; + $i += length($hide); + next; + } + if (substr($bytes, $i, length($show)) eq $show) { + $shows++; + $outside++ unless $sync; + $i += length($show); + next; + } + $i++; +} + +printf "%s cursor hides: %d; cursor shows: %d; changes outside sync: %d\n", + $ENV{MODE}, $hides, $shows, $outside; +die "stationary cursor changed outside synchronized output\n" if $outside; +PERL + +$INNER new-session -d -s inner -x 80 -y 24 \ + "CONTROL='$CONTROL' perl '$EMITTER'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -as terminal-features '*:sync' || exit 1 + +$OUTER new-session -d -s outer -x 80 -y 24 \ + "$TEST_TMUX -Li$$ -f/dev/null attach-session -t inner" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +wait_for_client + +if [ "$MODE" = prompt ]; then + $OUTER send-keys -t outer:0.0 C-b : || exit 1 + sleep 0.2 +fi + +i=0 +while [ "$i" -lt 50 ] && [ ! -e "$DIR/ready" ]; do + sleep 0.1 + i=$((i + 1)) +done +[ -e "$DIR/ready" ] || fail "application emitter did not become ready" + +$OUTER pipe-pane -O -t outer:0.0 "cat >'$CLIENT_BYTES'" || exit 1 +: >"$CONTROL" +sleep 2 +$OUTER pipe-pane -t outer:0.0 || exit 1 + +MODE=$MODE perl "$ASSERT" "$CLIENT_BYTES" From 60e871658fbac9f823bee16811e15bf51ea6c210 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Tue, 1 Sep 2026 20:52:44 +0100 Subject: [PATCH 02/38] Remove +x. --- regress/cfg-client-lost.sh | 0 regress/control-client-exit-stalled.sh | 0 regress/control-notify-guard.sh | 0 regress/copy-mode-unicode-whitespace.sh | 0 regress/mode-kill.sh | 0 regress/server-socket-error.sh | 0 regress/set-hook-E.sh | 0 regress/tab-cell-background.sh | 0 regress/theme-report.sh | 0 9 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 regress/cfg-client-lost.sh mode change 100755 => 100644 regress/control-client-exit-stalled.sh mode change 100755 => 100644 regress/control-notify-guard.sh mode change 100755 => 100644 regress/copy-mode-unicode-whitespace.sh mode change 100755 => 100644 regress/mode-kill.sh mode change 100755 => 100644 regress/server-socket-error.sh mode change 100755 => 100644 regress/set-hook-E.sh mode change 100755 => 100644 regress/tab-cell-background.sh mode change 100755 => 100644 regress/theme-report.sh diff --git a/regress/cfg-client-lost.sh b/regress/cfg-client-lost.sh old mode 100755 new mode 100644 diff --git a/regress/control-client-exit-stalled.sh b/regress/control-client-exit-stalled.sh old mode 100755 new mode 100644 diff --git a/regress/control-notify-guard.sh b/regress/control-notify-guard.sh old mode 100755 new mode 100644 diff --git a/regress/copy-mode-unicode-whitespace.sh b/regress/copy-mode-unicode-whitespace.sh old mode 100755 new mode 100644 diff --git a/regress/mode-kill.sh b/regress/mode-kill.sh old mode 100755 new mode 100644 diff --git a/regress/server-socket-error.sh b/regress/server-socket-error.sh old mode 100755 new mode 100644 diff --git a/regress/set-hook-E.sh b/regress/set-hook-E.sh old mode 100755 new mode 100644 diff --git a/regress/tab-cell-background.sh b/regress/tab-cell-background.sh old mode 100755 new mode 100644 diff --git a/regress/theme-report.sh b/regress/theme-report.sh old mode 100755 new mode 100644 From 3dd575ce551eadb2bbea794bd15c181ca9caafb0 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Tue, 1 Sep 2026 21:48:30 +0100 Subject: [PATCH 03/38] Update CHANGES. --- CHANGES | 49 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/CHANGES b/CHANGES index 093e29fc9..a447de2c3 100644 --- a/CHANGES +++ b/CHANGES @@ -9,7 +9,12 @@ CHANGES FROM 3.7c TO 3.8 - modal panes may be created with new-pane -O; a window can have one modal pane and it prevents interaction with the other panes while it is active. - A modal pane is now used for the editor in buffer mode; + -C closes the pane when the mouse is clicked outside it and -K sends all + keys, including the prefix key, to the pane. A modal pane is now used for + the editor in buffer mode; + + - new-pane -A creates a floating pane which remains above a zoomed pane; + modal panes do this by default; - move-pane and resize-pane can now move and resize floating panes, including mouse dragging; @@ -24,6 +29,9 @@ CHANGES FROM 3.7c TO 3.8 -k for choose commands kills the pane when the mode exits, which is useful for modes in floating panes; + - command-prompt -P opens a prompt inside a pane rather than on the status + line; + - pane-border-status has top-floating and bottom-floating, and there are new default bindings under C-b g for common move and resize operations; @@ -74,7 +82,7 @@ CHANGES FROM 3.7c TO 3.8 - new hooks to cover areas including client create and destroy, pane activity, pane mode and prompt changes, pane movement and resizing, session - group changes and window creation, close and zoom; + group changes and window creation, close, swap and zoom; - OSC 133 escape sequences now trigger events: pane-command-started, pane-command-finished and pane-shell-prompt. @@ -87,10 +95,23 @@ CHANGES FROM 3.7c TO 3.8 while scrolling or while hovered and disappears after pane-scrollbars-timeout (Michael Grant). -* Extend the fill-character option so both inside and outside the window can be - changed. +* Add a clear-on-attach server option. When disabled, tmux does not enter the + alternate screen on attach and instead scrolls the existing terminal content + into the scrollback buffer so it remains accessible (issue 5508). -* Change set-option and set-hooks to use formats and add a -F flag to each. +* Copy mode can now automatically refresh as pane content changes with the + refresh-on, refresh-off and refresh-toggle commands. Automatic refresh is off + by default; refresh-now refreshes once and is bound to r. These replace + refresh-from-pane (issue 5165). + +* Extend the fill-character option so both inside and outside the window can be + changed, with a new default for unused areas inside the window. + +* Change show-options and show-hooks to use formats and add a -F flag to each. + +* Allow individual terminal features to be disabled by adding @ to their names + in terminal-features, and add a utf8 feature for terminals which support + UTF-8 output. * Add a #{A/count:frames} modifier to show a series of frames as an animation in the status line (issue 5412 from Fernando Daciuk). @@ -105,6 +126,8 @@ CHANGES FROM 3.7c TO 3.8 * Add new-window -E, respawn-pane -E and respawn-window -E as more convenient methods to create an empty pane (rather than using '' for the command). +* Add a default C-b T binding to change the current pane title. + * Menus now belong to the window, so appear on all clients. * Dragging over an existing copy mode selection now adjusts it (Michael Grant). @@ -118,6 +141,13 @@ CHANGES FROM 3.7c TO 3.8 * Copy mode no longer exits at the bottom while a selection is in progress (issue 5349). +* Add copy-mode-current-line-style to set the style of the line containing the + cursor in copy mode (issue 5391). + +* Copy mode word commands now recognise all Unicode whitespace characters; a + space in word-separators matches any Unicode whitespace character (issue + 5562). + * Add style attributes for dimming colours (dim=) and for hyperlinks (link= and nolink) (issues 4842 and 4280 from Moritz Angermann). @@ -128,7 +158,9 @@ CHANGES FROM 3.7c TO 3.8 * Add or improve format variables including client_colours, pane_start_command_list, window_manual_width, window_manual_height, - pane_last_output_time, pane_modal_flag and window_modal_pane. + pane_last_output_time, pane_modal_flag, window_modal_pane, pane_private_modes, + pane_output_generation, history_added, history_collected, history_generation, + hook_fire_count and hook_fire_time. * Add additional pane sort orders, and a z sort order for choose-tree so floating panes can be sorted by z-index. @@ -145,7 +177,10 @@ CHANGES FROM 3.7c TO 3.8 * Fix control mode clients hanging on exit if pty data was still queued (Ben Maurer, issue 5356), and avoid sending notifications to clients which are - already exiting (Ben Maurer, issue 5357). + already exiting (Ben Maurer, issue 5357). Queue notifications so they are not + sent inside %begin/%end (issue 5458), do not let a stuck client prevent the + server from exiting (issue 5444), and reset control mode offsets when a pane + is respawned (issue 5498). * Fix grouped sessions sometimes being left as unusable command targets while they are being killed (Bryce Miller, issue 5180). From 2d6aed1d92d22864472b7958c68f3013c81ea050 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Tue, 1 Sep 2026 21:52:47 +0100 Subject: [PATCH 04/38] Add some more tests for recent stuff. --- regress/clear-on-attach.sh | 90 +++++++++++++++++ regress/client-key-order-focus.sh | 82 ++++++++++++++++ regress/control-client-popup.sh | 63 ++++++++++++ regress/copy-mode-selection-mode.sh | 81 ++++++++++++++++ regress/format-variables.sh | 18 ++++ regress/screen-redraw-scrollbars-auto-hide.sh | 96 +++++++++++++++++++ regress/terminal-feature-utf8.sh | 58 +++++++++++ 7 files changed, 488 insertions(+) create mode 100644 regress/clear-on-attach.sh create mode 100644 regress/client-key-order-focus.sh create mode 100644 regress/control-client-popup.sh create mode 100644 regress/copy-mode-selection-mode.sh create mode 100644 regress/screen-redraw-scrollbars-auto-hide.sh create mode 100644 regress/terminal-feature-utf8.sh diff --git a/regress/clear-on-attach.sh b/regress/clear-on-attach.sh new file mode 100644 index 000000000..89c8ebbbe --- /dev/null +++ b/regress/clear-on-attach.sh @@ -0,0 +1,90 @@ +#!/bin/sh + +# With clear-on-attach disabled tmux must preserve the terminal contents by +# scrolling them away, rather than entering and clearing the alternate screen. +# Capture the bytes written by a real attached client for both settings. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER_ON="$TEST_TMUX -LtestIon$$ -f/dev/null" +OUTER_ON="$TEST_TMUX -LtestOon$$ -f/dev/null" +INNER_OFF="$TEST_TMUX -LtestIoff$$ -f/dev/null" +OUTER_OFF="$TEST_TMUX -LtestOoff$$ -f/dev/null" +DIR=$(mktemp -d) || exit 1 + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER_ON kill-server 2>/dev/null + $INNER_ON kill-server 2>/dev/null + $OUTER_OFF kill-server 2>/dev/null + $INNER_OFF kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + $current_inner list-clients 2>/dev/null | grep -q . && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +capture_attach() +{ + setting=$1 + output=$2 + go=$DIR/go-$setting + if [ "$setting" = on ]; then + current_inner=$INNER_ON + current_outer=$OUTER_ON + else + current_inner=$INNER_OFF + current_outer=$OUTER_OFF + fi + + $current_inner new-session -d -s inner -x40 -y10 \ + 'exec sleep 100' || exit 1 + $current_inner set-option -g status off || exit 1 + $current_inner set-option -g clear-on-attach "$setting" || exit 1 + $current_outer new-session -d -s outer -x40 -y10 \ + "while [ ! -e '$go' ]; do sleep 0.01; done; exec $current_inner attach-session -t inner" || exit 1 + $current_outer set-option -g status off || exit 1 + $current_outer pipe-pane -O "cat >'$output'" || exit 1 + touch "$go" + wait_for_client + sleep 0.5 + $current_outer pipe-pane -O || exit 1 +} + +on=$DIR/on +off=$DIR/off +capture_attach on "$on" +capture_attach off "$off" + +smcup=$(printf '\033[?1049h') +grep -Fq "$smcup" "$on" || fail "clear-on-attach on did not use smcup" +if grep -Fq "$smcup" "$off"; then + fail "clear-on-attach off used smcup" +fi + +# screen has the indn capability (CSI Ps S), which the preserving path uses +# after setting the scrolling region and moving to its last line. +escape=$(printf '\033') +LC_ALL=C grep -Eq "${escape}\\[[0-9]+S" "$off" || + fail "clear-on-attach off did not scroll the old contents away" + +exit 0 diff --git a/regress/client-key-order-focus.sh b/regress/client-key-order-focus.sh new file mode 100644 index 000000000..4b0c27eb5 --- /dev/null +++ b/regress/client-key-order-focus.sh @@ -0,0 +1,82 @@ +#!/bin/sh + +# send-keys -K injects a key into a client. Commands produced by that key must +# be inserted immediately after send-keys, before later commands in the same +# command list. Focus events arriving between a prefix and its following key +# must be forwarded without cancelling the prefix table. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 1 15 + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + client=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + [ -n "$client" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +wait_for_option() +{ + option=$1 + expected=$2 + i=0 + while [ "$i" -lt 50 ]; do + actual=$($INNER show-option -gv "$option" 2>/dev/null) + [ "$actual" = "$expected" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "$option is '$actual', expected '$expected'" +} + +cleanup +$INNER new-session -d -s inner -x40 -y10 'exec sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g focus-events on || exit 1 +$OUTER new-session -d -s outer -x40 -y10 "$INNER attach -t inner" || exit 1 +$OUTER set-option -g status off || exit 1 +wait_for_client + +# The binding generated by send-keys -K must run before the command which +# follows send-keys in this command list. Before the queue-ordering fix this +# produces AB rather than BA. +$INNER set-option -g @order '' || exit 1 +$INNER bind-key -n a set-option -gF @order '#{@order}B' || exit 1 +$INNER send-keys -K -c "$client" a \; \ + set-option -gF @order '#{@order}A' || exit 1 +wait_for_option @order BA + +# Put the real attached client in the prefix table, deliver FocusIn, then the +# bound key. FocusIn is not bindable and must not reset the prefix table. +$INNER bind-key x set-option -g @focus-prefix yes || exit 1 +$OUTER send-keys C-b || exit 1 +focus_in=$(printf '\033[I') +$OUTER send-keys -l "$focus_in" || exit 1 +$OUTER send-keys x || exit 1 +wait_for_option @focus-prefix yes + +exit 0 diff --git a/regress/control-client-popup.sh b/regress/control-client-popup.sh new file mode 100644 index 000000000..8a98d34e9 --- /dev/null +++ b/regress/control-client-popup.sh @@ -0,0 +1,63 @@ +#!/bin/sh + +# Popups require a tty overlay and cannot be displayed by a control client. +# A popup command from control mode must be ignored cleanly, leaving the +# client command queue and server usable. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +DIR=$(mktemp -d) || exit 1 +FIFO=$DIR/input +OUT=$DIR/output +RAN=$DIR/popup-ran + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + exec 3>&- + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +mkfifo "$FIFO" || exit 1 +$TMUX new-session -d -s control -x40 -y10 'exec sleep 100' || exit 1 +$TMUX -C attach-session -t control <"$FIFO" >"$OUT" 2>&1 & +control_pid=$! +exec 3>"$FIFO" + +i=0 +while [ "$i" -lt 50 ]; do + $TMUX list-clients -F '#{client_flags}' 2>/dev/null | + grep -q 'control-mode' && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "control client did not attach" + +printf '%s\n' "display-popup -E 'touch $RAN'" >&3 +printf '%s\n' "display-message -p CONTROL-POPUP-ALIVE" >&3 + +i=0 +while [ "$i" -lt 50 ]; do + grep -q 'CONTROL-POPUP-ALIVE' "$OUT" 2>/dev/null && break + if ! kill -0 "$control_pid" 2>/dev/null; then + fail "control client exited after display-popup" + fi + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "command after control-client popup did not run" +$TMUX has-session -t control || fail "server exited after control-client popup" +[ ! -e "$RAN" ] || fail "control client started a popup command" + +exit 0 diff --git a/regress/copy-mode-selection-mode.sh b/regress/copy-mode-selection-mode.sh new file mode 100644 index 000000000..bcd070d59 --- /dev/null +++ b/regress/copy-mode-selection-mode.sh @@ -0,0 +1,81 @@ +#!/bin/sh + +# Changing an existing character selection to line mode must reset both ends +# to complete lines, including when the cursor subsequently crosses the fixed +# end. An invalid regular-expression search must also discard marks left by a +# previous successful search. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null +} +trap cleanup 0 1 15 + +$TMUX new-session -d -x30 -y6 \ + "printf 'alpha\nbeta\ngamma\ndelta\n'; exec sleep 100" || exit 1 +$TMUX set-option -g window-size manual || exit 1 +sleep 1 + +# A valid regular-expression search creates marks. Replacing it with an +# invalid expression, growing the mode, and scrolling must not leave an old +# mark array indexed with the new geometry (the original bug was an ASan heap +# overflow in this sequence). +$TMUX copy-mode || exit 1 +$TMUX send-keys -X history-top || exit 1 +$TMUX send-keys -X search-forward beta || exit 1 +[ "$($TMUX display-message -p '#{search_present}')" = 1 ] || + fail "successful search did not create marks" +$TMUX send-keys -X search-forward '[' || exit 1 +$TMUX resize-window -x40 -y8 || exit 1 +$TMUX send-keys -X scroll-down || exit 1 +[ "$($TMUX display-message -p '#{search_present}')" = 0 ] || + fail "invalid regular expression left stale marks after resize" +$TMUX send-keys -X cancel || exit 1 + +# Select part of alpha through part of beta, then change the existing +# selection to line mode. Both lines, and no partial columns, must be copied. +$TMUX copy-mode || exit 1 +$TMUX send-keys -X history-top || exit 1 +$TMUX send-keys -N2 -X cursor-right || exit 1 +$TMUX send-keys -X begin-selection || exit 1 +$TMUX send-keys -X cursor-down || exit 1 +$TMUX send-keys -N2 -X cursor-right || exit 1 +$TMUX send-keys -X selection-mode line || exit 1 +[ "$($TMUX display-message -p '#{selection_mode}')" = line ] || + fail "selection did not change to line mode" +$TMUX send-keys -X copy-selection || exit 1 +expected=$(printf 'alpha\nbeta') +[ "$($TMUX show-buffer)" = "$expected" ] || + fail "line-mode selection did not expand to complete lines" + +# Start with the cursor above the fixed end so selection-mode itself takes the +# reverse-direction path, then extend it through another line. +$TMUX copy-mode || exit 1 +$TMUX send-keys -X history-top || exit 1 +$TMUX send-keys -N2 -X cursor-down || exit 1 +$TMUX send-keys -N2 -X cursor-right || exit 1 +$TMUX send-keys -X begin-selection || exit 1 +$TMUX send-keys -X cursor-up || exit 1 +$TMUX send-keys -N2 -X cursor-right || exit 1 +$TMUX send-keys -X selection-mode line || exit 1 +$TMUX send-keys -X cursor-up || exit 1 +$TMUX send-keys -X copy-selection || exit 1 +expected=$(printf 'alpha\nbeta\ngamma') +[ "$($TMUX show-buffer)" = "$expected" ] || + fail "reversed line-mode selection did not keep complete lines" + +exit 0 diff --git a/regress/format-variables.sh b/regress/format-variables.sh index 449712e34..6908ad956 100644 --- a/regress/format-variables.sh +++ b/regress/format-variables.sh @@ -71,7 +71,10 @@ cursor_very_visible cursor_x cursor_y history_all_bytes +history_added history_bytes +history_collected +history_generation history_limit history_size host @@ -103,6 +106,11 @@ pane_at_right pane_at_top pane_bg pane_bottom +pane_command_duration +pane_command_end_time +pane_command_running +pane_command_start_time +pane_command_status pane_current_command pane_current_path pane_dead @@ -120,16 +128,21 @@ pane_index pane_input_off pane_key_mode pane_last +pane_last_output_time +pane_last_prompt_time pane_left pane_marked pane_marked_set +pane_modal_flag pane_mode +pane_output_generation pane_path pane_pb_progress pane_pb_state pane_pid pane_pipe pane_pipe_pid +pane_private_modes pane_right pane_search_string pane_start_command @@ -141,6 +154,8 @@ pane_title pane_top pane_tty pane_unseen_changes +pane_unzoomed_height +pane_unzoomed_width pane_width pane_x pane_y @@ -206,7 +221,10 @@ window_layout window_linked window_linked_sessions window_linked_sessions_list +window_manual_height +window_manual_width window_marked_flag +window_modal_pane window_name window_offset_x window_offset_y diff --git a/regress/screen-redraw-scrollbars-auto-hide.sh b/regress/screen-redraw-scrollbars-auto-hide.sh new file mode 100644 index 000000000..85093a5ae --- /dev/null +++ b/regress/screen-redraw-scrollbars-auto-hide.sh @@ -0,0 +1,96 @@ +#!/bin/sh + +# Auto-hide scrollbars appear on activity or pointer hover, then disappear +# after pane-scrollbars-timeout. Compare captures of the same copy-mode view so +# only the overlay scrollbar changes. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +OUTER="$TEST_TMUX -LtestA$$ -f/dev/null" +INNER="$TEST_TMUX -LtestB$$ -f/dev/null" +DIR=$(mktemp -d) || exit 1 +HIDDEN=$DIR/hidden +VISIBLE=$DIR/visible +AFTER=$DIR/after + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -pe -t outer:0.0 >"$1" || exit 1 +} + +wait_for_client() +{ + i=0 + while [ "$i" -lt 50 ]; do + $INNER list-clients 2>/dev/null | grep -q . && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "inner client did not attach" +} + +$INNER new-session -d -s inner -x40 -y12 \ + "sh -c 'seq 50; exec sleep 100'" || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -g mouse on || exit 1 +$INNER set-window-option pane-scrollbars auto-hide || exit 1 +$INNER set-window-option pane-scrollbars-timeout 300 || exit 1 +$INNER set-window-option pane-scrollbars-style \ + 'bg=colour196,fg=colour231,width=1,pad=0' || exit 1 + +$OUTER new-session -d -s outer -x40 -y12 "$INNER attach -t inner" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +wait_for_client + +# Enter copy mode and choose a stable view. The initial show timer is allowed +# to expire before taking the hidden reference capture. +$INNER copy-mode -H || exit 1 +$INNER send-keys -X history-top || exit 1 +sleep 0.7 +capture "$HIDDEN" + +# A copy-mode page movement shows the overlay and starts its timer. Capture +# that view immediately, then again after the timeout without moving it. +$INNER send-keys -X page-down || exit 1 +sleep 0.1 +capture "$VISIBLE" +sleep 0.7 +capture "$AFTER" +cmp -s "$VISIBLE" "$AFTER" && fail "scrollbar did not hide after timeout" + +# Use the post-movement hidden scene as the reference for pointer hover. SGR +# mouse column 40 is the right-hand scrollbar; moving to column 5 restarts the +# timer without changing the copy-mode view. +cp "$AFTER" "$HIDDEN" +hover=$(printf '\033[<35;40;5M') +$OUTER send-keys -l "$hover" || exit 1 +sleep 0.1 +capture "$VISIBLE" +cmp -s "$HIDDEN" "$VISIBLE" && fail "scrollbar did not appear on hover" +away=$(printf '\033[<35;5;5M') +$OUTER send-keys -l "$away" || exit 1 +sleep 0.7 +capture "$AFTER" +cmp -s "$HIDDEN" "$AFTER" || fail "scrollbar did not hide after hover" + +exit 0 diff --git a/regress/terminal-feature-utf8.sh b/regress/terminal-feature-utf8.sh new file mode 100644 index 000000000..b1e0a1fbd --- /dev/null +++ b/regress/terminal-feature-utf8.sh @@ -0,0 +1,58 @@ +#!/bin/sh + +# The utf8 terminal feature must mark an attached client as UTF-8 even when +# neither its environment nor -u does so. It must also be reported by the +# client terminal-feature format lookup. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null +} +trap cleanup 0 1 15 + +cleanup +$INNER new-session -d -s inner -x40 -y10 'exec sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 + +# A clean C-locale environment avoids the ordinary UTF-8 detection paths. +# -T utf8 is therefore solely responsible for setting client_utf8. +client_command="env -i PATH=/bin:/usr/bin TERM=screen LC_ALL=C $TEST_TMUX -T utf8 -LtestI$$ -f/dev/null attach-session -t inner" +$OUTER new-session -d -s outer -x40 -y10 "$client_command" || exit 1 +$OUTER set-option -g status off || exit 1 + +i=0 +while [ "$i" -lt 50 ]; do + client=$($INNER list-clients -F '#{client_name}' 2>/dev/null) + utf8=$($INNER list-clients -F '#{client_utf8}' 2>/dev/null) + [ -n "$client" ] && [ "$utf8" = 1 ] && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "utf8 terminal feature did not set client_utf8" + +features=$($INNER list-clients -F '#{client_termfeatures}') +case ",$features," in +*,utf8,*) ;; +*) fail "utf8 is missing from client_termfeatures: $features" ;; +esac + +present=$($INNER display-message -c "$client" -p '#{I/f:utf8}') +[ "$present" = 1 ] || fail "utf8 terminal feature lookup returned '$present'" + +exit 0 From 59dc0e75c439aa88bd303fd0e3e02dae677e78b1 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Tue, 1 Sep 2026 22:07:08 +0100 Subject: [PATCH 05/38] Add a couple more tests. --- regress/server-access.sh | 112 +++++++++++++++++++++ regress/switch-mode.sh | 203 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 regress/server-access.sh create mode 100644 regress/switch-mode.sh diff --git a/regress/server-access.sh b/regress/server-access.sh new file mode 100644 index 000000000..a6e44efca --- /dev/null +++ b/regress/server-access.sh @@ -0,0 +1,112 @@ +#!/bin/sh + +# Exercise the unprivileged server-access surface: user and group ACL entries, +# read-only/writable transitions, listing, removal, implied addition, and +# command errors. Actual connections from another UID require privileges and +# deliberately do not form part of this test. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +OUT=$(mktemp) || exit 1 + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null + rm -f "$OUT" +} +trap cleanup 0 1 15 + +expect_fail() +{ + message=$1 + shift + if $TMUX "$@" >"$OUT" 2>&1; then + fail "$message: command unexpectedly succeeded" + fi +} + +assert_entry() +{ + entry=$1 + $TMUX server-access -l >"$OUT" || exit 1 + grep -Fxq "$entry" "$OUT" || + fail "missing ACL entry '$entry'" +} + +assert_no_entry() +{ + entry=$1 + $TMUX server-access -l >"$OUT" || exit 1 + if grep -Fxq "$entry" "$OUT"; then + fail "unexpected ACL entry '$entry'" + fi +} + +owner_uid=$(id -u) +owner_name=$(id -un) + +# nobody is the non-owner test identity. Derive its primary group rather than +# assuming whether the system calls it nobody, nogroup, or something else. +acl_user=nobody +acl_group=$(id -gn "$acl_user" 2>/dev/null) || + fail "the nobody account is unavailable" +[ "$owner_name" != "$acl_user" ] || fail "test cannot run as nobody" + +$TMUX new-session -d -s access 'exec sleep 100' || exit 1 + +# The owner is installed as writable at server startup (root is intentionally +# omitted from display output). +if [ "$owner_uid" != 0 ]; then + assert_entry "$owner_name (U,W)" +fi + +# User ACL lifecycle, including duplicate and missing-entry errors. +$TMUX server-access -a "$acl_user" || exit 1 +assert_entry "$acl_user (U,W)" +expect_fail "duplicate user addition" server-access -a "$acl_user" +$TMUX server-access -r "$acl_user" || exit 1 +assert_entry "$acl_user (U,R)" +$TMUX server-access -w "$acl_user" || exit 1 +assert_entry "$acl_user (U,W)" +$TMUX server-access -d "$acl_user" || exit 1 +assert_no_entry "$acl_user (U,W)" +expect_fail "removing absent user" server-access -d "$acl_user" + +# -r and -w imply -a when an entry is absent. +$TMUX server-access -r "$acl_user" || exit 1 +assert_entry "$acl_user (U,R)" +$TMUX server-access -d "$acl_user" || exit 1 + +# The same lifecycle for a group exercises the separate ACL key space. +$TMUX server-access -g -a -r "$acl_group" || exit 1 +assert_entry "$acl_group (G,R)" +$TMUX server-access -g -w "$acl_group" || exit 1 +assert_entry "$acl_group (G,W)" +expect_fail "duplicate group addition" server-access -g -a "$acl_group" +$TMUX server-access -g -d "$acl_group" || exit 1 +assert_no_entry "$acl_group (G,W)" +expect_fail "removing absent group" server-access -g -d "$acl_group" + +# Parser and lookup errors, plus the immutable owner/root entries. +expect_fail "missing ACL subject" server-access -a +expect_fail "unknown user" server-access -a "tmux-no-user-$$" +expect_fail "unknown group" server-access -g -a "tmux-no-group-$$" +expect_fail "conflicting add/delete" server-access -a -d "$acl_user" +expect_fail "conflicting read/write" server-access -r -w "$acl_user" +expect_fail "changing server owner" server-access -r "$owner_name" +if [ "$owner_name" != root ]; then + expect_fail "changing root access" server-access -a root +fi + +$TMUX has-session -t access || fail "server exited during ACL updates" +exit 0 diff --git a/regress/switch-mode.sh b/regress/switch-mode.sh new file mode 100644 index 000000000..5f3fe8c12 --- /dev/null +++ b/regress/switch-mode.sh @@ -0,0 +1,203 @@ +#!/bin/sh + +# Exercise switch-mode's session and window lists with a real attached client: +# fuzzy filtering, movement and selection, the default and custom commands, +# no-match cancellation, resize, zoom restoration, and -k mode teardown. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -p -t outer:0.0 2>/dev/null +} + +wait_capture() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + captured=$(capture) + printf '%s\n' "$captured" | grep -Fq "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "timed out waiting for '$marker'" +} + +wait_count() +{ + marker=$1 + want=$2 + i=0 + while [ "$i" -lt 50 ]; do + captured=$(capture) + count=$(printf '%s\n' "$captured" | grep -Fc "$marker") + [ "$count" -eq "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "found $count '$marker' rows, expected $want" +} + +wait_format() +{ + target=$1 + format=$2 + want=$3 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER display-message -p -t "$target" "$format" \ + 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "$target format $format is '$got', expected '$want'" +} + +wait_client_session() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER list-clients -F '#{client_session}' 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "client session is '$got', expected '$want'" +} + +# Three alphabetically ordered sessions. alpha:main has two panes so -Z can be +# observed; the two pick-* windows make a deterministic two-row window filter. +$INNER new-session -d -s alpha -n main -x60 -y15 'exec sleep 100' || exit 1 +$INNER split-window -d -h -t alpha:main 'exec sleep 100' || exit 1 +$INNER new-window -d -t alpha: -n pick-one 'exec sleep 100' || exit 1 +$INNER new-session -d -s bravo -n pick-two -x60 -y15 \ + 'exec sleep 100' || exit 1 +$INNER new-session -d -s charlie -n last -x60 -y15 \ + 'exec sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER select-window -t alpha:main || exit 1 + +$OUTER new-session -d -s outer -x60 -y15 "$INNER attach -t alpha" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +wait_client_session alpha + +# Session rows are sorted alpha, bravo, charlie. Up from the first row wraps to +# charlie; Enter runs a custom command with the selected session target. +$INNER set-option -g @picked '' || exit 1 +$INNER switch-mode -s -t alpha:main.0 -F 'SESSION #{session_name}' \ + "set-option -g @picked '%%'" || exit 1 +wait_count 'SESSION ' 3 +$INNER send-keys -t alpha:main.0 Up Enter || exit 1 +wait_format alpha:main.0 '#{@picked}' '=charlie:' +wait_format alpha:main.0 '#{pane_in_mode}' 0 + +# Fuzzy filtering keeps only bravo for "brv". The default command switches the +# real client to that session and closes the mode. +$INNER switch-mode -s -t alpha:main.0 -F 'SESSION #{session_name}' || exit 1 +wait_count 'SESSION ' 3 +$INNER send-keys -t alpha:main.0 -l brv || exit 1 +wait_count 'SESSION ' 1 +printf '%s\n' "$captured" | grep -Fq 'SESSION bravo' || + fail "fuzzy session filter did not select bravo" +$INNER send-keys -t alpha:main.0 Enter || exit 1 +wait_client_session bravo +client=$($INNER list-clients -F '#{client_name}') +$INNER switch-client -c "$client" -t alpha || exit 1 +wait_client_session alpha + +# Window mode sees pick-one then pick-two for the "pick" filter. Down selects +# the second row, and the custom command receives its window target. +$INNER set-option -g @picked '' || exit 1 +$INNER switch-mode -w -t alpha:main.0 -F \ + 'WINDOW #{session_name}:#{window_name}' \ + "set-option -g @picked '%%'" || exit 1 +wait_capture 'WINDOW ' +$INNER send-keys -t alpha:main.0 -l pick || exit 1 +wait_count 'WINDOW ' 2 +$INNER send-keys -t alpha:main.0 Down Enter || exit 1 +picked=$($INNER show-option -gv @picked) +case "$picked" in +=bravo:0.) ;; +*) fail "window selection produced '$picked', expected '=bravo:0.'" ;; +esac + +# Navigation with an empty result set must be harmless, and Escape cancels +# without running the custom command. +$INNER set-option -g @picked unchanged || exit 1 +$INNER switch-mode -s -t alpha:main.0 -F 'NONE #{session_name}' \ + "set-option -g @picked '%%'" || exit 1 +wait_capture 'NONE ' +$INNER send-keys -t alpha:main.0 -l no-such-session || exit 1 +wait_count 'NONE ' 0 +$INNER send-keys -t alpha:main.0 Up Down PPage NPage Home End || exit 1 +wait_format alpha:main.0 '#{pane_mode}' switch-mode +$INNER send-keys -t alpha:main.0 Escape || exit 1 +wait_format alpha:main.0 '#{pane_in_mode}' 0 +[ "$($INNER show-option -gv @picked)" = unchanged ] || + fail "cancelled switch-mode ran its command" + +# -Z temporarily zooms an unzoomed window. Resizing while the mode is open +# rebuilds and redraws it; exiting restores the original unzoomed state. +$INNER switch-mode -Zs -t alpha:main.0 -F 'RESIZE #{session_name}' || exit 1 +wait_capture 'RESIZE alpha' +wait_format alpha:main.0 '#{window_zoomed_flag}' 1 +$INNER resize-window -t alpha:main -x50 -y12 || exit 1 +wait_format alpha:main.0 '#{window_width}x#{window_height}' 50x12 +wait_format alpha:main.0 '#{pane_mode}' switch-mode +$INNER send-keys -t alpha:main.0 Escape || exit 1 +wait_format alpha:main.0 '#{window_zoomed_flag}' 0 + +# If the window was already zoomed, leaving -Z mode must keep it zoomed. +$INNER resize-pane -Z -t alpha:main.0 || exit 1 +$INNER switch-mode -Zs -t alpha:main.0 -F 'ZOOMED #{session_name}' || exit 1 +wait_capture 'ZOOMED alpha' +$INNER send-keys -t alpha:main.0 Escape || exit 1 +wait_format alpha:main.0 '#{window_zoomed_flag}' 1 +$INNER resize-pane -Z -t alpha:main.0 || exit 1 + +# -k uses the shared mode teardown flag. Put it on a disposable pane and make +# sure exiting the mode removes that pane. +kill_pane=$($INNER split-window -d -P -F '#{pane_id}' -t alpha:main \ + 'exec sleep 100') || exit 1 +$INNER switch-mode -ks -t "$kill_pane" -F 'KILL #{session_name}' || exit 1 +wait_format "$kill_pane" '#{pane_mode}' switch-mode +$INNER send-keys -t "$kill_pane" Escape || exit 1 +i=0 +while [ "$i" -lt 50 ]; do + $INNER list-panes -s -t alpha -F '#{pane_id}' 2>/dev/null | \ + grep -q -x "$kill_pane" || break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "switch-mode -k did not kill its pane" + +exit 0 From 57a13664cc2cf0db1c6b4f575c4934bf4ec1c4ee Mon Sep 17 00:00:00 2001 From: nicm Date: Tue, 1 Sep 2026 19:50:58 +0000 Subject: [PATCH 06/38] Do not allow cursor on/off to escape synchronized updates. --- screen-redraw.c | 8 +++----- server-client.c | 8 ++++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/screen-redraw.c b/screen-redraw.c index 42cc5abed..c47bf09d5 100644 --- a/screen-redraw.c +++ b/screen-redraw.c @@ -1,4 +1,4 @@ -/* $OpenBSD: screen-redraw.c,v 1.157 2026/07/24 08:49:23 nicm Exp $ */ +/* $OpenBSD: screen-redraw.c,v 1.158 2026/09/01 19:50:58 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -1738,8 +1738,7 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags) else loop->flags &= ~PANE_NEWSTATUS; - width = redraw_pane_status_width(&dctx, loop, - &first); + width = redraw_pane_status_width(&dctx, loop, &first); if (width == 0) continue; @@ -1770,7 +1769,7 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags) } } } - tty_sync_start(tty); + tty_sync_start(tty); /* end in server_client_reset_state */ tty_update_mode(tty, tty->mode & ~CURSOR_MODES, NULL); if (wp != NULL) @@ -1815,7 +1814,6 @@ redraw_draw(struct client *c, struct window_pane *wp, int flags) c->overlay_draw(c, c->overlay_data); tty_reset(tty); - tty_sync_end(tty); log_debug("%s: finished @%u redraw", c->name, scene->w->id); diff --git a/server-client.c b/server-client.c index 97c83514f..54e615ed4 100644 --- a/server-client.c +++ b/server-client.c @@ -1,4 +1,4 @@ -/* $OpenBSD: server-client.c,v 1.509 2026/08/28 07:36:01 nicm Exp $ */ +/* $OpenBSD: server-client.c,v 1.510 2026/09/01 19:50:58 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -2249,7 +2249,7 @@ server_client_reset_state(struct client *c) cy += status_line_size(c); } - if ((pane_mode & MODE_SYNC) || !cursor) + if (!cursor) mode &= ~MODE_CURSOR; } } else if (c->overlay_mode == NULL || s == NULL) @@ -2257,6 +2257,10 @@ server_client_reset_state(struct client *c) if (~pane_mode & MODE_SYNC) { log_debug("%s: cursor to %u,%u", __func__, cx, cy); tty_cursor(tty, cx, cy); + } else { + mode &= ~CURSOR_MODES; + mode |= tty->mode & CURSOR_MODES; + s = NULL; } /* From 6bb6c286a972ffe1e4d2e98d8e61cc66beb87822 Mon Sep 17 00:00:00 2001 From: nicm Date: Tue, 1 Sep 2026 21:03:55 +0000 Subject: [PATCH 07/38] Create screen before zoom may need to use it. --- window-switch.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/window-switch.c b/window-switch.c index f9cb16ec5..1ff1007bb 100644 --- a/window-switch.c +++ b/window-switch.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window-switch.c,v 1.3 2026/08/25 07:23:30 nicm Exp $ */ +/* $OpenBSD: window-switch.c,v 1.4 2026/09/01 21:03:55 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -374,6 +374,9 @@ window_switch_init(struct window_mode_entry *wme, data->prompt = prompt_create(&pd); prompt_update(data->prompt, "(search) ", data->filter); + s = &data->screen; + screen_init(s, screen_size_x(&wp->base), screen_size_y(&wp->base), 0); + if (!args_has(args, 'Z')) data->zoomed = -1; else { @@ -382,9 +385,6 @@ window_switch_init(struct window_mode_entry *wme, server_redraw_window(wp->window); } - s = &data->screen; - screen_init(s, screen_size_x(&wp->base), screen_size_y(&wp->base), 0); - window_switch_build(data); prompt_incremental_start(data->prompt); window_switch_draw_screen(wme); From 1dd17174622dde26585b8de5e1d9e4b6ef31541d Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Wed, 2 Sep 2026 08:43:21 +0100 Subject: [PATCH 08/38] Fix tests for macOS. --- regress/clear-on-attach.sh | 14 ++++++++++---- regress/screen-redraw-scrollbars-auto-hide.sh | 8 ++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/regress/clear-on-attach.sh b/regress/clear-on-attach.sh index 89c8ebbbe..007a6916c 100644 --- a/regress/clear-on-attach.sh +++ b/regress/clear-on-attach.sh @@ -81,10 +81,16 @@ if grep -Fq "$smcup" "$off"; then fail "clear-on-attach off used smcup" fi -# screen has the indn capability (CSI Ps S), which the preserving path uses -# after setting the scrolling region and moving to its last line. +# Some screen terminfo entries have indn (CSI Ps S), while older ones only +# have ind (newline). The preserving path uses indn once or repeats ind once +# for every line in the terminal plus one. escape=$(printf '\033') -LC_ALL=C grep -Eq "${escape}\\[[0-9]+S" "$off" || - fail "clear-on-attach off did not scroll the old contents away" +if ! LC_ALL=C grep -Eq "${escape}\\[[0-9]+S" "$off"; then + hex=$(od -An -tx1 -v "$off" | tr -d ' \n') + case "$hex" in + *0a0a0a0a0a0a0a0a0a0a0a0a*) ;; + *) fail "clear-on-attach off did not scroll the old contents away" ;; + esac +fi exit 0 diff --git a/regress/screen-redraw-scrollbars-auto-hide.sh b/regress/screen-redraw-scrollbars-auto-hide.sh index 85093a5ae..7357b5cbe 100644 --- a/regress/screen-redraw-scrollbars-auto-hide.sh +++ b/regress/screen-redraw-scrollbars-auto-hide.sh @@ -53,7 +53,7 @@ $INNER set-option -g status off || exit 1 $INNER set-option -g window-size manual || exit 1 $INNER set-option -g mouse on || exit 1 $INNER set-window-option pane-scrollbars auto-hide || exit 1 -$INNER set-window-option pane-scrollbars-timeout 300 || exit 1 +$INNER set-window-option pane-scrollbars-timeout 3000 || exit 1 $INNER set-window-option pane-scrollbars-style \ 'bg=colour196,fg=colour231,width=1,pad=0' || exit 1 @@ -66,7 +66,7 @@ wait_for_client # to expire before taking the hidden reference capture. $INNER copy-mode -H || exit 1 $INNER send-keys -X history-top || exit 1 -sleep 0.7 +sleep 3.5 capture "$HIDDEN" # A copy-mode page movement shows the overlay and starts its timer. Capture @@ -74,7 +74,7 @@ capture "$HIDDEN" $INNER send-keys -X page-down || exit 1 sleep 0.1 capture "$VISIBLE" -sleep 0.7 +sleep 3.5 capture "$AFTER" cmp -s "$VISIBLE" "$AFTER" && fail "scrollbar did not hide after timeout" @@ -89,7 +89,7 @@ capture "$VISIBLE" cmp -s "$HIDDEN" "$VISIBLE" && fail "scrollbar did not appear on hover" away=$(printf '\033[<35;5;5M') $OUTER send-keys -l "$away" || exit 1 -sleep 0.7 +sleep 3.5 capture "$AFTER" cmp -s "$HIDDEN" "$AFTER" || fail "scrollbar did not hide after hover" From ad632726bda806d382e075c17e0161734894d1fa Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Wed, 2 Sep 2026 11:16:37 +0100 Subject: [PATCH 09/38] Some more regress tests. --- regress/choose-tree.sh | 88 +++++++++++ regress/clock-mode.sh | 101 +++++++++++++ regress/confirm-before-lock.sh | 150 +++++++++++++++++++ regress/copy-mode-commands.sh | 253 ++++++++++++++++++++++++++++++++ regress/find-window.sh | 118 +++++++++++++++ regress/layout-set.sh | 126 ++++++++++++++++ regress/list-commands.sh | 46 ++++++ regress/prompt-words-history.sh | 165 +++++++++++++++++++++ 8 files changed, 1047 insertions(+) create mode 100644 regress/clock-mode.sh create mode 100644 regress/confirm-before-lock.sh create mode 100644 regress/copy-mode-commands.sh create mode 100644 regress/find-window.sh create mode 100644 regress/layout-set.sh create mode 100644 regress/list-commands.sh create mode 100644 regress/prompt-words-history.sh diff --git a/regress/choose-tree.sh b/regress/choose-tree.sh index 132890730..4656963e6 100644 --- a/regress/choose-tree.sh +++ b/regress/choose-tree.sh @@ -290,4 +290,92 @@ wait_count ': G4' 7 fail "window 1 of zzz not killed" exit_mode q +# --- help and information preview -------------------------------------------- +# The complete tree help is taller than the 24-line test terminal, where the +# help renderer deliberately leaves the tree unchanged. +$TMUX2 resize-window -t out:0 -x 80 -y 50 || exit 1 +i=0 +while [ "$i" -lt 50 ]; do + # The inner session has a one-line status, leaving a 49-line window. + [ "$($TMUX display-message -p -t aaa:0 '#{window_height}')" -ge 49 ] && + break + sleep 0.2 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "attached client did not resize for tree help" +$TMUX choose-tree -t aaa:0 -F 'G5' -O index || exit 1 +wait_count ': G5' 7 +$TMUX2 send-keys -t out:0 F1 || fail "send-keys F1 failed" +wait_for 'Swap current and previous window' +# The first key closes help; the second exits the tree. +$TMUX send-keys -t aaa:0 q q || fail "send-keys help exit failed" +wait_mode aaa:0 0 + +$TMUX choose-tree -t aaa:0 -F 'G6' -O index || exit 1 +wait_count ': G6' 7 +$TMUX send-keys -t aaa:0 i || fail "send-keys info failed" +wait_for 'Session' +$TMUX send-keys -t aaa:0 i || fail "send-keys preview failed" +exit_mode q + +# --- window swapping and command prompt for current/tagged items ------------- +# Restrict a window-only tree to two disposable windows in aaa. J (the +# Shift-Down equivalent) swaps their window objects while leaving their indexes +# in place. +$TMUX new-window -d -t aaa: -n swap-a 'cat' || exit 1 +$TMUX new-window -d -t aaa: -n swap-b 'cat' || exit 1 +aidx=$($TMUX list-windows -t aaa -F '#{window_index}:#{window_name}' | \ + awk -F: '$2 == "swap-a" {print $1}') +bidx=$($TMUX list-windows -t aaa -F '#{window_index}:#{window_name}' | \ + awk -F: '$2 == "swap-b" {print $1}') +$TMUX choose-tree -w -t aaa:0 -O index -F 'WT #{window_name}' \ + -f '#{m:swap-*,#{window_name}}' || exit 1 +wait_count 'WT swap-' 2 +$TMUX send-keys -t aaa:0 g j J || fail "window tree swap failed" +i=0 +while [ "$i" -lt 50 ]; do + aname=$($TMUX display-message -p -t "aaa:$aidx" '#{window_name}') + bname=$($TMUX display-message -p -t "aaa:$bidx" '#{window_name}') + [ "$aname:$bname" = 'swap-b:swap-a' ] && break + sleep 0.2 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "window tree did not swap the two windows" + +# ':' runs an entered command for the current item and rebuilds the tree when +# its queued command completes. +$TMUX set-option -g @tree-command '' || exit 1 +$TMUX send-keys -t aaa:0 : || fail "window tree command prompt failed" +wait_for '(current)' +$TMUX send-keys -t aaa:0 -l "set-option -g @tree-command '%%'" || exit 1 +$TMUX send-keys -t aaa:0 Enter || exit 1 +i=0 +while [ "$i" -lt 50 ]; do + value=$($TMUX show-option -gqv @tree-command) + [ -n "$value" ] && break + sleep 0.2 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "window tree entered command did not run" +case "$value" in +=aaa:*.) ;; +*) fail "window tree command target was '$value'" ;; +esac + +# Tag both disposable windows and kill them through the tagged confirmation +# callback. The mode-hosting window is outside the filter and survives. +$TMUX send-keys -t aaa:0 g j t j t X || fail "tagged kill keys failed" +wait_for 'Kill 2 tagged?' +$TMUX send-keys -t aaa:0 y || fail "tagged kill confirmation failed" +i=0 +while [ "$i" -lt 50 ]; do + left=$($TMUX list-windows -t aaa -F '#{window_name}' | \ + grep -c '^swap-' || true) + [ "$left" -eq 0 ] && break + sleep 0.2 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "tagged windows were not killed" +exit_mode q + exit 0 diff --git a/regress/clock-mode.sh b/regress/clock-mode.sh new file mode 100644 index 000000000..846e8dc7e --- /dev/null +++ b/regress/clock-mode.sh @@ -0,0 +1,101 @@ +#!/bin/sh + +# Exercise clock-mode initialization, all four styles, its one-second timer, +# resize into the compact renderer, arbitrary-key exit, and cleanup. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -p -t outer:0.0 2>/dev/null +} + +wait_mode() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER display-message -p -t clock:0 '#{pane_mode}' 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "pane mode is '$got', expected '$want'" +} + +wait_hashes() +{ + i=0 + while [ "$i" -lt 50 ]; do + captured=$(capture) + printf '%s\n' "$captured" | grep -q '#' && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "large clock did not render" +} + +$INNER new-session -d -s clock -x80 -y24 'exec sleep 100' || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$INNER set-option -w clock-mode-colour red || exit 1 +$OUTER new-session -d -s outer -x80 -y24 "$INNER attach -t clock" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +sleep 1 + +for style in 12 24 12-with-seconds 24-with-seconds; do + $INNER set-option -w -t clock:0 clock-mode-style "$style" || exit 1 + $INNER clock-mode -t clock:0 || exit 1 + wait_mode clock-mode + wait_hashes + # Let at least one timer callback observe a new second. + [ "$style" != 12-with-seconds ] || sleep 1.2 + $INNER send-keys -t clock:0 x || exit 1 + wait_mode '' +done + +# Resizing an active mode below the large-glyph threshold selects the compact +# renderer. A visible time contains a colon and no block-clock hash. +$INNER clock-mode -t clock:0 || exit 1 +wait_mode clock-mode +$INNER resize-window -t clock:0 -x20 -y5 || exit 1 +i=0 +while [ "$i" -lt 50 ]; do + captured=$(capture) + printf '%s\n' "$captured" | grep -Eq '[0-9][0-9]?:[0-9][0-9]' && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "compact clock did not render after resize" +printf '%s\n' "$captured" | grep -q '#' && + fail "compact clock still used large glyphs" +$INNER send-keys -t clock:0 Enter || exit 1 +wait_mode '' + +$INNER has-session -t clock || fail "server died during clock-mode tests" +exit 0 diff --git a/regress/confirm-before-lock.sh b/regress/confirm-before-lock.sh new file mode 100644 index 000000000..db570ab10 --- /dev/null +++ b/regress/confirm-before-lock.sh @@ -0,0 +1,150 @@ +#!/bin/sh + +# Exercise confirm-before's waiting and background callbacks, custom keys and +# prompts, default-yes handling and errors. Also cover all three lock command +# entry points with a harmless lock-command which leaves a marker. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -p -t outer:0.0 2>/dev/null +} + +wait_capture() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + captured=$(capture) + printf '%s\n' "$captured" | grep -Fq "$marker" && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "timed out waiting for '$marker'" +} + +wait_option() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER show-option -gqv @confirmed 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "@confirmed is '$got', expected '$want'" +} + +wait_file() +{ + marker=$1 + i=0 + while [ "$i" -lt 50 ]; do + [ -f "$marker" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "lock command did not create $marker" +} + +$INNER new-session -d -s test -x80 -y24 'exec sleep 100' || exit 1 +$INNER set-option -g status on || exit 1 +$INNER set-option -g status-position bottom || exit 1 +$INNER set-option -g window-size manual || exit 1 +$OUTER new-session -d -s outer -x80 -y24 "$INNER attach -t test" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +sleep 1 + +client=$($INNER list-clients -F '#{client_name}') +[ -n "$client" ] || fail "inner client did not attach" + +$INNER bind-key -n M-a confirm-before \ + 'set-option -g @confirmed accepted' || exit 1 +$INNER bind-key -n M-b confirm-before -b -p 'Background?' \ + 'set-option -g @confirmed background' || exit 1 +$INNER bind-key -n M-c confirm-before -c x -p 'Custom?' \ + 'set-option -g @confirmed custom' || exit 1 +$INNER bind-key -n M-y confirm-before -y -p 'Default?' \ + 'set-option -g @confirmed default' || exit 1 + +# A non-confirming response closes the waiting prompt without running its +# command; reopening and accepting inserts the command after the waiting item. +$INNER set-option -g @confirmed sentinel || exit 1 +$OUTER send-keys M-a || exit 1 +wait_capture "Confirm 'set-option'? (y/n)" +$OUTER send-keys n || exit 1 +sleep 0.2 +wait_option sentinel +$OUTER send-keys M-a || exit 1 +wait_capture "Confirm 'set-option'? (y/n)" +$OUTER send-keys y || exit 1 +wait_option accepted + +# -b has no waiting queue item and appends its command to the client queue. +$OUTER send-keys M-b || exit 1 +wait_capture 'Background?' +$OUTER send-keys y || exit 1 +wait_option background + +# A custom printable key is accepted, and -y makes Enter affirmative. +$OUTER send-keys M-c || exit 1 +wait_capture 'Custom?' +$OUTER send-keys x || exit 1 +wait_option custom +$OUTER send-keys M-y || exit 1 +wait_capture 'Default?' +$OUTER send-keys Enter || exit 1 +wait_option default + +# Reject invalid multi-character and control confirmation keys. +if $INNER confirm-before -b -t "$client" -c xx 'display-message x' \ + >/dev/null 2>&1; then + fail "multi-character confirm key was accepted" +fi +if $INNER confirm-before -b -t "$client" -c "$(printf '\001')" \ + 'display-message x' >/dev/null 2>&1; then + fail "control confirm key was accepted" +fi + +# lock-client, lock-session and lock-server all send lock-command to the real +# attached client. The command returns immediately and unlocks the client. +for kind in client session server; do + marker="$DIR/locked-$kind" + $INNER set-option -t test lock-command "printf locked >$marker" || exit 1 + case "$kind" in + client) $INNER lock-client -t "$client" || exit 1 ;; + session) $INNER lock-session -t test || exit 1 ;; + server) $INNER lock-server || exit 1 ;; + esac + wait_file "$marker" +done + +$INNER has-session -t test || fail "server died during prompt or lock tests" +exit 0 diff --git a/regress/copy-mode-commands.sh b/regress/copy-mode-commands.sh new file mode 100644 index 000000000..aa94e9ef2 --- /dev/null +++ b/regress/copy-mode-commands.sh @@ -0,0 +1,253 @@ +#!/bin/sh + +# Exercise the broad non-mouse copy-mode command surface: navigation, search, +# jumps, state toggles, line/end-of-line copies, append, and pipe variants. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +TMUX="$TEST_TMUX -Ltest$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +x() +{ + $TMUX send-keys -t copy:0 -X "$@" || fail "copy command failed: $*" +} + +wait_mode() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($TMUX display-message -p -t copy:0 '#{pane_in_mode}' 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "pane_in_mode is '$got', expected '$want'" +} + +fresh() +{ + $TMUX copy-mode -t copy:0 || exit 1 + wait_mode 1 + x history-top + x start-of-line +} + +select_text() +{ + fresh + x begin-selection + x cursor-right + x cursor-right +} + +$TMUX new-session -d -s copy -x40 -y8 \ + "sh -c 'i=0; while [ \$i -lt 40 ]; do printf \" line-%02d needle (a[b]c) end\\n\" \$i; i=\$((i + 1)); done; printf \"\\nparagraph two needle\\n\\nparagraph three\\n\"; printf \"\\033]133;A\\007prompt-one\\n\\033]133;C\\007output-one\\n\\033]133;A\\007prompt-two\\n\\033]133;C\\007output-two\\n\"; exec sleep 100'" || + exit 1 +$TMUX set-option -g status off || exit 1 +$TMUX set-option -g history-limit 200 || exit 1 + +i=0 +while [ "$i" -lt 50 ]; do + history=$($TMUX display-message -p -t copy:0 '#{history_size}') + [ "$history" -ge 30 ] && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "history did not fill" + +fresh + +# Copy-mode-specific format callbacks expose the word, line, hyperlink and +# active search match at the cursor. +formats=$($TMUX display-message -p -t copy:0 \ + '#{copy_cursor_word}|#{copy_cursor_line}|#{copy_cursor_hyperlink}|#{search_match}') || + exit 1 +[ -n "$formats" ] || fail "copy-mode cursor formats were empty" + +# Cursor, viewport and paragraph commands. Each is asserted by successful +# dispatch and the final mode-liveness check; detailed word/selection geometry +# is covered by the existing copy-mode tests. +for command in back-to-indentation bottom-line top-line middle-line \ + cursor-centre-vertical cursor-centre-horizontal end-of-line history-bottom \ + history-top halfpage-down halfpage-up page-down page-up next-paragraph \ + previous-paragraph next-matching-bracket previous-matching-bracket; do + x "$command" +done +x goto-line 15 +x set-mark +x cursor-down +x jump-to-mark + +# OSC 133 prompt and output markers support shell-integration navigation. +x history-top +x next-prompt +case "$($TMUX display-message -p -t copy:0 '#{copy_cursor_line}')" in +*prompt-one*) ;; +*) fail "next-prompt did not find prompt-one" ;; +esac +x next-prompt +x previous-prompt +case "$($TMUX display-message -p -t copy:0 '#{copy_cursor_line}')" in +*prompt-one*) ;; +*) fail "previous-prompt did not return to prompt-one" ;; +esac +x next-prompt -o +case "$($TMUX display-message -p -t copy:0 '#{copy_cursor_line}')" in +*output-one*) ;; +*) fail "next-prompt -o did not find output-one" ;; +esac + +# Character jumps remember their direction and character for repeat/reverse. +x jump-forward e +x jump-again +x jump-reverse +x jump-backward e +x jump-to-forward e +x jump-to-backward e + +# Direct, text and incremental searches cover both directions and the saved +# search used by again/reverse. +x search-forward needle +x search-again +x search-reverse +x search-backward needle +x search-forward-text needle +x search-backward-text needle +x search-forward-incremental '=needle' +x search-forward-incremental '+needle' +x search-backward-incremental '=needle' +x search-backward-incremental '-needle' + +# Stateful display and behaviour toggles. +for command in rectangle-on rectangle-off rectangle-toggle \ + line-numbers-on line-numbers-off line-numbers-toggle \ + refresh-on refresh-now refresh-off refresh-toggle \ + scroll-exit-on scroll-exit-off scroll-exit-toggle toggle-position; do + x "$command" +done +wait_mode 1 +x cancel +wait_mode 0 + +# Line and end-of-line copy commands work without an explicit selection. +fresh +x select-line +x copy-selection-and-cancel +case "$($TMUX show-buffer)" in +*line-00*) ;; +*) fail "select-line did not select the current line" ;; +esac + +fresh +x copy-line +[ -n "$($TMUX show-buffer)" ] || fail "copy-line produced an empty buffer" +x cancel + +fresh +x copy-end-of-line +[ -n "$($TMUX show-buffer)" ] || fail "copy-end-of-line produced an empty buffer" +x cancel + +# Append variants retain or close the mode as advertised. +$TMUX set-buffer -b append-buffer prefix || exit 1 +select_text +x append-selection +appended=$($TMUX show-buffer -b append-buffer) +case "$appended" in +prefix*) ;; +*) fail "append-selection produced '$appended'" ;; +esac +x cancel + +$TMUX set-buffer -b append-cancel-buffer prefix || exit 1 +select_text +x append-selection-and-cancel +wait_mode 0 +appended=$($TMUX show-buffer -b append-cancel-buffer) +case "$appended" in +prefix*) ;; +*) fail "append-selection-and-cancel produced '$appended'" ;; +esac + +# The explicit cancel variants close copy mode. +fresh +x copy-line-and-cancel +wait_mode 0 +fresh +x copy-end-of-line-and-cancel +wait_mode 0 +select_text +x copy-selection-and-cancel +wait_mode 0 + +# Pipe wrappers have distinct command handlers. /dev/null keeps the test +# deterministic while exercising asynchronous pipe job creation and cleanup. +fresh +x copy-pipe-line 'cat >/dev/null' +x cancel +fresh +x copy-pipe-line-and-cancel 'cat >/dev/null' +wait_mode 0 +fresh +x copy-pipe-end-of-line 'cat >/dev/null' +x cancel +fresh +x copy-pipe-end-of-line-and-cancel 'cat >/dev/null' +wait_mode 0 + +select_text +x copy-pipe-no-clear 'cat >/dev/null' +x pipe-no-clear 'cat >/dev/null' +x copy-pipe 'cat >/dev/null' +x pipe 'cat >/dev/null' +x cancel +select_text +x copy-pipe-and-cancel 'cat >/dev/null' +wait_mode 0 +select_text +x pipe-and-cancel 'cat >/dev/null' +wait_mode 0 + +# Commands which cancel on downward movement have separate handlers. +fresh +x history-bottom +x bottom-line +x cursor-down-and-cancel +wait_mode 0 +fresh +x history-bottom +x halfpage-down-and-cancel +wait_mode 0 +fresh +x history-bottom +x page-down-and-cancel +wait_mode 0 +fresh +x history-bottom +x bottom-line +x scroll-down-and-cancel +wait_mode 0 + +$TMUX has-session -t copy || fail "server died during copy-mode commands" +exit 0 diff --git a/regress/find-window.sh b/regress/find-window.sh new file mode 100644 index 000000000..820a4777d --- /dev/null +++ b/regress/find-window.sh @@ -0,0 +1,118 @@ +#!/bin/sh + +# Exercise find-window's content, name and title filters in every combination, +# glob/regex and case-insensitive suffixes, plus zoom restoration. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -p -t outer:0.0 2>/dev/null +} + +wait_mode() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER display-message -p -t find:base.0 '#{pane_mode}' 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "pane mode is '$got', expected '$want'" +} + +run_find() +{ + label=$1 + expected=$2 + shift 2 + $INNER find-window -t find:base.0 "$@" || fail "$label failed" + wait_mode tree-mode + i=0 + while [ "$i" -lt 50 ]; do + captured=$(capture) + printf '%s\n' "$captured" | grep -Fq "$expected" && break + sleep 0.1 + i=$((i + 1)) + done + [ "$i" -lt 50 ] || fail "$label did not show '$expected'" + $INNER send-keys -t find:base.0 q || exit 1 + wait_mode '' +} + +$INNER new-session -d -s find -n base -x80 -y24 \ + "sh -c 'printf \"BaseBody\\n\"; exec sleep 100'" || exit 1 +$INNER split-window -d -h -t find:base \ + "sh -c 'printf \"OtherBody\\n\"; exec sleep 100'" || exit 1 +$INNER new-window -d -t find: -n NameNeedle 'exec sleep 100' || exit 1 +$INNER new-window -d -t find: -n body \ + "sh -c 'printf \"BodyNeedle\\n\"; exec sleep 100'" || exit 1 +$INNER new-window -d -t find: -n titled 'exec sleep 100' || exit 1 +$INNER select-pane -t find:titled.0 -T TitleNeedle || exit 1 +$INNER set-option -g status off || exit 1 +$INNER set-option -g window-size manual || exit 1 +$OUTER new-session -d -s outer -x80 -y24 "$INNER attach -t find:base" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +sleep 1 + +# Wait until the body text is in the pane history before searching it. +i=0 +while [ "$i" -lt 50 ]; do + $INNER capture-pane -p -t find:body.0 | grep -Fq BodyNeedle && break + sleep 0.1 + i=$((i + 1)) +done +[ "$i" -lt 50 ] || fail "pane content was not ready" + +# No flags means C+N+T. Then cover all three pairs and all three singletons. +run_find default NameNeedle NameNeedle +run_find content-name NameNeedle -C -N NameNeedle +run_find content-title titled -C -T TitleNeedle +run_find name-title titled -N -T TitleNeedle +run_find content-only body -C BodyNeedle +run_find name-only NameNeedle -N NameNeedle +run_find title-only titled -T TitleNeedle + +# Glob case folding and all regular-expression suffix variants. +run_find insensitive NameNeedle -N -i nameneedle +run_find regex NameNeedle -N -r '^NameNeedle$' +run_find regex-insensitive NameNeedle -N -r -i '^nameneedle$' + +# -Z temporarily zooms the target window and restores its prior state on exit. +$INNER find-window -Z -t find:base.0 -N NameNeedle || exit 1 +wait_mode tree-mode +[ "$($INNER display-message -p -t find:base '#{window_zoomed_flag}')" = 1 ] || + fail "find-window -Z did not zoom" +$INNER send-keys -t find:base.0 q || exit 1 +wait_mode '' +[ "$($INNER display-message -p -t find:base '#{window_zoomed_flag}')" = 0 ] || + fail "find-window -Z did not restore zoom" + +exit 0 diff --git a/regress/layout-set.sh b/regress/layout-set.sh new file mode 100644 index 000000000..ea02195a9 --- /dev/null +++ b/regress/layout-set.sh @@ -0,0 +1,126 @@ +#!/bin/sh + +# Exercise every predefined layout, next/previous cycling, lookup and restore +# paths, one-pane early returns, minimum-size handling and floating panes. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +TMUX="$TEST_TMUX -Ltest$$ -f/dev/null" +SEEN="$DIR/layouts" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +layout() +{ + $TMUX display-message -p -t layouts:many '#{window_layout}' +} + +check_layout() +{ + name=$1 + $TMUX select-layout -t layouts:many "$name" || + fail "select-layout $name failed" + value=$(layout) + [ -n "$value" ] || fail "$name produced an empty layout" + if grep -Fxq "$value" "$SEEN"; then + fail "$name produced the same layout as an earlier preset" + fi + printf '%s\n' "$value" >>"$SEEN" + [ "$($TMUX list-panes -t layouts:many -F x | wc -l)" -eq 5 ] || + fail "$name lost a pane" + [ "$($TMUX list-panes -t layouts:many -F '#{pane_floating_flag}' | \ + grep -c '^1$')" -eq 1 ] || fail "$name did not preserve the floating pane" +} + +$TMUX new-session -d -s layouts -n many -x100 -y40 'exec sleep 100' || exit 1 +$TMUX set-option -g window-size manual || exit 1 +$TMUX split-window -d -h -t layouts:many 'exec sleep 100' || exit 1 +$TMUX split-window -d -v -t layouts:many 'exec sleep 100' || exit 1 +$TMUX split-window -d -h -t layouts:many 'exec sleep 100' || exit 1 +floating=$($TMUX new-pane -dP -F '#{pane_id}' -t layouts:many \ + -x20 -y8 -X10 -Y5 'exec sleep 100') || exit 1 +: >"$SEEN" + +# Options exercise percentage and explicit secondary-size calculations. +$TMUX set-option -w -t layouts:many main-pane-height 30% || exit 1 +$TMUX set-option -w -t layouts:many other-pane-height 12 || exit 1 +$TMUX set-option -w -t layouts:many main-pane-width 35% || exit 1 +$TMUX set-option -w -t layouts:many other-pane-width 20 || exit 1 + +for name in even-horizontal even-vertical main-horizontal \ + main-horizontal-mirrored main-vertical main-vertical-mirrored tiled; do + check_layout "$name" +done + +# Exact and unique-prefix lookup work; ambiguous and invalid names fail. +$TMUX select-layout -t layouts:many even-h || fail "unique layout prefix failed" +$TMUX select-layout -t layouts:many main >/dev/null 2>&1 && + fail "ambiguous layout prefix succeeded" +$TMUX select-layout -t layouts:many no-such-layout >/dev/null 2>&1 && + fail "invalid layout name succeeded" + +# Cycle through all layouts in both directions, including wraparound. The +# aliases and select-layout flags share layout_set_next/previous. +first=$(layout) +i=0 +while [ "$i" -lt 7 ]; do + $TMUX next-layout -t layouts:many || exit 1 + i=$((i + 1)) +done +[ "$(layout)" = "$first" ] || fail "next-layout did not wrap" +i=0 +while [ "$i" -lt 7 ]; do + $TMUX previous-layout -t layouts:many || exit 1 + i=$((i + 1)) +done +[ "$(layout)" = "$first" ] || fail "previous-layout did not wrap" +$TMUX select-layout -t layouts:many -n || exit 1 +$TMUX select-layout -t layouts:many -p || exit 1 + +# No argument reapplies the last preset; -o restores the previous layout; -E +# spreads the current cell without changing the pane set. +$TMUX kill-pane -t "$floating" || exit 1 +$TMUX select-layout -t layouts:many tiled || exit 1 +tiled=$(layout) +$TMUX select-layout -t layouts:many even-horizontal || exit 1 +$TMUX select-layout -t layouts:many -o || exit 1 +[ "$(layout)" = "$tiled" ] || fail "select-layout -o did not restore layout" +$TMUX select-layout -t layouts:many || exit 1 +$TMUX select-layout -t layouts:many -E || exit 1 + +# Each arranger has a deliberate one-pane early return. +$TMUX new-window -d -t layouts: -n one 'exec sleep 100' || exit 1 +one=$($TMUX display-message -p -t layouts:one '#{window_layout}') +for name in even-horizontal even-vertical main-horizontal \ + main-horizontal-mirrored main-vertical main-vertical-mirrored tiled; do + $TMUX select-layout -t layouts:one "$name" || exit 1 + [ "$($TMUX display-message -p -t layouts:one '#{window_layout}')" = "$one" ] || + fail "$name changed a one-pane layout" +done + +# Force the minimum-size paths without requiring a terminal client. +$TMUX resize-window -t layouts:many -x10 -y6 || exit 1 +for name in even-horizontal even-vertical main-horizontal \ + main-horizontal-mirrored main-vertical main-vertical-mirrored tiled; do + $TMUX select-layout -t layouts:many "$name" || exit 1 +done + +$TMUX has-session -t layouts || fail "server died during layout tests" +exit 0 diff --git a/regress/list-commands.sh b/regress/list-commands.sh new file mode 100644 index 000000000..9e1463bea --- /dev/null +++ b/regress/list-commands.sh @@ -0,0 +1,46 @@ +#!/bin/sh + +# Exercise list-commands for the full table, a single command, aliases, custom +# and empty formats, and lookup errors. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +TMUX="$TEST_TMUX -Ltest$$ -f/dev/null" + +cleanup() +{ + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +# Keep a server alive across commands. Otherwise a STARTSERVER-only command +# may exit between invocations and leave a short socket-cleanup race. +$TMUX new-session -d -s list-commands 'exec sleep 100' || exit 1 + +all=$($TMUX list-commands) || exit 1 +printf '%s\n' "$all" | grep -q '^attach-session (attach)' || exit 1 +printf '%s\n' "$all" | grep -q '^list-commands (lscm)' || exit 1 + +one=$($TMUX list-commands -F \ + '#{command_list_name}|#{command_list_alias}|#{command_list_usage}' \ + list-commands) || exit 1 +case "$one" in +'list-commands|lscm|'*) ;; +*) exit 1 ;; +esac + +# Aliases resolve to their command, while an empty expansion prints no line. +[ "$($TMUX list-commands -F '#{command_list_name}' lscm)" = list-commands ] || + exit 1 +[ -z "$($TMUX list-commands -F '' list-commands)" ] || exit 1 + +$TMUX list-commands tmux-no-command-$$ >/dev/null 2>&1 && exit 1 +$TMUX list-commands list >/dev/null 2>&1 && exit 1 +exit 0 diff --git a/regress/prompt-words-history.sh b/regress/prompt-words-history.sh new file mode 100644 index 000000000..6aaa5689c --- /dev/null +++ b/regress/prompt-words-history.sh @@ -0,0 +1,165 @@ +#!/bin/sh + +# Cover prompt word movement in emacs and vi modes, ambiguous inline command +# completion, and the show/clear-prompt-history command surface. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +export PATH TERM LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX_TMPDIR=$DIR +export TMUX_TMPDIR +INNER="$TEST_TMUX -LtestI$$ -f/dev/null" +OUTER="$TEST_TMUX -LtestO$$ -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $OUTER kill-server 2>/dev/null + $INNER kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 1 15 + +capture() +{ + $OUTER capture-pane -p -t outer:0.0 2>/dev/null +} + +wait_result() +{ + want=$1 + i=0 + while [ "$i" -lt 50 ]; do + got=$($INNER show-option -gqv @result 2>/dev/null) + [ "$got" = "$want" ] && return 0 + sleep 0.1 + i=$((i + 1)) + done + fail "prompt result is '$got', expected '$want'" +} + +bind_prompt() +{ + initial=$1 + $INNER bind-key -n M-r command-prompt -I "$initial" -p '(word)' \ + "set-option -g @result '%%'" || exit 1 +} + +run_prompt() +{ + want=$1 + shift + $INNER set-option -g @result sentinel || exit 1 + $OUTER send-keys M-r || exit 1 + sleep 0.2 + $OUTER send-keys "$@" || exit 1 + $OUTER send-keys Enter || exit 1 + wait_result "$want" +} + +run_vi_prompt() +{ + want=$1 + shift + $INNER set-option -g @result sentinel || exit 1 + $OUTER send-keys M-r || exit 1 + sleep 0.2 + # Send Escape separately so the terminal's escape-time handling does not + # combine it with the first vi command as a Meta key. + $OUTER send-keys Escape || exit 1 + # The inner client must see Escape as a complete key, not the prefix of a + # Meta sequence (the default escape-time is 500 milliseconds). + sleep 0.7 + $OUTER send-keys "$@" || exit 1 + $OUTER send-keys Enter || exit 1 + wait_result "$want" +} + +$INNER new-session -d -s prompt -x80 -y24 'exec sleep 100' || exit 1 +$INNER set-option -g status on || exit 1 +$INNER set-option -g status-position bottom || exit 1 +$INNER set-option -g window-size manual || exit 1 +$OUTER new-session -d -s outer -x80 -y24 "$INNER attach -t prompt" || exit 1 +$OUTER set-option -g status off || exit 1 +$OUTER set-option -g window-size manual || exit 1 +sleep 1 + +# Emacs Meta-f stops after the first word; Meta-b returns to the start of the +# previous word. Inserting a marker makes the cursor position observable. +$INNER set-option -g status-keys emacs || exit 1 +bind_prompt 'one two' +run_prompt 'oneX two' Home M-f X +bind_prompt 'one two' +run_prompt 'one Xtwo' M-b X + +# Vi translation and the distinct separator-aware and WORD motions. +$INNER set-option -g status-keys vi || exit 1 +bind_prompt 'one-two three' +run_vi_prompt 'one-two Xthree' b i X +bind_prompt 'one-two three' +run_vi_prompt 'one-two Xthree' B i X +bind_prompt 'one-two three' +run_vi_prompt 'oneX-two three' 0 w i X +bind_prompt 'one-two three' +run_vi_prompt 'one-two Xthree' 0 W i X +bind_prompt 'one-two three' +run_vi_prompt 'oneX-two three' 0 e a X +bind_prompt 'one-two three' +run_vi_prompt 'one-twoX three' 0 E a X + +# "show-" has several command matches and no longer common prefix. Tab keeps +# the input and draws the sorted candidates inline. +$INNER set-option -g status-keys emacs || exit 1 +bind_prompt '' +$OUTER send-keys M-r || exit 1 +sleep 0.2 +$OUTER send-keys -l 'show-' || exit 1 +$OUTER send-keys Tab || exit 1 +sleep 0.2 +captured=$(capture) +printf '%s\n' "$captured" | grep -Fq 'show-buffer' || + fail "ambiguous completion list was not drawn" +printf '%s\n' "$captured" | grep -Fq 'show-environment' || + fail "ambiguous completion list was incomplete" +$OUTER send-keys Escape || exit 1 + +# Add entries to both history rings through real prompts. +bind_prompt 'history-command' +run_prompt 'history-command' +$INNER bind-key -n M-s command-prompt -T search -I history-search \ + -p '(search-history)' "set-option -g @result '%%'" || exit 1 +$OUTER send-keys M-s || exit 1 +$OUTER send-keys Enter || exit 1 +wait_result history-search + +command_history=$($INNER show-prompt-history -T command) || exit 1 +printf '%s\n' "$command_history" | grep -Fq 'history-command' || + fail "command history entry missing" +search_history=$($INNER show-prompt-history -T search) || exit 1 +printf '%s\n' "$search_history" | grep -Fq 'history-search' || + fail "search history entry missing" +all_history=$($INNER show-prompt-history) || exit 1 +printf '%s\n' "$all_history" | grep -Fq 'History for command:' || exit 1 +printf '%s\n' "$all_history" | grep -Fq 'History for search:' || exit 1 + +$INNER show-prompt-history -T invalid >/dev/null 2>&1 && + fail "invalid show history type succeeded" +$INNER clear-prompt-history -T invalid >/dev/null 2>&1 && + fail "invalid clear history type succeeded" +$INNER clear-prompt-history -T command || exit 1 +$INNER show-prompt-history -T command | grep -Fq history-command && + fail "type-specific history clear failed" +$INNER clear-prompt-history || exit 1 +$INNER show-prompt-history | grep -Fq history-search && + fail "all-history clear failed" + +exit 0 From 9fa390aee696f7c3886bc9146e4051cbbe9593cb Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Wed, 2 Sep 2026 12:32:29 +0100 Subject: [PATCH 10/38] Fix error in test. --- regress/clear-on-attach.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/regress/clear-on-attach.sh b/regress/clear-on-attach.sh index 007a6916c..012064660 100644 --- a/regress/clear-on-attach.sh +++ b/regress/clear-on-attach.sh @@ -88,7 +88,7 @@ escape=$(printf '\033') if ! LC_ALL=C grep -Eq "${escape}\\[[0-9]+S" "$off"; then hex=$(od -An -tx1 -v "$off" | tr -d ' \n') case "$hex" in - *0a0a0a0a0a0a0a0a0a0a0a0a*) ;; + *0a0a0a0a0a0a0a0a0a0a0a*) ;; *) fail "clear-on-attach off did not scroll the old contents away" ;; esac fi From 4b1586fa81f2cca49b4195ceba7b18d516ece106 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Thu, 3 Sep 2026 20:12:44 +0100 Subject: [PATCH 11/38] Test for exit status. --- regress/hooks-notify.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/regress/hooks-notify.sh b/regress/hooks-notify.sh index 6eb6ae49e..6c7029463 100644 --- a/regress/hooks-notify.sh +++ b/regress/hooks-notify.sh @@ -218,11 +218,13 @@ $TMUX set-hook -gu pane-mode-changed || fail "unset pane-mode-changed failed" # pane-exited when a pane's command exits with remain-on-exit off. $TMUX set -g @x 0 || fail "set @x failed" -$TMUX set-hook -g pane-exited 'set -gF @x "#{hook}:#{hook_pane}"' || +$TMUX set-hook -g pane-exited \ + 'set -gF @x "#{hook}:#{hook_pane}:#{hook_exit_status}:#{hook_exit_success}"' || fail "set-hook pane-exited failed" -pane=$($TMUX splitw -d -t main:0 -P -F '#{pane_id}' 'true') || - fail "split-window true failed" -wait_for @x "pane-exited:$pane" +pane=$($TMUX splitw -d -t main:0 -P -F '#{pane_id}' \ + 'trap "" HUP; exec /dev/null 2>&1; sleep 1; exit 42') || + fail "split-window exit 42 failed" +wait_for @x "pane-exited:$pane:42:0" $TMUX set-hook -gu pane-exited || fail "unset pane-exited failed" # pane-died when a pane's command exits with remain-on-exit on. From 5d5f66239a60e5bb1784685418bf8c19f0e3bbb0 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Thu, 3 Sep 2026 22:04:34 +0100 Subject: [PATCH 12/38] Test for -S from Mazunki Hoksaas. --- regress/window-ops.sh | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/regress/window-ops.sh b/regress/window-ops.sh index 25cbd8db0..c385a3328 100644 --- a/regress/window-ops.sh +++ b/regress/window-ops.sh @@ -8,7 +8,7 @@ # This exercises: # - new-window placement: next free index, explicit index, index in use with # and without -k, -a (after) and -b (before) insertion with shuffling, and -# -S selecting an existing window by name instead of creating; +# -S selecting an existing window by name or target instead of creating; # - move-window to a free index, to an occupied index with and without -k, # -a insertion and -r renumbering (including base-index); # - renumber-windows closing gaps; @@ -140,6 +140,36 @@ check_ok new-window -S -t W: -n w3 check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 9:w9k' check_fmt 'W:' '#{window_index}:#{window_name}' '5:w3' +# -S with a target-window that already exists selects it directly, ignoring +# -n (with -d it does not switch). +check_ok select-window -t W:0 +check_ok new-window -S -t W:9 -n ignored +check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 9:w9k' +check_fmt 'W:' '#{window_index}:#{window_name}' '9:w9k' +check_ok select-window -t W:0 +check_ok new-window -S -d -t W:9 -n ignored +check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 9:w9k' +check_fmt 'W:' '#{window_index}:#{window_name}' '0:wB' + +# -S with a target-window that does not yet exist still creates it normally. +check_ok new-window -S -t W:20 -n w20 +check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 9:w9k 20:w20' +check_fmt 'W:' '#{window_index}:#{window_name}' '20:w20' +check_ok kill-window -t W:20 + +# -S also works with a relative target-window (+N): selects the window at +# that offset if it exists, or creates it there if not. +check_ok select-window -t W:0 +check_ok new-window -S -t W:+1 +check_fmt 'W:' '#{window_index}:#{window_name}' '1:w0' +check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 9:w9k' + +check_ok select-window -t W:0 +check_ok new-window -S -t W:+6 -n rel6 +check_fmt 'W:' '#{window_index}:#{window_name}' '6:rel6' +check_windows W '0:wB 1:w0 2:w1 3:wA 4:w2 5:w3 6:rel6 9:w9k' +check_ok kill-window -t W:6 + # Clean up to a known arrangement. check_ok kill-window -t W:wB check_ok kill-window -t W:wA From 7b666e62be89e66aceef9f091a3c922ac5adf951 Mon Sep 17 00:00:00 2001 From: nicm Date: Thu, 3 Sep 2026 19:12:36 +0000 Subject: [PATCH 13/38] Wait for pane exit status in all cases not just remain-on-exit since events need it. --- server-fn.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server-fn.c b/server-fn.c index c833b0add..5c4ae3d2a 100644 --- a/server-fn.c +++ b/server-fn.c @@ -1,4 +1,4 @@ -/* $OpenBSD: server-fn.c,v 1.151 2026/08/20 09:19:24 nicm Exp $ */ +/* $OpenBSD: server-fn.c,v 1.152 2026/09/03 19:12:36 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -376,9 +376,9 @@ server_destroy_pane(struct window_pane *wp, int notify) wp->pipe_fd = -1; } - remain_on_exit = options_get_number(wp->options, "remain-on-exit"); - if (remain_on_exit != 0 && (~wp->flags & PANE_STATUSREADY)) + if (~wp->flags & PANE_STATUSREADY) return; + remain_on_exit = options_get_number(wp->options, "remain-on-exit"); switch (remain_on_exit) { case 0: break; From ce253864f88c08c97bc7ec32fda1fe50b2220345 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Thu, 3 Sep 2026 22:37:09 +0100 Subject: [PATCH 14/38] Test for late run-shell on a client that has exited. --- regress/control-client-run-shell-exit.sh | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 regress/control-client-run-shell-exit.sh diff --git a/regress/control-client-run-shell-exit.sh b/regress/control-client-run-shell-exit.sh new file mode 100644 index 000000000..e8acc0419 --- /dev/null +++ b/regress/control-client-run-shell-exit.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +# A waited run-shell command can complete after a control client has closed its +# input and exited. The command queue still references the client until the job +# callback runs, but control_stop has already destroyed c->control_state. Late +# command output must be discarded instead of dereferencing the stopped control +# state and killing the server. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -Ltest" +$TMUX kill-server 2>/dev/null + +OUT=$(mktemp) +trap "$TMUX kill-server 2>/dev/null; rm -f $OUT" 0 1 15 + +$TMUX -f/dev/null new-session -d -s main || exit 1 + +printf '%s\n' 'run-shell "sleep 0.2; echo TEST"' | + $TMUX -f/dev/null -C >"$OUT" 2>&1 + +sleep 1 + +$TMUX has-session -t main 2>/dev/null || { + echo "server exited after late run-shell output" + cat "$OUT" + exit 1 +} + +grep -q '^%exit' "$OUT" || { + echo "control client did not exit" + cat "$OUT" + exit 1 +} + +exit 0 From a6f9a206b7f31be24adc82d59bf352aa992a8111 Mon Sep 17 00:00:00 2001 From: nicm Date: Thu, 3 Sep 2026 21:04:11 +0000 Subject: [PATCH 15/38] Extend neww -S to work with -t as well as -t, GitHub issue 5571 from Mazunki Hoksaas. --- cmd-new-window.c | 62 ++++++++++++++++++++++++++++-------------------- tmux.1 | 19 ++++++++++----- 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/cmd-new-window.c b/cmd-new-window.c index d1e2ab4e5..6ec55fe64 100644 --- a/cmd-new-window.c +++ b/cmd-new-window.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-new-window.c,v 1.103 2026/07/08 08:07:42 nicm Exp $ */ +/* $OpenBSD: cmd-new-window.c,v 1.104 2026/09/03 21:04:11 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -60,7 +60,8 @@ cmd_new_window_exec(struct cmd *self, struct cmdq_item *item) struct client *tc = cmdq_get_target_client(item); struct session *s = target->s; struct winlink *wl = target->wl, *new_wl = NULL; - int idx = target->idx, before, count = args_count(args); + int idx = target->idx, before; + int count = args_count(args); char *cause = NULL, *cp, *expanded, *wname = NULL; const char *template, *name; struct cmd_find_state fs; @@ -74,8 +75,10 @@ cmd_new_window_exec(struct cmd *self, struct cmdq_item *item) } /* - * If -S and -n are given and -t is not and a single window with this - * name already exists, select it. + * If -S is given, select an existing window instead of creating a + * new one: preferring -t if it already points at a window, or + * otherwise -n if one exists with that name. If neither matches, + * fall through and create a window as normal. */ name = args_get(args, 'n'); if (name != NULL) { @@ -88,32 +91,39 @@ cmd_new_window_exec(struct cmd *self, struct cmdq_item *item) wname = clean_name(expanded, 0); free(expanded); } - if (args_has(args, 'S') && wname != NULL && target->idx == -1) { - expanded = format_single(item, wname, c, s, NULL, NULL); - RB_FOREACH(wl, winlinks, &s->windows) { - if (strcmp(wl->window->name, expanded) != 0) - continue; - if (new_wl == NULL) { - new_wl = wl; - continue; + if (args_has(args, 'S')) { + if (idx != -1) + new_wl = winlink_find_by_index(&s->windows, idx); + else if (wname != NULL) { + expanded = format_single(item, wname, c, s, NULL, NULL); + RB_FOREACH(wl, winlinks, &s->windows) { + if (strcmp(wl->window->name, expanded) != 0) + continue; + if (new_wl == NULL) { + new_wl = wl; + continue; + } + cmdq_error(item, "multiple windows named %s", + wname); + free(wname); + free(expanded); + return (CMD_RETURN_ERROR); } - cmdq_error(item, "multiple windows named %s", wname); - free(wname); free(expanded); - return (CMD_RETURN_ERROR); } - free(expanded); - if (new_wl != NULL) { - free(wname); - if (args_has(args, 'd')) - return (CMD_RETURN_NORMAL); - if (session_set_current(s, new_wl) == 0) - server_redraw_session(s); - if (c != NULL && c->session != NULL) - s->curw->window->latest = c; - recalculate_sizes(); + } + + /* Found an existing window to select instead of creating a new one. */ + if (new_wl != NULL) { + free(wname); + if (args_has(args, 'd')) return (CMD_RETURN_NORMAL); - } + if (session_set_current(s, new_wl) == 0) + server_redraw_session(s); + if (c != NULL && c->session != NULL) + s->curw->window->latest = c; + recalculate_sizes(); + return (CMD_RETURN_NORMAL); } before = args_has(args, 'b'); diff --git a/tmux.1 b/tmux.1 index 8ecca4fbd..f73d3f5b9 100644 --- a/tmux.1 +++ b/tmux.1 @@ -1,4 +1,4 @@ -.\" $OpenBSD: tmux.1,v 1.1163 2026/09/01 12:49:49 nicm Exp $ +.\" $OpenBSD: tmux.1,v 1.1164 2026/09/03 21:04:11 nicm Exp $ .\" .\" Copyright (c) 2007 Nicholas Marriott .\" @@ -14,7 +14,7 @@ .\" IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING .\" OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. .\" -.Dd $Mdocdate: September 1 2026 $ +.Dd $Mdocdate: September 3 2026 $ .Dt TMUX 1 .Os .Sh NAME @@ -3588,13 +3588,20 @@ represents the window to be created; if the target already exists an error is shown, unless the .Fl k flag is used, in which case it is destroyed. +.Ar window\-name +is the name given to the new window, if any. +.Pp If .Fl S -is given and a window named -.Ar window\-name -already exists, it is selected (unless +is given, an existing window is selected instead of creating a new one +(unless .Fl d -is also given in which case the command does nothing). +is also given in which case the command does nothing): +.Ar target\-window +is preferred if it already identifies an existing window; otherwise a +window named +.Ar window\-name +is selected if one exists. .Pp .Ar shell\-command is the command to execute. From 93169e2e6231853c61c33432bceebf4ffc91015b Mon Sep 17 00:00:00 2001 From: nicm Date: Thu, 3 Sep 2026 21:35:38 +0000 Subject: [PATCH 16/38] Do not attempt to write to control clients which have been destroyed, reported by Artur Penttinen in GitHub issue 5570. --- control.c | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/control.c b/control.c index c3bb82b01..af0d8b91a 100644 --- a/control.c +++ b/control.c @@ -1,4 +1,4 @@ -/* $OpenBSD: control.c,v 1.66 2026/08/18 07:43:44 nicm Exp $ */ +/* $OpenBSD: control.c,v 1.67 2026/09/03 21:35:38 nicm Exp $ */ /* * Copyright (c) 2012 Nicholas Marriott @@ -455,8 +455,12 @@ control_flush_deferred(struct client *c) void control_write(struct client *c, const char *fmt, ...) { - va_list ap; - char *line; + struct control_state *cs = c->control_state; + char *line; + va_list ap; + + if (cs == NULL) + return; va_start(ap, fmt); xvasprintf(&line, fmt, ap); @@ -479,6 +483,9 @@ control_write_guard(struct client *c, const char *guard, long t, u_int number, struct control_state *cs = c->control_state; char *line; + if (cs == NULL) + return; + if (strcmp(guard, "begin") == 0) cs->guard_depth++; @@ -503,6 +510,9 @@ control_notify_write(struct client *c, const char *fmt, ...) va_list ap; char *line; + if (cs == NULL) + return; + va_start(ap, fmt); xvasprintf(&line, fmt, ap); va_end(ap); From 73db0a54e5ae5abf80bb790829222c9760f60b1d Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Mon, 7 Sep 2026 13:05:38 +0100 Subject: [PATCH 17/38] Tests from GitHub issue 5576. --- regress/targets-panes.sh | 4 ++++ regress/targets.sh | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/regress/targets-panes.sh b/regress/targets-panes.sh index 9ea936173..6ec5ffe7d 100644 --- a/regress/targets-panes.sh +++ b/regress/targets-panes.sh @@ -97,6 +97,10 @@ check "p:.{top-left}" "%0" check_ok select-pane -t p:0.%0 check "p:0.+" "1" '#{pane_index}' # next pane check "p:0.-" "3" '#{pane_index}' # previous pane (wraps) +check_fail "can't find pane: +0" "p:0.+0" +check_fail "can't find pane: +foo" "p:0.+foo" +check_fail "can't find pane: -0" "p:0.-0" +check_fail "can't find pane: -foo" "p:0.-foo" # --- last pane (!) -------------------------------------------------------- check_ok select-pane -t p:0.%2 diff --git a/regress/targets.sh b/regress/targets.sh index 75ee3dbb8..768feda07 100644 --- a/regress/targets.sh +++ b/regress/targets.sh @@ -154,6 +154,13 @@ check "alpha:{previous}" "3" check "alpha:!" "2" # last window check "alpha:{last}" "2" +# Malformed relative offsets must fail rather than silently selecting the +# current window. +check_fail "can't find window: +0" "alpha:+0" +check_fail "can't find window: +foo" "alpha:+foo" +check_fail "can't find window: -0" "alpha:-0" +check_fail "can't find window: -foo" "alpha:-foo" + # --- combined and empty forms --------------------------------------------- # # Empty targets use the current pane from TMUX_PANE when there is no client. From 557967c36ce4d80412405fb2ac20b599186689ba Mon Sep 17 00:00:00 2001 From: nicm Date: Mon, 7 Sep 2026 10:15:25 +0000 Subject: [PATCH 18/38] Do not silently make a session monitor if the target is unknown, GitHub issue 5575 from zzchun12826 at gmail dot com. --- monitor.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/monitor.c b/monitor.c index ff0c6e5ed..596e137a7 100644 --- a/monitor.c +++ b/monitor.c @@ -1,4 +1,4 @@ -/* $OpenBSD: monitor.c,v 1.7 2026/07/27 19:15:58 nicm Exp $ */ +/* $OpenBSD: monitor.c,v 1.8 2026/09/07 10:15:25 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -614,8 +614,10 @@ monitor_parse(const char *value, char **name, enum monitor_type *type, int *id, *type = MONITOR_ALL_WINDOWS; else if (sscanf(what, "@%d", id) == 1 && *id >= 0) *type = MONITOR_WINDOW; - else + else if (*what == '\0') *type = MONITOR_SESSION; + else + goto fail; *name = xstrdup(copy); *format = xstrdup(split); From 00f3899aea035666fa9e7bd60bc7c2cf36c8e745 Mon Sep 17 00:00:00 2001 From: nicm Date: Mon, 7 Sep 2026 12:05:12 +0000 Subject: [PATCH 19/38] Error on invalid relative targets, such as +foo or -0. GitHub issue 5576 from imcusg at gmail dot com. --- cmd-find.c | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/cmd-find.c b/cmd-find.c index b872d9eff..461d44a2f 100644 --- a/cmd-find.c +++ b/cmd-find.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-find.c,v 1.87 2026/07/17 12:42:51 nicm Exp $ */ +/* $OpenBSD: cmd-find.c,v 1.88 2026/09/07 12:05:12 nicm Exp $ */ /* * Copyright (c) 2015 Nicholas Marriott @@ -389,9 +389,11 @@ cmd_find_get_window_with_session(struct cmd_find_state *fs, const char *window) /* Try as an offset. */ if (!exact && (window[0] == '+' || window[0] == '-')) { - if (window[1] != '\0') - n = strtonum(window + 1, 1, INT_MAX, NULL); - else + if (window[1] != '\0') { + n = strtonum(window + 1, 1, INT_MAX, &errstr); + if (errstr != NULL) + return (-1); + } else n = 1; s = fs->s; if (fs->flags & CMD_FIND_WINDOW_INDEX) { @@ -627,9 +629,11 @@ cmd_find_get_pane_with_window(struct cmd_find_state *fs, const char *pane) /* Try as an offset. */ if (pane[0] == '+' || pane[0] == '-') { - if (pane[1] != '\0') - n = strtonum(pane + 1, 1, INT_MAX, NULL); - else + if (pane[1] != '\0') { + n = strtonum(pane + 1, 1, INT_MAX, &errstr); + if (errstr != NULL) + return (-1); + } else n = 1; wp = fs->w->active; if (pane[0] == '+') From 8396a63128f7470affd2992969a27ec4ef5d2df6 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Tue, 8 Sep 2026 08:32:05 +0100 Subject: [PATCH 20/38] capture-pane -I test. --- regress/capture-pane-time.sh | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 regress/capture-pane-time.sh diff --git a/regress/capture-pane-time.sh b/regress/capture-pane-time.sh new file mode 100644 index 000000000..66faa29c3 --- /dev/null +++ b/regress/capture-pane-time.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +# capture-pane -I line timestamps + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +TMP=$(mktemp) +trap '$TMUX kill-server 2>/dev/null; rm -f "$TMP"' 0 1 15 +$TMUX kill-server 2>/dev/null + +before=$(date +%s) +$TMUX new-session -d -x 40 -y 5 'seq 1 12; sleep 10' || exit 1 +sleep 1 +after=$(date +%s) + +$TMUX capture-pane -pILF -S - -E - >"$TMP" || exit 1 +awk -v before="$before" -v after="$after" ' + $1 !~ /^-?[0-9]+$/ || $2 !~ /^[0-9]+$/ { exit 1 } + $4 == "1" { + if ($2 < before || $2 > after) + exit 1 + history = 1 + } + $4 == "9" { + if ($2 != 0) + exit 1 + visible = 1 + } + END { if (!history || !visible) exit 1 } +' "$TMP" || { + cat "$TMP" + exit 1 +} + +exit 0 From 6a24d5437b5c69486cb2c2ef10ff91b881a319d5 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Tue, 8 Sep 2026 09:36:26 +0100 Subject: [PATCH 21/38] JSON test. --- regress/json.sh | 137 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100755 regress/json.sh diff --git a/regress/json.sh b/regress/json.sh new file mode 100755 index 000000000..7a924d1e9 --- /dev/null +++ b/regress/json.sh @@ -0,0 +1,137 @@ +#!/bin/sh + +# Test parsing and printing JSON with display-message -j. + +PATH=/bin:/usr/bin +TERM=screen + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +$TMUX kill-server 2>/dev/null + +fail() +{ + echo "$1" + $TMUX kill-server 2>/dev/null + exit 1 +} + +check() +{ + actual=$($TMUX display-message -plj "$1" 2>&1) || + fail "JSON was rejected: $1: $actual" + [ "$actual" = "$2" ] || + fail "JSON output mismatch: $1: expected $2, got $actual" +} + +check_fail() +{ + if $TMUX display-message -plj "$1" >/dev/null 2>&1; then + fail "invalid JSON was accepted: $1" + fi +} + +$TMUX new-session -d || exit 1 + +check '{}' '{}' +check ' { "z":true, "a":-9223372036854775808, "m":[{}, {"x":false}] } ' \ + '{"a":-9223372036854775808,"m":[{},{"x":false}],"z":true}' +check '{"max":9223372036854775807,"min":-9223372036854775808}' \ + '{"max":9223372036854775807,"min":-9223372036854775808}' +check '{" key":" value","{key":"[value"}' \ + '{" key":" value","{key":"[value"}' +check '{"brace":"{value","bracket":"[value","colon":":value",'\ +'"comma":",value","space":" value"}' \ + '{"brace":"{value","bracket":"[value","colon":":value",'\ +'"comma":",value","space":" value"}' +check '{"esc":"\"\\\/\b\f\n\r\t\u0041"}' \ + '{"esc":"\"\\\/\b\f\n\r\t\u0041"}' +check '{"unicode":"\u0123\uabcd\uABCD"}' \ + '{"unicode":"\u0123\uabcd\uABCD"}' + +actual=$($TMUX display-message -palj '{}' 2>&1) || + fail "display-message -aj rejected JSON: $actual" +[ "$actual" = '{}' ] || + fail "display-message -aj did not ignore -a: got $actual" +actual=$($TMUX display-message -pIlj '{}' 2>&1) || + fail "display-message -Ij rejected JSON: $actual" +[ "$actual" = '{}' ] || + fail "display-message -Ij did not ignore -I: got $actual" + +check_fail '' +check_fail ' ' +check_fail '[]' +check_fail 'true' +check_fail '1' +check_fail '"string"' +check_fail '{}{}' + +check_fail '{' +check_fail '{x:1}' +check_fail '{"":1}' +check_fail '{"x" 1}' +check_fail '{"x",1}' +check_fail '{"x":}' +check_fail '{"x":,}' +check_fail '{"x":1' +check_fail '{"x":1 "y":2}' +check_fail '{"x":1,,"y":2}' +check_fail '{"x":1,}' +check_fail '{"x":1,"x":2}' +check_fail '{"x":{}}{}' +check_fail '{"x":{}} trailing' +check_fail '{"x":{' +check_fail '{"x":{}' + +check_fail '{"x":[' +check_fail '{"x":[}' +check_fail '{"x":[{}' +check_fail '{"x":[{},}' +check_fail '{"x":[{},]}' +check_fail '{"x":[{}{}]}' +check_fail '{"x":[{},,{}]}' +check_fail '{"x":[{"y":}]}' +check_fail '{"x":""}' +check_fail '{"x":null}' +check_fail '{"x":[null]}' +check_fail '{"x":[true]}' +check_fail '{"x":["string"]}' +check_fail '{"x":[[]]}' +check_fail '{"x":1.0}' +check_fail '{"x":1e2}' +check_fail '{"x":+1}' +check_fail '{"x":-}' +check_fail '{"x":0x10}' +check_fail '{"x":01}' +check_fail '{"x":-01}' +check_fail '{"x":[1]}' +check_fail '{"x":9223372036854775808}' +check_fail '{"x":-9223372036854775809}' +check_fail '{"x":tru}' +check_fail '{"x":True}' +check_fail '{"x":falsee}' +check_fail '{"x":"bad\q"}' +check_fail '{"x":"bad\x20"}' +check_fail '{"x":"bad\u123"}' +check_fail '{"x":"bad\u12x4"}' +check_fail '{"x":"unterminated}' +check_fail '{"x":"escaped quote\"}' +check_fail '{"x":"line +break"}' + +# The maximum object nesting depth is 200. +json='{}' +n=1 +while [ "$n" -lt 200 ]; do + json='{"x":'"$json"'}' + n=$((n + 1)) +done +check "$json" "$json" +check_fail '{"x":'"$json"'}' + +if $TMUX display-message -pj >/dev/null 2>&1; then + fail "display-message -j accepted a missing message" +fi + +$TMUX kill-server 2>/dev/null +exit 0 From 96f4c7b9faf02b14bf49260e8fb3848f8b7ffb72 Mon Sep 17 00:00:00 2001 From: nicm Date: Tue, 8 Sep 2026 07:31:59 +0000 Subject: [PATCH 22/38] Add capture-pane -I to show times. --- cmd-capture-pane.c | 20 +++++++++++++------- tmux.1 | 8 +++++--- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/cmd-capture-pane.c b/cmd-capture-pane.c index baf6fc62d..5b7c922ba 100644 --- a/cmd-capture-pane.c +++ b/cmd-capture-pane.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-capture-pane.c,v 1.68 2026/07/20 11:16:33 nicm Exp $ */ +/* $OpenBSD: cmd-capture-pane.c,v 1.69 2026/09/08 07:31:59 nicm Exp $ */ /* * Copyright (c) 2009 Jonathan Alvarado @@ -43,8 +43,8 @@ const struct cmd_entry cmd_capture_pane_entry = { .name = "capture-pane", .alias = "capturep", - .args = { "ab:CeE:FHJLMNpPqRS:Tt:", 0, 0, NULL }, - .usage = "[-aCeFHJLMNpPqRT] " CMD_BUFFER_USAGE " [-E end-line] " + .args = { "ab:CeE:FHIJLMNpPqRS:Tt:", 0, 0, NULL }, + .usage = "[-aCeFHIJLMNpPqRT] " CMD_BUFFER_USAGE " [-E end-line] " "[-S start-line] " CMD_TARGET_PANE_USAGE, .target = { 't', CMD_FIND_PANE, 0 }, @@ -201,7 +201,7 @@ cmd_capture_pane_pending(struct args *args, struct window_pane *wp, tmp[0] = line[i]; tmp[1] = '\0'; } else - xsnprintf(tmp, sizeof tmp, "\\%03hho", line[i]); + snprintf(tmp, sizeof tmp, "\\%03hho", line[i]); buf = cmd_capture_pane_append(buf, len, tmp, strlen(tmp)); } @@ -260,7 +260,7 @@ cmd_capture_pane_history(struct args *args, struct cmdq_item *item, struct grid_cell *gc = NULL; struct window_mode_entry *wme; int n, join_lines, number_lines, flags = 0; - int show_flags, hyperlinks; + int show_flags, show_time, hyperlinks; u_int *links = NULL, nlinks = 0; u_int i, sx, top, bottom, tmp; char *cause, *buf = NULL, *line, b[64], *cp; @@ -343,6 +343,7 @@ cmd_capture_pane_history(struct args *args, struct cmdq_item *item, flags |= GRID_STRING_TRIM_SPACES; number_lines = args_has(args, 'L'); show_flags = args_has(args, 'F'); + show_time = args_has(args, 'I'); hyperlinks = args_has(args, 'H'); if (hyperlinks) links = xreallocarray(NULL, gd->sx, sizeof *links); @@ -359,6 +360,7 @@ cmd_capture_pane_history(struct args *args, struct cmdq_item *item, free(line); continue; } + gl = grid_peek_line(gd, i); if (number_lines) { if (i >= gd->hsize) @@ -369,11 +371,16 @@ cmd_capture_pane_history(struct args *args, struct cmdq_item *item, if (n >= 0) buf = cmd_capture_pane_append(buf, len, b, n); } + if (show_time) { + n = snprintf(b, sizeof b, "%llu ", + (unsigned long long)grid_line_time(gl)); + if (n >= 0) + buf = cmd_capture_pane_append(buf, len, b, n); + } if (show_flags) { cp = b; *cp = '\0'; - gl = grid_peek_line(gd, i); if (gl->flags & GRID_LINE_DEAD) *cp++ = 'D'; if (gl->flags & GRID_LINE_HYPERLINK) @@ -394,7 +401,6 @@ cmd_capture_pane_history(struct args *args, struct cmdq_item *item, } buf = cmd_capture_pane_append(buf, len, line, linelen); - gl = grid_peek_line(gd, i); if (!join_lines || !(gl->flags & GRID_LINE_WRAPPED)) buf[(*len)++] = '\n'; diff --git a/tmux.1 b/tmux.1 index f73d3f5b9..81d9fc287 100644 --- a/tmux.1 +++ b/tmux.1 @@ -1,4 +1,4 @@ -.\" $OpenBSD: tmux.1,v 1.1164 2026/09/03 21:04:11 nicm Exp $ +.\" $OpenBSD: tmux.1,v 1.1165 2026/09/08 07:31:59 nicm Exp $ .\" .\" Copyright (c) 2007 Nicholas Marriott .\" @@ -14,7 +14,7 @@ .\" IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING .\" OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. .\" -.Dd $Mdocdate: September 3 2026 $ +.Dd $Mdocdate: September 8 2026 $ .Dt TMUX 1 .Os .Sh NAME @@ -2786,7 +2786,7 @@ The pane must not already be floating or hidden, and the window must not be zoomed. .Tg capturep .It Xo Ic capture\-pane -.Op Fl aeFHLpPRqCJMN +.Op Fl aeFHILpPRqCJMN .Op Fl b Ar buffer\-name .Op Fl E Ar end\-line .Op Fl S Ar start\-line @@ -2828,6 +2828,8 @@ captures only any output that the pane has received that is the beginning of an as-yet incomplete escape sequence. .Fl L includes the line number at the start of each line and +.Fl I +includes the time each line entered the history, or zero if not available. .Fl F includes the flags (where .Ql - From 01d1d6580ab2455d15d9602fe95c54118bef1912 Mon Sep 17 00:00:00 2001 From: nicm Date: Tue, 8 Sep 2026 08:33:10 +0000 Subject: [PATCH 23/38] Add a parser for a subset of JSON, will be used for new layout strings (and maybe some other stuff), from Dane Jensen. --- Makefile | 3 +- cmd-display-message.c | 30 +- json.c | 1001 +++++++++++++++++++++++++++++++++++++++++ tmux.1 | 10 +- tmux.h | 28 +- 5 files changed, 1061 insertions(+), 11 deletions(-) create mode 100644 json.c diff --git a/Makefile b/Makefile index a6d16a618..fdaee5a6f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# $OpenBSD: Makefile,v 1.122 2026/08/01 17:04:12 miod Exp $ +# $OpenBSD: Makefile,v 1.123 2026/09/08 08:33:10 nicm Exp $ PROG= tmux SRCS= alerts.c \ @@ -89,6 +89,7 @@ SRCS= alerts.c \ input-keys.c \ input.c \ job.c \ + json.c \ key-bindings.c \ key-string.c \ layout-custom.c \ diff --git a/cmd-display-message.c b/cmd-display-message.c index f786006c3..0e294ef50 100644 --- a/cmd-display-message.c +++ b/cmd-display-message.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-display-message.c,v 1.65 2026/02/23 08:46:57 nicm Exp $ */ +/* $OpenBSD: cmd-display-message.c,v 1.66 2026/09/08 08:33:10 nicm Exp $ */ /* * Copyright (c) 2009 Tiago Cunha @@ -39,8 +39,8 @@ const struct cmd_entry cmd_display_message_entry = { .name = "display-message", .alias = "display", - .args = { "aCc:d:lINpt:F:v", 0, 1, NULL }, - .usage = "[-aCIlNpv] [-c target-client] [-d delay] [-F format] " + .args = { "aCc:d:jlINpt:F:v", 0, 1, NULL }, + .usage = "[-aCIjlNpv] [-c target-client] [-d delay] [-F format] " CMD_TARGET_PANE_USAGE " [message]", .target = { 't', CMD_FIND_PANE, CMD_FIND_CANFAIL }, @@ -67,14 +67,15 @@ cmd_display_message_exec(struct cmd *self, struct cmdq_item *item) struct winlink *wl = target->wl; struct window_pane *wp = target->wp; const char *template; - char *msg, *cause; + char *msg, *cause = NULL; int delay = -1, flags, Nflag = args_has(args, 'N'); int Cflag = args_has(args, 'C'); struct format_tree *ft; u_int count = args_count(args); struct evbuffer *evb; + struct json_node *jn; - if (args_has(args, 'I')) { + if (args_has(args, 'I') && !args_has(args, 'j')) { if (wp == NULL) return (CMD_RETURN_NORMAL); switch (window_pane_start_input(wp, item, &cause)) { @@ -107,7 +108,9 @@ cmd_display_message_exec(struct cmd *self, struct cmdq_item *item) template = args_string(args, 0); else template = args_get(args, 'F'); - if (template == NULL) + if (args_has(args, 'j') && template == NULL) + template = ""; + else if (template == NULL) template = DISPLAY_MESSAGE_TEMPLATE; /* @@ -129,7 +132,7 @@ cmd_display_message_exec(struct cmd *self, struct cmdq_item *item) ft = format_create(cmdq_get_client(item), item, FORMAT_NONE, flags); format_defaults(ft, c, s, wl, wp); - if (args_has(args, 'a')) { + if (args_has(args, 'a') && !args_has(args, 'j')) { format_each(ft, cmd_display_message_each, item); format_free(ft); return (CMD_RETURN_NORMAL); @@ -139,6 +142,19 @@ cmd_display_message_exec(struct cmd *self, struct cmdq_item *item) msg = xstrdup(template); else msg = format_expand_time(ft, template); + if (args_has(args, 'j')) { + jn = json_parse(msg, &cause); + if (jn == NULL) { + cmdq_error(item, "%s", cause); + free(cause); + free(msg); + format_free(ft); + return (CMD_RETURN_ERROR); + } + free(msg); + msg = json_to_string(jn); + json_destroy_node(jn); + } if (cmdq_get_client(item) == NULL) cmdq_error(item, "%s", msg); diff --git a/json.c b/json.c new file mode 100644 index 000000000..1e07ca95b --- /dev/null +++ b/json.c @@ -0,0 +1,1001 @@ +/* $OpenBSD: json.c,v 1.1 2026/09/08 08:33:10 nicm Exp $ */ + +/* + * Copyright (c) 2026 Dane Jensen + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER + * IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING + * OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include + +#include +#include +#include +#include +#include + +#include "tmux.h" + +/* + * Parse a subset of JSON. The subset accepted is: + * + * - Arrays may only hold objects. + * - The top-level value must be an object. + * - Numbers are 64-bit signed integers in base 10; there are no fractions and + * no exponents. + * - There is no null, and a string may not be empty. + * - Escapes are validated but not decoded. + * - A key may not appear twice in the same object. Note that because escapes + * are not decoded, duplicate keys may go undetected. + * - Objects may only be parsed to a fixed maximum depth. + */ + +#define ERROR_CTX_LEN 8 +#define PARSE_DEPTH_MAX 200 + +/* JSON token types. */ +enum json_token_type { + TOK_OPENOBJECT, + TOK_CLOSEOBJECT, + TOK_OPENARRAY, + TOK_CLOSEARRAY, + TOK_COMMA, + TOK_COLON, + TOK_QUOTE, + TOK_VALUE, + TOK_EOF +}; + +/* JSON token. */ +struct json_token { + enum json_token_type type; + int offset; + int len; +}; + +/* JSON tokens. */ +struct json_tokens { + int size; + int capacity; + struct json_token *toks; +}; + +/* JSON node type. */ +enum json_node_type { + NODE_STRING, + NODE_NUMBER, + NODE_BOOLEAN, + NODE_OBJECT, + NODE_ARRAY +}; + +/* JSON field tree. */ +RB_HEAD(json_fields, json_node); + +/* JSON array queue. */ +TAILQ_HEAD(json_members, json_node); + +/* JSON parse context. */ +struct json_parse_ctx { + const char *input; + char **cause; + int depth; +}; + +/* JSON node. */ +struct json_node { + enum json_node_type type; + char *key; + struct json_node *parent; + union { + char *str; + int64_t num; + int boolean; + struct json_fields fields; + struct json_members members; + }; + RB_ENTRY(json_node) oentry; + TAILQ_ENTRY(json_node) aentry; +}; + +static int +json_node_cmp(struct json_node *a, struct json_node *b) +{ + return (strcmp(a->key, b->key)); +} +RB_GENERATE_STATIC(json_fields, json_node, oentry, json_node_cmp); + +static struct json_tokens *json_tokenize_input(const char *, char **); +static struct json_tokens *json_create_tokens(void); +static void json_destroy_tokens(struct json_tokens *); +static void json_add_token(struct json_tokens *, + enum json_token_type, const char *, const char *, + int); +static int json_tokenize_value(struct json_tokens *, + const char *); +static void json_error(char **, const char *, const char *); +static struct json_node *json_create_node(struct json_node *, + enum json_node_type, const char *, void *); +static void json_assign_value(struct json_node *, void *); +static struct json_node *json_parse_tokens(struct json_tokens **, + struct json_parse_ctx *); +static char *json_parse_key(struct json_token **, + struct json_parse_ctx *); +static struct json_node *json_parse_object(struct json_token **, + struct json_parse_ctx *, const char *, + struct json_node *); +static struct json_node *json_parse_array(struct json_token **, + struct json_parse_ctx *, const char *, + struct json_node *); +static struct json_node *json_parse_string(struct json_token **, + struct json_parse_ctx *, const char *, + struct json_node *); +static struct json_node *json_parse_number(struct json_token **, + struct json_parse_ctx *, const char *, + struct json_node *); +static struct json_node *json_parse_boolean(struct json_token **, + struct json_parse_ctx *, const char *, + struct json_node *); + +/* Parse an input string into JSON. */ +struct json_node * +json_parse(const char *input, char **cause) +{ + struct json_tokens *tokens; + struct json_parse_ctx pctx; + + if (*input == '\0') { + json_error(cause, "empty input", NULL); + return (NULL); + } + + if ((tokens = json_tokenize_input(input, cause)) == NULL) + return (NULL); + + pctx.input = input; + pctx.cause = cause; + pctx.depth = 0; + + return (json_parse_tokens(&tokens, &pctx)); +} + +/* Returns a field node from an object node. */ +struct json_node * +json_find(struct json_node *jn, const char *key) +{ + struct json_node *node = (struct json_node *)jn, tmp = { 0 }; + + if (jn->type != NODE_OBJECT) + return (NULL); + + tmp.key = (char *)key; + return (RB_FIND(json_fields, &node->fields, &tmp)); +} + +/* Returns the first member of an array node. */ +struct json_node * +json_array_first(struct json_node *jn) +{ + if (jn->type != NODE_ARRAY) + return (NULL); + + return (TAILQ_FIRST(&jn->members)); +} + +/* Returns the next member of an array's member node. */ +struct json_node * +json_array_next(struct json_node *member) +{ + if (member == NULL || + member->parent == NULL || + member->parent->type != NODE_ARRAY) + return (NULL); + return (TAILQ_NEXT(member, aentry)); +} + +/* Returns the string value from a node. */ +int +json_get_string(struct json_node *jn, const char **s) +{ + if (jn->type != NODE_STRING) + return (-1); + + *s = jn->str; + return (0); +} + +/* Returns the number value from a node. */ +int +json_get_number(struct json_node *jn, int64_t *i) +{ + if (jn->type != NODE_NUMBER) + return (-1); + + *i = jn->num; + return (0); +} + +/* Returns the boolean value from a node. */ +int +json_get_boolean(struct json_node *jn, int *b) +{ + if (jn->type != NODE_BOOLEAN) + return (-1); + + *b = jn->boolean; + return (0); +} + +/* Returns the object value from a node. */ +int +json_get_object(struct json_node *jn, struct json_node **o) +{ + if (jn->type != NODE_OBJECT) + return (-1); + + *o = jn; + return (0); +} + +/* Returns the array value from a node. */ +int +json_get_array(struct json_node *jn, struct json_node **a) +{ + if (jn->type != NODE_ARRAY) + return (-1); + + *a = jn; + return (0); +} + +/* Returns the string value from a given key in an object node. */ +int +json_find_string(struct json_node *jn, const char *key, const char **out, + char **cause) +{ + struct json_node *field; + + if ((field = json_find(jn, key)) == NULL) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" not found", key); + return (-1); + } + if (field->type != NODE_STRING) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" expected a string", key); + return (-1); + } + *out = field->str; + + return (0); +} + +/* Returns the number value from a given key in an object node. */ +int +json_find_number(struct json_node *jn, const char *key, int64_t *out, + char **cause) +{ + struct json_node *field; + + if ((field = json_find(jn, key)) == NULL) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" not found", key); + return (-1); + } + if (field->type != NODE_NUMBER) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" expected a number", key); + return (-1); + } + *out = field->num; + + return (0); +} + +/* Returns the boolean value from a given key in an object node. */ +int +json_find_boolean(struct json_node *jn, const char *key, int *out, char **cause) +{ + struct json_node *field; + + if ((field = json_find(jn, key)) == NULL) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" not found", key); + return (-1); + } + if (field->type != NODE_BOOLEAN) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" expected a boolean", key); + return (-1); + } + *out = field->boolean; + + return (0); +} + +/* Returns the object value from a given key in an object node. */ +int +json_find_object(struct json_node *jn, const char *key, struct json_node **out, + char **cause) +{ + struct json_node *field; + + if ((field = json_find(jn, key)) == NULL) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" not found", key); + return (-1); + } + if (field->type != NODE_OBJECT) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" expected an object", key); + return (-1); + } + *out = field; + + return (0); +} + +/* Returns the array value from a given key in an object node. */ +int +json_find_array(struct json_node *jn, const char *key, struct json_node **out, + char **cause) +{ + struct json_node *field; + + if ((field = json_find(jn, key)) == NULL) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" not found", key); + return (-1); + } + if (field->type != NODE_ARRAY) { + if (cause != NULL) + xasprintf(cause, "key \"%s\" expected an array", key); + return (-1); + } + *out = field; + + return (0); +} + +/* Fill an error cause. */ +static void +json_error(char **cause, const char *reason, const char *loc) +{ + const char *ellipsis = "..."; + int i; + + if (cause == NULL) + return; + if (loc == NULL || *loc == '\0') { + xasprintf(cause, "%s", reason); + return; + } + + for (i = 0; i < ERROR_CTX_LEN + 1; i++) { + if (loc[i] == '\0') { + ellipsis = ""; + break; + } + } + + xasprintf(cause, "%s: %.*s%s", reason, ERROR_CTX_LEN, loc, ellipsis); +} + +/* Tokenize the json string. */ +static struct json_tokens * +json_tokenize_input(const char *input, char **cause) +{ + struct json_tokens *tokens; + enum json_token_type type; + const char *loc, *start = input; + int in_string = 0, scan; + + tokens = json_create_tokens(); + while (*input != '\0') { + loc = input; + scan = 1; + + if (in_string && *input != '"') { + type = TOK_VALUE; + } else { + switch (*input) { + case ' ': + case '\t': + case '\n': + case '\r': + input++; + continue; + case '{': + type = TOK_OPENOBJECT; + break; + case '}': + type = TOK_CLOSEOBJECT; + break; + case '[': + type = TOK_OPENARRAY; + break; + case ']': + type = TOK_CLOSEARRAY; + break; + case '"': + type = TOK_QUOTE; + break; + case ':': + type = TOK_COLON; + break; + case ',': + type = TOK_COMMA; + break; + default: + type = TOK_VALUE; + break; + } + } + if (type == TOK_VALUE) { + scan = json_tokenize_value(tokens, loc); + if (scan == -1) + goto fail; + input += scan - 1; + } + json_add_token(tokens, type, start, loc, scan); + if (type == TOK_QUOTE) + in_string = !in_string; + + input++; + } + json_add_token(tokens, TOK_EOF, start, loc, 0); + + return (tokens); + +fail: + json_error(cause, "tokenization error", loc); + json_destroy_tokens(tokens); + return (NULL); +} + +/* + * Tokenize a value from the input string. Strings are terminated by a '"', and + * numbers/booleans are terminated by a ',', ']', '}', or whitespace. + */ +static int +json_tokenize_value(struct json_tokens *tokens, const char *loc) +{ + struct json_token *prev; + int i, scan = 0; + + if (tokens->size == 0) + return (-1); + prev = &tokens->toks[tokens->size - 1]; + + if (prev->type == TOK_QUOTE) { + while (loc[scan] != '"') { + if (loc[scan] == '\0' || (u_char)loc[scan] < 0x20) + return (-1); + if (loc[scan] != '\\') { + scan++; + continue; + } + scan++; + switch (loc[scan]) { + case '"': + case '\\': + case '/': + case 'b': + case 'f': + case 'n': + case 'r': + case 't': + scan++; + break; + case 'u': + for (i = 1; i <= 4; i++) { + if (!isxdigit((u_char)loc[scan + i])) + return (-1); + } + scan += 5; + break; + default: + return (-1); + } + } + } else if (prev->type == TOK_COLON) { + do { + if (loc[scan] == '\0') + return (-1); + scan++; + } while (loc[scan] != ']' && loc[scan] != '}' && + loc[scan] != ',' && !isspace((u_char) loc[scan])); + } else + return (-1); + + return (scan); +} + +/* Create a new token container. */ +static struct json_tokens * +json_create_tokens(void) +{ + struct json_tokens *tokens; + + tokens = xmalloc(sizeof *tokens); + tokens->size = 0; + tokens->capacity = 1024; + tokens->toks = xmalloc(tokens->capacity * sizeof *tokens->toks); + + return (tokens); +} + +/* Free a token container. */ +static void +json_destroy_tokens(struct json_tokens *tokens) +{ + free(tokens->toks); + tokens->toks = NULL; + free(tokens); +} + +/* Add a token to tokens. */ +static void +json_add_token(struct json_tokens *tokens, enum json_token_type type, + const char *input, const char *loc, int len) +{ + struct json_token *tok; + + while (tokens->size >= tokens->capacity) { + tokens->capacity *= 2; + tokens->toks = xrealloc(tokens->toks, + sizeof *tokens->toks * tokens->capacity); + } + + tok = &tokens->toks[tokens->size++]; + tok->type = type; + tok->offset = loc - input; + tok->len = len; +} + +/* Create a node and assign given values. */ +static struct json_node * +json_create_node(struct json_node *parent, enum json_node_type type, + const char *key, void *val) +{ + struct json_node *node; + + node = xcalloc(1, sizeof *node); + node->parent = parent; + if (key != NULL) + node->key = xstrdup(key); + node->type = type; + if (type == NODE_OBJECT) + RB_INIT(&node->fields); + else if (type == NODE_ARRAY) + TAILQ_INIT(&node->members); + if (val != NULL) + json_assign_value(node, val); + + return (node); +} + +/* Destroy a node and all of the node's fields. */ +void +json_destroy_node(struct json_node *node) +{ + struct json_node *field, *field1, *member; + + if (node == NULL) + return; + + switch (node->type) { + case NODE_STRING: + free(node->str); + break; + case NODE_NUMBER: + case NODE_BOOLEAN: + break; + case NODE_OBJECT: + RB_FOREACH_SAFE(field, json_fields, &node->fields, field1) { + RB_REMOVE(json_fields, &node->fields, field); + json_destroy_node(field); + } + break; + case NODE_ARRAY: + while (!TAILQ_EMPTY(&node->members)) { + member = TAILQ_FIRST(&node->members); + TAILQ_REMOVE(&node->members, member, aentry); + json_destroy_node(member); + } + break; + } + + if (node->key != NULL) + free(node->key); + free(node); +} + +/* Assign a value to a node. */ +static void +json_assign_value(struct json_node *node, void *val) +{ + struct json_node *child = val; + + switch (node->type) { + case NODE_STRING: + node->str = val; + break; + case NODE_NUMBER: + node->num = *(int64_t *)val; + break; + case NODE_BOOLEAN: + node->boolean = *(int *)val; + break; + case NODE_OBJECT: + RB_INSERT(json_fields, &node->fields, child); + break; + case NODE_ARRAY: + TAILQ_INSERT_TAIL(&node->members, child, aentry); + break; + default: + fatalx("unknown node type"); + } +} + +/* Parse a stream of tokens into nodes. Consumes the tokens. */ +static struct json_node * +json_parse_tokens(struct json_tokens **tokens, struct json_parse_ctx *pctx) +{ + struct json_token *tok = (*tokens)->toks; + struct json_node *jn = NULL; + + if (tok->type == TOK_OPENOBJECT) + jn = json_parse_object(&tok, pctx, NULL, NULL); + else { + json_error(pctx->cause, "expected object", + pctx->input + tok->offset); + goto fail; + } + if (jn == NULL) + goto fail; + + if (tok->type != TOK_EOF) { + json_error(pctx->cause, "unexpected trailing data", + pctx->input + tok->offset); + goto fail; + } + json_destroy_tokens(*tokens); + *tokens = NULL; + + return (jn); + +fail: + if (jn != NULL) + json_destroy_node(jn); + json_destroy_tokens(*tokens); + *tokens = NULL; + return (NULL); +} + +/* Parse and return a key string, and advance the token pointer. */ +static char * +json_parse_key(struct json_token **tok, struct json_parse_ctx *pctx) +{ + int len; + const char *loc, *start = pctx->input + (*tok)->offset; + char *key; + + if ((*tok)->type != TOK_QUOTE) + goto fail; + (*tok)++; + + loc = pctx->input + (*tok)->offset; + len = (*tok)->len; + + if ((*tok)->type != TOK_VALUE) + goto fail; + (*tok)++; + if ((*tok)->type != TOK_QUOTE) + goto fail; + + key = xstrndup(loc, len); + (*tok)++; + + return (key); + +fail: + json_error(pctx->cause, "invalid key", start); + return (NULL); +} + +/* Parse an object value, return the node, and advance the token pointer. */ +static struct json_node * +json_parse_object(struct json_token **tok, struct json_parse_ctx *pctx, + const char *key, struct json_node *parent) +{ + struct json_node *object, *field; + char *fkey = NULL; + u_char *valstr; + + if ((*tok)->type != TOK_OPENOBJECT) + return (NULL); + + pctx->depth++; + if (pctx->depth > PARSE_DEPTH_MAX) { + json_error(pctx->cause, "parse depth exceeded", + pctx->input + (*tok)->offset); + return (NULL); + } + + (*tok)++; + + object = json_create_node(parent, NODE_OBJECT, key, NULL); + while ((*tok)->type != TOK_CLOSEOBJECT) { + if ((fkey = json_parse_key(tok, pctx)) == NULL) + goto fail; + if (json_find(object, fkey) != NULL) { + json_error(pctx->cause, "duplicate key", + pctx->input + (*tok)->offset); + goto fail; + } + if ((*tok)->type != TOK_COLON) { + json_error(pctx->cause, "missing colon", + pctx->input + (*tok)->offset); + goto fail; + } + (*tok)++; + + switch ((*tok)->type) { + case TOK_QUOTE: + field = json_parse_string(tok, pctx, fkey, object); + break; + case TOK_VALUE: + valstr = (u_char *)(pctx->input + (*tok)->offset); + if ((*valstr == '-' && isdigit(valstr[1])) || + isdigit(*valstr)) { + field = json_parse_number(tok, pctx, fkey, + object); + } else { + field = json_parse_boolean(tok, pctx, fkey, + object); + } + break; + case TOK_OPENOBJECT: + field = json_parse_object(tok, pctx, fkey, object); + break; + case TOK_OPENARRAY: + field = json_parse_array(tok, pctx, fkey, object); + break; + default: + json_error(pctx->cause, + "unexpected value when parsing object", + pctx->input + (*tok)->offset); + goto fail; + } + if (field == NULL) + goto fail; + + json_assign_value(object, field); + if ((*tok)->type == TOK_COMMA) { + if ((*tok)[1].type == TOK_CLOSEOBJECT) { + json_error(pctx->cause, "invalid object", + pctx->input + (*tok)->offset); + goto fail; + } + (*tok)++; + } else if ((*tok)->type != TOK_CLOSEOBJECT) { + json_error(pctx->cause, "invalid object", + pctx->input + (*tok)->offset); + goto fail; + } + free(fkey); + } + (*tok)++; + pctx->depth--; + return (object); + +fail: + if (fkey != NULL) + free(fkey); + json_destroy_node(object); + return (NULL); +} + +/* Parse an array value, return the node, and advance the token pointer. */ +static struct json_node * +json_parse_array(struct json_token **tok, struct json_parse_ctx *pctx, + const char *key, struct json_node *parent) +{ + struct json_node *array, *member; + + if ((*tok)->type != TOK_OPENARRAY) + return (NULL); + (*tok)++; + + array = json_create_node(parent, NODE_ARRAY, key, NULL); + while ((*tok)->type != TOK_CLOSEARRAY) { + switch ((*tok)->type) { + case TOK_OPENOBJECT: + member = json_parse_object(tok, pctx, NULL, array); + break; + default: + json_error(pctx->cause, "invalid array member", + pctx->input + (*tok)->offset); + goto fail; + } + if (member == NULL) + goto fail; + + json_assign_value(array, member); + + if ((*tok)->type == TOK_COMMA) { + if ((*tok)[1].type == TOK_CLOSEARRAY) { + json_error(pctx->cause, "invalid array", + pctx->input + (*tok)->offset); + goto fail; + } + (*tok)++; + } else if ((*tok)->type != TOK_CLOSEARRAY) { + json_error(pctx->cause, "invalid array", + pctx->input + (*tok)->offset); + goto fail; + } + } + (*tok)++; + return (array); + +fail: + json_destroy_node(array); + return (NULL); +} + +/* Parse a string value, return the node, and advance the token pointer. */ +static struct json_node * +json_parse_string(struct json_token **tok, struct json_parse_ctx *pctx, + const char *key, struct json_node *parent) +{ + const char *loc, *start = pctx->input + (*tok)->offset; + char *str; + int len; + + if ((*tok)->type != TOK_QUOTE) + goto fail; + (*tok)++; + if ((*tok)->type != TOK_VALUE) + goto fail; + + loc = pctx->input + (*tok)->offset; + len = (*tok)->len; + (*tok)++; + + if ((*tok)->type != TOK_QUOTE) + goto fail; + (*tok)++; + + str = xstrndup(loc, len); + return (json_create_node(parent, NODE_STRING, key, str)); + +fail: + json_error(pctx->cause, "invalid string", start); + return (NULL); +} + +/* Parse a number value, return the node, and advance the token pointer. */ +static struct json_node * +json_parse_number(struct json_token **tok, struct json_parse_ctx *pctx, + const char *key, struct json_node *parent) +{ + const char *start = pctx->input + (*tok)->offset; + char *endptr; + int64_t num; + int len = (*tok)->len; + + if ((start[0] == '0' && len != 1) || + (start[0] == '-' && start[1] == '0' && len != 2)) + goto fail; + + errno = 0; + num = strtoll(start, &endptr, 10); + if (errno != 0 || endptr != start + len) + goto fail; + (*tok)++; + + return (json_create_node(parent, NODE_NUMBER, key, &num)); + +fail: + json_error(pctx->cause, "invalid number", start); + return (NULL); +} + +/* Parse a boolean value, return the node, and advance the token pointer. */ +static struct json_node * +json_parse_boolean(struct json_token **tok, struct json_parse_ctx *pctx, + const char *key, struct json_node *parent) +{ + int len = (*tok)->len, boolean; + const char *start = pctx->input + (*tok)->offset; + + if (strncmp(start, "true", len) == 0 && len == 4) + boolean = 1; + else if (strncmp(start, "false", len) == 0 && len == 5) + boolean = 0; + else + goto fail; + (*tok)++; + + return (json_create_node(parent, NODE_BOOLEAN, key, &boolean)); + +fail: + json_error(pctx->cause, "invalid boolean", start); + return (NULL); +} + +/* Append a node as JSON. */ +static void +json_string_append(struct evbuffer *buffer, struct json_node *node) +{ + struct json_node *field, *member; + const char *s; + int comma = 0; + + switch (node->type) { + case NODE_STRING: + evbuffer_add_printf(buffer, "\"%s\"", node->str); + break; + case NODE_NUMBER: + evbuffer_add_printf(buffer, "%lld", (long long)node->num); + break; + case NODE_BOOLEAN: + if (node->boolean) + s = "true"; + else + s = "false"; + evbuffer_add(buffer, s, strlen(s)); + break; + case NODE_OBJECT: + evbuffer_add(buffer, "{", 1); + RB_FOREACH(field, json_fields, &node->fields) { + if (comma) + evbuffer_add(buffer, ",", 1); + evbuffer_add_printf(buffer, "\"%s\":", field->key); + json_string_append(buffer, field); + comma = 1; + } + evbuffer_add(buffer, "}", 1); + break; + case NODE_ARRAY: + evbuffer_add(buffer, "[", 1); + TAILQ_FOREACH(member, &node->members, aentry) { + if (comma) + evbuffer_add(buffer, ",", 1); + json_string_append(buffer, member); + comma = 1; + } + evbuffer_add(buffer, "]", 1); + break; + } +} + +/* Convert a node back to JSON. */ +char * +json_to_string(struct json_node *node) +{ + struct evbuffer *buffer; + char *out; + + if (node == NULL) + return (NULL); + buffer = evbuffer_new(); + if (buffer == NULL) + fatalx("out of memory"); + json_string_append(buffer, node); + out = xmemdup(EVBUFFER_DATA(buffer), EVBUFFER_LENGTH(buffer)); + evbuffer_free(buffer); + return (out); +} diff --git a/tmux.1 b/tmux.1 index 81d9fc287..2dbac07ca 100644 --- a/tmux.1 +++ b/tmux.1 @@ -1,4 +1,4 @@ -.\" $OpenBSD: tmux.1,v 1.1165 2026/09/08 07:31:59 nicm Exp $ +.\" $OpenBSD: tmux.1,v 1.1166 2026/09/08 08:33:10 nicm Exp $ .\" .\" Copyright (c) 2007 Nicholas Marriott .\" @@ -8277,7 +8277,7 @@ The following keys are available in menus: .El .Tg display .It Xo Ic display\-message -.Op Fl aCIlNpv +.Op Fl aCIjlNpv .Op Fl c Ar target\-client .Op Fl d Ar delay .Op Fl t Ar target\-pane @@ -8317,6 +8317,12 @@ if .Fl t is given, otherwise the active pane. .Pp +If +.Fl j +is given, +.Ar message +is parsed as JSON and printed. +.Pp .Fl v prints verbose logging as the format is parsed and .Fl a diff --git a/tmux.h b/tmux.h index c920f8408..1691a26fa 100644 --- a/tmux.h +++ b/tmux.h @@ -1,4 +1,4 @@ -/* $OpenBSD: tmux.h,v 1.1433 2026/09/01 12:49:49 nicm Exp $ */ +/* $OpenBSD: tmux.h,v 1.1434 2026/09/08 08:33:10 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -59,6 +59,7 @@ struct input_ctx; struct input_request; struct input_requests; struct job; +struct json_node; struct menu_data; struct mode_tree_data; struct mouse_event; @@ -4200,4 +4201,29 @@ struct hyperlinks *hyperlinks_copy(struct hyperlinks *); void hyperlinks_reset(struct hyperlinks *); void hyperlinks_free(struct hyperlinks *); +/* json.c */ +struct json_node *json_parse(const char *, char **); +void json_destroy_node(struct json_node *); +char *json_to_string(struct json_node *); +struct json_node *json_find(struct json_node *, const char *); +struct json_node *json_array_first(struct json_node *); +struct json_node *json_array_next(struct json_node *); +int json_get_string(struct json_node *, const char **); +int json_get_number(struct json_node *, int64_t *); +int json_get_boolean(struct json_node *, int *); +int json_get_object(struct json_node *, + struct json_node **); +int json_get_array(struct json_node *, + struct json_node **); +int json_find_string(struct json_node *, const char *, + const char **, char **); +int json_find_number(struct json_node *, const char *, + int64_t *, char **); +int json_find_boolean(struct json_node *, const char *, + int *, char **); +int json_find_object(struct json_node *, const char *, + struct json_node **, char **); +int json_find_array(struct json_node *, const char *, + struct json_node **, char **); + #endif /* TMUX_H */ From 2191c9b1724e446cd33ea8bd3f7171e75e154eb8 Mon Sep 17 00:00:00 2001 From: nicm Date: Tue, 8 Sep 2026 08:37:56 +0000 Subject: [PATCH 24/38] Add a function to find last pane index, from Dane Jensen. --- tmux.h | 3 ++- window.c | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tmux.h b/tmux.h index 1691a26fa..54910a617 100644 --- a/tmux.h +++ b/tmux.h @@ -1,4 +1,4 @@ -/* $OpenBSD: tmux.h,v 1.1434 2026/09/08 08:33:10 nicm Exp $ */ +/* $OpenBSD: tmux.h,v 1.1435 2026/09/08 08:37:56 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -3700,6 +3700,7 @@ struct window_pane *window_pane_previous_by_number(struct window *, struct window_pane *, u_int); int window_pane_index(struct window_pane *, u_int *); int window_pane_zindex(struct window_pane *, u_int *); +int window_pane_last_index(struct window_pane *, u_int *); u_int window_count_panes(struct window *, int); void window_destroy_panes(struct window *); struct window_pane *window_pane_find_by_id_str(const char *); diff --git a/window.c b/window.c index 9feb1e71c..cbe65c615 100644 --- a/window.c +++ b/window.c @@ -1,4 +1,4 @@ -/* $OpenBSD: window.c,v 1.373 2026/08/24 21:17:19 nicm Exp $ */ +/* $OpenBSD: window.c,v 1.374 2026/09/08 08:37:56 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -1248,6 +1248,21 @@ window_pane_zindex(struct window_pane *wp, u_int *i) return (-1); } +int +window_pane_last_index(struct window_pane *wp, u_int *i) +{ + struct window *w = wp->window; + struct window_pane *wq; + + *i = 0; + TAILQ_FOREACH(wq, &w->last_panes, sentry) { + if (wq == wp) + return (0); + (*i)++; + } + return (-1); +} + u_int window_count_panes(struct window *w, int with_floating) { From 98637576bd960d287fc7a9fe6a3a4250d94898ca Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Tue, 8 Sep 2026 11:20:51 +0100 Subject: [PATCH 25/38] Test for new-layouts flag. --- regress/control-client-new-layouts.sh | 123 ++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100755 regress/control-client-new-layouts.sh diff --git a/regress/control-client-new-layouts.sh b/regress/control-client-new-layouts.sh new file mode 100755 index 000000000..1c5a51ccf --- /dev/null +++ b/regress/control-client-new-layouts.sh @@ -0,0 +1,123 @@ +#!/bin/sh + +# Check that control client flags are available in every format expansion path +# used to produce layouts. The layout strings themselves are tested separately. + +PATH=/bin:/usr/bin +TERM=screen +LC_ALL=C.UTF-8 +LANG=C.UTF-8 +export PATH TERM LC_ALL LANG + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" + +DIR=$(mktemp -d) || exit 1 +FIFO=$DIR/input +OUT=$DIR/output +CFG=$DIR/flags.conf +PID= + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + exec 3>&- + [ -n "$PID" ] && kill "$PID" 2>/dev/null + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup EXIT + +wait_for() +{ + pattern=$1 + i=0 + + while [ "$i" -lt 50 ]; do + grep -F -- "$pattern" "$OUT" >/dev/null 2>&1 && return 0 + if [ -n "$PID" ] && ! kill -0 "$PID" 2>/dev/null; then + fail "control client exited waiting for: $pattern" + fi + sleep 0.1 + i=$((i + 1)) + done + fail "missing: $pattern" +} + +send() +{ + printf '%s\n' "$*" >&3 +} + +check_commands() +{ + name=$1 + expected=$2 + match='#{m:*new-layouts*,#{client_flags}}' + + send "display-message -p 'DISPLAY-$name $match'" + wait_for "DISPLAY-$name $expected" + send "list-panes -F 'PANES-$name $match'" + wait_for "PANES-$name $expected" + send "list-windows -F 'WINDOWS-$name $match'" + wait_for "WINDOWS-$name $expected" + send "list-sessions -F 'SESSIONS-$name $match'" + wait_for "SESSIONS-$name $expected" + send "list-clients -F 'CLIENTS-$name $match'" + wait_for "CLIENTS-$name $expected" +} + +check_config() +{ + name=$1 + expected=$2 + + send "source-file '$CFG'" + send "display-message -p 'CONFIG-$name #{@config-new-layouts}'" + wait_for "CONFIG-$name $expected" +} + +cat >"$CFG" <<'EOF' +%if #{m:*new-layouts*,#{client_flags}} +set-option -g @config-new-layouts 1 +%else +set-option -g @config-new-layouts 0 +%endif +EOF + +$TMUX kill-server 2>/dev/null +$TMUX new-session -d -s layouts -x 80 -y 24 || exit 1 +$TMUX split-window -h -t layouts: || exit 1 + +mkfifo "$FIFO" || exit 1 +: >"$OUT" +$TMUX -C attach-session -t layouts <"$FIFO" >"$OUT" 2>&1 & +PID=$! +exec 3>"$FIFO" + +send 'display-message -p READY' +wait_for READY + +check_commands OFF 0 +check_config OFF 0 + +send 'refresh-client -f new-layouts' +check_commands ON 1 +check_config ON 1 + +send 'refresh-client -f !new-layouts' +check_commands OFF-AGAIN 0 +check_config OFF-AGAIN 0 + +# The list commands also run for unattached command clients. Supplying that +# client to formats must not make the session loop dereference a NULL session. +$TMUX list-sessions -F '#{S:all,active}' >/dev/null || + fail "session loop failed for unattached command client" +$TMUX has-session -t layouts || fail "server exited" + +exit 0 From d9692f7ecfee64e03d2111641299848f807c8a61 Mon Sep 17 00:00:00 2001 From: nicm Date: Tue, 8 Sep 2026 10:20:08 +0000 Subject: [PATCH 26/38] Add new-layouts flag for control clients so they can default to old layouts for backwards compatibility. --- cmd-list-panes.c | 5 +++-- cmd-list-sessions.c | 5 +++-- cmd-list-windows.c | 5 +++-- cmd-parse.y | 4 ++-- cmd-select-layout.c | 4 ++-- control-notify.c | 21 ++++++++++++++------- format.c | 27 ++++++++++++++++++++++----- layout-custom.c | 4 ++-- server-client.c | 6 +++++- tmux.1 | 4 +++- tmux.h | 7 ++++--- 11 files changed, 63 insertions(+), 29 deletions(-) diff --git a/cmd-list-panes.c b/cmd-list-panes.c index 54c8db7d3..5bc996c2f 100644 --- a/cmd-list-panes.c +++ b/cmd-list-panes.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-list-panes.c,v 1.40 2026/06/01 14:01:09 nicm Exp $ */ +/* $OpenBSD: cmd-list-panes.c,v 1.41 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -97,6 +97,7 @@ cmd_list_panes_window(struct cmd *self, struct session *s, struct winlink *wl, struct cmdq_item *item, int type) { struct args *args = cmd_get_args(self); + struct client *c = cmdq_get_client(item); struct window_pane *wp, **l; u_int i, n; struct format_tree *ft; @@ -147,7 +148,7 @@ cmd_list_panes_window(struct cmd *self, struct session *s, struct winlink *wl, wp = l[i]; ft = format_create(cmdq_get_client(item), item, FORMAT_NONE, 0); format_add(ft, "line", "%u", n); - format_defaults(ft, NULL, s, wl, wp); + format_defaults(ft, c, s, wl, wp); if (filter != NULL) { expanded = format_expand(ft, filter); diff --git a/cmd-list-sessions.c b/cmd-list-sessions.c index 07a622979..30f2178ad 100644 --- a/cmd-list-sessions.c +++ b/cmd-list-sessions.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-list-sessions.c,v 1.36 2026/02/27 08:25:12 nicm Exp $ */ +/* $OpenBSD: cmd-list-sessions.c,v 1.37 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -53,6 +53,7 @@ static enum cmd_retval cmd_list_sessions_exec(struct cmd *self, struct cmdq_item *item) { struct args *args = cmd_get_args(self); + struct client *c = cmdq_get_client(item); struct session **l; u_int n, i; struct format_tree *ft; @@ -76,7 +77,7 @@ cmd_list_sessions_exec(struct cmd *self, struct cmdq_item *item) for (i = 0; i < n; i++) { ft = format_create(cmdq_get_client(item), item, FORMAT_NONE, 0); format_add(ft, "line", "%u", i); - format_defaults(ft, NULL, l[i], NULL, NULL); + format_defaults(ft, c, l[i], NULL, NULL); if (filter != NULL) { expanded = format_expand(ft, filter); diff --git a/cmd-list-windows.c b/cmd-list-windows.c index 40f8c2db5..2cbccc1b8 100644 --- a/cmd-list-windows.c +++ b/cmd-list-windows.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-list-windows.c,v 1.50 2026/02/27 08:25:12 nicm Exp $ */ +/* $OpenBSD: cmd-list-windows.c,v 1.51 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -60,6 +60,7 @@ cmd_list_windows_exec(struct cmd *self, struct cmdq_item *item) { struct args *args = cmd_get_args(self); struct cmd_find_state *target = cmdq_get_target(item); + struct client *c = cmdq_get_client(item); struct winlink *wl, **l; struct session *s; u_int i, n; @@ -94,7 +95,7 @@ cmd_list_windows_exec(struct cmd *self, struct cmdq_item *item) s = wl->session; ft = format_create(cmdq_get_client(item), item, FORMAT_NONE, 0); format_add(ft, "line", "%u", n); - format_defaults(ft, NULL, s, wl, NULL); + format_defaults(ft, c, s, wl, NULL); if (filter != NULL) { expanded = format_expand(ft, filter); diff --git a/cmd-parse.y b/cmd-parse.y index 4a6ec66b1..a6541c930 100644 --- a/cmd-parse.y +++ b/cmd-parse.y @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-parse.y,v 1.59 2026/08/31 07:51:56 nicm Exp $ */ +/* $OpenBSD: cmd-parse.y,v 1.60 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2019 Nicholas Marriott @@ -210,7 +210,7 @@ expanded : format cmd_find_from_client(&fs, c, 0); fsp = &fs; } - ft = format_create(NULL, pi->item, FORMAT_NONE, flags); + ft = format_create(c, pi->item, FORMAT_NONE, flags); format_defaults(ft, c, fsp->s, fsp->wl, fsp->wp); $$ = format_expand(ft, $1); diff --git a/cmd-select-layout.c b/cmd-select-layout.c index 741dc63f4..86effb03c 100644 --- a/cmd-select-layout.c +++ b/cmd-select-layout.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-select-layout.c,v 1.43 2026/07/10 13:38:45 nicm Exp $ */ +/* $OpenBSD: cmd-select-layout.c,v 1.44 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -90,7 +90,7 @@ cmd_select_layout_exec(struct cmd *self, struct cmdq_item *item) previous = 1; oldlayout = w->old_layout; - w->old_layout = layout_dump(w, w->layout_root); + w->old_layout = layout_dump(w, w->layout_root, 0); if (next || previous) { if (next) diff --git a/control-notify.c b/control-notify.c index adfc9dcb0..9aa90662b 100644 --- a/control-notify.c +++ b/control-notify.c @@ -1,4 +1,4 @@ -/* $OpenBSD: control-notify.c,v 1.38 2026/08/03 13:38:42 nicm Exp $ */ +/* $OpenBSD: control-notify.c,v 1.39 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2012 Nicholas Marriott @@ -70,6 +70,7 @@ control_window_layout_changed_cb(__unused const char *name, struct session *s; struct winlink *wl; struct window *w = event_payload_get_window(ep, "window"); + struct format_tree *ft; const char *template; char *cp; @@ -84,19 +85,25 @@ control_window_layout_changed_cb(__unused const char *name, * and we don't need to inform the client about the layout change * because the whole window will go away soon. */ - wl = TAILQ_FIRST(&w->winlinks); - if (wl == NULL || w->layout_root == NULL) + if (TAILQ_FIRST(&w->winlinks) == NULL || w->layout_root == NULL) return; - cp = format_single(NULL, template, NULL, NULL, wl, NULL); TAILQ_FOREACH(c, &clients, entry) { if (!CONTROL_SHOULD_NOTIFY_CLIENT(c) || c->session == NULL) continue; s = c->session; - if (winlink_find_by_window_id(&s->windows, w->id) != NULL) - control_notify_write(c, "%s", cp); + wl = winlink_find_by_window_id(&s->windows, w->id); + if (wl == NULL) + continue; + + ft = format_create(c, NULL, FORMAT_NONE, 0); + format_defaults(ft, c, s, wl, NULL); + cp = format_expand(ft, template); + format_free(ft); + + control_notify_write(c, "%s", cp); + free(cp); } - free(cp); } /* Notify control clients that window pane changed. */ diff --git a/format.c b/format.c index daca3b30e..4145c88e2 100644 --- a/format.c +++ b/format.c @@ -1,4 +1,4 @@ -/* $OpenBSD: format.c,v 1.415 2026/08/31 19:34:09 nicm Exp $ */ +/* $OpenBSD: format.c,v 1.416 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2011 Nicholas Marriott @@ -841,26 +841,42 @@ format_cb_window_active_clients_list(struct format_tree *ft) static void * format_cb_window_layout(struct format_tree *ft) { - struct window *w = ft->w; + struct client *c = ft->c; + struct window *w = ft->w; + struct layout_cell *lcroot; + int flags = 0; if (w == NULL) return (NULL); if (w->saved_layout_root != NULL) - return (layout_dump(w, w->saved_layout_root)); - return (layout_dump(w, w->layout_root)); + lcroot = w->saved_layout_root; + else + lcroot = w->layout_root; + + if (c != NULL && + (c->flags & CLIENT_CONTROL) && + (~c->flags & CLIENT_CONTROL_NEWLAYOUTS)) + flags |= LAYOUT_CUSTOM_OLD_FORMAT; + return (layout_dump(w, lcroot, flags)); } /* Callback for window_visible_layout. */ static void * format_cb_window_visible_layout(struct format_tree *ft) { + struct client *c = ft->c; struct window *w = ft->w; + int flags = 0; if (w == NULL) return (NULL); - return (layout_dump(w, w->layout_root)); + if (c != NULL && + (c->flags & CLIENT_CONTROL) && + (~c->flags & CLIENT_CONTROL_NEWLAYOUTS)) + flags |= LAYOUT_CUSTOM_OLD_FORMAT; + return (layout_dump(w, w->layout_root, flags)); } /* Callback for pane_start_command. */ @@ -5216,6 +5232,7 @@ format_loop_sessions(struct format_expand_state *es, const char *fmt) format_log(es, "session loop: $%u", s->id); if (active != NULL && ft->c != NULL && + ft->c->session != NULL && s->id == ft->c->session->id) use = active; else diff --git a/layout-custom.c b/layout-custom.c index c58e92028..3fa30347e 100644 --- a/layout-custom.c +++ b/layout-custom.c @@ -1,4 +1,4 @@ -/* $OpenBSD: layout-custom.c,v 1.38 2026/07/16 12:36:58 nicm Exp $ */ +/* $OpenBSD: layout-custom.c,v 1.39 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2010 Nicholas Marriott @@ -58,7 +58,7 @@ layout_checksum(const char *layout) /* Dump layout as a string. */ char * -layout_dump(struct window *w, struct layout_cell *root) +layout_dump(struct window *w, struct layout_cell *root, __unused int flags) { char layout[8192], *out; int bracket = 0; diff --git a/server-client.c b/server-client.c index 54e615ed4..cd94444fa 100644 --- a/server-client.c +++ b/server-client.c @@ -1,4 +1,4 @@ -/* $OpenBSD: server-client.c,v 1.510 2026/09/01 19:50:58 nicm Exp $ */ +/* $OpenBSD: server-client.c,v 1.511 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -3084,6 +3084,8 @@ server_client_control_flags(struct client *c, const char *next) return (CLIENT_CONTROL_NOOUTPUT); if (strcmp(next, "wait-exit") == 0) return (CLIENT_CONTROL_WAITEXIT); + if (strcmp(next, "new-layouts") == 0) + return (CLIENT_CONTROL_NEWLAYOUTS); return (0); } @@ -3150,6 +3152,8 @@ server_client_get_flags(struct client *c) strlcat(s, "no-output,", sizeof s); if (c->flags & CLIENT_CONTROL_WAITEXIT) strlcat(s, "wait-exit,", sizeof s); + if (c->flags & CLIENT_CONTROL_NEWLAYOUTS) + strlcat(s, "new-layouts,", sizeof s); if (c->flags & CLIENT_CONTROL_PAUSEAFTER) { xsnprintf(tmp, sizeof tmp, "pause-after=%u,", c->pause_age / 1000); diff --git a/tmux.1 b/tmux.1 index 2dbac07ca..bd562178b 100644 --- a/tmux.1 +++ b/tmux.1 @@ -1,4 +1,4 @@ -.\" $OpenBSD: tmux.1,v 1.1166 2026/09/08 08:33:10 nicm Exp $ +.\" $OpenBSD: tmux.1,v 1.1167 2026/09/08 10:20:08 nicm Exp $ .\" .\" Copyright (c) 2007 Nicholas Marriott .\" @@ -1114,6 +1114,8 @@ The flags are: .Bl -tag -width Ds .It ignore\-size the client does not affect the size of other clients +.It new\-layouts +use the new layout string format .It no\-detach\-on\-destroy do not detach the client when the session it is attached to is destroyed if there are any other sessions diff --git a/tmux.h b/tmux.h index 54910a617..ac195fecb 100644 --- a/tmux.h +++ b/tmux.h @@ -1,4 +1,4 @@ -/* $OpenBSD: tmux.h,v 1.1435 2026/09/08 08:37:56 nicm Exp $ */ +/* $OpenBSD: tmux.h,v 1.1436 2026/09/08 10:20:08 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -2256,7 +2256,7 @@ struct client { #define CLIENT_CONTROL_PAUSEAFTER 0x100000000ULL #define CLIENT_CONTROL_WAITEXIT 0x200000000ULL #define CLIENT_WINDOWSIZECHANGED 0x400000000ULL -/* 0x800000000ULL unused */ +#define CLIENT_CONTROL_NEWLAYOUTS 0x800000000ULL #define CLIENT_BRACKETPASTING 0x1000000000ULL #define CLIENT_ASSUMEPASTING 0x2000000000ULL #define CLIENT_WRITE_ACK 0x4000000000ULL @@ -3864,7 +3864,8 @@ int layout_remove_tile(struct window *, struct layout_cell *); int layout_insert_tile(struct window *, struct layout_cell *); /* layout-custom.c */ -char *layout_dump(struct window *, struct layout_cell *); +#define LAYOUT_CUSTOM_OLD_FORMAT 0x1 +char *layout_dump(struct window *, struct layout_cell *, int); int layout_parse(struct window *, const char *, char **); /* layout-set.c */ From c7102ebbe5fa5547f9310886253d097d9a46a2c5 Mon Sep 17 00:00:00 2001 From: nicm Date: Tue, 8 Sep 2026 15:42:26 +0000 Subject: [PATCH 27/38] The client for the layout format is the client we are sending it to, not the target client. --- format.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/format.c b/format.c index 4145c88e2..86f7adb36 100644 --- a/format.c +++ b/format.c @@ -1,4 +1,4 @@ -/* $OpenBSD: format.c,v 1.416 2026/09/08 10:20:08 nicm Exp $ */ +/* $OpenBSD: format.c,v 1.417 2026/09/08 15:42:26 nicm Exp $ */ /* * Copyright (c) 2011 Nicholas Marriott @@ -841,7 +841,7 @@ format_cb_window_active_clients_list(struct format_tree *ft) static void * format_cb_window_layout(struct format_tree *ft) { - struct client *c = ft->c; + struct client *c = ft->client; struct window *w = ft->w; struct layout_cell *lcroot; int flags = 0; @@ -865,7 +865,7 @@ format_cb_window_layout(struct format_tree *ft) static void * format_cb_window_visible_layout(struct format_tree *ft) { - struct client *c = ft->c; + struct client *c = ft->client; struct window *w = ft->w; int flags = 0; From 4e19f7a8dc5d87028b30cb01281e18b0fc947d44 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Wed, 9 Sep 2026 08:04:23 +0100 Subject: [PATCH 28/38] New layout format regress test, from Dane Jensen. --- regress/layout-custom.sh | 986 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 986 insertions(+) create mode 100644 regress/layout-custom.sh diff --git a/regress/layout-custom.sh b/regress/layout-custom.sh new file mode 100644 index 000000000..e9537b9e5 --- /dev/null +++ b/regress/layout-custom.sh @@ -0,0 +1,986 @@ +#!/bin/sh + +# Tests of the custom layout dumper and evaluator in layout-custom.c, and of +# the JSON tokenizer and parser in json.c that the current layout format is +# built on. +# +# layout_dump is reached through the #{window_layout} and +# #{window_visible_layout} formats and layout_parse through +# "select-layout ". json.c has no command of its own either: +# layout_construct sniffs the first non-blank character and hands anything +# starting with '{' to json_parse, so select-layout is the only way into it +# from the shell as well. +# +# Both layout formats are covered: +# - the current (v2) JSON format, which is what every client except an old +# control client sees; +# - the legacy (v1) format, which is still produced for a control client that +# has not asked for the "new-layouts" flag, and which is still accepted by +# the parser (the version is sniffed from the first character). +# +# This exercises: +# - dumping a single pane, a split, the "a" (active) and "l" (last pane) keys +# and the "z" key of a floating pane; +# - #{window_visible_layout} agreeing with #{window_layout}; +# - the JSON syntax itself: insignificant whitespace, backslash escapes inside +# strings, the number and boolean forms, and one failure for each way json.c +# can reject an input that a layout string can carry; +# - a dump being parsed back to exactly the same layout (round trip), after +# another layout has been applied in between, and the same for a layout with +# two floating panes in it; +# - parsing a hand-written v2 layout; +# - "i" deciding which pane goes in which cell, checked with a layout whose +# cells are written in a different order from their indexes; +# - the same layouts with their fields in reversed and scrambled orders, +# including "c" before "t" and "V" after "L", neither of which changes the +# order the fields are read in; +# - a layout with more cells than the window has panes having the bottom right +# cells dropped, in both formats; +# - a layout naming no active or last pane leaving the active pane where it was +# and emptying the last pane stack, whether it leaves "a" out or gives it as +# false; +# - parsing a v1 layout and dumping it back as v1 through a control client, +# with the checksum computed here independently of layout_checksum(), and a +# v1 layout leaving the active pane and last pane stack untouched; +# - the legacy format meeting the floating panes it cannot represent: a v1 dump +# dropping the floating cells, both where that leaves the node they were in +# with one child so that it collapses, where it does not, and where adjacent +# nested floating-only subtrees are dropped, and a v1 layout being applied to +# a window that has floating panes without disturbing them, whether the tiled +# layout it names is a single cell or a split; +# - a window whose only tiled pane has been killed, which leaves it with a +# floating cell as its layout root or with a root node holding nothing but +# floating cells, producing no v1 dump at all, and being parsed as v1; +# - the %layout-change notification, in both formats at once: two control +# clients watching one layout change, only one of which has asked for new +# layouts, and the number of notifications a change produces in each format; +# - failures: a bad v1 header, checksum or body, a wrong version, a missing or +# duplicated root cell, missing sizes, sizes out of range, bad cell types, a +# pane cell missing "i", leaf cells with children and node cells with fewer +# than two, more than one active pane, too few cells for the panes and +# inconsistent sizes. + +PATH=/bin:/usr/bin +TERM=screen +LANG=C.UTF-8 +LC_ALL=C.UTF-8 +export TERM LANG LC_ALL + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +TMUX="$TEST_TMUX -LtestA$$ -f/dev/null" +$TMUX kill-server 2>/dev/null + +fail() +{ + echo "$*" >&2 + $TMUX kill-server 2>/dev/null + exit 1 +} + +# must_equal $what $got $expected +must_equal() +{ + if [ "$2" != "$3" ]; then + echo "$1 wrong." >&2 + echo "Expected: '$3'" >&2 + echo "But got: '$2'" >&2 + $TMUX kill-server 2>/dev/null + exit 1 + fi +} + +# must_differ $what $got $unwanted +must_differ() +{ + [ "$2" != "$3" ] || fail "$1 unchanged: '$2'" +} + +# must_contain $what $got $wanted +must_contain() +{ + case "$2" in + *"$3"*) ;; + *) fail "$1: '$2' does not contain '$3'";; + esac +} + +# check_ok $cmd... +# +# Run a command and require that it succeeds. +check_ok() +{ + out=$($TMUX "$@" 2>&1) || fail "Command failed (expected success): $* ($out)" +} + +# check_fail $cmd... +# +# Run a command and require that it fails. The error text itself is never +# checked anywhere in this test: the wording of a message is not part of what +# the layout formats promise, so matching on it only makes the test fail when a +# message is reworded. +check_fail() +{ + $TMUX "$@" >/dev/null 2>&1 && + fail "Command succeeded (expected failure): $*" +} + +# layout $target +# +# The layout of a window with pane ids replaced by %N, so that the expected +# strings do not depend on which ids the server handed out. +layout() +{ + $TMUX display-message -p -t "$1" '#{window_layout}' | + sed 's/%[0-9][0-9]*/%N/g' +} + +# visible_layout $target +# +# As layout(), but the visible (zoomed) layout. +visible_layout() +{ + $TMUX display-message -p -t "$1" '#{window_visible_layout}' | + sed 's/%[0-9][0-9]*/%N/g' +} + +# raw_layout $target +# +# The layout of a window with the real pane ids left in place. +raw_layout() +{ + $TMUX display-message -p -t "$1" '#{window_layout}' +} + +# v1_layout $target +# +# The legacy (v1) dump of a window, which is what a control client that has not +# asked for the "new-layouts" flag is sent. A control client wraps its output in +# %begin/%end guard lines, which are dropped here. +v1_layout() +{ + $TMUX -C display-message -p -t "$1" '#{window_layout}' | grep -v '^%' +} + +# v1 $body +# +# Prefix a legacy (v1) layout body with its checksum. This is a separate +# implementation of layout_checksum(): a 16 bit rotate right then add, so a +# mistake in either one shows up as a mismatch. +v1() +{ + awk -v s="$1" 'BEGIN { + for (i = 32; i < 127; i++) + ord[sprintf("%c", i)] = i + csum = 0 + for (i = 1; i <= length(s); i++) { + csum = int(csum / 2) + (csum % 2) * 32768 + csum = (csum + ord[substr(s, i, 1)]) % 65536 + } + printf "%04x,%s\n", csum, s + }' +} + +# A pane cell is dumped as its geometry, then "a" if it is the active pane or +# "l" with its position on the last pane stack if it is on it, then "i" with +# its pane index, then "z" if it is floating, then "I" with its pane id. +ONE='{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"a":true,"i":0,"I":"%N"}}' + +check_ok new-session -d -s L -x 80 -y 24 -n one + +p0=$($TMUX display-message -p -t L:one.0 '#{pane_id}') + +# A single leaf cell filling the window. A pane cell must carry "i", its pane +# index; "I", its pane id, is written by the dumper and is here so that the cell +# is the same shape as a dumped one. The JSON checks below care about the syntax +# around the cell rather than the cell itself. +LEAF='{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$p0"'"}' + +# --------------------------------------------------------------------------- +# Dumping a single pane. + +# The root cell of a new window is the pane itself, and it is the active pane +# so it has "a" rather than "l". +must_equal 'Single pane layout' "$(layout L:one)" "$ONE" + +# Nothing is zoomed, so the visible layout is the same. +must_equal 'Single pane visible layout' "$(visible_layout L:one)" "$ONE" + +# --------------------------------------------------------------------------- +# More cells than panes. + +# The bottom right cells are closed until as many are left as there are panes, +# so a two cell layout applied to a one pane window collapses back to the +# single pane filling the window: the cell that is left takes the space of the +# one that was closed. The window has one pane to name, so the cell that is +# closed carries an id belonging to no pane of it. +check_ok select-layout -t L:one \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"i":0,"I":"'"$p0"'"},{"t":"p","w":80,"h":12,"x":0,"y":12,"i":1,"I":"%999"}]}}' +must_equal 'Trimmed layout' "$(layout L:one)" "$ONE" + +# --------------------------------------------------------------------------- +# The JSON syntax. +# +# These run on the one pane window and are written so that what they prove +# depends on json.c rather than on the layout evaluation in layout-custom.c: +# an accepted layout is only required to leave the window as its single pane, +# and values that are not part of the layout format are carried on keys +# layout-custom.c never looks at ("n", "b" and so on), which it skips, so +# numbers, booleans and escapes can be exercised on their own. +# +# Objects nested in an array nested in an object are not checked here: every +# split layout below is one. +# +# Two of json.c's rejections cannot be reached from the shell and so are not +# covered: json_parse_tokens() refusing a top level that is not an object, +# because layout_construct() only calls json_parse() once the string already +# starts with '{'; and the maximum object depth, which needs a layout built by a +# program rather than one written out here. + +# check_json_ok $what $layout +# +# select-layout must parse $layout and leave the window as its single pane. +check_json_ok() +{ + check_ok select-layout -t L:one "$2" + must_equal "Layout after '$1'" "$(layout L:one)" "$ONE" +} + +# check_json_fail $what $layout +# +# select-layout must reject $layout. +check_json_fail() +{ + $TMUX select-layout -t L:one "$2" >/dev/null 2>&1 && + fail "$1: select-layout succeeded (expected failure)" +} + +# Whitespace between tokens is skipped. A number is scanned up to the ',', ']', +# '}' or whitespace that ends it, so a space after a number is fine but one +# inside it is not. +check_json_ok 'Spaces between tokens' \ + '{ "V" : 2 , "L" : { "t" : "p" , "w" : 80 , "h" : 24 , "x" : 0 , "y" : 0 , "i" : 0 , "I" : "'"$p0"'" } }' + +check_json_ok 'Newlines and tabs between tokens' "$(printf '{ +\t"V": 2, +\t"L": { +\t\t"t": "p", +\t\t"w": 80, +\t\t"h": 24, +\t\t"x": 0, +\t\t"y": 0, +\t\t"i": 0, +\t\t"I": "%s" +\t} +}' "$p0")" + +check_json_ok 'Carriage returns between tokens' \ + "$(printf '{\r"V":2,\r"L":%s\r}' "$LEAF")" + +# A backslash makes the tokenizer consume the next character whatever it is, so +# an escaped quote does not end the string. The key is not one that +# layout-custom.c looks at, so all that is being checked is that the string +# ended in the right place and the object still parsed. +check_json_ok 'Escaped quote in a string' \ + '{"V":2,"a\"b":0,"L":'"$LEAF"'}' + +# An escaped backslash immediately before the closing quote: the escape has to +# be cleared again so that the quote after it does end the string. +check_json_ok 'Escaped backslash before the closing quote' \ + '{"V":2,"a\\":0,"L":'"$LEAF"'}' + +# Numbers and booleans, again on keys layout-custom.c ignores, so only json.c +# decides whether they are accepted. +check_json_ok 'Zero' '{"V":2,"n":0,"L":'"$LEAF"'}' +check_json_ok 'Several digits' '{"V":2,"n":1234567,"L":'"$LEAF"'}' +check_json_ok 'Negative number' '{"V":2,"n":-42,"L":'"$LEAF"'}' +check_json_ok 'Booleans' '{"V":2,"b":true,"d":false,"L":'"$LEAF"'}' + +# Tokenizer failures. A value that runs to the end of the input has no +# terminator, so it is the tokenizer rather than the parser that gives up. Both +# the number scan and the string scan have to notice this, and with the closing +# quote escaped there is no terminator left either. +check_json_fail 'Unterminated number' '{"V":2' +check_json_fail 'Unterminated string' '{"V":"x' +check_json_fail 'Escaped closing quote' '{"V":2,"L":{"t":"p\"}}' + +# Something that is not a quoted string where a key belongs. +check_json_fail 'Missing key' '{"V":2,,"L":'"$LEAF"'}' + +# A key not followed by ':'. +check_json_fail 'Missing colon' '{"V","L":2}' + +# A bare word that is neither "true", "false" nor a number. This is where +# "null" ends up. +check_json_fail 'Unknown literal' '{"V":null,"L":'"$LEAF"'}' + +# A ':' with no value after it, so the token where the value belongs is one the +# object parser has no case for. +check_json_fail 'Missing value' '{"V":}' + +# A ',' with nothing after it, and a value with no ',' before the next key. +check_json_fail 'Trailing comma in an object' '{"V":2,"L":'"$LEAF"',}' +check_json_fail 'Missing comma in an object' '{"V":2 "L":'"$LEAF"'}' + +# Arrays hold objects and nothing else. +check_json_fail 'Non-object in an array' \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":["x"]}}' +check_json_fail 'Trailing comma in an array' \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":['"$LEAF"',]}}' + +# An empty string is two adjacent quotes with no value token between them, +# which the string parser does not accept. +check_json_fail 'Empty string' '{"V":2,"L":""}' + +# A number token that strtoll does not consume all of. +check_json_fail 'Number with trailing characters' '{"V":8a,"L":'"$LEAF"'}' + +# Anything after the top level object. +check_json_fail 'Data after the top level object' '{"V":2,"L":'"$LEAF"'}{}' + +# None of the rejections touched the layout. +must_equal 'Layout after rejected parses' "$(layout L:one)" "$ONE" + +# --------------------------------------------------------------------------- +# Dumping a split. + +check_ok new-window -d -t L:2 -n two +q0=$($TMUX display-message -p -t L:two.0 '#{pane_id}') + +# -l 12 gives the new (bottom) pane 12 lines, leaving 11 for the top pane and +# one for the border between them. With -d the top pane stays active. +check_ok split-window -d -v -l 12 -t L:two.0 +q1=$($TMUX display-message -p -t L:two.1 '#{pane_id}') + +# Nothing has changed the active pane, so the last pane stack is still empty +# and the bottom pane has neither "a" nor "l". +must_equal 'Split layout' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":12,"x":0,"y":12,"i":1,"I":"%N"}]}}' + +# --------------------------------------------------------------------------- +# The active and last pane keys. + +# Selecting the bottom pane makes it active and pushes the top pane onto the +# last pane stack, where it is at index 0. +check_ok select-pane -t "$q1" +must_equal 'Layout after select-pane' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"l":0,"i":0,"I":"%N"},{"t":"p","w":80,"h":12,"x":0,"y":12,"a":true,"i":1,"I":"%N"}]}}' + +# Selecting the top pane again swaps the two keys over. "i" and "I" do not +# move: they are the pane's position in the window and its id. +check_ok select-pane -t "$q0" +SPLIT='{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":12,"x":0,"y":12,"l":0,"i":1,"I":"%N"}]}}' +must_equal 'Layout after select-pane back' "$(layout L:two)" "$SPLIT" + +# --------------------------------------------------------------------------- +# The visible layout. + +# With nothing zoomed the two layout formats agree. +# +# The zoomed case is deliberately not covered here. While a pane is zoomed +# #{window_layout} dumps the saved (unzoomed) layout and +# #{window_visible_layout} the zoomed one, but that depends on how zooming +# stashes the layout root rather than on anything in layout-custom.c. +must_equal 'Visible layout' "$(visible_layout L:two)" "$SPLIT" + +# --------------------------------------------------------------------------- +# Round trip. + +# Make the two panes obviously uneven so that the layout applied in between +# cannot be mistaken for the saved one. A resize shows up in the dump as the +# new cell sizes and offsets. +check_ok resize-pane -t "$q0" -y 5 +saved=$(raw_layout L:two) +must_equal 'Resized layout' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":5,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":18,"x":0,"y":6,"l":0,"i":1,"I":"%N"}]}}' + +check_ok select-layout -t L:two even-vertical +must_differ 'Layout after even-vertical' "$(raw_layout L:two)" "$saved" + +# Parsing a dump gives back exactly the same dump, pane ids included. The panes +# go back into the cells that named them: the cells are ordered by "i" and then +# given the window's panes in order, so a cell dumped with "i":k must come back +# the k'th. +check_ok select-layout -t L:two "$saved" +must_equal 'Round tripped layout' "$(raw_layout L:two)" "$saved" + +# --------------------------------------------------------------------------- +# Parsing a hand-written layout. + +# Laid out over several lines to keep it readable; that the whitespace is +# skipped at all is json.c's business, what matters here is that the cells come +# out of it in the right shape. +# +# "a" and "l" are given on the cells so that the active pane and the last pane +# stack are pinned by the layout rather than left to whatever a layout that +# names neither happens to produce. +check_ok select-layout -t L:two "$(printf '{ + "V": 2, + "L": { + "t": "h", + "w": 80, + "h": 24, + "x": 0, + "y": 0, + "c": [ + {"t": "p", "w": 30, "h": 24, "x": 0, "y": 0, "a": true, "i": 0, "I": "%s"}, + {"t": "p", "w": 49, "h": 24, "x": 31, "y": 0, "l": 0, "i": 1, "I": "%s"} + ] + } +}' "$q0" "$q1")" +must_equal 'Hand-written layout' "$(layout L:two)" \ + '{"V":2,"L":{"t":"h","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":30,"h":24,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":49,"h":24,"x":31,"y":0,"l":0,"i":1,"I":"%N"}]}}' + +# The panes are assigned to the cells in order. +must_equal 'First pane width' \ + "$($TMUX display-message -p -t "$q0" '#{pane_width}')" '30' +must_equal 'Second pane width' \ + "$($TMUX display-message -p -t "$q1" '#{pane_width}')" '49' + +# --------------------------------------------------------------------------- +# Field order. + +# Fields are looked up by key once the object has been parsed, so the order +# they are written in must give the same layout. Here every object has its keys +# reversed: "c" comes before "t" and "V" comes after "L", neither of which +# changes the order they are read in - the cell type is always read before the +# children and the version before the layout. +check_ok select-layout -t L:two \ + '{"L":{"c":[{"I":"'"$q0"'","i":0,"a":true,"y":0,"x":0,"h":8,"w":80,"t":"p"},{"I":"'"$q1"'","i":1,"l":0,"y":9,"x":0,"h":15,"w":80,"t":"p"}],"y":0,"x":0,"h":24,"w":80,"t":"v"},"V":2}' +must_equal 'Reversed field order' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":8,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":15,"x":0,"y":9,"l":0,"i":1,"I":"%N"}]}}' + +# Keys interleaved rather than simply reversed, with "c" in the middle. This +# time "a" is on the second cell, so the second pane becomes the active one: +# which pane is active comes from the layout, while "i" and "I" still come from +# the window. The first cell names neither "a" nor "l", so its pane is neither +# active nor on the last pane stack and the dump gives it neither key. +check_ok select-layout -t L:two \ + '{"V":2,"L":{"h":24,"c":[{"w":40,"t":"p","y":0,"i":0,"h":24,"I":"'"$q0"'","x":0},{"a":true,"h":24,"I":"'"$q1"'","w":39,"y":0,"t":"p","i":1,"x":41}],"w":80,"y":0,"t":"h","x":0}}' +must_equal 'Scrambled field order' "$(layout L:two)" \ + '{"V":2,"L":{"t":"h","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":40,"h":24,"x":0,"y":0,"i":0,"I":"%N"},{"t":"p","w":39,"h":24,"x":41,"y":0,"a":true,"i":1,"I":"%N"}]}}' + +# --------------------------------------------------------------------------- +# The legacy (v1) format. + +# The layout just applied, in v1: a left/right cell is written with braces and +# a top/bottom cell with brackets, and each leaf carries its pane id without +# the leading %. +v1body="80x24,0,0{40x24,0,0,${q0#%},39x24,41,0,${q1#%}}" + +# A control client that has not asked for new layouts is dumped v1. +must_equal 'v1 dump' "$(v1_layout L:two)" "$(v1 "$v1body")" + +# With the new-layouts flag the same client is dumped v2 instead. The flag is +# set with "attach -f" rather than refresh-client because refresh-client needs +# a current client, which a control client that has not attached has not got. +got=$(printf "display-message -p -t L:two '#{window_layout}'\n" | + $TMUX -C attach -f new-layouts -t L 2>&1 | grep -v '^%') +must_contain 'v2 dump for control client' "$got" '{"V":2,"L":' + +# A v1 layout with a correct checksum is parsed, and dumping v1 again gives +# back the same string. That is the whole of what v1 carries: the cells take +# the sizes and offsets from the body, and the panes are assigned to them in +# order, which is what puts the same two ids back in the same two places. It is +# checked in v1 rather than against a v2 dump so that nothing v1 has no opinion +# on - the active pane, the last pane stack, the pane index - comes into it. +v1vsplit="80x24,0,0[80x11,0,0,${q0#%},80x12,0,12,${q1#%}]" +check_ok select-layout -t L:two "$(v1 "$v1vsplit")" +must_equal 'v1 round trip' "$(v1_layout L:two)" "$(v1 "$v1vsplit")" + +# v1 names no active pane, last pane or z-index and must disturb none of them. +# Applying the v1 form of the layout the window already has therefore leaves +# even the v2 dump the same byte for byte, last pane stack included. +check_ok select-pane -t "$q1" +check_ok select-pane -t "$q0" +before=$(raw_layout L:two) +check_ok select-layout -t L:two "$(v1 "$v1vsplit")" +must_equal 'v1 leaves the active and last panes alone' \ + "$(raw_layout L:two)" "$before" + +# A v1 layout with more cells than the window has panes is trimmed like any +# other: the bottom right cell is closed and the cell above it takes its eight +# rows and the border between them, leaving 16. Pane ids in a v1 body are not +# used to place panes, so the third cell can carry any id. +v1three="80x24,0,0[80x7,0,0,${q0#%},80x7,0,8,${q1#%},80x8,0,16,999]" +check_ok select-layout -t L:two "$(v1 "$v1three")" +must_equal 'v1 layout trimmed' "$(v1_layout L:two)" \ + "$(v1 "80x24,0,0[80x7,0,0,${q0#%},80x16,0,8,${q1#%}]")" + +# --------------------------------------------------------------------------- +# Pane assignment order. + +# "i" is what decides which pane goes into which cell: the cells are ordered by +# it and then handed the window's panes in order, so the cell with "i":0 takes +# the first pane of the window wherever that cell sits in the layout. Here the +# cells are written the other way round from their indexes - the first cell in +# the string is "i":1 and the second "i":0 - so the first pane of the window +# has to come out in the second cell. +# +# Every other layout above lists its cells in the same order as their indexes, +# which is the order the tree is walked in, so this is the only check that can +# tell the two apart. +check_ok select-layout -t L:two \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":8,"x":0,"y":0,"i":1,"I":"'"$q1"'"},{"t":"p","w":80,"h":15,"x":0,"y":9,"i":0,"I":"'"$q0"'"}]}}' +must_equal 'First pane height' \ + "$($TMUX display-message -p -t "$q0" '#{pane_height}')" '15' +must_equal 'Second pane height' \ + "$($TMUX display-message -p -t "$q1" '#{pane_height}')" '8' + +# So the dump carries the two ids the other way round from every dump above, +# and with them their indexes, which are the panes' positions in the window and +# have not moved. Neither cell named an active or last pane, so the pane that +# was active still is - it is now the one in the second cell. +swapped='{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":8,"x":0,"y":0,"i":1,"I":"'"$q1"'"},{"t":"p","w":80,"h":15,"x":0,"y":9,"a":true,"i":0,"I":"'"$q0"'"}]}}' +must_equal 'Layout with the panes swapped' "$(raw_layout L:two)" "$swapped" + +# And that dump round trips, indexes out of order and all. +check_ok select-layout -t L:two "$swapped" +must_equal 'Round tripped swapped layout' "$(raw_layout L:two)" "$swapped" + +# --------------------------------------------------------------------------- +# Cells that name no active or last pane. + +# "a" and "l" are the only things that decide which pane is active and what is +# on the last pane stack, so a layout naming neither leaves the active pane +# where it was and empties the stack. Here the first pane of the window is +# active and the second is at index 0 of the stack beforehand; afterwards the +# first pane is still active and the stack is empty, so the second pane has no +# "l". +check_ok select-pane -t "$q1" +check_ok select-pane -t "$q0" +check_ok select-layout -t L:two \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":9,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":14,"x":0,"y":10,"i":1,"I":"'"$q1"'"}]}}' +must_equal 'Layout naming no active pane' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":9,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":14,"x":0,"y":10,"i":1,"I":"%N"}]}}' + +# "a" may be given as false, which says the same as leaving it out: this pane +# is not the active one. A layout where every cell says so names no active pane +# at all and so leaves the active pane alone, exactly as the layout above did. +check_ok select-layout -t L:two \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":10,"x":0,"y":0,"a":false,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":13,"x":0,"y":11,"a":false,"i":1,"I":"'"$q1"'"}]}}' +must_equal 'Layout with only false active panes' "$(layout L:two)" \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":10,"x":0,"y":0,"a":true,"i":0,"I":"%N"},{"t":"p","w":80,"h":13,"x":0,"y":11,"i":1,"I":"%N"}]}}' + +# --------------------------------------------------------------------------- +# Failures. +# +# Each of these is a different reason for a layout to be rejected, but only the +# rejection itself is checked; the message that comes back with it is not. + +# check_layout_fail $layout +# +# select-layout must reject $layout. +check_layout_fail() +{ + check_fail select-layout -t L:two "$1" +} + +# A rejected layout must leave the window alone, whatever it was. +unchanged=$(raw_layout L:two) + +# Not JSON and not a checksum. +check_layout_fail 'garbage' + +# A v1 body with its checksum left off, and a string of nothing but hex digits. +# A v1 header is four hex digits and a comma; neither of these has one, so there +# is no header and nothing to check a body against. +check_layout_fail '80x24,0,0' +check_layout_fail 'ab' + +# A v1 header with the checksum of a different body. +good=$(v1 '80x24,0,0') +check_layout_fail "${good%%,*},80x24,0,1" + +# A correct checksum over a body that is not a layout: a cell with no offsets, +# and a top to bottom cell closed with '}' instead of ']'. layout_construct_v1 +# returns NULL for both. +check_layout_fail "$(v1 '80x24')" +check_layout_fail "$(v1 '80x24,0,0[80x11,0,0,80x12,0,12}')" + +# Fewer cells than the window has panes; unlike the other way around this +# cannot be fixed up. +check_layout_fail '{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"}}' + +# The children of a top to bottom cell must all be the width of their parent. +check_layout_fail \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":40,"h":12,"x":0,"y":12,"i":1,"I":"'"$q1"'"}]}}' + +# The rest are valid JSON, so it is layout_parse_json() and +# layout_parse_json_layout() doing the rejecting rather than json.c. Each of +# them is a layout that would be applied but for the one thing being checked. + +# Two root cells. +check_layout_fail \ + '{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"},"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"}}' + +# A missing "y". A cell needs all four of "w", "h", "x" and "y". +check_layout_fail '{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"i":0,"I":"'"$q0"'"}}' + +# Cell sizes are bounded below by one column or row and above by 10000 of +# either. Both cases are otherwise complete two cell layouts, so the size is +# the only thing wrong with them. +check_layout_fail \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":0,"h":12,"x":0,"y":12,"i":1,"I":"'"$q1"'"}]}}' +check_layout_fail \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":10001,"x":0,"y":12,"i":1,"I":"'"$q1"'"}]}}' + +# An unknown cell type: only "h", "v" and "p" exist. +check_layout_fail '{"V":2,"L":{"t":"q","w":80,"h":24,"x":0,"y":0}}' + +# A pane cell needs "i", its pane index. It is "i" that says which pane goes in +# the cell; "I" is the pane id the cell was dumped with and is not read back. +check_layout_fail '{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"I":"'"$q0"'"}}' + +# A node cell must have more than one child and a leaf cell must have none. A +# node is written with no "c" at all, with an empty one and with a single child. +check_layout_fail '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0}}' +check_layout_fail '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[]}}' +check_layout_fail \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"}]}}' +check_layout_fail \ + '{"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'","c":[{"t":"p","w":80,"h":24,"x":0,"y":0,"i":1,"I":"'"$q1"'"}]}}' + +# Only one cell may be the active pane. +check_layout_fail \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"a":true,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":12,"x":0,"y":12,"a":true,"i":1,"I":"'"$q1"'"}]}}' + +# The same rejections apply whatever order the fields are written in: a leaf +# with children when "c" comes first, and a node with no children and a bad cell +# type when "t" comes last. +check_layout_fail \ + '{"V":2,"L":{"c":[{"t":"p","w":80,"h":24,"x":0,"y":0,"i":1,"I":"'"$q1"'"}],"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"}}' +check_layout_fail '{"V":2,"L":{"w":80,"h":24,"x":0,"y":0,"t":"v"}}' +check_layout_fail '{"V":2,"L":{"w":80,"h":24,"x":0,"y":0,"t":"q"}}' + +# A child that fails after a sibling has already been parsed and added to the +# parent. This is the case the cleanup at the end of layout_parse_json_layout +# exists for: the children built so far have to be freed along with the parent +# that is never returned. The second child has no "y". +check_layout_fail \ + '{"V":2,"L":{"c":[{"t":"p","w":80,"h":11,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":12,"x":0,"i":1,"I":"'"$q1"'"}],"t":"v","w":80,"h":24,"x":0,"y":0}}' + +# No root cell at all. Every other rejection above comes from a cell that +# failed to parse; this one is the check for "L" itself. +check_layout_fail '{"V":2}' + +# The wrong version, with "V" before and after "L". Fields are looked up by +# key, so the version is read before the layout either way and the position of +# "V" in the string makes no difference. +check_layout_fail '{"V":1,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"}}' +check_layout_fail '{"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"i":0,"I":"'"$q0"'"},"V":1}' + +# None of that touched the layout. +must_equal 'Layout after failures' "$(raw_layout L:two)" "$unchanged" + +# --------------------------------------------------------------------------- +# Floating panes. + +check_ok new-window -d -t L:3 -n float +check_ok select-window -t L:float +check_ok new-pane -d -x 20 -y 6 -X 8 -Y 3 'sleep 100' +check_ok new-pane -d -x 30 -y 8 -X 30 -Y 10 'sleep 100' + +# The tiled pane and the two floating ones. A floating pane goes on the end of +# the window's pane list, so the pane indexes are in the order the panes were +# made whatever order their cells end up in. +f0=$($TMUX display-message -p -t L:float.0 '#{pane_id}') +fa=$($TMUX display-message -p -t L:float.1 '#{pane_id}') +fb=$($TMUX display-message -p -t L:float.2 '#{pane_id}') + +# A floating cell is dumped with its z-index, which is what marks it as +# floating when the layout is parsed back. Two of them, so that there is an +# order between them to get wrong: the newer floating pane is in front, and a +# cell's "z" is its place in that order counting from the front. +floating=$(raw_layout L:float) +must_contain 'Floating layout front z-index' "$floating" '"z":0' +must_contain 'Floating layout back z-index' "$floating" '"z":1' + +# Each floating cell goes in after the cell of the pane that was current when +# it was made, which is the tiled pane both times, so the newer floating cell +# is written before the older one while its pane comes after in the window. +# The dump therefore has its cells in one order and their indexes in another, +# and only comes back the same if the panes go by index. +check_ok select-layout -t L:float "$floating" +must_equal 'Floating layout after round trip' "$(raw_layout L:float)" "$floating" + +# --------------------------------------------------------------------------- +# Floating panes and the legacy (v1) format. +# +# v1 has no way to write a floating pane down, so the two formats cannot say the +# same thing about a window that has one. Dumping v1 takes a copy of the layout, +# deletes the floating cells from the copy and dumps what is left; parsing v1 +# rearranges the tiled panes and leaves the floating ones where they are. None +# of this is reached above: every v1 check so far runs on a window that has no +# floating panes, and every floating pane check so far is in v2. + +# float_state $target +# +# Everything about a floating pane that a v1 layout has no way to carry, so that +# applying one can be checked against all of it at once. +float_state() +{ + $TMUX display-message -p -t "$1" \ + '#{pane_floating_flag} #{pane_width}x#{pane_height} #{pane_left},#{pane_top} #{pane_z}' +} + +# Deleting both floating cells from the copy leaves the root node with a single +# child, and a node with a single child collapses into it, so the root of the +# copy is the tiled cell and the dump is that cell on its own filling the +# window. +must_equal 'v1 dump with floating panes' "$(v1_layout L:float)" \ + "$(v1 "80x24,0,0,${f0#%}")" + +# The cells are deleted from the copy, so the window itself comes through a v1 +# dump untouched - floating panes, z-indexes and all. +must_equal 'Layout after a v1 dump' "$(raw_layout L:float)" "$floating" + +# The same with a split, where deleting the floating cell still leaves two +# children behind and the node it was in does not collapse. +check_ok new-window -d -t L:4 -n mixed +m0=$($TMUX display-message -p -t L:mixed.0 '#{pane_id}') +check_ok split-window -d -v -l 12 -t L:mixed.0 +m1=$($TMUX display-message -p -t L:mixed.1 '#{pane_id}') +check_ok new-pane -d -x 20 -y 6 -X 8 -Y 3 -t L:mixed.0 'sleep 100' +mf=$($TMUX display-message -p -t L:mixed.2 '#{pane_id}') + +# A floating pane takes no space from the tiled layout, so the two tiled cells +# are the same 11 and 12 rows the split gave them. +must_equal 'v1 dump with a split and a floating pane' "$(v1_layout L:mixed)" \ + "$(v1 "80x24,0,0[80x11,0,0,${m0#%},80x12,0,12,${m1#%}]")" + +# A v1 layout applied to a window that has a floating pane rearranges the tiled +# panes and must leave the floating one exactly as it was: v1 names no floating +# pane, so there is nothing in it for one to be changed by. The top pane goes +# from 11 rows to 7 and the bottom one from 12 to 16. +v1mixed="80x24,0,0[80x7,0,0,${m0#%},80x16,0,8,${m1#%}]" +mfbefore=$(float_state "$mf") +check_ok select-layout -t L:mixed "$(v1 "$v1mixed")" +must_equal 'v1 layout with a floating pane' "$(v1_layout L:mixed)" \ + "$(v1 "$v1mixed")" +must_equal 'Floating pane after a v1 layout' "$(float_state "$mf")" "$mfbefore" +must_equal 'Panes after a v1 layout' \ + "$($TMUX display-message -p -t L:mixed '#{window_panes}')" '3' + +# When the tiled layout a v1 string names is a single cell there is no node in +# the new layout for the floating cells to go back into, so one is made: the +# root cell is replaced by a top to bottom node holding it and the floating +# cells go on the end. Nothing else here reaches that. +fabefore=$(float_state "$fa") +fbbefore=$(float_state "$fb") +check_ok select-layout -t L:float "$(v1 "80x24,0,0,${f0#%}")" +must_equal 'v1 single cell layout with floating panes' "$(v1_layout L:float)" \ + "$(v1 "80x24,0,0,${f0#%}")" +must_equal 'Front floating pane after a v1 layout' "$(float_state "$fb")" \ + "$fbbefore" +must_equal 'Back floating pane after a v1 layout' "$(float_state "$fa")" \ + "$fabefore" +must_equal 'Panes after a v1 single cell layout' \ + "$($TMUX display-message -p -t L:float '#{window_panes}')" '3' + +# Adjacent subtrees containing only floating panes used to be a distinct case: +# dumping v1 made a copy of the v2 tree and deleted floating cells from the +# copy, but deleting the last floating cell in the first subtree collapsed the +# parent and could leave the outer traversal holding a stale pointer to the +# second subtree. +check_ok new-window -d -t L:5 -n nested +n0=$($TMUX display-message -p -t L:nested.0 '#{pane_id}') +check_ok split-window -d -v -l 12 -t L:nested.0 +n1=$($TMUX display-message -p -t L:nested.1 '#{pane_id}') +check_ok split-window -d -v -l 6 -t L:nested.1 +n2=$($TMUX display-message -p -t L:nested.2 '#{pane_id}') +check_ok split-window -d -v -l 3 -t L:nested.2 +n3=$($TMUX display-message -p -t L:nested.3 '#{pane_id}') +check_ok split-window -d -v -l 2 -t L:nested.3 +n4=$($TMUX display-message -p -t L:nested.4 '#{pane_id}') +check_ok select-layout -t L:nested \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"h","w":30,"h":10,"x":0,"y":0,"c":[{"t":"p","w":10,"h":5,"x":2,"y":2,"i":0,"z":0},{"t":"p","w":12,"h":6,"x":5,"y":5,"i":1,"z":1}]},{"t":"h","w":30,"h":10,"x":0,"y":0,"c":[{"t":"p","w":14,"h":7,"x":8,"y":8,"i":2,"z":2},{"t":"p","w":16,"h":8,"x":11,"y":11,"i":3,"z":3}]},{"t":"p","w":80,"h":24,"x":0,"y":0,"i":4}]}}' +must_equal 'v1 dump with nested floating-only subtrees' \ + "$(v1_layout L:nested)" "$(v1 "80x24,0,0,${n4#%}")" +check_ok kill-window -t L:nested + +# --------------------------------------------------------------------------- +# A window with no tiled panes. +# +# Killing the last tiled pane of a window that has floating panes does not kill +# the window: that only happens when the pane being killed is the last one +# counting the floating ones. What is left is a window whose layout root is +# either a floating cell on its own, or a node holding nothing but floating +# cells, depending on how many are left. v1 has no way to write either down, so +# it must not try: a layout with no tiled panes in it produces no v1 dump at +# all, and #{window_layout} comes back empty for a client being sent v1. What v2 +# makes of such a window is a separate question and is not checked here. +# +# A dead server dumps nothing either, so the checks below have to establish that +# the server is still there before an empty dump is allowed to mean anything. + +# no_hang $cmd... +# +# Run a command whose result is not being checked, but which has to come back: +# only the server surviving it is checked afterwards, and a server wedged rather +# than killed would otherwise show up as the test never finishing. +no_hang() +{ + if command -v timeout >/dev/null 2>&1; then + timeout 10 "$@" >/dev/null 2>&1 + else + "$@" >/dev/null 2>&1 + fi + return 0 +} + +# One floating pane left. The node it and the tiled cell were in is down to a +# single child, so it collapses and the floating cell becomes the root. +check_ok new-window -d -t L:5 -n gone1 +g0=$($TMUX display-message -p -t L:gone1.0 '#{pane_id}') +check_ok new-pane -d -x 20 -y 6 -X 8 -Y 3 -t L:gone1.0 'sleep 100' +check_ok kill-pane -t "$g0" +must_equal 'Panes left with one floating pane' \ + "$($TMUX display-message -p -t L:gone1 '#{window_panes}')" '1' + +# The floating cell is the root and there is nothing tiled under it, so there is +# no v1 dump to make. In particular the floating cell must not be written out on +# its own, which would be a layout claiming the window is the size and position +# of the floating pane with no pane in it at all. +got=$(v1_layout L:gone1) +check_ok display-message -p alive +must_equal 'v1 dump with one floating pane and no tiled panes' "$got" '' + +# Two floating panes left, so the node keeps two children, does not collapse, +# and stays the root with nothing but floating cells in it. +check_ok new-window -d -t L:6 -n gone2 +h0=$($TMUX display-message -p -t L:gone2.0 '#{pane_id}') +check_ok new-pane -d -x 20 -y 6 -X 8 -Y 3 -t L:gone2.0 'sleep 100' +check_ok new-pane -d -x 30 -y 8 -X 30 -Y 10 -t L:gone2.0 'sleep 100' +check_ok kill-pane -t "$h0" +must_equal 'Panes left with two floating panes' \ + "$($TMUX display-message -p -t L:gone2 '#{window_panes}')" '2' + +# The node is the root this time rather than the floating cell, but it has no +# tiled cell anywhere under it either, so there is still no v1 dump to make - +# and making one must not take the server with it. +got=$(v1_layout L:gone2) +check_ok display-message -p alive +must_equal 'v1 dump with two floating panes and no tiled panes' "$got" '' + +# Nor must parsing a v1 layout against it. There is no tiled pane for the +# layout to name, so whether it is applied or rejected is the format's business; +# it just has to be one of the two. +no_hang $TMUX select-layout -t L:gone2 "$(v1 '80x24,0,0,999')" +check_ok display-message -p alive + +check_ok kill-window -t L:gone1 +check_ok kill-window -t L:gone2 + +# --------------------------------------------------------------------------- +# Control mode notifications. +# +# %layout-change is what a control client actually reads a layout from, and it +# carries both #{window_layout} and #{window_visible_layout}. Its template is +# expanded once per client (control-notify.c), so two clients watching the same +# window must be told about the same change in different formats: v1 for the +# one that has not asked for new layouts, v2 for the one that has. +# +# The dumps above go through "-C display-message", which only ever reaches the +# format callbacks for the client asking. This needs clients that stay +# attached while something else changes the layout, so they go on the end of +# fifos and the change is made from outside. + +DIR=$(mktemp -d) || fail 'Could not make a temporary directory' +OLDIN="$DIR/old-in" +OLDOUT="$DIR/old-out" +NEWIN="$DIR/new-in" +NEWOUT="$DIR/new-out" +OLDPID= +NEWPID= + +cleanup() +{ + [ -n "$OLDPID" ] && kill "$OLDPID" 2>/dev/null + [ -n "$NEWPID" ] && kill "$NEWPID" 2>/dev/null + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup EXIT + +# wait_for $file $text +# +# Wait for $text to appear in a control client's output. +wait_for() +{ + i=0 + while [ "$i" -lt 6 ]; do + grep -F -- "$2" "$1" >/dev/null 2>&1 && return 0 + sleep 1 + i=$((i + 1)) + done + echo "missing from $1: $2" >&2 + cat "$1" >&2 + return 1 +} + +mkfifo "$OLDIN" "$NEWIN" || fail 'Could not make the control client fifos' +: >"$OLDOUT" +: >"$NEWOUT" + +$TMUX -C attach -t L <"$OLDIN" >"$OLDOUT" 2>&1 & +OLDPID=$! +exec 4>"$OLDIN" +$TMUX -C attach -f new-layouts -t L <"$NEWIN" >"$NEWOUT" 2>&1 & +NEWPID=$! +exec 5>"$NEWIN" + +# Both clients have to be attached before the layout changes, or they miss the +# notification entirely. +printf 'display-message -p ready\n' >&4 +printf 'display-message -p ready\n' >&5 +wait_for "$OLDOUT" ready || fail 'Control client without new-layouts did not attach' +wait_for "$NEWOUT" ready || fail 'Control client with new-layouts did not attach' + +wid=$($TMUX display-message -p -t L:two '#{window_id}') + +# One layout change, made by a third client so that neither of the two is the +# one running the command. 8 lines for the top pane leaves 15 for the bottom +# and one for the border. +check_ok resize-pane -t "$q0" -y 8 + +# Nothing is zoomed, so both fields of the notification carry the same layout. +# The v2 one is compared against the dump rather than a literal so that it is +# the two formats being checked and not the geometry again. +v2now=$(raw_layout L:two) +v1now=$(v1 "80x24,0,0[80x8,0,0,${q0#%},80x15,0,9,${q1#%}]") +wait_for "$NEWOUT" "%layout-change $wid $v2now $v2now " || + fail 'No v2 %layout-change for the client with new-layouts' +wait_for "$OLDOUT" "%layout-change $wid $v1now $v1now " || + fail 'No v1 %layout-change for the client without new-layouts' + +# How many notifications one layout change produces, which differs by format +# on purpose. cmd_select_layout_exec() fires window-layout-changed for any +# layout it applies, and layout_parse() fires it again for a v1 one, so v1 +# arrives twice - which is what master does for every layout, and what control +# clients written against it expect. v2 is new and has no such clients, so it +# gets the single notification. Counting the delta rather than the total, with +# a settle in between, keeps this independent of what has already been sent. +n1=$(grep -c "%layout-change $wid " "$OLDOUT") +check_ok select-layout -t L:two \ + '{"V":2,"L":{"t":"v","w":80,"h":24,"x":0,"y":0,"c":[{"t":"p","w":80,"h":9,"x":0,"y":0,"i":0,"I":"'"$q0"'"},{"t":"p","w":80,"h":14,"x":0,"y":10,"i":1,"I":"'"$q1"'"}]}}' +sleep 2 +n2=$(grep -c "%layout-change $wid " "$OLDOUT") +must_equal 'Notifications for a v2 layout' "$((n2 - n1))" '1' + +check_ok select-layout -t L:two "$(v1 "$v1vsplit")" +sleep 2 +n3=$(grep -c "%layout-change $wid " "$OLDOUT") +must_equal 'Notifications for a v1 layout' "$((n3 - n2))" '2' + +# And the client that did not ask for new layouts must never have been sent +# one, in that notification or any other. +grep -F '{"V":2,' "$OLDOUT" >/dev/null 2>&1 && + fail 'Control client without new-layouts was sent a v2 layout' + +if [ "$($TMUX display-message -p alive 2>&1)" != "alive" ]; then + echo "Server died." >&2 + exit 1 +fi + +$TMUX kill-server 2>/dev/null +exit 0 From 077d6e53cdeb0a9ccc3243f14ae6508102285fc5 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Wed, 9 Sep 2026 08:53:51 +0100 Subject: [PATCH 29/38] Test for invalid prompt. --- regress/prompt-keys.sh | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/regress/prompt-keys.sh b/regress/prompt-keys.sh index 6fde51b7b..508787b6c 100644 --- a/regress/prompt-keys.sh +++ b/regress/prompt-keys.sh @@ -217,7 +217,36 @@ $OUT send-keys M-r || exit 1 settle status_line | grep -q '>' || fail "status-line prompt not drawn on the status line" -# --- 11. emacs cursor-marker edit, accept recovers the exact buffer. --- +# --- 11. Invalid UTF-8 input must not desynchronize the prompt buffer. --- +$OUT send-keys -H e6 85 5f || exit 1 # invalid UTF-8, then "_" +settle +$IN display-message -p '#{version}' >/dev/null 2>&1 || \ + fail "invalid UTF-8 append killed inner tmux" +status_line | grep -qF "> _" || \ + fail "invalid UTF-8 append did not keep prompt usable (got '$(status_line)')" +$OUT send-keys Enter || exit 1 +settle +[ "$($IN show -gv @r)" = "_" ] || \ + fail "invalid UTF-8 append recovered '$($IN show -gv @r)', wanted '_'" + +$IN set -g @r "SENTINEL" || exit 1 +$OUT send-keys M-r || exit 1 +settle +$OUT send-keys -H e6 85 04 || exit 1 # invalid UTF-8, then C-d +settle +$IN display-message -p '#{version}' >/dev/null 2>&1 || \ + fail "invalid UTF-8 delete killed inner tmux" +status_line | grep -q '>' || \ + fail "invalid UTF-8 delete closed the prompt (got '$(status_line)')" +$OUT send-keys Escape || exit 1 +settle +[ "$($IN show -gv @r)" = "SENTINEL" ] || \ + fail "invalid UTF-8 delete accepted the prompt" + +# --- 12. emacs cursor-marker edit, accept recovers the exact buffer. --- +$IN set -g @r "" || exit 1 +$OUT send-keys M-r || exit 1 +settle $OUT send-keys -l "abc" || exit 1 $OUT send-keys Home || exit 1 $OUT send-keys -l "X" || exit 1 @@ -229,7 +258,7 @@ settle [ "$($IN show -gv @r)" = "Xabc" ] || \ fail "status-line accept recovered '$($IN show -gv @r)', wanted 'Xabc'" -# --- 12. Unicode on the status line: insert, move, delete wide char. --- +# --- 13. Unicode on the status line: insert, move, delete wide char. --- $IN set -g @r "" || exit 1 $OUT send-keys M-r || exit 1 settle @@ -249,7 +278,7 @@ settle [ "$($IN show -gv @r)" = "Za" ] || \ fail "status-line wide edit recovered '$($IN show -gv @r)', wanted 'Za'" -# --- 13. Overflow: more text than fits stays within the line and is kept. --- +# --- 14. Overflow: more text than fits stays within the line and is kept. --- big="0123456789012345678901234567890123456789012345678901234567890123456789ABCDEFGHIJ" $IN set -g @r "" || exit 1 $OUT send-keys M-r || exit 1 @@ -264,7 +293,7 @@ settle # The whole buffer was kept despite only part being visible. [ "$($IN show -gv @r)" = "$big" ] || fail "overflowing prompt lost buffer content" -# --- 14. Escape closes the status-line prompt cleanly. --- +# --- 15. Escape closes the status-line prompt cleanly. --- $IN set -g @r "SENTINEL" || exit 1 $OUT send-keys M-r || exit 1 settle From bf43fdc0c7dd4e951b758b527e6a1fcd8600af9c Mon Sep 17 00:00:00 2001 From: nicm Date: Wed, 9 Sep 2026 07:03:39 +0000 Subject: [PATCH 30/38] Add new layout format which includes floating panes. The new format is now a JSON subset which is less fragile and easier to handle than the old custom format. The old (version 1) format is still supported for control mode clients for now - they must set the new-layouts flag to receive the new format. From Dane Jensen. --- cmd-select-layout.c | 13 +- layout-custom.c | 1067 +++++++++++++++++++++++++++++++++++++------ layout.c | 37 +- tmux.1 | 23 +- tmux.h | 6 +- 5 files changed, 959 insertions(+), 187 deletions(-) diff --git a/cmd-select-layout.c b/cmd-select-layout.c index 86effb03c..95f60e6ca 100644 --- a/cmd-select-layout.c +++ b/cmd-select-layout.c @@ -1,4 +1,4 @@ -/* $OpenBSD: cmd-select-layout.c,v 1.44 2026/09/08 10:20:08 nicm Exp $ */ +/* $OpenBSD: cmd-select-layout.c,v 1.45 2026/09/09 07:03:39 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -73,12 +73,13 @@ cmd_select_layout_exec(struct cmd *self, struct cmdq_item *item) { struct args *args = cmd_get_args(self); struct cmd_find_state *target = cmdq_get_target(item); + struct client *c = cmdq_get_target_client(item); struct winlink *wl = target->wl; struct window *w = wl->window; struct window_pane *wp = target->wp; const char *layoutname; - char *oldlayout, *cause; - int next, previous, layout; + char *oldlayout, *cause = NULL; + int next, previous, layout, flags = 0; server_unzoom_window(w); @@ -89,8 +90,12 @@ cmd_select_layout_exec(struct cmd *self, struct cmdq_item *item) if (args_has(args, 'p')) previous = 1; + if (c != NULL && + (c->flags & CLIENT_CONTROL) && + (~c->flags & CLIENT_CONTROL_NEWLAYOUTS)) + flags |= LAYOUT_CUSTOM_OLD_FORMAT; oldlayout = w->old_layout; - w->old_layout = layout_dump(w, w->layout_root, 0); + w->old_layout = layout_dump(w, w->layout_root, flags); if (next || previous) { if (next) diff --git a/layout-custom.c b/layout-custom.c index 3fa30347e..7f6da285c 100644 --- a/layout-custom.c +++ b/layout-custom.c @@ -1,4 +1,4 @@ -/* $OpenBSD: layout-custom.c,v 1.39 2026/09/08 10:20:08 nicm Exp $ */ +/* $OpenBSD: layout-custom.c,v 1.40 2026/09/09 07:03:39 nicm Exp $ */ /* * Copyright (c) 2010 Nicholas Marriott @@ -19,18 +19,238 @@ #include #include +#include +#include +#include +#include #include #include "tmux.h" +/* + * Layouts can be represented as strings in a JSON format (v2). The legacy + * format (v1) will be removed in the future and should no longer be used. + * + * The current (v2) format is JSON. The top level has two keys: + * "V": version number, currently 2 + * "L": root layout cell + * + * Each cell is an object with: + * "t": cell type: + * "h": horizontal + * "v": vertical + * "p": pane + * "w": cell width + * "h": cell height + * "x": horizontal position + * "y": vertical position + * + * If the cell is a node cell (with child cells), it additionally has: + * "c": array of child cells + * + * If the cell is a leaf cell (that is, containing a pane and no child cells), + * it additionally has: + * "I": pane ID as %n (currently ignored on parse) + * "l": index into last panes list if visited and not the active pane + * "a": true if the active pane + * "i": pane index + * "z": z-index, if a floating pane + */ + +/* Layout string. */ +struct layout_string { + char *dat; + size_t size; /* length written, not including terminator */ + size_t capacity; /* bytes allocated */ +}; + +/* Layout parse cell context. */ +struct layout_parse_cell_ctx { + struct layout_cell *lc; + int active; + int last; + int index; + int zindex; +}; + +/* Layout parse context. */ +struct layout_parse_ctx { + int64_t version; + int num_active; + struct layout_cell *root; + char **cause; + + int size; /* number used */ + int capacity; /* number allocated */ + struct layout_parse_cell_ctx *cctxs; +}; + static struct layout_cell *layout_find_bottomright(struct layout_cell *); static u_short layout_checksum(const char *); -static int layout_append(struct layout_cell *, char *, - size_t); -static int layout_construct(struct layout_cell *, - const char **, struct layout_cell **); -static void layout_assign(struct window_pane **, - struct layout_cell *, int); +static int layout_append(struct layout_cell *, + struct layout_string *, int); +static int layout_construct(const char *, + struct layout_parse_ctx *); +static void layout_assign(struct window *, + struct layout_parse_ctx *); +static void layout_parse_apply_ctx(struct window *, + struct layout_parse_ctx *); +static struct layout_cell *layout_parse_json_layout(struct json_node *, + struct layout_cell *, + struct layout_parse_ctx *); +static int layout_parse_ctx_check_indexes( + struct layout_parse_ctx *); + +/* Compare cell contexts in ascending order of index. */ +static int +layout_parse_index_cmp(const void *a, const void *b) +{ + const struct layout_parse_cell_ctx *cca = a; + const struct layout_parse_cell_ctx *ccb = b; + int retval = 0; + + if (cca->index < ccb->index) + retval = -1; + if (cca->index > ccb->index) + retval = 1; + return (retval); +} + +/* Compare cell contexts in descending order of z-index. */ +static int +layout_parse_zindex_cmp(const void *a, const void *b) +{ + const struct layout_parse_cell_ctx *cca = a; + const struct layout_parse_cell_ctx *ccb = b; + int retval = 0; + + if (cca->zindex > ccb->zindex) + retval = -1; + if (cca->zindex < ccb->zindex) + retval = 1; + return (retval); +} + +/* Compare cell contexts in descending order of last. */ +static int +layout_parse_last_cmp(const void *a, const void *b) +{ + const struct layout_parse_cell_ctx *cca = a; + const struct layout_parse_cell_ctx *ccb = b; + int retval = 0; + + if (cca->last > ccb->last) + retval = -1; + if (cca->last < ccb->last) + retval = 1; + return (retval); +} + +/* Initialize a layout string. */ +static void +layout_string_init(struct layout_string *ls) +{ + ls->capacity = 1024; + ls->dat = xmalloc(ls->capacity); + ls->dat[0] = '\0'; + ls->size = 0; +} + +/* Free a layout string. */ +static void +layout_string_free(struct layout_string *ls) +{ + free(ls->dat); + ls->dat = NULL; + ls->size = 0; + ls->capacity = 0; +} + +/* Write an optionally formatted string to the end of the layout string. */ +static void printflike(2, 3) +layout_string_write(struct layout_string *ls, const char *fmt, ...) +{ + va_list ap; + char *s; + int slen; + + va_start(ap, fmt); + slen = xvasprintf(&s, fmt, ap); + va_end(ap); + + while (ls->size + slen + 1 > ls->capacity) { + ls->dat = xreallocarray(ls->dat, 2, ls->capacity); + ls->capacity *= 2; + } + memcpy(ls->dat + ls->size, s, slen); + ls->size += slen; + ls->dat[ls->size] = '\0'; + + free(s); +} + +/* Initialize a parse context. */ +static void +layout_parse_init_ctx(struct layout_parse_ctx *pctx, char **cause) +{ + pctx->version = -1; + pctx->num_active = 0; + pctx->root = NULL; + pctx->cause = cause; + pctx->size = 0; + pctx->capacity = 64; + pctx->cctxs = xcalloc(pctx->capacity, sizeof *pctx->cctxs); +} + +/* Free a parse context. */ +static void +layout_parse_free_ctx(struct layout_parse_ctx *pctx) +{ + layout_free_cell(pctx->root, 0); + pctx->root = NULL; + free(pctx->cctxs); + pctx->cctxs = NULL; + pctx->size = 0; + pctx->capacity = 0; +} + +/* Add a cell context to the parse context. */ +static void +layout_parse_add_cctx(struct layout_parse_ctx *pctx, struct layout_cell *lc, + int active, int last, int index, int zindex) +{ + struct layout_parse_cell_ctx *cctx; + + if (pctx->size >= pctx->capacity) { + pctx->capacity *= 2; + pctx->cctxs = xreallocarray(pctx->cctxs, pctx->capacity, + sizeof *pctx->cctxs); + } + cctx = &pctx->cctxs[pctx->size++]; + + cctx->lc = lc; + cctx->active = active; + cctx->last = last; + cctx->index = index; + cctx->zindex = zindex; +} + +/* Remove a cell context from the parse context. Does not preserve ordering. */ +static int +layout_parse_remove_cctx(struct layout_parse_ctx *pctx, struct layout_cell *lc) +{ + struct layout_parse_cell_ctx *cctx; + int i; + + for (i = 0; i < pctx->size; i++) { + if (lc == pctx->cctxs[i].lc) { + cctx = &pctx->cctxs[--pctx->size]; + memmove(&pctx->cctxs[i], cctx, sizeof *cctx); + return (0); + } + } + return (-1); +} /* Find the bottom-right cell. */ static struct layout_cell * @@ -58,73 +278,121 @@ layout_checksum(const char *layout) /* Dump layout as a string. */ char * -layout_dump(struct window *w, struct layout_cell *root, __unused int flags) +layout_dump(__unused struct window *w, struct layout_cell *lcroot, int flags) { - char layout[8192], *out; - int bracket = 0; - struct window_pane *wp; + struct layout_string layout_string; + char *out = NULL; - *layout = '\0'; - if (layout_append(root, layout, sizeof layout) != 0) - return (NULL); + if (lcroot == NULL) + return NULL; - TAILQ_FOREACH(wp, &w->z_index, zentry) { - if (!window_pane_is_floating(wp)) - break; - if (!bracket) { - strlcat(layout, "<", sizeof layout); - bracket = 1; - } - if (layout_append(wp->layout_cell, layout, sizeof layout) != 0) - return (NULL); - strlcat(layout, ",", sizeof layout); + layout_string_init(&layout_string); + + if (layout_append(lcroot, &layout_string, flags) == 0) { + if (flags & LAYOUT_CUSTOM_OLD_FORMAT) + xasprintf(&out, "%04hx,%s", + layout_checksum(layout_string.dat), + layout_string.dat); + else + xasprintf(&out, "{\"V\":2,\"L\":%s}", + layout_string.dat); } - if (bracket) - layout[strlen(layout) - 1] = '>'; + layout_string_free(&layout_string); - xasprintf(&out, "%04hx,%s", layout_checksum(layout), layout); return (out); } -/* Append information for a single cell. */ +/* Append information for a single cell in a JSON (v2) format. */ static int -layout_append(struct layout_cell *lc, char *buf, size_t len) +layout_append_v2(struct layout_cell *lc, struct layout_string *ls) { - struct layout_cell *lcchild; - char tmp[64]; - size_t tmplen; - const char *brackets = "]["; + struct layout_cell *lcchild; + struct window_pane *wp; + enum layout_type type; + char c; + u_int i, n; - if (len == 0) - return (-1); if (lc == NULL) - return (0); - if (lc->wp != NULL) { - tmplen = xsnprintf(tmp, sizeof tmp, "%ux%u,%d,%d,%u", - lc->g.sx, lc->g.sy, lc->g.xoff, lc->g.yoff, lc->wp->id); - } else { - tmplen = xsnprintf(tmp, sizeof tmp, "%ux%u,%d,%d", - lc->g.sx, lc->g.sy, lc->g.xoff, lc->g.yoff); - } - if (tmplen > (sizeof tmp) - 1) - return (-1); - if (strlcat(buf, tmp, len) >= len) return (-1); + type = lc->type; + if (type == LAYOUT_TOPBOTTOM) + c = 'v'; + else if (type == LAYOUT_LEFTRIGHT) + c = 'h'; + else if (type == LAYOUT_WINDOWPANE) + c = 'p'; + else + return (-1); + + layout_string_write(ls, "{\"t\":\"%c\",\"w\":%u,\"h\":%u,\"x\":%d" + ",\"y\":%d", c, lc->g.sx, lc->g.sy, lc->g.xoff, lc->g.yoff); + if (type != LAYOUT_WINDOWPANE) { + layout_string_write(ls, ",\"c\":["); + n = 0; + TAILQ_FOREACH(lcchild, &lc->cells, entry) { + if (layout_append_v2(lcchild, ls) != 0) + return (-1); + layout_string_write(ls, ","); + n++; + } + if (n == 0) + return (-1); + ls->dat[--ls->size] = '\0'; /* removing trailing comma */ + layout_string_write(ls, "]"); + } else { + wp = lc->wp; + if (wp == NULL) + return (-1); + if (wp == wp->window->active) + layout_string_write(ls, ",\"a\":true"); + else if (window_pane_last_index(wp, &i) == 0) + layout_string_write(ls, ",\"l\":%u", i); + if (window_pane_index(wp, &i) != 0) + return (-1); + layout_string_write(ls, ",\"i\":%u", i); + if ((lc->flags & LAYOUT_CELL_FLOATING) && + window_pane_zindex(wp, &i) == 0) + layout_string_write(ls, ",\"z\":%u", i); + layout_string_write(ls, ",\"I\":\"%%%u\"", wp->id); + } + + layout_string_write(ls, "}"); + + return (0); +} + +/* Append information for a single cell in the legacy (v1) format. */ +static int +layout_append_v1(struct layout_cell *lc, struct layout_string *ls) +{ + struct layout_cell *lcchild; + const char *brackets = "[]"; + + if (lc == NULL) + return (-1); + + if (lc->wp != NULL) { + layout_string_write(ls, "%ux%u,%d,%d,%u", lc->g.sx, lc->g.sy, + lc->g.xoff, lc->g.yoff, lc->wp->id); + } else { + layout_string_write(ls, "%ux%u,%d,%d", lc->g.sx, lc->g.sy, + lc->g.xoff, lc->g.yoff); + } switch (lc->type) { case LAYOUT_LEFTRIGHT: - brackets = "}{"; + brackets = "{}"; /* FALLTHROUGH */ case LAYOUT_TOPBOTTOM: - if (strlcat(buf, &brackets[1], len) >= len) - return (-1); + layout_string_write(ls, "%c", brackets[0]); TAILQ_FOREACH(lcchild, &lc->cells, entry) { - if (layout_append(lcchild, buf, len) != 0) - return (-1); - if (strlcat(buf, ",", len) >= len) + if (layout_append_v1(lcchild, ls) != 0) return (-1); + layout_string_write(ls, ","); } - buf[strlen(buf) - 1] = brackets[0]; + + ls->dat[--ls->size] = '\0'; /* removing trailing comma */ + layout_string_write(ls, "%c", brackets[1]); break; case LAYOUT_WINDOWPANE: break; @@ -133,6 +401,121 @@ layout_append(struct layout_cell *lc, char *buf, size_t len) return (0); } +/* + * Copies the tiled part of a layout. Only populates what is necessary to dump a + * V1 layout string. + */ +static struct layout_cell * +layout_custom_copy_layout(struct layout_cell *lc) +{ + struct layout_cell *lcchild, *lcnewchild, *lconly; + struct layout_cell *lcnew; + + if (lc->type == LAYOUT_WINDOWPANE && + (lc->flags & LAYOUT_CELL_FLOATING)) + return (NULL); + + lcnew = layout_create_cell(NULL); + + lcnew->type = lc->type; + lcnew->flags = lc->flags; + if (lc->type == LAYOUT_WINDOWPANE) + lcnew->wp = lc->wp; + layout_set_size(lcnew, lc->g.sx, lc->g.sy, lc->g.xoff, lc->g.yoff); + + switch (lc->type) { + case LAYOUT_WINDOWPANE: + break; + case LAYOUT_TOPBOTTOM: + case LAYOUT_LEFTRIGHT: + TAILQ_FOREACH(lcchild, &lc->cells, entry) { + lcnewchild = layout_custom_copy_layout(lcchild); + if (lcnewchild == NULL) + continue; + TAILQ_INSERT_TAIL(&lcnew->cells, lcnewchild, entry); + lcnewchild->parent = lcnew; + } + + lconly = TAILQ_FIRST(&lcnew->cells); + if (lconly == NULL) { + layout_free_cell(lcnew, 0); + return (NULL); + } + if (TAILQ_NEXT(lconly, entry) == NULL) { + TAILQ_REMOVE(&lcnew->cells, lconly, entry); + lconly->parent = NULL; + layout_free_cell(lcnew, 0); + return (lconly); + } + break; + } + + return (lcnew); +} + +/* Create a compatibility layout for dumping a V1 layout string. */ +static struct layout_cell * +layout_custom_create_compat(struct layout_cell *lcroot) +{ + struct layout_cell *lccompat; + + lccompat = layout_custom_copy_layout(lcroot); + if (lccompat != NULL && layout_cell_is_tiled(lccompat)) { + lccompat->g.xoff = 0; + lccompat->g.yoff = 0; + } + + return (lccompat); +} + +/* Unlinks all panes from the given layout. */ +static void +layout_custom_unlink_panes(struct layout_cell *lc) +{ + struct layout_cell *lcchild; + + switch (lc->type) { + case LAYOUT_WINDOWPANE: + lc->wp = NULL; + break; + case LAYOUT_LEFTRIGHT: + case LAYOUT_TOPBOTTOM: + TAILQ_FOREACH(lcchild, &lc->cells, entry) + layout_custom_unlink_panes(lcchild); + break; + } +} + +/* Frees the compatibility layout. */ +static void +layout_custom_free_compat(struct layout_cell *lcroot) +{ + if (lcroot == NULL) + return; + layout_custom_unlink_panes(lcroot); + layout_free_cell(lcroot, 0); +} + +/* Dispatch to append the appropriate version. */ +static int +layout_append(struct layout_cell *lcroot, struct layout_string *ls, int flags) +{ + struct layout_cell *lccompat; + int result; + + if (flags & LAYOUT_CUSTOM_OLD_FORMAT) { + if (!layout_cell_is_tiled(lcroot) && + !layout_cell_has_tiled_child(lcroot)) + return (-1); + lccompat = layout_custom_create_compat(lcroot); + result = layout_append_v1(lccompat, ls); + layout_custom_free_compat(lccompat); + } else + result = layout_append_v2(lcroot, ls); + + return (result); +} + /* Check layout sizes fit. */ static int layout_check(struct layout_cell *lc) @@ -145,24 +528,30 @@ layout_check(struct layout_cell *lc) break; case LAYOUT_LEFTRIGHT: TAILQ_FOREACH(lcchild, &lc->cells, entry) { + if (!layout_cell_is_tiled(lcchild) && + !layout_cell_has_tiled_child(lcchild)) + continue; if (lcchild->g.sy != lc->g.sy) return (0); if (!layout_check(lcchild)) return (0); n += lcchild->g.sx + 1; } - if (n - 1 != lc->g.sx) + if (n != 0 && n - 1 != lc->g.sx) return (0); break; case LAYOUT_TOPBOTTOM: TAILQ_FOREACH(lcchild, &lc->cells, entry) { + if (!layout_cell_is_tiled(lcchild) && + !layout_cell_has_tiled_child(lcchild)) + continue; if (lcchild->g.sx != lc->g.sx) return (0); if (!layout_check(lcchild)) return (0); n += lcchild->g.sy + 1; } - if (n - 1 != lc->g.sy) + if (n != 0 && n - 1 != lc->g.sy) return (0); break; } @@ -171,45 +560,30 @@ layout_check(struct layout_cell *lc) /* Parse a layout string and arrange window as layout. */ int -layout_parse(struct window *w, const char *layout, char **cause) +layout_parse(struct window *w, const char *input, char **cause) { - struct layout_cell *lcchild, *tiled_lc = NULL; struct window_pane *wp; + struct layout_cell *lcchild, *lc = NULL; + struct layout_parse_ctx pctx; u_int npanes, ncells, sx = 0, sy = 0; - u_short csum; - int n = 0; - - /* Check validity. */ - if (sscanf(layout, "%hx,%n", &csum, &n) != 1 || n != 5) { - *cause = xstrdup("invalid layout"); - return (-1); - } - layout += n; - if (csum != layout_checksum(layout)) { - *cause = xstrdup("invalid layout"); - return (-1); - } + int with_floating; /* Build the layout. */ - if (layout_construct(NULL, &layout, &tiled_lc) != 0) { - *cause = xstrdup("invalid layout"); + layout_parse_init_ctx(&pctx, cause); + if (layout_construct(input, &pctx) != 0) { + layout_parse_free_ctx(&pctx); return (-1); } - if (tiled_lc == NULL) { - /* A stub layout cell for an empty window. */ - tiled_lc = layout_create_cell(NULL); - tiled_lc->type = LAYOUT_LEFTRIGHT; - layout_set_size(tiled_lc, w->sx, w->sy, 0, 0); - } - if (*layout != '\0') { - *cause = xstrdup("invalid layout"); - goto fail; - } + with_floating = pctx.version > 1; /* Check this window will fit into the layout. */ - npanes = window_count_panes(w, 1); + npanes = window_count_panes(w, with_floating); + if (npanes == 0) { + xasprintf(cause, "window @%u has no panes", w->id); + goto fail; + } for (;;) { - ncells = layout_count_cells(tiled_lc); + ncells = layout_count_cells(pctx.root, with_floating); if (npanes > ncells) { xasprintf(cause, "have %u panes but need %u", npanes, ncells); @@ -222,82 +596,127 @@ layout_parse(struct window *w, const char *layout, char **cause) * Fewer panes than cells, close the bottom right until none * remain. */ - lcchild = layout_find_bottomright(tiled_lc); - layout_destroy_cell(w, lcchild, &tiled_lc); + lcchild = layout_find_bottomright(pctx.root); + if (pctx.version > 1 && layout_parse_remove_cctx(&pctx, + lcchild) != 0) { + *cause = xstrdup("empty/missing layout parse context"); + goto fail; + } + layout_destroy_cell(NULL, lcchild, &pctx.root); } + /* The root is now owned by lc. */ + lc = pctx.root; + pctx.root = NULL; + /* * It appears older versions of tmux were able to generate layouts with * an incorrect top cell size - if it is larger than the top child then * correct that (if this is still wrong the check code will catch it). */ - - switch (tiled_lc->type) { + switch (lc->type) { case LAYOUT_WINDOWPANE: break; case LAYOUT_LEFTRIGHT: - TAILQ_FOREACH(lcchild, &tiled_lc->cells, entry) { - sy = lcchild->g.sy + 1; - sx += lcchild->g.sx + 1; + TAILQ_FOREACH(lcchild, &lc->cells, entry) { + if (layout_cell_is_tiled(lcchild) || + layout_cell_has_tiled_child(lcchild)) { + sy = lcchild->g.sy + 1; + sx += lcchild->g.sx + 1; + } } break; case LAYOUT_TOPBOTTOM: - TAILQ_FOREACH(lcchild, &tiled_lc->cells, entry) { - sx = lcchild->g.sx + 1; - sy += lcchild->g.sy + 1; + TAILQ_FOREACH(lcchild, &lc->cells, entry) { + if (layout_cell_is_tiled(lcchild) || + layout_cell_has_tiled_child(lcchild)) { + sx = lcchild->g.sx + 1; + sy += lcchild->g.sy + 1; + } } break; } - if (tiled_lc->type != LAYOUT_WINDOWPANE && - (tiled_lc->g.sx != sx || tiled_lc->g.sy != sy)) { - layout_print_cell(tiled_lc, __func__, 0); - tiled_lc->g.sx = sx - 1; tiled_lc->g.sy = sy - 1; + if (lc->type != LAYOUT_WINDOWPANE && sx != 0 && sy != 0 && + (lc->g.sx != sx || lc->g.sy != sy)) { + layout_print_cell(lc, __func__, 0); + lc->g.sx = sx - 1; lc->g.sy = sy - 1; } /* Check the new layout. */ - if (!layout_check(tiled_lc)) { + if (!layout_check(lc)) { *cause = xstrdup("size mismatch after applying layout"); goto fail; } /* Resize window to the layout size. */ - if (sx != 0 && sy != 0) - window_resize(w, tiled_lc->g.sx, tiled_lc->g.sy, -1, -1); + if (layout_cell_is_tiled(lc) || + layout_cell_has_tiled_child(lc)) + window_resize(w, lc->g.sx, lc->g.sy, -1, -1); + + /* Preserve floating panes for version 1. */ + if (pctx.version == 1) { + TAILQ_FOREACH(wp, &w->panes, entry) { + if (!window_pane_is_floating(wp)) + continue; + lcchild = wp->layout_cell; + TAILQ_REMOVE(&lcchild->parent->cells, lcchild, entry); + lcchild->parent = NULL; + } + } /* Destroy the old layout and swap to the new. */ layout_free_cell(w->layout_root, 0); - w->layout_root = tiled_lc; + w->layout_root = lc; /* Assign the panes into the cells. */ - wp = TAILQ_FIRST(&w->panes); - if (tiled_lc != NULL) - layout_assign(&wp, tiled_lc, 0); + layout_assign(w, &pctx); - /* Fix pane z-indexes. */ - while (!TAILQ_EMPTY(&w->z_index)) { - wp = TAILQ_FIRST(&w->z_index); - TAILQ_REMOVE(&w->z_index, wp, zentry); - } - layout_fix_zindexes(w, tiled_lc); - - /* Update pane offsets and sizes. */ + /* Update pane attributes. */ layout_fix_offsets(w); layout_fix_panes(w, NULL); + if (pctx.version > 1) + layout_parse_apply_ctx(w, &pctx); recalculate_sizes(); - layout_print_cell(tiled_lc, __func__, 0); + layout_print_cell(lc, __func__, 0); - events_fire_window("window-layout-changed", w); + /* Backwards compatibility. */ + if (pctx.version == 1) + events_fire_window("window-layout-changed", w); + layout_parse_free_ctx(&pctx); return (0); fail: - layout_free_cell(tiled_lc, 0); + layout_free_cell(lc, 0); + layout_parse_free_ctx(&pctx); return (-1); } -/* Assign panes into cells. */ +/* Assign panes into cells from the cell contexts. */ static void -layout_assign(struct window_pane **wp, struct layout_cell *lc, int flags) +layout_assign_from_ctx(struct window *w, struct layout_parse_ctx *pctx) +{ + struct layout_cell *lc; + struct window_pane *wp; + int i; + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_index_cmp); + + wp = TAILQ_FIRST(&w->panes); + for (i = 0; i < pctx->size; i++) { + lc = pctx->cctxs[i].lc; + layout_make_leaf(lc, wp); + wp = TAILQ_NEXT(wp, entry); + } +} + +/* + * Assign tiled cells to available panes. Panes that already have a cell are + * floating and are skipped over. + */ +static void +layout_assign_fallback_tiled(struct window_pane **wp, struct layout_cell *lc) { struct layout_cell *lcchild; @@ -306,18 +725,60 @@ layout_assign(struct window_pane **wp, struct layout_cell *lc, int flags) switch (lc->type) { case LAYOUT_WINDOWPANE: + while (*wp != NULL && (*wp)->layout_cell != NULL) + *wp = TAILQ_NEXT(*wp, entry); + if (*wp == NULL) + return; layout_make_leaf(lc, *wp); - lc->flags |= flags; *wp = TAILQ_NEXT(*wp, entry); return; case LAYOUT_LEFTRIGHT: case LAYOUT_TOPBOTTOM: - TAILQ_FOREACH(lcchild, &lc->cells, entry) - layout_assign(wp, lcchild, flags); + TAILQ_FOREACH(lcchild, &lc->cells, entry) { + layout_assign_fallback_tiled(wp, lcchild); + } return; } } +/* + * Assign panes into cells when there are no cell contexts. This will be removed + * when the non-JSON format is deprecated. + */ +static void +layout_assign_fallback(struct window *w, struct layout_cell *lcroot) +{ + struct window_pane *wp = TAILQ_FIRST(&w->panes); + struct layout_cell *lc; + + layout_assign_fallback_tiled(&wp, lcroot); + + if (window_count_panes(w, 1) > 1 && + lcroot->type == LAYOUT_WINDOWPANE) + lcroot = layout_replace_with_node(w, lcroot, LAYOUT_TOPBOTTOM); + + wp = TAILQ_FIRST(&w->panes); + while (wp != NULL) { + if (window_pane_is_floating(wp)) { + lc = wp->layout_cell; + lc->parent = lcroot; + TAILQ_INSERT_TAIL(&lcroot->cells, lc, entry); + } + wp = TAILQ_NEXT(wp, entry); + } +} + +/* Assign panes into cells. Number of cells must match the number of panes. */ +static void +layout_assign(struct window *w, struct layout_parse_ctx *pctx) +{ + if (pctx->size > 0) + layout_assign_from_ctx(w, pctx); + else + layout_assign_fallback(w, w->layout_root); +} + +/* Construct a cell from the legacy (v1) format. */ static struct layout_cell * layout_construct_cell(struct layout_cell *lcparent, const char **layout) { @@ -366,34 +827,27 @@ layout_construct_cell(struct layout_cell *lcparent, const char **layout) return (lc); } -/* - * Given a character string layout, recursively construct cells. - * Possible return values: - * lc LAYOUT_WINDOWPANE, no children - * lc LAYOUT_LEFTRIGHT or LAYOUT_TOPBOTTOM, with children - */ -static int -layout_construct(struct layout_cell *lcparent, const char **layout, - struct layout_cell **lc) +/* Construct a layout from the legacy (v1) format. */ +static struct layout_cell * +layout_construct_v1(struct layout_cell *lcparent, const char **layout) { - struct layout_cell *lcchild; + struct layout_cell *lc, *lcchild; - *lc = layout_construct_cell(lcparent, layout); - if (*lc == NULL) - return (-1); + lc = layout_construct_cell(lcparent, layout); + if (lc == NULL) + return (NULL); switch (**layout) { case ',': case '}': case ']': - case '>': case '\0': - return (0); + return (lc); case '{': - (*lc)->type = LAYOUT_LEFTRIGHT; + lc->type = LAYOUT_LEFTRIGHT; break; case '[': - (*lc)->type = LAYOUT_TOPBOTTOM; + lc->type = LAYOUT_TOPBOTTOM; break; default: goto fail; @@ -401,12 +855,13 @@ layout_construct(struct layout_cell *lcparent, const char **layout, do { (*layout)++; - if (layout_construct(*lc, layout, &lcchild) != 0) + lcchild = layout_construct_v1(lc, layout); + if (lcchild == NULL) goto fail; - TAILQ_INSERT_TAIL(&(*lc)->cells, lcchild, entry); + TAILQ_INSERT_TAIL(&lc->cells, lcchild, entry); } while (**layout == ','); - switch ((*lc)->type) { + switch (lc->type) { case LAYOUT_LEFTRIGHT: if (**layout != '}') goto fail; @@ -420,9 +875,337 @@ layout_construct(struct layout_cell *lcparent, const char **layout, } (*layout)++; + return (lc); + +fail: + layout_free_cell(lc, 0); + return (NULL); +} + +/* + * Evaluate parsed JSON. Check metadata at the top level and return the new + * layout root. Consumes json input. + */ +static int +layout_parse_json(struct json_node *jnroot, struct layout_parse_ctx *pctx) +{ + struct json_node *jn, *object; + int64_t num; + char **cause = pctx->cause; + + if (json_get_object(jnroot, &jn) != 0) { + *cause = xstrdup("invalid layout json"); + goto fail; + } + + if (json_find_number(jn, "V", &num, cause) != 0) + goto fail; + pctx->version = num; + + if (json_find_object(jn, "L", &object, cause) != 0) + goto fail; + pctx->root = layout_parse_json_layout(object, NULL, pctx); + if (pctx->root == NULL) + goto fail; + + json_destroy_node(jnroot); + return (0); fail: - layout_free_cell(*lc, 0); + json_destroy_node(jnroot); + if (pctx->root != NULL) + layout_free_cell(pctx->root, 0); + pctx->root = NULL; return (-1); } + +/* Parse nodes into layout cells. */ +static struct layout_cell * +layout_parse_json_layout(struct json_node *node, struct layout_cell *lcparent, + struct layout_parse_ctx *pctx) +{ + struct json_node *member, *array; + struct layout_cell *lc = layout_create_cell(lcparent), *lcchild; + const char *str; + int64_t num; + char **cause = pctx->cause; + int boolean, index, zindex, active = -1; + int last = -1; + + if (json_find_string(node, "t", &str, cause) != 0) + goto fail; + if (strcmp(str, "p") == 0) + lc->type = LAYOUT_WINDOWPANE; + else if (strcmp(str, "v") == 0) + lc->type = LAYOUT_TOPBOTTOM; + else if (strcmp(str, "h") == 0) + lc->type = LAYOUT_LEFTRIGHT; + else { + xasprintf(cause, "unknown cell type \"%s\"", str); + goto fail; + } + + if (json_find_number(node, "w", &num, cause) != 0) + goto fail; + if (num < PANE_MINIMUM || num > PANE_MAXIMUM) { + xasprintf(cause, "invalid width %lld", (long long)num); + goto fail; + } + lc->g.sx = num; + + if (json_find_number(node, "h", &num, cause) != 0) + goto fail; + if (num < PANE_MINIMUM || num > PANE_MAXIMUM) { + xasprintf(cause, "invalid height %lld", (long long)num); + goto fail; + } + lc->g.sy = num; + + if (json_find_number(node, "x", &num, cause) != 0) + goto fail; + if (num < -WINDOW_MAXIMUM || num > WINDOW_MAXIMUM) { + xasprintf(cause, "invalid x-offset %lld", (long long)num); + goto fail; + } + lc->g.xoff = num; + + if (json_find_number(node, "y", &num, cause) != 0) + goto fail; + if (num < -WINDOW_MAXIMUM || num > WINDOW_MAXIMUM) { + xasprintf(cause, "invalid y-offset %lld", (long long)num); + goto fail; + } + lc->g.yoff = num; + + if (lc->type == LAYOUT_WINDOWPANE) { /* "I" is currently ignored */ + if (json_find(node, "c") != NULL) { + *cause = xstrdup("panes cannot have children"); + goto fail; + } + if (json_find_number(node, "i", &num, cause) != 0) + goto fail; + if (num < 0 || num > INT_MAX) { + xasprintf(cause, "invalid index %lld", (long long)num); + goto fail; + } + index = num; + + if (json_find(node, "a") != NULL) { + if (json_find_boolean(node, "a", &boolean, cause) != 0) + goto fail; + active = boolean; + if (active) + pctx->num_active++; + } else if (json_find(node, "l") != NULL) { + if (json_find_number(node, "l", &num, cause) != 0) + goto fail; + if (num < 0 || num > INT_MAX) { + xasprintf(cause, "invalid last %lld", + (long long)num); + goto fail; + } + last = num; + } + + if (json_find(node, "z") != NULL) { + if (json_find_number(node, "z", &num, cause) != 0) + goto fail; + if (num < 0 || num > INT_MAX - 1) { + xasprintf(cause, "invalid floating zindex %lld", + (long long)num); + goto fail; + } + zindex = num; + lc->flags |= LAYOUT_CELL_FLOATING; + } else + zindex = INT_MAX; + + layout_parse_add_cctx(pctx, lc, active, last, index, zindex); + } else { + if (json_find_array(node, "c", &array, cause) != 0) + goto fail; + if ((member = json_array_first(array)) == NULL || + json_array_next(member) == NULL) { + *cause = xstrdup("nodes must have more than one child"); + goto fail; + } + while (member != NULL) { + lcchild = layout_parse_json_layout(member, lc, + pctx); + if (lcchild == NULL) + goto fail; + TAILQ_INSERT_TAIL(&lc->cells, lcchild, entry); + member = json_array_next(member); + } + } + + return (lc); + +fail: + layout_free_cell(lc, 0); + return (NULL); +} + +/* Construct a layout root from a formatted string. */ +static int +layout_construct(const char *input, struct layout_parse_ctx *pctx) +{ + struct json_node *json; + u_short csum; + int n = 0; + + while (isspace((u_char) *input)) + input++; + + if (*input != '{') { /* sniffing version */ + if (sscanf(input, "%hx,%n", &csum, &n) != 1 || n != 5) { + *pctx->cause = xstrdup("malformed layout header"); + return (-1); + } + input += n; + if (csum != layout_checksum(input)) { + *pctx->cause = xstrdup("invalid layout checksum"); + return (-1); + } + if ((pctx->root = layout_construct_v1(NULL, &input)) == NULL) { + *pctx->cause = xstrdup("invalid layout"); + return (-1); + } + if (*input != '\0') { + *pctx->cause = xstrdup("trailing data"); + return (-1); + } + pctx->version = 1; + } else { + if ((json = json_parse(input, pctx->cause)) == NULL) + return (-1); + + if (layout_parse_json(json, pctx) != 0) + return (-1); + + if (pctx->version != 2) { + *pctx->cause = xstrdup("version mismatch"); + return (-1); + } + if (pctx->num_active > 1) { + *pctx->cause = xstrdup("more than one active pane"); + return (-1); + } + if (pctx->size == 0) { + *pctx->cause = xstrdup("no panes"); + return (-1); + } + if (!layout_parse_ctx_check_indexes(pctx)) + return (-1); + } + + return (0); +} + +/* Apply the remaining context to the layout. */ +static void +layout_parse_apply_ctx(struct window *w, struct layout_parse_ctx *pctx) +{ + struct layout_parse_cell_ctx *cctx; + struct window_pane *wp, *wpnext; + int i; + + /* Apply z-indexes. */ + wp = TAILQ_FIRST(&w->z_index); + while (wp != NULL) { + wpnext = TAILQ_NEXT(wp, zentry); + if (window_pane_is_floating(wp)) + TAILQ_REMOVE(&w->z_index, wp, zentry); + wp = wpnext; + } + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_zindex_cmp); + + for (i = 0; i < pctx->size; i++) { + cctx = &pctx->cctxs[i]; + wp = cctx->lc->wp; + if (window_pane_is_floating(wp)) + TAILQ_INSERT_HEAD(&w->z_index, wp, zentry); + } + + /* Set the active pane. */ + for (i = 0; i < pctx->size; i++) { + cctx = &pctx->cctxs[i]; + if (cctx->active == 1) { + window_set_active_pane(w, cctx->lc->wp, 1); + break; + } + } + + /* Apply last panes. */ + while (!TAILQ_EMPTY(&w->last_panes)) { + wp = TAILQ_FIRST(&w->last_panes); + window_pane_stack_remove(&w->last_panes, wp); + } + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_last_cmp); + + for (i = 0; i < pctx->size; i++) { + cctx = &pctx->cctxs[i]; + wp = cctx->lc->wp; + if (cctx->last < 0 || cctx->active == 1) + continue; + window_pane_stack_push(&w->last_panes, wp); + } +} + +/* Checks for duplicate pane indexes, z-indexes, and last indexes. */ +static int +layout_parse_ctx_check_indexes(struct layout_parse_ctx *pctx) +{ + int i, n; + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_index_cmp); + + for (i = 1; i < pctx->size; i++) { + if (pctx->cctxs[i].index == pctx->cctxs[i - 1].index) { + *pctx->cause = xstrdup("duplicate pane index"); + return (0); + } + } + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_zindex_cmp); + + /* + * Sorted in descending order, so the panes without a z-index come first + * and the floating panes run to the end. + */ + n = 0; + while (n < pctx->size && pctx->cctxs[n].zindex == INT_MAX) + n++; + for (i = n + 1; i < pctx->size; i++) { + if (pctx->cctxs[i].zindex == pctx->cctxs[i - 1].zindex) { + *pctx->cause = xstrdup("duplicate pane z-index"); + return (0); + } + } + + qsort(pctx->cctxs, pctx->size, sizeof pctx->cctxs[0], + layout_parse_last_cmp); + + /* + * Sorted in descending order, so the panes without a last index come + * last. + */ + n = 0; + while (n < pctx->size && pctx->cctxs[n].last >= 0) + n++; + for (i = 1; i < n; i++) { + if (pctx->cctxs[i].last == pctx->cctxs[i - 1].last) { + *pctx->cause = xstrdup("duplicate last pane index"); + return (0); + } + } + + return (1); +} diff --git a/layout.c b/layout.c index ac87f30e5..21d78880c 100644 --- a/layout.c +++ b/layout.c @@ -1,4 +1,4 @@ -/* $OpenBSD: layout.c,v 1.98 2026/08/25 18:38:05 nicm Exp $ */ +/* $OpenBSD: layout.c,v 1.99 2026/09/09 07:03:39 nicm Exp $ */ /* * Copyright (c) 2009 Nicholas Marriott @@ -106,7 +106,7 @@ layout_free_cell(struct layout_cell *lc, int only_nodes) } break; case LAYOUT_WINDOWPANE: - if (lc->wp != NULL) { + if (lc->wp != NULL && lc->wp->layout_cell != NULL) { lc->wp->layout_cell->parent = NULL; lc->wp->layout_cell = NULL; } @@ -233,29 +233,6 @@ layout_make_node(struct layout_cell *lc, enum layout_type type) lc->wp = NULL; } -/* Fix z-indexes. */ -void -layout_fix_zindexes(struct window *w, struct layout_cell *lc) -{ - struct layout_cell *lcchild; - - if (lc == NULL) - return; - - switch (lc->type) { - case LAYOUT_WINDOWPANE: - TAILQ_INSERT_TAIL(&w->z_index, lc->wp, zentry); - break; - case LAYOUT_LEFTRIGHT: - case LAYOUT_TOPBOTTOM: - TAILQ_FOREACH(lcchild, &lc->cells, entry) - layout_fix_zindexes(w, lcchild); - return; - default: - fatalx("bad layout type"); - } -} - int layout_cell_is_tiled(struct layout_cell *lc) { @@ -265,7 +242,7 @@ layout_cell_is_tiled(struct layout_cell *lc) return is_leaf && !is_floating; } -static int +int layout_cell_has_tiled_child(struct layout_cell *lc) { struct layout_cell *lcchild; @@ -509,18 +486,20 @@ layout_fix_panes(struct window *w, struct window_pane *skip) /* Count the number of available cells in a layout. */ u_int -layout_count_cells(struct layout_cell *lc) +layout_count_cells(struct layout_cell *lc, int with_floating) { struct layout_cell *lcchild; u_int count = 0; switch (lc->type) { case LAYOUT_WINDOWPANE: + if (lc->flags & LAYOUT_CELL_FLOATING && !with_floating) + return 0; return (1); case LAYOUT_LEFTRIGHT: case LAYOUT_TOPBOTTOM: TAILQ_FOREACH(lcchild, &lc->cells, entry) - count += layout_count_cells(lcchild); + count += layout_count_cells(lcchild, with_floating); return (count); default: fatalx("bad layout type"); @@ -721,7 +700,7 @@ layout_destroy_cell(struct window *w, struct layout_cell *lc, /* If no parent, this is the last pane in a window. */ lcparent = lc->parent; if (lcparent == NULL) { - if (lc->wp != NULL) + if (*lcroot == lc) *lcroot = NULL; layout_free_cell(lc, 0); return; diff --git a/tmux.1 b/tmux.1 index bd562178b..22780e2ce 100644 --- a/tmux.1 +++ b/tmux.1 @@ -1,4 +1,4 @@ -.\" $OpenBSD: tmux.1,v 1.1167 2026/09/08 10:20:08 nicm Exp $ +.\" $OpenBSD: tmux.1,v 1.1168 2026/09/09 07:03:39 nicm Exp $ .\" .\" Copyright (c) 2007 Nicholas Marriott .\" @@ -14,7 +14,7 @@ .\" IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING .\" OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. .\" -.Dd $Mdocdate: September 8 2026 $ +.Dd $Mdocdate: September 9 2026 $ .Dt TMUX 1 .Os .Sh NAME @@ -2699,8 +2699,8 @@ For example: .Bd -literal -offset indent $ tmux list\-windows 0: ksh [159x48] - layout: bb62,159x48,0,0{79x48,0,0,79x48,80,0} -$ tmux select\-layout \[aq]bb62,159x48,0,0{79x48,0,0,79x48,80,0}\[aq] + layout: {"V":2,"L":{"t":"h","w":159,"h":48,"x":0,"y":0,"c":[{"t":"p","w":79,"h":48,"x":0,"y":0,"l":0,"i":0,"I":"%0"},{"t":"p","w":79,"h":48,"x":80,"y":0,"a":true,"i":1,"I":"%2"}]}} +$ tmux select\-layout \[aq]{"V":2,"L":{"t":"h","w":159,"h":48,"x":0,"y":0,"c":[{"t":"p","w":79,"h":48,"x":0,"y":0,"l":0,"i":0,"I":"%0"},{"t":"p","w":79,"h":48,"x":80,"y":0,"a":true,"i":1,"I":"%2"}]}}\[aq] .Ed .Pp .Nm @@ -9070,7 +9070,7 @@ and flags (currently not used). For example: .Bd -literal -offset indent %begin 1363006971 2 1 -0: ksh* (1 panes) [80x24] [layout b25f,80x24,0,0,2] @2 (active) +0: ksh* (1 panes) [80x24] [layout {"V":2,"L":{"t":"p","w":80,"h":24,"x":0,"y":0,"a":true,"i":0,"I":"%2"}}] @2 (active) %end 1363006971 2 1 .Ed .Pp @@ -9131,10 +9131,15 @@ The layout of a window with ID .Ar window\-id changed. The new layout is -.Ar window\-layout . -The window's visible layout is -.Ar window\-visible\-layout -and the window flags are +.Ar window\-layout +and the window's visible layout is +.Ar window\-visible\-layout . +If the +.Ar new\-layouts +flag is set, both layout fields use the new format string; see +.Ic refresh\-client +.Fl f . +The window flags are .Ar window\-flags . .It Ic %message Ar message A message sent with the diff --git a/tmux.h b/tmux.h index ac195fecb..ff2158e02 100644 --- a/tmux.h +++ b/tmux.h @@ -1,4 +1,4 @@ -/* $OpenBSD: tmux.h,v 1.1436 2026/09/08 10:20:08 nicm Exp $ */ +/* $OpenBSD: tmux.h,v 1.1437 2026/09/09 07:03:39 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -3800,7 +3800,7 @@ struct visible_ranges *window_visible_ranges(struct window_pane *, int, int, u_int, struct visible_ranges *); /* layout.c */ -u_int layout_count_cells(struct layout_cell *); +u_int layout_count_cells(struct layout_cell *, int); struct layout_cell *layout_create_cell(struct layout_cell *); void layout_free_cell(struct layout_cell *, int); void layout_print_cell(struct layout_cell *, const char *, u_int); @@ -3812,8 +3812,8 @@ struct layout_cell *layout_search_by_border(struct layout_cell *, u_int, u_int); void layout_set_size(struct layout_cell *, u_int, u_int, int, int); void layout_make_leaf(struct layout_cell *, struct window_pane *); void layout_make_node(struct layout_cell *, enum layout_type); -void layout_fix_zindexes(struct window *, struct layout_cell *); int layout_cell_is_tiled(struct layout_cell *); +int layout_cell_has_tiled_child(struct layout_cell *); int layout_add_horizontal_border(struct layout_cell *, struct layout_cell *, int); void layout_fix_offsets(struct window *); From 36fb7ab08427f7ce39c20982d40fc64a515bf3c5 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Wed, 9 Sep 2026 09:31:54 +0100 Subject: [PATCH 31/38] Tests of border. --- regress/screen-redraw-outside.sh | 9 +++++++++ .../outside-both-status-top-single.result | 14 ++++++++++++++ .../outside-both-status-top-split.result | 14 ++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 regress/screen-redraw-results/outside-both-status-top-single.result create mode 100644 regress/screen-redraw-results/outside-both-status-top-split.result diff --git a/regress/screen-redraw-outside.sh b/regress/screen-redraw-outside.sh index 3eeb94938..cebca2c74 100644 --- a/regress/screen-redraw-outside.sh +++ b/regress/screen-redraw-outside.sh @@ -93,6 +93,15 @@ $TMUX2 splitw -v "$C" || exit 1 $TMUX2 select-layout tiled || exit 1 compare outside-both-2x2 +# Top pane status supplies internal horizontal borders, but there is no pane +# below the window to supply its bottom edge. +new_scene 28 9 +$TMUX2 setw pane-border-status top || exit 1 +$TMUX2 setw pane-border-format "" || exit 1 +compare outside-both-status-top-single +$TMUX2 splitw -v "$C" || exit 1 +compare outside-both-status-top-split + # Window BIGGER than the client: only part of the window is viewed and the view # can be panned (refresh-client). This exercises a non-zero scene offset. # A 2x2 grid in a 60x20 window viewed through the 40x14 client. diff --git a/regress/screen-redraw-results/outside-both-status-top-single.result b/regress/screen-redraw-results/outside-both-status-top-single.result new file mode 100644 index 000000000..5f1395ea3 --- /dev/null +++ b/regress/screen-redraw-results/outside-both-status-top-single.result @@ -0,0 +1,14 @@ +────────────────────────────┐··········· +OUT01 abcdefghij │··········· +OUT02 abcdefghij │··········· +OUT03 abcdefghij │··········· +OUT04 abcdefghij │··········· +OUT05 abcdefghij │··········· +OUT06 abcdefghij │··········· +OUT07 abcdefghij │··········· + │··········· +────────────────────────────┘··········· +········································ +········································ +········································ +········································ diff --git a/regress/screen-redraw-results/outside-both-status-top-split.result b/regress/screen-redraw-results/outside-both-status-top-split.result new file mode 100644 index 000000000..ae8ca37d6 --- /dev/null +++ b/regress/screen-redraw-results/outside-both-status-top-split.result @@ -0,0 +1,14 @@ +────────────────────────────┐··········· +OUT06 abcdefghij │··········· +OUT07 abcdefghij │··········· + │··········· +────────────────────────────┤··········· +PAN05 abcdefghij │··········· +PAN06 abcdefghij │··········· +PAN07 abcdefghij │··········· + │··········· +────────────────────────────┘··········· +········································ +········································ +········································ +········································ From fc695b4e8643fa2ec07ff8f8d9351c3c17960811 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Wed, 9 Sep 2026 10:01:32 +0100 Subject: [PATCH 32/38] =?UTF-8?q?Test=20for=20v1=20nesting=20limit,=20base?= =?UTF-8?q?d=20on=20a=20changed=20from=20Afonso=20Janu=C3=A1rio.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- regress/layout-nesting-limit.sh | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 regress/layout-nesting-limit.sh diff --git a/regress/layout-nesting-limit.sh b/regress/layout-nesting-limit.sh new file mode 100644 index 000000000..7d0594d9c --- /dev/null +++ b/regress/layout-nesting-limit.sh @@ -0,0 +1,77 @@ +#!/bin/sh + +# Version 1 layouts must reject excessive nesting without losing the session or +# changing its layout. Check both split types and the depth limit boundary. + +PATH=/bin:/usr/bin +TERM=screen +export PATH TERM + +[ -z "$TEST_TMUX" ] && TEST_TMUX=$(readlink -f ../tmux) +DIR=$(mktemp -d) || exit 1 +TMUX="$TEST_TMUX -S$DIR/socket -f/dev/null" + +fail() +{ + echo "$*" >&2 + exit 1 +} + +cleanup() +{ + $TMUX kill-server 2>/dev/null + rm -rf "$DIR" +} +trap cleanup 0 +trap 'exit 1' 1 2 15 + +# Generate a checksum-valid v1 layout with one child in every nested group. +layout() +{ + awk -v depth="$1" -v open="$2" 'BEGIN { + closing = (open == "{" ? "}" : "]") + body = "" + for (i = 0; i < depth; i++) + body = body "1x1,0,0" open + body = body "1x1,0,0" + for (i = 0; i < depth; i++) + body = body closing + + ord["0"] = 48; ord["1"] = 49; ord["x"] = 120 + ord[","] = 44; ord["{"] = 123; ord["}"] = 125 + ord["["] = 91; ord["]"] = 93 + csum = 0 + for (i = 1; i <= length(body); i++) { + c = substr(body, i, 1) + bit = csum % 2 + csum = int(csum / 2) + bit * 32768 + csum = (csum + ord[c]) % 65536 + } + printf "%04x,%s", csum, body + }' +} + +$TMUX new-session -d -s deep -x80 -y24 'exec sleep 100' || exit 1 + +for open in '{' '['; do + # A valid checksum and nesting up to the limit must still be accepted. + value=$(layout 1000 "$open") || fail "could not generate layout" + $TMUX select-layout -t deep "$value" || + fail "layout at the depth limit was rejected ($open)" + before=$($TMUX display-message -p -t deep \ + '#{pane_id} #{pane_width} #{pane_height} #{window_layout}') || exit 1 + + for depth in 1001 1500; do + value=$(layout "$depth" "$open") || fail "could not generate layout" + $TMUX select-layout -t deep "$value" >/dev/null 2>&1 && + fail "excessive nesting was accepted ($open, $depth)" + $TMUX has-session -t deep || + fail "server died on excessive nesting ($open, $depth)" + after=$($TMUX display-message -p -t deep \ + '#{pane_id} #{pane_width} #{pane_height} #{window_layout}') || exit 1 + [ "$before" = "$after" ] || + fail "pane or layout changed after rejection ($open, $depth)" + done +done + +exit 0 From 930c81b819f8499b68a2cd5ae14ef3a57c0d7d41 Mon Sep 17 00:00:00 2001 From: nicm Date: Wed, 9 Sep 2026 07:53:03 +0000 Subject: [PATCH 33/38] Do not adjust prompt position on invalid Unicode, from Kaixuan Li. --- prompt.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/prompt.c b/prompt.c index cc808a54e..300b278d8 100644 --- a/prompt.c +++ b/prompt.c @@ -1,4 +1,4 @@ -/* $OpenBSD: prompt.c,v 1.6 2026/08/17 06:45:16 nicm Exp $ */ +/* $OpenBSD: prompt.c,v 1.7 2026/09/09 07:53:03 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -1493,9 +1493,11 @@ append_key: utf8_set(&tmp, key); if (key <= 0x1f || key == 0x7f) tmp.width = 2; - } else if (KEYC_IS_UNICODE(key)) + } else if (KEYC_IS_UNICODE(key)) { utf8_to_data(key, &tmp); - else + if (tmp.size == 0) + return (PROMPT_KEY_HANDLED); + } else return (PROMPT_KEY_HANDLED); pr->buffer = xreallocarray(pr->buffer, size + 2, From a3129249d19b74439f0c25c72124f72e47ddd9eb Mon Sep 17 00:00:00 2001 From: nicm Date: Wed, 9 Sep 2026 08:30:05 +0000 Subject: [PATCH 34/38] Bound the memory used by buffered control mode command replies (to 64 MB), GitHub issue 5565 from kagari dot shusei at proton dot me. --- control.c | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- tmux.h | 3 ++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/control.c b/control.c index af0d8b91a..1da0283ff 100644 --- a/control.c +++ b/control.c @@ -1,4 +1,4 @@ -/* $OpenBSD: control.c,v 1.67 2026/09/03 21:35:38 nicm Exp $ */ +/* $OpenBSD: control.c,v 1.68 2026/09/09 08:30:05 nicm Exp $ */ /* * Copyright (c) 2012 Nicholas Marriott @@ -108,6 +108,7 @@ struct control_state { u_int pending_count; TAILQ_HEAD(, control_block) all_blocks; + size_t queued_reply_bytes; struct bufferevent *read_event; struct bufferevent *write_event; @@ -132,6 +133,9 @@ struct control_state { /* Maximum age for clients that are not using pause mode. */ #define CONTROL_MAXIMUM_AGE 300000 +/* Maximum buffered command replies for a client that is not reading. */ +#define CONTROL_MAXIMUM_REPLY_BUFFER (64 * 1024 * 1024) + /* Flags to ignore client. */ #define CONTROL_IGNORE_FLAGS \ (CLIENT_CONTROL_NOOUTPUT| \ @@ -165,6 +169,15 @@ RB_GENERATE_STATIC(control_windows, control_window, entry, control_window_cmp); static void control_free_block(struct control_state *cs, struct control_block *cb) { + size_t size; + + if (cb->size == 0 && cb->line != NULL) { + size = strlen(cb->line) + 1; + if (cs->queued_reply_bytes > size) + cs->queued_reply_bytes -= size; + else + cs->queued_reply_bytes = 0; + } free(cb->line); TAILQ_REMOVE(&cs->all_blocks, cb, all_entry); free(cb); @@ -407,16 +420,52 @@ control_reset_pane(struct client *c, struct window_pane *wp) memcpy(&cp->queued, &wp->offset, sizeof cp->queued); } +/* + * Check if the replies buffered for a client, including one about to be + * added, have grown too large and kill it if so. Returns 1 if further output + * for the client should be dropped. + */ +static int +control_check_reply_buffer(struct client *c, size_t added) +{ + struct control_state *cs = c->control_state; + size_t size; + + if (c->flags & CLIENT_CONTROL_DISCARD) + return (1); + size = EVBUFFER_LENGTH(cs->write_event->output); + size += cs->queued_reply_bytes; + size += added; + if (size < CONTROL_MAXIMUM_REPLY_BUFFER) + return (0); + + log_debug("%s: %s: %zu bytes of replies buffered", __func__, c->name, + size); + if (~c->flags & CLIENT_EXIT) { + c->exit_message = xstrdup("too far behind"); + c->flags |= CLIENT_EXIT; + control_discard(c); + } + c->flags |= CLIENT_CONTROL_DISCARD; + return (1); +} + /* Write an already-formatted line, queueing it behind %output if needed. */ static void control_write_line(struct client *c, char *line) { struct control_state *cs = c->control_state; struct control_block *cb; + size_t size = strlen(line) + 1; + + if (control_check_reply_buffer(c, size)) { + free(line); + return; + } if (TAILQ_EMPTY(&cs->all_blocks)) { log_debug("%s: %s: writing line: %s", __func__, c->name, line); - bufferevent_write(cs->write_event, line, strlen(line)); + bufferevent_write(cs->write_event, line, size - 1); bufferevent_write(cs->write_event, "\n", 1); bufferevent_enable(cs->write_event, EV_WRITE); free(line); @@ -426,6 +475,7 @@ control_write_line(struct client *c, char *line) cb = xcalloc(1, sizeof *cb); cb->line = line; TAILQ_INSERT_TAIL(&cs->all_blocks, cb, all_entry); + cs->queued_reply_bytes += size; cb->t = get_timer(); log_debug("%s: %s: storing line: %s", __func__, c->name, cb->line); @@ -1007,6 +1057,7 @@ control_discard_all(struct client *c) control_discard(c); TAILQ_FOREACH_SAFE(cb, &cs->all_blocks, all_entry, cb1) control_free_block(cs, cb); + cs->queued_reply_bytes = 0; bufferevent_disable(cs->write_event, EV_WRITE); } diff --git a/tmux.h b/tmux.h index ff2158e02..57b8b3939 100644 --- a/tmux.h +++ b/tmux.h @@ -1,4 +1,4 @@ -/* $OpenBSD: tmux.h,v 1.1437 2026/09/09 07:03:39 nicm Exp $ */ +/* $OpenBSD: tmux.h,v 1.1438 2026/09/09 08:30:05 nicm Exp $ */ /* * Copyright (c) 2007 Nicholas Marriott @@ -2261,6 +2261,7 @@ struct client { #define CLIENT_ASSUMEPASTING 0x2000000000ULL #define CLIENT_WRITE_ACK 0x4000000000ULL #define CLIENT_NO_DETACH_ON_DESTROY 0x8000000000ULL +#define CLIENT_CONTROL_DISCARD 0x1000000000ULL #define CLIENT_ALLREDRAWFLAGS \ (CLIENT_REDRAWWINDOW| \ CLIENT_REDRAWSTATUS| \ From 0544f0b21066f02659bc7b3eafdb844c95f86a36 Mon Sep 17 00:00:00 2001 From: nicm Date: Wed, 9 Sep 2026 08:31:42 +0000 Subject: [PATCH 35/38] Draw bottom border with pane status line at the top. --- screen-redraw.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/screen-redraw.c b/screen-redraw.c index c47bf09d5..706540309 100644 --- a/screen-redraw.c +++ b/screen-redraw.c @@ -1,4 +1,4 @@ -/* $OpenBSD: screen-redraw.c,v 1.158 2026/09/01 19:50:58 nicm Exp $ */ +/* $OpenBSD: screen-redraw.c,v 1.159 2026/09/09 08:31:42 nicm Exp $ */ /* * Copyright (c) 2026 Nicholas Marriott @@ -713,7 +713,7 @@ redraw_mark_pane_borders(struct redraw_build_ctx *bctx, struct window_pane *wp, } else { mark_right = (right <= (int)bctx->w->sx); mark_bottom = (bottom <= (int)bctx->w->sy); - if (pane_status == PANE_STATUS_TOP) + if (pane_status == PANE_STATUS_TOP && bottom < (int)bctx->w->sy) mark_bottom = 0; else if (pane_status == PANE_STATUS_BOTTOM) mark_top = 0; From 5aeacf1caebd0f243285436e477482d3bd43e483 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Wed, 9 Sep 2026 13:06:56 +0100 Subject: [PATCH 36/38] Update CHANGES. --- CHANGES | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index a447de2c3..7f6c4e9e6 100644 --- a/CHANGES +++ b/CHANGES @@ -37,6 +37,10 @@ CHANGES FROM 3.7c TO 3.8 - floating panes are unzoomed before creation to avoid a crash. +* Layout strings now use a JSON subset format which includes floating panes. + The old format is still accepted; control mode clients receive old layouts + unless they set the new-layouts flag. + * Add support for themes and improve default colours: - tmux now has builtin light and dark colour themes for terminals with 256 or @@ -126,6 +130,8 @@ CHANGES FROM 3.7c TO 3.8 * Add new-window -E, respawn-pane -E and respawn-window -E as more convenient methods to create an empty pane (rather than using '' for the command). +* Add capture-pane -I to include the time each line entered history. + * Add a default C-b T binding to change the current pane title. * Menus now belong to the window, so appear on all clients. @@ -172,6 +178,8 @@ CHANGES FROM 3.7c TO 3.8 * The mouse option now defaults to on. +* The active-pane client flag has been removed. + * Fix send-keys -K so keys are inserted in the correct place in the input queue, like keys from key bindings (issue 3476). @@ -180,7 +188,8 @@ CHANGES FROM 3.7c TO 3.8 already exiting (Ben Maurer, issue 5357). Queue notifications so they are not sent inside %begin/%end (issue 5458), do not let a stuck client prevent the server from exiting (issue 5444), and reset control mode offsets when a pane - is respawned (issue 5498). + is respawned (issue 5498). Bound buffered command replies so a control mode + client that stops reading cannot consume unlimited memory (issue 5565). * Fix grouped sessions sometimes being left as unusable command targets while they are being killed (Bryce Miller, issue 5180). From 9b3268a2a06f72fa57be099a450841c3a0bfde88 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Wed, 9 Sep 2026 13:28:07 +0100 Subject: [PATCH 37/38] Bump version. --- configure.ac | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configure.ac b/configure.ac index b09bc5d4e..de61d7cf8 100644 --- a/configure.ac +++ b/configure.ac @@ -1,6 +1,6 @@ # configure.ac -AC_INIT([tmux], next-3.8) +AC_INIT([tmux], next-3.9) AC_PREREQ([2.60]) AC_CONFIG_AUX_DIR(etc) From 483912351048bf60937bb8df950bcea12606a3a2 Mon Sep 17 00:00:00 2001 From: Nicholas Marriott Date: Wed, 9 Sep 2026 13:50:14 +0100 Subject: [PATCH 38/38] Tweak changes. --- CHANGES | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index 7f6c4e9e6..88baf7f42 100644 --- a/CHANGES +++ b/CHANGES @@ -33,9 +33,7 @@ CHANGES FROM 3.7c TO 3.8 line; - pane-border-status has top-floating and bottom-floating, and there are new - default bindings under C-b g for common move and resize operations; - - - floating panes are unzoomed before creation to avoid a crash. + default bindings under C-b g for common move and resize operations. * Layout strings now use a JSON subset format which includes floating panes. The old format is still accepted; control mode clients receive old layouts