Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`.
Expand Down Expand Up @@ -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.

Expand All @@ -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 |
|-----|--------|
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
46 changes: 46 additions & 0 deletions Tests/StackNudgePanelCoreTests/MuteStateTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
63 changes: 0 additions & 63 deletions Tests/StackNudgePanelCoreTests/QuotaProbeRetryAfterTests.swift

This file was deleted.

6 changes: 6 additions & 0 deletions notify.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
15 changes: 15 additions & 0 deletions panel/CompactView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ struct CompactView: View {
HStack(alignment: .center, spacing: 4) {
gaugeCluster
Spacer(minLength: 0)
muteIndicator
expandButton
}
.frame(maxHeight: .infinity)
Expand All @@ -72,6 +73,7 @@ struct CompactView: View {
headline
sessionBadge
Spacer(minLength: 0)
muteIndicator
expandButton
}
.padding(.horizontal, 12)
Expand Down Expand Up @@ -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))
Expand Down
63 changes: 63 additions & 0 deletions panel/MenuBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
Loading