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
44 changes: 12 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,7 @@ Add that to your shell profile.

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.

The panel is installed and registered as a launchd agent by `./install.sh` — no opt-in needed. To run quietly without macOS banners (panel-only):

```bash
STACKNUDGE_BANNER=false # optional — suppress macOS banners when using the panel
```
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.

Expand Down Expand Up @@ -186,7 +182,7 @@ Bars are color-coded: green below 50%, yellow 50–80%, red 80%+. Reset times sh

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).

Polls every 60 seconds while the panel is visible, or every 5 minutes by default in the background (configurable via Settings → Usage → "Poll frequency"; underlying key `STACKNUDGE_USAGE_POLL_MIN`). On the Usage tab: `r` triggers a manual sync, `p` pauses/resumes the poller.
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.

#### Threshold-crossing notifications

Expand All @@ -207,7 +203,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), 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), 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+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).

| Key | Action |
|-----|--------|
Expand All @@ -216,9 +212,9 @@ Reachable from the tab strip or `Cmd+4`. Keyboard-driven rows for hotkey, behavi
| `⏎` | Activate (toggles flip, action rows fire, hotkey row records a new combo) |
| `Esc` | Back to events |

The hotkey row records live: press `⏎` on it, press the new combo, and stack-nudge re-registers the global hotkey and writes it to config. If the combo is already grabbed by another app, the previous one stays and an inline error explains why.
The hotkey row records live: press `⏎` on it, press the new combo, and stack-nudge re-registers the global hotkey. If the combo is already grabbed by another app, the previous one stays and an inline error explains why.

stack-nudge also watches `~/.stack-nudge/config` for external edits, so changes you make via "Open config file…" or another editor flow back into the running panel without a restart.
All Settings choices persist to `~/.stack-nudge/config` (a `KEY=value` text file). You don't need to edit it directly — Settings is the source of truth — but it's there for backup/sync or scripted setup.

### Menu bar (macOS)

