From ebcf747285a764ee4613ac5b97f747d8be541b8d Mon Sep 17 00:00:00 2001 From: StuBehan Date: Thu, 30 Apr 2026 22:29:22 +0200 Subject: [PATCH 1/2] fix: make dev watcher catches notify.sh + phrases edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WATCH_DIRS listed notify.sh and phrases/ but the find filter was scoped to *.swift / Info.plist, so shell-script changes never triggered a reload even though `make reload` does propagate them. Also drops `notifier/` from the watch list — its sources were removed in the single-binary refactor; only notifier/Icon.icns is still used at build time. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index b21b5c7..65221d0 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ APP := $(HOME)/Applications/stack-nudge.app APP_LABEL := com.stackonehq.stack-nudge BUILD_LOG := /tmp/stack-nudge-dev.log -WATCH_DIRS := panel shared notifier notify.sh phrases +WATCH_DIRS := panel shared notify.sh phrases .PHONY: help help: @@ -81,7 +81,7 @@ dev: @touch $(WATCH_MARKER); \ while true; do \ sleep 0.5; \ - if find $(WATCH_DIRS) \( -name '*.swift' -o -name 'Info.plist' \) -newer $(WATCH_MARKER) -print -quit 2>/dev/null | grep -q .; then \ + if find $(WATCH_DIRS) \( -name '*.swift' -o -name '*.sh' -o -name 'Info.plist' \) -newer $(WATCH_MARKER) -print -quit 2>/dev/null | grep -q .; then \ touch $(WATCH_MARKER); \ $(MAKE) --no-print-directory reload || true; \ fi; \ From b9286ccbcd3642d8a152bea1d56dc8468a8cb6dd Mon Sep 17 00:00:00 2001 From: StuBehan Date: Thu, 30 Apr 2026 22:29:29 +0200 Subject: [PATCH 2/2] feat: add mute-when-focused toggle Surfaces the existing frontmost-window suppression as a configurable toggle (STACKNUDGE_MUTE_WHEN_FOCUSED, default true to preserve current behaviour). When off, banners + voice fire regardless of which window is focused. Wired through notify.sh, the panel's Settings page (new toggle row in the Toggles section), and the menu-bar dropdown. Co-Authored-By: Claude Opus 4.7 (1M context) --- notify.conf.example | 6 ++++++ notify.sh | 41 +++++++++++++++++++++++------------------ panel/MenuBar.swift | 4 +++- panel/PanelNav.swift | 38 ++++++++++++++++++++++---------------- panel/Settings.swift | 21 +++++++++++---------- 5 files changed, 65 insertions(+), 45 deletions(-) diff --git a/notify.conf.example b/notify.conf.example index f4ff030..c8ba1b6 100644 --- a/notify.conf.example +++ b/notify.conf.example @@ -38,3 +38,9 @@ # (without the .aiff extension). Defaults: Glass for stop, Ping for permission. #STACKNUDGE_SOUND_STOP=Glass #STACKNUDGE_SOUND_PERMISSION=Ping + +# Suppress banner + voice when the source window is already frontmost. A +# soft chime still plays so you know the agent finished. Set to false to +# always notify regardless of focus. +# Default: true +#STACKNUDGE_MUTE_WHEN_FOCUSED=false diff --git a/notify.sh b/notify.sh index 5a651b2..5a8a626 100755 --- a/notify.sh +++ b/notify.sh @@ -401,25 +401,30 @@ notify_macos() { -e "end tell" 2>/dev/null) fi - # Suppress banner only if the exact source window is currently frontmost - local frontmost_id - frontmost_id=$(osascript -e "id of app (path to frontmost application as text)" 2>/dev/null) - if [[ "$frontmost_id" == "$bundle_id" && -n "$process_name" && -n "$win_title" ]]; then - local frontmost_win - frontmost_win=$(osascript \ - -e "tell application \"System Events\"" \ - -e " tell process \"${process_name}\"" \ - -e " get title of window 1" \ - -e " end tell" \ - -e "end tell" 2>/dev/null) - if [[ "$frontmost_win" == "$win_title" ]]; then - # Source window is already focused — minimal signal. Skip sound when - # voice is on (voice itself is suppressed here too, but keep the - # "voice replaces sound" rule consistent across all paths). - if [[ "${VOICE_ENABLED}" != "true" ]]; then - afplay "/System/Library/Sounds/${sound}.aiff" 2>/dev/null + # Suppress banner only if the exact source window is currently frontmost. + # Gated on STACKNUDGE_MUTE_WHEN_FOCUSED — set to false to always notify + # regardless of which window has focus. + local mute_when_focused="${STACKNUDGE_MUTE_WHEN_FOCUSED:-true}" + if [[ "$mute_when_focused" == "true" ]]; then + local frontmost_id + frontmost_id=$(osascript -e "id of app (path to frontmost application as text)" 2>/dev/null) + if [[ "$frontmost_id" == "$bundle_id" && -n "$process_name" && -n "$win_title" ]]; then + local frontmost_win + frontmost_win=$(osascript \ + -e "tell application \"System Events\"" \ + -e " tell process \"${process_name}\"" \ + -e " get title of window 1" \ + -e " end tell" \ + -e "end tell" 2>/dev/null) + if [[ "$frontmost_win" == "$win_title" ]]; then + # Source window is already focused — minimal signal. Skip sound when + # voice is on (voice itself is suppressed here too, but keep the + # "voice replaces sound" rule consistent across all paths). + if [[ "${VOICE_ENABLED}" != "true" ]]; then + afplay "/System/Library/Sounds/${sound}.aiff" 2>/dev/null + fi + return fi - return fi fi diff --git a/panel/MenuBar.swift b/panel/MenuBar.swift index 1df41ee..e68ba03 100644 --- a/panel/MenuBar.swift +++ b/panel/MenuBar.swift @@ -112,6 +112,7 @@ final class MenuBarController: NSObject, NSMenuDelegate { let config = ConfigFile.read() let banner = ConfigFile.bool(config, "STACKNUDGE_BANNER", default: true) let voice = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false) + let mute = ConfigFile.bool(config, "STACKNUDGE_MUTE_WHEN_FOCUSED", default: true) let hotkey = config["STACKNUDGE_PANEL_HOTKEY"] ?? "cmd+shift+n" let status = NSMenuItem(title: "Hotkey · \(hotkey)", action: nil, keyEquivalent: "") @@ -119,8 +120,9 @@ final class MenuBarController: NSObject, NSMenuDelegate { menu.addItem(status) menu.addItem(.separator()) - menu.addItem(toggle("Show banners", state: banner, key: "STACKNUDGE_BANNER")) + menu.addItem(toggle("Show banners", state: banner, key: "STACKNUDGE_BANNER")) menu.addItem(toggle("Voice notifications", state: voice, key: "STACKNUDGE_VOICE")) + menu.addItem(toggle("Mute when focused", state: mute, key: "STACKNUDGE_MUTE_WHEN_FOCUSED")) menu.addItem(.separator()) menu.addItem(action("Show panel", #selector(showPanelAction))) diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 250e27c..cfd6c90 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -30,6 +30,7 @@ final class PanelNav: ObservableObject { @Published var hotkeyError: String? @Published var bannerEnabled: Bool = true @Published var voiceEnabled: Bool = false + @Published var muteWhenFocused: Bool = true @Published var panelPinned: Bool = true @Published var soundStop: String = "Glass" @Published var soundPermission: String = "Ping" @@ -69,21 +70,22 @@ final class PanelNav: ObservableObject { "I'd love your input on this.", ] - var rowCount: Int { 11 } + var rowCount: Int { 12 } // Row layout (kept in one place so the controller, view, and indexing // logic all agree on what each row index means): // 0 Hotkey hotkey-record // 1 Banner notifications toggle // 2 Voice notifications toggle - // 3 Pin panel toggle - // 4 Agent done sound cycle - // 5 Permission sound cycle - // 6 Voice cycle - // 7 Speed cycle - // 8 Check permissions… action - // 9 Open config file… action - // 10 Quit panel action + // 3 Mute when focused toggle + // 4 Pin panel toggle + // 5 Agent done sound cycle + // 6 Permission sound cycle + // 7 Voice cycle + // 8 Speed cycle + // 9 Check permissions… action + // 10 Open config file… action + // 11 Quit panel action // MARK: - Disk I/O @@ -92,6 +94,7 @@ final class PanelNav: ObservableObject { hotkeyDisplay = config["STACKNUDGE_PANEL_HOTKEY"] ?? "cmd+opt+n" bannerEnabled = ConfigFile.bool(config, "STACKNUDGE_BANNER", default: true) voiceEnabled = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false) + muteWhenFocused = ConfigFile.bool(config, "STACKNUDGE_MUTE_WHEN_FOCUSED", default: true) panelPinned = ConfigFile.bool(config, "STACKNUDGE_PANEL_PIN", default: true) soundStop = config["STACKNUDGE_SOUND_STOP"] ?? "Glass" soundPermission = config["STACKNUDGE_SOUND_PERMISSION"] ?? "Ping" @@ -146,9 +149,9 @@ final class PanelNav: ObservableObject { func activate() { switch selectedSettingIndex { case 0: startRecordingHotkey() - case 8: actions?.checkPermissions() - case 9: actions?.openConfig() - case 10: actions?.quit() + case 9: actions?.checkPermissions() + case 10: actions?.openConfig() + case 11: actions?.quit() default: applyCycle(forward: true) } } @@ -168,18 +171,21 @@ final class PanelNav: ObservableObject { voiceEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_VOICE", value: voiceEnabled ? "true" : "false") case 3: + muteWhenFocused.toggle() + ConfigFile.write(key: "STACKNUDGE_MUTE_WHEN_FOCUSED", value: muteWhenFocused ? "true" : "false") + case 4: panelPinned.toggle() ConfigFile.write(key: "STACKNUDGE_PANEL_PIN", value: panelPinned ? "true" : "false") - case 4: - soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 5: - soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 6: + soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + case 7: guard !voicesLoading, !voicesAvailable.isEmpty else { return } voice = step(voice, in: voicesAvailable, forward: forward, key: "STACKNUDGE_VOICE_NAME", preview: false) let phrase = Self.voicePreviewPhrases.randomElement() ?? "Hello." Speaker.speak(phrase, voice: voice, speed: String(format: "%.2f", voiceSpeed)) - case 7: + case 8: let next = forward ? voiceSpeed + Self.speedStep : voiceSpeed - Self.speedStep voiceSpeed = max(Self.speedMin, min(Self.speedMax, (next * 100).rounded() / 100)) ConfigFile.write(key: "STACKNUDGE_VOICE_SPEED", value: String(format: "%.2f", voiceSpeed)) diff --git a/panel/Settings.swift b/panel/Settings.swift index 67eb260..db9543d 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -28,25 +28,26 @@ struct SettingsView: View { } section("Toggles") { - row(1, label: "Banner notifications", kind: .toggle, value: nav.bannerEnabled ? "On" : "Off") - row(2, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") - row(3, label: "Pin panel", kind: .toggle, value: nav.panelPinned ? "On" : "Off") + row(1, label: "Banner notifications", kind: .toggle, value: nav.bannerEnabled ? "On" : "Off") + row(2, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") + row(3, label: "Mute when focused", kind: .toggle, value: nav.muteWhenFocused ? "On" : "Off") + row(4, label: "Pin panel", kind: .toggle, value: nav.panelPinned ? "On" : "Off") } section("Sounds") { - row(4, label: "Agent done", kind: .cycle, value: nav.soundStop) - row(5, label: "Permission", kind: .cycle, value: nav.soundPermission) + row(5, label: "Agent done", kind: .cycle, value: nav.soundStop) + row(6, label: "Permission", kind: .cycle, value: nav.soundPermission) } section("Voice") { - row(6, label: "Voice", kind: .cycle, value: voiceLabel) - row(7, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) + row(7, label: "Voice", kind: .cycle, value: voiceLabel) + row(8, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed)) } section("Actions") { - row(8, label: "Check permissions…", kind: .action, value: "") - row(9, label: "Open config file…", kind: .action, value: "") - row(10, label: "Quit panel", kind: .action, value: "") + row(9, label: "Check permissions…", kind: .action, value: "") + row(10, label: "Open config file…", kind: .action, value: "") + row(11, label: "Quit panel", kind: .action, value: "") } } .padding(.horizontal, 14)