diff --git a/bin/omarchy b/bin/omarchy index 7a484eb4..7c1ca018 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -79,6 +79,7 @@ GROUP_DESCRIPTIONS[style]="Global UI style controls" GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers" GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock" 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-tmux-alert b/bin/omarchy-tmux-alert new file mode 100755 index 00000000..88866801 --- /dev/null +++ b/bin/omarchy-tmux-alert @@ -0,0 +1,104 @@ +#!/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 + +# Window names can contain the separator, so they always come last when parsed. +WINDOW_FORMAT='#{window_bell_flag}#{window_activity_flag}#{window_silence_flag}|#{session_name}|#{window_index}|#{window_name}' + +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. +alerted_windows() { + local flags session index name + + 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 +} + +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 +} + +focus() { + local target session index client tty pid + + target=$(alerted_windows | head -n1) + [[ -n $target ]] || return 0 + + IFS='|' read -r session index _ <<<"$target" + + client=$(tmux list-clients -t "$session" -F '#{client_tty}|#{client_pid}' 2>/dev/null | head -n1) + [[ -n $client ]] || client=$(tmux list-clients -F '#{client_tty}|#{client_pid}' 2>/dev/null | head -n1) + + 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 + ;; + *) + usage + ;; +esac diff --git a/config/tmux/tmux.conf b/config/tmux/tmux.conf index d73c42ea..0bfc01f4 100644 --- a/config/tmux/tmux.conf +++ b/config/tmux/tmux.conf @@ -81,6 +81,13 @@ 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-shell -q omarchy.indicators refresh"' +set-hook -g client-session-changed 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' + # 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 b5e02d10..4c2aa40c 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -8,6 +8,7 @@ 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 new file mode 100644 index 00000000..3ca290c5 --- /dev/null +++ b/migrations/1784849592.sh @@ -0,0 +1,17 @@ +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/shell/plugins/bar/indicators/TmuxAlert.qml b/shell/plugins/bar/indicators/TmuxAlert.qml new file mode 100644 index 00000000..64ef8abe --- /dev/null +++ b/shell/plugins/bar/indicators/TmuxAlert.qml @@ -0,0 +1,63 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Ui + +BarIndicator { + id: root + + property int waitingCount: 0 + property string tooltip: "" + + active: waitingCount > 0 + activeText: "󰆍" + inactiveText: "󰆍" + activeTooltipText: tooltip + inactiveTooltipText: "No terminal is waiting" + + function refresh() { + if (!statusProc.running) 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() + } + + Connections { + target: root.indicatorHost + ignoreUnknownSignals: true + 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 = "" + } + } + } + + 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 858e758d..fcd13cf3 100644 --- a/shell/plugins/bar/widgets/Indicators.manifest.json +++ b/shell/plugins/bar/widgets/Indicators.manifest.json @@ -36,6 +36,11 @@ "label": "Reminder", "description": "Queued reminder status" }, + { + "value": "TmuxAlert", + "label": "Tmux alert", + "description": "Tmux windows waiting for attention" + }, { "value": "NightLight", "label": "Night light", diff --git a/shell/plugins/bar/widgets/Indicators.qml b/shell/plugins/bar/widgets/Indicators.qml index 5b3a1f3a..c07d53f3 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: [ "Dnd", "Reminder", "NightLight", "StayAwake", "ScreenRecording", "Dictation" ] + readonly property var defaultIndicatorEntries: [ "Dnd", "Reminder", "TmuxAlert", "NightLight", "StayAwake", "ScreenRecording", "Dictation" ] readonly property var indicatorEntries: indicatorEntriesFromSettings(settings) property var activeIndicatorIds: [] property var indicatorActiveStates: ({}) diff --git a/test/shell.d/tmux-alert-test.sh b/test/shell.d/tmux-alert-test.sh new file mode 100644 index 00000000..a83addab --- /dev/null +++ b/test/shell.d/tmux-alert-test.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +require_command jq + +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" +fi +STUB +chmod +x "$test_dir/tmux" + +export TMUX_STUB_WINDOWS="$test_dir/windows" +export PATH="$test_dir:$PATH" + +cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS' +000|Work|1|editor +100|Work|2|claude +001|Side|1|server|with|pipes +WINDOWS + +output=$("$ROOT/bin/omarchy-tmux-alert" show --json) +expected='{"count":2,"tooltip":"claude (Work:2), server|with|pipes (Side: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|with|pipes (Side:1)" ]] || fail "tmux alert describes alerted windows" "$output" +pass "tmux alert describes alerted windows" + +cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS' +000|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" +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"