diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index 7155831..0fc2c68 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -171,6 +171,57 @@ enum Bootstrap { } } + // MARK: Reconciliation + + // Agents detected on disk whose hook config has no entry pointing at + // our notify.sh. Powers the Settings-tab "agent detected without hooks" + // banner: covers three scenarios in one mechanism — + // 1. User installs a NEW agent on their Mac after first-run wizard. + // 2. New StackNudge release adds support for an agent the user already + // has installed (eg v1.8 lands Codex support; v1.7 user updates). + // 3. User manually deleted our hook entry then forgot. + // + // Cheap: one JSON parse per detected agent, all on the main thread. + // Called from app launch + every Settings tab open. + static func unwiredAgents() -> [BootstrapAgent] { + availableAgents().filter { !isAgentWired($0) } + } + + // Looks at the agent's on-disk config for any hook command matching + // our notify.sh path. The same staleHookRegex used for uninstall does + // the right thing here — it matches both `tinynudge/notify.sh` and + // `stack-nudge/notify.sh`, which is what we want for "is *anything* + // of ours wired?" + static func isAgentWired(_ agent: BootstrapAgent) -> Bool { + let path = agent.hookConfigPath + guard let root = try? readJSONObject(at: path), + let hooks = root["hooks"] as? [String: Any] else { return false } + for (_, value) in hooks { + if let groups = value as? [[String: Any]] { + // Matcher-group shape (Claude / Codex / Gemini). + if groups.contains(where: { group in + let inner = group["hooks"] as? [[String: Any]] ?? [] + return inner.contains { isStaleHook(command: ($0["command"] as? String) ?? "") } + }) { return true } + // Cursor's flat shape (entries directly under the event). + if groups.contains(where: { isStaleHook(command: ($0["command"] as? String) ?? "") }) { + return true + } + } + } + return false + } + + // Wire a single agent — used by the reconciliation row's "Set up" + // action. Same per-agent dispatch as install(agents:) does in its + // loop, just exposed for one-at-a-time wiring without re-running + // the full bootstrap. Idempotent: calling on an already-wired agent + // adds another entry (existing entries are detected by the next + // unwiredAgents() refresh). + static func wireSingleAgent(_ agent: BootstrapAgent) throws { + try wireHooks(for: agent) + } + // MARK: Install // Install stack-nudge: copy bundled resources to ~/.stack-nudge/, diff --git a/panel/Panel.swift b/panel/Panel.swift index 6cbe8ad..0e19f32 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -388,6 +388,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // resets_at slides forward every poll so we can't trust it). private var quotaLastFired: [String: (maxBucketFired: Int, peakUtil: Double)] = [:] + // When a notification banner is clicked, macOS fires + // applicationShouldHandleReopen BEFORE userNotificationCenter(_:didReceive:). + // Our reopen handler shows the panel; didReceive then hides it as + // part of the banner-click flow — producing a visible flash. We + // defer the reopen-show and let didReceive cancel it by setting + // this deadline. See applicationShouldHandleReopen for the deferral + // logic and didReceive for the cancellation. + private var bannerActivationUntil: Date = .distantPast + // UserDefaults keys for panel size + origin persistence. UserDefaults // lives in ~/Library/Preferences/com.stackonehq.stack-nudge.plist, so it // survives uninstall/reinstall cycles of ~/.stack-nudge/ and across @@ -478,6 +487,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, setupNotificationCenter() store.onAppend = { [weak self] event in self?.postBannerIfNeeded(event) } nav.loadFromConfig() // populate panelPinned + other live values up-front + // Scan agent configs for missing wires (post-update / post-install + // reconciliation). Surfaces a "Set up X" banner in Settings when + // any detected agent lacks our notify.sh hook. + nav.refreshUnwiredAgents() updateChecker = UpdateChecker(nav: nav) updateChecker?.start() @@ -951,6 +964,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { defer { completionHandler() } + // Veto the deferred panel-show that applicationShouldHandleReopen + // queued: we know this activation came from a banner click, not + // a user re-opening the app. + bannerActivationUntil = Date().addingTimeInterval(0.5) guard let eventID = response.notification.request.content.userInfo["eventID"] as? String, let event = store.events.first(where: { $0.id.uuidString == eventID }) else { return } @@ -1124,12 +1141,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } // Fired when the user re-opens the app while it's already running — - // double-click from Finder, `open -a StackNudge`, Spotlight, etc. - // LSUIElement apps have no Dock icon, so this is the single entry - // point for "I clicked the app." Show the panel and return false so - // macOS knows we handled it and doesn't spawn a second process. + // double-click from Finder, `open -a StackNudge`, Spotlight — AND + // (less obviously) as part of the system activation sequence that + // accompanies a notification-banner click. In the banner-click case + // this delegate fires BEFORE userNotificationCenter(_:didReceive:), + // so calling showPanel() here flashes the panel up just before + // didReceive's NSApp.hide() takes it back down. + // + // Defer the show so didReceive can veto. If a banner-click delegate + // arrives within the window, it bumps bannerActivationUntil and we + // skip. For a true app-icon reopen, no banner delegate fires and + // the panel appears after the brief delay. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { - showPanel() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self else { return } + if Date() < self.bannerActivationUntil { return } + self.showPanel() + } return false } diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index a98a14f..9007027 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -114,6 +114,27 @@ final class PanelNav: ObservableObject { @Published var uninstallPhase: UninstallPhase = .confirm @Published var uninstallLog: String = "" + // Reconciliation state. `unwiredAgents` is the live list of detected + // agents whose hook configs don't reference our notify.sh. Drives the + // "Set up X" banner at the top of Settings. Refreshed on app launch + // and every Settings.onAppear so post-update / post-agent-install + // scenarios surface naturally. + // + // `dismissedAgents` holds rawValue strings the user clicked away on; + // persisted to ~/.stack-nudge/dismissed-agents.json so the banner + // doesn't re-pester them between launches. An agent re-appears in + // the banner if it leaves and re-enters the unwired set — eg they + // wire it manually, then delete the entry; or upgrade lands new + // event types we should wire. + @Published var unwiredAgents: [BootstrapAgent] = [] + @Published var dismissedAgents: Set = [] + // Transient confirmation state. When the user clicks Set up on the + // reconciliation banner, `recentlyWiredAgents` holds the agents we + // just wired so the Settings view can flash a "✓ Wired up X" message + // in place of the now-empty unwired banner. Cleared automatically + // a few seconds later. + @Published var recentlyWiredAgents: [BootstrapAgent] = [] + var actions: SettingsActions? // Wired by PanelController so nav can re-register the global hotkey // without owning the Hotkey instance directly. Returns true if the @@ -199,6 +220,76 @@ final class PanelNav: ObservableObject { quotaAlertThreshold = Self.quotaThresholds.min(by: { abs($0 - rawThreshold) < abs($1 - rawThreshold) }) ?? 80 } + // MARK: - Agent reconciliation + + // Re-scan the on-disk agent configs and surface anything our + // notify.sh isn't wired into yet. Dismissed agents stay hidden + // until either the file goes back to "wired" or the dismissal + // file is deleted. + func refreshUnwiredAgents() { + loadDismissedAgents() + let detected = Bootstrap.unwiredAgents() + let visible = detected.filter { !dismissedAgents.contains($0.rawValue) } + if visible != unwiredAgents { unwiredAgents = visible } + } + + // Wire one agent in-place, then refresh the unwired list so the + // row disappears immediately on success. Records the agent in + // recentlyWiredAgents so the Settings view can show a transient + // "✓ Wired up X" confirmation; cleared after a few seconds. + func wireSingleAgent(_ agent: BootstrapAgent) { + do { + try Bootstrap.wireSingleAgent(agent) + recentlyWiredAgents.append(agent) + // Auto-clear so the confirmation doesn't linger forever. + // Re-dispatching is harmless: each new wire extends the + // visible window, then the latest scheduler clears the list. + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in + self?.recentlyWiredAgents.removeAll { $0 == agent } + } + } catch { + FileHandle.standardError.write(Data( + "stack-nudge: wire \(agent.rawValue) failed: \(error)\n".utf8)) + } + // Always refresh — even on error the file state may have partly + // changed and we want the UI to reflect reality. + refreshUnwiredAgents() + } + + // Click "Not now" on the banner. Persist to ~/.stack-nudge/ + // dismissed-agents.json so the user isn't pestered next launch. + // We re-show only if the agent leaves the unwired set (eg user + // manually adds a hook then deletes it) — see refreshUnwiredAgents. + func dismissUnwiredAgent(_ agent: BootstrapAgent) { + dismissedAgents.insert(agent.rawValue) + saveDismissedAgents() + refreshUnwiredAgents() + } + + private static let dismissedAgentsPath = + "\(NSHomeDirectory())/.stack-nudge/dismissed-agents.json" + + private func loadDismissedAgents() { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: Self.dismissedAgentsPath)), + let arr = try? JSONSerialization.jsonObject(with: data) as? [String] + else { return } + dismissedAgents = Set(arr) + } + + private func saveDismissedAgents() { + let arr = Array(dismissedAgents).sorted() + guard let data = try? JSONSerialization.data(withJSONObject: arr, options: [.prettyPrinted]) + else { return } + let url = URL(fileURLWithPath: Self.dismissedAgentsPath) + // ~/.stack-nudge/ may not yet exist if reconciliation runs before + // the bootstrap wizard completes. Create the parent on demand. + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try? data.write(to: url, options: [.atomic]) + } + func refreshVoiceModelCached() { voiceModelCached = Speaker.voiceModelCached() } diff --git a/panel/Settings.swift b/panel/Settings.swift index bd5c29c..b08bfc8 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -14,6 +14,17 @@ struct SettingsView: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 14) { + // Reconciliation banner — appears above all other + // rows when one or more detected agents lack our + // notify.sh hook. Not part of the keyboard index; + // mouse-only at v1. After Set up is clicked, the + // success state takes over the slot for a few + // seconds before disappearing. + if !nav.unwiredAgents.isEmpty { + unwiredAgentsRow(nav.unwiredAgents) + } else if !nav.recentlyWiredAgents.isEmpty { + wiredConfirmationRow(nav.recentlyWiredAgents) + } // Index 0 when present, shifting everything else by // +1. The offset is held on nav (updateRowOffset). if let version = nav.updateAvailable { @@ -101,7 +112,101 @@ struct SettingsView: View { if nav.voiceModelCached, nav.voicesAvailable.isEmpty { nav.loadVoices() } + // Re-scan agent configs on every Settings open so the + // "unwired agent" banner reflects current disk state + // (covers: user just installed Codex; user manually edited + // a hook file; old install lacks events added in a recent + // StackNudge release). + nav.refreshUnwiredAgents() + } + } + + // Transient success confirmation that takes the reconciliation + // banner's slot for ~3 s after the user clicks Set up. Disappears + // by itself once `recentlyWiredAgents` clears. + @ViewBuilder + private func wiredConfirmationRow(_ agents: [BootstrapAgent]) -> some View { + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .font(.body) + .foregroundStyle(.green) + VStack(alignment: .leading, spacing: 1) { + Text(agents.count == 1 + ? "\(agents[0].displayName) is set up." + : "\(agents.count) agents are set up.") + .font(.subheadline.weight(.semibold)) + Text("New banners will fire when the agent finishes a turn or waits for approval.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.green.opacity(0.12)) + ) + .transition(.opacity) + } + + // Reconciliation banner. Shown above all other settings rows when one + // or more detected agents lack a notify.sh hook entry. Two click + // targets: "Set up" (wire every unwired agent) and "Not now" (dismiss + // for this/future launches until the agent's state changes again). + // Not part of the keyboard-indexed nav at v1 — mouse-only. + @ViewBuilder + private func unwiredAgentsRow(_ agents: [BootstrapAgent]) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "sparkle") + .font(.body) + .foregroundStyle(Color.accentColor) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + Text(agents.count == 1 + ? "Wire up \(agents[0].displayName)?" + : "Wire up \(agents.count) agents?") + .font(.subheadline.weight(.semibold)) + Text(agents.map(\.displayName).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + Text("Detected on this Mac without StackNudge hooks. Set up to start getting banners.") + .font(.caption) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + Button { + for agent in agents { nav.wireSingleAgent(agent) } + } label: { + Text("Set up") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor) + ) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + Button { + for agent in agents { nav.dismissUnwiredAgent(agent) } + } label: { + Text("Not now") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.top, 2) + } + Spacer() } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(0.12)) + ) } // Replaces the Voice + Speed rows when the Kokoro model hasn't been