diff --git a/README.md b/README.md
index b7595b0..0445570 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
-

+

+
StackNudge
Notifications for AI coding agents.
Get a banner + sound when your agent finishes a task or pauses for your approval — step away without missing a beat.
@@ -22,10 +23,12 @@
|-------|--------|
| Claude Code | ✅ |
| Cursor | ✅ |
-| Gemini CLI | ✅ *(experimental)* |
-| Codex | ✅ *(experimental)* |
+| Codex | ✅ |
+| Gemini CLI | ✅ † |
| Any hooks-capable agent | ✅ — point it at `notify.sh` |
+† Gemini's tool-permission hook is observability-only — the banner shows the prompt, but the actual Allow / Deny click has to happen in Gemini's terminal. Claude Code, Cursor, and Codex permission events can be approved from the panel directly.
+
**Platforms:** macOS — full app with panel, click-to-focus banners, auto-update, quota tracking, voice. Linux (PulseAudio / ALSA / libnotify) and Windows (Git Bash / WSL) get audio + basic notifications via `notify.sh` only.
## Install
@@ -329,15 +332,19 @@ Same set of cleanups as the in-app path, useful when the .app isn't reachable or
## Manual setup
-Every supported agent just needs a hook that runs `notify.sh `. Example for Codex (or any other hooks-capable agent):
+Claude Code, Cursor, Codex, and Gemini CLI are auto-wired by the first-launch wizard. For other hooks-capable agents (or to integrate from a custom script), all you need is to invoke `notify.sh ` from wherever your agent emits lifecycle events. `` should be `stop` (agent finished a turn) or `permission` (waiting for approval); `` can be anything — it just controls the banner title.
+
+Example block in any agent's hooks config:
```json
{
"hooks": {
- "stop": [
+ "Stop": [
{
- "type": "command",
- "command": "$HOME/.stack-nudge/notify.sh codex stop"
+ "matcher": "",
+ "hooks": [
+ { "type": "command", "command": "$HOME/.stack-nudge/notify.sh my-agent stop", "timeout": 30 }
+ ]
}
]
}
diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift
index a44ca30..0fc2c68 100644
--- a/panel/Bootstrap.swift
+++ b/panel/Bootstrap.swift
@@ -17,6 +17,7 @@ import SwiftUI
enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable {
case claude
case cursor
+ case codex
case gemini
var id: String { rawValue }
@@ -25,6 +26,7 @@ enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable {
switch self {
case .claude: return "Claude Code"
case .cursor: return "Cursor"
+ case .codex: return "Codex"
case .gemini: return "Gemini CLI"
}
}
@@ -35,6 +37,7 @@ enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable {
switch self {
case .claude: return "\(NSHomeDirectory())/.claude"
case .cursor: return "\(NSHomeDirectory())/.cursor"
+ case .codex: return "\(NSHomeDirectory())/.codex"
case .gemini: return "\(NSHomeDirectory())/.gemini"
}
}
@@ -45,9 +48,14 @@ enum BootstrapAgent: String, CaseIterable, Identifiable, Equatable {
switch self {
case .claude: return "\(NSHomeDirectory())/.claude/settings.json"
case .cursor: return "\(NSHomeDirectory())/.cursor/hooks.json"
- // Gemini hook support is experimental; install.sh writes nothing
- // for it today. We mirror that — Bootstrap.install skips Gemini
- // hook wiring. Selecting it is a no-op aside from acknowledging.
+ // Codex's hooks file shares Claude Code's matcher-group JSON
+ // shape and event names (Stop + PermissionRequest). See
+ // https://developers.openai.com/codex/hooks.
+ case .codex: return "\(NSHomeDirectory())/.codex/hooks.json"
+ // Gemini CLI uses ~/.gemini/settings.json (same path as Claude's
+ // settings.json analog), but its events are renamed: AfterAgent
+ // (turn end) and Notification (tool-permission alerts). See
+ // https://geminicli.com/docs/hooks/.
case .gemini: return "\(NSHomeDirectory())/.gemini/settings.json"
}
}
@@ -163,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/,
@@ -333,39 +392,64 @@ enum Bootstrap {
let path = agent.hookConfigPath
switch agent {
case .claude:
- try wireClaudeHooks(at: path)
+ // Stop (30s) + PermissionRequest (600s); Claude's
+ // matcher-group JSON shape.
+ try wireClaudeShapedHooks(at: path, agentArg: "claude-code",
+ events: [("Stop", "stop", 30),
+ ("PermissionRequest", "permission", 600)])
case .cursor:
try wireCursorHooks(at: path)
+ case .codex:
+ // Codex's hooks file is structurally identical to Claude's
+ // (matcher-groups), with the same event names. Only the
+ // file path and the agent-arg differ.
+ try wireClaudeShapedHooks(at: path, agentArg: "codex",
+ events: [("Stop", "stop", 30),
+ ("PermissionRequest", "permission", 600)])
case .gemini:
- // install.sh just prints "experimental, see README" for
- // Gemini today. Mirror that: no-op, but accept the agent
- // in `agents` so the wizard checkbox does something
- // (acknowledges the user's choice).
- break
+ // Gemini renames Claude's `Stop` to `AfterAgent` and routes
+ // tool-permission prompts through `Notification` (with
+ // `notification_type=ToolPermission` on stdin). Same
+ // matcher-group JSON shape otherwise.
+ //
+ // CRITICAL DIFFERENCE FROM CLAUDE/CODEX: Gemini's `timeout`
+ // is measured in **milliseconds**, not seconds. Sending 30
+ // would kill the hook after 30 ms — before the shell even
+ // forks. Multiply by 1000 to match the writer's
+ // milliseconds-only convention for Gemini.
+ //
+ // Note: Notification is observability-only — our hook can
+ // surface the banner but can't return an allow/deny
+ // decision the way Claude's PermissionRequest can.
+ try wireClaudeShapedHooks(at: path, agentArg: "gemini",
+ events: [("AfterAgent", "stop", 30_000),
+ ("Notification", "permission", 30_000)])
}
}
- private static func wireClaudeHooks(at path: String) throws {
+ // Generic writer for the matcher-group JSON shape that Claude,
+ // Codex, and Gemini all use. Differs from agent to agent only in
+ // file path, agent-arg passed to notify.sh, and the set of event
+ // names. Cursor uses a flat-array shape and has its own writer.
+ private static func wireClaudeShapedHooks(
+ at path: String,
+ agentArg: String,
+ events: [(event: String, arg: String, timeout: Int)]
+ ) throws {
var root = try readJSONObject(at: path)
var hooks = root["hooks"] as? [String: Any] ?? [:]
- // Two Claude events: Stop (turn ends, 30s timeout) and
- // PermissionRequest (blocking on user approval, 600s).
- let entries: [(event: String, arg: String, timeout: Int)] = [
- ("Stop", "stop", 30),
- ("PermissionRequest", "permission", 600),
- ]
-
- for (event, arg, timeout) in entries {
+ for (event, arg, timeout) in events {
var groups = hooks[event] as? [[String: Any]] ?? []
groups = pruneStaleHookGroups(groups)
let ourHook: [String: Any] = [
"type": "command",
- "command": "\(notifyPath) claude-code \(arg)",
+ "command": "\(notifyPath) \(agentArg) \(arg)",
"timeout": timeout,
]
// Swift's [String: Any] doesn't preserve key order; the
- // resulting JSON is still valid. Claude Code parses by key.
+ // resulting JSON is still valid. All three agents parse
+ // by key.
groups.append([
"matcher": "",
"hooks": [ourHook],
@@ -432,8 +516,11 @@ enum Bootstrap {
var root = try readJSONObject(at: path)
guard var hooks = root["hooks"] as? [String: Any] else { return }
- // Claude shape: matcher-groups
- for event in ["Stop", "PermissionRequest"] {
+ // Matcher-group shape — shared by Claude (Stop/PermissionRequest),
+ // Codex (same names), and Gemini (AfterAgent/Notification).
+ // Iterating all four event names is harmless: events not present
+ // are simply skipped.
+ for event in ["Stop", "PermissionRequest", "AfterAgent", "Notification"] {
if let groups = hooks[event] as? [[String: Any]] {
let cleaned = pruneStaleHookGroups(groups)
if cleaned.isEmpty {
@@ -809,7 +896,7 @@ struct BootstrapView: View {
@ViewBuilder
private var agentList: some View {
if nav.bootstrapAvailableAgents.isEmpty {
- Text("No supported agents detected (~/.claude, ~/.cursor, ~/.gemini). Install one and restart StackNudge to wire it up.")
+ Text("No supported agents detected (~/.claude, ~/.cursor, ~/.codex, ~/.gemini). Install one and restart StackNudge to wire it up.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -829,47 +916,27 @@ struct BootstrapView: View {
@ViewBuilder
private func agentRow(_ agent: BootstrapAgent) -> some View {
- if agent == .gemini {
- // Gemini hook wiring isn't implemented — show the row as
- // informational only so the user doesn't think they can toggle
- // it on.
- HStack(alignment: .top, spacing: 10) {
- Image(systemName: "info.circle")
- .font(.callout)
- .foregroundStyle(.tertiary)
- .frame(width: 20, alignment: .center)
- .padding(.top, 2)
- VStack(alignment: .leading, spacing: 1) {
- Text(agent.displayName).font(.subheadline.weight(.medium))
- Text("Detected, but hook wiring is manual. See README for setup.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Spacer()
- }
- } else {
- let isSelected = nav.bootstrapSelectedAgents.contains(agent)
- HStack(alignment: .top, spacing: 10) {
- Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
- .font(.callout)
- .foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
- .frame(width: 20, alignment: .center)
- .padding(.top, 2)
- VStack(alignment: .leading, spacing: 1) {
- Text(agent.displayName).font(.subheadline.weight(.medium))
- Text("Hooks will be added to \((agent.hookConfigPath as NSString).abbreviatingWithTildeInPath)")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Spacer()
+ let isSelected = nav.bootstrapSelectedAgents.contains(agent)
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
+ .font(.callout)
+ .foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
+ .frame(width: 20, alignment: .center)
+ .padding(.top, 2)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(agent.displayName).font(.subheadline.weight(.medium))
+ Text("Hooks will be added to \((agent.hookConfigPath as NSString).abbreviatingWithTildeInPath)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
}
- .contentShape(Rectangle())
- .onTapGesture {
- if isSelected {
- nav.bootstrapSelectedAgents.remove(agent)
- } else {
- nav.bootstrapSelectedAgents.insert(agent)
- }
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ if isSelected {
+ nav.bootstrapSelectedAgents.remove(agent)
+ } else {
+ nav.bootstrapSelectedAgents.insert(agent)
}
}
}
diff --git a/panel/Panel.swift b/panel/Panel.swift
index cc086ea..26d087c 100644
--- a/panel/Panel.swift
+++ b/panel/Panel.swift
@@ -419,6 +419,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
@@ -509,6 +518,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()
@@ -530,12 +543,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
// wizard again.
if !Bootstrap.isInstalled(), nav.mode != .postUpdate {
nav.bootstrapAvailableAgents = Bootstrap.availableAgents()
- // Exclude Gemini from the default-selected set — its row is
- // info-only (hook wiring is manual), so pre-selecting it would
- // mislead the user into thinking we'll wire something.
- nav.bootstrapSelectedAgents = Set(
- nav.bootstrapAvailableAgents.filter { $0 != .gemini }
- )
+ // Pre-select every detected agent — Claude, Cursor, Codex,
+ // and Gemini all wire real hooks. Earlier versions excluded
+ // Gemini because its row was info-only; that's no longer
+ // true (AfterAgent + Notification are wired now).
+ nav.bootstrapSelectedAgents = Set(nav.bootstrapAvailableAgents)
nav.bootstrapPhase = .idle
nav.mode = .bootstrap
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
@@ -983,6 +995,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 }
@@ -1156,12 +1172,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