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

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