From bdcdfeb428b1bdedbdc7b1dfed22cf374f123ae7 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 27 Jul 2026 15:32:56 -0700 Subject: [PATCH] This tmux alert system didn't work as nicely as I imagined --- bin/omarchy | 1 - bin/omarchy-menu-keybindings | 1 - bin/omarchy-shell | 5 +- bin/omarchy-tmux-alert | 140 ---------- config/tmux/tmux.conf | 9 - default/hypr/bindings/utilities.lua | 1 - migrations/1784849592.sh | 17 -- migrations/1784955584.sh | 17 -- migrations/1785189600.sh | 124 +++++++++ shell/plugins/bar/indicators/TmuxAlert.qml | 46 ---- .../bar/widgets/Indicators.manifest.json | 5 - shell/plugins/bar/widgets/Indicators.qml | 2 +- shell/plugins/services/tmux/Service.qml | 60 ----- shell/plugins/services/tmux/TmuxModel.js | 29 --- shell/plugins/services/tmux/manifest.json | 14 - test/shell.d/shell-ipc-display-test.sh | 3 +- .../tmux-alert-removal-migration-test.sh | 246 ++++++++++++++++++ test/shell.d/tmux-alert-test.sh | 117 --------- .../tmux-hidden-activity-migration-test.sh | 54 ---- 19 files changed, 376 insertions(+), 515 deletions(-) delete mode 100755 bin/omarchy-tmux-alert delete mode 100644 migrations/1784849592.sh delete mode 100644 migrations/1784955584.sh create mode 100644 migrations/1785189600.sh delete mode 100644 shell/plugins/bar/indicators/TmuxAlert.qml delete mode 100644 shell/plugins/services/tmux/Service.qml delete mode 100644 shell/plugins/services/tmux/TmuxModel.js delete mode 100644 shell/plugins/services/tmux/manifest.json create mode 100644 test/shell.d/tmux-alert-removal-migration-test.sh delete mode 100644 test/shell.d/tmux-alert-test.sh delete mode 100644 test/shell.d/tmux-hidden-activity-migration-test.sh diff --git a/bin/omarchy b/bin/omarchy index 57910d51..2c59cae4 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -81,7 +81,6 @@ GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers" GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock" GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers" GROUP_DESCRIPTIONS[theme]="Theme management" -GROUP_DESCRIPTIONS[tmux]="Tmux session helpers" GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features" GROUP_DESCRIPTIONS[transcode]="Image and video transcoding" GROUP_DESCRIPTIONS[tui]="Terminal UI launchers" diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index 07ad3f80..9a6160c3 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -418,7 +418,6 @@ prioritize_entries() { if (match(line, /Toggle workspace gaps/)) prio = 39 if (match(line, /Toggle nightlight/)) prio = 40 if (match(line, /Toggle locking/)) prio = 41 - if (match(line, /Jump to waiting Tmux pane/)) prio = 42 if (match(line, /group/)) prio = 94 if (match(line, /Scroll active workspace/)) prio = 95 if (match(line, /Cycle to/)) prio = 96 diff --git a/bin/omarchy-shell b/bin/omarchy-shell index 5bd615a8..5b44d339 100755 --- a/bin/omarchy-shell +++ b/bin/omarchy-shell @@ -40,8 +40,9 @@ fi [[ -n ${OMARCHY_PATH:-} ]] || fail "OMARCHY_PATH is not set" [[ -f $OMARCHY_PATH/shell/shell.qml ]] || fail "omarchy-shell config not found: $OMARCHY_PATH/shell/shell.qml" -# qs matches instances by display, and tmux run-shell strips WAYLAND_DISPLAY -# from hooks, so recover it from the compositor socket when it is missing. +# qs matches instances by display, and a caller from outside the session (an +# ssh or TTY omarchy-restart-shell, and the migrations it runs for) has none, +# so recover it from the compositor socket. if [[ -z ${WAYLAND_DISPLAY:-} ]]; then socket=$(ls -t "${XDG_RUNTIME_DIR:-/run/user/$UID}"/wayland-[0-9]* 2>/dev/null | grep -v '\.lock$' | head -n1) [[ -n $socket ]] && export WAYLAND_DISPLAY=${socket##*/} diff --git a/bin/omarchy-tmux-alert b/bin/omarchy-tmux-alert deleted file mode 100755 index 0dfbf990..00000000 --- a/bin/omarchy-tmux-alert +++ /dev/null @@ -1,140 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Show or jump to tmux windows waiting for attention -# omarchy:args= -# omarchy:examples=omarchy tmux alert show | omarchy tmux alert focus - -set -e - -# Session names cannot contain colons, and window names can, so they always -# come last when parsed. -WINDOW_FORMAT='#{window_id}:#{window_bell_flag}#{window_activity_flag}#{window_silence_flag}:#{window_activity}:#{@omarchy_unfocused_activity}:#{session_name}:#{window_index}:#{window_name}' -CLIENT_FORMAT='#{client_flags}:#{window_id}' - -usage() { - echo "Usage: omarchy-tmux-alert " >&2 - exit 1 -} - -# Include tmux alert flags plus output newer than the last focus transition for -# selected windows whose attached terminals are all unfocused. -alerted_windows() { - local client_flags window_id flags activity seen_activity session index name - local waiting - local -A attached_windows=() - local -A focused_windows=() - - while IFS=':' read -r client_flags window_id; do - [[ -n $window_id ]] || continue - attached_windows[$window_id]=1 - if [[ ,$client_flags, == *",focused,"* ]]; then - focused_windows[$window_id]=1 - fi - done < <(tmux list-clients -F "$CLIENT_FORMAT" 2>/dev/null) - - while IFS=':' read -r window_id flags activity seen_activity session index name; do - waiting=0 - [[ $flags == *1* ]] && waiting=1 - - if [[ ${attached_windows[$window_id]:-} == "1" && - ${focused_windows[$window_id]:-} != "1" && - $activity =~ ^[0-9]+$ && - $seen_activity =~ ^[0-9]+$ ]] && ((activity > seen_activity)); then - waiting=1 - fi - - ((waiting)) && printf '%s:%s:%s\n' "$session" "$index" "$name" - done < <(tmux list-windows -a -F "$WINDOW_FORMAT" 2>/dev/null) -} - -show() { - local windows=() window session index name description="" - - readarray -t windows < <(alerted_windows) - - for window in "${windows[@]}"; do - IFS=':' read -r session index name <<<"$window" - [[ -n $description ]] && description+=", " - description+="$name ($session:$index)" - done - - if [[ ${1:-} == "--json" ]]; then - jq -nc --argjson count "${#windows[@]}" --arg tooltip "$description" '{count: $count, tooltip: $tooltip}' - elif [[ -n $description ]]; then - echo "$description" - fi -} - -focus_terminal_window() { - local pid=$1 address="" window_pid window_address - local -A windows=() - - while read -r window_pid window_address; do - windows[$window_pid]=$window_address - done < <(hyprctl clients -j | jq -r '.[] | "\(.pid) \(.address)"') - - # The tmux client runs somewhere below the terminal that owns the window. - while [[ -n $pid ]] && (( pid > 1 )); do - if [[ -n ${windows[$pid]:-} ]]; then - address=${windows[$pid]} - break - fi - pid=$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null) - done - - [[ -n $address ]] || return 1 - - hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" >/dev/null 2>&1 || - hyprctl dispatch focuswindow "address:$address" >/dev/null -} - -most_recent_client() { - tmux list-clients "$@" -F '#{client_activity}:#{client_tty}:#{client_pid}' 2>/dev/null | sort -rn | head -n1 -} - -track() { - local window=${1:-} activity=${2:-} - - [[ $window =~ ^@[0-9]+$ && $activity =~ ^[0-9]+$ ]] || return 0 - tmux set-option -wq -t "$window" @omarchy_unfocused_activity "$activity" 2>/dev/null || return 0 - omarchy-shell -q omarchy.indicators refresh -} - -focus() { - local target session index client tty pid - - target=$(alerted_windows | head -n1) - [[ -n $target ]] || return 0 - - IFS=':' read -r session index _ <<<"$target" - - client=$(most_recent_client -t "$session") - [[ -n $client ]] || client=$(most_recent_client) - - if [[ -z $client ]]; then - tmux select-window -t "$session:$index" - exec omarchy-launch-terminal tmux attach -t "$session" - fi - - IFS=':' read -r _ tty pid <<<"$client" - tmux switch-client -c "$tty" -t "$session:$index" - omarchy-shell -q omarchy.indicators refresh - focus_terminal_window "$pid" -} - -case "${1:-}" in - show) - shift - show "$@" - ;; - focus) - focus - ;; - track) - shift - track "$@" - ;; - *) - usage - ;; -esac diff --git a/config/tmux/tmux.conf b/config/tmux/tmux.conf index b9800d01..d73c42ea 100644 --- a/config/tmux/tmux.conf +++ b/config/tmux/tmux.conf @@ -81,15 +81,6 @@ set -ag terminal-features "xterm-kitty:extkeys" set -as terminal-features ",*:clipboard" set -sg escape-time 10 -# Alerts -set-hook -g alert-bell 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -set-hook -g alert-activity 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -set-hook -g alert-silence 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -set-hook -g after-select-window 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' -set-hook -g client-session-changed 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' -set-hook -g client-focus-out[100] 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' -set-hook -g client-focus-in[100] 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' - # Status bar set -g status-position top set -g status-interval 5 diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index eeb067a4..1bf194dc 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -8,7 +8,6 @@ o.bind("SUPER + ESCAPE", "System menu", "omarchy-menu toggle system") o.bind("XF86PowerOff", "Power menu", "omarchy-menu toggle system", { locked = true }) o.bind("SUPER + K", "Show key bindings", "omarchy-menu-keybindings") o.bind("SUPER + ALT + K", "Show Tmux key bindings", "omarchy-menu-tmux-keybindings") -o.bind("SUPER + CTRL + J", "Jump to waiting Tmux pane", "omarchy-tmux-alert focus") o.bind("XF86Calculator", "Calculator", "gnome-calculator") o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar") diff --git a/migrations/1784849592.sh b/migrations/1784849592.sh deleted file mode 100644 index 3ca290c5..00000000 --- a/migrations/1784849592.sh +++ /dev/null @@ -1,17 +0,0 @@ -echo "Add tmux hooks that surface waiting windows in the bar" - -tmux_config="$HOME/.config/tmux/tmux.conf" - -if [[ -f $tmux_config ]] && ! grep -q 'alert-bell' "$tmux_config"; then - cat >>"$tmux_config" <<'EOF' - -# Alerts -set-hook -g alert-bell 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -set-hook -g alert-activity 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -set-hook -g alert-silence 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -set-hook -g after-select-window 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -set-hook -g client-session-changed 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -EOF - - omarchy-restart-tmux -fi diff --git a/migrations/1784955584.sh b/migrations/1784955584.sh deleted file mode 100644 index b57d9ddc..00000000 --- a/migrations/1784955584.sh +++ /dev/null @@ -1,17 +0,0 @@ -echo "Track tmux output while its terminal is unfocused" - -tmux_config="$HOME/.config/tmux/tmux.conf" - -if [[ -f $tmux_config ]] && ! grep -q 'client-focus-out\[100\].*omarchy-tmux-alert track' "$tmux_config"; then - sed -i \ - -e 's|^set-hook -g after-select-window .*|set-hook -g after-select-window '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'"'|' \ - -e 's|^set-hook -g client-session-changed .*|set-hook -g client-session-changed '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'"'|' \ - "$tmux_config" - - cat >>"$tmux_config" <<'EOF' -set-hook -g client-focus-out[100] 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' -set-hook -g client-focus-in[100] 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' -EOF - - omarchy-restart-tmux -fi diff --git a/migrations/1785189600.sh b/migrations/1785189600.sh new file mode 100644 index 00000000..4916f7d9 --- /dev/null +++ b/migrations/1785189600.sh @@ -0,0 +1,124 @@ +echo "Remove the tmux alert hooks and its bar indicator" + +tmux_config="$HOME/.config/tmux/tmux.conf" + +# Drop only the hooks Omarchy installed, wherever the migrations that wrote +# them left them, and only the blank run around what goes: every other line +# comes through untouched. +if [[ -f $tmux_config ]]; then + tmp=$(mktemp) + + if awk ' + function flush(count, i) { for (i = 0; i < count; i++) print "" } + + function keep(line) { + if (dropped) { if (printed && blanks) print "" } + else flush(blanks) + + blanks = 0 + dropped = 0 + printed = 1 + print line + } + + # The heading only belongs to the block when a removed hook follows it. + /^[[:space:]]*# Alerts$/ && header == "" { header = $0; next } + + /^[[:space:]]*set-hook -g alert-(bell|activity|silence) .*omarchy\.indicators refresh/ || + /^[[:space:]]*set-hook -g (after-select-window|client-session-changed|client-focus-(in|out))(\[[0-9]+\])? .*(omarchy-tmux-alert track|omarchy\.indicators refresh)/ { + header = "" + dropped = 1 + next + } + + /^[[:space:]]*$/ { + if (header != "") { keep(header); header = "" } + blanks++ + next + } + + { + if (header != "") { keep(header); header = "" } + keep($0) + } + + END { + if (header != "") keep(header) + if (!dropped) flush(blanks) + } + ' "$tmux_config" >"$tmp"; then + if ! cmp -s "$tmp" "$tmux_config"; then + # Write through the path instead of replacing it, so a tmux.conf + # symlinked out of a dotfiles repo keeps pointing where it pointed. + cat "$tmp" >"$tmux_config" + omarchy-restart-tmux + fi + else + echo "Could not rewrite $tmux_config; remove the tmux alert hooks by hand." + fi + + rm -f "$tmp" +fi + +# A running server keeps its hooks as options, so sourcing a config that no +# longer sets them leaves every one of them firing at the deleted command. +# Unset by the exact name and index tmux reports, which leaves hooks the user +# added at other indexes alone. +if tmux has-session 2>/dev/null; then + while read -r hook; do + [[ -n $hook ]] || continue + tmux set-hook -gu "$hook" 2>/dev/null || true + done < <(tmux show-hooks -g 2>/dev/null | + awk '$0 ~ /omarchy-tmux-alert|omarchy\.indicators refresh/ { print $1 }') + + # The removed track subcommand stamped this on every window it saw. + while read -r window; do + [[ -n $window ]] || continue + tmux set-option -wqu -t "$window" @omarchy_unfocused_activity 2>/dev/null || true + done < <(tmux list-windows -a -F '#{window_id}' 2>/dev/null) +fi + +# Only a hand-picked indicator list names TmuxAlert; the default list lives in +# the widget. An emptied list would read as "show them all", so a widget that +# has nothing left to show goes with it. +config_file="$HOME/.config/omarchy/shell.json" + +if [[ -s $config_file ]] && grep -q 'TmuxAlert' "$config_file"; then + tmp=$(mktemp) + + if jq ' + def entry_id: + if type == "string" then . elif type == "object" then (.id // "") else "" end; + + def without_tmux_alert: map(select(entry_id != "TmuxAlert")); + + def stripped($key): + if (.[$key] | type) == "array" then .[$key] |= without_tmux_alert else . end; + + # The list the widget actually reads: "indicators" is the older spelling of + # "items", and either one empty means "show the default set". + def picked: + if (.items | type) == "array" and (.items | length) > 0 then .items + elif (.indicators | type) == "array" and (.indicators | length) > 0 then .indicators + else null end; + + def cleaned: + if type == "object" and .id == "omarchy.indicators" then stripped("items") | stripped("indicators") else . end; + + def emptied: + type == "object" and .id == "omarchy.indicators" and + picked != null and (cleaned | picked) == null; + + walk(if type == "array" then map(select(emptied | not)) | map(cleaned) else . end) + ' "$config_file" >"$tmp"; then + cat "$tmp" >"$config_file" + else + echo "Could not rewrite $config_file; remove the TmuxAlert indicator by hand." + fi + + rm -f "$tmp" +fi + +# Nothing to restart from a TTY or over ssh, and that is no reason to stop the +# rest of the queue: hand it to the post-update restart instead. +omarchy-restart-shell >/dev/null 2>&1 || omarchy-state set restart-shell-required diff --git a/shell/plugins/bar/indicators/TmuxAlert.qml b/shell/plugins/bar/indicators/TmuxAlert.qml deleted file mode 100644 index 8ceb38c6..00000000 --- a/shell/plugins/bar/indicators/TmuxAlert.qml +++ /dev/null @@ -1,46 +0,0 @@ -import QtQuick -import Quickshell -import qs.Ui - -BarIndicator { - id: root - - readonly property var tmuxService: bar?.shell?.firstPartyServiceFor("omarchy.tmux") - - active: tmuxService ? tmuxService.waiting : false - activeText: "󰆍" - inactiveText: "󰆍" - activeTooltipText: tmuxService ? tmuxService.tooltip : "" - inactiveTooltipText: "No Terminal Waiting" - - // The service owns the probe and its polling; the tmux hooks still reach it - // through the indicator host's refresh broadcast, which coalesces into a - // single run no matter how many bars relay it. - function refresh() { - if (root.tmuxService) root.tmuxService.refresh() - } - - SequentialAnimation { - running: root.active - loops: Animation.Infinite - - PauseAnimation { duration: 2740 } - NumberAnimation { target: root; property: "textRotation"; to: -10; duration: 50; easing.type: Easing.OutQuad } - NumberAnimation { target: root; property: "textRotation"; to: 10; duration: 70; easing.type: Easing.InOutQuad } - NumberAnimation { target: root; property: "textRotation"; to: -7; duration: 55; easing.type: Easing.InOutQuad } - NumberAnimation { target: root; property: "textRotation"; to: 7; duration: 45; easing.type: Easing.InOutQuad } - NumberAnimation { target: root; property: "textRotation"; to: 0; duration: 40; easing.type: Easing.OutQuad } - - onStopped: root.textRotation = 0 - } - - Connections { - target: root.indicatorHost - ignoreUnknownSignals: true - function onRefreshRequested() { root.refresh() } - } - - onPressed: function() { - Quickshell.execDetached(["omarchy-tmux-alert", "focus"]) - } -} diff --git a/shell/plugins/bar/widgets/Indicators.manifest.json b/shell/plugins/bar/widgets/Indicators.manifest.json index 2a52f979..4a488a4c 100644 --- a/shell/plugins/bar/widgets/Indicators.manifest.json +++ b/shell/plugins/bar/widgets/Indicators.manifest.json @@ -26,11 +26,6 @@ "placeholderText": "Search indicators...", "emptyText": "No indicators", "options": [ - { - "value": "TmuxAlert", - "label": "Tmux alert", - "description": "Tmux windows waiting for attention" - }, { "value": "Dictation", "label": "Dictation", diff --git a/shell/plugins/bar/widgets/Indicators.qml b/shell/plugins/bar/widgets/Indicators.qml index d0f6d1a2..cb35c5a2 100644 --- a/shell/plugins/bar/widgets/Indicators.qml +++ b/shell/plugins/bar/widgets/Indicators.qml @@ -8,7 +8,7 @@ BarWidget { id: root moduleName: "omarchy.indicators" - readonly property var defaultIndicatorEntries: [ "TmuxAlert", "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] + readonly property var defaultIndicatorEntries: [ "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] readonly property var indicatorEntries: indicatorEntriesFromSettings(settings) property var activeIndicatorIds: [] property var indicatorActiveStates: ({}) diff --git a/shell/plugins/services/tmux/Service.qml b/shell/plugins/services/tmux/Service.qml deleted file mode 100644 index da8efc04..00000000 --- a/shell/plugins/services/tmux/Service.qml +++ /dev/null @@ -1,60 +0,0 @@ -import QtQuick -import Quickshell.Io -import "TmuxModel.js" as TmuxModel - -Item { - id: root - - // Injected by omarchy-shell (the first-party service loader). - property var shell: null - - property int waitingCount: 0 - property string tooltip: "" - property bool refreshPending: false - - readonly property bool waiting: waitingCount > 0 - - // A bar surface exists per monitor and each one instantiates the indicator - // twice (active and inactive block), so the probe lives here instead: one - // process for the whole shell, and every indicator reads the same answer. - function refresh() { - if (statusProc.running) refreshPending = true - else statusProc.running = true - } - - // tmux hooks cover the flag transitions, but output in a selected window - // whose terminal sits on another workspace never fires one, and neither does - // a window or session killed while it was still flagged. - Timer { - interval: 3000 - running: true - repeat: true - onTriggered: root.refresh() - } - - Process { - id: statusProc - command: ["omarchy-tmux-alert", "show", "--json"] - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - var waiting = TmuxModel.waitingFromOutput(text) - root.waitingCount = waiting.count - root.tooltip = waiting.tooltip - } - } - onExited: function(exitCode) { - if (exitCode !== 0) { - root.waitingCount = 0 - root.tooltip = "" - } - - if (root.refreshPending) { - root.refreshPending = false - root.refresh() - } - } - } - - Component.onCompleted: refresh() -} diff --git a/shell/plugins/services/tmux/TmuxModel.js b/shell/plugins/services/tmux/TmuxModel.js deleted file mode 100644 index 1e93b2b1..00000000 --- a/shell/plugins/services/tmux/TmuxModel.js +++ /dev/null @@ -1,29 +0,0 @@ -// Shape of `omarchy-tmux-alert show --json`. The command emits one JSON object -// on the last line; anything tmux or a shell profile printed before it is -// ignored so a noisy environment cannot blank the indicator. -function waitingFromOutput(output) { - var text = String(output === undefined || output === null ? "" : output).trim() - if (!text) return { count: 0, tooltip: "" } - - var lines = text.split("\n") - var data - try { - data = JSON.parse(lines[lines.length - 1]) - } catch (e) { - return { count: 0, tooltip: "" } - } - - if (!data || typeof data !== "object") return { count: 0, tooltip: "" } - - var count = Number(data.count) - return { - count: isFinite(count) && count > 0 ? Math.floor(count) : 0, - tooltip: data.tooltip === undefined || data.tooltip === null ? "" : String(data.tooltip) - } -} - -if (typeof module !== "undefined") { - module.exports = { - waitingFromOutput: waitingFromOutput - } -} diff --git a/shell/plugins/services/tmux/manifest.json b/shell/plugins/services/tmux/manifest.json deleted file mode 100644 index eeddb28d..00000000 --- a/shell/plugins/services/tmux/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "schemaVersion": 1, - "id": "omarchy.tmux", - "name": "Tmux", - "version": "1.0.0", - "author": "Omarchy", - "description": "Owns the set of tmux windows waiting for attention for the bar indicator.", - "kinds": [ - "service" - ], - "entryPoints": { - "service": "Service.qml" - } -} diff --git a/test/shell.d/shell-ipc-display-test.sh b/test/shell.d/shell-ipc-display-test.sh index 57327a75..3bba57ff 100755 --- a/test/shell.d/shell-ipc-display-test.sh +++ b/test/shell.d/shell-ipc-display-test.sh @@ -20,7 +20,8 @@ export PATH="$test_dir/bin:$PATH" export OMARCHY_PATH="$ROOT" export XDG_RUNTIME_DIR="$test_dir/run" -# tmux hooks run without WAYLAND_DISPLAY, and qs matches instances by display. +# Callers from a stripped environment have no WAYLAND_DISPLAY, and qs matches +# instances by display. output=$(env -u WAYLAND_DISPLAY "$ROOT/bin/omarchy-shell" omarchy.indicators refresh) [[ $output == "display=[wayland-1]" ]] || fail "shell ipc recovers a missing display" "$output" pass "shell ipc recovers a missing display" diff --git a/test/shell.d/tmux-alert-removal-migration-test.sh b/test/shell.d/tmux-alert-removal-migration-test.sh new file mode 100644 index 00000000..53a8e7f5 --- /dev/null +++ b/test/shell.d/tmux-alert-removal-migration-test.sh @@ -0,0 +1,246 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +migration="$ROOT/migrations/1785189600.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +mkdir -p "$test_dir/bin" + +# Everything the migration reaches for is stubbed: a real tmux here would talk +# to the developer's own server. +cat >"$test_dir/bin/tmux" <<'STUB' +#!/bin/bash + +printf '%s\n' "$*" >>"$TMUX_CALLS" + +case "$1" in + has-session) exit "${TMUX_SERVER_MISSING:-0}" ;; + show-hooks) + cat <<'HOOKS' +after-select-window[0] run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}" +alert-activity +alert-bell[0] run-shell -b "omarchy-shell -q omarchy.indicators refresh" +client-focus-in[100] run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}" +client-focus-out[42] display-message "user hook" +session-created +HOOKS + ;; + list-windows) printf '@0\n@3\n' ;; +esac +STUB + +cat >"$test_dir/bin/omarchy-restart-tmux" <<'STUB' +#!/bin/bash + +echo restart >>"$TMUX_RESTARTS" +STUB + +cat >"$test_dir/bin/omarchy-restart-shell" <<'STUB' +#!/bin/bash + +echo restart >>"$SHELL_RESTARTS" +exit "${SHELL_RESTART_STATUS:-0}" +STUB + +cat >"$test_dir/bin/omarchy-state" <<'STUB' +#!/bin/bash + +printf '%s\n' "$*" >>"$STATE_CALLS" +STUB + +chmod +x "$test_dir/bin/"* + +export TMUX_CALLS="$test_dir/tmux-calls" +export TMUX_RESTARTS="$test_dir/tmux-restarts" +export SHELL_RESTARTS="$test_dir/shell-restarts" +export STATE_CALLS="$test_dir/state-calls" + +home="$test_dir/home" +tmux_config="$home/.config/tmux/tmux.conf" +shell_config="$home/.config/omarchy/shell.json" + +run_migration() { + : >"$TMUX_CALLS" + : >"$TMUX_RESTARTS" + : >"$SHELL_RESTARTS" + : >"$STATE_CALLS" + + HOME="$home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null +} + +reset_home() { + rm -rf "$home" + mkdir -p "$home/.config/tmux" "$home/.config/omarchy" +} + +# ---------------------------------------------------------------- tmux config + +# The shipped config with the alert block put back is exactly what an installed +# machine has, so cleaning it has to land on the shipped config byte for byte. +reset_home +awk ' + /^# Status bar$/ { + print "# Alerts" + print "set-hook -g alert-bell '\''run-shell -b \"omarchy-shell -q omarchy.indicators refresh\"'\''" + print "set-hook -g alert-activity '\''run-shell -b \"omarchy-shell -q omarchy.indicators refresh\"'\''" + print "set-hook -g alert-silence '\''run-shell -b \"omarchy-shell -q omarchy.indicators refresh\"'\''" + print "set-hook -g after-select-window '\''run-shell -b \"omarchy-tmux-alert track #{window_id} #{window_activity}\"'\''" + print "set-hook -g client-session-changed '\''run-shell -b \"omarchy-tmux-alert track #{window_id} #{window_activity}\"'\''" + print "set-hook -g client-focus-out[100] '\''run-shell -b \"omarchy-tmux-alert track #{window_id} #{window_activity}\"'\''" + print "set-hook -g client-focus-in[100] '\''run-shell -b \"omarchy-tmux-alert track #{window_id} #{window_activity}\"'\''" + print "" + } + { print } +' "$ROOT/config/tmux/tmux.conf" >"$tmux_config" + +run_migration + +diff -u "$ROOT/config/tmux/tmux.conf" "$tmux_config" >/dev/null || + fail "alert removal restores the shipped tmux config" "$(diff -u "$ROOT/config/tmux/tmux.conf" "$tmux_config")" +pass "alert removal restores the shipped tmux config" + +(($(wc -l <"$TMUX_RESTARTS") == 1)) || fail "alert removal reloads tmux once" +pass "alert removal reloads tmux once" + +before=$(sha256sum "$tmux_config") +run_migration +[[ $before == $(sha256sum "$tmux_config") ]] || fail "alert removal is idempotent" +[[ ! -s $TMUX_RESTARTS ]] || fail "idempotent alert removal does not reload tmux" +pass "alert removal is idempotent" + +# The hooks the removed migrations appended sit at the end of the file, and a +# user's own hooks and blank lines have to survive next to them. +reset_home +cat >"$tmux_config" <<'EOF' +set -g mouse on + + +# Custom +set-hook -g alert-bell 'display-message "mine"' +set-hook -g client-focus-out[42] 'display-message "custom focus hook"' + +# Alerts +set-hook -g alert-bell 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g alert-activity 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g after-select-window 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' + set-hook -g client-session-changed 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' +set-hook -g client-focus-out[100] 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' +EOF + +run_migration + +expected=$(printf '%s\n' 'set -g mouse on' '' '' '# Custom' "set-hook -g alert-bell 'display-message \"mine\"'" "set-hook -g client-focus-out[42] 'display-message \"custom focus hook\"'") +[[ $(cat "$tmux_config") == "$expected" ]] || + fail "alert removal drops an appended block and keeps the user's lines" "$(cat -A "$tmux_config")" +pass "alert removal drops an appended block and keeps the user's lines" + +# An older config kept the refresh spelling of these two hooks; an indented one +# is still the hook Omarchy wrote. +grep -q 'after-select-window\|client-session-changed' "$tmux_config" && + fail "alert removal drops the older and indented hook spellings" +pass "alert removal drops the older and indented hook spellings" + +# A config that never had the feature is not a config to rewrite. +reset_home +printf '%s\n' 'set -g mouse on' '' '' 'set -g status-position top' '' >"$tmux_config" +before=$(sha256sum "$tmux_config") +run_migration +[[ $before == $(sha256sum "$tmux_config") ]] || fail "alert removal leaves an unrelated config alone" "$(cat -A "$tmux_config")" +[[ ! -s $TMUX_RESTARTS ]] || fail "alert removal does not reload tmux for an unrelated config" +pass "alert removal leaves an unrelated config alone" + +# Dotfile setups symlink the config; the link has to survive the rewrite. +reset_home +mkdir -p "$home/dotfiles" +cat >"$home/dotfiles/tmux.conf" <<'EOF' +set -g mouse on + +# Alerts +set-hook -g alert-bell 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +EOF +ln -sf "$home/dotfiles/tmux.conf" "$tmux_config" + +run_migration + +[[ -L $tmux_config ]] || fail "alert removal writes through a symlinked config" +[[ $(readlink "$tmux_config") == "$home/dotfiles/tmux.conf" ]] || fail "alert removal keeps the symlink target" +grep -q 'alert-bell' "$home/dotfiles/tmux.conf" && fail "alert removal cleans the symlink target" +pass "alert removal writes through a symlinked config" + +# --------------------------------------------------------------- live server + +reset_home +run_migration + +grep -Fxq 'set-hook -gu after-select-window[0]' "$TMUX_CALLS" || fail "alert removal unsets live hooks by index" "$(cat "$TMUX_CALLS")" +grep -Fxq 'set-hook -gu alert-bell[0]' "$TMUX_CALLS" || fail "alert removal unsets live alert hooks" +grep -Fxq 'set-hook -gu client-focus-in[100]' "$TMUX_CALLS" || fail "alert removal unsets live focus hooks" +grep -q 'set-hook -gu client-focus-out\[42\]' "$TMUX_CALLS" && fail "alert removal keeps a user's hook at another index" +pass "alert removal unsets the live hooks it installed" + +grep -Fxq 'set-option -wqu -t @0 @omarchy_unfocused_activity' "$TMUX_CALLS" || fail "alert removal clears tracked window options" +grep -Fxq 'set-option -wqu -t @3 @omarchy_unfocused_activity' "$TMUX_CALLS" || fail "alert removal clears every tracked window" +pass "alert removal clears the window options it stamped" + +TMUX_SERVER_MISSING=1 run_migration +grep -q 'set-hook' "$TMUX_CALLS" && fail "alert removal skips a server that is not running" "$(cat "$TMUX_CALLS")" +pass "alert removal skips a server that is not running" + +# ---------------------------------------------------------------- shell.json + +reset_home +cat >"$shell_config" <<'EOF' +{ + "bar": { + "layout": { + "left": [ + { "id": "omarchy.indicators", "items": ["TmuxAlert", "Dnd", { "id": "TmuxAlert" }, { "id": "NightLight" }] } + ], + "center": [ + { "id": "omarchy.indicators", "items": ["TmuxAlert"] }, + { "id": "omarchy.clock" } + ], + "right": [ + { "id": "omarchy.indicators", "indicators": ["TmuxAlert", "Dnd"] }, + { "id": "omarchy.indicators", "items": [] } + ] + } + } +} +EOF + +run_migration + +grep -q 'TmuxAlert' "$shell_config" && fail "alert removal strips TmuxAlert from shell.json" "$(cat "$shell_config")" +pass "alert removal strips TmuxAlert from shell.json" + +[[ $(jq -c '.bar.layout.left[0].items' "$shell_config") == '["Dnd",{"id":"NightLight"}]' ]] || + fail "alert removal keeps the other picked indicators" "$(jq -c '.bar.layout.left[0].items' "$shell_config")" +pass "alert removal keeps the other picked indicators" + +[[ $(jq -c '[.bar.layout.center[].id]' "$shell_config") == '["omarchy.clock"]' ]] || + fail "alert removal drops a widget left with nothing to show" "$(jq -c '.bar.layout.center' "$shell_config")" +pass "alert removal drops a widget left with nothing to show" + +[[ $(jq -c '.bar.layout.right[0].indicators' "$shell_config") == '["Dnd"]' ]] || + fail "alert removal handles the older indicators key" "$(jq -c '.bar.layout.right[0]' "$shell_config")" +pass "alert removal handles the older indicators key" + +[[ $(jq -c '.bar.layout.right[1]' "$shell_config") == '{"id":"omarchy.indicators","items":[]}' ]] || + fail "alert removal leaves an already-empty list alone" "$(jq -c '.bar.layout.right[1]' "$shell_config")" +pass "alert removal leaves an already-empty list alone" + +(($(wc -l <"$SHELL_RESTARTS") == 1)) || fail "alert removal restarts the shell" +[[ ! -s $STATE_CALLS ]] || fail "a restarted shell needs no deferred restart" "$(cat "$STATE_CALLS")" +pass "alert removal restarts the shell" + +# Migrations run from a TTY or over ssh have no shell to restart, and stopping +# there would strand every migration queued behind this one. +reset_home +SHELL_RESTART_STATUS=1 run_migration +grep -Fxq 'set restart-shell-required' "$STATE_CALLS" || fail "an unavailable shell defers its restart" "$(cat "$STATE_CALLS")" +pass "an unavailable shell defers its restart" diff --git a/test/shell.d/tmux-alert-test.sh b/test/shell.d/tmux-alert-test.sh deleted file mode 100644 index 19aecf84..00000000 --- a/test/shell.d/tmux-alert-test.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(dirname "$0")/base-test.sh" - -require_command jq - -run_node_test <<'JS' -const tmux = requireFromRoot('shell/plugins/services/tmux/TmuxModel.js') - -assertEqual(tmux.waitingFromOutput('{"count":2,"tooltip":"claude (Work:2)"}').count, 2, 'tmux model reads the waiting count') -assertEqual(tmux.waitingFromOutput('{"count":2,"tooltip":"claude (Work:2)"}').tooltip, 'claude (Work:2)', 'tmux model reads the tooltip') -assertEqual(tmux.waitingFromOutput('shell noise\n{"count":1,"tooltip":"editor (Work:1)"}').count, 1, 'tmux model ignores output before the json line') -assertEqual(tmux.waitingFromOutput('').count, 0, 'tmux model treats empty output as nothing waiting') -assertEqual(tmux.waitingFromOutput('not json').count, 0, 'tmux model treats unparseable output as nothing waiting') -assertEqual(tmux.waitingFromOutput('not json').tooltip, '', 'tmux model blanks the tooltip on unparseable output') -assertEqual(tmux.waitingFromOutput('{"count":-3}').count, 0, 'tmux model clamps a negative count') -assertEqual(tmux.waitingFromOutput('{"tooltip":"editor (Work:1)"}').count, 0, 'tmux model defaults a missing count') -JS - -test_dir=$(mktemp -d) -trap 'rm -rf "$test_dir"' EXIT - -cat >"$test_dir/tmux" <<'STUB' -#!/bin/bash - -if [[ $1 == "list-windows" ]]; then - cat "$TMUX_STUB_WINDOWS" -elif [[ $1 == "list-clients" ]]; then - cat "$TMUX_STUB_CLIENTS" -elif [[ $1 == "set-option" ]]; then - printf '%s\n' "$*" >>"$TMUX_STUB_CALLS" -fi -STUB -chmod +x "$test_dir/tmux" - -cat >"$test_dir/omarchy-shell" <<'STUB' -#!/bin/bash - -printf '%s\n' "$*" >>"$TMUX_STUB_CALLS" -STUB -chmod +x "$test_dir/omarchy-shell" - -export TMUX_STUB_WINDOWS="$test_dir/windows" -export TMUX_STUB_CLIENTS="$test_dir/clients" -export TMUX_STUB_CALLS="$test_dir/calls" -export PATH="$test_dir:$PATH" - -cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS' -@1:000:100::Work:1:editor -@2:100:110::Work:2:claude -@3:001:120::Side|Gig:1:server: still going -WINDOWS -: >"$TMUX_STUB_CLIENTS" - -output=$("$ROOT/bin/omarchy-tmux-alert" show --json) -expected='{"count":2,"tooltip":"claude (Work:2), server: still going (Side|Gig:1)"}' -[[ $output == "$expected" ]] || fail "tmux alert reports alerted windows" "expected: $expected"$'\n'"actual: $output" -pass "tmux alert reports alerted windows" - -output=$("$ROOT/bin/omarchy-tmux-alert" show) -[[ $output == "claude (Work:2), server: still going (Side|Gig:1)" ]] || fail "tmux alert describes alerted windows" "$output" -pass "tmux alert describes alerted windows" - -cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS' -@1:000:200:100:Work:1:editor -WINDOWS -echo 'attached,UTF-8:@1' >"$TMUX_STUB_CLIENTS" - -output=$("$ROOT/bin/omarchy-tmux-alert" show --json) -expected='{"count":1,"tooltip":"editor (Work:1)"}' -[[ $output == "$expected" ]] || fail "tmux alert reports output in an unfocused active window" "expected: $expected"$'\n'"actual: $output" -pass "tmux alert reports output in an unfocused active window" - -echo 'attached,focused,UTF-8:@1' >"$TMUX_STUB_CLIENTS" -output=$("$ROOT/bin/omarchy-tmux-alert" show --json) -[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert ignores output in a focused active window" "$output" -pass "tmux alert ignores output in a focused active window" - -cat >"$TMUX_STUB_CLIENTS" <<'CLIENTS' -attached,UTF-8:@1 -attached,focused,UTF-8:@1 -CLIENTS -output=$("$ROOT/bin/omarchy-tmux-alert" show --json) -[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert ignores a window visible in another focused client" "$output" -pass "tmux alert ignores a window visible in another focused client" - -echo 'attached,UTF-8:@1' >"$TMUX_STUB_CLIENTS" -cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS' -@1:000:200:200:Work:1:editor -WINDOWS - -output=$("$ROOT/bin/omarchy-tmux-alert" show --json) -[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert reports no alerted windows" "$output" -pass "tmux alert reports no alerted windows" - -[[ -z $("$ROOT/bin/omarchy-tmux-alert" show) ]] || fail "tmux alert stays quiet without alerts" -pass "tmux alert stays quiet without alerts" - -: >"$TMUX_STUB_WINDOWS" -: >"$TMUX_STUB_CLIENTS" -output=$("$ROOT/bin/omarchy-tmux-alert" show --json) -[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert handles a missing tmux server" "$output" -pass "tmux alert handles a missing tmux server" - -: >"$TMUX_STUB_CALLS" -"$ROOT/bin/omarchy-tmux-alert" track @3 345 -expected=$'set-option -wq -t @3 @omarchy_unfocused_activity 345\n-q omarchy.indicators refresh' -output=$(cat "$TMUX_STUB_CALLS") -[[ $output == "$expected" ]] || fail "tmux alert records the activity watermark and refreshes indicators" "expected: $expected"$'\n'"actual: $output" -pass "tmux alert records the activity watermark and refreshes indicators" - -: >"$TMUX_STUB_CALLS" -"$ROOT/bin/omarchy-tmux-alert" track invalid nope -[[ ! -s $TMUX_STUB_CALLS ]] || fail "tmux alert ignores invalid tracking arguments" "$(cat "$TMUX_STUB_CALLS")" -pass "tmux alert ignores invalid tracking arguments" diff --git a/test/shell.d/tmux-hidden-activity-migration-test.sh b/test/shell.d/tmux-hidden-activity-migration-test.sh deleted file mode 100644 index 174532c1..00000000 --- a/test/shell.d/tmux-hidden-activity-migration-test.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(dirname "$0")/base-test.sh" - -migration="$ROOT/migrations/1784955584.sh" -test_dir=$(mktemp -d) -trap 'rm -rf "$test_dir"' EXIT - -mkdir -p "$test_dir/home/.config/tmux" "$test_dir/bin" - -cat >"$test_dir/home/.config/tmux/tmux.conf" <<'EOF' -# Alerts -set-hook -g alert-bell 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -set-hook -g after-select-window 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -set-hook -g client-session-changed 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' -set-hook -g client-focus-out[42] 'display-message "custom focus hook"' -EOF - -cat >"$test_dir/bin/omarchy-restart-tmux" <<'EOF' -#!/bin/bash - -echo restart >>"$TMUX_MIGRATION_RESTART_LOG" -EOF -chmod +x "$test_dir/bin/omarchy-restart-tmux" - -export TMUX_MIGRATION_RESTART_LOG="$test_dir/restarts" -tmux_config="$test_dir/home/.config/tmux/tmux.conf" - -HOME="$test_dir/home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null - -grep -Fq 'after-select-window '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" || - fail "tmux activity migration tracks newly selected windows" -grep -Fq 'client-session-changed '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" || - fail "tmux activity migration tracks session changes" -grep -Fq 'client-focus-out[100] '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" || - fail "tmux activity migration tracks terminal focus loss" -grep -Fq 'client-focus-in[100] '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" || - fail "tmux activity migration tracks terminal focus gain" -grep -Fq 'client-focus-out[42] '"'"'display-message "custom focus hook"'"'" "$tmux_config" || - fail "tmux activity migration preserves custom indexed focus hooks" -restart_count=$(wc -l <"$TMUX_MIGRATION_RESTART_LOG") -((restart_count == 1)) || fail "tmux activity migration reloads tmux once" -pass "tmux activity migration installs focus-aware hooks" - -before=$(sha256sum "$tmux_config") -HOME="$test_dir/home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null -after=$(sha256sum "$tmux_config") - -[[ $before == "$after" ]] || fail "tmux activity migration is idempotent" -restart_count=$(wc -l <"$TMUX_MIGRATION_RESTART_LOG") -((restart_count == 1)) || fail "idempotent tmux activity migration does not reload tmux" -pass "tmux activity migration is idempotent"