Skip to content

feat(panel): timed global mute + CLI-only Claude usage probe#130

Merged
hiskudin merged 5 commits into
mainfrom
feat/mute-notifications-duration
Jul 15, 2026
Merged

feat(panel): timed global mute + CLI-only Claude usage probe#130
hiskudin merged 5 commits into
mainfrom
feat/mute-notifications-duration

Conversation

@hiskudin

@hiskudin hiskudin commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

This branch carries two independent changes.

1. Timed global mute for notifications (feat)

A "mute everything for X minutes" control that suppresses every interrupting output — banner, sound, voice, and the activate-immediately focus jump — for all events including permission prompts — then auto-lifts when the timer expires. Events still flow into the panel while muted.

  • Transient: in-memory only, resets on relaunch. A single gate at the top of postBannerIfNeeded does the suppression.
  • The default one-click duration is a config-backed preference (STACKNUDGE_MUTE_DURATION_MIN, allowed 15/30/60/120, default 30).

Surfaces

  • Panel header bell button with live countdown
  • Menu-bar submenu (For 15m / 30m / 1h / 2h) + bell.slash icon + countdown badge while muted
  • Settings → Toggles: a Mute/Resume action row (bell glyph, not a nav chevron), a muted-only top banner with one-click Resume, and the duration cycle row
  • Compact widget bell.slash glyph while muted
  • M keyboard shortcut in the Events tab, with footer hint

2. Drop keychain/API Claude usage probe, rely solely on the claude CLI (refactor)

Removes QuotaProbe — the only code that read the Claude OAuth token from the macOS Keychain (SecItemCopyMatching) or the plaintext ~/.claude/.credentials.json, then called the unofficial api.anthropic.com/api/oauth/usage endpoint directly.

Claude quota now comes solely from claude --print /usage (ClaudeCliQuotaProbe), which reads its own keychain — so StackNudge never touches the keychain or hits the API, eliminating the periodic password prompts caused by Claude Code rotating its keychain item.

Also removes the now-dead usingPlaintextCredentials / usingClaudeCliProbe flags, the Settings "probe source" status label, and QuotaProbeRetryAfterTests; rewords the empty-state copy and comments (no more "unofficial endpoint" / keychain-prompt language).

Warning

User-visible behavioral change: without the claude CLI on PATH / signed in, the Usage tab now shows "Claude usage unavailable — run claude /usage to check your session" instead of silently falling back to the keychain/API. Fine for this app's audience (it exists to watch Claude Code), but a conscious trade-off. Codex/Antigravity probes and GitHub's keychain use are untouched.

Docs

README.md brought in line with both changes:

  • Mute — new "Mute for a while" note plus the M row in the Events-tab table, the Mute notifications ▸ menu-bar row, the Settings-tab Mute/Resume + duration row, and the compact-widget glyph (no countdown there — corrected).
  • Quota source — rewrote the Usage-tab data-source paragraph (no more /api/oauth/usage / keychain "Always Allow" copy) and softened the uninstall note now that we create no keychain entries.
  • Drive-by fix (pre-existing): the tab list was wrong — the panel has five tabs (Tickets was missing), Tickets is Cmd+4, and Settings is Cmd+5 (was documented as Cmd+4).

Testing

  • ./build.sh clean + Developer-ID signed after each change and after merging origin/main.
  • Added MuteStateTests (transient state, mute-row order, countdown-label rounding); removed the obsolete QuotaProbeRetryAfterTests; ClaudeCliQuotaProbeTests unchanged.
  • Mute feature installed and exercised locally: bell, M key, menu submenu, Settings row + banner, and confirmed actual suppression of banner/sound/voice/permission-prompt while events still land in the panel and the timer auto-lifts.

🤖 Generated with Claude Code

hiskudin and others added 3 commits July 14, 2026 09:51
Add a "mute everything for X minutes" control that suppresses every
interrupting output — banner, sound, voice, and the activate-immediately
focus jump — for all events including permission prompts, then auto-lifts
when the timer expires. Events still flow into the panel while muted.

The mute is transient (in-memory only, resets on relaunch); a single gate
at the top of postBannerIfNeeded does the suppression. The default
one-click duration is a config-backed preference
(STACKNUDGE_MUTE_DURATION_MIN, 15/30/60/120, default 30).

Surfaces:
- Panel header bell button with live countdown
- Menu-bar submenu (For 15m/30m/1h/2h) + bell.slash icon while muted
- Settings: Mute/Resume action row (bell glyph, not a nav chevron), a
  muted-only top banner with one-click Resume, and the duration cycle row
- Compact widget bell.slash glyph
- M keyboard shortcut in the Events tab, with footer hint

Add MuteStateTests covering the transient state, mute-row order, and the
countdown label rounding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e CLI

Delete QuotaProbe — the only code that read the Claude OAuth token from the
macOS Keychain (SecItemCopyMatching) or the plaintext
~/.claude/.credentials.json, then called the unofficial
api.anthropic.com/api/oauth/usage endpoint directly. Claude quota now comes
solely from `claude --print /usage` (ClaudeCliQuotaProbe), which reads its
own keychain, so our process never touches the keychain or hits the API.

