diff --git a/README.md b/README.md index 410fec7..f7b0fd9 100644 --- a/README.md +++ b/README.md @@ -130,11 +130,11 @@ Add that to your shell profile. ### Keyboard-native panel (macOS) -If you'd rather not click banners with the mouse, stack-nudge runs a small floating panel that you summon with a hotkey. It has four tabs — **Events**, **Sessions**, **Usage**, and **Settings** — and is fully keyboard-driven. +If you'd rather not click banners with the mouse, stack-nudge runs a small floating panel that you summon with a hotkey. It has five tabs — **Events**, **Sessions**, **Usage**, **Tickets**, and **Settings** — and is fully keyboard-driven. The panel is installed and registered as a launchd agent by `./install.sh` — no opt-in needed. To run quietly without macOS banners, toggle **Settings → Banner notifications** off (panel-only mode). -Default hotkey is `cmd+opt+n`. Hit it from anywhere to summon the panel; hit it again while focused to hide. Switch tabs with `Cmd+1` (Events), `Cmd+2` (Sessions), `Cmd+3` (Usage), `Cmd+4` (Settings) — or click them. Banner and panel can run together, alone, or both off — the sound and voice still fire as passive signals. +Default hotkey is `cmd+opt+n`. Hit it from anywhere to summon the panel; hit it again while focused to hide. Switch tabs with `Cmd+1` (Events), `Cmd+2` (Sessions), `Cmd+3` (Usage), `Cmd+4` (Tickets), `Cmd+5` (Settings) — or click them. Banner and panel can run together, alone, or both off — the sound and voice still fire as passive signals. #### Events tab @@ -145,11 +145,14 @@ Recent nudges in chronological order. Each shows agent, message, project name, t | `↑ ↓` | Move selection | | `⏎` | Approve permission / focus source editor | | `O` | Focus source editor without approving | +| `M` | Mute all notifications for the configured duration (press again to resume) | | `⌫` | Dismiss the selected nudge locally | | `Esc` | Hide the panel | When you press `⏎` on a permission event in a VS Code / Cursor terminal pane, stack-nudge walks the editor's accessibility tree to focus the right pane (matched by the agent name in the tab title) before sending Enter — so the approval keystroke lands in the agent's terminal, not whatever was last focused. Falls back gracefully if the pane can't be found. +**Mute for a while.** Press `M` (or use the bell button in the panel header, or the menu-bar **Mute notifications** submenu) to silence *everything* — banner, sound, voice, and the focus jump — for a set duration, **including permission prompts**. Events keep flowing into the panel while muted; only the interruptions are suppressed. A live countdown shows on the header bell and the menu-bar icon (the compact widget just shows a muted-bell glyph), and the mute lifts itself when the timer runs out (or immediately if you press `M` / **Resume** again). The default duration is configurable (`STACKNUDGE_MUTE_DURATION_MIN`, one of 15 / 30 / 60 / 120, default 30) and can be cycled in Settings. Mute is in-memory only — it resets on relaunch. + #### Sessions tab Live list of running agent processes (`claude`, `gemini`, `codex` — including node-hosted variants like `gemini-cli`). Polls every 3 seconds while visible and every 15 seconds in the background for the compact widget. Sessions that exit linger for 30s with `ended Ns ago`. @@ -181,7 +184,7 @@ Reachable from the tab strip or `Cmd+3`. Renders your Claude Code subscription q Bars are color-coded: green below 50%, yellow 50–80%, red 80%+. Reset times shown per tier. -Data is fetched from the same endpoint Claude Code's own statusline uses (`/api/oauth/usage`), reading your OAuth token from the macOS Keychain. **The first time stack-nudge polls you'll see a keychain dialog — click "Always Allow"** to grant access (one-time, per release). +Numbers come straight from the `claude` CLI — stack-nudge shells out to `claude --print /usage` and parses the result. Because the CLI reads its *own* keychain grant, **stack-nudge never touches your keychain or calls the Anthropic API, so there's no password prompt**. If `claude` isn't on your `PATH` or you're signed out, the tab shows *"Claude usage unavailable — run `claude /usage` to check your session"* rather than falling back to any other source. (Codex and Antigravity usage are read from their own local files, unaffected.) Polls every 60 seconds while the panel is visible, or every 5 minutes by default in the background (configurable via Settings → Usage → "Poll frequency"). On the Usage tab: `r` triggers a manual sync, `p` pauses/resumes the poller. @@ -204,7 +207,7 @@ Independent of quota: stack-nudge can also fire a banner when an individual Clau #### Settings tab -Reachable from the tab strip or `Cmd+4`. Keyboard-driven rows for hotkey, behavior toggles (banner, mute when focused, pin panel, launch at login), widget (corner, mascot picker, opacity), sound picks (with preview-on-cycle), voice notifications + picker + speed (with preview-on-cycle using a random conversational phrase), usage config (quota tracking + alerts + threshold + poll frequency + context alert threshold + show-remaining), and action rows (edit phrases, check permissions, open config file, view release notes, check for updates, uninstall, quit). +Reachable from the tab strip or `Cmd+5`. Keyboard-driven rows for hotkey, behavior toggles (banner, mute when focused, a Mute/Resume action row with mute duration, pin panel, launch at login), widget (corner, mascot picker, opacity), sound picks (with preview-on-cycle), voice notifications + picker + speed (with preview-on-cycle using a random conversational phrase), usage config (quota tracking + alerts + threshold + poll frequency + context alert threshold + show-remaining), and action rows (edit phrases, check permissions, open config file, view release notes, check for updates, uninstall, quit). | Key | Action | |-----|--------| @@ -239,6 +242,7 @@ When the panel daemon is running, a bell icon appears in your menu bar. The same | `Hotkey · …` | Shows your current hotkey (info only) | | `Show banners` | Toggles macOS banner notifications. Enabling fires a confirmation banner. | | `Voice notifications` | Toggles spoken notifications. Enabling speaks *"Voice notifications enabled"*. | +| `Mute notifications ▸` | Submenu (For 15m / 30m / 1h / 2h) to silence all nudges for a set time. While muted the item becomes `Resume notifications` and the menu-bar icon shows a `bell.slash` + countdown. | | `Show panel` | Brings the floating panel up (handy when no events are queued) | | `Check permissions…` | Opens the permissions checker (see below) | | `Open config file…` | Opens `~/.stack-nudge/config` in your default editor | @@ -336,7 +340,7 @@ Open the panel (`⌘⌥N`), go to **Settings → Uninstall stack-nudge…**, con - `~/.stack-nudge/` (config, `notify.sh`, phrases) - Moves `stack-nudge.app` to Trash and quits -Settings (config, the cached Kokoro voice model in `~/.cache/huggingface/`, your macOS keychain entry for Claude Code) are not touched. +Settings (config, the cached Kokoro voice model in `~/.cache/huggingface/`) are not touched. stack-nudge never creates its own keychain entries, so there's nothing of ours to clean up there. ### Linux / Windows / fallback diff --git a/Tests/StackNudgePanelCoreTests/MuteStateTests.swift b/Tests/StackNudgePanelCoreTests/MuteStateTests.swift new file mode 100644 index 0000000..e80abcd --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/MuteStateTests.swift @@ -0,0 +1,46 @@ +import XCTest + +@testable import StackNudgePanelCore + +// Timed global mute: the transient state lives on PanelNav (muteUntil), the +// persistent default duration is a config-backed cycle row, and the countdown +// label is a pure helper. PanelController owns the timer/side-effects and is +// not exercised here. +@MainActor +final class MuteStateTests: XCTestCase { + + func test_muteRowsAreContiguous() { + // Render/keyboard order: the one-click mute action sits between the + // "mute when focused" toggle and the default-duration cycle, so the + // trigger and its knob stay adjacent. + let nav = PanelNav() + let rows = nav.settingsRows + guard let i = rows.firstIndex(of: .muteWhenFocused) else { + return XCTFail("muteWhenFocused row missing") + } + XCTAssertEqual(rows[i + 1], .mute) + XCTAssertEqual(rows[i + 2], .muteDuration) + } + + func test_isMuted_reflectsMuteUntil() { + let nav = PanelNav() + XCTAssertFalse(nav.isMuted) // nil → not muted + nav.muteUntil = Date().addingTimeInterval(600) + XCTAssertTrue(nav.isMuted) // future → muted + nav.muteUntil = Date().addingTimeInterval(-1) + XCTAssertFalse(nav.isMuted) // past → lifted + } + + func test_defaultDuration_isAValidOption() { + // The cycle row snaps to a member; the initial value must already be one. + let nav = PanelNav() + XCTAssertTrue(PanelNav.muteDurationOptions.contains(nav.muteDurationMinutes)) + } + + func test_muteRemainingLabel_ceilsToWholeMinutes() { + XCTAssertEqual(PanelNav.muteRemainingLabel(until: Date().addingTimeInterval(90)), "2m") + XCTAssertEqual(PanelNav.muteRemainingLabel(until: Date().addingTimeInterval(30)), "1m") + // Already elapsed clamps to 0 rather than going negative. + XCTAssertEqual(PanelNav.muteRemainingLabel(until: Date().addingTimeInterval(-5)), "0m") + } +} diff --git a/Tests/StackNudgePanelCoreTests/QuotaProbeRetryAfterTests.swift b/Tests/StackNudgePanelCoreTests/QuotaProbeRetryAfterTests.swift deleted file mode 100644 index 8e97f6b..0000000 --- a/Tests/StackNudgePanelCoreTests/QuotaProbeRetryAfterTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -import XCTest - -@testable import StackNudgePanelCore - -final class QuotaProbeRetryAfterTests: XCTestCase { - - func testNilHeader() { - XCTAssertNil(QuotaProbe.parseRetryAfter(nil)) - } - - func testEmptyHeader() { - XCTAssertNil(QuotaProbe.parseRetryAfter("")) - XCTAssertNil(QuotaProbe.parseRetryAfter(" ")) - } - - func testDeltaSeconds() { - XCTAssertEqual(QuotaProbe.parseRetryAfter("30"), 30) - XCTAssertEqual(QuotaProbe.parseRetryAfter(" 120 "), 120) - XCTAssertEqual(QuotaProbe.parseRetryAfter("0"), 0) - } - - func testNegativeDeltaIsRejected() { - // Per RFC 7231 delta-seconds must be non-negative — fall through to - // date parsing (which fails) and return nil. - XCTAssertNil(QuotaProbe.parseRetryAfter("-5")) - } - - func testClampedToMax() { - let huge = QuotaProbe.maxRetryAfter * 4 - XCTAssertEqual(QuotaProbe.parseRetryAfter("\(Int(huge))"), - QuotaProbe.maxRetryAfter) - } - - func testHTTPDateInFuture() { - let future = Date().addingTimeInterval(120) - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = TimeZone(identifier: "GMT") - f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" - let header = f.string(from: future) - - let parsed = QuotaProbe.parseRetryAfter(header) - XCTAssertNotNil(parsed) - // Allow ±2s clock drift between Date() in the test and Date() inside - // parseRetryAfter. - XCTAssertEqual(parsed ?? 0, 120, accuracy: 2) - } - - func testHTTPDateInPastReturnsZero() { - let past = Date().addingTimeInterval(-3600) - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = TimeZone(identifier: "GMT") - f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" - XCTAssertEqual(QuotaProbe.parseRetryAfter(f.string(from: past)), 0) - } - - func testGarbageReturnsNil() { - XCTAssertNil(QuotaProbe.parseRetryAfter("not a number")) - XCTAssertNil(QuotaProbe.parseRetryAfter("Mon, totally not a date")) - XCTAssertNil(QuotaProbe.parseRetryAfter("3.14e10x")) - } -} diff --git a/notify.conf.example b/notify.conf.example index 3a0cc80..e7a65d2 100644 --- a/notify.conf.example +++ b/notify.conf.example @@ -45,6 +45,12 @@ # Default: true #STACKNUDGE_MUTE_WHEN_FOCUSED=false +# Default duration (minutes) for the "Mute notifications" bell in the panel +# header and menu bar. The mute itself is temporary and resets on relaunch; +# this only sets how long a one-click mute lasts. Allowed: 15, 30, 60, 120. +# Default: 30 +#STACKNUDGE_MUTE_DURATION_MIN=30 + # Pill widget accent color preset. Also settable from Settings → Widget → # "Accent color". Allowed: cyan, violet, mint, rose, system (system follows # the macOS accent and matches the banner color). diff --git a/panel/CompactView.swift b/panel/CompactView.swift index d61dd85..481d7c7 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -61,6 +61,7 @@ struct CompactView: View { HStack(alignment: .center, spacing: 4) { gaugeCluster Spacer(minLength: 0) + muteIndicator expandButton } .frame(maxHeight: .infinity) @@ -72,6 +73,7 @@ struct CompactView: View { headline sessionBadge Spacer(minLength: 0) + muteIndicator expandButton } .padding(.horizontal, 12) @@ -335,6 +337,19 @@ struct CompactView: View { .buttonStyle(.plain) } + // Passive muted signal on the pill — a dimmed bell.slash while a timed + // mute is active, nothing otherwise. Reads nav.muteTick so it clears the + // instant the mute lifts (the controller's timer bumps it). + @ViewBuilder private var muteIndicator: some View { + let _ = nav.muteTick + if nav.isMuted { + Image(systemName: "bell.slash.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .help("Notifications muted") + } + } + private var separator: some View { Rectangle() .fill(Color.secondary.opacity(0.22)) diff --git a/panel/MenuBar.swift b/panel/MenuBar.swift index 8ab3ed2..2b09989 100644 --- a/panel/MenuBar.swift +++ b/panel/MenuBar.swift @@ -206,6 +206,9 @@ final class MenuBarController: NSObject, NSMenuDelegate { menu.addItem(toggle("Mute when focused", state: mute, key: "STACKNUDGE_MUTE_WHEN_FOCUSED")) menu.addItem(.separator()) + addMuteSection(to: menu) + menu.addItem(.separator()) + menu.addItem(action("Show panel", #selector(showPanelAction))) menu.addItem(action("Check permissions…", #selector(showPermissionsAction))) menu.addItem(action("Open config file…", #selector(openConfigAction))) @@ -214,6 +217,57 @@ final class MenuBarController: NSObject, NSMenuDelegate { menu.addItem(action("Quit StackNudge panel", #selector(quitAction), keyEquivalent: "q")) } + // MARK: - Timed mute + + // Read from the panel's transient nav.muteUntil. The menu rebuilds on + // every open, so this is always current: a countdown + Resume while muted, + // a duration submenu otherwise. + private func addMuteSection(to menu: NSMenu) { + guard let panel = panelController else { return } + if let until = panel.nav.muteUntil, until > Date() { + let info = NSMenuItem( + title: "Muted · \(PanelNav.muteRemainingLabel(until: until)) left", + action: nil, keyEquivalent: "") + info.isEnabled = false + menu.addItem(info) + menu.addItem(action("Resume notifications", #selector(resumeMuteAction))) + } else { + let parent = NSMenuItem(title: "Mute notifications", action: nil, keyEquivalent: "") + let submenu = NSMenu() + for minutes in PanelNav.muteDurationOptions { + let item = NSMenuItem(title: "For \(muteDurationLabel(minutes))", + action: #selector(muteMinutesAction(_:)), keyEquivalent: "") + item.target = self + item.representedObject = minutes + submenu.addItem(item) + } + parent.submenu = submenu + menu.addItem(parent) + } + } + + private func muteDurationLabel(_ minutes: Int) -> String { + minutes % 60 == 0 ? "\(minutes / 60)h" : "\(minutes)m" + } + + // Swap the status-bar icon to a muted bell + countdown while muted, and + // back to the brand mark when the mute lifts. Driven by PanelController's + // countdown timer and its mute/resume calls. + func refreshMuteBadge(until: Date?) { + guard let button = statusItem.button else { return } + if let until, until > Date() { + let img = NSImage(systemSymbolName: "bell.slash.fill", + accessibilityDescription: "StackNudge (muted)") + img?.isTemplate = true + button.image = img + button.imagePosition = .imageLeft + button.title = " " + PanelNav.muteRemainingLabel(until: until) + } else { + button.image = MenuBarController.brandMarkImage() + button.title = "" + } + } + private func toggle(_ title: String, state: Bool, key: String) -> NSMenuItem { let item = NSMenuItem(title: title, action: #selector(toggleAction(_:)), keyEquivalent: "") item.target = self @@ -260,6 +314,15 @@ final class MenuBarController: NSObject, NSMenuDelegate { Speaker.speak(text) } + @objc private func muteMinutesAction(_ sender: NSMenuItem) { + guard let minutes = sender.representedObject as? Int else { return } + panelController?.muteFor(minutes: minutes) + } + + @objc private func resumeMuteAction() { + panelController?.resumeNotifications() + } + @objc private func showPanelAction() { panelController?.showPanel() } diff --git a/panel/Panel.swift b/panel/Panel.swift index d1bcde8..1b4b502 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -172,6 +172,8 @@ struct PanelContentView: View { Spacer() + muteBell + // One combined hint instead of per-tab keycaps — keeps the strip // uncluttered while still surfacing the shortcut range. HStack(spacing: 2) { @@ -184,6 +186,32 @@ struct PanelContentView: View { .padding(.vertical, 8) } + // Global-mute toggle in the header. A click mutes for the configured + // default duration (or resumes if already muted); the menu bar offers + // explicit durations. Reading nav.muteTick establishes the dependency so + // the countdown re-renders off the controller's 30s timer. + private var muteBell: some View { + _ = nav.muteTick + let muted = nav.isMuted + return Button { + if muted { nav.actions?.resumeNotifications() } + else { nav.actions?.muteFor(nav.muteDurationMinutes) } + } label: { + HStack(spacing: 3) { + Image(systemName: muted ? "bell.slash.fill" : "bell") + .font(.caption) + if muted, let until = nav.muteUntil { + Text(PanelNav.muteRemainingLabel(until: until)) + .font(.caption2.monospacedDigit()) + } + } + .foregroundStyle(muted ? Color.accentColor : Color.secondary) + } + .buttonStyle(.plain) + .help(muted ? "Muted — click to resume notifications" + : "Mute notifications for \(nav.muteDurationMinutes) min") + } + private func tab(_ mode: PanelMode, label: String, count: Int, dotColor: Color? = nil) -> some View { let isActive = nav.mode == mode return Button { @@ -297,6 +325,10 @@ struct PanelContentView: View { private var footer: some View { PageFooter { if store.events.isEmpty { + // Mute is still useful with an empty list — it pre-silences + // incoming nudges (e.g. heading into a meeting), so keep the + // shortcut discoverable even here. + FooterHint(label: muteLabel, keys: ["M"]) FooterHint(label: "Hide", keys: ["Esc"]) } else { if let primary = primaryActionLabel { @@ -312,11 +344,16 @@ struct PanelContentView: View { FooterHint(label: "Snooze", keys: ["S"]) .opacity(snoozeEnabled ? 1.0 : 0.35) FooterHint(label: dismissLabel, keys: ["⌫"]) + FooterHint(label: muteLabel, keys: ["M"]) FooterHint(label: "Hide", keys: ["Esc"]) } } } + // "Mute" toggles to "Resume" while a timed mute is active — mirrors the + // header bell and the menu-bar "Resume notifications" item. + private var muteLabel: String { nav.isMuted ? "Resume" : "Mute" } + private var snoozeEnabled: Bool { guard let selected = store.selectedEvent else { return false } return selected.kind == .permission && selected.hasActionButton @@ -536,7 +573,6 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private var permissionsWC: PermissionsWindowController? private var updateChecker: UpdateChecker? private var updater: Updater? - private let quotaProbe = QuotaProbe() private let claudeCliQuotaProbe = ClaudeCliQuotaProbe() private let codexQuotaProbe = CodexQuotaProbe() private let antigravityUsageProbe = AntigravityUsageProbe() @@ -687,7 +723,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, runBootstrap: { [weak self] in self?.runBootstrap() }, quit: { Bootstrap.userQuit() }, expandFromCompact: { [weak self] in self?.expandFromCompact() }, - exitCompactMode: { [weak self] in self?.exitCompactMode() } + exitCompactMode: { [weak self] in self?.exitCompactMode() }, + muteFor: { [weak self] minutes in self?.muteFor(minutes: minutes) }, + resumeNotifications: { [weak self] in self?.resumeNotifications() } ) nav.setHotkey = { [weak self] spec in self?.registerHotkey(spec: spec) ?? false @@ -1208,7 +1246,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // MARK: - Quota polling - // Fires QuotaProbe on a recurring timer. Cadence varies: 60s while the + // Fires the quota probes on a recurring timer. Cadence varies: 60s while the // panel is visible (keeps the Usage tab feeling alive), longer when // hidden (default 5 min, configurable via STACKNUDGE_USAGE_POLL_MIN). // Re-evaluating on every tick keeps the timer schedule responsive to @@ -1243,48 +1281,35 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private func runQuotaProbe() { guard quotaTrackingEnabled else { return } nav.quotaSyncing = true - // CLI probe first — Claude CLI reads its own keychain, so our process - // never triggers the periodic password prompt. Only fall back to the - // direct API probe when the CLI invocation failed outright (binary - // missing, spawn/timeout error, or unparseable envelope). The CLI's - // own soft-fail (rate-limited cold cache, no bucket lines) is treated - // as "leave the prior snapshot alone" — NOT a reason to re-prompt - // the user via the keychain path. + // Claude quota comes solely from the `claude` CLI (`claude --print + // /usage`), which reads its own keychain — our process never touches + // the keychain or hits Anthropic's API directly. A soft-fail + // (rate-limited cold cache, no bucket lines) holds the prior snapshot + // untouched; a hard-fail (no `claude` on PATH, spawn/timeout, or + // unparseable envelope) surfaces an error in the Usage tab. claudeCliQuotaProbe.fetch { [weak self] snapshot in guard let self else { return } + self.nav.quotaSyncing = false if let snapshot { - self.nav.quotaSyncing = false - self.nav.usingClaudeCliProbe = true - self.nav.usingPlaintextCredentials = false self.nav.quota = snapshot self.nav.quotaError = nil self.nav.quotaLastUpdated = Date() self.evaluateQuotaThresholds(snapshot) - return - } - if self.claudeCliQuotaProbe.isRateLimited { - // Soft-fail: hold the prior snapshot, don't fall back to the - // API probe (which would just hit the same upstream limit). - self.nav.quotaSyncing = false - self.nav.usingClaudeCliProbe = true - return - } - // Hard-fail from the CLI probe → try the API probe. - self.quotaProbe.fetch { [weak self] snapshot in - guard let self else { return } - self.nav.quotaSyncing = false - self.nav.usingClaudeCliProbe = false - self.nav.usingPlaintextCredentials = self.quotaProbe.usingPlaintextCredentials - if let snapshot { - self.nav.quota = snapshot - self.nav.quotaError = nil - self.nav.quotaLastUpdated = Date() - self.evaluateQuotaThresholds(snapshot) - } else if self.quotaProbe.isRateLimited { - self.nav.quotaError = "Rate-limited by Anthropic — retrying shortly." - } else if self.quotaProbe.lastProbeFailed { - self.nav.quotaError = "Quota data unavailable — the usage endpoint may have changed." + } else if self.claudeCliQuotaProbe.isRateLimited { + // Soft-fail: hold any prior snapshot. On a cold first probe + // (no snapshot yet) surface a rate-limit note so the tab shows + // that instead of sitting on a bare "Loading…" spinner until + // the backoff clears. + if self.nav.quota == nil { + self.nav.quotaError = "Claude usage rate-limited — retrying shortly." } + } else { + // Hard-fail: the CLI couldn't run or its output didn't parse. + // Drop any stale snapshot so the Usage tab surfaces the error + // state instead of rendering old bars as if they were current. + self.nav.quota = nil + self.nav.quotaLastUpdated = nil + self.nav.quotaError = "Claude usage unavailable — run `claude /usage` to check your session." } } // Codex (ChatGPT-plan) rate limits — read locally from the newest @@ -1860,6 +1885,13 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } private func postBannerIfNeeded(_ event: NudgeEvent) { + // Timed global mute swallows every interrupting output — banner, + // sound, voice, and the activate-immediately focus jump — for all + // events including permission prompts and welcome. The event has + // already been appended to the store (onAppend runs before this), so + // it still shows in the Events list; only the interruption is muted. + if nav.isMuted { return } + let config = PanelConfig.load() // Mascot/ripple still fire — they're driven from onAppend, not here. @@ -2007,6 +2039,42 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, center.add(req, withCompletionHandler: nil) } + // MARK: - Timed global mute + + // Fires every 30s while muted: refreshes the countdown UI and lifts the + // mute once the window passes. Nil when not muted. + private var muteTicker: Timer? + + // Suppress all banner/sound/voice output for `minutes`, then auto-lift. + // The state (nav.muteUntil) is transient, so a relaunch clears it. Muting + // also dismisses any banner already on screen and cuts any in-flight voice + // utterance, so it silences an in-flight nudge rather than only future ones. + func muteFor(minutes: Int) { + nav.muteUntil = Date().addingTimeInterval(TimeInterval(minutes * 60)) + nav.muteTick += 1 + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + Speaker.stopAllAudio() + muteTicker?.invalidate() + muteTicker = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + guard let self else { return } + guard let until = self.nav.muteUntil, until > Date() else { + self.resumeNotifications() + return + } + self.nav.muteTick += 1 + self.menuBar?.refreshMuteBadge(until: until) + } + menuBar?.refreshMuteBadge(until: nav.muteUntil) + } + + func resumeNotifications() { + muteTicker?.invalidate() + muteTicker = nil + nav.muteUntil = nil + nav.muteTick += 1 + menuBar?.refreshMuteBadge(until: nil) + } + // Snooze: mark the event, schedule a Timer to clear the snooze flag and // re-post a fresh banner after `seconds`. The hook stays blocked on the // FIFO the whole time. If the event is removed (resolved or dismissed) @@ -2021,6 +2089,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let current = self.store.events.first(where: { $0.id == event.id }) else { return } self.store.setSnoozedUntil(id: current.id, nil) + // Respect an active global mute. The snooze re-fire posts a banner + // directly, bypassing postBannerIfNeeded's mute gate, so guard it + // here too. The snooze flag is already cleared, so the event stays + // visible in the panel — only the banner interruption is dropped. + guard !self.nav.isMuted else { return } self.postBanner(for: current.with(snoozedUntil: nil)) } } @@ -2635,12 +2708,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, actOnSelected(approve: false) case KeyCode.rKey, KeyCode.delete, KeyCode.forwardDelete: dismissSelected() + case KeyCode.mKey: + toggleGlobalMute() default: return false } return true } + // Events-tab keyboard toggle for the global timed mute — mirrors the + // header bell. Mutes for the configured default duration, or resumes if + // already muted. (In the Sessions tab, M mutes the selected session; both + // read as "M = mute", scoped to the tab.) + private func toggleGlobalMute() { + if nav.isMuted { resumeNotifications() } + else { muteFor(minutes: nav.muteDurationMinutes) } + } + private func snoozeSelected(for seconds: TimeInterval) { guard let event = store.selectedEvent, event.kind == .permission, diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index e29b3b0..62c9581 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -135,7 +135,7 @@ enum GithubSignIn: Equatable { // exhaustive, so the compiler flags any row left unhandled. enum SettingsRow: Hashable { case permissions, update, hotkey - case banner, muteWhenFocused, pinPanel, keepOpenWhenEmpty, launchAtLogin + case banner, muteWhenFocused, mute, muteDuration, pinPanel, keepOpenWhenEmpty, launchAtLogin case widget, widgetCorner, widgetOpacity, widgetContent, mascot, theme case soundEnabled, agentDoneSound, permissionSound case voiceEnabled, voice, voiceSpeed, speakHotkey, downloadVoiceModel @@ -161,6 +161,10 @@ struct SettingsActions { // resize happens synchronously before SwiftUI re-renders. let expandFromCompact: () -> Void let exitCompactMode: () -> Void + // Timed global mute — muteFor takes a duration in minutes. Both are wired + // by PanelController, which owns the expiry timer and the menu-bar badge. + let muteFor: (Int) -> Void + let resumeNotifications: () -> Void } // Owns the panel's navigation state plus the live values the Settings page @@ -182,6 +186,27 @@ final class PanelNav: ObservableObject { @Published var soundEnabled: Bool = true @Published var voiceEnabled: Bool = false @Published var muteWhenFocused: Bool = true + // Timed global mute. When `muteUntil` is a future date, PanelController + // suppresses ALL banner/sound/voice output (permission prompts included) + // until it passes, then auto-lifts. Deliberately transient — never read + // in loadFromConfig and never written to disk, so it resets on relaunch. + // `muteTick` is bumped by PanelController's 30s countdown timer purely to + // re-render the tab-strip/pill countdown while muted (the SwiftUI views + // reference it so a value-less change still refreshes them). + @Published var muteUntil: Date? + @Published var muteTick: Int = 0 + // Persistent default duration (minutes) the bell button and menu use. + @Published var muteDurationMinutes: Int = 30 + static let muteDurationOptions: [Int] = [15, 30, 60, 120] + var isMuted: Bool { + guard let until = muteUntil else { return false } + return until > Date() + } + // "14m" style remainder for the menu-bar badge, tab-strip bell, and menu. + static func muteRemainingLabel(until: Date) -> String { + let mins = Int(ceil(max(0, until.timeIntervalSinceNow) / 60)) + return "\(mins)m" + } @Published var panelPinned: Bool = true // When true, clearing the last event leaves the panel open instead of // auto-hiding (STACKNUDGE_KEEP_OPEN_WHEN_EMPTY). Default off = prior behaviour. @@ -229,9 +254,9 @@ final class PanelNav: ObservableObject { // PostUpdateView (mode = .postUpdate). Cleared on dismiss. @Published var postUpdateVersion: String? @Published var postUpdateNotes: String? - // Latest /api/oauth/usage snapshot. Driven by the QuotaProbe poller in - // PanelController. nil before the first probe completes, or when the - // probe failed (e.g. user denied keychain access, 401, 429). + // Latest quota snapshot from `claude /usage`. Driven by the CLI probe + // poller in PanelController. nil before the first probe completes, or + // when the probe failed (no `claude` on PATH, not signed in, parse error). @Published var quota: QuotaSnapshot? @Published var quotaLastUpdated: Date? // True while a probe is in-flight. Set by PanelController around the @@ -242,14 +267,6 @@ final class PanelNav: ObservableObject { // state so a silently-changed endpoint isn't read as "still loading". // Cleared on the next successful probe. @Published var quotaError: String? - // True when the Claude token was read from the plaintext - // ~/.claude/.credentials.json rather than the Keychain — any same-user - // process can read that file. Drives a warning in Settings → Usage. - @Published var usingPlaintextCredentials: Bool = false - // True when quota was sourced via `claude --print /usage` rather than - // the direct API probe. Claude CLI reads its own keychain (no prompts - // for us). Drives the corresponding status line in Settings → Usage. - @Published var usingClaudeCliProbe: Bool = false // Set when the event socket failed to bind at startup — the panel is then // deaf to every agent notification. Drives the banner at the top of the // Events tab so the failure isn't silent. Cleared when the socket binds. @@ -625,7 +642,7 @@ final class PanelNav: ObservableObject { if !missingPermissions.isEmpty { rows.append(.permissions) } if updateAvailable != nil { rows.append(.update) } rows += [.hotkey, - .banner, .muteWhenFocused, .pinPanel, .keepOpenWhenEmpty, .launchAtLogin, + .banner, .muteWhenFocused, .mute, .muteDuration, .pinPanel, .keepOpenWhenEmpty, .launchAtLogin, .widget, .widgetCorner, .widgetOpacity, .widgetContent, .mascot, .theme, .soundEnabled, .agentDoneSound, .permissionSound, .voiceEnabled, .speakHotkey] @@ -674,6 +691,10 @@ final class PanelNav: ObservableObject { soundEnabled = ConfigFile.bool(config, "STACKNUDGE_SOUND", default: true) voiceEnabled = ConfigFile.bool(config, "STACKNUDGE_VOICE", default: false) muteWhenFocused = ConfigFile.bool(config, "STACKNUDGE_MUTE_WHEN_FOCUSED", default: true) + // Persistent default only — the live `muteUntil` is transient and + // intentionally left untouched here so config reloads never clear it. + let rawMuteDuration = Int(config["STACKNUDGE_MUTE_DURATION_MIN"] ?? "") ?? 30 + muteDurationMinutes = Self.muteDurationOptions.min(by: { abs($0 - rawMuteDuration) < abs($1 - rawMuteDuration) }) ?? 30 panelPinned = ConfigFile.bool(config, "STACKNUDGE_PANEL_PIN", default: true) keepOpenWhenEmpty = ConfigFile.bool(config, "STACKNUDGE_KEEP_OPEN_WHEN_EMPTY", default: false) // Source of truth for the toggle is the plist's presence on disk @@ -958,8 +979,6 @@ final class PanelNav: ObservableObject { quota = nil quotaLastUpdated = nil quotaError = nil - usingPlaintextCredentials = false - usingClaudeCliProbe = false } } @@ -1004,6 +1023,18 @@ final class PanelNav: ObservableObject { case .muteWhenFocused: muteWhenFocused.toggle() ConfigFile.write(key: "STACKNUDGE_MUTE_WHEN_FOCUSED", value: muteWhenFocused ? "true" : "false") + case .mute: + // Action row: Enter and ←/→ both toggle the timed global mute. + // Nothing persists — the controller owns the expiry timer + the + // menu-bar badge (muteFor / resumeNotifications). Uses the current + // default duration, same as the header bell and the M shortcut. + if isMuted { actions?.resumeNotifications() } else { actions?.muteFor(muteDurationMinutes) } + case .muteDuration: + let list = Self.muteDurationOptions + let idx = list.firstIndex(of: muteDurationMinutes) ?? 1 + let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count + muteDurationMinutes = list[next] + ConfigFile.write(key: "STACKNUDGE_MUTE_DURATION_MIN", value: String(muteDurationMinutes)) case .pinPanel: panelPinned.toggle() ConfigFile.write(key: "STACKNUDGE_PANEL_PIN", value: panelPinned ? "true" : "false") diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 5fe3bea..216cf36 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -1,9 +1,8 @@ import AppKit import Foundation -import Security import SwiftUI -// Quota tier reported by Anthropic's /api/oauth/usage endpoint. Each tier +// Quota tier surfaced by the `claude` CLI's `/usage` output. Each tier // is a percentage of a budget with a known reset time. resetsAt is optional // because some tiers (extra_usage, future tiers) don't reset on a cycle. struct QuotaTier: Equatable { @@ -11,8 +10,8 @@ struct QuotaTier: Equatable { let resetsAt: Date? } -// Snapshot of the user's Claude Code quota at a point in time. Mirrors the -// shape of the JSON returned by api.anthropic.com/api/oauth/usage. +// Snapshot of the user's Claude Code quota at a point in time. Built from +// the `claude --print /usage` output — the same data Claude Code's TUI shows. // // fiveHour → "Current session" — the 5-hour rolling window the TUI shows. // sevenDay → "Current week (all models)". @@ -44,275 +43,14 @@ enum UsageClient: String, CaseIterable, Hashable { } } -// Reads the Claude Code OAuth token and calls the (unofficial) -// /api/oauth/usage endpoint to fetch the user's quota state. The endpoint -// is the exact data source Claude Code's TUI statusline uses, so output -// matches `/usage` 1:1. -// -// Failure paths (no token, denied keychain access, network error, 401, 429) -// return nil and log to stderr — quota tracking is informational, never a -// critical path. The caller (PanelController) will just retry on the next -// poll tick. -final class QuotaProbe { - - static let endpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")! - static let keychainService = "Claude Code-credentials" - - private let session: URLSession - - // In-memory cache of the OAuth token. Touched only from the main queue - // (fetch is invoked from PanelController's main-thread timer, and the - // 401-retry path hops back to main before clearing). - // - // Why cache: Claude Code rotates this keychain item periodically and - // each rotation wipes the trusted-app ACL we got from "Always Allow", - // so the next SecItemCopyMatching re-fires the password prompt. Most - // rotations happen well before the old token actually expires, so by - // holding the token in memory and only re-reading the keychain when - // the API rejects it (HTTP 401), we skip the prompts tied to rotations - // that didn't invalidate the token we already have. - private var cachedToken: String? - - // Subscription tier read from the same claudeAiOauth blob as the token - // (e.g. "max", "pro"). Set whenever we read the blob; persists while the - // token is cached. nil when the field isn't present. - private var lastSubscriptionType: String? - - // Surfaced to the UI by PanelController after each probe (both touched only - // on the main queue, like cachedToken). lastProbeFailed is true when we had - // a token but the request/parse failed — distinct from having no token at - // all and distinct from being rate-limited (see `isRateLimited`). - // usingPlaintextCredentials is true when the token came from the plaintext - // credentials file rather than the Keychain. - private(set) var lastProbeFailed = false - private(set) var usingPlaintextCredentials = false - - // 429 backoff. While `Date() < retryAfterUntil`, fetch() short-circuits - // and returns nil without hitting the network so the same source IP - // doesn't keep stoking the rate limit. - private var retryAfterUntil: Date? - private static let defaultRetryAfter: TimeInterval = 15 * 60 - static let maxRetryAfter: TimeInterval = 60 * 60 - - // True while we're sitting inside an active 429 backoff window. Kept - // separate from `lastProbeFailed` so the UI can surface a "rate-limited" - // message that's accurate (the endpoint isn't broken — we're just told - // to come back later). - var isRateLimited: Bool { - guard let until = retryAfterUntil else { return false } - return until > Date() - } - - init() { - let cfg = URLSessionConfiguration.ephemeral - cfg.timeoutIntervalForRequest = 10 - cfg.timeoutIntervalForResource = 15 - self.session = URLSession(configuration: cfg) - } - - // One-shot probe. Calls completion on the main queue. - func fetch(completion: @escaping (QuotaSnapshot?) -> Void) { - fetch(retried: false, completion: completion) - } - - private func fetch(retried: Bool, completion: @escaping (QuotaSnapshot?) -> Void) { - if let until = retryAfterUntil, until > Date() { - // Still inside the 429 backoff window. Don't fire the request — - // it would re-trigger the rate limit and reset our cooldown. - // Not a "failure": `isRateLimited` already covers this state for - // the UI; leaving `lastProbeFailed` alone keeps the "endpoint may - // have changed" message accurate. - completion(nil) - return - } - - let token: String - if let cached = cachedToken { - token = cached - } else if let fresh = readAccessToken() { - cachedToken = fresh - token = fresh - } else { - lastProbeFailed = false - completion(nil) - return - } - - var request = URLRequest(url: Self.endpoint) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("stack-nudge", forHTTPHeaderField: "User-Agent") - - session.dataTask(with: request) { [weak self] data, response, _ in - let http = response as? HTTPURLResponse - let code = http?.statusCode - - // 401 = the token we used is no longer valid (Claude Code rotated - // and the old token has actually expired, not just been replaced). - // Drop the cache and re-read the keychain exactly once. - if code == 401, !retried { - DispatchQueue.main.async { - self?.cachedToken = nil - self?.fetch(retried: true, completion: completion) - } - return - } - - guard let data, code == 200, - let snapshot = Self.parse(data, planType: self?.lastSubscriptionType ?? nil) else { - if let code, code != 200 { - FileHandle.standardError.write(Data( - "stack-nudge: /api/oauth/usage returned \(code)\n".utf8)) - } - let backoff = code == 429 - ? Self.parseRetryAfter(http) ?? Self.defaultRetryAfter - : nil - DispatchQueue.main.async { - // A real 429 isn't a parse/shape failure either — it's - // the server telling us to slow down. Only flag - // `lastProbeFailed` for the genuine "something broke" - // codes (non-200, non-429). - self?.lastProbeFailed = code != 429 - if let backoff { - self?.retryAfterUntil = Date().addingTimeInterval(backoff) - } - completion(nil) - } - return - } - DispatchQueue.main.async { - self?.lastProbeFailed = false - self?.retryAfterUntil = nil - completion(snapshot) - } - }.resume() - } - - private static func parseRetryAfter(_ http: HTTPURLResponse?) -> TimeInterval? { - parseRetryAfter(http?.value(forHTTPHeaderField: "Retry-After")) - } - - // RFC 7231: Retry-After is either an HTTP-date or a non-negative integer - // delta-seconds. Anthropic typically returns the integer form; tolerate - // either, and clamp to a max so a misconfigured value can't strand us. - // Exposed `internal` so the unit tests can exercise the parser directly - // without having to construct an HTTPURLResponse. - static func parseRetryAfter(_ raw: String?) -> TimeInterval? { - guard let raw else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return nil } - if let seconds = TimeInterval(trimmed), seconds >= 0 { - return min(seconds, maxRetryAfter) - } - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = TimeZone(identifier: "GMT") - f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" - if let date = f.date(from: trimmed) { - return min(max(0, date.timeIntervalSinceNow), maxRetryAfter) - } - return nil - } - - // MARK: - Token sources - - // Prefer ~/.claude/.credentials.json when present. Claude Code itself - // reads this file before falling back to the Keychain, and users who - // want to avoid the periodic Keychain prompt (caused by Claude rotating - // the Keychain item ~every 8h, wiping the ACL grant — anthropics/claude-code#22144, - // closed as not planned) can opt in by writing the file at mode 0600. - private func readAccessToken() -> String? { - if let token = readCredentialsFile() { - usingPlaintextCredentials = true - return token - } - usingPlaintextCredentials = false - return readKeychainToken() - } - - private func readCredentialsFile() -> String? { - let path = "\(NSHomeDirectory())/.claude/.credentials.json" - guard FileManager.default.fileExists(atPath: path), - let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let blob = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let oauth = blob["claudeAiOauth"] as? [String: Any] else { - return nil - } - lastSubscriptionType = oauth["subscriptionType"] as? String - guard let token = oauth["accessToken"] as? String, !token.isEmpty else { return nil } - return token - } - - private func readKeychainToken() -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: Self.keychainService, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess, let data = item as? Data else { - if status != errSecItemNotFound { - FileHandle.standardError.write(Data( - "stack-nudge: keychain read failed (OSStatus \(status))\n".utf8)) - } - return nil - } - guard let blob = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let oauth = blob["claudeAiOauth"] as? [String: Any] else { - return nil - } - lastSubscriptionType = oauth["subscriptionType"] as? String - guard let token = oauth["accessToken"] as? String, !token.isEmpty else { return nil } - return token - } - - // MARK: - Parsing - - private static let iso: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }() - private static let isoNoFrac: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime] - return f - }() - - private static func parse(_ data: Data, planType: String?) -> QuotaSnapshot? { - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { return nil } - - return QuotaSnapshot( - fiveHour: tier(json["five_hour"]), - sevenDay: tier(json["seven_day"]), - sevenDayOpus: tier(json["seven_day_opus"]), - sevenDaySonnet: tier(json["seven_day_sonnet"]), - planType: planType - ) - } - - private static func tier(_ raw: Any?) -> QuotaTier? { - guard let dict = raw as? [String: Any], - let utilization = dict["utilization"] as? Double else { return nil } - let resetsAt: Date? = (dict["resets_at"] as? String).flatMap { - iso.date(from: $0) ?? isoNoFrac.date(from: $0) - } - return QuotaTier(utilization: utilization, resetsAt: resetsAt) - } -} - // MARK: - Usage tab UI // Renders the current QuotaSnapshot as labelled progress bars. One bar per // non-nil tier; an "Extra usage" row when the user's plan has top-up enabled. // Empty state covers two cases: // 1. Probe hasn't returned yet (loading) — show a spinner. -// 2. Probe failed (no token / denied keychain / 401 / 429) — instructional -// copy pointing the user at `claude /usage` and the in-app settings. +// 2. Probe failed (no `claude` CLI on PATH, not signed in, or unparseable +// output) — instructional copy pointing the user at `claude /usage`. struct UsageView: View { @ObservedObject var nav: PanelNav @@ -542,7 +280,7 @@ struct UsageView: View { .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - Text("StackNudge reads an unofficial Claude usage endpoint; a Claude Code update can change its shape. This clears on its own once the endpoint is reachable and parseable again.") + Text("StackNudge reads your usage by running `claude /usage`. This clears on its own once the CLI is on PATH, signed in, and its output is parseable again.") .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) @@ -554,7 +292,7 @@ struct UsageView: View { Text("Loading usage…") .font(.subheadline) .foregroundStyle(.secondary) - Text("Requires a signed-in Claude Code session, or a Codex session on a ChatGPT plan. The first Claude read may prompt the system keychain.") + Text("Requires the `claude` CLI signed in (Claude reads usage via `claude /usage`), or a Codex session on a ChatGPT plan.") .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) diff --git a/panel/Settings.swift b/panel/Settings.swift index 83053c8..0b1a363 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -3,6 +3,11 @@ import SwiftUI enum SettingsKind { case toggle, cycle, action + // Like .action (acts in place) but renders a bell instead of the + // navigation chevron — the chevron would wrongly imply a drill-in. + // Trailing reflects mute state: a plain bell when idle, bell.slash + + // countdown while muted, matching the header bell / menu-bar glyph. + case mute } struct SettingsView: View { @@ -14,6 +19,13 @@ struct SettingsView: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 14) { + // Timed-mute status banner — pinned at the very top + // only while a mute is active, so landing in Settings + // mid-mute makes it obvious (and one-click undoable). + // Mouse-only like the reconciliation banner; keyboard + // users toggle mute via the .mute row in Toggles, so + // this isn't part of the settingsRows index. + muteBanner // Reconciliation banner — appears above all other // rows when one or more detected agents lack our // notify.sh hook. Not part of the keyboard index; @@ -53,6 +65,8 @@ struct SettingsView: View { section("Toggles") { row(.banner, label: "Banner notifications", kind: .toggle, value: nav.bannerEnabled ? "On" : "Off") row(.muteWhenFocused, label: "Mute when focused", kind: .toggle, value: nav.muteWhenFocused ? "On" : "Off") + row(.mute, label: nav.isMuted ? "Resume notifications" : "Mute notifications", kind: .mute, value: muteRowValue) + row(.muteDuration, label: "Mute duration", kind: .cycle, value: "\(nav.muteDurationMinutes) min") row(.pinPanel, label: "Pin panel", kind: .toggle, value: nav.panelPinned ? "On" : "Off") row(.keepOpenWhenEmpty, label: "Keep open when empty", kind: .toggle, value: nav.keepOpenWhenEmpty ? "On" : "Off") row(.launchAtLogin, label: "Launch at login", kind: .toggle, value: nav.launchAtLogin ? "On" : "Off") @@ -99,9 +113,6 @@ struct SettingsView: View { row(.pollFrequency, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) row(.contextAlert, label: "Context alert at", kind: .cycle, value: contextAlertLabel) row(.showRemaining, label: "Show remaining", kind: .toggle, value: nav.quotaShowRemaining ? "On" : "Off", enabled: nav.quotaTrackingEnabled) - if nav.quotaTrackingEnabled { - probeSourceLabel - } } section("Tickets") { @@ -170,6 +181,59 @@ struct SettingsView: View { } } + // Trailing value for the .mute action row: the live countdown while + // muted (re-rendered by nav.muteTick every 30s), empty otherwise so the + // row reads as a plain "Mute notifications" affordance. + private var muteRowValue: String { + guard nav.isMuted, let until = nav.muteUntil else { return "" } + return PanelNav.muteRemainingLabel(until: until) + " left" + } + + // Pinned status banner shown only while a timed mute is active. Accent + // tint + bell.slash to match the header bell's muted state; the Resume + // button reuses the same resumeNotifications action. nav.muteTick drives + // the countdown refresh (bumped by the controller's 30s ticker). + @ViewBuilder + private var muteBanner: some View { + let _ = nav.muteTick + if nav.isMuted, let until = nav.muteUntil { + HStack(spacing: 10) { + Image(systemName: "bell.slash.fill") + .font(.body) + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 1) { + Text("Notifications muted") + .font(.subheadline.weight(.semibold)) + Text("\(PanelNav.muteRemainingLabel(until: until)) left · banners, sounds & voice paused") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Button { + nav.actions?.resumeNotifications() + } label: { + Text("Resume") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor) + ) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(0.12)) + ) + .transition(.opacity) + } + } + // 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. @@ -332,28 +396,6 @@ struct SettingsView: View { nav.contextAlertThresholdK == 0 ? "Off" : "\(nav.contextAlertThresholdK)K" } - // Only surfaced when there's something the user needs to know: the - // plaintext-file security tradeoff, or the keychain-rotation prompts. - // The CLI probe path (the default) is silent — it's the happy state. - @ViewBuilder private var probeSourceLabel: some View { - if nav.usingPlaintextCredentials { - label("⚠︎ Reading the Claude token from ~/.claude/.credentials.json (plaintext) — any process running as you can read it.", - color: .orange) - } else if !nav.usingClaudeCliProbe { - label("Reading via macOS Keychain — periodic password prompts are expected when Claude Code rotates its token.", - color: .secondary) - } - } - - private func label(_ text: String, color: Color) -> some View { - Text(text) - .font(.caption) - .foregroundStyle(color) - .padding(.horizontal, 14) - .padding(.top, 2) - .fixedSize(horizontal: false, vertical: true) - } - private var checkForUpdatesStatus: String { switch nav.updateCheckStatus { case .idle: return "" @@ -494,8 +536,9 @@ struct SettingsView: View { .onTapGesture { nav.selectedSettingIndex = nav.index(of: id) // For actions, single-click is enough. For toggles/cycles a click - // on the row also acts so mouse users don't have to keyboard. - if kind == .action || kind == .toggle { + // on the row also acts so mouse users don't have to keyboard. The + // mute row acts in place too, so it clicks like an action. + if kind == .action || kind == .toggle || kind == .mute { nav.activate() } } @@ -537,6 +580,19 @@ private struct SettingsRowView: View { Text(value) .font(.subheadline.monospaced()) .foregroundStyle(selected ? Color.primary : .secondary) + case .mute: + // value is non-empty ("27m left") only while muted, so it doubles + // as the muted flag for the glyph/tint — no separate state needed. + HStack(spacing: 8) { + if !value.isEmpty { + Text(value) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Image(systemName: value.isEmpty ? "bell" : "bell.slash.fill") + .font(.callout) + .foregroundStyle(value.isEmpty ? Color.secondary : Color.accentColor) + } case .action: HStack(spacing: 8) { if !value.isEmpty {