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/Core/NotchViewModel.swift b/ClaudeIsland/Core/NotchViewModel.swift index a7406daa..4c01bc27 100644 --- a/ClaudeIsland/Core/NotchViewModel.swift +++ b/ClaudeIsland/Core/NotchViewModel.swift @@ -45,6 +45,10 @@ 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 + /// The session ID currently targeted by keyboard shortcuts + @Published var selectedPendingSessionId: String? // MARK: - Dependencies @@ -61,6 +65,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,13 +84,44 @@ 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) ) } } + // 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 { @@ -178,9 +216,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 — the user + // needs to approve/deny before the island can dismiss + if hasPendingPermissions { + 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/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..82dae3bc --- /dev/null +++ b/ClaudeIsland/Services/Shortcuts/KeyboardShortcutHandler.swift @@ -0,0 +1,246 @@ +// +// KeyboardShortcutHandler.swift +// ClaudeIsland +// +// 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 hotkeys +private let kApproveHotKeyID: UInt32 = 1 +private let kDenyHotKeyID: UInt32 = 2 +private let kCycleNextHotKeyID: UInt32 = 3 +private let kCyclePrevHotKeyID: UInt32 = 4 + +@MainActor +class KeyboardShortcutHandler { + static let shared = 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, viewModel: NotchViewModel) { + self.sessionMonitor = sessionMonitor + self.viewModel = viewModel + if AppSettings.shortcutsEnabled { + registerHotKeys() + } + } + + func stop() { + unregisterHotKeys() + sessionMonitor = nil + viewModel = nil + } + + /// Re-register hotkeys after settings change + func reloadShortcuts() { + unregisterHotKeys() + if AppSettings.shortcutsEnabled { + registerHotKeys() + } + } + + // MARK: - Carbon Hotkey Registration + + private func registerHotKeys() { + unregisterHotKeys() + + // 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) + ) + + InstallEventHandler( + GetApplicationEventTarget(), + { (_: 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, + &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 + ) + + // 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() { + if let ref = approveHotKeyRef { + UnregisterEventHotKey(ref) + approveHotKeyRef = nil + } + if let ref = denyHotKeyRef { + 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 + } + } + + // MARK: - Actions + + func handleApprove() { + guard AppSettings.shortcutsEnabled, let sessionMonitor 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) + } + + func handleDeny() { + guard AppSettings.shortcutsEnabled, let sessionMonitor 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") + } + + 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 + 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 new file mode 100644 index 00000000..82af392d --- /dev/null +++ b/ClaudeIsland/UI/Components/ShortcutSettingsRow.swift @@ -0,0 +1,269 @@ +// +// 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 + KeyboardShortcutHandler.shared.reloadShortcuts() + if shortcutsEnabled { + 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 + KeyboardShortcutHandler.shared.reloadShortcuts() + } 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 + KeyboardShortcutHandler.shared.reloadShortcuts() + } + } + .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/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/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..bd578c99 100644 --- a/ClaudeIsland/UI/Views/NotchView.swift +++ b/ClaudeIsland/UI/Views/NotchView.swift @@ -190,6 +190,7 @@ struct NotchView: View { .preferredColorScheme(.dark) .onAppear { sessionMonitor.startMonitoring() + 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 @@ -199,9 +200,23 @@ struct NotchView: View { handleStatusChange(from: oldStatus, to: newStatus) } .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 + viewModel.instanceCount = instances.count handleProcessingChange() handleWaitingForInputChange(instances) } @@ -254,8 +269,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)) @@ -419,12 +443,25 @@ 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) } + // Auto-close when all pending permissions are resolved + 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() + } + } + } + } + previousPendingIds = currentIds } @@ -467,12 +504,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