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
1 change: 1 addition & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ build_app "$APP" "stack-nudge" \
panel/Permissions.swift \
panel/Settings.swift \
panel/SessionStore.swift \
panel/SessionUsage.swift \
panel/Sessions.swift \
panel/Phrases.swift \
panel/UpdateChecker.swift \
Expand Down
123 changes: 121 additions & 2 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ private enum KeyCode {
static let one: UInt16 = 18
static let two: UInt16 = 19
static let three: UInt16 = 20
static let four: UInt16 = 21
static let nKey: UInt16 = 45
}

Expand Down Expand Up @@ -91,6 +92,7 @@ struct PanelContentView: View {
switch nav.mode {
case .events: eventsBody
case .sessions: SessionsView(store: sessions)
case .usage: UsageView(nav: nav)
case .settings: SettingsView(nav: nav)
case .phrases: PhrasesView(model: phrases) { nav.mode = .settings }
case .updateConfirm:
Expand All @@ -115,6 +117,7 @@ struct PanelContentView: View {

tab(.events, label: "Events", count: store.events.count)
tab(.sessions, label: "Sessions", count: sessions.sessions.filter { $0.status == .active }.count)
tab(.usage, label: "Usage", count: 0)
tab(.settings, label: "Settings", count: 0, dot: nav.updateAvailable != nil)

Spacer()
Expand All @@ -123,7 +126,7 @@ struct PanelContentView: View {
// uncluttered while still surfacing the shortcut range.
HStack(spacing: 2) {
KeyCapView(symbol: "⌘")
KeyCapView(symbol: "1-3")
KeyCapView(symbol: "1-4")
}
.opacity(0.7)
}
Expand Down Expand Up @@ -327,6 +330,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
private var permissionsWC: PermissionsWindowController?
private var updateChecker: UpdateChecker?
private var updater: Updater?
private let quotaProbe = QuotaProbe()
private var quotaTimer: Timer?
// Tracks whether the banner has already fired this period per tier so
// we don't refire on every poll. Reset when the tier's resets_at
// advances (a new period started, fresh budget).
private var quotaLastFired: [String: (resetsAt: Date?, fired: Bool)] = [:]

func applicationDidFinishLaunching(_ notification: Notification) {
let frame = NSRect(x: 0, y: 0, width: 420, height: 280)
Expand Down Expand Up @@ -396,6 +405,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
updateChecker?.start()
updater = Updater(nav: nav)

startQuotaPolling()

// If a previous panel instance was pkilled mid-update by install.sh,
// it left a status file behind. Read it now and surface a brief toast
// so the user knows the update completed (or failed).
Expand Down Expand Up @@ -486,6 +497,98 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
hidePanel()
}

// MARK: - Quota polling

// Fires QuotaProbe on a recurring timer. Cadence varies: 60s while the
// panel is visible (keeps the Usage tab feeling alive), longer when
// hidden (default 5 min, configurable via STACKNUDGE_USAGE_POLL_MIN).
// Re-evaluating on every tick keeps the timer schedule responsive to
// the panel being shown/hidden.
private static let quotaPollVisibleInterval: TimeInterval = 60
private var quotaPollHiddenInterval: TimeInterval {
let mins = Double(ConfigFile.read()["STACKNUDGE_USAGE_POLL_MIN"] ?? "") ?? 5
return max(60, mins * 60) // floor at 60s to avoid hammering the endpoint
}

private func startQuotaPolling() {
runQuotaProbe()
scheduleNextQuotaPoll()
}

private func scheduleNextQuotaPoll() {
quotaTimer?.invalidate()
let interval = panel.isVisible ? Self.quotaPollVisibleInterval : quotaPollHiddenInterval
quotaTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
self?.runQuotaProbe()
self?.scheduleNextQuotaPoll()
}
}

private func runQuotaProbe() {
quotaProbe.fetch { [weak self] snapshot in
guard let self, let snapshot else { return }
self.nav.quota = snapshot
self.nav.quotaLastUpdated = Date()
self.evaluateQuotaThresholds(snapshot)
}
}

// Fire a banner when a tier crosses the user's configured threshold —
// once per period (reset when the tier's resets_at advances past the
// prior recorded reset, i.e. a new period started with a fresh budget).
// Master switch on PanelNav silences everything when toggled off.
private func evaluateQuotaThresholds(_ snapshot: QuotaSnapshot) {
guard nav.quotaAlertsEnabled else { return }
let threshold = Double(nav.quotaAlertThreshold)

let tiers: [(name: String, label: String, tier: QuotaTier?)] = [
("five_hour", "Session", snapshot.fiveHour),
("seven_day", "Weekly", snapshot.sevenDay),
("seven_day_opus", "Weekly (Opus)", snapshot.sevenDayOpus),
("seven_day_sonnet", "Weekly (Sonnet)", snapshot.sevenDaySonnet),
]

for (name, label, tier) in tiers {
guard let tier else { continue }
var state = quotaLastFired[name] ?? (resetsAt: tier.resetsAt, fired: false)
// Reset the fired flag if the period has rolled over.
if let prior = state.resetsAt, let now = tier.resetsAt, now > prior {
state = (resetsAt: now, fired: false)
}
if tier.utilization >= threshold, !state.fired {
postQuotaBanner(label: label,
percent: Int(tier.utilization.rounded()),
resetsAt: tier.resetsAt)
state.fired = true
}
quotaLastFired[name] = state
}
}

// Cached — banner posts can fire frequently in test/edge cases; avoid
// allocating a fresh formatter every time.
private static let quotaBannerFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .full
return f
}()

private func postQuotaBanner(label: String, percent: Int, resetsAt: Date?) {
let body: String
if let resetsAt {
body = "\(percent)% used. Resets \(Self.quotaBannerFormatter.localizedString(for: resetsAt, relativeTo: Date()))."
} else {
body = "\(percent)% used."
}
let content = UNMutableNotificationContent()
content.title = "\(label) quota at \(percent)%"
content.body = body
content.categoryIdentifier = "STOP"
let req = UNNotificationRequest(identifier: UUID().uuidString,
content: content, trigger: nil)
UNUserNotificationCenter.current().add(req, withCompletionHandler: nil)
}

// MARK: - UNUserNotificationCenter

private func setupNotificationCenter() {
Expand Down Expand Up @@ -729,6 +832,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,

func applicationWillTerminate(_ notification: Notification) {
listener?.stop()
quotaTimer?.invalidate()
quotaTimer = nil
}

// MARK: - PanelKeyDelegate
Expand Down Expand Up @@ -787,7 +892,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
return true
}

// Cmd+1/2/3 jump directly between modes; the in-panel tab strip is
// Cmd+1/2/3/4 jump directly between modes; the in-panel tab strip is
// the discoverable mouse equivalent.
if cmdOnly {
switch event.keyCode {
Expand All @@ -796,6 +901,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
case KeyCode.two:
nav.mode = .sessions; return true
case KeyCode.three:
nav.mode = .usage; return true
case KeyCode.four:
nav.mode = .settings; return true
default:
break
Expand Down Expand Up @@ -942,6 +1049,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
return true
}

// Usage tab: only Esc — no selection / list semantics here, but we
// don't want arrow keys to leak through to the events store either.
if nav.mode == .usage {
let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty
guard plain else { return false }
if event.keyCode == KeyCode.escape {
hidePanel()
return true
}
return true // swallow other keys so they don't navigate events
}

// Events mode: filter out cmd/ctrl/opt-modified keys so app-level
// shortcuts pass through to the responder chain. Shift is allowed
// since we use Shift+S for the longer snooze.
Expand Down
53 changes: 44 additions & 9 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftUI
enum PanelMode {
case events
case sessions
case usage
case settings
case phrases
// Confirmation step after the user clicks the "Update available" row.
Expand Down Expand Up @@ -75,13 +76,27 @@ final class PanelNav: ObservableObject {
// PostUpdateView (mode = .postUpdate). Cleared on dismiss.
@Published var postUpdateVersion: String?
@Published var postUpdateNotes: String?
// Latest /api/oauth/usage snapshot. Driven by the QuotaProbe poller in
// PanelController. nil before the first probe completes, or when the
// probe failed (e.g. user denied keychain access, 401, 429).
@Published var quota: QuotaSnapshot?
@Published var quotaLastUpdated: Date?
// Threshold-crossing notifications. quotaAlertsEnabled is the master
// switch; quotaAlertThreshold is the single percent value used across
// all tiers — banner fires once per period when any tier reaches it.
@Published var quotaAlertsEnabled: Bool = true
@Published var quotaAlertThreshold: Int = 80

var actions: SettingsActions?
// Wired by PanelController so nav can re-register the global hotkey
// without owning the Hotkey instance directly. Returns true if the
// new spec registered successfully.
var setHotkey: ((String) -> Bool)?

// Selectable thresholds for the Usage "Alert threshold" cycle row.
// Sorted ascending so left/right arrows feel intuitive.
static let quotaThresholds: [Int] = [50, 70, 80, 90, 95]

static let macSounds = [
"Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero",
"Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink",
Expand Down Expand Up @@ -112,7 +127,7 @@ final class PanelNav: ObservableObject {
// when the offset is 1.
var updateRowOffset: Int { updateAvailable != nil ? 1 : 0 }

var rowCount: Int { 13 + updateRowOffset }
var rowCount: Int { 15 + updateRowOffset }

// Row layout (kept in one place so the controller, view, and indexing
// logic all agree on what each row index means). When updateAvailable
Expand All @@ -127,10 +142,12 @@ final class PanelNav: ObservableObject {
// 6 Permission sound cycle
// 7 Voice cycle
// 8 Speed cycle
// 9 Edit phrases… action
// 10 Check permissions… action
// 11 Open config file… action
// 12 Quit panel action
// 9 Quota alerts toggle
// 10 Alert threshold cycle
// 11 Edit phrases… action
// 12 Check permissions… action
// 13 Open config file… action
// 14 Quit panel action

// MARK: - Disk I/O

Expand All @@ -148,6 +165,11 @@ final class PanelNav: ObservableObject {
soundPermission = config["STACKNUDGE_SOUND_PERMISSION"] ?? "Ping"
voice = config["STACKNUDGE_VOICE_NAME"] ?? "af_aoede"
voiceSpeed = Double(config["STACKNUDGE_VOICE_SPEED"] ?? "") ?? 1.1
quotaAlertsEnabled = ConfigFile.bool(config, "STACKNUDGE_QUOTA_ALERTS", default: true)
// Coerce out-of-list values to the nearest valid threshold so a
// hand-edited config can't desync the cycle row's selection.
let rawThreshold = Int(config["STACKNUDGE_QUOTA_THRESHOLD"] ?? "") ?? 80
quotaAlertThreshold = Self.quotaThresholds.min(by: { abs($0 - rawThreshold) < abs($1 - rawThreshold) }) ?? 80
}

func loadVoices() {
Expand Down Expand Up @@ -208,10 +230,10 @@ final class PanelNav: ObservableObject {
}
switch selectedSettingIndex - updateRowOffset {
case 0: startRecordingHotkey()
case 9: actions?.editPhrases()
case 10: actions?.checkPermissions()
case 11: actions?.openConfig()
case 12: actions?.quit()
case 11: actions?.editPhrases()
case 12: actions?.checkPermissions()
case 13: actions?.openConfig()
case 14: actions?.quit()
default: applyCycle(forward: true)
}
}
Expand Down Expand Up @@ -255,6 +277,19 @@ final class PanelNav: ObservableObject {
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 9:
quotaAlertsEnabled.toggle()
ConfigFile.write(key: "STACKNUDGE_QUOTA_ALERTS",
value: quotaAlertsEnabled ? "true" : "false")
case 10:
// Cycle through the static thresholds list. Index wraps in both
// directions so the user can dial in either way.
let list = Self.quotaThresholds
let idx = list.firstIndex(of: quotaAlertThreshold) ?? 2
let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count
quotaAlertThreshold = list[next]
ConfigFile.write(key: "STACKNUDGE_QUOTA_THRESHOLD",
value: String(quotaAlertThreshold))
default:
break
}
Expand Down
Loading