On a CLI hard-fail (no `claude` on PATH, not signed in, or unparseable
output) the Usage tab now surfaces an error pointing at `claude /usage`
instead of falling back to the keychain/API path.

Also drop the now-dead usingPlaintextCredentials / usingClaudeCliProbe flags,
the Settings probe-source status label, and QuotaProbeRetryAfterTests; reword
the empty-state copy and comments (no more "unofficial endpoint" / keychain
prompt language). Codex/Antigravity probes and GitHub's keychain use are
untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@StuBehan StuBehan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two correctness gaps, both small fixes; plus non-blocking cleanup, detailed inline.

1. Panel.swift:2081 — snooze re-fire bypasses the mute gate. snoozeEvent's timer re-posts via postBanner(for:), skipping the if nav.isMuted { return } gate in postBannerIfNeeded. A snoozed prompt fires a banner during an active mute window — the only involuntary banner path the gate doesn't cover. Fix: re-post through postBannerIfNeeded(current.with(snoozedUntil: nil)) (also picks up session-mute/focus handling), or guard with guard !nav.isMuted else { return }.

2. Panel.swift:1302 — hard-fail quota error is never shown when a stale snapshot exists (inline).

Note: the global-mute gate also suppresses bypassMute (welcome/critical) events. The comment documents this as intended — flagging to confirm it's deliberate.

Comment thread panel/Panel.swift
// Soft-fail: leave the prior snapshot in place, no error.
} else {
// Hard-fail: the CLI couldn't run or its output didn't parse.
self.nav.quotaError = "Claude usage unavailable — run `claude /usage` to check your session."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-fail sets quotaError but leaves nav.quota populated. emptyState (the error surface) only renders when availableUsageClients is empty, and a stale Claude snapshot keeps .claude available (PanelNav.swift:469), so the old bars keep rendering and this error is never shown. After the claude CLI is removed/signed out, the tab shows stale usage as current with no error.

Fix: clear nav.quota and quotaLastUpdated on hard-fail, or thread a stale/error flag into the tier view.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 400e60d — the hard-fail branch now clears nav.quota + nav.quotaLastUpdated, so once the claude CLI is removed/signed out the Usage tab surfaces the error state instead of rendering the stale snapshot as current. Good catch; this became reachable exactly because the API fallback that previously masked it is gone.

Comment thread panel/Panel.swift
Comment thread panel/Panel.swift
Comment thread panel/PanelNav.swift
- Snooze re-fire now respects an active global mute. The snooze timer posts
  a banner directly, bypassing postBannerIfNeeded's `if nav.isMuted` gate;
  guard it so a snoozed nudge can't fire a banner during a mute window.
- muteFor now cuts any in-flight voice utterance (Speaker.stopAllAudio),
  making good on the "silences an in-flight nudge" contract — previously it
  only dismissed the banner while a running Speaker.speak played on.
- Quota hard-fail drops the stale snapshot (nav.quota/quotaLastUpdated) so
  the Usage tab shows the error state instead of rendering old bars as if
  current once the claude CLI is gone/signed out.
- Cold rate-limited soft-fail (no prior snapshot) surfaces a rate-limit note
  instead of an indefinite "Loading…" spinner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hiskudin

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review @StuBehan. Pushed 400e60d covering the correctness items (inline replies on each thread):

  • Snooze re-fire bypassing the mute gate — fixed. The snooze timer clears the snooze flag, then guard !nav.isMuted else { return } before re-posting, so a snoozed nudge can't fire a banner during a mute window. I kept it banner-only rather than routing through postBannerIfNeeded(...) — that would turn a re-fire into a full re-notification (sound/voice/activate-immediately), a behavior change beyond the fix.
  • Stale-snapshot hard-fail, cold rate-limit spinner, in-flight voice — all fixed (see inline).

On the bypassMute note: yes, suppressing welcome/critical events during an active mute is intentional. The spec for this feature is "mute everything, permission prompts included," for a bounded window that auto-lifts — a user who explicitly muted for N minutes shouldn't be pierced by a welcome banner. Happy to carve out a truly-critical class later if we decide one warrants it, but that'd be a deliberate follow-up, not part of this window's contract.

Cleanup suggestions captured as follow-ups (noted inline on PanelNav.swift).

@StuBehan StuBehan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

- Add the timed-mute feature: M key, header bell, menu-bar submenu,
  Settings row, STACKNUDGE_MUTE_DURATION_MIN, in-memory/auto-lift behavior.
- Rewrite the Usage-tab data source: quota now comes from `claude /usage`
  via the CLI, so stack-nudge never touches the keychain or the API — no
  more password prompt. Drop the stale /api/oauth/usage + keychain copy.
- Fix pre-existing tab errors: panel has five tabs (Tickets was missing);
  Settings is Cmd+5, Tickets is Cmd+4.
- Soften the uninstall note now that we create no keychain entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hiskudin hiskudin merged commit da9e5d6 into main Jul 15, 2026
6 checks passed
@hiskudin hiskudin deleted the feat/mute-notifications-duration branch July 15, 2026 10:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants