Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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; \
Expand Down
6 changes: 6 additions & 0 deletions notify.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 23 additions & 18 deletions notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion panel/MenuBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,17 @@ 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: "")
status.isEnabled = false
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)))
Expand Down
38 changes: 22 additions & 16 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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))
Expand Down
21 changes: 11 additions & 10 deletions panel/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down