aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/modules/status/README.md35
-rw-r--r--desktop/modules/status/StatusPage.qml16
-rwxr-xr-xdesktop/modules/status/notify-sound38
-rwxr-xr-xdesktop/modules/status/statusctl2
-rwxr-xr-xdesktop/modules/status/test-notify-sound.sh68
-rwxr-xr-xdesktop/modules/status/test-statusctl.sh6
-rw-r--r--shared/Status.qml14
7 files changed, 169 insertions, 10 deletions
diff --git a/desktop/modules/status/README.md b/desktop/modules/status/README.md
index b25505f..d3d0cce 100644
--- a/desktop/modules/status/README.md
+++ b/desktop/modules/status/README.md
@@ -1,7 +1,7 @@
# status
-Desktop modes as state: `dnd`, `presentation` and `nolock`, owned by the
-`Status` singleton and stored as files under `$XDG_RUNTIME_DIR`. A fourth,
+Desktop modes as state: `dnd`, `presentation`, `nolock` and `quiet`, owned by
+the `Status` singleton and stored as files under `$XDG_RUNTIME_DIR`. A fifth,
`status.gaming`, is written only by the shell's game detector; it is not a
mode `statusctl` exposes.
@@ -10,6 +10,7 @@ mode `statusctl` exposes.
$XDG_RUNTIME_DIR/status.dnd
$XDG_RUNTIME_DIR/status.presentation
$XDG_RUNTIME_DIR/status.nolock
+ $XDG_RUNTIME_DIR/status.quiet
$XDG_RUNTIME_DIR/status.gaming
Each holds `0` or `1`; a missing file means off. That directory is tmpfs, so a
@@ -43,10 +44,10 @@ and reasserted when it returns.
`dnd` has none of its own. It is state the notification daemon reads.
-`presentation` sets `dnd` and `nolock`, asserts a Wayland idle inhibitor, and
-pauses breaktimer. Turning it off restores `dnd` and `nolock` to the values
-they had before rather than clearing them, so hand-set DND or a hand-set
-disable survives a presentation.
+`presentation` sets `dnd`, `nolock` and `quiet`, asserts a Wayland idle
+inhibitor, and pauses breaktimer. Turning it off restores the three to the
+values they had before rather than clearing them, so hand-set DND or a
+hand-set disable survives a presentation.
`nolock` disables auto-lock by holding the idle inhibitor. That is the only
thing that stops hypridle's `loginctl lock-session`, so the mode governs
@@ -56,6 +57,20 @@ for a number of minutes, in which case the singleton's timer re-enables it.
The timer is shell-lifetime: a restart during a timed disable leaves the lock
off until it is toggled, the same class of limit as `dndBeforePresentation`.
+`quiet` mutes the Claude and opencode sounds and leaves their popups alone.
+The shell does nothing with it: both tools play through `notify-sound`, which
+asks `statusctl quiet get` and plays nothing on a 1. `~/bin/notify-sound` is a
+symlink to the repo copy, like `statusctl`.
+
+ notify-sound claude Claude Code's Notification hook
+ notify-sound <event> opencode-notifier's command hook
+
+The opencode notifier runs its command for every event, `session_started` and
+`user_message` included, so an argument with no sound plays nothing; only
+`permission`, `complete`, `error` and `question` do. The notifier's own sounds
+are off in `opencode-notifier.json`, and it rereads that file on every event,
+so neither change needs an opencode restart.
+
breaktimer is paused and resumed by verb, never by writing its state file. See
Breaktimer below, which is also the read direction.
@@ -140,10 +155,16 @@ still allowed, since the manual half is worth setting for when the game exits.
The watch therefore follows `status.gaming` as well as its own file, and emits
only on a real change so an unchanged rewrite draws nothing.
-## The check
+## The checks
./test-statusctl.sh
+ ./test-notify-sound.sh
Points `XDG_RUNTIME_DIR` at a temporary directory, so it never touches live
modes. Covers the file format, the atomic write, the toggle, the unknown-mode
error, both the activated report and the absent-file off report.
+
+`test-notify-sound.sh` puts a fake `paplay` first on `PATH` and a temporary
+`XDG_RUNTIME_DIR` under it, so it plays nothing and leaves the live mode
+alone. It covers the file per argument, silence for an event with no sound,
+and silence while `quiet` is on.
diff --git a/desktop/modules/status/StatusPage.qml b/desktop/modules/status/StatusPage.qml
index 7da7a0d..e0de86b 100644
--- a/desktop/modules/status/StatusPage.qml
+++ b/desktop/modules/status/StatusPage.qml
@@ -37,7 +37,7 @@ Column {
width: page.width
mode: "presentation"
label: "Presentation"
- description: "Do not disturb, no screen lock, breaktimer paused."
+ description: "Do not disturb, quiet, no screen lock, breaktimer paused."
value: Status.presentation
}
@@ -47,6 +47,20 @@ Column {
color: Qt.alpha(Theme.text, 0.08)
}
+ StatusRow {
+ width: page.width
+ mode: "quiet"
+ label: "Quiet"
+ description: "Mutes Claude and opencode sounds. Popups still appear."
+ value: Status.quiet
+ }
+
+ Rectangle {
+ width: page.width
+ height: 1
+ color: Qt.alpha(Theme.text, 0.08)
+ }
+
LockRow {
width: page.width
}
diff --git a/desktop/modules/status/notify-sound b/desktop/modules/status/notify-sound
new file mode 100755
index 0000000..c028b3e
--- /dev/null
+++ b/desktop/modules/status/notify-sound
@@ -0,0 +1,38 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The one place Claude and opencode play their sounds, so quiet mode can
+# silence both. Their popups do not come through here and are unaffected.
+#
+# notify-sound claude Claude Code's Notification hook
+# notify-sound <opencode-notifier event> the notifier's command hook
+#
+# An argument with no sound plays nothing. That is deliberate: the opencode
+# notifier runs its command for every event, session_started and
+# user_message included, and only four of them ever made a sound.
+
+set -u
+
+here="$(dirname "$(readlink -f "$0")")"
+[[ "$("$here/statusctl" quiet get)" == "1" ]] && exit 0
+
+dir="${NOTIFY_SOUND_DIR:-$HOME/Music/notify}"
+
+case "${1:-}" in
+ claude) file="claude-attention.opus" ;;
+ permission) file="opencode-perms.opus" ;;
+ complete|error|question) file="opencode-attention.opus" ;;
+ *) exit 0 ;;
+esac
+
+exec paplay "$dir/$file" 2>/dev/null
diff --git a/desktop/modules/status/statusctl b/desktop/modules/status/statusctl
index 87c2cac..718ee39 100755
--- a/desktop/modules/status/statusctl
+++ b/desktop/modules/status/statusctl
@@ -25,7 +25,7 @@
set -u
-MODES="dnd presentation nolock"
+MODES="dnd presentation nolock quiet"
DIR="${XDG_RUNTIME_DIR:-/tmp}"
usage() {
diff --git a/desktop/modules/status/test-notify-sound.sh b/desktop/modules/status/test-notify-sound.sh
new file mode 100755
index 0000000..9571d4c
--- /dev/null
+++ b/desktop/modules/status/test-notify-sound.sh
@@ -0,0 +1,68 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The one runnable check for notify-sound. A fake paplay first on PATH
+# records what it was asked to play, and XDG_RUNTIME_DIR is a temporary
+# directory, so no sound plays and the live quiet mode is untouched.
+#
+# Usage: ./test-notify-sound.sh (exit 0 = all passed)
+
+set -u
+
+here="$(cd "$(dirname "$0")" && pwd)"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+export XDG_RUNTIME_DIR="$tmp"
+export NOTIFY_SOUND_DIR="/sounds"
+mkdir "$tmp/bin"
+printf '#!/bin/sh\nprintf "%%s\\n" "$1" >> "%s/played"\n' "$tmp" > "$tmp/bin/paplay"
+chmod +x "$tmp/bin/paplay"
+export PATH="$tmp/bin:$PATH"
+
+pass=0
+fail=0
+
+check() {
+ local label="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ printf 'ok %s\n' "$label"
+ pass=$((pass + 1))
+ else
+ printf 'FAIL %s: want %q, got %q\n' "$label" "$want" "$got"
+ fail=$((fail + 1))
+ fi
+}
+
+play() {
+ rm -f "$tmp/played"
+ "$here/notify-sound" "$@"
+ cat "$tmp/played" 2>/dev/null
+}
+
+check "claude" "/sounds/claude-attention.opus" "$(play claude)"
+check "permission" "/sounds/opencode-perms.opus" "$(play permission)"
+check "complete" "/sounds/opencode-attention.opus" "$(play complete)"
+check "error" "/sounds/opencode-attention.opus" "$(play error)"
+check "question" "/sounds/opencode-attention.opus" "$(play question)"
+check "an event with no sound" "" "$(play session_started)"
+check "no argument" "" "$(play)"
+
+"$here/statusctl" quiet set 1
+check "quiet mutes claude" "" "$(play claude)"
+check "quiet mutes permission" "" "$(play permission)"
+
+"$here/statusctl" quiet set 0
+check "quiet off plays again" "/sounds/claude-attention.opus" "$(play claude)"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]
diff --git a/desktop/modules/status/test-statusctl.sh b/desktop/modules/status/test-statusctl.sh
index afe56d4..170101c 100755
--- a/desktop/modules/status/test-statusctl.sh
+++ b/desktop/modules/status/test-statusctl.sh
@@ -149,5 +149,11 @@ kill "$watcher5" 2>/dev/null
wait "$watcher5" 2>/dev/null
check "first write, created by link, redraws" "1" "$(grep -c '"class": *"gaming"' "$out5")"
+# quiet is a plain mode like dnd.
+"$ctl" quiet set 1
+check "quiet set 1" "1" "$("$ctl" quiet get)"
+"$ctl" quiet toggle
+check "quiet toggle from 1" "0" "$("$ctl" quiet get)"
+
printf '\n%d passed, %d failed\n' "$pass" "$fail"
[[ "$fail" -eq 0 ]]
diff --git a/shared/Status.qml b/shared/Status.qml
index 128256c..523ea7d 100644
--- a/shared/Status.qml
+++ b/shared/Status.qml
@@ -39,6 +39,10 @@ Singleton {
// Stored as "auto-lock disabled", so the missing-file default of off is
// the wanted default of locking on. The row inverts it for the label.
readonly property bool nolock: nolockFile.value
+ // Mutes the Claude and opencode sounds; their popups are unaffected. The
+ // sound scripts read the file through statusctl, so this is only the
+ // drawer's view of it.
+ readonly property bool quiet: quietFile.value
// Breaktimer, read only. The daemon publishes these three files and owns
// them; the shell calls verbs and never writes them, because the daemon
@@ -58,7 +62,7 @@ Singleton {
readonly property bool btPaused: root.btState === "paused"
readonly property int activeCount: (root.dnd ? 1 : 0) + (root.presentation ? 1 : 0)
- + (root.nolock ? 1 : 0)
+ + (root.nolock ? 1 : 0) + (root.quiet ? 1 : 0)
// Set once by shell.qml. IdleInhibitor does nothing with a null window,
// and the singleton has no window of its own to offer.
@@ -74,6 +78,7 @@ Singleton {
// recorded prior values depend on which file loads first.
property bool dndBeforePresentation: false
property bool nolockBeforePresentation: false
+ property bool quietBeforePresentation: false
// Deadline for a timed disable, owned by the singleton rather than the row
// because the drawer page (and so the row) is destroyed on close while the
@@ -91,6 +96,8 @@ Singleton {
// Any write clears a pending deadline; the timed path sets it back.
root.nolockUntil = 0;
nolockFile.write(on);
+ } else if (name === "quiet") {
+ quietFile.write(on);
}
}
@@ -106,6 +113,7 @@ Singleton {
if (name === "dnd") root.setMode("dnd", !root.dnd);
else if (name === "presentation") root.setMode("presentation", !root.presentation);
else if (name === "nolock") root.setMode("nolock", !root.nolock);
+ else if (name === "quiet") root.setMode("quiet", !root.quiet);
}
// The detector calls this every poll; a write on an unchanged value would
@@ -132,14 +140,17 @@ Singleton {
if (root.presentation) {
root.dndBeforePresentation = root.dnd;
root.nolockBeforePresentation = root.nolock;
+ root.quietBeforePresentation = root.quiet;
root.setMode("dnd", true);
root.setMode("nolock", true);
+ root.setMode("quiet", true);
// pause/resume write the state file whether or not a daemon
// runs, so send the verb only when one exists.
if (root.btRunning) root.runBreaktimer("pause");
} else {
root.setMode("dnd", root.dndBeforePresentation);
root.setMode("nolock", root.nolockBeforePresentation);
+ root.setMode("quiet", root.quietBeforePresentation);
if (root.btRunning) root.runBreaktimer("resume");
}
}
@@ -303,6 +314,7 @@ Singleton {
ModeFile { id: dndFile; path: root.dir + "/status.dnd" }
ModeFile { id: presFile; path: root.dir + "/status.presentation" }
ModeFile { id: nolockFile; path: root.dir + "/status.nolock" }
+ ModeFile { id: quietFile; path: root.dir + "/status.quiet" }
// Written only by the game detector above, never by statusctl.
ModeFile { id: gamingFile; path: root.dir + "/status.gaming" }