From e880cf77cc3507cb3ac919118e7e962347c51bbc Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 24 Jul 2026 22:41:54 -0700 Subject: [PATCH] Alert on tmux output while its terminal is unfocused tmux only raises an activity flag for windows that are not currently selected, so a long-running command in the window you left selected -- the common case, since you switch away by moving your Hyprland focus elsewhere rather than by selecting another tmux window -- finished silently and the indicator never lit. Windows now also count as waiting when they are selected in an attached client, every client showing them is unfocused, and their window_activity is newer than an @omarchy_unfocused_activity watermark. The watermark is stamped by a new `track` subcommand wired to client-focus-in/out and the existing select-window hooks, so it records where attention last was. The hooks pass #{window_id} and #{window_activity} as arguments, which tmux expands when the hook fires; run-shell -b would otherwise let output arriving during the handoff be swallowed by the new watermark. The focus hooks take index 100 to leave a user's own bindings alone. That state has no hook of its own, so it needs polling to be noticed. The probe therefore moves out of the indicator and into a service plugin, alongside nightlight and battery. A bar surface exists per monitor and each one instantiates every indicator twice, once per block, so a timer on the indicator meant a shell-out per instance per tick -- four probe processes every three seconds on a two-monitor machine, forever. One service polls for the whole shell instead. Sharing the state also fixes what per-instance polling would have papered over: each indicator used to own its own count, so a timer-driven update only refreshed the bar it ran on and left the other monitors stale. Refreshes still arrive over the existing indicator broadcast, which now coalesces into a single run no matter how many bars relay it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018CC9kSQv8ZEogaxDoeKBzL --- bin/omarchy-tmux-alert | 51 ++++++++++--- config/tmux/tmux.conf | 6 +- migrations/1784955584.sh | 17 +++++ shell/plugins/bar/indicators/TmuxAlert.qml | 52 ++------------ shell/plugins/services/tmux/Service.qml | 60 ++++++++++++++++ shell/plugins/services/tmux/TmuxModel.js | 29 ++++++++ shell/plugins/services/tmux/manifest.json | 14 ++++ test/shell.d/tmux-alert-test.sh | 72 +++++++++++++++++-- .../tmux-hidden-activity-migration-test.sh | 54 ++++++++++++++ 9 files changed, 294 insertions(+), 61 deletions(-) create mode 100644 migrations/1784955584.sh create mode 100644 shell/plugins/services/tmux/Service.qml create mode 100644 shell/plugins/services/tmux/TmuxModel.js create mode 100644 shell/plugins/services/tmux/manifest.json create mode 100644 test/shell.d/tmux-hidden-activity-migration-test.sh diff --git a/bin/omarchy-tmux-alert b/bin/omarchy-tmux-alert index a0881f71..0dfbf990 100755 --- a/bin/omarchy-tmux-alert +++ b/bin/omarchy-tmux-alert @@ -8,24 +8,43 @@ set -e # Session names cannot contain colons, and window names can, so they always # come last when parsed. -WINDOW_FORMAT='#{window_bell_flag}#{window_activity_flag}#{window_silence_flag}:#{session_name}:#{window_index}:#{window_name}' +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 } -# Windows tmux has flagged with a bell, activity, or silence alert, which is the -# same state that highlights the window in the tmux status bar. +# Include tmux alert flags plus output newer than the last focus transition for +# selected windows whose attached terminals are all unfocused. alerted_windows() { - local flags session index name + local client_flags window_id flags activity seen_activity session index name + local waiting + local -A attached_windows=() + local -A focused_windows=() - tmux list-windows -a -F "$WINDOW_FORMAT" 2>/dev/null | - while IFS=':' read -r flags session index name; do - if [[ $flags == *1* ]]; then - printf '%s:%s:%s\n' "$session" "$index" "$name" - fi - done + 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() { @@ -73,6 +92,14 @@ 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 @@ -103,6 +130,10 @@ case "${1:-}" in focus) focus ;; + track) + shift + track "$@" + ;; *) usage ;; diff --git a/config/tmux/tmux.conf b/config/tmux/tmux.conf index 0bfc01f4..b9800d01 100644 --- a/config/tmux/tmux.conf +++ b/config/tmux/tmux.conf @@ -85,8 +85,10 @@ set -sg escape-time 10 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"' +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 diff --git a/migrations/1784955584.sh b/migrations/1784955584.sh new file mode 100644 index 00000000..b57d9ddc --- /dev/null +++ b/migrations/1784955584.sh @@ -0,0 +1,17 @@ +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/shell/plugins/bar/indicators/TmuxAlert.qml b/shell/plugins/bar/indicators/TmuxAlert.qml index dcb25eeb..8ceb38c6 100644 --- a/shell/plugins/bar/indicators/TmuxAlert.qml +++ b/shell/plugins/bar/indicators/TmuxAlert.qml @@ -1,41 +1,23 @@ import QtQuick import Quickshell -import Quickshell.Io import qs.Ui BarIndicator { id: root - property int waitingCount: 0 - property string tooltip: "" - property bool refreshPending: false + readonly property var tmuxService: bar?.shell?.firstPartyServiceFor("omarchy.tmux") - active: waitingCount > 0 + active: tmuxService ? tmuxService.waiting : false activeText: "󰆍" inactiveText: "󰆍" - activeTooltipText: tooltip + 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 (statusProc.running) refreshPending = true - else statusProc.running = true - } - - function update(raw) { - var data = extractData(raw) - waitingCount = Number(data.count || 0) - tooltip = String(data.tooltip || "") - } - - Component.onCompleted: refresh() - - // Alerts can also disappear without a hook, such as when the window or its - // session is killed while still flagged. - Timer { - interval: 5000 - running: root.active - repeat: true - onTriggered: root.refresh() + if (root.tmuxService) root.tmuxService.refresh() } SequentialAnimation { @@ -58,26 +40,6 @@ BarIndicator { function onRefreshRequested() { root.refresh() } } - Process { - id: statusProc - command: ["omarchy-tmux-alert", "show", "--json"] - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: root.update(text) - } - onExited: function(exitCode) { - if (exitCode !== 0) { - root.waitingCount = 0 - root.tooltip = "" - } - - if (root.refreshPending) { - root.refreshPending = false - root.refresh() - } - } - } - onPressed: function() { Quickshell.execDetached(["omarchy-tmux-alert", "focus"]) } diff --git a/shell/plugins/services/tmux/Service.qml b/shell/plugins/services/tmux/Service.qml new file mode 100644 index 00000000..da8efc04 --- /dev/null +++ b/shell/plugins/services/tmux/Service.qml @@ -0,0 +1,60 @@ +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 new file mode 100644 index 00000000..1e93b2b1 --- /dev/null +++ b/shell/plugins/services/tmux/TmuxModel.js @@ -0,0 +1,29 @@ +// 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 new file mode 100644 index 00000000..eeddb28d --- /dev/null +++ b/shell/plugins/services/tmux/manifest.json @@ -0,0 +1,14 @@ +{ + "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/tmux-alert-test.sh b/test/shell.d/tmux-alert-test.sh index 276771cb..19aecf84 100644 --- a/test/shell.d/tmux-alert-test.sh +++ b/test/shell.d/tmux-alert-test.sh @@ -6,6 +6,19 @@ 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 @@ -14,18 +27,32 @@ cat >"$test_dir/tmux" <<'STUB' 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' -000:Work:1:editor -100:Work:2:claude -001:Side|Gig:1:server: still going +@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)"}' @@ -37,7 +64,31 @@ output=$("$ROOT/bin/omarchy-tmux-alert" show) pass "tmux alert describes alerted windows" cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS' -000:Work:1:editor +@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) @@ -48,6 +99,19 @@ pass "tmux alert reports no alerted windows" 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 new file mode 100644 index 00000000..174532c1 --- /dev/null +++ b/test/shell.d/tmux-hidden-activity-migration-test.sh @@ -0,0 +1,54 @@ +#!/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"