From 7f6e55dccdea6ba9da144d575088f1bf21939c90 Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 30 Apr 2026 11:18:07 +0200 Subject: [PATCH 1/5] refactor: merge into single persistent binary using UNUserNotificationCenter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace two-binary setup (stack-nudge.app + stack-nudge-panel.app) with a single persistent stack-nudge.app built from panel/ sources - Switch from deprecated NSUserNotification to UNUserNotificationCenter for banner delivery — proper permission dialog, alert-style banners, Allow action button for permission events - Remove fire_banner() and find_app_bundle() from notify.sh; all events now route through the socket to the persistent app - App hides itself before calling AppActivator on banner click so focus reverts to the previous app before window targeting runs - Remove stale delivered notifications on each new event to prevent pile-up - Single launchd agent (com.stackonehq.stack-nudge) replaces the panel launcher script + panel agent - Update Info.plist bundle ID to com.stackonehq.stack-nudge - Add bannerEnabled to PanelConfig; add onAppend callback to EventStore Co-Authored-By: Claude Sonnet 4.6 (1M context) --- build.sh | 22 ++-- install.sh | 43 ++++---- notify.sh | 81 +++------------ panel/Config.swift | 2 + panel/EventStore.swift | 4 + panel/Info.plist | 8 +- panel/Panel.swift | 86 +++++++++++++++- ui_improvements.md | 226 +++++++++++++++++++++++++++++++++++++++++ uninstall.sh | 8 +- 9 files changed, 364 insertions(+), 116 deletions(-) create mode 100644 ui_improvements.md diff --git a/build.sh b/build.sh index d320feb..b0f53f3 100755 --- a/build.sh +++ b/build.sh @@ -1,12 +1,11 @@ #!/usr/bin/env bash -# Builds stack-nudge.app and stack-nudge-panel.app +# Builds stack-nudge.app (single persistent binary: panel + banners + voice) # Usage: ./build.sh [arm64|x86_64] (defaults to host arch) set -e ARCH="${1:-$(uname -m)}" -NOTIFIER_APP="build/stack-nudge.app" -PANEL_APP="build/stack-nudge-panel.app" +APP="build/stack-nudge.app" build_app() { local app="$1" @@ -37,16 +36,7 @@ build_app() { echo "Building stack-nudge ($ARCH)..." rm -rf build -build_app "$NOTIFIER_APP" "stack-nudge" \ - "notifier/Info.plist" "notifier/Icon.icns" "12.0" \ - notifier/main.swift \ - notifier/Config.swift \ - notifier/Notifier.swift \ - shared/AppActivator.swift \ - -framework Foundation -framework AppKit -echo " Built $NOTIFIER_APP" - -build_app "$PANEL_APP" "stack-nudge-panel" \ +build_app "$APP" "stack-nudge" \ "panel/Info.plist" "notifier/Icon.icns" "13.0" \ panel/main.swift \ panel/Config.swift \ @@ -63,5 +53,7 @@ build_app "$PANEL_APP" "stack-nudge-panel" \ panel/SessionStore.swift \ panel/Sessions.swift \ shared/AppActivator.swift \ - -framework Foundation -framework AppKit -framework SwiftUI -framework Carbon -echo " Built $PANEL_APP" + -framework Foundation -framework AppKit -framework SwiftUI -framework Carbon \ + -framework UserNotifications +echo " Built $APP" +echo " Binary: $(file "$APP/Contents/MacOS/stack-nudge")" diff --git a/install.sh b/install.sh index b2b491e..27679b6 100755 --- a/install.sh +++ b/install.sh @@ -11,17 +11,15 @@ echo "Installing stack-nudge..." mkdir -p "$INSTALL_DIR" -# Build and install the native app bundles (macOS click-to-focus banners + panel) +# Build and install the native app bundle (single persistent binary) if [[ "$(uname -s)" == "Darwin" ]]; then echo "" - echo "Building stack-nudge.app + stack-nudge-panel.app..." + echo "Building stack-nudge.app..." bash "$SCRIPT_DIR/build.sh" >/dev/null rm -rf "$HOME/Applications/stack-nudge.app" - rm -rf "$HOME/Applications/stack-nudge-panel.app" - cp -r "$SCRIPT_DIR/build/stack-nudge.app" "$HOME/Applications/stack-nudge.app" - cp -r "$SCRIPT_DIR/build/stack-nudge-panel.app" "$HOME/Applications/stack-nudge-panel.app" - echo " Installed stack-nudge.app -> ~/Applications/stack-nudge.app" - echo " Installed stack-nudge-panel.app -> ~/Applications/stack-nudge-panel.app" + rm -rf "$HOME/Applications/stack-nudge-panel.app" # clean up old panel binary + cp -r "$SCRIPT_DIR/build/stack-nudge.app" "$HOME/Applications/stack-nudge.app" + echo " Installed stack-nudge.app -> ~/Applications/stack-nudge.app" fi # Pick a Python ≥ 3.10 for the venv. stackvox requires it, but `python3` on @@ -119,23 +117,20 @@ if [[ "$(uname -s)" == "Darwin" ]]; then "${VENV}/bin/stackvox" "serve" echo " Voice daemon registered as launchd agent (starts at login)" - # Panel launcher is config-aware: exits 0 when STACKNUDGE_PANEL isn't enabled. - # KeepAlive=on_crash lets launchd respect that exit and not loop. - PANEL_LAUNCHER="$INSTALL_DIR/panel-launcher.sh" - cat > "$PANEL_LAUNCHER" <<'LAUNCHER' -#!/usr/bin/env bash -[[ -f "$HOME/.stack-nudge/config" ]] && source "$HOME/.stack-nudge/config" -[[ "${STACKNUDGE_PANEL:-false}" != "true" ]] && exit 0 -exec "$HOME/Applications/stack-nudge-panel.app/Contents/MacOS/stack-nudge-panel" -LAUNCHER - chmod +x "$PANEL_LAUNCHER" - + # Single persistent app — always running, restarts on crash. register_launchd_agent \ - "com.stackonehq.stack-nudge-panel" \ - "on_crash" \ - "${INSTALL_DIR}/panel.log" \ - "${PANEL_LAUNCHER}" - echo " Panel daemon registered as launchd agent (starts at login when STACKNUDGE_PANEL=true)" + "com.stackonehq.stack-nudge" \ + "always" \ + "${INSTALL_DIR}/app.log" \ + "$HOME/Applications/stack-nudge.app/Contents/MacOS/stack-nudge" + echo " App registered as launchd agent (starts at login)" + + # Remove old panel launchd agent if upgrading from two-binary setup + OLD_PANEL_PLIST="$HOME/Library/LaunchAgents/com.stackonehq.stack-nudge-panel.plist" + if [[ -f "$OLD_PANEL_PLIST" ]]; then + launchctl unload "$OLD_PANEL_PLIST" 2>/dev/null || true + rm -f "$OLD_PANEL_PLIST" + fi fi # Copy notify.sh and the phrase pools (sourced by notify.sh at runtime @@ -238,6 +233,6 @@ echo "" echo "Config: edit ~/.stack-nudge/config to customise behaviour." echo " STACKNUDGE_VOICE=true — speak notifications aloud" echo " STACKNUDGE_ACTIVATE_IMMEDIATELY=true — focus your editor without clicking" -echo " STACKNUDGE_PANEL=true — keyboard-native floating panel (cmd+shift+n)" echo " STACKNUDGE_BANNER=false — suppress macOS banner notifications" +echo " STACKNUDGE_PANEL_HOTKEY=cmd+opt+n — global hotkey for the floating panel" echo "To uninstall, run: ./uninstall.sh" diff --git a/notify.sh b/notify.sh index 2ffe30b..f2c1745 100755 --- a/notify.sh +++ b/notify.sh @@ -186,9 +186,6 @@ voice_phrase_for() { printf "$template" "$repo" } -# Banner and panel surfaces are independent; sound/voice always fire. -BANNER_ENABLED="${STACKNUDGE_BANNER:-true}" -PANEL_ENABLED="${STACKNUDGE_PANEL:-false}" PANEL_SOCK="${HOME}/.stack-nudge/panel.sock" # Pretty-print the agent name for the notification title @@ -227,27 +224,12 @@ speak_notification() { # own directory, and the repo build/ output (for in-tree development). # Args: app-bundle-name (e.g. "stack-nudge.app") # Echoes the first match, empty string if none found. -find_app_bundle() { - local name="$1" - for candidate in \ - "$HOME/Applications/$name" \ - "$(dirname "$0")/$name" \ - "$(dirname "$0")/build/$name"; do - if [[ -d "$candidate" ]]; then - printf '%s' "$candidate" - return - fi - done -} - -ensure_panel_running() { - [[ "${PANEL_ENABLED}" != "true" ]] && return +ensure_app_running() { [[ -S "$PANEL_SOCK" ]] && return - local panel_app - panel_app=$(find_app_bundle "stack-nudge-panel.app") - [[ -z "$panel_app" ]] && return - # -g: launch in the background, don't bring the panel app to foreground - open -ga "$panel_app" 2>/dev/null + local app_path="$HOME/Applications/stack-nudge.app" + [[ ! -d "$app_path" ]] && return + # -g: launch in the background, don't steal focus from the editor + open -ga "$app_path" 2>/dev/null for _ in 1 2 3 4 5 6 7 8 9 10; do [[ -S "$PANEL_SOCK" ]] && return sleep 0.1 @@ -289,8 +271,7 @@ walk_session_chain() { # we have ~15 fields. # Args: title message bundle_id window_title has_action(true|false) post_to_panel() { - [[ "${PANEL_ENABLED}" != "true" ]] && return - ensure_panel_running + ensure_app_running [[ ! -S "$PANEL_SOCK" ]] && return walk_session_chain @@ -434,58 +415,20 @@ notify_macos() { local has_action="false" [[ "${EVENT}" == "permission" ]] && has_action="true" - # Post first so the panel has the event queued by the time the sound plays. - # Backgrounded — the python3 cold-start (~50ms) shouldn't block the agent's hook. + # Post to the persistent app — it handles both the panel history and the + # UNUserNotification banner based on the user's config. Backgrounded so + # Python startup (~50ms) doesn't block the agent hook. post_to_panel "${title}" "${message}" "${bundle_id}" "${project_name:-}" "${has_action}" & - # Voice "replaces" sound — when both are configured, the spoken message - # is the audible signal so we don't double-cue with a chime. - local effective_sound="$sound" - [[ "${VOICE_ENABLED}" == "true" ]] && effective_sound="" - - if [[ "${BANNER_ENABLED}" == "true" ]]; then - fire_banner "$title" "$message" "$effective_sound" "$bundle_id" \ - "${project_name:-}" "${win_title}" "${has_action}" - elif [[ "${VOICE_ENABLED}" != "true" ]]; then + # Sound fires independently via afplay — guaranteed even if macOS throttles + # or the app isn't running yet. Voice replaces the chime when enabled. + if [[ "${VOICE_ENABLED}" != "true" ]]; then afplay "/System/Library/Sounds/${sound}.aiff" 2>/dev/null & fi speak_notification "${voice_message}" } -fire_banner() { - local title="$1" message="$2" sound="$3" bundle_id="$4" - local project_name="$5" win_title="$6" has_action="$7" - - local app_bundle - app_bundle=$(find_app_bundle "stack-nudge.app") - - if [[ -z "$app_bundle" ]]; then - # Fallback path when stack-nudge.app isn't installed. `sound` is empty - # when voice is enabled — skip both the afplay chime and osascript's - # sound name so the user hears voice only. - if [[ -n "$sound" ]]; then - afplay "/System/Library/Sounds/${sound}.aiff" 2>/dev/null & - osascript -e "display notification \"${message}\" with title \"${title}\" sound name \"${sound}\"" 2>/dev/null - else - osascript -e "display notification \"${message}\" with title \"${title}\"" 2>/dev/null - fi - return - fi - - local open_args=( - --args - --title "${title}" --message "${message}" - --sound "${sound}" --activate "${bundle_id}" - ) - [[ "${ACTIVATE_IMMEDIATELY}" == "true" ]] && open_args+=(--activate-immediately) - [[ -n "$win_title" ]] && open_args+=(--window-title "${project_name}") - [[ -n "${VSCODE_IPC_HOOK_CLI}" ]] && open_args+=(--ipc-hook "${VSCODE_IPC_HOOK_CLI}") - open_args+=(--project-path "${PWD}") - [[ "${has_action}" == "true" ]] && open_args+=(--has-action-button) - - open -a "$app_bundle" "${open_args[@]}" -} play_linux() { local sound_complete="/usr/share/sounds/freedesktop/stereo/complete.oga" diff --git a/panel/Config.swift b/panel/Config.swift index 4eac937..5d1aeb9 100644 --- a/panel/Config.swift +++ b/panel/Config.swift @@ -5,6 +5,7 @@ import Foundation // notify.sh shell-sources it; we just need the subset relevant to the panel. struct PanelConfig { var hotkeySpec: String = "cmd+opt+n" + var bannerEnabled: Bool = true static func load() -> PanelConfig { var config = PanelConfig() @@ -21,6 +22,7 @@ struct PanelConfig { .trimmingCharacters(in: .whitespaces)) switch key { case "STACKNUDGE_PANEL_HOTKEY": config.hotkeySpec = value + case "STACKNUDGE_BANNER": config.bannerEnabled = value.lowercased() != "false" default: break } } diff --git a/panel/EventStore.swift b/panel/EventStore.swift index ab372e4..9641169 100644 --- a/panel/EventStore.swift +++ b/panel/EventStore.swift @@ -68,12 +68,16 @@ final class EventStore: ObservableObject { private let maxEvents = 5 + /// Called on main queue after each new event is inserted. + var onAppend: ((NudgeEvent) -> Void)? + func append(_ event: NudgeEvent) { events.insert(event, at: 0) if events.count > maxEvents { events = Array(events.prefix(maxEvents)) } if selectedID != event.id { selectedID = event.id } + onAppend?(event) if ProcessInfo.processInfo.environment["STACKNUDGE_PANEL_DEBUG"] != nil { FileHandle.standardError.write(Data( "panel: received \(event.agent)/\(event.kind.rawValue): \(event.message)\n".utf8)) diff --git a/panel/Info.plist b/panel/Info.plist index febe1f0..1190db4 100644 --- a/panel/Info.plist +++ b/panel/Info.plist @@ -3,11 +3,11 @@ CFBundleIdentifier - com.stackonehq.stack-nudge-panel + com.stackonehq.stack-nudge CFBundleName - stack-nudge-panel + stack-nudge CFBundleExecutable - stack-nudge-panel + stack-nudge CFBundleIconFile Icon CFBundlePackageType @@ -20,6 +20,8 @@ NSPrincipalClass NSApplication + NSUserNotificationAlertStyle + alert NSAppleEventsUsageDescription stack-nudge uses System Events to focus the correct window when you act on a notification. diff --git a/panel/Panel.swift b/panel/Panel.swift index 40e9b4a..0be3a37 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import UserNotifications protocol PanelKeyDelegate: AnyObject { func panelHandlesKey(_ event: NSEvent) -> Bool @@ -258,7 +259,8 @@ struct EventRow: View { } // Owns the panel + hotkey + listener + menu bar. -final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate { +final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, + UNUserNotificationCenterDelegate { private var panel: FloatingPanel! private var hotkey: Hotkey? @@ -319,6 +321,88 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate { } startConfigWatcher() + setupNotificationCenter() + store.onAppend = { [weak self] event in self?.postBannerIfNeeded(event) } + } + + // MARK: - UNUserNotificationCenter + + private func setupNotificationCenter() { + let center = UNUserNotificationCenter.current() + center.delegate = self + + // Request permission (shows the system dialog once; subsequent calls are no-ops). + center.requestAuthorization(options: [.alert]) { _, _ in } + + // Register categories: STOP (no actions) and PERMISSION (Allow button). + let allow = UNNotificationAction(identifier: "ALLOW", title: "Allow", options: []) + let permCategory = UNNotificationCategory(identifier: "PERMISSION", + actions: [allow], + intentIdentifiers: [], + options: []) + let stopCategory = UNNotificationCategory(identifier: "STOP", + actions: [], + intentIdentifiers: [], + options: []) + center.setNotificationCategories([permCategory, stopCategory]) + } + + // Post a UNUserNotification when STACKNUDGE_BANNER is enabled. + // Sound is omitted — afplay fires independently in notify.sh so we + // don't double-cue when the macOS banner is also shown. + private func postBannerIfNeeded(_ event: NudgeEvent) { + let config = PanelConfig.load() + guard config.bannerEnabled else { return } + + let content = UNMutableNotificationContent() + content.title = event.title + content.body = event.message + content.categoryIdentifier = event.kind == .permission ? "PERMISSION" : "STOP" + content.userInfo = ["eventID": event.id.uuidString] + + let center = UNUserNotificationCenter.current() + // Remove stale delivered notifications so they don't pile up. + center.removeAllDeliveredNotifications() + let req = UNNotificationRequest(identifier: event.id.uuidString, + content: content, trigger: nil) + center.add(req, withCompletionHandler: nil) + } + + // Called when the user clicks the banner or its action button. + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + defer { completionHandler() } + guard let eventID = response.notification.request.content.userInfo["eventID"] as? String, + let event = store.events.first(where: { $0.id.uuidString == eventID }) + else { return } + + store.remove(id: event.id) + let approve = response.actionIdentifier == "ALLOW" + guard let bundleID = event.bundleID else { return } + + // Hide the app first so the system restores focus to the previous + // frontmost app before AppActivator tries to raise the target window. + // Without this the brief activation of stack-nudge interferes with + // window detection in AppActivator. + NSApp.hide(nil) + + DispatchQueue.global(qos: .userInitiated).async { + Thread.sleep(forTimeInterval: 0.15) + AppActivator.activate(bundleID: bundleID, + windowTitle: event.windowTitle, + ipcHook: event.ipcHook, + projectPath: event.projectPath, + sendApproval: approve, + agent: event.agent) + } + } + + // Show banners even when the app is frontmost (needed for accessory apps). + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner]) } @discardableResult diff --git a/ui_improvements.md b/ui_improvements.md new file mode 100644 index 0000000..28c84a7 --- /dev/null +++ b/ui_improvements.md @@ -0,0 +1,226 @@ +StackNudge UI Concept — Keyboard-First Notification Hub + +🧠 Overview + +The goal is to design a keyboard-driven notification and control hub that replaces (or complements) the native macOS notification system. + +Instead of ephemeral popups, this UI acts as a persistent, interactive layer that lets users: + +* View notifications +* Navigate them quickly +* Jump back into the terminal context + +The inspiration is similar to tools like Raycast, but tailored for developer workflows and terminal integration. + +⸻ + +🎯 Core Principles + +1. Keyboard-First + +* All interactions should be possible without a mouse +* Fast open/close via global shortcut (e.g. Cmd + ) +* Navigation via: + * ↑ ↓ → move through notifications + * Enter → open/focus item + * Esc → close panel + +⸻ + +2. Persistent Notifications (Not Ephemeral) + +* Notifications do not disappear +* They form a scrollable history +* Users can revisit past events at any time + +⸻ + +3. Terminal-Centric Workflow + +* Each notification should: + * Link back to a specific terminal context + * Allow quick jump/focus into that context + +This makes the UI a bridge between system events and terminal actions. + +⸻ + +4. Lightweight but Always Available + +* The UI should feel: + * Fast + * Minimal + * Non-intrusive + +It should never interrupt flow like native notifications often do. + +⸻ + +🧩 UI Structure + +1. Entry Point (Idle State) + +A small on-screen element: + +* Could be: + * A dot + * A pill + * A subtle icon +* Positioned somewhere consistent (e.g. top-right or edge of screen) +* Displays: + * Unread count (optional) + * Subtle activity indicator + +⸻ + +2. Command Panel (Primary Interface) + +Triggered via shortcut. + +Layout: + +* Centered or anchored floating panel +* Minimal design (likely dark mode) +* Similar to a command palette + +Contents: + +* Search / filter input (optional but recommended) +* Scrollable notification list + +⸻ + +3. Notification Item Design + +Each item should include: + +* Title (short summary) +* Timestamp +* Type indicator (e.g. error, success, info) +* Optional metadata (project, process, etc.) + +States: + +* Default +* Highlighted (keyboard selection) +* Read / unread + +⸻ + +4. Interaction Model + +Navigation: + +* ↑ ↓ → move selection +* Enter → open notification +* Cmd + Enter (optional) → jump to terminal context + +On Select: + +* Either: + * Expand inline + * Or open a secondary detail view + +⸻ + +5. Detail View + +Shows: + +* Full message / logs +* Contextual information +* Action(s): + * “Open in terminal” + * “Focus session” + * “Copy output” + +⸻ + +🏗️ Future Direction: “Information Hub” + +Beyond notifications, this UI can evolve into: + +* Central place for: + * Logs + * System events + * Task status +* Internal tooling surface +* Debug / observability layer + +Essentially: a lightweight developer dashboard embedded into the OS layer + +⸻ + +⚖️ Tradeoffs + +Pros + +* Faster than native notifications +* Fully keyboard accessible +* Persistent + actionable +* Better suited for dev workflows + +Cons + +* More complex to build +* Requires careful UX tuning (“getting the flavour right”) +* Reinvents OS-level patterns + +⸻ + +❓ Open Questions + +1. Trigger Shortcut + * What should the global shortcut be? + * Should it be configurable? +2. Placement + * Should the idle UI element always be visible? + * Or only appear when there are unread notifications? +3. Search / Filtering + * Do we want fuzzy search (like Raycast)? + * Or just simple filtering? +4. Notification Types + * What kinds of events are we supporting initially? + * Logs? + * Errors? + * Background jobs? + * Deployments? +5. Terminal Integration + * What does “jump back to terminal” mean exactly? + * Focus a tab? + * Re-run a command? + * Open a specific session? +6. Multi-Project Context + * Will users have multiple projects? + * Should notifications be grouped by project? +7. Persistence + * How long should notifications be stored? + * Should there be a “clear all” or archive? +8. UI Style + * Closer to: + * Raycast (command palette)? + * Notification center (list)? + * Hybrid? +9. Mouse Support + * Strictly keyboard-first, or allow optional mouse interaction? +10. MVP Scope + +* What’s the smallest useful version? + * Just list + open? + * Or include search + grouping? + +⸻ + +🧾 Summary + +We are designing a: + +Keyboard-first, persistent notification hub that integrates tightly with terminal workflows and replaces the need for native macOS notifications. + +It should feel: + +* Fast +* Focused +* Developer-native + + diff --git a/uninstall.sh b/uninstall.sh index 617ea50..4b29f16 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -56,7 +56,7 @@ PY fi # Stop and remove launchd agents (macOS) -for label in com.stackonehq.stack-nudge-daemon com.stackonehq.stack-nudge-panel; do +for label in com.stackonehq.stack-nudge com.stackonehq.stack-nudge-daemon com.stackonehq.stack-nudge-panel; do plist="$HOME/Library/LaunchAgents/${label}.plist" if [[ -f "$plist" ]]; then launchctl unload "$plist" 2>/dev/null || true @@ -65,10 +65,10 @@ for label in com.stackonehq.stack-nudge-daemon com.stackonehq.stack-nudge-panel; fi done -# Stop any running panel process the launchd agent didn't catch -pkill -f stack-nudge-panel 2>/dev/null || true +# Stop any running app process the launchd agent didn't catch +pkill -f "stack-nudge$" 2>/dev/null || true -# Remove app bundles +# Remove app bundles (including old two-binary setup) for app in stack-nudge.app stack-nudge-panel.app; do if [[ -d "$HOME/Applications/$app" ]]; then rm -rf "$HOME/Applications/$app" From 6b534f681b2918b65eb9528deeae60c34c6931df Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 30 Apr 2026 11:33:18 +0200 Subject: [PATCH 2/5] fix: esc closes panel from any tab; preserve active tab on hide - Esc now hides the panel regardless of which tab is active (previously it redirected sessions/settings to events first) - Remove nav.mode reset from hidePanel() so the active tab is preserved when the panel is reopened Co-Authored-By: Claude Sonnet 4.6 (1M context) --- panel/Panel.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index 0be3a37..a5bdb04 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -551,7 +551,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty switch event.keyCode { case KeyCode.escape where plain: - nav.mode = .events + hidePanel() case KeyCode.upArrow where plain: selectPrevSession() case KeyCode.downArrow where plain: @@ -580,7 +580,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let shiftOnly = mods.intersection([.command, .control, .option, .shift]) == .shift switch event.keyCode { case KeyCode.escape where plain: - nav.mode = .events + hidePanel() case KeyCode.upArrow where plain: nav.selectPrevRow() case KeyCode.downArrow where plain: @@ -730,10 +730,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // NSApp.hide hides all our windows AND deactivates the app, so the system // frontmost reverts to whatever was active before the panel was summoned. - // Always return to events mode on hide so the next show is predictable. private func hidePanel() { panel.orderOut(nil) - nav.mode = .events NSApp.hide(nil) } From 349cbe715ee9fa611d12fdf903f26e9bee9f628b Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 30 Apr 2026 11:35:41 +0200 Subject: [PATCH 3/5] fix: restore STACKNUDGE_ACTIVATE_IMMEDIATELY; document STACKNUDGE_PANEL migration - Read STACKNUDGE_ACTIVATE_IMMEDIATELY in PanelConfig; when set, focus the source editor immediately on event receipt without showing a banner - Update notify.conf.example to note that STACKNUDGE_PANEL is no longer required (panel is always available via hotkey) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- notify.conf.example | 10 ++-------- panel/Config.swift | 6 ++++-- panel/Panel.swift | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/notify.conf.example b/notify.conf.example index 3ae4845..70db96e 100644 --- a/notify.conf.example +++ b/notify.conf.example @@ -26,14 +26,8 @@ # Default: true #STACKNUDGE_BANNER=false -# Run the floating panel daemon. Press the hotkey to focus a movable -# window listing recent nudges, then act on them with the keyboard: -# Enter → approve permission / focus source editor -# O → focus source editor without approving -# ↑ ↓ → move selection -# Esc → hide panel -# Default: false -#STACKNUDGE_PANEL=true +# Note: STACKNUDGE_PANEL=true is no longer required — the panel is always +# available via the hotkey below. STACKNUDGE_PANEL=false is also no-op. # Global hotkey that focuses the panel. Modifiers: cmd, shift, opt/alt, ctrl. # Default: cmd+opt+n (avoids cmd+shift+n which most browsers bind to diff --git a/panel/Config.swift b/panel/Config.swift index 5d1aeb9..1db537c 100644 --- a/panel/Config.swift +++ b/panel/Config.swift @@ -6,6 +6,7 @@ import Foundation struct PanelConfig { var hotkeySpec: String = "cmd+opt+n" var bannerEnabled: Bool = true + var activateImmediately: Bool = false static func load() -> PanelConfig { var config = PanelConfig() @@ -21,8 +22,9 @@ struct PanelConfig { let value = stripQuotes(String(line[line.index(after: eq)...]) .trimmingCharacters(in: .whitespaces)) switch key { - case "STACKNUDGE_PANEL_HOTKEY": config.hotkeySpec = value - case "STACKNUDGE_BANNER": config.bannerEnabled = value.lowercased() != "false" + case "STACKNUDGE_PANEL_HOTKEY": config.hotkeySpec = value + case "STACKNUDGE_BANNER": config.bannerEnabled = value.lowercased() != "false" + case "STACKNUDGE_ACTIVATE_IMMEDIATELY": config.activateImmediately = value.lowercased() == "true" default: break } } diff --git a/panel/Panel.swift b/panel/Panel.swift index a5bdb04..9218f71 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -350,8 +350,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // Post a UNUserNotification when STACKNUDGE_BANNER is enabled. // Sound is omitted — afplay fires independently in notify.sh so we // don't double-cue when the macOS banner is also shown. + // If STACKNUDGE_ACTIVATE_IMMEDIATELY is set, focus the source editor + // right away without waiting for the user to click. private func postBannerIfNeeded(_ event: NudgeEvent) { let config = PanelConfig.load() + + if config.activateImmediately, let bundleID = event.bundleID { + DispatchQueue.global(qos: .userInitiated).async { + AppActivator.activate(bundleID: bundleID, + windowTitle: event.windowTitle, + ipcHook: event.ipcHook, + projectPath: event.projectPath, + sendApproval: false, + agent: event.agent) + } + return + } + guard config.bannerEnabled else { return } let content = UNMutableNotificationContent() From 2434633fe932e986c054815ad98cff76de15f208 Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 30 Apr 2026 11:36:57 +0200 Subject: [PATCH 4/5] fix: remove unused ACTIVATE_IMMEDIATELY variable (shellcheck SC2034) Logic moved to Swift app; no longer needed in notify.sh. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- notify.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/notify.sh b/notify.sh index f2c1745..eea0304 100755 --- a/notify.sh +++ b/notify.sh @@ -70,9 +70,6 @@ voice_permission_context() { esac } -# Set to "true" to bring your editor to focus immediately when the notification -# fires, instead of waiting for you to click it. -ACTIVATE_IMMEDIATELY="${STACKNUDGE_ACTIVATE_IMMEDIATELY:-false}" # Set to "true" to speak notifications aloud via StackVox (offline TTS). # Requires: pip install stackvox && stackvox serve From c3bfec15d4cf0bbddadde57c5bb38b23e6a318f3 Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 30 Apr 2026 11:43:37 +0200 Subject: [PATCH 5/5] ci: update ci.yml and Makefile for single-binary architecture - ci.yml: verify only stack-nudge.app (not the removed panel bundle); lint only panel/Info.plist (notifier/ no longer in build) - Makefile: remove stack-nudge-panel references, update reload target Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++ Makefile | 18 ++++++------- 2 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f18f328 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + shell: + name: shell syntax + shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: bash -n on tracked shell scripts + run: | + set -e + mapfile -d '' files < <(git ls-files -z '*.sh') + for f in "${files[@]}"; do + echo "→ bash -n $f" + bash -n "$f" + done + + - name: shellcheck + uses: ludeeus/action-shellcheck@master + with: + severity: warning + + build-macos: + name: swift build (${{ matrix.arch }}) + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + arch: [arm64, x86_64] + steps: + - uses: actions/checkout@v6 + + - name: build stack-nudge.app + run: ./build.sh ${{ matrix.arch }} + + - name: verify executable exists + is correct arch + run: | + set -e + bin=$(ls build/stack-nudge.app/Contents/MacOS/) + file "build/stack-nudge.app/Contents/MacOS/$bin" + file "build/stack-nudge.app/Contents/MacOS/$bin" | grep -q "${{ matrix.arch }}" + + - name: verify Info.plist is valid + run: plutil -lint panel/Info.plist diff --git a/Makefile b/Makefile index 33a5307..ee5f429 100644 --- a/Makefile +++ b/Makefile @@ -3,18 +3,18 @@ .DEFAULT_GOAL := help -PANEL_APP := $(HOME)/Applications/stack-nudge-panel.app -PANEL_LABEL := com.stackonehq.stack-nudge-panel +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 .PHONY: help help: @echo "stack-nudge targets:" - @echo " make build build both .app bundles into build/" + @echo " make build build stack-nudge.app into build/" @echo " make install full install (build + copy + register hooks + launchd)" - @echo " make uninstall remove apps, hooks, launchd agents, ~/.stack-nudge/" - @echo " make reload rebuild + replace installed panel + bounce the daemon" + @echo " make uninstall remove app, hooks, launchd agents, ~/.stack-nudge/" + @echo " make reload rebuild + replace installed app + bounce the daemon" @echo " make dev watch sources; auto-reload on change (ctrl-c to stop)" @echo " make clean remove build/ output" @@ -34,7 +34,7 @@ uninstall: clean: @rm -rf build -# One-shot dev cycle: rebuild, reinstall the panel.app, refresh notify.sh in +# One-shot dev cycle: rebuild, reinstall the app, refresh notify.sh in # ~/.stack-nudge so hook-side changes propagate, kickstart the daemon. # Build output goes to $(BUILD_LOG); on failure, last 20 lines tail to stderr. .PHONY: reload @@ -46,8 +46,8 @@ reload: tail -20 $(BUILD_LOG) | sed 's/^/ /'; \ exit 1; \ fi; \ - rm -rf "$(PANEL_APP)"; \ - cp -R build/stack-nudge-panel.app "$(PANEL_APP)"; \ + rm -rf "$(APP)"; \ + cp -R build/stack-nudge.app "$(APP)"; \ if [ -d "$$HOME/.stack-nudge" ]; then \ cp notify.sh "$$HOME/.stack-nudge/notify.sh"; \ if [ -d phrases ]; then \ @@ -55,7 +55,7 @@ reload: cp -R phrases "$$HOME/.stack-nudge/phrases"; \ fi; \ fi; \ - launchctl kickstart -k "gui/$$(id -u)/$(PANEL_LABEL)" 2>/dev/null || true; \ + launchctl kickstart -k "gui/$$(id -u)/$(APP_LABEL)" 2>/dev/null || true; \ printf 'reloaded\n' # Watch loop. Polling-based (500 ms) — no fswatch / entr dependency. Uses a