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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions panel/CompactView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -410,31 +410,30 @@ 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)
}

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 {
Expand Down
28 changes: 24 additions & 4 deletions panel/EventStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,26 @@ final class EventStore: ObservableObject {
@Published private(set) var events: [NudgeEvent] = []
@Published var selectedID: NudgeEvent.ID?

private let maxEvents = 5
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)?
Expand All @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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?)] = [
Expand Down Expand Up @@ -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

Expand Down
46 changes: 33 additions & 13 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down
22 changes: 13 additions & 9 deletions panel/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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
Expand Down