From 3e93dd3263aac35b73a4a0d815e80e03f76c71b2 Mon Sep 17 00:00:00 2001 From: Hisku Date: Fri, 1 May 2026 10:51:33 +0200 Subject: [PATCH 1/4] feat: install hardening + first-run welcome screen + about footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles four small UX improvements pulled out of the install-flow review: - install.sh: pkill survivor processes before re-registering launchd agents so the unload→load sequence doesn't race a stale instance. Rotate app.log / daemon.log if they exceed 10MB; old logs go to .old. - Settings: small "About" footer at the bottom of the scroll view shows the app version (read from CFBundleShortVersionString) and a clickable link to the GitHub repo. Non-navigable so it doesn't bump rowCount. - First-run welcome: WelcomeView replaces the tab strip on first panel open after install, summarising what the app does, surfacing the hotkey, and listing the three tabs. Enter dismisses; the choice persists via STACKNUDGE_WELCOMED=true so it never reappears. - PanelController auto-opens the panel on first launch when the welcome flag isn't set, so new users see stack-nudge work without having to discover the hotkey first. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- build.sh | 1 + install.sh | 22 +++++++++ panel/Panel.swift | 44 ++++++++++++++--- panel/PanelNav.swift | 11 +++++ panel/Settings.swift | 28 +++++++++++ panel/Welcome.swift | 114 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 panel/Welcome.swift diff --git a/build.sh b/build.sh index b0f53f3..dd1d54c 100755 --- a/build.sh +++ b/build.sh @@ -52,6 +52,7 @@ build_app "$APP" "stack-nudge" \ panel/Settings.swift \ panel/SessionStore.swift \ panel/Sessions.swift \ + panel/Welcome.swift \ shared/AppActivator.swift \ -framework Foundation -framework AppKit -framework SwiftUI -framework Carbon \ -framework UserNotifications diff --git a/install.sh b/install.sh index 8bf8b39..a771ba1 100755 --- a/install.sh +++ b/install.sh @@ -131,6 +131,28 @@ PLIST } if [[ "$(uname -s)" == "Darwin" ]]; then + # Rotate logs (>10 MB) before launchd starts writing to them again. We move + # rather than truncate so users can still inspect the previous session if a + # bug shows up post-upgrade. + rotate_log() { + local log="$1" + [[ ! -f "$log" ]] && return + local size + size=$(stat -f%z "$log" 2>/dev/null || echo 0) + if (( size > 10485760 )); then + mv -f "$log" "${log}.old" + fi + } + rotate_log "${INSTALL_DIR}/daemon.log" + rotate_log "${INSTALL_DIR}/app.log" + + # Belt-and-suspenders: kill any survivor processes from a prior install + # BEFORE we re-register the launchd agents, so the unload-then-load below + # doesn't race with an old instance still hanging on. Matching the exact + # binary path so we don't reap unrelated processes. + pkill -f "Applications/stack-nudge\.app/Contents/MacOS/stack-nudge$" 2>/dev/null || true + pkill -f "venv/bin/stackvox serve$" 2>/dev/null || true + register_launchd_agent \ "com.stackonehq.stack-nudge-daemon" \ "always" \ diff --git a/panel/Panel.swift b/panel/Panel.swift index 91201d5..b9ab3b9 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -67,13 +67,17 @@ struct PanelContentView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - tabStrip - Divider().opacity(0.4) + if !nav.welcomed { + WelcomeView(nav: nav, hotkeyDisplay: nav.hotkeyDisplay) + } else { + tabStrip + Divider().opacity(0.4) - switch nav.mode { - case .events: eventsBody - case .sessions: SessionsView(store: sessions) - case .settings: SettingsView(nav: nav) + switch nav.mode { + case .events: eventsBody + case .sessions: SessionsView(store: sessions) + case .settings: SettingsView(nav: nav) + } } } } @@ -325,6 +329,17 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, store.onAppend = { [weak self] event in self?.postBannerIfNeeded(event) } nav.loadFromConfig() // populate panelPinned + other live values up-front + // First-run welcome: auto-open the panel if STACKNUDGE_WELCOMED isn't + // set yet. Brief delay so install.sh's launchctl bounce settles and + // the dialog isn't fighting macOS's notification permission prompt. + if !nav.welcomed { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + guard let self, !self.nav.welcomed else { return } + NSApp.activate(ignoringOtherApps: true) + self.panel.makeKeyAndOrderFront(nil) + } + } + // Auto-hide when the panel loses key focus, if pin is off. // Detect "click outside" without polling — NSWindow fires this when // another window or app takes focus. @@ -547,6 +562,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let blockingMods: NSEvent.ModifierFlags = [.control, .option] let cmdOnly = mods.intersection([.command, .control, .option, .shift]) == .command + // Welcome view: only Enter (dismiss) and Esc (hide) are meaningful. + // Swallow everything else so the user can't navigate to a non-existent + // tab strip while welcome is showing. + if !nav.welcomed { + let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty + guard plain else { return true } + switch event.keyCode { + case KeyCode.returnKey, KeyCode.numpadEnter: + nav.dismissWelcome() + case KeyCode.escape: + hidePanel() + default: + break + } + return true + } + // While recording a hotkey, capture the next combo. Arrow keys / Tab // bail out gracefully — otherwise users who entered record mode by // mistake would be stuck on row 0 with all their keypresses swallowed. diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index cfd6c90..c061de6 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -32,6 +32,7 @@ final class PanelNav: ObservableObject { @Published var voiceEnabled: Bool = false @Published var muteWhenFocused: Bool = true @Published var panelPinned: Bool = true + @Published var welcomed: Bool = true // default true; install creates a fresh config without it set @Published var soundStop: String = "Glass" @Published var soundPermission: String = "Ping" @Published var voice: String = "af_aoede" @@ -96,6 +97,9 @@ final class PanelNav: ObservableObject { 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) + // Default false on first run so the welcome view shows. We also write + // STACKNUDGE_WELCOMED=true the first time the user dismisses it. + welcomed = ConfigFile.bool(config, "STACKNUDGE_WELCOMED", default: false) soundStop = config["STACKNUDGE_SOUND_STOP"] ?? "Glass" soundPermission = config["STACKNUDGE_SOUND_PERMISSION"] ?? "Ping" voice = config["STACKNUDGE_VOICE_NAME"] ?? "af_aoede" @@ -130,6 +134,13 @@ final class PanelNav: ObservableObject { .filter { !$0.isEmpty && !$0.contains(" ") } } + // MARK: - Welcome + + func dismissWelcome() { + welcomed = true + ConfigFile.write(key: "STACKNUDGE_WELCOMED", value: "true") + } + // MARK: - Row movement func selectNextRow() { diff --git a/panel/Settings.swift b/panel/Settings.swift index db9543d..7a00b27 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -49,6 +49,8 @@ struct SettingsView: View { row(10, label: "Open config file…", kind: .action, value: "") row(11, label: "Quit panel", kind: .action, value: "") } + + aboutFooter } .padding(.horizontal, 14) .padding(.vertical, 14) @@ -86,6 +88,32 @@ struct SettingsView: View { return nav.voice } + // Non-navigable footer with version info. Sits below the action rows so + // keyboard nav (rowCount=12) doesn't need to know about it. Clicking the + // GitHub link opens the repo in the user's browser. + private var aboutFooter: some View { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" + return VStack(spacing: 4) { + Text("stack-nudge v\(version)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + Button { + if let url = URL(string: "https://github.com/StackOneHQ/stack-nudge") { + NSWorkspace.shared.open(url) + } + } label: { + Text("github.com/StackOneHQ/stack-nudge") + .font(.caption2) + .foregroundStyle(.tertiary) + .underline() + } + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity) + .padding(.top, 12) + .padding(.bottom, 4) + } + private func section(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) diff --git a/panel/Welcome.swift b/panel/Welcome.swift new file mode 100644 index 0000000..b526f30 --- /dev/null +++ b/panel/Welcome.swift @@ -0,0 +1,114 @@ +import SwiftUI + +// One-time welcome shown the first time the panel opens after install. +// Replaces the tab strip + content until the user presses Enter / clicks +// "Got it"; then PanelNav.dismissWelcome() persists the dismissal. +struct WelcomeView: View { + + @ObservedObject var nav: PanelNav + let hotkeyDisplay: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + + Text("Notifications for AI coding agents — banners, voice, and a keyboard-driven panel.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + hotkeyHint + + tabsSummary + } + .padding(.horizontal, 18) + .padding(.vertical, 18) + } + + PageFooter { + FooterHint(label: "Got it", keys: ["⏎"], primary: true) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "bell.badge.fill") + .font(.title3) + .foregroundStyle(Color.accentColor) + Text("Welcome to stack-nudge") + .font(.title3.weight(.semibold)) + Spacer() + } + } + + private var hotkeyHint: some View { + HStack(spacing: 8) { + Text("Press") + .font(.subheadline) + .foregroundStyle(.secondary) + HStack(spacing: 3) { + ForEach(hotkeyDisplay.keyCapTokens, id: \.self) { token in + KeyCapView(symbol: token) + } + } + Text("anytime to open this panel.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + private var tabsSummary: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Three tabs:") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.bottom, 2) + + tabRow(systemImage: "bell.fill", + title: "Events", + detail: "Recent nudges, approve / focus with the keyboard") + tabRow(systemImage: "list.bullet.rectangle", + title: "Sessions", + detail: "Running agents — focus, rename, terminate") + tabRow(systemImage: "gearshape.fill", + title: "Settings", + detail: "Hotkey, sounds, voice, and more") + } + } + + private func tabRow(systemImage: String, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: systemImage) + .font(.callout) + .foregroundStyle(Color.accentColor.opacity(0.8)) + .frame(width: 20, alignment: .center) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 1) { + Text(title).font(.subheadline.weight(.medium)) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + } + } +} + +private extension String { + // Split a hotkey spec like "cmd+opt+n" into key cap tokens that match + // the macOS modifier glyphs the rest of the panel uses. + var keyCapTokens: [String] { + split(separator: "+").map { part in + let p = part.trimmingCharacters(in: .whitespaces).lowercased() + switch p { + case "cmd", "command": return "⌘" + case "shift": return "⇧" + case "opt", "alt", "option": return "⌥" + case "ctrl", "control": return "⌃" + default: return p.uppercased() + } + } + } +} From 37e04df9dbe98eea11a25444de21a4a7ceba9536 Mon Sep 17 00:00:00 2001 From: Hisku Date: Fri, 1 May 2026 11:19:17 +0200 Subject: [PATCH 2/4] feat: opt-in permissions flow + cleaner welcome UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Welcome view: replace em dashes; add "Grant permissions" / "Got it" action bar so the user explicitly chooses when to set up perms instead of being ambushed by an auto-fired Accessibility prompt - Apply ThinScrollers() to the welcome scroll view so its scrollbars match the rest of the panel - Permissions: add Notifications row alongside Accessibility and Automation; new Permissions.notifications() / promptNotifications() helpers, .notifications case on SettingsPane, "Prompt" label (instead of "Reset & prompt") for the notifications row since TCC reset doesn't apply to it - Permissions window: drop level=.modalPanel — that was forcing it above macOS system dialogs and clipping them. With .normal level, system alerts and System Settings naturally take precedence as the OS intends. Window now centres normally; user can move it themselves. - Install completion message: boxed hotkey reminder so new users see cmd+opt+n on first install Co-Authored-By: Claude Sonnet 4.6 (1M context) --- install.sh | 11 ++++- panel/Panel.swift | 25 ++++++++-- panel/Permissions.swift | 101 ++++++++++++++++++++++++++++++++-------- panel/Welcome.swift | 78 ++++++++++++++++++++++++++++--- 4 files changed, 183 insertions(+), 32 deletions(-) diff --git a/install.sh b/install.sh index a771ba1..4e48abe 100755 --- a/install.sh +++ b/install.sh @@ -299,9 +299,18 @@ fi echo "" echo "Done! Hooks are wired up." echo "" -echo "Config: edit ~/.stack-nudge/config to customise behaviour." +echo " ┌──────────────────────────────────────────────┐" +echo " │ Press ⌘ + ⌥ + N to open the stack-nudge │" +echo " │ panel — events, sessions, and settings. │" +echo " └──────────────────────────────────────────────┘" +echo "" +echo "macOS may ask for notification + accessibility permissions on first launch." +echo "Grant them so banners appear and 'Allow' on permission nudges can press Enter." +echo "" +echo "Config: edit ~/.stack-nudge/config (or use the Settings tab in the panel)." echo " STACKNUDGE_VOICE=true — speak notifications aloud" echo " STACKNUDGE_ACTIVATE_IMMEDIATELY=true — focus your editor without clicking" echo " STACKNUDGE_BANNER=false — suppress macOS banner notifications" echo " STACKNUDGE_PANEL_HOTKEY=cmd+opt+n — global hotkey for the floating panel" +echo "" echo "To uninstall, run: ./uninstall.sh" diff --git a/panel/Panel.swift b/panel/Panel.swift index b9ab3b9..d87405c 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -65,10 +65,14 @@ struct PanelContentView: View { @ObservedObject var sessions: SessionStore @ObservedObject var nav: PanelNav + let onGrantPermissions: () -> Void + var body: some View { VStack(alignment: .leading, spacing: 0) { if !nav.welcomed { - WelcomeView(nav: nav, hotkeyDisplay: nav.hotkeyDisplay) + WelcomeView(nav: nav, + hotkeyDisplay: nav.hotkeyDisplay, + onGrantPermissions: onGrantPermissions) } else { tabStrip Divider().opacity(0.4) @@ -289,7 +293,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, blur.layer?.cornerRadius = 12 blur.layer?.masksToBounds = true - let host = NSHostingView(rootView: PanelContentView(store: store, sessions: sessions, nav: nav)) + let host = NSHostingView(rootView: PanelContentView( + store: store, sessions: sessions, nav: nav, + onGrantPermissions: { [weak self] in self?.handleGrantPermissions() } + )) host.translatesAutoresizingMaskIntoConstraints = false blur.addSubview(host) NSLayoutConstraint.activate([ @@ -330,8 +337,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, nav.loadFromConfig() // populate panelPinned + other live values up-front // First-run welcome: auto-open the panel if STACKNUDGE_WELCOMED isn't - // set yet. Brief delay so install.sh's launchctl bounce settles and - // the dialog isn't fighting macOS's notification permission prompt. + // set yet. Brief delay so install.sh's launchctl bounce settles. + // Permission prompts are user-triggered from the welcome screen, + // not auto-fired. if !nav.welcomed { DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in guard let self, !self.nav.welcomed else { return } @@ -351,6 +359,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, ) } + // Triggered from the welcome screen's "Grant permissions" button. Opens + // the in-app Permissions window — it shows live status for Notifications, + // Accessibility, and Automation, with per-row "Prompt" and "Settings" + // buttons so the user can drive each grant without surprise modal dialogs + // covering anything. + private func handleGrantPermissions() { + showPermissions() + } + @objc private func panelDidResignKey(_ notification: Notification) { guard !nav.panelPinned, panel.isVisible else { return } hidePanel() diff --git a/panel/Permissions.swift b/panel/Permissions.swift index d207a5c..fe0f585 100644 --- a/panel/Permissions.swift +++ b/panel/Permissions.swift @@ -1,6 +1,7 @@ import AppKit import ApplicationServices import SwiftUI +import UserNotifications enum PermissionStatus { case granted, denied, unknown @@ -11,7 +12,7 @@ enum PermissionStatus { // with askUserIfNeeded=false so we never accidentally prompt. enum Permissions { - static let bundleID = "com.stackonehq.stack-nudge-panel" + static let bundleID = "com.stackonehq.stack-nudge" static func accessibility() -> PermissionStatus { AXIsProcessTrusted() ? .granted : .denied @@ -32,18 +33,61 @@ enum Permissions { NSWorkspace.shared.open(target.url) } + // UNUserNotificationCenter authorization status. Async, so we hop on + // the main queue to deliver the result for SwiftUI consumption. + static func notifications(_ completion: @escaping (PermissionStatus) -> Void) { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: completion(.granted) + case .denied: completion(.denied) + case .notDetermined: completion(.unknown) + @unknown default: completion(.unknown) + } + } + } + } + + // Triggers the system notification permission dialog. Once the user has + // chosen (allow/deny), subsequent calls are no-ops — they have to manage + // the toggle in System Settings → Notifications. + static func promptNotifications() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { _, _ in } + } + + // Non-destructive prompt — surfaces the system dialog that asks the user + // to grant the permission and offers a button into System Settings, but + // does NOT clear the existing TCC entry first (unlike resetAndPrompt). + // Use this for first-run onboarding; resetAndPrompt is for dev-time + // recovery after rebuilds change the cdhash. + static func prompt(_ pane: SettingsPane) { + switch pane { + case .accessibility: + let key = "AXTrustedCheckOptionPrompt" as CFString + let options = [key: kCFBooleanTrue!] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + case .automation: + let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.systemevents") + _ = AEDeterminePermissionToAutomateTarget( + target.aeDesc, typeWildCard, typeWildCard, true) + case .notifications: + promptNotifications() + } + } + // Clear the TCC entry for this app + service, then trigger the system // prompt so a fresh entry is bound to the current cdhash. This is the // dev-time recovery for the ad-hoc-rebuild stale-grant problem: each // rebuild changes the cdhash, but Settings keeps showing the old entry // as "on" until you remove + re-add. static func resetAndPrompt(_ pane: SettingsPane) { - let service = pane.tccService - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") - task.arguments = ["reset", service, bundleID] - try? task.run() - task.waitUntilExit() + if let service = pane.tccService { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") + task.arguments = ["reset", service, bundleID] + try? task.run() + task.waitUntilExit() + } switch pane { case .accessibility: @@ -54,6 +98,8 @@ enum Permissions { let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.systemevents") _ = AEDeterminePermissionToAutomateTarget( target.aeDesc, typeWildCard, typeWildCard, true) + case .notifications: + promptNotifications() } } } @@ -61,6 +107,7 @@ enum Permissions { enum SettingsPane { case accessibility case automation + case notifications var url: URL { switch self { @@ -68,13 +115,16 @@ enum SettingsPane { return URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! case .automation: return URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")! + case .notifications: + return URL(string: "x-apple.systempreferences:com.apple.preference.notifications")! } } - var tccService: String { + var tccService: String? { switch self { case .accessibility: return "Accessibility" case .automation: return "AppleEvents" + case .notifications: return nil // notifications aren't a TCC service } } } @@ -83,18 +133,24 @@ struct PermissionsView: View { @State private var accessibility: PermissionStatus = .unknown @State private var automation: PermissionStatus = .unknown + @State private var notifications: PermissionStatus = .unknown var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("Permissions") .font(.title3.weight(.semibold)) - Text("stack-nudge needs these grants to focus the right window and send the Enter keystroke when you approve a permission nudge.") + Text("stack-nudge needs these grants to show banners, focus the right window, and send the Enter keystroke when you approve a permission nudge.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } + row(title: "Notifications", + description: "Required for the macOS banner to appear when an agent finishes or pauses for approval.", + status: notifications, + pane: .notifications) + row(title: "Accessibility", description: "Required for the Enter-to-approve keystroke. AXIsProcessTrusted() is false until you grant this.", status: accessibility, @@ -114,7 +170,7 @@ struct PermissionsView: View { } } .padding(20) - .frame(width: 480, height: 340) + .frame(width: 480, height: 440) .onAppear { refresh() } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in refresh() @@ -137,8 +193,12 @@ struct PermissionsView: View { Spacer() VStack(alignment: .trailing, spacing: 6) { if status != .granted { - Button("Reset & prompt") { - Permissions.resetAndPrompt(pane) + Button(pane == .notifications ? "Prompt" : "Reset & prompt") { + if pane == .notifications { + Permissions.promptNotifications() + } else { + Permissions.resetAndPrompt(pane) + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { refresh() } } .controlSize(.small) @@ -159,6 +219,7 @@ struct PermissionsView: View { private func refresh() { accessibility = Permissions.accessibility() automation = Permissions.automation() + Permissions.notifications { notifications = $0 } } private func glyph(for status: PermissionStatus) -> String { @@ -183,18 +244,18 @@ final class PermissionsWindowController: NSWindowController { convenience init() { let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 340), + contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), styleMask: [.titled, .closable], backing: .buffered, defer: false) window.title = "stack-nudge — Permissions" - window.center() window.isReleasedWhenClosed = false - // Above the panel's .floating level so this window can't be hidden - // behind it. Modal-panel level still sits below the menu bar/dock. - window.level = .modalPanel - // .canJoinAllSpaces and .moveToActiveSpace are mutually exclusive — - // the latter follows the user to whatever Space they're on, which is - // what we want for an on-demand permissions window. + window.center() + // .moveToActiveSpace follows the user to whatever Space they're on, + // which is what we want for an on-demand permissions window. We do + // NOT raise the window's level — when the user clicks a button that + // triggers a system dialog (Accessibility prompt) or opens System + // Settings, those should be the focused, frontmost window. With + // .modalPanel level, our window stayed on top and clipped them. window.collectionBehavior = [.moveToActiveSpace] window.contentView = NSHostingView(rootView: PermissionsView()) self.init(window: window) diff --git a/panel/Welcome.swift b/panel/Welcome.swift index b526f30..2065e03 100644 --- a/panel/Welcome.swift +++ b/panel/Welcome.swift @@ -2,11 +2,12 @@ import SwiftUI // One-time welcome shown the first time the panel opens after install. // Replaces the tab strip + content until the user presses Enter / clicks -// "Got it"; then PanelNav.dismissWelcome() persists the dismissal. +// "Got it"; PanelNav.dismissWelcome() persists the dismissal. struct WelcomeView: View { @ObservedObject var nav: PanelNav let hotkeyDisplay: String + let onGrantPermissions: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -14,7 +15,7 @@ struct WelcomeView: View { VStack(alignment: .leading, spacing: 16) { header - Text("Notifications for AI coding agents — banners, voice, and a keyboard-driven panel.") + Text("Notifications for AI coding agents. Banners, voice, and a keyboard-driven panel.") .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -22,14 +23,15 @@ struct WelcomeView: View { hotkeyHint tabsSummary + + permissionsHint } .padding(.horizontal, 18) .padding(.vertical, 18) + .background(ThinScrollers()) } - PageFooter { - FooterHint(label: "Got it", keys: ["⏎"], primary: true) - } + actionBar } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -71,16 +73,30 @@ struct WelcomeView: View { tabRow(systemImage: "bell.fill", title: "Events", - detail: "Recent nudges, approve / focus with the keyboard") + detail: "Recent nudges; approve and focus with the keyboard") tabRow(systemImage: "list.bullet.rectangle", title: "Sessions", - detail: "Running agents — focus, rename, terminate") + detail: "Running agents you can focus, rename, or terminate") tabRow(systemImage: "gearshape.fill", title: "Settings", detail: "Hotkey, sounds, voice, and more") } } + private var permissionsHint: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.shield.fill") + .font(.callout) + .foregroundStyle(Color.orange.opacity(0.8)) + .frame(width: 20, alignment: .center) + .padding(.top, 2) + Text("Notifications and Accessibility permissions are needed for banners and 'Allow' approvals. You can grant them now or later from Settings.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + private func tabRow(systemImage: String, title: String, detail: String) -> some View { HStack(alignment: .top, spacing: 10) { Image(systemName: systemImage) @@ -94,6 +110,54 @@ struct WelcomeView: View { } } } + + private var actionBar: some View { + HStack(spacing: 10) { + Button { + onGrantPermissions() + } label: { + Text("Grant permissions") + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.08)) + ) + + Spacer() + + Button { + nav.dismissWelcome() + } label: { + HStack(spacing: 6) { + Text("Got it") + .font(.subheadline.weight(.medium)) + KeyCapView(symbol: "⏎") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor.opacity(0.25)) + ) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background( + ZStack { + Color.primary.opacity(0.05) + Rectangle() + .fill(Color.primary.opacity(0.1)) + .frame(height: 0.5) + .frame(maxHeight: .infinity, alignment: .top) + } + ) + } } private extension String { From a6870cde483c576945249ef4d99ab5f68bb02abe Mon Sep 17 00:00:00 2001 From: Hisku Date: Fri, 1 May 2026 11:21:27 +0200 Subject: [PATCH 3/4] feat: space activates settings rows alongside enter Co-Authored-By: Claude Sonnet 4.6 (1M context) --- panel/Panel.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index d87405c..37138db 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -14,6 +14,7 @@ private enum KeyCode { static let rightArrow: UInt16 = 124 static let returnKey: UInt16 = 36 static let numpadEnter: UInt16 = 76 + static let space: UInt16 = 49 static let tab: UInt16 = 48 static let oKey: UInt16 = 31 static let rKey: UInt16 = 15 @@ -694,7 +695,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, nav.cycleBackward() case KeyCode.rightArrow where plain: nav.cycleForward() - case KeyCode.returnKey, KeyCode.numpadEnter: + case KeyCode.returnKey, KeyCode.numpadEnter, KeyCode.space: guard plain else { return false } nav.activate() default: From 08b2621926bc4b8eb1ab6027b154070a50de8563 Mon Sep 17 00:00:00 2001 From: Hisku Date: Fri, 1 May 2026 11:32:04 +0200 Subject: [PATCH 4/4] feat: fire a welcome notification at end of install Gives the user immediate proof the install worked AND triggers macOS's notification permission prompt during install instead of mid-task later. - notify.sh: new 'welcome' event with a friendly "You're all set" body; bypasses STACKNUDGE_MUTE_WHEN_FOCUSED so the banner fires even though the user is staring at the install terminal at that moment - install.sh: after hooks are wired, wait briefly for the launchd-managed app's panel socket to come up, then post the welcome via the freshly installed notify.sh Co-Authored-By: Claude Sonnet 4.6 (1M context) --- install.sh | 12 ++++++++++++ notify.sh | 11 ++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 4e48abe..13fb708 100755 --- a/install.sh +++ b/install.sh @@ -314,3 +314,15 @@ echo " STACKNUDGE_BANNER=false — suppress macOS banner notifica echo " STACKNUDGE_PANEL_HOTKEY=cmd+opt+n — global hotkey for the floating panel" echo "" echo "To uninstall, run: ./uninstall.sh" + +# Fire a welcome notification so the user immediately sees what a nudge +# looks like AND macOS prompts for notification permission now (during +# install) rather than ambushing them mid-task later. Briefly wait for +# the launchd-managed app to come up before posting. +if [[ "$(uname -s)" == "Darwin" ]]; then + for _ in 1 2 3 4 5 6 7 8; do + [[ -S "$INSTALL_DIR/panel.sock" ]] && break + sleep 0.25 + done + "$NOTIFY" stack-nudge welcome >/dev/null 2>&1 & +fi diff --git a/notify.sh b/notify.sh index d726cf5..798834b 100755 --- a/notify.sh +++ b/notify.sh @@ -407,8 +407,11 @@ notify_macos() { # 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. + # regardless of which window has focus. The welcome event always fires + # (post-install confirmation must reach the user even though they're + # staring at the install terminal at that moment). local mute_when_focused="${STACKNUDGE_MUTE_WHEN_FOCUSED:-true}" + [[ "${EVENT}" == "welcome" ]] && mute_when_focused="false" 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) @@ -546,6 +549,12 @@ case "$OS" in voice_msg=$(voice_phrase_for permission) notify_macos "$TITLE" "${ctx:-Waiting for your approval}" "$SOUND_PERMISSION" "$voice_msg" ;; + welcome) + # Fired once by install.sh so the user sees what a notification + # looks like and macOS prompts for notification permission while + # they're still in the install terminal, not mid-work later. + notify_macos "stack-nudge" "You're all set. Press ⌘⌥N to open the panel." "$SOUND_STOP" "" + ;; *) voice_msg=$(voice_phrase_for stop) notify_macos "$TITLE" "Done" "$SOUND_STOP" "$voice_msg"