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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CC9kSQv8ZEogaxDoeKBzL
This commit is contained in:
David Heinemeier Hansson
2026-07-25 07:19:02 -07:00
co-authored by Claude Opus 5
parent 2503edadfc
commit e880cf77cc
9 changed files with 294 additions and 61 deletions
+68 -4
View File
@@ -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"
@@ -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"