Skip to content
Open
4 changes: 2 additions & 2 deletions ClaudeIsland.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
51 changes: 47 additions & 4 deletions ClaudeIsland/Core/NotchViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions ClaudeIsland/Core/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
}
}
116 changes: 116 additions & 0 deletions ClaudeIsland/Models/KeyboardShortcut.swift
Original file line number Diff line number Diff line change
@@ -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 "?"
}
}
Loading