Expand All @@ -227,14 +223,14 @@ When the panel daemon is running, a bell icon appears in your menu bar. The same
| Item | What it does |
|------|--------------|
| `Hotkey · …` | Shows your current hotkey (info only) |
| `Show banners` | Toggles `STACKNUDGE_BANNER`. Enabling fires a confirmation banner. |
| `Voice notifications` | Toggles `STACKNUDGE_VOICE`. Enabling speaks *"Voice notifications enabled"*. |
| `Show banners` | Toggles macOS banner notifications. Enabling fires a confirmation banner. |
| `Voice notifications` | Toggles spoken notifications. Enabling speaks *"Voice notifications enabled"*. |
| `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 |
| `Quit stack-nudge panel` | Exits the daemon |

Toggles re-read the live config every time the menu opens, so changes you make to `~/.stack-nudge/config` directly stay in sync. Banner and voice changes take effect immediately for the next nudge — no daemon restart needed.
Menu changes take effect immediately for the next nudge — no daemon restart needed.

### Permissions (macOS)

Expand Down Expand Up @@ -287,15 +283,11 @@ Per-pool customisations are stored in `~/.stack-nudge/phrases.user.json` and mer

stack-nudge uses [stackvox](https://github.com/StackOneHQ/stackvox), an offline Kokoro-82M TTS engine that speaks notifications aloud with ~13 ms latency. `./install.sh` pip-installs it from PyPI into an isolated venv at `~/.stack-nudge/venv` — no separate setup needed.

**Enable in your config** (`~/.stack-nudge/config`):

```bash
STACKNUDGE_VOICE=true
```
**Enable** via Settings → Voice → "Voice notifications".

The voice daemon starts automatically on first notification and is registered as a login item so it stays running across reboots.

Voice fires whenever `STACKNUDGE_VOICE=true`, alongside the banner and panel surfaces. The frontmost-suppression check still applies — when the source window is already focused, sound, banner, panel post, *and* voice are all suppressed (you don't need a nudge for the thing you're looking at).
Voice fires alongside the banner and panel surfaces. The frontmost-suppression check still applies — when the source window is already focused, sound, banner, panel post, *and* voice are all suppressed (you don't need a nudge for the thing you're looking at).

When voice is enabled, the chime is suppressed automatically — voice replaces sound rather than playing alongside it.

Expand All @@ -308,12 +300,7 @@ Voice messages are picked at random from per-event phrase pools and labelled wit

Phrase pools live in `~/.stack-nudge/phrases/` — `en.sh`, `fr.sh`, `hi.sh`, `it.sh`, `pt.sh`. The right pool is picked from the configured voice's prefix (`af_*`/`am_*`/`bf_*`/`bm_*` → en, `ff_*` → fr, `hf_*`/`hm_*` → hi, `if_*`/`im_*` → it, `pf_*`/`pm_*` → pt) so a French voice speaks French phrasing.

Optional tuning (also in `~/.stack-nudge/config`):

```bash
STACKNUDGE_VOICE_NAME=af_heart # voice ID (run `~/.stack-nudge/venv/bin/stackvox voices` for the full list)
STACKNUDGE_VOICE_SPEED=1.1 # playback speed (1.0 = normal)
```
Tune via Settings → Voice → "Voice" (cycle voices with preview) and "Speed" (1.0 = normal).

## Sounds

Expand All @@ -322,14 +309,7 @@ STACKNUDGE_VOICE_SPEED=1.1 # playback speed (1.0 = normal)
| Agent done | `Glass.aiff` | freedesktop bell | 800 Hz beep |
| Waiting for approval | `Ping.aiff` | freedesktop bell | 1200 Hz beep |

Any file from `/System/Library/Sounds/` works on macOS: Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink. Override per-event in `~/.stack-nudge/config`:

```bash
STACKNUDGE_SOUND_STOP=Glass
STACKNUDGE_SOUND_PERMISSION=Ping
```

The Settings tab exposes the same picks with audio preview on each change.
Any file from `/System/Library/Sounds/` works on macOS: Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink. Override per-event via Settings → Sounds → "Agent done" / "Permission" (cycle plays a preview on each step).

## Uninstall

Expand Down
39 changes: 27 additions & 12 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,8 @@ struct PanelContentView: View {
private var footer: some View {
PageFooter {
if store.events.isEmpty {
FooterHint(label: "Compact", keys: ["M"])
FooterHint(label: "Hide", keys: ["Esc"])
if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) }
FooterHint(label: "Hide", keys: ["Esc"])
} else {
if let primary = primaryActionLabel {
FooterHint(label: primary, keys: ["⏎"], primary: true)
Expand All @@ -282,8 +282,8 @@ struct PanelContentView: View {
FooterHint(label: "Snooze", keys: ["S"])
.opacity(snoozeEnabled ? 1.0 : 0.35)
FooterHint(label: "Dismiss", keys: ["⌫"])
FooterHint(label: "Compact", keys: ["M"])
FooterHint(label: "Hide", keys: ["Esc"])
if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) }
FooterHint(label: "Hide", keys: ["Esc"])
}
}
}
Expand Down Expand Up @@ -656,6 +656,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
updater = Updater(nav: nav)

startQuotaPolling()
// The pill (CompactView) reads sessions.sessions for the busy/idle
// headline and mascot state, so polling has to run as soon as the
// app is up — not gated on the Sessions tab being visible. Sessions
// view still calls startPolling on appear (idempotent), which keeps
// it polling even if some future code stops the timer.
sessions.startPolling()

// Whenever SessionStore re-polls (~every 3s while polling, on
// panel-becomes-visible otherwise), refresh transcript stats for
Expand Down Expand Up @@ -788,14 +794,23 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
private func handlePostUpdateStatus(result: (state: String, version: String, error: String?)) {
switch result.state {
case "success":
nav.postUpdateVersion = result.version.isEmpty ? "?" : result.version
nav.postUpdateNotes = nil
nav.mode = .postUpdate
// Expand out of the pill so the changelog renders in the full
// panel rather than getting clipped into the widget frame.
// Expand the window BEFORE flipping the mode. The order matters:
// setting nav.mode = .postUpdate triggers a SwiftUI re-render
// immediately, and if the window is still pill-sized at that
// point, PostUpdateView (or even the Events page during a
// transition) renders into the 320×56 frame and looks crushed.
// Resizing first guarantees the full-panel content lands in a
// full-panel-sized window.
if nav.compactMode, !nav.compactExpanded {
nav.compactExpanded = true
// applyCompactLayout already ran via the Combine sink;
// call again here so the synchronous setFrame is committed
// before the mode flip below schedules SwiftUI work.
applyCompactLayout()
}
nav.postUpdateVersion = result.version.isEmpty ? "?" : result.version
nav.postUpdateNotes = nil
nav.mode = .postUpdate
// Auto-open the panel so the user immediately sees the
// "what shipped" view rather than discovering it via hotkey.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
Expand Down Expand Up @@ -903,7 +918,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
applyCompactLayout()
}

// Applies the user-configured pill opacity to the window. Only takes
// Applies the user-configured widget opacity to the window. Only takes
// effect in pill mode; expanded panel + full-mode are always fully
// opaque so the user can actually read content.
private func applyCompactAlpha() {
Expand Down Expand Up @@ -1803,8 +1818,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,

// From the pill (compact-not-expanded), M expands to full panel.
// Mirrors the M-to-collapse shortcut shown in the full panel's
// footer. The pill must be key for this to land, so a single
// click anywhere on the pill arms it.
// footer. Gated on compactMode so the shortcut quietly no-ops
// when the user has turned off the widget.
if nav.compactMode, !nav.compactExpanded,
event.keyCode == KeyCode.mKey,
mods.intersection([.command, .control, .option, .shift]).isEmpty {
Expand Down
Loading