diff --git a/notify.sh b/notify.sh index 5fc2f0a..07bb599 100755 --- a/notify.sh +++ b/notify.sh @@ -276,6 +276,23 @@ agent_supports_decision() { esac } +# Agent-initiated question (multi-select / open prompt), not a tool +# permission. Approving in the panel would write "allow" to the FIFO, +# which CC interprets as "press Enter on the highlighted option" — making +# it pick a default. By emitting has_action=false, panel Enter falls +# through to focusing the editor so the user answers in the terminal. +is_question_event() { + [[ "$AGENT" != "claude-code" ]] && return 1 + command -v jq &>/dev/null || return 1 + [[ -z "$HOOK_JSON" ]] && return 1 + local tool_name + tool_name=$(printf '%s' "$HOOK_JSON" | jq -r '.tool_name // empty' 2>/dev/null) + case "$tool_name" in + AskUserQuestion) return 0 ;; + *) return 1 ;; + esac +} + # Bundled voice engine paths. stackvox 0.3.x consolidated the CLI — there # is no separate `stackvox-say` console script anymore; speech goes through # `stackvox say ` as a subcommand. @@ -550,7 +567,7 @@ notify_macos() { # only agents the banner still shows; the user approves in the agent's own UI. local has_action="false" local fifo_path="" - if [[ "${EVENT}" == "permission" ]] && agent_supports_decision; then + if [[ "${EVENT}" == "permission" ]] && agent_supports_decision && ! is_question_event; then has_action="true" fifo_path=$(create_perm_fifo) fi diff --git a/panel/CompactView.swift b/panel/CompactView.swift index 83ff476..c11ddaa 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -31,15 +31,27 @@ struct CompactView: View { private static let cyclePeriod: TimeInterval = 5.0 var body: some View { - HStack(spacing: 10) { - gaugeCluster - separator - headline - Spacer(minLength: 4) - sessionBadge - expandButton + Group { + if nav.compactContent == .usage { + HStack(alignment: .center, spacing: 6) { + gaugeCluster + Spacer(minLength: 0) + expandButton + } + .frame(maxHeight: .infinity) + .padding(.horizontal, 4) + } else { + HStack(spacing: 10) { + gaugeCluster + separator + headline + Spacer(minLength: 4) + sessionBadge + expandButton + } + .padding(.horizontal, 12) + } } - .padding(.horizontal, 12) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(pillBackground) .overlay(ripple) @@ -79,12 +91,16 @@ struct CompactView: View { // MARK: - Gauge cluster (5h gauge + 7d + reset countdown) private var gaugeCluster: some View { + gaugeClusterBody(size: nav.compactContent == .usage ? 52 : 42) + } + + private func gaugeClusterBody(size: CGFloat) -> some View { HStack(spacing: 6) { ZStack { if !nav.compactDragging { Circle() .fill(urgencyColor.opacity(0.18)) - .frame(width: 44, height: 44) + .frame(width: size + 2, height: size + 2) .blur(radius: 8) } QuotaGauge( @@ -97,15 +113,50 @@ struct CompactView: View { paused: nav.compactDragging, showRemaining: nav.quotaShowRemaining ) - .frame(width: 42, height: 42) + .frame(width: size, height: size) } + sideText + } + // Gated on !dragging so we don't fight AppKit's drag handler. + .scaleEffect(isHovering && !nav.compactDragging ? 1.07 : 1.0) + .brightness(isHovering && !nav.compactDragging ? 0.06 : 0) + .animation(.easeInOut(duration: 0.18), value: isHovering) + .help("Inner ring: 5h session quota · Outer ring: 7d weekly quota") + } + + @ViewBuilder + private var sideText: some View { + let show = isHovering && !nav.compactDragging + 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. + hoverLegend + .opacity(0) + .accessibilityHidden(true) if let reset = nav.quota?.fiveHour?.resetsAt { Text(Self.shortDuration(until: reset)) .font(.system(size: 9).monospacedDigit()) .foregroundStyle(.tertiary) + .opacity(show ? 0 : 1) } + hoverLegend + .opacity(show ? 1 : 0) + } + .animation(.easeInOut(duration: 0.18), value: show) + } + + 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 —" + return VStack(alignment: .leading, spacing: 1) { + Text(fiveText) + Text(sevenText) } + .font(.system(size: 9, weight: .medium).monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() } // MARK: - Headline diff --git a/panel/OutcomesView.swift b/panel/OutcomesView.swift index 4d9fcb3..264ab73 100644 --- a/panel/OutcomesView.swift +++ b/panel/OutcomesView.swift @@ -111,7 +111,6 @@ struct OutcomesView: View { FooterHint(label: "Open", keys: ["↵"]) FooterHint(label: "Remove", keys: ["⌫"]) } - if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } FooterHint(label: "Hide", keys: ["Esc"]) } } diff --git a/panel/Panel.swift b/panel/Panel.swift index 7b33a78..7bb5fd7 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -296,7 +296,6 @@ struct PanelContentView: View { private var footer: some View { PageFooter { if store.events.isEmpty { - if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } FooterHint(label: "Hide", keys: ["Esc"]) } else { if let primary = primaryActionLabel { @@ -311,7 +310,6 @@ struct PanelContentView: View { FooterHint(label: "Snooze", keys: ["S"]) .opacity(snoozeEnabled ? 1.0 : 0.35) FooterHint(label: dismissLabel, keys: ["⌫"]) - if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } FooterHint(label: "Hide", keys: ["Esc"]) } } @@ -757,6 +755,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, .removeDuplicates() .sink { [weak self] _ in self?.applyCompactAlpha() } .store(in: &cancellables) + nav.$compactContent + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in self?.applyCompactLayout() } + .store(in: &cancellables) store.maxEventsPerSession = nav.eventsPerSession nav.$eventsPerSession .removeDuplicates() @@ -923,8 +926,15 @@ 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 compactWidgetInset: CGFloat = 14 + private var compactWidgetSizeForMode: NSSize { + nav.compactContent == .usage + ? Self.compactWidgetUsageSize + : Self.compactWidgetSize + } + // Apply window size + origin appropriate to the current compact-mode // state. Called whenever nav.compactMode or nav.compactExpanded changes // and once at launch to handle config-restored state. @@ -933,7 +943,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // Widget: shrink + pin to the chosen corner, float above // everything, follow the user across spaces. Transparent // window background so the SwiftUI Capsule shows through. - let size = Self.compactWidgetSize + let size = compactWidgetSizeForMode // Lower the minimum content size below the widget dimensions // so AppKit doesn't enforce the original 560x260 floor after // a system event like screen reconfiguration or lock/unlock. @@ -953,6 +963,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, .fullScreenAuxiliary, .ignoresCycle] panel.hasShadow = false // SwiftUI Capsule provides its own panel.isMovableByWindowBackground = true // drag to reposition + // Dropping .resizable kills invisible edge-resize handles and + // opts out of macOS Sequoia's drag-to-edge tile gesture. + panel.styleMask.remove(.resizable) panel.orderFront(nil) } else { // Full panel: restore saved size + saved origin. Keep @@ -978,6 +991,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, panel.collectionBehavior = [] panel.hasShadow = true panel.isMovableByWindowBackground = true + panel.styleMask.insert(.resizable) positionPanel() if nav.compactExpanded { NSApp.activate(ignoringOtherApps: true) @@ -1030,15 +1044,6 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, expandFromCompactUserGesture() } - // Called from the "M" keystroke in Events/Sessions/Usage tabs to - // collapse back to the pill. - private func enterCompactMode() { - if nav.compactExpanded { - nav.compactExpanded = false - applyCompactLayout() - } - } - // Debounced snap: each move during a drag bumps the deadline forward. // After the user releases (no further moves for ~280ms), pick the // nearest corner of the active screen, persist it, and animate the @@ -1796,6 +1801,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private func postBannerIfNeeded(_ event: NudgeEvent) { let config = PanelConfig.load() + // Mascot/ripple still fire — they're driven from onAppend, not here. + if !event.bypassMute, + nav.isSessionMuted(event: event, in: sessions.sessions) { + return + } + if config.activateImmediately, let bundleID = event.bundleID { DispatchQueue.global(qos: .userInitiated).async { AppActivator.activate(bundleID: bundleID, @@ -2378,7 +2389,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, let pid = sessions.selectedPID ?? sessions.sessions.first?.pid if let pid { sessions.startRenaming(pid) } case KeyCode.mKey where plain: - enterCompactMode() + toggleMuteSelectedSession() default: return false } @@ -2466,8 +2477,6 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, case KeyCode.pKey: nav.toggleQuotaTracking() if nav.quotaTrackingEnabled { syncQuotaNow() } - case KeyCode.mKey: - enterCompactMode() default: break } @@ -2490,8 +2499,6 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // after the keystroke — otherwise they'd wait for the next // scheduled tick (up to the configured poll interval). if nav.quotaTrackingEnabled { syncQuotaNow() } - case KeyCode.mKey: - enterCompactMode() default: break } @@ -2499,8 +2506,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } // Tickets tab: ↑/↓ move the row selection, →/Enter open the selected - // row's ticket/PR link, M enters the widget, Esc hides. Other keys are - // swallowed so they don't leak through to the events store. + // row's ticket/PR link, Esc hides. Other keys are swallowed so they + // don't leak through to the events store. if nav.mode == .outcomes { let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty guard plain else { return false } @@ -2515,8 +2522,6 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, nav.activateSelectedOutcome() case KeyCode.delete, KeyCode.forwardDelete: nav.dismissSelectedOutcome() - case KeyCode.mKey: - enterCompactMode() default: break } @@ -2552,8 +2557,6 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, actOnSelected(approve: false) case KeyCode.rKey, KeyCode.delete, KeyCode.forwardDelete: dismissSelected() - case KeyCode.mKey: - enterCompactMode() default: return false } @@ -2664,6 +2667,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, sessions.killSession(pid) } + private func toggleMuteSelectedSession() { + guard let pid = sessions.selectedPID, + let session = sessions.sessions.first(where: { $0.pid == pid }) else { return } + nav.toggleMute(for: session) + } + // Map a terminal/IDE process name to the launch-services bundle ID so // AppActivator can talk to the right app. private func bundleID(for terminalApp: String?) -> String? { diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index 9634478..279f057 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -55,6 +55,18 @@ enum CompactCorner: String, CaseIterable { } } +enum CompactContent: String, CaseIterable { + case sessions + case usage + + var label: String { + switch self { + case .sessions: return "Sessions" + case .usage: return "Usage" + } + } +} + enum MascotKind: String, CaseIterable { case robot case cat @@ -88,7 +100,7 @@ enum GithubSignIn: Equatable { enum SettingsRow: Hashable { case update, hotkey case banner, muteWhenFocused, pinPanel, keepOpenWhenEmpty, launchAtLogin - case widget, widgetCorner, widgetOpacity, mascot + case widget, widgetCorner, widgetOpacity, widgetContent, mascot case soundEnabled, agentDoneSound, permissionSound case voiceEnabled, voice, voiceSpeed, downloadVoiceModel case quotaTracking, quotaAlerts, alertThreshold, pollFrequency, contextAlert, showRemaining @@ -435,6 +447,7 @@ final class PanelNav: ObservableObject { // the controller's compact-aware code; just don't expose a toggle. @Published var compactMode: Bool = true @Published var compactCorner: CompactCorner = .topRight + @Published var compactContent: CompactContent = .sessions @Published var mascot: MascotKind = .robot // Pill window alpha when at rest. 1.0 = fully opaque; lower values // let the desktop show through so the widget recedes. Applied @@ -538,7 +551,7 @@ final class PanelNav: ObservableObject { if updateAvailable != nil { rows.append(.update) } rows += [.hotkey, .banner, .muteWhenFocused, .pinPanel, .keepOpenWhenEmpty, .launchAtLogin, - .widget, .widgetCorner, .widgetOpacity, .mascot, + .widget, .widgetCorner, .widgetOpacity, .widgetContent, .mascot, .soundEnabled, .agentDoneSound, .permissionSound, .voiceEnabled] rows += voiceModelCached ? [.voice, .voiceSpeed] : [.downloadVoiceModel] @@ -615,6 +628,8 @@ final class PanelNav: ObservableObject { compactMode = ConfigFile.bool(config, "STACKNUDGE_COMPACT_MODE", default: true) compactCorner = CompactCorner(rawValue: config["STACKNUDGE_COMPACT_CORNER"] ?? "") ?? .topRight + compactContent = CompactContent(rawValue: config["STACKNUDGE_COMPACT_CONTENT"] ?? "") + ?? .sessions 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 @@ -671,6 +686,24 @@ final class PanelNav: ObservableObject { private static let dismissedAgentsPath = "\(NSHomeDirectory())/.stack-nudge/dismissed-agents.json" + func toggleMute(for session: Session) { + SessionPersistence.shared.toggleMuted(session) + objectWillChange.send() + } + + func isMuted(_ session: Session) -> Bool { + SessionPersistence.shared.isMuted(session) + } + + // Unmatched events fall through unmuted — better to notify on + // something we can't classify than to silently drop it. + func isSessionMuted(event: NudgeEvent, in sessions: [Session]) -> Bool { + guard let match = sessions.first(where: { sessionMatches(event: event, session: $0) }) else { + return false + } + return isMuted(match) + } + private func loadDismissedAgents() { guard let data = try? Data(contentsOf: URL(fileURLWithPath: Self.dismissedAgentsPath)), let arr = try? JSONSerialization.jsonObject(with: data) as? [String] @@ -906,6 +939,12 @@ final class PanelNav: ObservableObject { 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 .widgetContent: + let list = CompactContent.allCases + let idx = list.firstIndex(of: compactContent) ?? 0 + let next = forward ? (idx + 1) % list.count : (idx - 1 + list.count) % list.count + compactContent = list[next] + ConfigFile.write(key: "STACKNUDGE_COMPACT_CONTENT", value: compactContent.rawValue) case .mascot: let list = MascotKind.allCases let idx = list.firstIndex(of: mascot) ?? 0 diff --git a/panel/SessionPersistence.swift b/panel/SessionPersistence.swift index 357aac2..7ec09e8 100644 --- a/panel/SessionPersistence.swift +++ b/panel/SessionPersistence.swift @@ -17,16 +17,29 @@ enum Agent { } } -// Per-session settings that survive app restarts. Today the only thing -// we keep is the user-chosen display name; `lastSeenAt` exists so a -// future cleanup pass can evict long-dormant entries without us needing -// to re-shape the file. We deliberately do NOT store an entry for every -// session we ever observe — only entries the user has explicitly named. -// That keeps the file small, sidesteps cleanup for v1, and means an -// un-renamed session has zero on-disk footprint. +// Per-session preferences that survive restarts. Only sessions the user +// has deliberately touched (rename, mute) get an entry; un-touched +// sessions have zero on-disk footprint. `lastSeenAt` exists for a +// future dormancy-based eviction pass. struct SessionEntry: Codable { - var customName: String + var customName: String? + var muted: Bool var lastSeenAt: TimeInterval + + init(customName: String? = nil, muted: Bool = false, lastSeenAt: TimeInterval) { + self.customName = customName + self.muted = muted + self.lastSeenAt = lastSeenAt + } + + // Tolerate legacy entries (no `muted` field) so existing sessions.json + // files keep working after the schema gained the field. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.customName = try c.decodeIfPresent(String.self, forKey: .customName) + self.muted = (try c.decodeIfPresent(Bool.self, forKey: .muted)) ?? false + self.lastSeenAt = try c.decode(TimeInterval.self, forKey: .lastSeenAt) + } } // Disk-backed store of session names, keyed by "::". @@ -91,13 +104,56 @@ final class SessionPersistence: ObservableObject { guard let projectPath else { return } let key = Self.key(agent: Agent.canonical(agent), projectPath: projectPath, tabId: tabId) let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let now = Date().timeIntervalSince1970 if trimmed.isEmpty { - guard entries.removeValue(forKey: key) != nil else { return } + // Clearing the name: keep the entry only if it has other prefs. + guard var existing = entries[key] else { return } + existing.customName = nil + if existing.muted { + existing.lastSeenAt = now + entries[key] = existing + } else { + entries.removeValue(forKey: key) + } } else { - entries[key] = SessionEntry( - customName: trimmed, - lastSeenAt: Date().timeIntervalSince1970 - ) + var entry = entries[key] ?? SessionEntry(lastSeenAt: now) + entry.customName = trimmed + entry.lastSeenAt = now + entries[key] = entry + } + save() + } + + // MARK: - Mute + + func isMuted(agent: String, projectPath: String?, tabId: String? = nil) -> Bool { + guard let projectPath else { return false } + let canon = Agent.canonical(agent) + if let tabId, !tabId.isEmpty, + entries[Self.key(agent: canon, projectPath: projectPath, tabId: tabId)]?.muted == true { + return true + } + return entries[Self.key(agent: canon, projectPath: projectPath, tabId: nil)]?.muted == true + } + + func isMuted(_ session: Session) -> Bool { + isMuted(agent: session.agent, projectPath: session.projectPath, tabId: session.tabId) + } + + func toggleMuted(_ session: Session) { + guard let projectPath = session.projectPath, !projectPath.isEmpty else { return } + let key = Self.key(agent: Agent.canonical(session.agent), + projectPath: projectPath, tabId: session.tabId) + let now = Date().timeIntervalSince1970 + var entry = entries[key] ?? SessionEntry(lastSeenAt: now) + entry.muted = !entry.muted + entry.lastSeenAt = now + // Drop entries that hold no surviving preference, matching the + // "deliberate user intent only" invariant for the on-disk store. + if entry.muted == false, entry.customName?.isEmpty ?? true { + entries.removeValue(forKey: key) + } else { + entries[key] = entry } save() } @@ -131,25 +187,54 @@ final class SessionPersistence: ObservableObject { // MARK: - I/O - private static func key(agent: String, projectPath: String, tabId: String?) -> String { + // Stable across PID churn and restarts; shared with PanelNav.mutedSessions. + static func key(agent: String, projectPath: String, tabId: String?) -> String { + let canon = Agent.canonical(agent) if let tabId, !tabId.isEmpty { - return "\(agent)::\(projectPath)::\(tabId)" + return "\(canon)::\(projectPath)::\(tabId)" } - return "\(agent)::\(projectPath)" + return "\(canon)::\(projectPath)" + } + + // nil when the session has no projectPath — no stable identity possible. + static func key(for session: Session) -> String? { + guard let path = session.projectPath, !path.isEmpty else { return nil } + return key(agent: session.agent, projectPath: path, tabId: session.tabId) } private func load() { - guard FileManager.default.fileExists(atPath: url.path), - let data = try? Data(contentsOf: url) else { return } - do { - entries = try JSONDecoder().decode([String: SessionEntry].self, from: data) - } catch { - // Don't crash — a malformed file shouldn't take down the app. - // Logging is enough; the next setCustomName call will rewrite. - FileHandle.standardError.write(Data( - "stack-nudge: sessions.json decode failed (\(error)); starting fresh\n".utf8)) - entries = [:] + if FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url) { + do { + entries = try JSONDecoder().decode([String: SessionEntry].self, from: data) + } catch { + // Don't crash — a malformed file shouldn't take down the app. + // Logging is enough; the next setCustomName call will rewrite. + FileHandle.standardError.write(Data( + "stack-nudge: sessions.json decode failed (\(error)); starting fresh\n".utf8)) + entries = [:] + } } + migrateLegacyMutedSessions() + } + + // Pre-fold mute lived in ~/.stack-nudge/muted-sessions.json with the + // same key shape; merge it into the entries dict and delete the file. + private func migrateLegacyMutedSessions() { + let legacy = url.deletingLastPathComponent() + .appendingPathComponent("muted-sessions.json", isDirectory: false) + guard FileManager.default.fileExists(atPath: legacy.path), + let data = try? Data(contentsOf: legacy), + let keys = try? JSONSerialization.jsonObject(with: data) as? [String] + else { return } + let now = Date().timeIntervalSince1970 + for key in keys { + var entry = entries[key] ?? SessionEntry(lastSeenAt: now) + entry.muted = true + entries[key] = entry + } + try? FileManager.default.removeItem(at: legacy) + save() } private func save() { diff --git a/panel/SessionUsage.swift b/panel/SessionUsage.swift index 7f5e90d..4335597 100644 --- a/panel/SessionUsage.swift +++ b/panel/SessionUsage.swift @@ -280,7 +280,6 @@ struct UsageView: View { FooterHint(label: "Sync now", keys: ["R"]) } FooterHint(label: nav.quotaTrackingEnabled ? "Pause" : "Resume", keys: ["P"]) - if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } FooterHint(label: "Hide", keys: ["Esc"]) } } diff --git a/panel/Sessions.swift b/panel/Sessions.swift index 780d082..9d603bc 100644 --- a/panel/Sessions.swift +++ b/panel/Sessions.swift @@ -52,6 +52,7 @@ struct SessionsView: View { activeNudgeCount: nudgeCount(for: session), lastNudgeAt: lastNudgeAt(for: session), transcriptStats: transcriptStats(for: session), + isMuted: nav.isMuted(session), onCommit: { store.commitRename() }, onCancel: { store.cancelRename() } ) @@ -82,8 +83,8 @@ struct SessionsView: View { FooterHint(label: "Select", keys: ["↑", "↓"]) FooterHint(label: "Focus", keys: ["⏎"]) FooterHint(label: "Rename", keys: ["N"]) + FooterHint(label: "Mute", keys: ["M"]) FooterHint(label: "Kill", keys: ["⌫"]) - if nav.compactMode { FooterHint(label: "Compact", keys: ["M"]) } FooterHint(label: "Back", keys: ["Esc"]) } } @@ -134,31 +135,36 @@ struct SessionsView: View { } private func matches(event: NudgeEvent, session: Session) -> Bool { - guard Agent.canonical(event.agent) == Agent.canonical(session.agent) else { - return false - } - // Strongest disambiguator: notify.sh's walk_session_chain captures - // the agent process PID; SessionStore.pid is the same number. Trust - // it over projectPath because the event's project path (= shell - // $PWD when notify.sh ran) can disagree with the session's project - // path (= lsof cwd of the claude process). Zed sessions exhibit - // this — claude cwd stays at the workspace root while the user's - // shell can cd into a subdirectory. - if let eventPID = event.agentPID, eventPID > 0 { - return eventPID == session.pid - } - // Without a PID, fall back to projectPath + terminal-tab - // disambiguator. Same path required; tabId narrows when both have it. - guard event.projectPath == session.projectPath else { return false } - let eventTab = (event.sessionID?.isEmpty == false ? event.sessionID : event.ipcHook) - if let sessionTab = session.tabId, let eventTab, - !sessionTab.isEmpty, !eventTab.isEmpty { - return sessionTab == eventTab - } - return true + sessionMatches(event: event, session: session) } } +// Top-level so PanelNav can reuse this when gating per-session mute. +func sessionMatches(event: NudgeEvent, session: Session) -> Bool { + guard Agent.canonical(event.agent) == Agent.canonical(session.agent) else { + return false + } + // Strongest disambiguator: notify.sh's walk_session_chain captures + // the agent process PID; SessionStore.pid is the same number. Trust + // it over projectPath because the event's project path (= shell + // $PWD when notify.sh ran) can disagree with the session's project + // path (= lsof cwd of the claude process). Zed sessions exhibit + // this — claude cwd stays at the workspace root while the user's + // shell can cd into a subdirectory. + if let eventPID = event.agentPID, eventPID > 0 { + return eventPID == session.pid + } + // Without a PID, fall back to projectPath + terminal-tab + // disambiguator. Same path required; tabId narrows when both have it. + guard event.projectPath == session.projectPath else { return false } + let eventTab = (event.sessionID?.isEmpty == false ? event.sessionID : event.ipcHook) + if let sessionTab = session.tabId, let eventTab, + !sessionTab.isEmpty, !eventTab.isEmpty { + return sessionTab == eventTab + } + return true +} + private struct SessionRow: View { let session: Session @@ -168,6 +174,7 @@ private struct SessionRow: View { let activeNudgeCount: Int let lastNudgeAt: Date? let transcriptStats: TranscriptStats? + let isMuted: Bool let onCommit: () -> Void let onCancel: () -> Void @@ -251,6 +258,12 @@ private struct SessionRow: View { .truncationMode(.tail) } agentTag + if isMuted { + Image(systemName: "speaker.slash.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .help("Muted — press M to unmute") + } Spacer(minLength: 8) Text(statusLabel) .font(.caption2) diff --git a/panel/Settings.swift b/panel/Settings.swift index 0315d5e..8ea18e6 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -56,6 +56,7 @@ struct SettingsView: View { row(.widget, label: "Widget", kind: .toggle, value: nav.compactMode ? "On" : "Off") row(.widgetCorner, label: "Widget corner", kind: .cycle, value: nav.compactCorner.label, enabled: nav.compactMode) 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) }