diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-15 11:25:50 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-15 11:25:50 +0200 |
| commit | 98fc6036ee817a92810e060c80d48aa6a936a1ab (patch) | |
| tree | e05b1b724463de10649180615ad32bb6094f84e0 | |
| parent | e22d2ccee82b829875fe343cf1be48975174b054 (diff) | |
| download | quickshell-98fc6036ee817a92810e060c80d48aa6a936a1ab.tar.gz quickshell-98fc6036ee817a92810e060c80d48aa6a936a1ab.zip | |
feat(status): quiet mode mutes the Claude and opencode sounds
A new registry mode, status.quiet, with a row on the status page. Both tools
will play their sounds through notify-sound, which asks statusctl and plays
nothing while quiet is on, so their popups stay and only the sound goes.
Presentation sets quiet and restores it afterwards, like dnd and nolock: a
sound during a call or a game is what quiet is for.
notify-sound ignores any argument it has no sound for, because the opencode
notifier runs its command on every event, session_started and user_message
included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | desktop/modules/status/README.md | 35 | ||||
| -rw-r--r-- | desktop/modules/status/StatusPage.qml | 16 | ||||
| -rwxr-xr-x | desktop/modules/status/notify-sound | 38 | ||||
| -rwxr-xr-x | desktop/modules/status/statusctl | 2 | ||||
| -rwxr-xr-x | desktop/modules/status/test-notify-sound.sh | 68 | ||||
| -rwxr-xr-x | desktop/modules/status/test-statusctl.sh | 6 | ||||
| -rw-r--r-- | shared/Status.qml | 14 |
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" } |
