From 98fc6036ee817a92810e060c80d48aa6a936a1ab Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 15 Oct 2026 11:25:50 +0200 Subject: 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 --- desktop/modules/status/README.md | 35 ++++++++++++--- desktop/modules/status/StatusPage.qml | 16 ++++++- desktop/modules/status/notify-sound | 38 ++++++++++++++++ desktop/modules/status/statusctl | 2 +- desktop/modules/status/test-notify-sound.sh | 68 +++++++++++++++++++++++++++++ desktop/modules/status/test-statusctl.sh | 6 +++ 6 files changed, 156 insertions(+), 9 deletions(-) create mode 100755 desktop/modules/status/notify-sound create mode 100755 desktop/modules/status/test-notify-sound.sh (limited to 'desktop/modules/status') 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 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. +# +# 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 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. +# +# 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 ]] -- cgit v1.2.3