From a408e10a1457c81ba7662c2c7056cedaa154fb33 Mon Sep 17 00:00:00 2001 From: Hisku Date: Wed, 3 Jun 2026 15:05:29 +0100 Subject: [PATCH 1/3] fix(compact): solid urgency color on quota rings, no wrap-around MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QuotaGauge rings were stroked with an AngularGradient mapped across the full 360° (cyan→yellow→orange→red from 0° to 360°). When a ring trim went nearly all the way around, the red **end** of the stroke met the cyan **start** at 12 o'clock, producing a jarring blue notch on an otherwise red ring. Replace the gradient with a single solid color picked by percentage (cyan < 50% < yellow < 75% < orange < 90% < red). Matches the urgency ramp the pill border already uses; no more wrap-around artifact. Release-As: 1.14.2 Co-Authored-By: Claude Opus 4.7 --- panel/CompactView.swift | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 1e0f7c6..13c1c15 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -410,7 +410,8 @@ private struct QuotaGauge: View { private var outerFill: some View { Circle() .trim(from: 0, to: max(0, min(1, sevenPct / 100))) - .stroke(gradient, style: StrokeStyle(lineWidth: Self.outerLineWidth, lineCap: .round)) + .stroke(Self.urgencyColor(for: sevenPct), + style: StrokeStyle(lineWidth: Self.outerLineWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) .padding(Self.outerLineWidth / 2) } @@ -418,23 +419,21 @@ private struct QuotaGauge: View { private var innerFill: some View { Circle() .trim(from: 0, to: max(0, min(1, fivePct / 100))) - .stroke(gradient, style: StrokeStyle(lineWidth: Self.innerLineWidth, lineCap: .round)) + .stroke(Self.urgencyColor(for: fivePct), + style: StrokeStyle(lineWidth: Self.innerLineWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) .padding(Self.outerLineWidth + Self.ringGap) } - private var gradient: AngularGradient { - AngularGradient( - gradient: Gradient(stops: [ - .init(color: Self.cyan, location: 0.0), - .init(color: .yellow, location: 0.55), - .init(color: .orange, location: 0.80), - .init(color: .red, location: 1.0), - ]), - center: .center, - startAngle: .degrees(-90), - endAngle: .degrees(270) - ) + // Stroke color reflects urgency at a glance. Previous AngularGradient + // 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 { + if pct >= 90 { return .red } + if pct >= 75 { return .orange } + if pct >= 50 { return .yellow } + return cyan } private func innerGlow(at date: Date) -> some View { From 312bda50402946546895d5f433e740df5eec783f Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 4 Jun 2026 11:51:37 +0100 Subject: [PATCH 2/3] fix(events): raise event history cap from 5 to 20 The Events tab + pill pending-count badge silently dropped the oldest event once a sixth arrived. With multiple agents running in parallel or a brief AFK window, you'd lose track of what fired. 20 covers a typical short-meeting gap without making the list unwieldy. Co-Authored-By: Claude Opus 4.7 --- panel/EventStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panel/EventStore.swift b/panel/EventStore.swift index c03f5c0..560859b 100644 --- a/panel/EventStore.swift +++ b/panel/EventStore.swift @@ -137,7 +137,7 @@ final class EventStore: ObservableObject { @Published private(set) var events: [NudgeEvent] = [] @Published var selectedID: NudgeEvent.ID? - private let maxEvents = 5 + private let maxEvents = 20 /// Called on main queue after each new event is inserted. var onAppend: ((NudgeEvent) -> Void)? From 00111ddbc94050278634d28808573156d49891e8 Mon Sep 17 00:00:00 2001 From: Hisku Date: Thu, 4 Jun 2026 15:47:54 +0100 Subject: [PATCH 3/3] fix(1.14.2): per-session event cap, banner-gate alerts, pin overrides widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Events: bound history per Claude session (or agent+project bucket) with 100 global ceiling; expose as Settings → Events → "History per session" (3/5/10/20/50, default 5). Keeps chatty sessions from crowding out quiet ones. - Banners: gate context-fill and quota-threshold UNNotifications on nav.bannerEnabled so they respect the global Banner notifications toggle instead of bypassing it. - Pin + Widget: when both are on, Pin wins. Focus loss no longer auto-collapses to the pill; toggling Pin on from the pill auto-expands the panel. Explicit Esc / hotkey still collapse. - Settings: swap Widget rows so Mascot sits at the end of the section (Widget → corner → opacity → Mascot). --- panel/EventStore.swift | 28 +++++++++++++++++++++---- panel/Panel.swift | 16 +++++++++++++++ panel/PanelNav.swift | 46 ++++++++++++++++++++++++++++++------------ panel/Settings.swift | 22 +++++++++++--------- 4 files changed, 86 insertions(+), 26 deletions(-) diff --git a/panel/EventStore.swift b/panel/EventStore.swift index 560859b..8e17ff1 100644 --- a/panel/EventStore.swift +++ b/panel/EventStore.swift @@ -137,7 +137,26 @@ final class EventStore: ObservableObject { @Published private(set) var events: [NudgeEvent] = [] @Published var selectedID: NudgeEvent.ID? - private let maxEvents = 20 + var maxEventsPerSession: Int = 5 { + didSet { if maxEventsPerSession != oldValue { prune() } } + } + private let maxEventsTotal = 100 + + private func sessionKey(_ e: NudgeEvent) -> String { + e.claudeSessionID ?? "\(e.agent):\(e.projectPath ?? "")" + } + + private func prune() { + var counts: [String: Int] = [:] + events = events.filter { e in + let k = sessionKey(e) + counts[k, default: 0] += 1 + return counts[k]! <= maxEventsPerSession + } + if events.count > maxEventsTotal { + events = Array(events.prefix(maxEventsTotal)) + } + } /// Called on main queue after each new event is inserted. var onAppend: ((NudgeEvent) -> Void)? @@ -157,9 +176,10 @@ final class EventStore: ObservableObject { return } events.insert(event, at: 0) - if events.count > maxEvents { - events = Array(events.prefix(maxEvents)) - } + // Bound per-session first (so a chatty session can't crowd out quiet + // ones), then apply a hard global ceiling to cap memory/UI growth + // when many sessions are active in parallel. + prune() if selectedID != event.id { selectedID = event.id } onAppend?(event) if ProcessInfo.processInfo.environment["STACKNUDGE_PANEL_DEBUG"] != nil { diff --git a/panel/Panel.swift b/panel/Panel.swift index 2045f99..64e2bef 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -694,6 +694,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, .removeDuplicates() .sink { [weak self] _ in self?.applyCompactAlpha() } .store(in: &cancellables) + store.maxEventsPerSession = nav.eventsPerSession + nav.$eventsPerSession + .removeDuplicates() + .sink { [weak self] value in self?.store.maxEventsPerSession = value } + .store(in: &cancellables) applyCompactLayout() // If a previous panel instance was pkilled mid-update by install.sh, @@ -838,6 +843,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // so the panel actually shrinks before SwiftUI re-evaluates body // (otherwise the new CompactView renders at the still-large size). if nav.compactMode { + // Pin panel + Widget: Pin wins. Stay full-size on focus loss; + // the widget collapse still happens via Esc / hotkey / explicit + // user gestures, but auto-collapse on focus loss is suppressed. + if nav.panelPinned { return } if nav.compactExpanded { nav.compactExpanded = false applyCompactLayout() @@ -1206,6 +1215,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private func evaluateQuotaThresholds(_ snapshot: QuotaSnapshot) { guard nav.quotaAlertsEnabled else { return } + // Respect the global Banner notifications toggle — quota alerts + // are system-level banners and shouldn't bypass it. + guard nav.bannerEnabled else { return } let threshold = Double(nav.quotaAlertThreshold) let tiers: [(name: String, label: String, tier: QuotaTier?)] = [ @@ -1375,6 +1387,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private func evaluateContextThreshold(sessionID: String, stats: TranscriptStats) { let thresholdK = nav.contextAlertThresholdK guard thresholdK > 0 else { return } + // Respect the global Banner notifications toggle — context-fill + // banners are still system-level notifications and shouldn't fire + // when the user has turned banners off. + guard nav.bannerEnabled else { return } let threshold = thresholdK * 1_000 let current = stats.tokens diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 5391e19..765b128 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -179,6 +179,11 @@ final class PanelNav: ObservableObject { // tokens (not %) because Claude 4.x context windows vary by model. @Published var contextAlertThresholdK: Int = 0 static let contextAlertThresholdOptions: [Int] = [0, 100, 150, 175, 200, 300, 500, 750] + // Per-session cap on Events history. Each Claude session (or + // agent+project bucket for non-Claude agents) keeps the newest N + // events; a global ceiling of 100 in EventStore caps total growth. + @Published var eventsPerSession: Int = 5 + static let eventsPerSessionOptions: [Int] = [3, 5, 10, 20, 50] // Compact widget mode. Shrinks the panel to a glance-only widget pinned // to a screen corner; clicking it expands back to the full panel. // Compact mode is always-on now. The compactMode field is kept (and @@ -347,6 +352,8 @@ final class PanelNav: ObservableObject { mascot = MascotKind(rawValue: config["STACKNUDGE_MASCOT"] ?? "") ?? .robot 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 + eventsPerSession = Self.eventsPerSessionOptions.min(by: { abs($0 - rawPerSession) < abs($1 - rawPerSession) }) ?? 5 } // MARK: - Agent reconciliation @@ -539,13 +546,13 @@ final class PanelNav: ObservableObject { } else { startVoiceModelDownload() } - 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() + case 22: actions?.editPhrases() + case 23: actions?.checkPermissions() + case 24: actions?.openConfig() + case 25: actions?.openReleaseNotes() + case 26: actions?.checkForUpdates() + case 27: actions?.beginUninstall() + case 28: actions?.quit() default: applyCycle(forward: true) } } @@ -588,6 +595,12 @@ final class PanelNav: ObservableObject { case 3: panelPinned.toggle() ConfigFile.write(key: "STACKNUDGE_PANEL_PIN", value: panelPinned ? "true" : "false") + // Pin + Widget: Pin wins. Toggling Pin on from the widget pill + // should bring the full panel forward so the user sees what + // they just pinned. + if panelPinned, compactMode, !compactExpanded { + compactExpanded = true + } case 4: // Optimistic UI flip; revert if launchctl fails so the toggle // never reports a state that disagrees with the plist on disk. @@ -623,18 +636,18 @@ final class PanelNav: ObservableObject { ConfigFile.write(key: "STACKNUDGE_COMPACT_CORNER", value: compactCorner.rawValue) 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 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: + 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 9: soundEnabled.toggle() ConfigFile.write(key: "STACKNUDGE_SOUND", value: soundEnabled ? "true" : "false") @@ -694,6 +707,13 @@ final class PanelNav: ObservableObject { quotaShowRemaining.toggle() ConfigFile.write(key: "STACKNUDGE_QUOTA_SHOW_REMAINING", value: quotaShowRemaining ? "true" : "false") + case 21: + let list = Self.eventsPerSessionOptions + let idx = list.firstIndex(of: eventsPerSession) ?? 1 + let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count + eventsPerSession = list[next] + ConfigFile.write(key: "STACKNUDGE_EVENTS_PER_SESSION", + value: String(eventsPerSession)) default: break } diff --git a/panel/Settings.swift b/panel/Settings.swift index e97ac6a..0b0c801 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -55,8 +55,8 @@ struct SettingsView: View { section("Widget") { 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: "Widget opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%", enabled: nav.compactMode) + row(7 + off, label: "Widget opacity", kind: .cycle, value: "\(Int(nav.compactAlpha * 100))%", enabled: nav.compactMode) + row(8 + off, label: "Mascot", kind: .cycle, value: nav.mascot.label, enabled: nav.compactMode) } section("Sounds") { @@ -84,14 +84,18 @@ struct SettingsView: View { row(20 + off, label: "Show remaining", kind: .toggle, value: nav.quotaShowRemaining ? "On" : "Off", enabled: nav.quotaTrackingEnabled) } + section("Events") { + row(21 + off, label: "History per session", kind: .cycle, value: "\(nav.eventsPerSession)") + } + section("Actions") { - 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: "") + row(22 + off, label: "Edit phrases…", kind: .action, value: "") + row(23 + off, label: "Check permissions…", kind: .action, value: "") + row(24 + off, label: "Open config file…", kind: .action, value: "") + row(25 + off, label: "View release notes…", kind: .action, value: "") + row(26 + off, label: "Check for updates…", kind: .action, value: checkForUpdatesStatus) + row(27 + off, label: "Uninstall StackNudge…", kind: .action, value: "") + row(28 + off, label: "Quit panel", kind: .action, value: "") } aboutFooter