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
3 changes: 3 additions & 0 deletions panel/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,13 @@ struct FooterHint: View {
Text(label)
.font(.caption)
.foregroundStyle(primary ? Color.primary : Color.secondary)
.fixedSize(horizontal: true, vertical: false)
HStack(spacing: 2) {
ForEach(keys, id: \.self) { KeyCapView(symbol: $0) }
}
.fixedSize()
}
.fixedSize()
.padding(.leading, 10)
}
}
Expand Down
35 changes: 33 additions & 2 deletions panel/EventStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ struct NudgeEvent: Identifiable, Equatable {
let ipcHook: String?
let hasActionButton: Bool
let timestamp: Date
// When set and in the future, the user has snoozed this event. The panel
// dims it; a timer scheduled in PanelController will fire a fresh banner
// when the snooze elapses. EventStore.setSnoozedUntil replaces the
// whole struct since we keep all fields immutable.
let snoozedUntil: Date?
// Session enrichment — populated by notify.sh's walk_session_chain. Used
// by AppActivator for precise pane focus and by the future sessions
// feature for grouping events back to their source CC/Gemini/Codex
Expand All @@ -45,8 +50,10 @@ struct NudgeEvent: Identifiable, Equatable {
agentPID: Int? = nil, shellPID: Int? = nil,
terminalPID: Int? = nil, terminalApp: String? = nil,
termProgram: String? = nil, sessionID: String? = nil,
fifoPath: String? = nil) {
self.id = UUID()
fifoPath: String? = nil,
snoozedUntil: Date? = nil,
id: UUID = UUID()) {
self.id = id
self.agent = agent
self.kind = kind
self.title = title
Expand All @@ -64,6 +71,23 @@ struct NudgeEvent: Identifiable, Equatable {
self.termProgram = termProgram
self.sessionID = sessionID
self.fifoPath = fifoPath
self.snoozedUntil = snoozedUntil
}

// Whole-struct copy with the snoozedUntil overridden. EventStore uses
// this when the user snoozes / when the snooze elapses.
func with(snoozedUntil: Date?) -> NudgeEvent {
NudgeEvent(
agent: agent, kind: kind, title: title, message: message,
projectPath: projectPath, bundleID: bundleID,
windowTitle: windowTitle, ipcHook: ipcHook,
hasActionButton: hasActionButton, timestamp: timestamp,
agentPID: agentPID, shellPID: shellPID,
terminalPID: terminalPID, terminalApp: terminalApp,
termProgram: termProgram, sessionID: sessionID,
fifoPath: fifoPath, snoozedUntil: snoozedUntil,
id: id // preserve identity across snooze cycles
)
}
}

Expand Down Expand Up @@ -95,6 +119,13 @@ final class EventStore: ObservableObject {
if selectedID == id { selectedID = events.first?.id }
}

// Replace the event in-place with a new copy that has snoozedUntil set
// (or cleared with nil). Triggers SwiftUI updates because @Published.
func setSnoozedUntil(id: NudgeEvent.ID, _ until: Date?) {
guard let idx = events.firstIndex(where: { $0.id == id }) else { return }
events[idx] = events[idx].with(snoozedUntil: until)
}

func selectNext() {
guard !events.isEmpty else { return }
let idx = events.firstIndex { $0.id == selectedID } ?? 0
Expand Down
105 changes: 96 additions & 9 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ private enum KeyCode {
static let space: UInt16 = 49
static let tab: UInt16 = 48
static let oKey: UInt16 = 31
static let sKey: UInt16 = 1
static let rKey: UInt16 = 15
static let delete: UInt16 = 51
static let forwardDelete: UInt16 = 117
Expand Down Expand Up @@ -191,6 +192,10 @@ struct PanelContentView: View {
FooterDivider()
}
FooterHint(label: "Select", keys: ["↑", "↓"])
if let selected = store.selectedEvent,
selected.kind == .permission, selected.hasActionButton {
FooterHint(label: "Snooze", keys: ["S"])
}
FooterHint(label: "Dismiss", keys: ["⌫"])
FooterHint(label: "Hide", keys: ["esc"])
}
Expand Down Expand Up @@ -237,7 +242,7 @@ struct EventRow: View {
.truncationMode(.middle)
}
Spacer(minLength: 8)
Text(Self.timeFormatter.localizedString(for: event.timestamp, relativeTo: Date()))
Text(rightTimestamp)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Expand All @@ -248,6 +253,7 @@ struct EventRow: View {
.truncationMode(.tail)
}
}
.opacity(isSnoozed ? 0.55 : 1.0)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
Expand All @@ -258,12 +264,28 @@ struct EventRow: View {
.padding(.horizontal, 6)
}

private var isSnoozed: Bool {
guard let until = event.snoozedUntil else { return false }
return until > Date()
}

// For snoozed events show "snoozed Xm" in place of the relative
// timestamp so the user can see how long is left until the re-fire.
private var rightTimestamp: String {
if let until = event.snoozedUntil, until > Date() {
return "snoozed " + Self.timeFormatter.localizedString(for: until, relativeTo: Date())
}
return Self.timeFormatter.localizedString(for: event.timestamp, relativeTo: Date())
}

private var glyph: String {
event.kind == .permission ? "questionmark.circle.fill" : "checkmark.circle.fill"
if isSnoozed { return "moon.zzz.fill" }
return event.kind == .permission ? "questionmark.circle.fill" : "checkmark.circle.fill"
}

private var glyphColor: Color {
event.kind == .permission ? .orange : .green
if isSnoozed { return .gray }
return event.kind == .permission ? .orange : .green
}
}

