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
51 changes: 51 additions & 0 deletions panel/Bootstrap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand Down
38 changes: 33 additions & 5 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
}

Expand Down
91 changes: 91 additions & 0 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
// 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
Expand Down Expand Up @@ -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()
}
Expand Down
105 changes: 105 additions & 0 deletions panel/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down