diff --git a/agent-nav.sh b/agent-nav.sh new file mode 100755 index 0000000..12d53aa --- /dev/null +++ b/agent-nav.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Cycle the agent popup through the windows of the session underneath it rather +# than through the agent session's own window list. Bound to C-n and +# C-p inside the agent session (see tmux.conf). +# +# Root windows with no agent window are stepped over; cycling never creates an +# agent window, only A does. Both the client underneath and the popup +# move, so closing the popup leaves you on the window you cycled to. + +set -e + +direction=${1:-next} +us=$'\x1f' + +# Fall back to plain window cycling within the agent session when there is no +# usable client underneath (popup opened before this was configured, or the +# recorded client has gone away). +fallback() { + case $direction in + prev) tmux previous-window -t agent ;; + *) tmux next-window -t agent ;; + esac + exit 0 +} + +popup_client=$(tmux display-message -p '#{@agent_popup_client}' 2>/dev/null || true) +[ -n "$popup_client" ] || fallback + +# The session and window underneath the popup, as the client sees them now. +client_info=$(tmux list-clients -F "#{client_name}$us#{session_name}$us#{window_id}" \ + | awk -v FS="$us" -v client="$popup_client" '$1 == client { print $2 FS $3; exit }') +[ -n "$client_info" ] || fallback +IFS=$us read -r client_session client_window <<<"$client_info" + +# Every agent window, with what it is keyed to. +agent_ids=() +agent_dirs=() +agent_origins=() +while IFS=$us read -r window_id dir origin; do + [ -n "$dir" ] || continue + agent_ids+=("$window_id") + agent_dirs+=("$dir") + agent_origins+=("$origin") +done < <(tmux list-windows -t agent \ + -F "#{window_id}$us#{@agent_dir}$us#{@agent_origin_window}" 2>/dev/null || true) +[ ${#agent_ids[@]} -gt 0 ] || exit 0 + +# The agent window serving a root window: by recorded origin first, then by the +# directory its first pane sits in. Mirrors agent-sync.sh's resolution, reversed. +agent_window_for() { + local root_window=$1 start_path=$2 current_path=$3 i + for i in "${!agent_ids[@]}"; do + if [ -n "${agent_origins[$i]}" ] && [ "${agent_origins[$i]}" = "$root_window" ]; then + printf '%s\n' "${agent_ids[$i]}" + return 0 + fi + done + for i in "${!agent_ids[@]}"; do + if [ "${agent_dirs[$i]}" = "$start_path" ] || [ "${agent_dirs[$i]}" = "$current_path" ]; then + printf '%s\n' "${agent_ids[$i]}" + return 0 + fi + done + return 1 +} + +# The underlying session's windows, in its own order, each with its first pane's +# directory. Panes are listed grouped by window in index order. +root_windows=() +root_paths=() +seen_window="" +while IFS=$us read -r window_id start_path current_path; do + [ "$window_id" != "$seen_window" ] || continue + seen_window=$window_id + root_windows+=("$window_id") + root_paths+=("$start_path$us$current_path") +done < <(tmux list-panes -s -t "$client_session" \ + -F "#{window_id}$us#{pane_start_path}$us#{pane_current_path}" 2>/dev/null || true) + +count=${#root_windows[@]} +[ "$count" -gt 0 ] || exit 0 + +# Where the client sits in that order. +current=0 +for i in "${!root_windows[@]}"; do + if [ "${root_windows[$i]}" = "$client_window" ]; then + current=$i + break + fi +done + +if [ "$direction" = prev ]; then step=-1; else step=1; fi + +# Walk in that direction for the first window with an agent window, wrapping. +# Landing back on the starting window means nothing else has one: selecting it +# again is a no-op, which is the wanted behaviour. +i=$current +for _ in $(seq 1 "$count"); do + i=$(( (i + step + count) % count )) + target_root=${root_windows[$i]} + IFS=$us read -r start_path current_path <<<"${root_paths[$i]}" + target_agent=$(agent_window_for "$target_root" "$start_path" "$current_path") || continue + + # Re-key the agent window to the window being moved to, so the sync hook that + # the select below fires agrees with what happened here instead of dragging + # the client back to wherever that window was last opened from. + tmux set-window-option -t "$target_agent" @agent_origin_window "$target_root" + tmux select-window -t "$target_root" + tmux select-window -t "$target_agent" + exit 0 +done diff --git a/agent-sync.sh b/agent-sync.sh new file mode 100755 index 0000000..60b3a54 --- /dev/null +++ b/agent-sync.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Move the client that opened the agent popup to the root window matching the +# agent session's current window. Installed by agent.sh as the agent session's +# session-window-changed hook, so it runs for any window change made inside the +# popup: C-n, C-p, select-window, choose-tree or the mouse. +# +# Set @agent_follow_session to "off" to keep the opening client in the session +# it is already on, never switching it to the session owning the match. + +set -e + +# Fields are joined with a unit separator, not a tab: `read` folds runs of tabs +# and spaces even when IFS names them, so an unset option in the middle would +# silently shift every field after it. +us=$'\x1f' + +# Everything recorded about the agent session's current window, in one +# round-trip. Format lookups rather than show-option, so unset user options come +# back empty instead of logging "invalid option" into the tmux message log. +state=$(tmux display-message -p -t agent "#{session_attached}$us#{@agent_dir}\ +$us#{@agent_origin_window}$us#{@agent_popup_client}$us#{@agent_follow_session}" \ + 2>/dev/null || true) +IFS=$us read -r attached agent_dir origin_window popup_client follow <<<"$state" + +# Only sync while a popup is actually attached. Without this guard the +# detach-client + kill-window agent-cmd.sh performs when an agent exits would +# drag the root client to whichever window tmux picks next. +[ "${attached:-0}" -gt 0 ] || exit 0 +[ -n "$agent_dir" ] || exit 0 + +# An empty session means the recorded client has since gone away. +client_session="" +if [ -n "$popup_client" ]; then + client_session=$(tmux list-clients -F "#{client_name}$us#{session_name}" \ + | awk -v FS="$us" -v client="$popup_client" '$1 == client { print $2; exit }') +fi + +target_window="" +target_session="" + +# Prefer the recorded origin window, as long as it still exists. +if [ -n "$origin_window" ]; then + while IFS=$us read -r window_id session_name; do + if [ "$window_id" = "$origin_window" ] && [ "$session_name" != agent ]; then + target_window=$window_id + target_session=$session_name + break + fi + done < <(tmux list-windows -a -F "#{window_id}$us#{session_name}") +fi + +# Otherwise match the recorded directory against root windows. This covers agent +# windows created before origin recording existed, and root windows that have +# been recreated since. +if [ -z "$target_window" ]; then + seen_window="" + while IFS=$us read -r window_id session_name start_path current_path; do + # Panes are listed grouped by window in index order, so the first row for a + # window is its first pane. + [ "$window_id" != "$seen_window" ] || continue + seen_window=$window_id + [ "$session_name" != agent ] || continue + [ "$start_path" = "$agent_dir" ] || [ "$current_path" = "$agent_dir" ] || continue + # Prefer a window in the session the opening client is already on. + if [ "$session_name" = "$client_session" ]; then + target_window=$window_id + target_session=$session_name + break + fi + if [ -z "$target_window" ]; then + target_window=$window_id + target_session=$session_name + fi + done < <(tmux list-panes -a -F "#{window_id}$us#{session_name}\ +$us#{pane_start_path}$us#{pane_current_path}") +fi + +# No match: leave the root sessions alone rather than inventing a window. +[ -n "$target_window" ] || exit 0 + +# Window ids are server-global, so this moves the owning session whichever +# client happens to be on it. +tmux select-window -t "$target_window" 2>/dev/null || exit 0 + +# Selecting is enough when the client is already on the target session. +[ -n "$client_session" ] || exit 0 +[ "$follow" != off ] || exit 0 +if [ -n "$target_session" ] && [ "$target_session" != "$client_session" ]; then + tmux switch-client -c "$popup_client" -t "$target_session" +fi diff --git a/agent.sh b/agent.sh index 6ef7f2f..5ecb6e6 100755 --- a/agent.sh +++ b/agent.sh @@ -9,6 +9,16 @@ dir="${PWD:-$HOME}" # Use the directory basename as window name window_name=$dir +# The client that opened the popup, recorded by the `bind A` binding. Its +# current window is the root window A was pressed in; the popup cannot +# pass that through itself, so resolve it here before attaching. +popup_client=$(tmux display-message -p '#{@agent_popup_client}' 2>/dev/null || true) +origin_window="" +if [ -n "$popup_client" ]; then + origin_window=$(tmux list-clients -F $'#{client_name}\x1f#{window_id}' \ + | awk -v FS=$'\x1f' -v client="$popup_client" '$1 == client { print $2; exit }') +fi + # Create the agent session if it doesn't exist if ! tmux has-session -t agent; then tmux new-session -ds agent -c "$dir" -n "$window_name" "$script_dir/agent-cmd.sh" @@ -20,6 +30,9 @@ fi tmux set-option -t agent status off tmux set-option -t agent remain-on-exit on tmux set-option -t agent default-command "$script_dir/agent-cmd.sh" +# Session-scoped (not -g) so the root-session window changes agent-sync.sh makes +# cannot re-trigger it. +tmux set-hook -t agent session-window-changed "run -b '$script_dir/agent-sync.sh'" # Search for an existing window with matching directory target_window="" @@ -32,6 +45,12 @@ for window_id in $(tmux list-windows -t agent -F '#{window_id}'); do done if [ -n "$target_window" ]; then + # Remember which root window this agent window came from, refreshed on every + # invocation. Recorded *before* selecting, so the sync hook select-window + # fires sees this invocation's origin rather than the previous one's. + if [ -n "$origin_window" ]; then + tmux set-window-option -t "$target_window" @agent_origin_window "$origin_window" + fi # Select the existing window tmux select-window -t "$target_window" else @@ -39,6 +58,9 @@ else tmux new-window -t agent -c "$dir" -n "$window_name" # Store the directory in a window option for later matching tmux set-window-option -t agent @agent_dir "$dir" + if [ -n "$origin_window" ]; then + tmux set-window-option -t agent @agent_origin_window "$origin_window" + fi fi # Prune windows whose recorded directory no longer exists. This runs after the diff --git a/tmux.conf b/tmux.conf index 9af16ed..975c53d 100644 --- a/tmux.conf +++ b/tmux.conf @@ -71,6 +71,9 @@ set -ga terminal-overrides 'xterm*:smxx=\E[9m' if -b '~/.config/tmux/check-version.sh ">= 3.2" and "< 3.3"' { bind A if -F '#{==:#{session_name},agent}' { detach-client } { + # Record the opening client so agent-sync.sh can move it along with the + # window selected inside the popup. + run-shell "tmux set -g @agent_popup_client '#{client_name}'" display-popup -d '#{pane_current_path}' -w 50% -h 90% -E ~/.config/tmux/agent.sh } bind H display-popup -w 75% -h 75% -E htop @@ -81,6 +84,9 @@ if -b '~/.config/tmux/check-version.sh ">= 3.2" and "< 3.3"' { } if -b '~/.config/tmux/check-version.sh ">= 3.3"' { bind A if -F '#{==:#{session_name},agent}' { detach-client } { + # Record the opening client so agent-sync.sh can move it along with the + # window selected inside the popup. + run-shell "tmux set -g @agent_popup_client '#{client_name}'" display-popup -S fg=#54546D -b rounded -d '#{pane_current_path}' -w 75% -h 90% -E ~/.config/tmux/agent.sh } bind H display-popup -S fg=#54546D -b rounded -w 50% -h 75% -E htop @@ -92,9 +98,19 @@ if -b '~/.config/tmux/check-version.sh ">= 3.3"' { bind P run-shell ~/.config/tmux/project.sh -# Restore old next/previous window bindings -bind C-n next-window -bind C-p previous-window +# Restore old next/previous window bindings. Inside the agent popup these walk +# the windows of the session underneath instead, skipping any that have no agent +# window, so the popup and the client below it move together. +bind C-n if -F '#{==:#{session_name},agent}' { + run -b '~/.config/tmux/agent-nav.sh next' +} { + next-window +} +bind C-p if -F '#{==:#{session_name},agent}' { + run -b '~/.config/tmux/agent-nav.sh prev' +} { + previous-window +} # Kill current window bind W kill-window