Expand Down Expand Up @@ -383,10 +405,18 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
// Request permission (shows the system dialog once; subsequent calls are no-ops).
center.requestAuthorization(options: [.alert]) { _, _ in }

// Register categories: STOP (no actions) and PERMISSION (Allow button).
let allow = UNNotificationAction(identifier: "ALLOW", title: "Allow", options: [])
// Register categories: STOP (no actions) and PERMISSION with Allow +
// two snooze options. macOS surfaces the first action as a primary
// button on the banner and collapses the rest into the chevron
// expansion automatically once the action count exceeds two.
let allow = UNNotificationAction(identifier: "ALLOW",
title: "Allow", options: [])
let snooze5 = UNNotificationAction(identifier: "SNOOZE_5M",
title: "Snooze 5 min", options: [])
let snooze15 = UNNotificationAction(identifier: "SNOOZE_15M",
title: "Snooze 15 min", options: [])
let permCategory = UNNotificationCategory(identifier: "PERMISSION",
actions: [allow],
actions: [allow, snooze5, snooze15],
intentIdentifiers: [],
options: [])
let stopCategory = UNNotificationCategory(identifier: "STOP",
Expand Down Expand Up @@ -417,21 +447,45 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
}

guard config.bannerEnabled else { return }
postBanner(for: event)
}

// Posts a UNNotificationRequest for an event. Used by postBannerIfNeeded
// for the initial fire and by the snooze timer for re-fires. Request
// identifier is a fresh UUID each time (macOS replaces by identifier);
// event.id stays in userInfo so click handlers can find the source.
private func postBanner(for event: NudgeEvent) {
let content = UNMutableNotificationContent()
content.title = event.title
content.body = event.message
content.categoryIdentifier = event.kind == .permission ? "PERMISSION" : "STOP"
content.userInfo = ["eventID": event.id.uuidString]

let center = UNUserNotificationCenter.current()
// Remove stale delivered notifications so they don't pile up.
center.removeAllDeliveredNotifications()
let req = UNNotificationRequest(identifier: event.id.uuidString,
let req = UNNotificationRequest(identifier: UUID().uuidString,
content: content, trigger: nil)
center.add(req, withCompletionHandler: nil)
}

// Snooze: mark the event, schedule a Timer to clear the snooze flag and
// re-post a fresh banner after `seconds`. The hook stays blocked on the
// FIFO the whole time. If the event is removed (resolved or dismissed)
// before the timer fires, the timer becomes a no-op via the lookup.
private func snoozeEvent(_ event: NudgeEvent, for seconds: TimeInterval) {
let until = Date().addingTimeInterval(seconds)
store.setSnoozedUntil(id: event.id, until)
UNUserNotificationCenter.current().removeAllDeliveredNotifications()

Timer.scheduledTimer(withTimeInterval: seconds, repeats: false) { [weak self] _ in
guard let self,
let current = self.store.events.first(where: { $0.id == event.id })
else { return }
self.store.setSnoozedUntil(id: current.id, nil)
self.postBanner(for: current.with(snoozedUntil: nil))
}
}

// Called when the user clicks the banner or its action button.
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
Expand All @@ -441,6 +495,20 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
let event = store.events.first(where: { $0.id.uuidString == eventID })
else { return }

// Snooze: don't write the FIFO, don't remove the event. Just mark
// it as snoozed and schedule a re-fire of the banner after the
// chosen duration. The hook stays blocked the whole time.
switch response.actionIdentifier {
case "SNOOZE_5M":
snoozeEvent(event, for: 5 * 60)
return
case "SNOOZE_15M":
snoozeEvent(event, for: 15 * 60)
return
default:
break
}

store.remove(id: event.id)
let approve = response.actionIdentifier == "ALLOW"

Expand Down Expand Up @@ -705,10 +773,21 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
}

// Events mode: filter out cmd/ctrl/opt-modified keys so app-level
// shortcuts pass through to the responder chain.
// shortcuts pass through to the responder chain. Shift is allowed
// since we use Shift+S for the longer snooze.
guard mods.intersection(blockingMods.union([.command])).isEmpty else {
return false
}
let shifted = mods.contains(.shift)

// S/Shift+S handled before the no-shift switch since both variants
// are valid; everything else requires no shift.
if event.keyCode == KeyCode.sKey {
snoozeSelected(for: shifted ? 15 * 60 : 5 * 60)
return true
}
guard !shifted else { return false }

switch event.keyCode {
case KeyCode.escape:
hidePanel()
Expand All @@ -728,6 +807,14 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
return true
}

private func snoozeSelected(for seconds: TimeInterval) {
guard let event = store.selectedEvent,
event.kind == .permission,
event.hasActionButton
else { return }
snoozeEvent(event, for: seconds)
}

// MARK: - Actions

// Acting on a nudge: hide the app (so system frontmost reverts naturally),
Expand Down