From f483dd1462fbd0d5abfe52f3dd9e03ddcc44117b Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 18 Jun 2026 09:45:48 +0100 Subject: [PATCH 1/7] fix(panel): honor explicit Quit; align side text with gauge center Two unrelated fixes bundled together: 1. KeepAlive: true caused launchd to respawn the panel after a user clicks Quit. Switch new installs (install.sh + Bootstrap.writePlist) to the dict form `KeepAlive: { SuccessfulExit: false }` so the process restarts on crashes but respects clean exits. Existing installs get a one-shot migration in cleanupPostUpdateBackup that rewrites the plist on launch and reloads launchd, so the fix takes effect without a reinstall. 2. CompactView's sideText (resting countdown / hover legend) sat above the gauge digit's y-center because its intrinsic height differed from the gauge cluster's. Pin sideText to the gauge cluster's full height (size + 2 to match the halo Circle) so the ZStack's center alignment lines up with QuotaGauge's centerReadout in both compact-content modes. Co-Authored-By: Claude Opus 4.7 --- install.sh | 4 ++-- panel/Bootstrap.swift | 25 ++++++++++++++++++++++++- panel/CompactView.swift | 8 ++++++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index f6f400b..6bd1709 100755 --- a/install.sh +++ b/install.sh @@ -203,10 +203,10 @@ if [[ "$(uname -s)" == "Darwin" ]]; then "${VENV}/bin/stackvox" "serve" echo " Voice daemon registered as launchd agent (starts at login)" - # Single persistent app — always running, restarts on crash. + # Single persistent app — restarts on crash; respects explicit user Quit. register_launchd_agent \ "com.stackonehq.stack-nudge" \ - "always" \ + "on_crash" \ "${INSTALL_DIR}/app.log" \ "$HOME/Applications/StackNudge.app/Contents/MacOS/stack-nudge" echo " App registered as launchd agent (starts at login)" diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index 0077aea..bd6ec63 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -157,6 +157,27 @@ enum Bootstrap { // write them when the user finishes the wizard. retargetLaunchAgentIfNeeded(label: appLabel) retargetLaunchAgentIfNeeded(label: daemonLabel) + migrateKeepAliveIfNeeded(label: appLabel) + } + + // Older installs wrote KeepAlive: true, so launchd respawned the panel + // even after an explicit user Quit. Rewrite to the dict form that + // restarts on crash only. + private static func migrateKeepAliveIfNeeded(label: String) { + let fm = FileManager.default + let plistPath = "\(launchAgentsDir)/\(label).plist" + guard fm.fileExists(atPath: plistPath), + let data = try? Data(contentsOf: URL(fileURLWithPath: plistPath)), + var plist = (try? PropertyListSerialization.propertyList( + from: data, options: [], format: nil)) as? [String: Any] + else { return } + guard (plist["KeepAlive"] as? Bool) == true else { return } + plist["KeepAlive"] = ["SuccessfulExit": false] + guard let updated = try? PropertyListSerialization.data( + fromPropertyList: plist, format: .xml, options: 0) else { return } + try? updated.write(to: URL(fileURLWithPath: plistPath), options: [.atomic]) + _ = try? runLaunchctl(["unload", plistPath]) + _ = try? runLaunchctl(["load", plistPath]) } // Read the on-disk launchd plist for `label`; if its first program- @@ -668,7 +689,9 @@ enum Bootstrap { "Label": label, "ProgramArguments": programArgs, "RunAtLoad": true, - "KeepAlive": true, + // Restart only on crashes (non-zero exit). An explicit user + // Quit ends with a successful exit and should actually quit. + "KeepAlive": ["SuccessfulExit": false], "StandardOutPath": logPath, "StandardErrorPath": logPath, ] diff --git a/panel/CompactView.swift b/panel/CompactView.swift index c11ddaa..c15c36b 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -95,7 +95,7 @@ struct CompactView: View { } private func gaugeClusterBody(size: CGFloat) -> some View { - HStack(spacing: 6) { + HStack(alignment: .center, spacing: 6) { ZStack { if !nav.compactDragging { Circle() @@ -115,8 +115,12 @@ struct CompactView: View { ) .frame(width: size, height: size) } - + // Force sideText to fill the gauge cluster's height so its + // ZStack centers at the same y as the gauge's center digit, + // regardless of which child (countdown vs two-line legend) + // is currently visible. sideText + .frame(height: size + 2) } // Gated on !dragging so we don't fight AppKit's drag handler. .scaleEffect(isHovering && !nav.compactDragging ? 1.07 : 1.0) From 0804c374867ac6c6f7e97b85a0f9b4109e49731a Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 18 Jun 2026 09:54:28 +0100 Subject: [PATCH 2/7] fix(panel): honor 429 Retry-After on the Claude usage probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hammering /api/oauth/usage with the same source IP kept stoking the rate limit — once a 429 streak started it never cleared because every 60s poll kept the window alive. Track retryAfterUntil; while it's in the future, short-circuit fetch() before hitting the network. Parse the Retry-After header (delta-seconds or HTTP-date), fall back to 15 minutes when absent, clamp at 1 hour so a bogus value can't strand the probe. Cleared on the next 200. Co-Authored-By: Claude Opus 4.7 --- panel/SessionUsage.swift | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 4335597..8250946 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -86,6 +86,13 @@ final class QuotaProbe { 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 + private static let maxRetryAfter: TimeInterval = 60 * 60 + init() { let cfg = URLSessionConfiguration.ephemeral cfg.timeoutIntervalForRequest = 10 @@ -99,6 +106,14 @@ final class QuotaProbe { } 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. + lastProbeFailed = true + completion(nil) + return + } + let token: String if let cached = cachedToken { token = cached @@ -138,19 +153,44 @@ final class QuotaProbe { 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 { self?.lastProbeFailed = true + if let backoff { + self?.retryAfterUntil = Date().addingTimeInterval(backoff) + } completion(nil) } return } DispatchQueue.main.async { self?.lastProbeFailed = false + self?.retryAfterUntil = nil completion(snapshot) } }.resume() } + // 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. + private static func parseRetryAfter(_ http: HTTPURLResponse?) -> TimeInterval? { + guard let raw = http?.value(forHTTPHeaderField: "Retry-After") else { return nil } + if let seconds = TimeInterval(raw.trimmingCharacters(in: .whitespaces)), 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: raw) { + return min(max(0, date.timeIntervalSinceNow), maxRetryAfter) + } + return nil + } + // MARK: - Token sources // Prefer ~/.claude/.credentials.json when present. Claude Code itself From bb53e59c2e13e4978120f49134b70afff2b0c3a3 Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 18 Jun 2026 11:33:56 +0100 Subject: [PATCH 3/7] =?UTF-8?q?fix(panel):=20keep=20quit=20quit=20?= =?UTF-8?q?=E2=80=94=20don't=20relaunch=20via=20notify.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User Quit now drops a marker at ~/.stack-nudge/user-quit; notify.sh's ensure_app_running gate honors it and stops opening the bundle on hook events. The marker is cleared on the next manual launch, so reopening restores normal auto-relaunch behavior. Co-Authored-By: Claude Opus 4.7 --- notify.sh | 3 +++ panel/Bootstrap.swift | 13 +++++++++++++ panel/MenuBar.swift | 2 +- panel/Panel.swift | 8 ++++++-- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/notify.sh b/notify.sh index 07bb599..b815acf 100755 --- a/notify.sh +++ b/notify.sh @@ -312,6 +312,9 @@ nudge_debug() { # old bundle is still running with the new hook script — keeps working. ensure_app_running() { [[ -S "$PANEL_SOCK" ]] && return + # User explicitly Quit. Stay quiet until they reopen the app. Marker + # is cleared on the next manual launch by Bootstrap.clearUserQuitMarker. + [[ -f "$HOME/.stack-nudge/user-quit" ]] && return local app_path="" if [[ -d "$HOME/Applications/StackNudge.app" ]]; then app_path="$HOME/Applications/StackNudge.app" diff --git a/panel/Bootstrap.swift b/panel/Bootstrap.swift index bd6ec63..80464a0 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -78,6 +78,7 @@ enum Bootstrap { static let venvSymlinkPath = "\(NSHomeDirectory())/.stack-nudge/venv" static let configPath = "\(NSHomeDirectory())/.stack-nudge/config" static let phrasesDir = "\(NSHomeDirectory())/.stack-nudge/phrases" + static let userQuitMarker = "\(NSHomeDirectory())/.stack-nudge/user-quit" static let launchAgentsDir = "\(NSHomeDirectory())/Library/LaunchAgents" static let appLabel = "com.stackonehq.stack-nudge" @@ -140,6 +141,18 @@ enum Bootstrap { NSWorkspace.shared.recycle([backup]) { _, _ in } } + // User-initiated Quit: drop a marker file so notify.sh's ensure_app_running + // gate doesn't relaunch us on the next hook event. Cleared by + // clearUserQuitMarker() on the next manual app launch. + static func userQuit() { + _ = try? "".write(toFile: userQuitMarker, atomically: true, encoding: .utf8) + NSApp.terminate(nil) + } + + static func clearUserQuitMarker() { + try? FileManager.default.removeItem(atPath: userQuitMarker) + } + static func migrateBundleNameIfNeeded() { let fm = FileManager.default let runningFromNewPath = Bundle.main.bundleURL.lastPathComponent == "StackNudge.app" diff --git a/panel/MenuBar.swift b/panel/MenuBar.swift index 98fda3f..172a403 100644 --- a/panel/MenuBar.swift +++ b/panel/MenuBar.swift @@ -197,6 +197,6 @@ final class MenuBarController: NSObject, NSMenuDelegate { } @objc private func quitAction() { - NSApp.terminate(nil) + Bootstrap.userQuit() } } diff --git a/panel/Panel.swift b/panel/Panel.swift index 7bb5fd7..61b40c2 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -107,7 +107,7 @@ struct PanelContentView: View { hotkeyDisplay: nav.hotkeyDisplay, onInstall: { nav.actions?.runBootstrap() }, onGrantPermissions: onGrantPermissions, - onQuit: { NSApp.terminate(nil) } + onQuit: { Bootstrap.userQuit() } ) } else if nav.mode == .postUpdate { // Full-screen takeover, no tab strip — matches welcome's @@ -598,6 +598,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // launched, so the safety net has served its purpose — recycle it // so Spotlight stops indexing two StackNudge.app entries. Bootstrap.cleanupPostUpdateBackup() + // We're back up, so any prior user-Quit intent is satisfied. Clear + // the marker so notify.sh will relaunch us on the next event after + // the *next* Quit. + Bootstrap.clearUserQuitMarker() let size = Self.loadSavedPanelSize() let frame = NSRect(origin: .zero, size: size) @@ -675,7 +679,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, beginUninstall: { [weak self] in self?.beginUninstallFlow() }, runUninstall: { [weak self] in self?.runUninstall() }, runBootstrap: { [weak self] in self?.runBootstrap() }, - quit: { NSApp.terminate(nil) }, + quit: { Bootstrap.userQuit() }, expandFromCompact: { [weak self] in self?.expandFromCompact() }, exitCompactMode: { [weak self] in self?.exitCompactMode() } ) From fc3b32a5d49d67ea919ac51a08edc7c6ef1f685e Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 18 Jun 2026 11:59:04 +0100 Subject: [PATCH 4/7] fix(widget): align usage side text with gauge + match hover legend weight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the explicit Spacer in the usage pill so sideText fills the slack (maxWidth: .infinity, leading) — gauge center and text center now share the same y, and the expand button sits closer to the cluster instead of being pushed to the far edge. Widen the pill from 150→170 to give the two-line hover legend room without compressing the layout, and bump the resting countdown to .medium/.secondary so it matches the hover legend's visual weight (sizes were already equal at 9pt — the perceived gap was weight + color tier). Co-Authored-By: Claude Opus 4.7 --- panel/CompactView.swift | 13 ++++--------- panel/Panel.swift | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/panel/CompactView.swift b/panel/CompactView.swift index c15c36b..43b621a 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -33,9 +33,8 @@ struct CompactView: View { var body: some View { Group { if nav.compactContent == .usage { - HStack(alignment: .center, spacing: 6) { + HStack(alignment: .center, spacing: 4) { gaugeCluster - Spacer(minLength: 0) expandButton } .frame(maxHeight: .infinity) @@ -115,12 +114,8 @@ struct CompactView: View { ) .frame(width: size, height: size) } - // Force sideText to fill the gauge cluster's height so its - // ZStack centers at the same y as the gauge's center digit, - // regardless of which child (countdown vs two-line legend) - // is currently visible. sideText - .frame(height: size + 2) + .frame(maxWidth: .infinity, alignment: .leading) } // Gated on !dragging so we don't fight AppKit's drag handler. .scaleEffect(isHovering && !nav.compactDragging ? 1.07 : 1.0) @@ -140,8 +135,8 @@ struct CompactView: View { .accessibilityHidden(true) if let reset = nav.quota?.fiveHour?.resetsAt { Text(Self.shortDuration(until: reset)) - .font(.system(size: 9).monospacedDigit()) - .foregroundStyle(.tertiary) + .font(.system(size: 9, weight: .medium).monospacedDigit()) + .foregroundStyle(.secondary) .opacity(show ? 0 : 1) } hoverLegend diff --git a/panel/Panel.swift b/panel/Panel.swift index 61b40c2..caffff4 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -930,7 +930,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // MARK: - Compact widget layout private static let compactWidgetSize = NSSize(width: 320, height: 56) - private static let compactWidgetUsageSize = NSSize(width: 150, height: 66) + private static let compactWidgetUsageSize = NSSize(width: 170, height: 66) private static let compactWidgetInset: CGFloat = 14 private var compactWidgetSizeForMode: NSSize { From 8e738472837037ddcc331b5d2afac89056bc6d9b Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 18 Jun 2026 17:05:00 +0100 Subject: [PATCH 5/7] =?UTF-8?q?fix(widget):=20simplify=20mini=20+=20full?= =?UTF-8?q?=20pill=20=E2=80=94=20symmetric=20margins,=20hover=20stability,?= =?UTF-8?q?=20cycling-only=20headline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mini pill (usage mode): - Reserve hoverLegend's measured width via PreferenceKey so the countdown sits in a fixed-width slot — hovering no longer bumps the expand button. - Vertically center the visible text in sideText (countdown / hover legend / "—" placeholder when no quota). - Gate hover on nav.quota != nil so we don't flash placeholder legend text on hover. - Shrink pill 170→145 and bump horizontal padding 4→10 so both edge gaps resolve to the same value. Full pill (non-usage mode): - Drop the middle Spacer + trailing minLength-4 Spacer in favor of a single trailing Spacer; sessionBadge + expandButton now sit right next to the headline. - Shrink pill 320→290 so the right margin matches the left 12pt padding. - Strip the busy / recent-event / most-recent-active branches in the headline — always show cycling active session names (falls back to "watching" when no sessions are active). Drops the · token-count and · position indicators from the cycling view. Co-Authored-By: Claude Opus 4.7 --- panel/CompactView.swift | 141 +++++++++++++++------------------------- panel/Panel.swift | 4 +- 2 files changed, 54 insertions(+), 91 deletions(-) diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 43b621a..2a34afa 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -1,5 +1,12 @@ import SwiftUI +private struct LegendWidthKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + // Pill-shaped glance widget. Layout left→right: // [gauge] [7d%, reset-in] | [headline: project · tokens · status] // [active count badge] [expand] @@ -20,6 +27,9 @@ struct CompactView: View { @State private var rippleScale: CGFloat = 0.3 @State private var rippleOpacity: Double = 0 @State private var isHovering: Bool = false + // Measured intrinsic width of the hover legend, used to reserve a + // constant slot for sideText so hovering doesn't bump the expand button. + @State private var legendWidth: CGFloat = 0 // Rotating index into `activeSessions` for the cycling display. Only // advances when `shouldCycle` is true (≥2 active sessions and nothing // more urgent on screen). Stays bounded by % so a session disappearing @@ -38,15 +48,15 @@ struct CompactView: View { expandButton } .frame(maxHeight: .infinity) - .padding(.horizontal, 4) + .padding(.horizontal, 10) } else { HStack(spacing: 10) { gaugeCluster separator headline - Spacer(minLength: 4) sessionBadge expandButton + Spacer(minLength: 0) } .padding(.horizontal, 12) } @@ -115,7 +125,6 @@ struct CompactView: View { .frame(width: size, height: size) } sideText - .frame(maxWidth: .infinity, alignment: .leading) } // Gated on !dragging so we don't fight AppKit's drag handler. .scaleEffect(isHovering && !nav.compactDragging ? 1.07 : 1.0) @@ -126,22 +135,39 @@ struct CompactView: View { @ViewBuilder private var sideText: some View { - let show = isHovering && !nav.compactDragging + let show = isHovering && !nav.compactDragging && nav.quota != nil ZStack(alignment: .center) { - // Invisible sizer: reserves the legend's slot height so the - // resting countdown centers at the same y as it does on hover. + // Invisible width sizer — reserves the legend's horizontal + // footprint so hover doesn't widen the cluster and bump the + // expand button. Clipped to zero size via .frame so it doesn't + // grow the row vertically; only its measured width survives + // through the explicit width below. hoverLegend - .opacity(0) - .accessibilityHidden(true) - if let reset = nav.quota?.fiveHour?.resetsAt { + .fixedSize() + .hidden() + .background( + GeometryReader { geo in + Color.clear.preference( + key: LegendWidthKey.self, + value: geo.size.width + ) + } + ) + .frame(width: 0, height: 0) + if show { + hoverLegend + } else if let reset = nav.quota?.fiveHour?.resetsAt { Text(Self.shortDuration(until: reset)) .font(.system(size: 9, weight: .medium).monospacedDigit()) .foregroundStyle(.secondary) - .opacity(show ? 0 : 1) + } else { + Text("—") + .font(.system(size: 9, weight: .medium).monospacedDigit()) + .foregroundStyle(.tertiary) } - hoverLegend - .opacity(show ? 1 : 0) } + .frame(width: legendWidth > 0 ? legendWidth : nil, alignment: .leading) + .onPreferenceChange(LegendWidthKey.self) { legendWidth = $0 } .animation(.easeInOut(duration: 0.18), value: show) } @@ -176,57 +202,12 @@ struct CompactView: View { @ViewBuilder private var headlineText: some View { - if let busy = busiestSession { - HStack(spacing: 4) { - Text(displayName(busy)) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(1) - if let stats = transcriptStats(for: busy) { - Text("·") - .font(.system(size: 10)) - .foregroundStyle(.tertiary) - Text(TokenFormat.short(stats.tokens)) - .font(.system(size: 10, weight: .medium).monospacedDigit()) - .foregroundStyle(.secondary) - } - } - } else if let recent = recentEvent { - HStack(spacing: 4) { - Text(recent.title) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(1) - Text("·") - .font(.system(size: 10)) - .foregroundStyle(.tertiary) - // Adaptive: pending count when the queue has built up, - // otherwise show age of the latest event so the user can - // gauge whether it's fresh. - if store.events.count > 1 { - Text("×\(store.events.count)") - .font(.system(size: 10, weight: .semibold).monospacedDigit()) - .foregroundStyle(.secondary) - .lineLimit(1) - .fixedSize() - } else { - Text(RelativeTime.string(recent.timestamp, style: .short)) - .font(.system(size: 10).monospacedDigit()) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } else if shouldCycle { - cyclingActiveSession - } else if let active = mostRecentActive { - Text(displayName(active)) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(1) - } else { + if activeSessions.isEmpty { Text("watching") .font(.system(size: 11)) .foregroundStyle(.tertiary) + } else { + cyclingActiveSession } } @@ -242,36 +223,18 @@ struct CompactView: View { private var cyclingActiveSession: some View { let pool = activeSessions let session = pool[cycleIndex % max(pool.count, 1)] - HStack(spacing: 4) { - Text(displayName(session)) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(1) - Text("·") - .font(.system(size: 10)) - .foregroundStyle(.tertiary) - if let stats = transcriptStats(for: session) { - Text(TokenFormat.short(stats.tokens)) - .font(.system(size: 10, weight: .medium).monospacedDigit()) - .foregroundStyle(.secondary) - } else { - let position = (pool.firstIndex { $0.pid == session.pid } ?? 0) + 1 - Text("\(position)/\(pool.count)") - .font(.system(size: 10).monospacedDigit()) - .foregroundStyle(.tertiary) - } - } - .id(session.pid) - .transition(.opacity) - .onReceive(Timer.publish(every: Self.cyclePeriod, on: .main, in: .common).autoconnect()) { _ in - // Re-check shouldCycle on every tick — pool size can change as - // sessions finish or new ones start. Skip during drag so we - // don't compete with AppKit's mouse handler. - guard shouldCycle, !nav.compactDragging else { return } - withAnimation(.easeInOut(duration: 0.45)) { - cycleIndex = (cycleIndex + 1) % max(activeSessions.count, 1) + Text(displayName(session)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .id(session.pid) + .transition(.opacity) + .onReceive(Timer.publish(every: Self.cyclePeriod, on: .main, in: .common).autoconnect()) { _ in + guard activeSessions.count > 1, !nav.compactDragging else { return } + withAnimation(.easeInOut(duration: 0.45)) { + cycleIndex = (cycleIndex + 1) % max(activeSessions.count, 1) + } } - } } // Passive quota-stress signal for the mascot. True when either the 5h diff --git a/panel/Panel.swift b/panel/Panel.swift index caffff4..55a6735 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -929,8 +929,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // MARK: - Compact widget layout - private static let compactWidgetSize = NSSize(width: 320, height: 56) - private static let compactWidgetUsageSize = NSSize(width: 170, height: 66) + private static let compactWidgetSize = NSSize(width: 290, height: 56) + private static let compactWidgetUsageSize = NSSize(width: 145, height: 66) private static let compactWidgetInset: CGFloat = 14 private var compactWidgetSizeForMode: NSSize { From 01dd78a53444c239cfcb6bc3a1aa49686be9cf2b Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 18 Jun 2026 17:24:29 +0100 Subject: [PATCH 6/7] fix(panel): address PR #106 review feedback - writePlist: parameterize KeepAlive so the voice daemon keeps its "always" restart policy. Wizard-installed daemons were inheriting the panel's SuccessfulExit:false form, diverging from install.sh. - QuotaProbe: distinguish "rate-limited" from "endpoint may have changed" in the Usage tab so the message is accurate during a 429 backoff. isRateLimited reads off retryAfterUntil; lastProbeFailed now only flags real failures (non-200, non-429). - parseRetryAfter: refactor to take String? so it can be unit-tested without an HTTPURLResponse; add tests covering delta-seconds, HTTP-date, negative values, >1h clamp, empty, and garbage input. - gaugeClusterBody: extract `haloSize = size + 2` so the halo blur frame and gauge frame share one source of truth; drop redundant explicit HStack(alignment: .center). Co-Authored-By: Claude Opus 4.7 --- .../QuotaProbeRetryAfterTests.swift | 63 +++++++++++++++++++ panel/Bootstrap.swift | 13 +++- panel/CompactView.swift | 8 ++- panel/Panel.swift | 2 + panel/SessionUsage.swift | 42 ++++++++++--- 5 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 Tests/StackNudgePanelCoreTests/QuotaProbeRetryAfterTests.swift diff --git a/Tests/StackNudgePanelCoreTests/QuotaProbeRetryAfterTests.swift b/Tests/StackNudgePanelCoreTests/QuotaProbeRetryAfterTests.swift new file mode 100644 index 0000000..8e97f6b --- /dev/null +++ b/Tests/StackNudgePanelCoreTests/QuotaProbeRetryAfterTests.swift @@ -0,0 +1,63 @@ +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/panel/Bootstrap.swift b/panel/Bootstrap.swift index 80464a0..70f67d9 100644 --- a/panel/Bootstrap.swift +++ b/panel/Bootstrap.swift @@ -673,9 +673,13 @@ enum Bootstrap { let python = venvURL.appendingPathComponent("bin/python3").path let stackvox = venvURL.appendingPathComponent("bin/stackvox").path let logPath = "\(installDir)/daemon.log" + // Daemon mirrors install.sh's "always" mode — if stackvox serve ever + // exits 0 launchd should still bring it back. Only the panel uses the + // SuccessfulExit:false form so a user Quit actually quits. try writePlist(label: daemonLabel, programArgs: [python, stackvox, "serve"], logPath: logPath, + keepAlive: true, env: stackvoxEnv(venvURL: venvURL)) } @@ -694,17 +698,20 @@ enum Bootstrap { // Common plist serialiser: emits the same XML shape install.sh's // register_launchd_agent function produces, via PropertyListSerialization. + // + // `keepAlive` mirrors install.sh's "always" vs "on_crash" modes: + // true → restart unconditionally + // ["SuccessfulExit": false] → restart on crash only (Quit means quit) private static func writePlist(label: String, programArgs: [String], logPath: String, + keepAlive: Any = ["SuccessfulExit": false], env: [String: String] = [:]) throws { var plist: [String: Any] = [ "Label": label, "ProgramArguments": programArgs, "RunAtLoad": true, - // Restart only on crashes (non-zero exit). An explicit user - // Quit ends with a successful exit and should actually quit. - "KeepAlive": ["SuccessfulExit": false], + "KeepAlive": keepAlive, "StandardOutPath": logPath, "StandardErrorPath": logPath, ] diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 2a34afa..3363055 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -104,12 +104,16 @@ struct CompactView: View { } private func gaugeClusterBody(size: CGFloat) -> some View { - HStack(alignment: .center, spacing: 6) { + // Halo is 1pt larger than the gauge on each side so the soft blur + // bleeds outward rather than clipping at the gauge edge. Shared + // constant so the two frames can't drift apart. + let haloSize = size + 2 + return HStack(spacing: 6) { ZStack { if !nav.compactDragging { Circle() .fill(urgencyColor.opacity(0.18)) - .frame(width: size + 2, height: size + 2) + .frame(width: haloSize, height: haloSize) .blur(radius: 8) } QuotaGauge( diff --git a/panel/Panel.swift b/panel/Panel.swift index 55a6735..1e8ee31 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -1239,6 +1239,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, 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." } diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 8250946..0e0ac57 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -81,8 +81,9 @@ final class QuotaProbe { // 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. usingPlaintextCredentials is true when the token came from the - // plaintext credentials file rather than the Keychain. + // 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 @@ -91,7 +92,16 @@ final class QuotaProbe { // doesn't keep stoking the rate limit. private var retryAfterUntil: Date? private static let defaultRetryAfter: TimeInterval = 15 * 60 - private static let maxRetryAfter: TimeInterval = 60 * 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 @@ -109,7 +119,9 @@ final class QuotaProbe { 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. - lastProbeFailed = true + // 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 } @@ -157,7 +169,11 @@ final class QuotaProbe { ? Self.parseRetryAfter(http) ?? Self.defaultRetryAfter : nil DispatchQueue.main.async { - self?.lastProbeFailed = true + // 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) } @@ -173,19 +189,27 @@ final class QuotaProbe { }.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. - private static func parseRetryAfter(_ http: HTTPURLResponse?) -> TimeInterval? { - guard let raw = http?.value(forHTTPHeaderField: "Retry-After") else { return nil } - if let seconds = TimeInterval(raw.trimmingCharacters(in: .whitespaces)), seconds >= 0 { + // 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: raw) { + if let date = f.date(from: trimmed) { return min(max(0, date.timeIntervalSinceNow), maxRetryAfter) } return nil From df64fe81c23c6935b0d4b13703c49b8dd8824567 Mon Sep 17 00:00:00 2001 From: Hisku Date: Tue, 23 Jun 2026 09:20:15 +0100 Subject: [PATCH 7/7] fix(widget): layout polish + accent color presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pill accent (border glow, gauge tracks, mascot tints, spinner dot, stress sweat-drop) is now themable via five presets surfaced in Settings → Widget → "Accent color": cyan (default — current color), violet, mint, rose, and system (follows the macOS accent, matches banners). Routed through a new themeAccent EnvironmentValue so QuotaGauge and every mascot pick the color up without per-init plumbing. Persisted via STACKNUDGE_THEME. Urgency .orange ≥75%, .red ≥90%, and the 50–75% yellow gauge band stay semantic. Two layout fixes shipped alongside: - Gauge side-text slot now sizes to the max of the hover legend's intrinsic width and a worst-case countdown template ("0h00m"), so the countdown ("2h24m") no longer wraps onto a second line. Visible text gets lineLimit(1).fixedSize() so it can't break even at sub-pixel widths. - Expand button now anchors to the right edge in both pill modes (Spacer ahead of it instead of behind), symmetric to how the gauge anchors left. Dead fileprivate gaugeColor(pct:) helper deleted — no callers. Co-Authored-By: Claude Opus 4.7 --- notify.conf.example | 6 ++ panel/CompactView.swift | 163 +++++++++++++++++++++++++--------------- panel/PanelNav.swift | 48 +++++++++++- panel/Settings.swift | 1 + 4 files changed, 154 insertions(+), 64 deletions(-) diff --git a/notify.conf.example b/notify.conf.example index 051b3c7..3a0cc80 100644 --- a/notify.conf.example +++ b/notify.conf.example @@ -45,6 +45,12 @@ # Default: true #STACKNUDGE_MUTE_WHEN_FOCUSED=false +# 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). +# Default: cyan +#STACKNUDGE_THEME=violet + # Log a debug line (to the launchd log) explaining voice decisions — useful # when a notification played silently and you want to know why. # Default: false diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 3363055..d61dd85 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -7,6 +7,22 @@ private struct LegendWidthKey: PreferenceKey { } } +// Pill accent color (cyan by default). Read by QuotaGauge + every mascot via +// `@Environment(\.themeAccent)`; written once at the root of CompactView's +// body from `nav.theme.color`. Lets a single setting retint pill border, +// gauge tracks, mascot accents, and the stress sweat-drop in one place +// without threading a Color parameter through every leaf view's init. +private struct ThemeAccentKey: EnvironmentKey { + static let defaultValue: Color = Color(red: 0.40, green: 0.85, blue: 1.00) +} + +extension EnvironmentValues { + var themeAccent: Color { + get { self[ThemeAccentKey.self] } + set { self[ThemeAccentKey.self] = newValue } + } +} + // Pill-shaped glance widget. Layout left→right: // [gauge] [7d%, reset-in] | [headline: project · tokens · status] // [active count badge] [expand] @@ -36,7 +52,6 @@ struct CompactView: View { // mid-rotation doesn't crash. @State private var cycleIndex: Int = 0 - private static let glowColor = Color(red: 0.4, green: 0.85, blue: 1.0) private static let recentEventWindow: TimeInterval = 5 * 60 private static let cyclePeriod: TimeInterval = 5.0 @@ -45,6 +60,7 @@ struct CompactView: View { if nav.compactContent == .usage { HStack(alignment: .center, spacing: 4) { gaugeCluster + Spacer(minLength: 0) expandButton } .frame(maxHeight: .infinity) @@ -55,8 +71,8 @@ struct CompactView: View { separator headline sessionBadge - expandButton Spacer(minLength: 0) + expandButton } .padding(.horizontal, 12) } @@ -71,6 +87,11 @@ struct CompactView: View { // cat wink + ear twitch, eye pupil dilate-and-dart, ghost // pop-and-yawn). Gated on pill mode inside each mascot. .onHover { isHovering = $0 } + // Pill border, gauge tracks, mascot accents, and the sweat-drop + // stress signal all read this through @Environment(\.themeAccent). + // Single write at the root keeps every leaf in sync as the user + // cycles the Theme setting. + .environment(\.themeAccent, nav.theme.color) } private var mascotHovered: Bool { @@ -141,22 +162,21 @@ struct CompactView: View { private var sideText: some View { let show = isHovering && !nav.compactDragging && nav.quota != nil ZStack(alignment: .center) { - // Invisible width sizer — reserves the legend's horizontal - // footprint so hover doesn't widen the cluster and bump the - // expand button. Clipped to zero size via .frame so it doesn't - // grow the row vertically; only its measured width survives - // through the explicit width below. + // Invisible width sizers — reserve the slot's horizontal footprint + // so the cluster width stays constant across hover and across the + // countdown's minute-by-minute changes. Both sizers report to + // LegendWidthKey, which reduces with max(), so the slot fits + // whichever is widest. Without the countdown sizer, the wide + // letters 'h'/'m' in "2h24m" exceed the legend's footprint + // ("5h 50%") and the countdown wraps onto a second line. hoverLegend .fixedSize() .hidden() - .background( - GeometryReader { geo in - Color.clear.preference( - key: LegendWidthKey.self, - value: geo.size.width - ) - } - ) + .background(legendWidthReporter) + .frame(width: 0, height: 0) + countdownSizer + .hidden() + .background(legendWidthReporter) .frame(width: 0, height: 0) if show { hoverLegend @@ -164,10 +184,14 @@ struct CompactView: View { Text(Self.shortDuration(until: reset)) .font(.system(size: 9, weight: .medium).monospacedDigit()) .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() } else { Text("—") .font(.system(size: 9, weight: .medium).monospacedDigit()) .foregroundStyle(.tertiary) + .lineLimit(1) + .fixedSize() } } .frame(width: legendWidth > 0 ? legendWidth : nil, alignment: .leading) @@ -175,6 +199,24 @@ struct CompactView: View { .animation(.easeInOut(duration: 0.18), value: show) } + // Worst-case countdown footprint. shortDuration emits "Xh", "XhYm", or + // "Ym"; the 5h quota window caps the leading digit, so "0h00m" covers + // every shape (digits are monospaced; 'h'/'m' are the widest letters). + private var countdownSizer: some View { + Text("0h00m") + .font(.system(size: 9, weight: .medium).monospacedDigit()) + .fixedSize() + } + + private var legendWidthReporter: some View { + GeometryReader { geo in + Color.clear.preference( + key: LegendWidthKey.self, + value: geo.size.width + ) + } + } + private var hoverLegend: some View { let fiveText = nav.quota?.fiveHour.map { "5h \(Int($0.utilization.rounded()))%" } ?? "5h —" let sevenText = nav.quota?.sevenDay.map { "7d \(Int($0.utilization.rounded()))%" } ?? "7d —" @@ -342,14 +384,14 @@ struct CompactView: View { (anyBusy || (nav.quota?.fiveHour?.utilization ?? 0) >= 75) ? 0.1 : 60 } - // Border color tracks 5h quota urgency: cyan under 75%, amber 75–90%, - // red 90%+. Pulse rate climbs with severity so red-state pill is + // Border color tracks 5h quota urgency: themed accent under 75%, amber + // 75–90%, red 90%+. Pulse rate climbs with severity so red-state pill is // visibly more urgent than amber. private var urgencyColor: Color { let pct = nav.quota?.fiveHour?.utilization ?? 0 if pct >= 90 { return .red } if pct >= 75 { return .orange } - return Self.glowColor + return nav.theme.color } private func pulseAmount(at date: Date) -> Double { @@ -425,15 +467,6 @@ struct CompactView: View { } } - fileprivate static func gaugeColor(pct: Double) -> Color { - if pct >= 90 { return .red } - if pct >= 75 { return .orange } - if pct >= 50 { return .yellow } - return Color(red: 0.4, green: 0.85, blue: 1.0) - } - - - private static func shortDuration(until date: Date) -> String { let s = max(0, Int(date.timeIntervalSinceNow)) if s >= 3600 { @@ -461,7 +494,8 @@ private struct QuotaGauge: View { let paused: Bool let showRemaining: Bool - private static let cyan = Color(red: 0.30, green: 0.92, blue: 1.0) + @Environment(\.themeAccent) private var accent + private static let outerLineWidth: CGFloat = 4.0 private static let innerLineWidth: CGFloat = 4.0 private static let ringGap: CGFloat = 5.0 @@ -512,7 +546,7 @@ private struct QuotaGauge: View { private var outerFill: some View { Circle() .trim(from: 0, to: max(0, min(1, sevenPct / 100))) - .stroke(Self.urgencyColor(for: sevenPct), + .stroke(urgencyColor(for: sevenPct), style: StrokeStyle(lineWidth: Self.outerLineWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) .padding(Self.outerLineWidth / 2) @@ -521,7 +555,7 @@ private struct QuotaGauge: View { private var innerFill: some View { Circle() .trim(from: 0, to: max(0, min(1, fivePct / 100))) - .stroke(Self.urgencyColor(for: fivePct), + .stroke(urgencyColor(for: fivePct), style: StrokeStyle(lineWidth: Self.innerLineWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) .padding(Self.outerLineWidth + Self.ringGap) @@ -531,11 +565,11 @@ private struct QuotaGauge: View { // mapped cyan→red across the full 360°, so a nearly-full ring wrapped // its red end back into cyan at 12 o'clock — a jarring blue notch. // Solid color per band reads cleaner and matches the pill border. - private static func urgencyColor(for pct: Double) -> Color { + private func urgencyColor(for pct: Double) -> Color { if pct >= 90 { return .red } if pct >= 75 { return .orange } if pct >= 50 { return .yellow } - return cyan + return accent } private func innerGlow(at date: Date) -> some View { @@ -545,7 +579,7 @@ private struct QuotaGauge: View { return Circle() .fill( RadialGradient( - gradient: Gradient(colors: [Self.cyan.opacity(intensity), .clear]), + gradient: Gradient(colors: [accent.opacity(intensity), .clear]), center: .center, startRadius: 0, endRadius: 22 @@ -568,7 +602,7 @@ private struct QuotaGauge: View { let t = date.timeIntervalSinceReferenceDate let angle = (t * 180).truncatingRemainder(dividingBy: 360) return Circle() - .fill(Self.cyan) + .fill(accent) .frame(width: 3, height: 3) .offset(y: -15) .rotationEffect(.degrees(angle)) @@ -632,7 +666,8 @@ private struct RobotMascot: View { @State private var pulse: Double = 0 @State private var pulseKind: NudgeKind? - private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) + @Environment(\.themeAccent) private var accent + private static let outline = Color.secondary.opacity(0.7) var body: some View { @@ -742,10 +777,10 @@ private struct RobotMascot: View { // Subtle cyan cheek pulse. HStack(spacing: 9) { Circle() - .fill(Self.cyan.opacity(0.55 * pulse)) + .fill(accent.opacity(0.55 * pulse)) .frame(width: 3, height: 3) Circle() - .fill(Self.cyan.opacity(0.55 * pulse)) + .fill(accent.opacity(0.55 * pulse)) .frame(width: 3, height: 3) } .offset(y: 4) @@ -753,10 +788,11 @@ private struct RobotMascot: View { } private var sweatDrop: some View { - // Tiny blue teardrop above the right side of the head — passive - // quota-stress signal. Only renders when `stressed` is true. + // Tiny teardrop above the right side of the head — passive + // quota-stress signal. Themed so a non-cyan accent doesn't leave a + // jarring blue drop floating over a violet/rose/mint pill. Circle() - .fill(Color(red: 0.45, green: 0.75, blue: 1.0).opacity(0.85)) + .fill(accent.opacity(0.85)) .frame(width: 2.8, height: 2.8) .offset(x: 8, y: -4) } @@ -795,7 +831,7 @@ private struct RobotMascot: View { switch state { case .alert: return .orange.opacity(0.15) case .happy: return .green.opacity(0.15) - case .busy: return Self.cyan.opacity(0.18) + case .busy: return accent.opacity(0.18) default: return Color.secondary.opacity(0.10) } } @@ -819,7 +855,7 @@ private struct RobotMascot: View { switch state { case .alert: return .orange case .happy: return .green - case .busy: return Self.cyan + case .busy: return accent default: return Self.outline } } @@ -848,7 +884,7 @@ private struct RobotMascot: View { case .busy: // Focused: narrow horizontal slits Capsule() - .fill(Self.cyan) + .fill(accent) .frame(width: 4.5, height: 1.8) case .alert: // Surprised: bigger round eyes @@ -866,7 +902,7 @@ private struct RobotMascot: View { .frame(width: 4.5, height: 3) case .watching: Circle() - .fill(Self.cyan) + .fill(accent) .frame(width: 3.3, height: 3.3) case .idle: Circle() @@ -894,7 +930,7 @@ private struct RobotMascot: View { .offset(y: 6) case .busy: Rectangle() - .fill(Self.cyan.opacity(0.5)) + .fill(accent.opacity(0.5)) .frame(width: 4.5, height: 1.2) .offset(y: 6) default: @@ -906,7 +942,7 @@ private struct RobotMascot: View { p.addQuadCurve(to: CGPoint(x: 6, y: 0), control: CGPoint(x: 3, y: 2.2)) } - .stroke(Self.cyan, style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) + .stroke(accent, style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) .frame(width: 6, height: 2.2) .offset(y: 6) .transition(.scale.combined(with: .opacity)) @@ -930,7 +966,8 @@ private struct CatMascot: View { @State private var pulse: Double = 0 @State private var pulseKind: NudgeKind? - private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) + @Environment(\.themeAccent) private var accent + private static let outline = Color.secondary.opacity(0.7) var body: some View { @@ -1015,7 +1052,7 @@ private struct CatMascot: View { case .other: // Cyan whisker shimmer. Capsule() - .fill(Self.cyan.opacity(0.5 * pulse)) + .fill(accent.opacity(0.5 * pulse)) .frame(width: 18, height: 1.5) .blur(radius: 1.5) .offset(y: 4.5) @@ -1087,7 +1124,7 @@ private struct CatMascot: View { switch state { case .alert: return .orange.opacity(0.18) case .happy: return .green.opacity(0.18) - case .busy: return Self.cyan.opacity(0.20) + case .busy: return accent.opacity(0.20) default: return Color.secondary.opacity(0.12) } } @@ -1123,7 +1160,7 @@ private struct CatMascot: View { private var eyeShape: some View { switch state { case .busy: - Capsule().fill(Self.cyan).frame(width: 1.4, height: 4.5) // slit + Capsule().fill(accent).frame(width: 1.4, height: 4.5) // slit case .alert: Circle().fill(Color.orange).frame(width: 4.5, height: 4.5) case .happy: @@ -1135,7 +1172,7 @@ private struct CatMascot: View { .stroke(Color.green, style: StrokeStyle(lineWidth: 1.2, lineCap: .round)) .frame(width: 4.5, height: 3) case .watching: - Circle().fill(Self.cyan).frame(width: 3.0, height: 3.0) + Circle().fill(accent).frame(width: 3.0, height: 3.0) case .idle: Circle().fill(Self.outline).frame(width: 2.4, height: 2.4) } @@ -1247,7 +1284,8 @@ private struct EyeMascot: View { // direction. Set true while dragging, cleared on release. @State private var lagging: Bool = false - private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) + @Environment(\.themeAccent) private var accent + private static let outline = Color.secondary.opacity(0.7) var body: some View { @@ -1335,7 +1373,7 @@ private struct EyeMascot: View { case .other: // Single cyan tick at the top — small blip. Capsule() - .fill(Self.cyan.opacity(0.6 * pulse)) + .fill(accent.opacity(0.6 * pulse)) .frame(width: 6, height: 1.5) .offset(y: -10) } @@ -1386,7 +1424,7 @@ private struct EyeMascot: View { switch state { case .alert: return .red.opacity(0.18) case .happy: return .green.opacity(0.14) - case .busy: return Self.cyan.opacity(0.16) + case .busy: return accent.opacity(0.16) default: return Color.secondary.opacity(0.10) } } @@ -1428,7 +1466,7 @@ private struct EyeMascot: View { switch state { case .alert: return .red case .happy: return .green - case .busy: return Self.cyan + case .busy: return accent default: return Self.outline } } @@ -1446,7 +1484,8 @@ private struct GhostMascot: View { @State private var pulse: Double = 0 @State private var pulseKind: NudgeKind? - private static let cyan = Color(red: 0.4, green: 0.85, blue: 1.0) + @Environment(\.themeAccent) private var accent + private static let outline = Color.secondary.opacity(0.7) var body: some View { @@ -1536,7 +1575,7 @@ private struct GhostMascot: View { case .other: // Soft cyan ring around the body. Capsule() - .fill(Self.cyan.opacity(0.35 * pulse)) + .fill(accent.opacity(0.35 * pulse)) .frame(width: 22, height: 26) .blur(radius: 4) } @@ -1561,8 +1600,8 @@ private struct GhostMascot: View { private var sparkle: some View { // Four-point star drawn as two crossed thin capsules. ZStack { - Capsule().fill(Self.cyan).frame(width: 1.2, height: 4) - Capsule().fill(Self.cyan).frame(width: 4, height: 1.2) + Capsule().fill(accent).frame(width: 1.2, height: 4) + Capsule().fill(accent).frame(width: 4, height: 1.2) } } @@ -1577,7 +1616,7 @@ private struct GhostMascot: View { switch state { case .alert: return .orange.opacity(0.18) case .happy: return .green.opacity(0.18) - case .busy: return Self.cyan.opacity(0.20) + case .busy: return accent.opacity(0.20) default: return Color.secondary.opacity(0.12) } } @@ -1603,7 +1642,7 @@ private struct GhostMascot: View { private var eyeShape: some View { switch state { case .busy: - Capsule().fill(Self.cyan).frame(width: 4, height: 1.6) + Capsule().fill(accent).frame(width: 4, height: 1.6) case .alert: Circle().fill(Color.orange).frame(width: 4, height: 4) case .happy: @@ -1615,7 +1654,7 @@ private struct GhostMascot: View { .stroke(Color.green, style: StrokeStyle(lineWidth: 1.1, lineCap: .round)) .frame(width: 4, height: 2.5) case .watching: - Circle().fill(Self.cyan).frame(width: 3, height: 3) + Circle().fill(accent).frame(width: 3, height: 3) case .idle: Circle().fill(Self.outline).frame(width: 2.4, height: 2.4) } diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 279f057..000c00e 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -83,6 +83,42 @@ enum MascotKind: String, CaseIterable { } } +// Pill accent color preset. Drives the cyan-tinted surfaces in CompactView +// (pill border at <75% utilization, gauge tracks, gauge fill under 50%, +// mascot accents, inner-glow halo, spinner dot, sweat-drop). Urgency +// colors (.orange ≥75%, .red ≥90%) and the gauge's .yellow 50–75% band +// are deliberately untouched — they are semantic, not aesthetic. +// +// Orange and yellow are excluded from the palette because they collide +// with the urgency bands and would muddy the at-a-glance read. +enum AccentTheme: String, CaseIterable { + case cyan + case violet + case mint + case rose + case system + + var label: String { + switch self { + case .cyan: return "Cyan" + case .violet: return "Violet" + case .mint: return "Mint" + case .rose: return "Rose" + case .system: return "System" + } + } + + var color: Color { + switch self { + case .cyan: return Color(red: 0.40, green: 0.85, blue: 1.00) + case .violet: return Color(red: 0.72, green: 0.55, blue: 1.00) + case .mint: return Color(red: 0.50, green: 0.95, blue: 0.78) + case .rose: return Color(red: 1.00, green: 0.55, blue: 0.78) + case .system: return Color.accentColor + } + } +} + // In-panel GitHub device-flow sign-in state. `awaitingApproval` carries the // one-time code + verification URL to show while we poll in the background. enum GithubSignIn: Equatable { @@ -100,7 +136,7 @@ enum GithubSignIn: Equatable { enum SettingsRow: Hashable { case update, hotkey case banner, muteWhenFocused, pinPanel, keepOpenWhenEmpty, launchAtLogin - case widget, widgetCorner, widgetOpacity, widgetContent, mascot + case widget, widgetCorner, widgetOpacity, widgetContent, mascot, theme case soundEnabled, agentDoneSound, permissionSound case voiceEnabled, voice, voiceSpeed, downloadVoiceModel case quotaTracking, quotaAlerts, alertThreshold, pollFrequency, contextAlert, showRemaining @@ -449,6 +485,7 @@ final class PanelNav: ObservableObject { @Published var compactCorner: CompactCorner = .topRight @Published var compactContent: CompactContent = .sessions @Published var mascot: MascotKind = .robot + @Published var theme: AccentTheme = .cyan // Pill window alpha when at rest. 1.0 = fully opaque; lower values // let the desktop show through so the widget recedes. Applied // window-level so the NSVisualEffectView blur fades with it. Only @@ -551,7 +588,7 @@ final class PanelNav: ObservableObject { if updateAvailable != nil { rows.append(.update) } rows += [.hotkey, .banner, .muteWhenFocused, .pinPanel, .keepOpenWhenEmpty, .launchAtLogin, - .widget, .widgetCorner, .widgetOpacity, .widgetContent, .mascot, + .widget, .widgetCorner, .widgetOpacity, .widgetContent, .mascot, .theme, .soundEnabled, .agentDoneSound, .permissionSound, .voiceEnabled] rows += voiceModelCached ? [.voice, .voiceSpeed] : [.downloadVoiceModel] @@ -631,6 +668,7 @@ final class PanelNav: ObservableObject { compactContent = CompactContent(rawValue: config["STACKNUDGE_COMPACT_CONTENT"] ?? "") ?? .sessions mascot = MascotKind(rawValue: config["STACKNUDGE_MASCOT"] ?? "") ?? .robot + theme = AccentTheme(rawValue: config["STACKNUDGE_THEME"] ?? "") ?? .cyan let rawAlpha = Double(config["STACKNUDGE_COMPACT_ALPHA"] ?? "") ?? 1.0 compactAlpha = Self.compactAlphaOptions.min(by: { abs($0 - rawAlpha) < abs($1 - rawAlpha) }) ?? 1.0 let rawPerSession = Int(config["STACKNUDGE_EVENTS_PER_SESSION"] ?? "") ?? 5 @@ -951,6 +989,12 @@ final class PanelNav: ObservableObject { 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 .theme: + let list = AccentTheme.allCases + let idx = list.firstIndex(of: theme) ?? 0 + let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count + theme = list[next] + ConfigFile.write(key: "STACKNUDGE_THEME", value: theme.rawValue) case .soundEnabled: soundEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_SOUND", value: soundEnabled ? "true" : "false") diff --git a/panel/Settings.swift b/panel/Settings.swift index 8ea18e6..c6e0686 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -58,6 +58,7 @@ struct SettingsView: View { row(.widgetOpacity, label: "Widget opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%", enabled: nav.compactMode) row(.widgetContent, label: "Widget type", kind: .cycle, value: nav.compactContent.label, enabled: nav.compactMode) row(.mascot, label: "Mascot", kind: .cycle, value: nav.mascot.label, enabled: nav.compactMode) + row(.theme, label: "Accent color", kind: .cycle, value: nav.theme.label, enabled: nav.compactMode) } section("Sounds") {