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
+41 -10
View File
@@ -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 <show [--json]|focus>" >&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
;;
+4 -2
View File
@@ -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
+17
View File
@@ -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
+7 -45
View File
@@ -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"])
}
+60
View File
@@ -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()
}
+29
View File
@@ -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
}
}
+14
View File
@@ -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"
}
}
+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"