From 62751a429e03cefc353af2a27d4ba329f7516005 Mon Sep 17 00:00:00 2001 From: Lance Hambly Date: Tue, 24 Feb 2026 12:25:48 +0000 Subject: [PATCH 01/10] Add global keyboard shortcuts for approve/deny permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds macOS-level keyboard shortcuts (⌘⇧Y to approve, ⌘⇧N to deny) that work system-wide without needing to click the notch UI. Includes configurable shortcut recording in settings, visual flash feedback on the notch, and audio confirmation sounds. New files: - KeyboardShortcut.swift: KeyCombo model with Carbon key code mapping - KeyboardShortcutHandler.swift: Global/local NSEvent monitors - ShortcutFeedback.swift: Visual/audio feedback on shortcut activation - ShortcutSettingsRow.swift: Settings UI with shortcut recorder Co-Authored-By: Claude Opus 4.6 --- ClaudeIsland/Core/Settings.swift | 51 ++++ ClaudeIsland/Models/KeyboardShortcut.swift | 116 ++++++++ .../Shortcuts/KeyboardShortcutHandler.swift | 105 +++++++ .../Services/Shortcuts/ShortcutFeedback.swift | 44 +++ .../UI/Components/ShortcutSettingsRow.swift | 267 ++++++++++++++++++ ClaudeIsland/UI/Views/NotchMenuView.swift | 1 + ClaudeIsland/UI/Views/NotchView.swift | 18 ++ 7 files changed, 602 insertions(+) create mode 100644 ClaudeIsland/Models/KeyboardShortcut.swift create mode 100644 ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift create mode 100644 ClaudeIsland/Services/Shortcuts/ShortcutFeedback.swift create mode 100644 ClaudeIsland/UI/Components/ShortcutSettingsRow.swift diff --git a/ClaudeIsland/Core/Settings.swift b/ClaudeIsland/Core/Settings.swift index bc068289..a7353ad7 100644 --- a/ClaudeIsland/Core/Settings.swift +++ b/ClaudeIsland/Core/Settings.swift @@ -38,6 +38,9 @@ enum AppSettings { private enum Keys { static let notificationSound = "notificationSound" + static let approveShortcut = "approveShortcut" + static let denyShortcut = "denyShortcut" + static let shortcutsEnabled = "shortcutsEnabled" } // MARK: - Notification Sound @@ -55,4 +58,52 @@ enum AppSettings { defaults.set(newValue.rawValue, forKey: Keys.notificationSound) } } + + // MARK: - Keyboard Shortcuts + + /// Whether global keyboard shortcuts are enabled + static var shortcutsEnabled: Bool { + get { + // Default to true if never set + if defaults.object(forKey: Keys.shortcutsEnabled) == nil { + return true + } + return defaults.bool(forKey: Keys.shortcutsEnabled) + } + set { + defaults.set(newValue, forKey: Keys.shortcutsEnabled) + } + } + + /// Keyboard shortcut for approving permissions (default: ⌘⇧Y) + static var approveShortcut: KeyCombo { + get { + guard let data = defaults.data(forKey: Keys.approveShortcut), + let combo = try? JSONDecoder().decode(KeyCombo.self, from: data) else { + return .defaultApprove + } + return combo + } + set { + if let data = try? JSONEncoder().encode(newValue) { + defaults.set(data, forKey: Keys.approveShortcut) + } + } + } + + /// Keyboard shortcut for denying permissions (default: ⌘⇧N) + static var denyShortcut: KeyCombo { + get { + guard let data = defaults.data(forKey: Keys.denyShortcut), + let combo = try? JSONDecoder().decode(KeyCombo.self, from: data) else { + return .defaultDeny + } + return combo + } + set { + if let data = try? JSONEncoder().encode(newValue) { + defaults.set(data, forKey: Keys.denyShortcut) + } + } + } } diff --git a/ClaudeIsland/Models/KeyboardShortcut.swift b/ClaudeIsland/Models/KeyboardShortcut.swift new file mode 100644 index 00000000..6eb9f702 --- /dev/null +++ b/ClaudeIsland/Models/KeyboardShortcut.swift @@ -0,0 +1,116 @@ +// +// KeyboardShortcut.swift +// ClaudeIsland +// +// Model for a keyboard shortcut (modifier flags + key code) +// + +import AppKit +import Carbon.HIToolbox + +struct KeyCombo: Equatable, Codable { + let keyCode: UInt16 + let modifierFlags: UInt // Raw value of NSEvent.ModifierFlags + + var modifiers: NSEvent.ModifierFlags { + NSEvent.ModifierFlags(rawValue: modifierFlags) + } + + init(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) { + self.keyCode = keyCode + self.modifierFlags = modifiers.intersection(.deviceIndependentFlagsMask).rawValue + } + + /// Human-readable display string (e.g. "⌘⇧Y") + var displayString: String { + var parts: [String] = [] + let mods = modifiers + + if mods.contains(.control) { parts.append("⌃") } + if mods.contains(.option) { parts.append("⌥") } + if mods.contains(.shift) { parts.append("⇧") } + if mods.contains(.command) { parts.append("⌘") } + + parts.append(keyCodeToString(keyCode)) + return parts.joined() + } + + // MARK: - Defaults + + /// Default: ⌘⇧Y + static let defaultApprove = KeyCombo( + keyCode: UInt16(kVK_ANSI_Y), + modifiers: [.command, .shift] + ) + + /// Default: ⌘⇧N + static let defaultDeny = KeyCombo( + keyCode: UInt16(kVK_ANSI_N), + modifiers: [.command, .shift] + ) +} + +// MARK: - Key Code to String + +private func keyCodeToString(_ keyCode: UInt16) -> String { + switch Int(keyCode) { + case kVK_ANSI_A: return "A" + case kVK_ANSI_B: return "B" + case kVK_ANSI_C: return "C" + case kVK_ANSI_D: return "D" + case kVK_ANSI_E: return "E" + case kVK_ANSI_F: return "F" + case kVK_ANSI_G: return "G" + case kVK_ANSI_H: return "H" + case kVK_ANSI_I: return "I" + case kVK_ANSI_J: return "J" + case kVK_ANSI_K: return "K" + case kVK_ANSI_L: return "L" + case kVK_ANSI_M: return "M" + case kVK_ANSI_N: return "N" + case kVK_ANSI_O: return "O" + case kVK_ANSI_P: return "P" + case kVK_ANSI_Q: return "Q" + case kVK_ANSI_R: return "R" + case kVK_ANSI_S: return "S" + case kVK_ANSI_T: return "T" + case kVK_ANSI_U: return "U" + case kVK_ANSI_V: return "V" + case kVK_ANSI_W: return "W" + case kVK_ANSI_X: return "X" + case kVK_ANSI_Y: return "Y" + case kVK_ANSI_Z: return "Z" + case kVK_ANSI_0: return "0" + case kVK_ANSI_1: return "1" + case kVK_ANSI_2: return "2" + case kVK_ANSI_3: return "3" + case kVK_ANSI_4: return "4" + case kVK_ANSI_5: return "5" + case kVK_ANSI_6: return "6" + case kVK_ANSI_7: return "7" + case kVK_ANSI_8: return "8" + case kVK_ANSI_9: return "9" + case kVK_Space: return "Space" + case kVK_Return: return "↩" + case kVK_Escape: return "⎋" + case kVK_Delete: return "⌫" + case kVK_Tab: return "⇥" + case kVK_UpArrow: return "↑" + case kVK_DownArrow: return "↓" + case kVK_LeftArrow: return "←" + case kVK_RightArrow: return "→" + case kVK_F1: return "F1" + case kVK_F2: return "F2" + case kVK_F3: return "F3" + case kVK_F4: return "F4" + case kVK_F5: return "F5" + case kVK_F6: return "F6" + case kVK_F7: return "F7" + case kVK_F8: return "F8" + case kVK_F9: return "F9" + case kVK_F10: return "F10" + case kVK_F11: return "F11" + case kVK_F12: return "F12" + default: return "?" + } +} diff --git a/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift b/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift new file mode 100644 index 00000000..bbfce50d --- /dev/null +++ b/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift @@ -0,0 +1,105 @@ +// +// KeyboardShortcutHandler.swift +// ClaudeIsland +// +// Global keyboard shortcuts for approve/deny permission requests +// + +import AppKit +import Carbon.HIToolbox + +@MainActor +class KeyboardShortcutHandler { + static let shared = KeyboardShortcutHandler() + + private var globalMonitor: Any? + private var localMonitor: Any? + private var sessionMonitor: ClaudeSessionMonitor? + + private init() {} + + // MARK: - Public API + + func start(sessionMonitor: ClaudeSessionMonitor) { + self.sessionMonitor = sessionMonitor + startMonitoring() + } + + func stop() { + stopMonitoring() + sessionMonitor = nil + } + + // MARK: - Monitoring + + private func startMonitoring() { + stopMonitoring() + + // Global monitor: fires when app is NOT focused + globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in + Task { @MainActor in + self?.handleKeyDown(event) + } + } + + // Local monitor: fires when app IS focused + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + Task { @MainActor in + self?.handleKeyDown(event) + } + return event + } + } + + private func stopMonitoring() { + if let monitor = globalMonitor { + NSEvent.removeMonitor(monitor) + globalMonitor = nil + } + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } + } + + // MARK: - Key Handling + + private func handleKeyDown(_ event: NSEvent) { + guard AppSettings.shortcutsEnabled else { return } + + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let keyCode = event.keyCode + + let approveShortcut = AppSettings.approveShortcut + let denyShortcut = AppSettings.denyShortcut + + if modifiers == approveShortcut.modifiers && keyCode == approveShortcut.keyCode { + handleApprove() + } else if modifiers == denyShortcut.modifiers && keyCode == denyShortcut.keyCode { + handleDeny() + } + } + + private func handleApprove() { + guard let sessionMonitor else { return } + + // Find the most recent session waiting for approval + guard let pendingSession = sessionMonitor.pendingInstances.first(where: { $0.phase.isWaitingForApproval }) else { + return + } + + sessionMonitor.approvePermission(sessionId: pendingSession.sessionId) + ShortcutFeedback.flash(.approve) + } + + private func handleDeny() { + guard let sessionMonitor else { return } + + guard let pendingSession = sessionMonitor.pendingInstances.first(where: { $0.phase.isWaitingForApproval }) else { + return + } + + sessionMonitor.denyPermission(sessionId: pendingSession.sessionId, reason: "Denied via keyboard shortcut") + ShortcutFeedback.flash(.deny) + } +} diff --git a/ClaudeIsland/Services/Shortcuts/ShortcutFeedback.swift b/ClaudeIsland/Services/Shortcuts/ShortcutFeedback.swift new file mode 100644 index 00000000..91d1e683 --- /dev/null +++ b/ClaudeIsland/Services/Shortcuts/ShortcutFeedback.swift @@ -0,0 +1,44 @@ +// +// ShortcutFeedback.swift +// ClaudeIsland +// +// Visual and audio feedback when a keyboard shortcut fires +// + +import AppKit +import Combine + +enum ShortcutAction { + case approve + case deny +} + +@MainActor +class ShortcutFeedback: ObservableObject { + static let shared = ShortcutFeedback() + + @Published var activeFlash: ShortcutAction? + + private init() {} + + static func flash(_ action: ShortcutAction) { + shared.activeFlash = action + + // Play a brief confirmation sound + let soundName: String? = { + switch action { + case .approve: return "Tink" + case .deny: return "Basso" + } + }() + + if let name = soundName { + NSSound(named: name)?.play() + } + + // Clear flash after brief display + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + shared.activeFlash = nil + } + } +} diff --git a/ClaudeIsland/UI/Components/ShortcutSettingsRow.swift b/ClaudeIsland/UI/Components/ShortcutSettingsRow.swift new file mode 100644 index 00000000..581c0c6c --- /dev/null +++ b/ClaudeIsland/UI/Components/ShortcutSettingsRow.swift @@ -0,0 +1,267 @@ +// +// ShortcutSettingsRow.swift +// ClaudeIsland +// +// Keyboard shortcut configuration row for settings menu +// + +import AppKit +import SwiftUI + +struct ShortcutSettingsRow: View { + @State private var isExpanded = false + @State private var isHovered = false + @State private var shortcutsEnabled = AppSettings.shortcutsEnabled + @State private var approveCombo = AppSettings.approveShortcut + @State private var denyCombo = AppSettings.denyShortcut + @State private var recording: ShortcutTarget? + + private enum ShortcutTarget { + case approve + case deny + } + + var body: some View { + VStack(spacing: 0) { + // Main row + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 10) { + Image(systemName: "keyboard") + .font(.system(size: 12)) + .foregroundColor(textColor) + .frame(width: 16) + + Text("Keyboard Shortcuts") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(textColor) + + Spacer() + + Text(shortcutsEnabled ? "On" : "Off") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.4)) + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.4)) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isHovered ? Color.white.opacity(0.08) : Color.clear) + ) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + + // Expanded settings + if isExpanded { + VStack(spacing: 6) { + // Enable/disable toggle + Button { + shortcutsEnabled.toggle() + AppSettings.shortcutsEnabled = shortcutsEnabled + if shortcutsEnabled { + // Re-read current combos + approveCombo = AppSettings.approveShortcut + denyCombo = AppSettings.denyShortcut + } + } label: { + HStack(spacing: 8) { + Circle() + .fill(shortcutsEnabled ? TerminalColors.green : Color.white.opacity(0.2)) + .frame(width: 6, height: 6) + + Text("Enabled") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + + if shortcutsEnabled { + // Approve shortcut + ShortcutRecorderRow( + label: "Approve", + combo: approveCombo, + isRecording: recording == .approve + ) { + recording = recording == .approve ? nil : .approve + } + + // Deny shortcut + ShortcutRecorderRow( + label: "Deny", + combo: denyCombo, + isRecording: recording == .deny + ) { + recording = recording == .deny ? nil : .deny + } + + // Reset to defaults + Button { + approveCombo = .defaultApprove + denyCombo = .defaultDeny + AppSettings.approveShortcut = .defaultApprove + AppSettings.denyShortcut = .defaultDeny + recording = nil + } label: { + Text("Reset to Defaults") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.4)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } + } + .padding(.leading, 28) + .padding(.top, 4) + } + } + .background( + // Invisible key event catcher when recording + ShortcutRecorderKeyView(isActive: recording != nil) { combo in + if let target = recording { + switch target { + case .approve: + approveCombo = combo + AppSettings.approveShortcut = combo + case .deny: + denyCombo = combo + AppSettings.denyShortcut = combo + } + recording = nil + } + } + .frame(width: 0, height: 0) + ) + .onAppear { + shortcutsEnabled = AppSettings.shortcutsEnabled + approveCombo = AppSettings.approveShortcut + denyCombo = AppSettings.denyShortcut + } + } + + private var textColor: Color { + .white.opacity(isHovered ? 1.0 : 0.7) + } +} + +// MARK: - Shortcut Recorder Row + +private struct ShortcutRecorderRow: View { + let label: String + let combo: KeyCombo + let isRecording: Bool + let onTap: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: onTap) { + HStack(spacing: 8) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + + Spacer() + + Text(isRecording ? "Press keys..." : combo.displayString) + .font(.system(size: 11, weight: isRecording ? .regular : .semibold, design: .monospaced)) + .foregroundColor(isRecording ? .white.opacity(0.5) : .white.opacity(0.8)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isRecording ? Color.white.opacity(0.15) : Color.white.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(isRecording ? TerminalColors.blue : Color.clear, lineWidth: 1) + ) + ) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? Color.white.opacity(0.06) : Color.clear) + ) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + } +} + +// MARK: - Key Event Catcher (NSView-based for reliable key capture) + +private struct ShortcutRecorderKeyView: NSViewRepresentable { + let isActive: Bool + let onRecord: (KeyCombo) -> Void + + func makeNSView(context: Context) -> ShortcutKeyCapture { + let view = ShortcutKeyCapture() + view.onRecord = onRecord + return view + } + + func updateNSView(_ nsView: ShortcutKeyCapture, context: Context) { + nsView.isActive = isActive + nsView.onRecord = onRecord + if isActive { + // Use local monitor to capture keys in our app + nsView.startLocalMonitor() + } else { + nsView.stopLocalMonitor() + } + } +} + +class ShortcutKeyCapture: NSView { + var isActive = false + var onRecord: ((KeyCombo) -> Void)? + private var localMonitor: Any? + + func startLocalMonitor() { + guard localMonitor == nil else { return } + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, self.isActive else { return event } + + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + + // Require at least one modifier key (command, option, control, or shift) + guard !modifiers.intersection([.command, .option, .control, .shift]).isEmpty else { + return event + } + + // Escape cancels recording + if event.keyCode == 53 { // kVK_Escape + return nil + } + + let combo = KeyCombo(keyCode: event.keyCode, modifiers: modifiers) + self.onRecord?(combo) + return nil // Consume the event + } + } + + func stopLocalMonitor() { + if let monitor = localMonitor { + NSEvent.removeMonitor(monitor) + localMonitor = nil + } + } + + deinit { + stopLocalMonitor() + } +} diff --git a/ClaudeIsland/UI/Views/NotchMenuView.swift b/ClaudeIsland/UI/Views/NotchMenuView.swift index 14d7f868..184a6a8a 100644 --- a/ClaudeIsland/UI/Views/NotchMenuView.swift +++ b/ClaudeIsland/UI/Views/NotchMenuView.swift @@ -38,6 +38,7 @@ struct NotchMenuView: View { // Appearance settings ScreenPickerRow(screenSelector: screenSelector) SoundPickerRow(soundSelector: soundSelector) + ShortcutSettingsRow() Divider() .background(Color.white.opacity(0.08)) diff --git a/ClaudeIsland/UI/Views/NotchView.swift b/ClaudeIsland/UI/Views/NotchView.swift index 6884ce75..fbcaeed8 100644 --- a/ClaudeIsland/UI/Views/NotchView.swift +++ b/ClaudeIsland/UI/Views/NotchView.swift @@ -19,6 +19,7 @@ struct NotchView: View { @ObservedObject var viewModel: NotchViewModel @StateObject private var sessionMonitor = ClaudeSessionMonitor() @StateObject private var activityCoordinator = NotchActivityCoordinator.shared + @StateObject private var shortcutFeedback = ShortcutFeedback.shared @ObservedObject private var updateManager = UpdateManager.shared @State private var previousPendingIds: Set = [] @State private var previousWaitingForInputIds: Set = [] @@ -185,11 +186,28 @@ struct NotchView: View { } } } + .overlay(alignment: .top) { + // Flash overlay when keyboard shortcut fires + if let flash = shortcutFeedback.activeFlash { + RoundedRectangle(cornerRadius: bottomCornerRadius) + .fill(flash == .approve ? TerminalColors.green.opacity(0.3) : Color.red.opacity(0.3)) + .frame( + width: closedContentWidth + 20, + height: closedNotchSize.height + 4 + ) + .transition(.opacity) + .animation(.easeOut(duration: 0.3), value: shortcutFeedback.activeFlash == nil) + .allowsHitTesting(false) + } + } .opacity(isVisible ? 1 : 0) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .preferredColorScheme(.dark) .onAppear { sessionMonitor.startMonitoring() + if AppSettings.shortcutsEnabled { + KeyboardShortcutHandler.shared.start(sessionMonitor: sessionMonitor) + } // On non-notched devices, keep visible so users have a target to interact with if !viewModel.hasPhysicalNotch { isVisible = true From ed7020580e8861a24b1018c58b79fb005579560e Mon Sep 17 00:00:00 2001 From: Lance Hambly Date: Tue, 24 Feb 2026 12:41:00 +0000 Subject: [PATCH 02/10] Fix global hotkeys and notch expansion for permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1. Replace NSEvent global key monitor with Carbon RegisterEventHotKey for true system-wide hotkeys that work regardless of focused app (NSEvent global monitors cannot reliably capture keyDown events) 2. Always expand notch for pending permissions, even when a terminal is visible — users need to see what tool is requesting approval Also wire reloadShortcuts() to settings UI so hotkeys re-register immediately when changed. Co-Authored-By: Claude Opus 4.6 --- .../Shortcuts/KeyboardShortcutHandler.swift | 185 +++++++++++++----- .../UI/Components/ShortcutSettingsRow.swift | 4 +- ClaudeIsland/UI/Views/NotchView.swift | 10 +- 3 files changed, 141 insertions(+), 58 deletions(-) diff --git a/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift b/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift index bbfce50d..a467da49 100644 --- a/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift +++ b/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift @@ -2,18 +2,59 @@ // KeyboardShortcutHandler.swift // ClaudeIsland // -// Global keyboard shortcuts for approve/deny permission requests +// Global keyboard shortcuts for approve/deny permission requests. +// Uses Carbon RegisterEventHotKey for true system-wide hotkeys +// that work regardless of which app is focused. // import AppKit import Carbon.HIToolbox +// Unique IDs for our two hotkeys +private let kApproveHotKeyID: UInt32 = 1 +private let kDenyHotKeyID: UInt32 = 2 + +// Global C callback — Carbon hotkey events land here +private func hotKeyHandler( + nextHandler: EventHandlerCallRef?, + event: EventRef?, + userData: UnsafeMutableRawPointer? +) -> OSStatus { + guard let event else { return OSStatus(eventNotHandledErr) } + + var hotKeyID = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + guard status == noErr else { return status } + + Task { @MainActor in + switch hotKeyID.id { + case kApproveHotKeyID: + KeyboardShortcutHandler.shared.handleApprove() + case kDenyHotKeyID: + KeyboardShortcutHandler.shared.handleDeny() + default: + break + } + } + + return noErr +} + @MainActor class KeyboardShortcutHandler { static let shared = KeyboardShortcutHandler() - private var globalMonitor: Any? - private var localMonitor: Any? + private var approveHotKeyRef: EventHotKeyRef? + private var denyHotKeyRef: EventHotKeyRef? + private var eventHandler: EventHandlerRef? private var sessionMonitor: ClaudeSessionMonitor? private init() {} @@ -22,68 +63,89 @@ class KeyboardShortcutHandler { func start(sessionMonitor: ClaudeSessionMonitor) { self.sessionMonitor = sessionMonitor - startMonitoring() + if AppSettings.shortcutsEnabled { + registerHotKeys() + } } func stop() { - stopMonitoring() + unregisterHotKeys() sessionMonitor = nil } - // MARK: - Monitoring - - private func startMonitoring() { - stopMonitoring() - - // Global monitor: fires when app is NOT focused - globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in - Task { @MainActor in - self?.handleKeyDown(event) - } + /// Re-register hotkeys after settings change + func reloadShortcuts() { + unregisterHotKeys() + if AppSettings.shortcutsEnabled { + registerHotKeys() } + } - // Local monitor: fires when app IS focused - localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - Task { @MainActor in - self?.handleKeyDown(event) - } - return event - } + // MARK: - Carbon Hotkey Registration + + private func registerHotKeys() { + unregisterHotKeys() + + // Install the event handler (once) + var eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + + InstallEventHandler( + GetApplicationEventTarget(), + hotKeyHandler, + 1, + &eventType, + nil, + &eventHandler + ) + + // Register approve hotkey + let approveCombo = AppSettings.approveShortcut + var approveID = EventHotKeyID(signature: fourCharCode("CISL"), id: kApproveHotKeyID) + RegisterEventHotKey( + UInt32(approveCombo.keyCode), + carbonModifiers(from: approveCombo.modifiers), + approveID, + GetApplicationEventTarget(), + 0, + &approveHotKeyRef + ) + + // Register deny hotkey + let denyCombo = AppSettings.denyShortcut + var denyID = EventHotKeyID(signature: fourCharCode("CISL"), id: kDenyHotKeyID) + RegisterEventHotKey( + UInt32(denyCombo.keyCode), + carbonModifiers(from: denyCombo.modifiers), + denyID, + GetApplicationEventTarget(), + 0, + &denyHotKeyRef + ) } - private func stopMonitoring() { - if let monitor = globalMonitor { - NSEvent.removeMonitor(monitor) - globalMonitor = nil + private func unregisterHotKeys() { + if let ref = approveHotKeyRef { + UnregisterEventHotKey(ref) + approveHotKeyRef = nil } - if let monitor = localMonitor { - NSEvent.removeMonitor(monitor) - localMonitor = nil + if let ref = denyHotKeyRef { + UnregisterEventHotKey(ref) + denyHotKeyRef = nil } - } - - // MARK: - Key Handling - - private func handleKeyDown(_ event: NSEvent) { - guard AppSettings.shortcutsEnabled else { return } - - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let keyCode = event.keyCode - - let approveShortcut = AppSettings.approveShortcut - let denyShortcut = AppSettings.denyShortcut - - if modifiers == approveShortcut.modifiers && keyCode == approveShortcut.keyCode { - handleApprove() - } else if modifiers == denyShortcut.modifiers && keyCode == denyShortcut.keyCode { - handleDeny() + if let handler = eventHandler { + RemoveEventHandler(handler) + eventHandler = nil } } - private func handleApprove() { - guard let sessionMonitor else { return } + // MARK: - Actions + + func handleApprove() { + guard AppSettings.shortcutsEnabled, let sessionMonitor else { return } - // Find the most recent session waiting for approval guard let pendingSession = sessionMonitor.pendingInstances.first(where: { $0.phase.isWaitingForApproval }) else { return } @@ -92,8 +154,8 @@ class KeyboardShortcutHandler { ShortcutFeedback.flash(.approve) } - private func handleDeny() { - guard let sessionMonitor else { return } + func handleDeny() { + guard AppSettings.shortcutsEnabled, let sessionMonitor else { return } guard let pendingSession = sessionMonitor.pendingInstances.first(where: { $0.phase.isWaitingForApproval }) else { return @@ -102,4 +164,25 @@ class KeyboardShortcutHandler { sessionMonitor.denyPermission(sessionId: pendingSession.sessionId, reason: "Denied via keyboard shortcut") ShortcutFeedback.flash(.deny) } + + // MARK: - Helpers + + /// Convert NSEvent.ModifierFlags to Carbon modifier mask + private func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 { + var carbon: UInt32 = 0 + if flags.contains(.command) { carbon |= UInt32(cmdKey) } + if flags.contains(.option) { carbon |= UInt32(optionKey) } + if flags.contains(.control) { carbon |= UInt32(controlKey) } + if flags.contains(.shift) { carbon |= UInt32(shiftKey) } + return carbon + } +} + +/// Convert a 4-character string to OSType (FourCharCode) +private func fourCharCode(_ string: String) -> OSType { + var result: OSType = 0 + for char in string.utf8.prefix(4) { + result = result << 8 + OSType(char) + } + return result } diff --git a/ClaudeIsland/UI/Components/ShortcutSettingsRow.swift b/ClaudeIsland/UI/Components/ShortcutSettingsRow.swift index 581c0c6c..82af392d 100644 --- a/ClaudeIsland/UI/Components/ShortcutSettingsRow.swift +++ b/ClaudeIsland/UI/Components/ShortcutSettingsRow.swift @@ -66,8 +66,8 @@ struct ShortcutSettingsRow: View { Button { shortcutsEnabled.toggle() AppSettings.shortcutsEnabled = shortcutsEnabled + KeyboardShortcutHandler.shared.reloadShortcuts() if shortcutsEnabled { - // Re-read current combos approveCombo = AppSettings.approveShortcut denyCombo = AppSettings.denyShortcut } @@ -114,6 +114,7 @@ struct ShortcutSettingsRow: View { AppSettings.approveShortcut = .defaultApprove AppSettings.denyShortcut = .defaultDeny recording = nil + KeyboardShortcutHandler.shared.reloadShortcuts() } label: { Text("Reset to Defaults") .font(.system(size: 11)) @@ -141,6 +142,7 @@ struct ShortcutSettingsRow: View { AppSettings.denyShortcut = combo } recording = nil + KeyboardShortcutHandler.shared.reloadShortcuts() } } .frame(width: 0, height: 0) diff --git a/ClaudeIsland/UI/Views/NotchView.swift b/ClaudeIsland/UI/Views/NotchView.swift index fbcaeed8..c38f1618 100644 --- a/ClaudeIsland/UI/Views/NotchView.swift +++ b/ClaudeIsland/UI/Views/NotchView.swift @@ -205,9 +205,7 @@ struct NotchView: View { .preferredColorScheme(.dark) .onAppear { sessionMonitor.startMonitoring() - if AppSettings.shortcutsEnabled { - KeyboardShortcutHandler.shared.start(sessionMonitor: sessionMonitor) - } + KeyboardShortcutHandler.shared.start(sessionMonitor: sessionMonitor) // On non-notched devices, keep visible so users have a target to interact with if !viewModel.hasPhysicalNotch { isVisible = true @@ -437,9 +435,9 @@ struct NotchView: View { let currentIds = Set(sessions.map { $0.stableId }) let newPendingIds = currentIds.subtracting(previousPendingIds) - if !newPendingIds.isEmpty && - viewModel.status == .closed && - !TerminalVisibilityDetector.isTerminalVisibleOnCurrentSpace() { + if !newPendingIds.isEmpty && viewModel.status == .closed { + // Always expand for permission requests — the user needs to see + // what tool is requesting approval, even if a terminal is visible viewModel.notchOpen(reason: .notification) } From f8d80cf1acb1694200e83d7eac42bc4b2d3dfbf5 Mon Sep 17 00:00:00 2001 From: Lance Hambly Date: Tue, 24 Feb 2026 13:35:03 +0000 Subject: [PATCH 03/10] Dynamic notch sizing and auto-retract after shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Instance list height now scales to content — ~52pt per row instead of a fixed 320pt, clamped between 120-400pt 2. Notch auto-closes 0.8s after all pending permissions are resolved (only when opened via notification, not manual click) Co-Authored-By: Claude Opus 4.6 --- ClaudeIsland/Core/NotchViewModel.swift | 9 ++++++++- ClaudeIsland/UI/Views/NotchView.swift | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/ClaudeIsland/Core/NotchViewModel.swift b/ClaudeIsland/Core/NotchViewModel.swift index a7406daa..28ada587 100644 --- a/ClaudeIsland/Core/NotchViewModel.swift +++ b/ClaudeIsland/Core/NotchViewModel.swift @@ -61,6 +61,9 @@ class NotchViewModel: ObservableObject { var screenRect: CGRect { geometry.screenRect } var windowHeight: CGFloat { geometry.windowHeight } + /// Number of instances, set externally by NotchView to drive dynamic sizing + @Published var instanceCount: Int = 0 + /// Dynamic opened size based on content type var openedSize: CGSize { switch contentType { @@ -77,9 +80,13 @@ class NotchViewModel: ObservableObject { height: 420 + screenSelector.expandedPickerHeight + soundSelector.expandedPickerHeight ) case .instances: + // Dynamic height: ~52pt per row + 60pt for header/padding, clamped + let rowHeight: CGFloat = 52 + let chrome: CGFloat = 60 + let contentHeight = CGFloat(max(instanceCount, 1)) * rowHeight + chrome return CGSize( width: min(screenRect.width * 0.4, 480), - height: 320 + height: min(max(contentHeight, 120), 400) ) } } diff --git a/ClaudeIsland/UI/Views/NotchView.swift b/ClaudeIsland/UI/Views/NotchView.swift index c38f1618..d4e59e4f 100644 --- a/ClaudeIsland/UI/Views/NotchView.swift +++ b/ClaudeIsland/UI/Views/NotchView.swift @@ -218,6 +218,7 @@ struct NotchView: View { handlePendingSessionsChange(sessions) } .onChange(of: sessionMonitor.instances) { _, instances in + viewModel.instanceCount = instances.count handleProcessingChange() handleWaitingForInputChange(instances) } @@ -441,6 +442,18 @@ struct NotchView: View { viewModel.notchOpen(reason: .notification) } + // Auto-close when all pending permissions are resolved + // (e.g. after keyboard shortcut approval/denial) + if currentIds.isEmpty && !previousPendingIds.isEmpty && + viewModel.status == .opened && viewModel.openReason == .notification { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [self] in + // Only close if still opened via notification and no new pending items + if viewModel.status == .opened && viewModel.openReason == .notification && !hasPendingPermission { + viewModel.notchClose() + } + } + } + previousPendingIds = currentIds } From 4177725440bb929d21728671f548da6fa214dd44 Mon Sep 17 00:00:00 2001 From: Lance Hambly Date: Tue, 24 Feb 2026 13:40:05 +0000 Subject: [PATCH 04/10] Keep island open while permissions are pending When there are unanswered permission requests and the user's terminal is not visible, clicking outside the island no longer dismisses it. The click still passes through to the underlying app, but the island stays visible so the user doesn't lose sight of pending approvals. If the terminal IS visible (user can see the CLI prompt), outside clicks dismiss as before. Co-Authored-By: Claude Opus 4.6 --- ClaudeIsland/Core/NotchViewModel.swift | 13 ++++++++++--- ClaudeIsland/UI/Views/NotchView.swift | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ClaudeIsland/Core/NotchViewModel.swift b/ClaudeIsland/Core/NotchViewModel.swift index 28ada587..9b87aae9 100644 --- a/ClaudeIsland/Core/NotchViewModel.swift +++ b/ClaudeIsland/Core/NotchViewModel.swift @@ -45,6 +45,8 @@ class NotchViewModel: ObservableObject { @Published var openReason: NotchOpenReason = .unknown @Published var contentType: NotchContentType = .instances @Published var isHovering: Bool = false + /// Set by NotchView — prevents auto-dismiss when permissions need attention + @Published var hasPendingPermissions: Bool = false // MARK: - Dependencies @@ -185,9 +187,14 @@ class NotchViewModel: ObservableObject { switch status { case .opened: if geometry.isPointOutsidePanel(location, size: openedSize) { - notchClose() - // Re-post the click so it reaches the window/app behind us - repostClickAt(location) + // Stay open if there are pending permissions and the terminal isn't visible + if hasPendingPermissions && !TerminalVisibilityDetector.isTerminalVisibleOnCurrentSpace() { + // Re-post the click but keep the island open + repostClickAt(location) + } else { + notchClose() + repostClickAt(location) + } } else if geometry.notchScreenRect.contains(location) { // Clicking notch while opened - only close if NOT in chat mode if !isInChatMode { diff --git a/ClaudeIsland/UI/Views/NotchView.swift b/ClaudeIsland/UI/Views/NotchView.swift index d4e59e4f..ae1c7429 100644 --- a/ClaudeIsland/UI/Views/NotchView.swift +++ b/ClaudeIsland/UI/Views/NotchView.swift @@ -215,6 +215,7 @@ struct NotchView: View { handleStatusChange(from: oldStatus, to: newStatus) } .onChange(of: sessionMonitor.pendingInstances) { _, sessions in + viewModel.hasPendingPermissions = sessions.contains { $0.phase.isWaitingForApproval } handlePendingSessionsChange(sessions) } .onChange(of: sessionMonitor.instances) { _, instances in From f791d00ff4bf16c2cfdbcf9ee9092b0f29e88521 Mon Sep 17 00:00:00 2001 From: Lance Hambly Date: Tue, 24 Feb 2026 13:42:24 +0000 Subject: [PATCH 05/10] Auto-retract island after completion when no pending permissions When all permissions are resolved and the island is showing the instances list: retract after 0.8s (keyboard shortcut / notification) or 5s (manually opened). Click-away already works for non-permission states. Does not auto-close if user is in chat or menu view. Co-Authored-By: Claude Opus 4.6 --- ClaudeIsland/UI/Views/NotchView.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ClaudeIsland/UI/Views/NotchView.swift b/ClaudeIsland/UI/Views/NotchView.swift index ae1c7429..a7c0c4dc 100644 --- a/ClaudeIsland/UI/Views/NotchView.swift +++ b/ClaudeIsland/UI/Views/NotchView.swift @@ -444,13 +444,14 @@ struct NotchView: View { } // Auto-close when all pending permissions are resolved - // (e.g. after keyboard shortcut approval/denial) - if currentIds.isEmpty && !previousPendingIds.isEmpty && - viewModel.status == .opened && viewModel.openReason == .notification { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [self] in - // Only close if still opened via notification and no new pending items - if viewModel.status == .opened && viewModel.openReason == .notification && !hasPendingPermission { - viewModel.notchClose() + if currentIds.isEmpty && !previousPendingIds.isEmpty && viewModel.status == .opened { + // Quick retract for keyboard shortcut approvals (notification-opened) + let delay: TimeInterval = viewModel.openReason == .notification ? 0.8 : 5.0 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [self] in + if viewModel.status == .opened && !hasPendingPermission { + if case .instances = viewModel.contentType { + viewModel.notchClose() + } } } } From 928cb4795b3287d91cdc19ac63c6069dbf2a3104 Mon Sep 17 00:00:00 2001 From: Lance Hambly Date: Tue, 24 Feb 2026 13:44:11 +0000 Subject: [PATCH 06/10] Fix 5s auto-retract for completion states The previous auto-retract only triggered when pending permissions went from non-empty to empty. Completions (waitingForInput) that arrive while the island is open now also schedule a 5s retract if there are no outstanding permission requests. Co-Authored-By: Claude Opus 4.6 --- ClaudeIsland/UI/Views/NotchView.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ClaudeIsland/UI/Views/NotchView.swift b/ClaudeIsland/UI/Views/NotchView.swift index a7c0c4dc..bd93f2ec 100644 --- a/ClaudeIsland/UI/Views/NotchView.swift +++ b/ClaudeIsland/UI/Views/NotchView.swift @@ -498,12 +498,23 @@ struct NotchView: View { // Trigger bounce animation to get user's attention DispatchQueue.main.async { isBouncing = true - // Bounce back after a short delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { isBouncing = false } } + // Auto-retract after 5s if the island is open showing a completion + // (no pending permissions — just a "done" state) + if viewModel.status == .opened && !hasPendingPermission { + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [self] in + if viewModel.status == .opened && !hasPendingPermission { + if case .instances = viewModel.contentType { + viewModel.notchClose() + } + } + } + } + // Schedule hiding the checkmark after 30 seconds DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [self] in // Trigger a UI update to re-evaluate hasWaitingForInput From 18a195c7817d3e340dd646fd234fcc969a6dc6c7 Mon Sep 17 00:00:00 2001 From: Lance Hambly Date: Tue, 24 Feb 2026 13:46:46 +0000 Subject: [PATCH 07/10] Always keep island open while permissions are pending Remove the terminal visibility check from the click-away guard. The island now stays open unconditionally when there are pending permission requests, regardless of whether a terminal is visible. Users running Claude Code will always have a terminal on screen, so the previous check was effectively disabling persistence. Co-Authored-By: Claude Opus 4.6 --- ClaudeIsland/Core/NotchViewModel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ClaudeIsland/Core/NotchViewModel.swift b/ClaudeIsland/Core/NotchViewModel.swift index 9b87aae9..90cb172f 100644 --- a/ClaudeIsland/Core/NotchViewModel.swift +++ b/ClaudeIsland/Core/NotchViewModel.swift @@ -187,9 +187,9 @@ class NotchViewModel: ObservableObject { switch status { case .opened: if geometry.isPointOutsidePanel(location, size: openedSize) { - // Stay open if there are pending permissions and the terminal isn't visible - if hasPendingPermissions && !TerminalVisibilityDetector.isTerminalVisibleOnCurrentSpace() { - // Re-post the click but keep the island open + // Stay open if there are pending permissions — the user + // needs to approve/deny before the island can dismiss + if hasPendingPermissions { repostClickAt(location) } else { notchClose() From 4dc78cbe1027bf4df75ad8be818e655129059080 Mon Sep 17 00:00:00 2001 From: Lance Hambly Date: Tue, 24 Feb 2026 15:06:53 +0000 Subject: [PATCH 08/10] Add multi-session approval selection with keyboard cycling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple Claude sessions have pending permission requests: - Amber accent bar on the left edge of the selected row shows which session ⌘⇧Y/⌘⇧N will act on - ⌘⇧↑ / ⌘⇧↓ cycles selection between pending sessions - Closed notch shows count badge ("2", "3"...) when 2+ pending - Selection auto-reconciles when sessions resolve or arrive - Approve/deny shortcuts now target the selected session instead of blindly picking the first one New state: NotchViewModel.selectedPendingSessionId New hotkeys: Carbon RegisterEventHotKey for ⌘⇧↑ and ⌘⇧↓ Co-Authored-By: Claude Opus 4.6 --- ClaudeIsland/Core/NotchViewModel.swift | 29 ++++ .../Shortcuts/KeyboardShortcutHandler.swift | 148 ++++++++++++------ .../UI/Views/ClaudeInstancesView.swift | 12 ++ ClaudeIsland/UI/Views/NotchView.swift | 27 +++- 4 files changed, 169 insertions(+), 47 deletions(-) diff --git a/ClaudeIsland/Core/NotchViewModel.swift b/ClaudeIsland/Core/NotchViewModel.swift index 90cb172f..4c01bc27 100644 --- a/ClaudeIsland/Core/NotchViewModel.swift +++ b/ClaudeIsland/Core/NotchViewModel.swift @@ -47,6 +47,8 @@ class NotchViewModel: ObservableObject { @Published var isHovering: Bool = false /// Set by NotchView — prevents auto-dismiss when permissions need attention @Published var hasPendingPermissions: Bool = false + /// The session ID currently targeted by keyboard shortcuts + @Published var selectedPendingSessionId: String? // MARK: - Dependencies @@ -93,6 +95,33 @@ class NotchViewModel: ObservableObject { } } + // MARK: - Pending Selection + + /// Keep selection in sync when pending sessions change. + /// Auto-selects first if current selection is no longer valid. + func reconcilePendingSelection(pendingSessionIds: [String]) { + if pendingSessionIds.isEmpty { + selectedPendingSessionId = nil + return + } + if let selected = selectedPendingSessionId, pendingSessionIds.contains(selected) { + return + } + selectedPendingSessionId = pendingSessionIds.first + } + + /// Cycle selection through pending sessions. direction: +1 = next, -1 = prev. + func cyclePendingSelection(direction: Int, pendingSessionIds: [String]) { + guard pendingSessionIds.count > 1 else { return } + guard let current = selectedPendingSessionId, + let currentIndex = pendingSessionIds.firstIndex(of: current) else { + selectedPendingSessionId = pendingSessionIds.first + return + } + let nextIndex = (currentIndex + direction + pendingSessionIds.count) % pendingSessionIds.count + selectedPendingSessionId = pendingSessionIds[nextIndex] + } + // MARK: - Animation var animation: Animation { diff --git a/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift b/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift index a467da49..84dd2379 100644 --- a/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift +++ b/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift @@ -10,43 +10,11 @@ import AppKit import Carbon.HIToolbox -// Unique IDs for our two hotkeys +// Unique IDs for our hotkeys private let kApproveHotKeyID: UInt32 = 1 private let kDenyHotKeyID: UInt32 = 2 - -// Global C callback — Carbon hotkey events land here -private func hotKeyHandler( - nextHandler: EventHandlerCallRef?, - event: EventRef?, - userData: UnsafeMutableRawPointer? -) -> OSStatus { - guard let event else { return OSStatus(eventNotHandledErr) } - - var hotKeyID = EventHotKeyID() - let status = GetEventParameter( - event, - EventParamName(kEventParamDirectObject), - EventParamType(typeEventHotKeyID), - nil, - MemoryLayout.size, - nil, - &hotKeyID - ) - guard status == noErr else { return status } - - Task { @MainActor in - switch hotKeyID.id { - case kApproveHotKeyID: - KeyboardShortcutHandler.shared.handleApprove() - case kDenyHotKeyID: - KeyboardShortcutHandler.shared.handleDeny() - default: - break - } - } - - return noErr -} +private let kCycleNextHotKeyID: UInt32 = 3 +private let kCyclePrevHotKeyID: UInt32 = 4 @MainActor class KeyboardShortcutHandler { @@ -54,15 +22,19 @@ class KeyboardShortcutHandler { private var approveHotKeyRef: EventHotKeyRef? private var denyHotKeyRef: EventHotKeyRef? + private var cycleNextHotKeyRef: EventHotKeyRef? + private var cyclePrevHotKeyRef: EventHotKeyRef? private var eventHandler: EventHandlerRef? private var sessionMonitor: ClaudeSessionMonitor? + private var viewModel: NotchViewModel? private init() {} // MARK: - Public API - func start(sessionMonitor: ClaudeSessionMonitor) { + func start(sessionMonitor: ClaudeSessionMonitor, viewModel: NotchViewModel) { self.sessionMonitor = sessionMonitor + self.viewModel = viewModel if AppSettings.shortcutsEnabled { registerHotKeys() } @@ -71,6 +43,7 @@ class KeyboardShortcutHandler { func stop() { unregisterHotKeys() sessionMonitor = nil + viewModel = nil } /// Re-register hotkeys after settings change @@ -86,7 +59,8 @@ class KeyboardShortcutHandler { private func registerHotKeys() { unregisterHotKeys() - // Install the event handler (once) + // Install the event handler with a literal closure + // (Swift requires a literal closure to form a C function pointer) var eventType = EventTypeSpec( eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed) @@ -94,7 +68,38 @@ class KeyboardShortcutHandler { InstallEventHandler( GetApplicationEventTarget(), - hotKeyHandler, + { (_: EventHandlerCallRef?, event: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in + guard let event else { return OSStatus(eventNotHandledErr) } + + var hotKeyID = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + guard status == noErr else { return status } + + Task { @MainActor in + switch hotKeyID.id { + case kApproveHotKeyID: + KeyboardShortcutHandler.shared.handleApprove() + case kDenyHotKeyID: + KeyboardShortcutHandler.shared.handleDeny() + case kCycleNextHotKeyID: + KeyboardShortcutHandler.shared.handleCycleNext() + case kCyclePrevHotKeyID: + KeyboardShortcutHandler.shared.handleCyclePrev() + default: + break + } + } + + return noErr + }, 1, &eventType, nil, @@ -124,6 +129,28 @@ class KeyboardShortcutHandler { 0, &denyHotKeyRef ) + + // Register cycle-next hotkey (⌘⇧↓) + var cycleNextID = EventHotKeyID(signature: fourCharCode("CISL"), id: kCycleNextHotKeyID) + RegisterEventHotKey( + UInt32(kVK_DownArrow), + carbonModifiers(from: [.command, .shift]), + cycleNextID, + GetApplicationEventTarget(), + 0, + &cycleNextHotKeyRef + ) + + // Register cycle-prev hotkey (⌘⇧↑) + var cyclePrevID = EventHotKeyID(signature: fourCharCode("CISL"), id: kCyclePrevHotKeyID) + RegisterEventHotKey( + UInt32(kVK_UpArrow), + carbonModifiers(from: [.command, .shift]), + cyclePrevID, + GetApplicationEventTarget(), + 0, + &cyclePrevHotKeyRef + ) } private func unregisterHotKeys() { @@ -135,6 +162,14 @@ class KeyboardShortcutHandler { UnregisterEventHotKey(ref) denyHotKeyRef = nil } + if let ref = cycleNextHotKeyRef { + UnregisterEventHotKey(ref) + cycleNextHotKeyRef = nil + } + if let ref = cyclePrevHotKeyRef { + UnregisterEventHotKey(ref) + cyclePrevHotKeyRef = nil + } if let handler = eventHandler { RemoveEventHandler(handler) eventHandler = nil @@ -146,9 +181,10 @@ class KeyboardShortcutHandler { func handleApprove() { guard AppSettings.shortcutsEnabled, let sessionMonitor else { return } - guard let pendingSession = sessionMonitor.pendingInstances.first(where: { $0.phase.isWaitingForApproval }) else { - return - } + let targetId = viewModel?.selectedPendingSessionId + guard let pendingSession = sessionMonitor.pendingInstances.first(where: { + $0.phase.isWaitingForApproval && (targetId == nil || $0.sessionId == targetId) + }) else { return } sessionMonitor.approvePermission(sessionId: pendingSession.sessionId) ShortcutFeedback.flash(.approve) @@ -157,16 +193,40 @@ class KeyboardShortcutHandler { func handleDeny() { guard AppSettings.shortcutsEnabled, let sessionMonitor else { return } - guard let pendingSession = sessionMonitor.pendingInstances.first(where: { $0.phase.isWaitingForApproval }) else { - return - } + let targetId = viewModel?.selectedPendingSessionId + guard let pendingSession = sessionMonitor.pendingInstances.first(where: { + $0.phase.isWaitingForApproval && (targetId == nil || $0.sessionId == targetId) + }) else { return } sessionMonitor.denyPermission(sessionId: pendingSession.sessionId, reason: "Denied via keyboard shortcut") ShortcutFeedback.flash(.deny) } + func handleCycleNext() { + guard AppSettings.shortcutsEnabled, let viewModel else { return } + viewModel.cyclePendingSelection(direction: 1, pendingSessionIds: sortedApprovalSessionIds()) + } + + func handleCyclePrev() { + guard AppSettings.shortcutsEnabled, let viewModel else { return } + viewModel.cyclePendingSelection(direction: -1, pendingSessionIds: sortedApprovalSessionIds()) + } + // MARK: - Helpers + /// Sorted approval session IDs matching the visual order in ClaudeInstancesView + private func sortedApprovalSessionIds() -> [String] { + guard let sessionMonitor else { return [] } + return sessionMonitor.pendingInstances + .filter { $0.phase.isWaitingForApproval } + .sorted { a, b in + let dateA = a.lastUserMessageDate ?? a.lastActivity + let dateB = b.lastUserMessageDate ?? b.lastActivity + return dateA > dateB + } + .map { $0.sessionId } + } + /// Convert NSEvent.ModifierFlags to Carbon modifier mask private func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 { var carbon: UInt32 = 0 diff --git a/ClaudeIsland/UI/Views/ClaudeInstancesView.swift b/ClaudeIsland/UI/Views/ClaudeInstancesView.swift index b3f33533..294ff789 100644 --- a/ClaudeIsland/UI/Views/ClaudeInstancesView.swift +++ b/ClaudeIsland/UI/Views/ClaudeInstancesView.swift @@ -71,6 +71,7 @@ struct ClaudeInstancesView: View { ForEach(sortedInstances) { session in InstanceRow( session: session, + isSelected: session.sessionId == viewModel.selectedPendingSessionId, onFocus: { focusSession(session) }, onChat: { openChat(session) }, onArchive: { archiveSession(session) }, @@ -120,6 +121,7 @@ struct ClaudeInstancesView: View { struct InstanceRow: View { let session: SessionState + let isSelected: Bool let onFocus: () -> Void let onChat: () -> Void let onArchive: () -> Void @@ -286,6 +288,16 @@ struct InstanceRow: View { RoundedRectangle(cornerRadius: 12) .fill(isHovered ? Color.white.opacity(0.06) : Color.clear) ) + .overlay(alignment: .leading) { + if isSelected && isWaitingForApproval { + RoundedRectangle(cornerRadius: 1.5) + .fill(TerminalColors.amber) + .frame(width: 3, height: 28) + .padding(.leading, 2) + .transition(.opacity.combined(with: .scale(scale: 0.5, anchor: .leading))) + } + } + .animation(.spring(response: 0.25, dampingFraction: 0.8), value: isSelected) .onHover { isHovered = $0 } .task { isYabaiAvailable = await WindowFinder.shared.isYabaiAvailable() diff --git a/ClaudeIsland/UI/Views/NotchView.swift b/ClaudeIsland/UI/Views/NotchView.swift index bd93f2ec..686ae463 100644 --- a/ClaudeIsland/UI/Views/NotchView.swift +++ b/ClaudeIsland/UI/Views/NotchView.swift @@ -205,7 +205,7 @@ struct NotchView: View { .preferredColorScheme(.dark) .onAppear { sessionMonitor.startMonitoring() - KeyboardShortcutHandler.shared.start(sessionMonitor: sessionMonitor) + KeyboardShortcutHandler.shared.start(sessionMonitor: sessionMonitor, viewModel: viewModel) // On non-notched devices, keep visible so users have a target to interact with if !viewModel.hasPhysicalNotch { isVisible = true @@ -216,6 +216,18 @@ struct NotchView: View { } .onChange(of: sessionMonitor.pendingInstances) { _, sessions in viewModel.hasPendingPermissions = sessions.contains { $0.phase.isWaitingForApproval } + + // Keep keyboard selection in sync with pending list + let approvalIds = sessions + .filter { $0.phase.isWaitingForApproval } + .sorted { a, b in + let dateA = a.lastUserMessageDate ?? a.lastActivity + let dateB = b.lastUserMessageDate ?? b.lastActivity + return dateA > dateB + } + .map { $0.sessionId } + viewModel.reconcilePendingSelection(pendingSessionIds: approvalIds) + handlePendingSessionsChange(sessions) } .onChange(of: sessionMonitor.instances) { _, instances in @@ -272,8 +284,17 @@ struct NotchView: View { // Permission indicator only (amber) - waiting for input shows checkmark on right if hasPendingPermission { - PermissionIndicatorIcon(size: 14, color: Color(red: 0.85, green: 0.47, blue: 0.34)) - .matchedGeometryEffect(id: "status-indicator", in: activityNamespace, isSource: showClosedActivity) + HStack(spacing: 2) { + PermissionIndicatorIcon(size: 14, color: Color(red: 0.85, green: 0.47, blue: 0.34)) + .matchedGeometryEffect(id: "status-indicator", in: activityNamespace, isSource: showClosedActivity) + + let pendingCount = sessionMonitor.instances.filter { $0.phase.isWaitingForApproval }.count + if pendingCount > 1 { + Text("\(pendingCount)") + .font(.system(size: 9, weight: .bold, design: .rounded)) + .foregroundColor(TerminalColors.amber) + } + } } } .frame(width: viewModel.status == .opened ? nil : sideWidth + (hasPendingPermission ? 18 : 0)) From 418381fe164578a1c39268f16cf20f4ab64b5a17 Mon Sep 17 00:00:00 2001 From: Lance Hambly Date: Tue, 24 Feb 2026 15:09:55 +0000 Subject: [PATCH 09/10] Move approval flash from notch to selected instance row The green/red flash now highlights only the specific row that was approved/denied, not the entire notch. The flash overlay sits on InstanceRow and only renders when that row is the selected target. Co-Authored-By: Claude Opus 4.6 --- ClaudeIsland/UI/Views/ClaudeInstancesView.swift | 11 +++++++++++ ClaudeIsland/UI/Views/NotchView.swift | 15 --------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/ClaudeIsland/UI/Views/ClaudeInstancesView.swift b/ClaudeIsland/UI/Views/ClaudeInstancesView.swift index 294ff789..1b2bc8dd 100644 --- a/ClaudeIsland/UI/Views/ClaudeInstancesView.swift +++ b/ClaudeIsland/UI/Views/ClaudeInstancesView.swift @@ -128,6 +128,7 @@ struct InstanceRow: View { let onApprove: () -> Void let onReject: () -> Void + @ObservedObject private var shortcutFeedback = ShortcutFeedback.shared @State private var isHovered = false @State private var spinnerPhase = 0 @State private var isYabaiAvailable = false @@ -297,6 +298,16 @@ struct InstanceRow: View { .transition(.opacity.combined(with: .scale(scale: 0.5, anchor: .leading))) } } + .overlay { + // Flash on this row when a shortcut fires and this row is selected + if isSelected, let flash = shortcutFeedback.activeFlash { + RoundedRectangle(cornerRadius: 12) + .fill(flash == .approve ? TerminalColors.green.opacity(0.25) : Color.red.opacity(0.25)) + .transition(.opacity) + .allowsHitTesting(false) + } + } + .animation(.easeOut(duration: 0.3), value: shortcutFeedback.activeFlash == nil) .animation(.spring(response: 0.25, dampingFraction: 0.8), value: isSelected) .onHover { isHovered = $0 } .task { diff --git a/ClaudeIsland/UI/Views/NotchView.swift b/ClaudeIsland/UI/Views/NotchView.swift index 686ae463..bd578c99 100644 --- a/ClaudeIsland/UI/Views/NotchView.swift +++ b/ClaudeIsland/UI/Views/NotchView.swift @@ -19,7 +19,6 @@ struct NotchView: View { @ObservedObject var viewModel: NotchViewModel @StateObject private var sessionMonitor = ClaudeSessionMonitor() @StateObject private var activityCoordinator = NotchActivityCoordinator.shared - @StateObject private var shortcutFeedback = ShortcutFeedback.shared @ObservedObject private var updateManager = UpdateManager.shared @State private var previousPendingIds: Set = [] @State private var previousWaitingForInputIds: Set = [] @@ -186,20 +185,6 @@ struct NotchView: View { } } } - .overlay(alignment: .top) { - // Flash overlay when keyboard shortcut fires - if let flash = shortcutFeedback.activeFlash { - RoundedRectangle(cornerRadius: bottomCornerRadius) - .fill(flash == .approve ? TerminalColors.green.opacity(0.3) : Color.red.opacity(0.3)) - .frame( - width: closedContentWidth + 20, - height: closedNotchSize.height + 4 - ) - .transition(.opacity) - .animation(.easeOut(duration: 0.3), value: shortcutFeedback.activeFlash == nil) - .allowsHitTesting(false) - } - } .opacity(isVisible ? 1 : 0) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .preferredColorScheme(.dark) From 7a72abd1f4def4157e18b63a40736e60f49ed9af Mon Sep 17 00:00:00 2001 From: Lance Hambly Date: Tue, 24 Feb 2026 15:12:33 +0000 Subject: [PATCH 10/10] Remove approval flash overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The green/red glow on approve/deny wasn't necessary — the row disappearing from the list is sufficient feedback. Removes ShortcutFeedback entirely as nothing uses it. Co-Authored-By: Claude Opus 4.6 --- ClaudeIsland.xcodeproj/project.pbxproj | 4 +- .../Shortcuts/KeyboardShortcutHandler.swift | 2 - .../Services/Shortcuts/ShortcutFeedback.swift | 44 ------------------- .../UI/Views/ClaudeInstancesView.swift | 11 ----- 4 files changed, 2 insertions(+), 59 deletions(-) delete mode 100644 ClaudeIsland/Services/Shortcuts/ShortcutFeedback.swift diff --git a/ClaudeIsland.xcodeproj/project.pbxproj b/ClaudeIsland.xcodeproj/project.pbxproj index ce22de4e..e3e4306c 100644 --- a/ClaudeIsland.xcodeproj/project.pbxproj +++ b/ClaudeIsland.xcodeproj/project.pbxproj @@ -284,7 +284,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 3; - DEVELOPMENT_TEAM = 2DKS5U9LV4; + DEVELOPMENT_TEAM = L3SZ55LFCK; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -319,7 +319,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 3; - DEVELOPMENT_TEAM = 2DKS5U9LV4; + DEVELOPMENT_TEAM = L3SZ55LFCK; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift b/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift index 84dd2379..82dae3bc 100644 --- a/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift +++ b/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift @@ -187,7 +187,6 @@ class KeyboardShortcutHandler { }) else { return } sessionMonitor.approvePermission(sessionId: pendingSession.sessionId) - ShortcutFeedback.flash(.approve) } func handleDeny() { @@ -199,7 +198,6 @@ class KeyboardShortcutHandler { }) else { return } sessionMonitor.denyPermission(sessionId: pendingSession.sessionId, reason: "Denied via keyboard shortcut") - ShortcutFeedback.flash(.deny) } func handleCycleNext() { diff --git a/ClaudeIsland/Services/Shortcuts/ShortcutFeedback.swift b/ClaudeIsland/Services/Shortcuts/ShortcutFeedback.swift deleted file mode 100644 index 91d1e683..00000000 --- a/ClaudeIsland/Services/Shortcuts/ShortcutFeedback.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// ShortcutFeedback.swift -// ClaudeIsland -// -// Visual and audio feedback when a keyboard shortcut fires -// - -import AppKit -import Combine - -enum ShortcutAction { - case approve - case deny -} - -@MainActor -class ShortcutFeedback: ObservableObject { - static let shared = ShortcutFeedback() - - @Published var activeFlash: ShortcutAction? - - private init() {} - - static func flash(_ action: ShortcutAction) { - shared.activeFlash = action - - // Play a brief confirmation sound - let soundName: String? = { - switch action { - case .approve: return "Tink" - case .deny: return "Basso" - } - }() - - if let name = soundName { - NSSound(named: name)?.play() - } - - // Clear flash after brief display - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - shared.activeFlash = nil - } - } -} diff --git a/ClaudeIsland/UI/Views/ClaudeInstancesView.swift b/ClaudeIsland/UI/Views/ClaudeInstancesView.swift index 1b2bc8dd..294ff789 100644 --- a/ClaudeIsland/UI/Views/ClaudeInstancesView.swift +++ b/ClaudeIsland/UI/Views/ClaudeInstancesView.swift @@ -128,7 +128,6 @@ struct InstanceRow: View { let onApprove: () -> Void let onReject: () -> Void - @ObservedObject private var shortcutFeedback = ShortcutFeedback.shared @State private var isHovered = false @State private var spinnerPhase = 0 @State private var isYabaiAvailable = false @@ -298,16 +297,6 @@ struct InstanceRow: View { .transition(.opacity.combined(with: .scale(scale: 0.5, anchor: .leading))) } } - .overlay { - // Flash on this row when a shortcut fires and this row is selected - if isSelected, let flash = shortcutFeedback.activeFlash { - RoundedRectangle(cornerRadius: 12) - .fill(flash == .approve ? TerminalColors.green.opacity(0.25) : Color.red.opacity(0.25)) - .transition(.opacity) - .allowsHitTesting(false) - } - } - .animation(.easeOut(duration: 0.3), value: shortcutFeedback.activeFlash == nil) .animation(.spring(response: 0.25, dampingFraction: 0.8), value: isSelected) .onHover { isHovered = $0 } .task {