This tmux alert system didn't work as nicely as I imagined

This commit is contained in:
David Heinemeier Hansson
2026-07-27 15:32:56 -07:00
parent b6e1a43278
commit bdcdfeb428
19 changed files with 376 additions and 515 deletions
-1
View File
@@ -81,7 +81,6 @@ GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers"
GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock" GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock"
GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers" GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers"
GROUP_DESCRIPTIONS[theme]="Theme management" GROUP_DESCRIPTIONS[theme]="Theme management"
GROUP_DESCRIPTIONS[tmux]="Tmux session helpers"
GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features" GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features"
GROUP_DESCRIPTIONS[transcode]="Image and video transcoding" GROUP_DESCRIPTIONS[transcode]="Image and video transcoding"
GROUP_DESCRIPTIONS[tui]="Terminal UI launchers" GROUP_DESCRIPTIONS[tui]="Terminal UI launchers"
-1
View File
@@ -418,7 +418,6 @@ prioritize_entries() {
if (match(line, /Toggle workspace gaps/)) prio = 39 if (match(line, /Toggle workspace gaps/)) prio = 39
if (match(line, /Toggle nightlight/)) prio = 40 if (match(line, /Toggle nightlight/)) prio = 40
if (match(line, /Toggle locking/)) prio = 41 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, /group/)) prio = 94
if (match(line, /Scroll active workspace/)) prio = 95 if (match(line, /Scroll active workspace/)) prio = 95
if (match(line, /Cycle to/)) prio = 96 if (match(line, /Cycle to/)) prio = 96
+3 -2
View File
@@ -40,8 +40,9 @@ fi
[[ -n ${OMARCHY_PATH:-} ]] || fail "OMARCHY_PATH is not set" [[ -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" [[ -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 # qs matches instances by display, and a caller from outside the session (an
# from hooks, so recover it from the compositor socket when it is missing. # 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 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) 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##*/} [[ -n $socket ]] && export WAYLAND_DISPLAY=${socket##*/}
-140
View File
@@ -1,140 +0,0 @@
#!/bin/bash
# omarchy:summary=Show or jump to tmux windows waiting for attention
# omarchy:args=<show [--json]|focus>
# 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 <show [--json]|focus>" >&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
-9
View File
@@ -81,15 +81,6 @@ set -ag terminal-features "xterm-kitty:extkeys"
set -as terminal-features ",*:clipboard" set -as terminal-features ",*:clipboard"
set -sg escape-time 10 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 # Status bar
set -g status-position top set -g status-position top
set -g status-interval 5 set -g status-interval 5
-1
View File
@@ -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("XF86PowerOff", "Power menu", "omarchy-menu toggle system", { locked = true })
o.bind("SUPER + K", "Show key bindings", "omarchy-menu-keybindings") 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 + 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("XF86Calculator", "Calculator", "gnome-calculator")
o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar") o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar")
-17
View File
@@ -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
-17
View File
@@ -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
+124
View File
@@ -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
@@ -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"])
}
}
@@ -26,11 +26,6 @@
"placeholderText": "Search indicators...", "placeholderText": "Search indicators...",
"emptyText": "No indicators", "emptyText": "No indicators",
"options": [ "options": [
{
"value": "TmuxAlert",
"label": "Tmux alert",
"description": "Tmux windows waiting for attention"
},
{ {
"value": "Dictation", "value": "Dictation",
"label": "Dictation", "label": "Dictation",
+1 -1
View File
@@ -8,7 +8,7 @@ BarWidget {
id: root id: root
moduleName: "omarchy.indicators" 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) readonly property var indicatorEntries: indicatorEntriesFromSettings(settings)
property var activeIndicatorIds: [] property var activeIndicatorIds: []
property var indicatorActiveStates: ({}) property var indicatorActiveStates: ({})
-60
View File
@@ -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()
}
-29
View File
@@ -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
}
}
-14
View File
@@ -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"
}
}
+2 -1
View File
@@ -20,7 +20,8 @@ export PATH="$test_dir/bin:$PATH"
export OMARCHY_PATH="$ROOT" export OMARCHY_PATH="$ROOT"
export XDG_RUNTIME_DIR="$test_dir/run" 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=$(env -u WAYLAND_DISPLAY "$ROOT/bin/omarchy-shell" omarchy.indicators refresh)
[[ $output == "display=[wayland-1]" ]] || fail "shell ipc recovers a missing display" "$output" [[ $output == "display=[wayland-1]" ]] || fail "shell ipc recovers a missing display" "$output"
pass "shell ipc recovers a missing display" pass "shell ipc recovers a missing display"
@@ -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"
-117
View File
@@ -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"
@@ -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"