From eab3ab455fd616ed6fae8e03892e78fa8269f091 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 2 Jun 2026 14:16:08 +0100 Subject: [PATCH 1/5] fix(updater): explicit bundle relaunch + post-update render order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updater bug: After a successful atomic swap to ~/Applications/StackNudge.app, the updater calls `launchctl kickstart -k gui//com.stackonehq.stack-nudge` to restart. When no panel launchd agent is registered (users who never ran the Bootstrap install, or dev builds running outside ~/Applications), kickstart silently fails and scheduleAutoQuit then kills the panel with nothing to bring it back. User is stuck on the old binary in memory. Now: after kickstart, NSWorkspace.openApplication launches the new bundle explicitly with createsNewApplicationInstance=true so it starts even when an old-path process is still alive. Post-update render order: handlePostUpdateStatus set nav.mode = .postUpdate BEFORE nav.compactExpanded = true. The mode flip triggered a SwiftUI re-render that rendered PostUpdateView (or even the still-active Events page mid- transition) into the pill-sized 320×56 window — looked crushed. Resize the window first; then change the mode so SwiftUI sees both together in a full-panel-sized frame. Co-Authored-By: Claude Opus 4.7 --- panel/Panel.swift | 19 ++++++++++++++----- panel/Updater.swift | 31 +++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index cfd3415..e0cb306 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -788,14 +788,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 diff --git a/panel/Updater.swift b/panel/Updater.swift index 8014310..7cf9f11 100644 --- a/panel/Updater.swift +++ b/panel/Updater.swift @@ -151,17 +151,36 @@ final class Updater { // 7. Write status file so the next launch surfaces the welcome view. try writeStatusFile(state: "success", version: release.version, error: nil) - // 8. Restart launchd → current process dies, new bundle starts. + // 8. Restart. Try launchctl first (handles cases where a launchd + // agent is loaded and will respawn us), then explicitly launch + // the new bundle via NSWorkspace — this covers users who never + // went through Bootstrap install, never have the panel plist, + // or whose currently-running process is from a different path + // than ~/Applications/ (e.g. a dev build still in the picture). + // Without the explicit launch, kickstart silently fails and + // the auto-quit kills the panel with nothing to restart it. setPhase(.done) - appendLog("Restarting via launchd…") + appendLog("Restarting…") try kickstartLaunchd() - - // launchctl kickstart -k will SIGTERM us; if for some reason it - // doesn't, fall back to a self-quit after a brief delay so the - // user isn't stuck staring at "Restarting…" forever. + relaunchInstalledBundle() scheduleAutoQuit() } + private func relaunchInstalledBundle() { + let url = URL(fileURLWithPath: Self.installedAppPath) + let cfg = NSWorkspace.OpenConfiguration() + cfg.activates = true + // newInstance=true bypasses the running-instance check so the new + // bundle starts even when an old process at a different path is + // still alive (it'll be killed by scheduleAutoQuit shortly after). + cfg.createsNewApplicationInstance = true + NSWorkspace.shared.openApplication(at: url, configuration: cfg) { [weak self] _, error in + if let error { + self?.appendLog("relaunch via NSWorkspace failed: \(error.localizedDescription)") + } + } + } + // MARK: - Release manifest private struct ReleaseInfo { From f8374e13cfb5ef6c87f139b53481d994a14d831e Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 2 Jun 2026 14:43:36 +0100 Subject: [PATCH 2/5] fix(sessions): start polling at app launch, not Sessions tab onAppear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill (CompactView) reads sessions.sessions for the busy-session headline and mascot state, but SessionStore.startPolling() was only called from Sessions.swift's .onAppear — so the pill displayed stale data until the user visited the Sessions tab once. Worse, Sessions .onDisappear called stopPolling, freezing the pill again on exit. Now: start polling in PanelController.applicationDidFinishLaunching and drop the Sessions .onDisappear stop. The Sessions .onAppear still calls startPolling as an idempotent safety. 3s interval, ps + filesystem scan only — cheap enough to run for the app's lifetime. Co-Authored-By: Claude Opus 4.7 --- panel/Panel.swift | 6 ++++++ panel/Sessions.swift | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index e0cb306..cc9d5f1 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -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 diff --git a/panel/Sessions.swift b/panel/Sessions.swift index f860056..55a1c3c 100644 --- a/panel/Sessions.swift +++ b/panel/Sessions.swift @@ -18,8 +18,10 @@ struct SessionsView: View { footer } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .onAppear { store.startPolling() } - .onDisappear { store.stopPolling() } + // Polling is started at app launch (PanelController) so the pill + // reads fresh session state without requiring this tab to be open. + // Kept here as a no-op safety in case the timer was ever stopped. + .onAppear { store.startPolling() } } private var emptyState: some View { From 0c9e7d5880fdb39702d09948748687ad74f17c3d Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 2 Jun 2026 16:40:35 +0100 Subject: [PATCH 3/5] docs(readme): point at Settings UI, drop manual config-edit snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pivot to UI-first documentation: every voice/sound/banner setting now has a Settings row, so README's "edit ~/.stack-nudge/config" blocks were stale. Replaced each with a "Settings → ..." breadcrumb, dropped config-key references, and added one line under Settings noting that the config file is the underlying persistence (not for direct editing). Also expanded the Settings-tab row inventory to include the Widget section (corner, mascot, pill opacity) and the new Usage "Show remaining" toggle. Co-Authored-By: Claude Opus 4.7 --- README.md | 44 ++++++++++++-------------------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 4a230ca..cb31178 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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, pill 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 | |-----|--------| @@ -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) @@ -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) @@ -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. @@ -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 @@ -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 From 36aae98170995246c773851af1c038c6f5bf3c28 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 2 Jun 2026 17:44:21 +0100 Subject: [PATCH 4/5] =?UTF-8?q?feat(widget):=20Settings=20=E2=86=92=20Widg?= =?UTF-8?q?et=20toggle=20(default=20on)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings back the off-switch that was removed in PR #65. Users who don't want the pill can disable it and get classic show/hide-the-panel behavior; hotkey toggles the full panel like before, and the M shortcut + "Compact" footer hints no-op / disappear when widget is off. Persists as STACKNUDGE_COMPACT_MODE (default true). Widget corner, mascot, and pill opacity rows grey out while widget is off so the implication is obvious. Toggling off also clears compactExpanded so the window restores to its saved full-panel size cleanly. Release-As: 1.14.1 Co-Authored-By: Claude Opus 4.7 --- panel/Panel.swift | 12 ++--- panel/PanelNav.swift | 97 ++++++++++++++++++++++------------------ panel/SessionUsage.swift | 2 +- panel/Sessions.swift | 2 +- panel/Settings.swift | 47 +++++++++---------- 5 files changed, 85 insertions(+), 75 deletions(-) diff --git a/panel/Panel.swift b/panel/Panel.swift index cc9d5f1..e5de45b 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -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) @@ -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"]) } } } @@ -1818,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 { diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index b132cf1..db0376b 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -285,28 +285,29 @@ final class PanelNav: ObservableObject { // 2 Mute when focused toggle // 3 Pin panel toggle // 4 Launch at login toggle - // 5 Widget corner cycle - // 6 Mascot cycle - // 7 Pill opacity cycle (40/60/80/100%; applied window-level) - // 8 Sound enabled toggle (gates rows 9 + 10) - // 9 Agent done sound cycle - // 10 Permission sound cycle - // 11 Voice notifications toggle (gates rows 12 + 13) - // 12 Voice cycle (or "Download model" action) - // 13 Speed cycle - // 14 Quota tracking toggle (master; gates rows 15-17) - // 15 Quota alerts toggle - // 16 Alert threshold cycle - // 17 Poll frequency cycle - // 18 Context alert at cycle (per-session token thresholds) - // 19 Show remaining toggle (invert gauge readout: 70% left vs 30% used) - // 20 Edit phrases… action - // 21 Check permissions… action - // 22 Open config file… action - // 23 View release notes… action - // 24 Check for updates… action - // 25 Uninstall stack-nudge action - // 26 Quit panel action + // 5 Widget toggle (gates rows 6-8; off = classic show/hide-panel mode) + // 6 Widget corner cycle + // 7 Mascot cycle + // 8 Pill opacity cycle (40/60/80/100%; applied window-level) + // 9 Sound enabled toggle (gates rows 10 + 11) + // 10 Agent done sound cycle + // 11 Permission sound cycle + // 12 Voice notifications toggle (gates rows 13 + 14) + // 13 Voice cycle (or "Download model" action) + // 14 Speed cycle + // 15 Quota tracking toggle (master; gates rows 16-18) + // 16 Quota alerts toggle + // 17 Alert threshold cycle + // 18 Poll frequency cycle + // 19 Context alert at cycle (per-session token thresholds) + // 20 Show remaining toggle (invert gauge readout: 70% left vs 30% used) + // 21 Edit phrases… action + // 22 Check permissions… action + // 23 Open config file… action + // 24 View release notes… action + // 25 Check for updates… action + // 26 Uninstall stack-nudge action + // 27 Quit panel action // MARK: - Disk I/O @@ -340,7 +341,7 @@ final class PanelNav: ObservableObject { quotaPollMinutes = Self.quotaPollMinuteOptions.min(by: { abs($0 - rawPoll) < abs($1 - rawPoll) }) ?? 5 let rawCtx = Int(config["STACKNUDGE_CONTEXT_ALERT_THRESHOLD"] ?? "") ?? 0 contextAlertThresholdK = Self.contextAlertThresholdOptions.min(by: { abs($0 - rawCtx) < abs($1 - rawCtx) }) ?? 0 - compactMode = true // always-on; toggle removed from Settings + compactMode = ConfigFile.bool(config, "STACKNUDGE_COMPACT_MODE", default: true) compactCorner = CompactCorner(rawValue: config["STACKNUDGE_COMPACT_CORNER"] ?? "") ?? .topRight mascot = MascotKind(rawValue: config["STACKNUDGE_MASCOT"] ?? "") ?? .robot @@ -538,13 +539,13 @@ final class PanelNav: ObservableObject { } else { startVoiceModelDownload() } - case 20: actions?.editPhrases() - case 21: actions?.checkPermissions() - case 22: actions?.openConfig() - case 23: actions?.openReleaseNotes() - case 24: actions?.checkForUpdates() - case 25: actions?.beginUninstall() - case 26: actions?.quit() + case 21: actions?.editPhrases() + case 22: actions?.checkPermissions() + case 23: actions?.openConfig() + case 24: actions?.openReleaseNotes() + case 25: actions?.checkForUpdates() + case 26: actions?.beginUninstall() + case 27: actions?.quit() default: applyCycle(forward: true) } } @@ -600,36 +601,44 @@ final class PanelNav: ObservableObject { "stack-nudge: setLaunchAtLogin(\(target)) failed: \(error)\n".utf8)) } case 5: + // Widget on/off. When toggling off, collapse the pill to + // expanded-panel mode first; otherwise the next applyCompactLayout + // wouldn't know whether to restore the saved full-panel size. + compactMode.toggle() + ConfigFile.write(key: "STACKNUDGE_COMPACT_MODE", + value: compactMode ? "true" : "false") + if !compactMode { compactExpanded = false } + case 6: let list = CompactCorner.allCases let idx = list.firstIndex(of: compactCorner) ?? 0 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count compactCorner = list[next] ConfigFile.write(key: "STACKNUDGE_COMPACT_CORNER", value: compactCorner.rawValue) - case 6: + case 7: let list = MascotKind.allCases let idx = list.firstIndex(of: mascot) ?? 0 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count mascot = list[next] ConfigFile.write(key: "STACKNUDGE_MASCOT", value: mascot.rawValue) - case 7: + case 8: let list = Self.compactAlphaOptions let idx = list.firstIndex(of: compactAlpha) ?? (list.count - 1) let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count compactAlpha = list[next] ConfigFile.write(key: "STACKNUDGE_COMPACT_ALPHA", value: String(format: "%.2f", compactAlpha)) - case 8: + case 9: soundEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_SOUND", value: soundEnabled ? "true" : "false") - case 9: - soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 10: - soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + soundStop = step(soundStop, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_STOP", preview: true) case 11: + soundPermission = step(soundPermission, in: Self.macSounds, forward: forward, key: "STACKNUDGE_SOUND_PERMISSION", preview: true) + case 12: voiceEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_VOICE", value: voiceEnabled ? "true" : "false") - case 12: + case 13: // Pre-download: the row is an action, not a cycle. Treat // left/right arrow as a trigger so a user discovering the // row keyboard-only can still start the download. @@ -641,17 +650,17 @@ final class PanelNav: ObservableObject { voice = step(voice, in: voicesAvailable, forward: forward, key: "STACKNUDGE_VOICE_NAME", preview: false) let phrase = Self.voicePreviewPhrases.randomElement() ?? "Hello." Speaker.speak(phrase, voice: voice, speed: String(format: "%.2f", voiceSpeed)) - case 13: + case 14: let next = forward ? voiceSpeed + Self.speedStep : voiceSpeed - Self.speedStep voiceSpeed = max(Self.speedMin, min(Self.speedMax, (next * 100).rounded() / 100)) ConfigFile.write(key: "STACKNUDGE_VOICE_SPEED", value: String(format: "%.2f", voiceSpeed)) - case 14: - toggleQuotaTracking() case 15: + toggleQuotaTracking() + case 16: quotaAlertsEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_QUOTA_ALERTS", value: quotaAlertsEnabled ? "true" : "false") - case 16: + case 17: // Cycle through the static thresholds list. Index wraps in both // directions so the user can dial in either way. let list = Self.quotaThresholds @@ -660,21 +669,21 @@ final class PanelNav: ObservableObject { quotaAlertThreshold = list[next] ConfigFile.write(key: "STACKNUDGE_QUOTA_THRESHOLD", value: String(quotaAlertThreshold)) - case 17: + case 18: let list = Self.quotaPollMinuteOptions let idx = list.firstIndex(of: quotaPollMinutes) ?? 2 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count quotaPollMinutes = list[next] ConfigFile.write(key: "STACKNUDGE_USAGE_POLL_MIN", value: String(quotaPollMinutes)) - case 18: + case 19: let list = Self.contextAlertThresholdOptions let idx = list.firstIndex(of: contextAlertThresholdK) ?? 0 let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count contextAlertThresholdK = list[next] ConfigFile.write(key: "STACKNUDGE_CONTEXT_ALERT_THRESHOLD", value: String(contextAlertThresholdK)) - case 19: + case 20: quotaShowRemaining.toggle() ConfigFile.write(key: "STACKNUDGE_QUOTA_SHOW_REMAINING", value: quotaShowRemaining ? "true" : "false") diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 27a21a7..f5785c7 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -250,7 +250,7 @@ struct UsageView: View { FooterHint(label: "Sync now", keys: ["R"]) } FooterHint(label: nav.quotaTrackingEnabled ? "Pause" : "Resume", keys: ["P"]) - FooterHint(label: "Compact", keys: ["M"]) + if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } FooterHint(label: "Hide", keys: ["Esc"]) } } diff --git a/panel/Sessions.swift b/panel/Sessions.swift index 55a1c3c..1473470 100644 --- a/panel/Sessions.swift +++ b/panel/Sessions.swift @@ -83,7 +83,7 @@ struct SessionsView: View { FooterHint(label: "Focus", keys: ["⏎"]) FooterHint(label: "Rename", keys: ["N"]) FooterHint(label: "Kill", keys: ["⌫"]) - FooterHint(label: "Compact", keys: ["M"]) + if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } FooterHint(label: "Back", keys: ["Esc"]) } } diff --git a/panel/Settings.swift b/panel/Settings.swift index c53ec7a..29b7ee3 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -53,44 +53,45 @@ struct SettingsView: View { } section("Widget") { - row(5 + off, label: "Widget corner", kind: .cycle, value: nav.compactCorner.label) - row(6 + off, label: "Mascot", kind: .cycle, value: nav.mascot.label) - row(7 + off, label: "Pill opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%") + row(5 + off, label: "Widget", kind: .toggle, value: nav.compactMode ? "On" : "Off") + row(6 + off, label: "Widget corner", kind: .cycle, value: nav.compactCorner.label, enabled: nav.compactMode) + row(7 + off, label: "Mascot", kind: .cycle, value: nav.mascot.label, enabled: nav.compactMode) + row(8 + off, label: "Pill opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%", enabled: nav.compactMode) } section("Sounds") { - row(8 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") - row(9 + off, label: "Agent done", kind: .cycle, value: nav.soundStop, enabled: nav.soundEnabled) - row(10 + off, label: "Permission", kind: .cycle, value: nav.soundPermission, enabled: nav.soundEnabled) + row(9 + off, label: "Sound enabled", kind: .toggle, value: nav.soundEnabled ? "On" : "Off") + row(10 + off, label: "Agent done", kind: .cycle, value: nav.soundStop, enabled: nav.soundEnabled) + row(11 + off, label: "Permission", kind: .cycle, value: nav.soundPermission, enabled: nav.soundEnabled) } section("Voice") { - row(11 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") + row(12 + off, label: "Voice notifications", kind: .toggle, value: nav.voiceEnabled ? "On" : "Off") if nav.voiceModelCached { - row(12 + off, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) - row(13 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) + row(13 + off, label: "Voice", kind: .cycle, value: voiceLabel, enabled: nav.voiceEnabled) + row(14 + off, label: "Speed", kind: .cycle, value: String(format: "%.2f×", nav.voiceSpeed), enabled: nav.voiceEnabled) } else { - voiceModelDownloadRow(index: 12 + off) + voiceModelDownloadRow(index: 13 + off) } } section("Usage") { - row(14 + off, label: "Quota tracking", kind: .toggle, value: nav.quotaTrackingEnabled ? "On" : "Off") - row(15 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off", enabled: nav.quotaTrackingEnabled) - row(16 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) - row(17 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) - row(18 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) - row(19 + off, label: "Show remaining", kind: .toggle, value: nav.quotaShowRemaining ? "On" : "Off", enabled: nav.quotaTrackingEnabled) + row(15 + off, label: "Quota tracking", kind: .toggle, value: nav.quotaTrackingEnabled ? "On" : "Off") + row(16 + off, label: "Quota alerts", kind: .toggle, value: nav.quotaAlertsEnabled ? "On" : "Off", enabled: nav.quotaTrackingEnabled) + row(17 + off, label: "Alert threshold", kind: .cycle, value: "\(nav.quotaAlertThreshold)%", enabled: nav.quotaTrackingEnabled && nav.quotaAlertsEnabled) + row(18 + off, label: "Poll frequency", kind: .cycle, value: "\(nav.quotaPollMinutes) min", enabled: nav.quotaTrackingEnabled) + row(19 + off, label: "Context alert at", kind: .cycle, value: contextAlertLabel) + row(20 + off, label: "Show remaining", kind: .toggle, value: nav.quotaShowRemaining ? "On" : "Off", enabled: nav.quotaTrackingEnabled) } section("Actions") { - row(20 + off, label: "Edit phrases…", kind: .action, value: "") - row(21 + off, label: "Check permissions…", kind: .action, value: "") - row(22 + off, label: "Open config file…", kind: .action, value: "") - row(23 + off, label: "View release notes…", kind: .action, value: "") - row(24 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) - row(25 + off, label: "Uninstall StackNudge…", kind: .action, value: "") - row(26 + off, label: "Quit panel", kind: .action, value: "") + row(21 + off, label: "Edit phrases…", kind: .action, value: "") + row(22 + off, label: "Check permissions…", kind: .action, value: "") + row(23 + off, label: "Open config file…", kind: .action, value: "") + row(24 + off, label: "View release notes…", kind: .action, value: "") + row(25 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) + row(26 + off, label: "Uninstall StackNudge…", kind: .action, value: "") + row(27 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter From 533c85de33965e89789be04ccbd301147994431a Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 2 Jun 2026 18:02:07 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(widget):=20rename=20Pill=E2=86=92Widget?= =?UTF-8?q?=20opacity;=20toggle-on=20keeps=20panel=20expanded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings → Widget → "Pill opacity" renamed to "Widget opacity" so the Widget section uses consistent terminology (Widget / Widget corner / Mascot / Widget opacity). - Toggling the Widget switch On from Settings used to immediately collapse the open panel into the 320×56 pill — but the CompactView rendered inside the still-large frame for a frame, looking like a giant pill. Set compactExpanded=true when toggling on so the user stays in the full panel; the pill takes effect on the next Esc / focus-loss instead. Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- panel/Panel.swift | 2 +- panel/PanelNav.swift | 17 ++++++++++++----- panel/Settings.swift | 2 +- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index cb31178..bc6e30b 100644 --- a/README.md +++ b/README.md @@ -203,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), widget (corner, mascot picker, pill 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+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 | |-----|--------| diff --git a/panel/Panel.swift b/panel/Panel.swift index e5de45b..2045f99 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -918,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() { diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index db0376b..5391e19 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -288,7 +288,7 @@ final class PanelNav: ObservableObject { // 5 Widget toggle (gates rows 6-8; off = classic show/hide-panel mode) // 6 Widget corner cycle // 7 Mascot cycle - // 8 Pill opacity cycle (40/60/80/100%; applied window-level) + // 8 Widget opacity cycle (40/60/80/100%; applied window-level) // 9 Sound enabled toggle (gates rows 10 + 11) // 10 Agent done sound cycle // 11 Permission sound cycle @@ -601,13 +601,20 @@ final class PanelNav: ObservableObject { "stack-nudge: setLaunchAtLogin(\(target)) failed: \(error)\n".utf8)) } case 5: - // Widget on/off. When toggling off, collapse the pill to - // expanded-panel mode first; otherwise the next applyCompactLayout - // wouldn't know whether to restore the saved full-panel size. + // Widget on/off. The user is in the full panel (that's where + // the Settings row lives). On toggle-on we set compactExpanded + // so the panel stays open at full size; the pill only appears + // when the user dismisses (Esc / focus-out). On toggle-off we + // clear compactExpanded so the next applyCompactLayout commits + // the saved full-panel frame cleanly. compactMode.toggle() ConfigFile.write(key: "STACKNUDGE_COMPACT_MODE", value: compactMode ? "true" : "false") - if !compactMode { compactExpanded = false } + if compactMode { + compactExpanded = true + } else { + compactExpanded = false + } case 6: let list = CompactCorner.allCases let idx = list.firstIndex(of: compactCorner) ?? 0 diff --git a/panel/Settings.swift b/panel/Settings.swift index 29b7ee3..e97ac6a 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -56,7 +56,7 @@ struct SettingsView: View { row(5 + off, label: "Widget", kind: .toggle, value: nav.compactMode ? "On" : "Off") row(6 + off, label: "Widget corner", kind: .cycle, value: nav.compactCorner.label, enabled: nav.compactMode) row(7 + off, label: "Mascot", kind: .cycle, value: nav.mascot.label, enabled: nav.compactMode) - row(8 + off, label: "Pill opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%", enabled: nav.compactMode) + row(8 + off, label: "Widget opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%", enabled: nav.compactMode) } section("Sounds") {