Merge pull request #26 from Ebullioscopic/feature/screen-assistant

Feature/screen assistant
This commit is contained in:
Hariharan Mudaliar 2025-09-27 16:33:00 +05:30 • committed by GitHub
commit 238dbdf060
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 3722 additions and 2 deletions

5
.gitignore vendored
View file

@ -68,4 +68,7 @@ SettingsView.swift.md
logic.md
TheBoringWorker-HUD
boringnotch + meteo + airpods + traduzione modificabile
segmentedhud.swift.ref
segmentedhud.swift.ref
gemini-apireference.md
ScreenshotApp-main

View file

@ -370,6 +370,7 @@
"$(PROJECT_DIR)/mediaremote-adapter",
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DynamicIsland/Info.plist;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
@ -411,6 +412,7 @@
"$(PROJECT_DIR)/mediaremote-adapter",
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DynamicIsland/Info.plist;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";

View file

@ -4,6 +4,10 @@
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.temporary-exception.apple-events</key>
<array>
<string>com.spotify.client</string>

View file

@ -2,7 +2,7 @@
// DynamicIslandApp.swift
// DynamicIslandApp
//
// Created by Harsh Vardhan Goswami on 02/08/24.
// Modified by Hariharan Mudaliar on 20/09/25.
//
import AVFoundation
@ -581,6 +581,36 @@ class AppDelegate: NSObject, NSApplicationDelegate {
ColorPickerPanelManager.shared.toggleColorPickerPanel()
}
KeyboardShortcuts.onKeyDown(for: .screenAssistantPanel) { [weak self] in
guard let self = self else { return }
// Only execute if shortcuts are enabled
guard Defaults[.enableShortcuts] else { return }
// Only open screen assistant if the feature is enabled
guard Defaults[.enableScreenAssistant] else { return }
// Handle keyboard shortcut based on display mode
switch Defaults[.screenAssistantDisplayMode] {
case .panel:
ScreenAssistantPanelManager.shared.toggleScreenAssistantPanel()
case .popover:
// For popover mode, first ensure notch is open, then toggle popover
// If notch is closed, open it first
if self.vm.notchState == .closed {
self.vm.open()
// Wait a moment for the notch to open, then show popover
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
}
} else {
// Notch is already open, toggle popover immediately
NotificationCenter.default.post(name: NSNotification.Name("ToggleScreenAssistantPopover"), object: nil)
}
}
}
KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in
guard let self = self else { return }

View file

@ -11,6 +11,12 @@
<string>Dynamic Island uses AppleScripts to control Spotify and Apple Music</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>Show calender events inside the notch</string>
<key>NSScreenCaptureUsageDescription</key>
<string>Dynamic Island needs screen recording permission to capture screenshots for the AI assistant feature.</string>
<key>NSDesktopFolderUsageDescription</key>
<string>Dynamic Island needs access to save screenshots to your desktop.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string>Dynamic Island needs access to save and read AI assistant files.</string>
<key>SUEnableDownloaderService</key>
<true/>
<key>SUEnableInstallerLauncherService</key>

View file

@ -28,6 +28,9 @@
},
"%@" : {
},
"%@ Models" : {
},
"%d%%" : {
@ -52,9 +55,18 @@
},
"• New feature 1" : {
},
"• Thinking" : {
},
"• Updated %@" : {
},
"••••••••" : {
},
"+%lld more" : {
},
"3 items" : {
@ -121,6 +133,9 @@
},
"Add" : {
},
"Add files" : {
},
"Add manually" : {
@ -130,6 +145,21 @@
},
"Additional features" : {
},
"AI Assistant" : {
},
"AI is thinking..." : {
},
"AI Model Selection" : {
},
"AI Provider" : {
},
"AI-powered assistant that can analyze files, images, and provide conversational help. Use Cmd+Shift+A to quickly access the assistant." : {
},
"AirDrop" : {
@ -148,6 +178,12 @@
},
"Always show tabs" : {
},
"API Configuration" : {
},
"API Key Required" : {
},
"App icon" : {
@ -157,6 +193,12 @@
},
"Are you sure you want to delete this color from your history?" : {
},
"Ask me anything..." : {
},
"Attached Files" : {
},
"Automatically check for updates" : {
"localizations" : {
@ -223,6 +265,9 @@
},
"Cancel" : {
},
"Change" : {
},
"Charging" : {
"localizations" : {
@ -245,15 +290,27 @@
},
"Choose a Music Source" : {
},
"Choose AI model" : {
},
"Choose File" : {
},
"Choose your preferred AI model and configuration" : {
},
"Circle" : {
},
"Clear" : {
},
"Clear All Files" : {
},
"Clear all files removes all attached files and audio recordings. This action is permanent." : {
},
"Clear Clipboard History" : {
@ -293,6 +350,9 @@
},
"Close" : {
},
"Close assistant" : {
},
"Color Details" : {
@ -327,6 +387,9 @@
}
}
}
},
"Configuration" : {
},
"Continue" : {
@ -534,6 +597,9 @@
},
"Enable system stats monitoring in Settings to view detailed performance data." : {
},
"Enable Thinking Mode" : {
},
"Enable timer feature in Settings to use this tab" : {
@ -546,6 +612,9 @@
},
"Enjoy your free time!" : {
},
"Enter your Gemini API Key" : {
},
"Error" : {
@ -568,6 +637,9 @@
},
"Finish" : {
},
"Gemini API Key" : {
},
"General" : {
"localizations" : {
@ -587,6 +659,9 @@
},
"Get started" : {
},
"Get your free API key from Google AI Studio" : {
},
"GitHub" : {
"localizations" : {
@ -609,6 +684,9 @@
},
"Graph Visibility" : {
},
"Hide" : {
},
"Hide DynamicIsland Options" : {
@ -878,6 +956,9 @@
},
"Not Now" : {
},
"Not Set" : {
},
"Notch behavior" : {
@ -916,12 +997,21 @@
}
}
}
},
"Open Google AI Studio" : {
},
"Open Model Settings" : {
},
"Open Settings" : {
},
"Open System Settings" : {
},
"Opens the AI assistant panel for file analysis and conversation. Default is Cmd+Shift+A. Only works when screen assistant feature is enabled." : {
},
"Opens the clipboard history panel. Default is Cmd+Shift+V (similar to Windows+V on PC). Only works when clipboard feature is enabled." : {
@ -937,6 +1027,9 @@
},
"Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button." : {
},
"Panel mode shows the assistant in a floating window near the notch. Popover mode shows the assistant as a dropdown attached to the AI button." : {
},
"Pause" : {
@ -977,6 +1070,9 @@
},
"Pinned Items" : {
},
"Please configure your API key for the selected AI provider in model settings." : {
},
"Plugged In" : {
@ -986,6 +1082,9 @@
},
"Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window." : {
},
"Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch." : {
},
"Progress bar" : {
"localizations" : {
@ -1029,9 +1128,18 @@
},
"Ready" : {
},
"Reasoning Mode" : {
},
"Recent Colors" : {
},
"Recording" : {
},
"Recording Status" : {
},
"Release name" : {
"localizations" : {
@ -1063,6 +1171,27 @@
},
"Running" : {
},
"Save" : {
},
"Save Configuration" : {
},
"Screen Assistant" : {
},
"Screen Assistant feature is disabled" : {
},
"Screen Assistant:" : {
},
"Screenshot options" : {
},
"Screenshot Type" : {
},
"Search clipboard..." : {
@ -1107,6 +1236,9 @@
},
"Sensitivity" : {
},
"Set" : {
},
"Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface." : {
@ -1185,6 +1317,9 @@
},
"Show shuffle and repeat buttons" : {
},
"Shows the model's reasoning process before the final answer" : {
},
"Slider color" : {
@ -1213,6 +1348,9 @@
},
"Start" : {
},
"Start a conversation to see your chat history here" : {
},
"Start Color Picking" : {
@ -1225,6 +1363,9 @@
},
"Start picking colors to build your history" : {
},
"Start recording" : {
},
"Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : {
@ -1258,6 +1399,9 @@
},
"Stop Monitoring" : {
},
"Stop recording" : {
},
"Stopped" : {
@ -1272,12 +1416,18 @@
}
}
}
},
"Supports reasoning mode" : {
},
"System features" : {
},
"System Performance Monitor" : {
},
"Take area screenshot" : {
},
"TheDynamicIsland" : {
@ -1382,6 +1532,9 @@
},
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
},
"You" : {
},
"You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : {

View file

@ -11,6 +11,7 @@ import SwiftUI
extension KeyboardShortcuts.Name {
static let clipboardHistoryPanel = Self("clipboardHistoryPanel", default: .init(.c, modifiers: [.shift, .command]))
static let colorPickerPanel = Self("colorPickerPanel", default: .init(.p, modifiers: [.shift, .command]))
static let screenAssistantPanel = Self("screenAssistantPanel", default: .init(.a, modifiers: [.shift, .command]))
static let statsPanel = Self("statsPanel", default: .init(.s, modifiers: [.shift, .command]))
static let toggleMicrophone = Self("toggleMicrophone", default: .init(.f5, modifiers: [.function]))
static let decreaseBacklight = Self("decreaseBacklight", default: .init(.f1, modifiers: [.command]))

View file

@ -7,6 +7,7 @@
import AppKit
import SwiftUI
import Defaults
class ClipboardPanel: NSPanel {
@ -51,6 +52,10 @@ class ClipboardPanel: NSPanel {
.fullScreenAuxiliary // Float above full-screen apps
]
// Apply screenshot protection
updateScreenshotProtection()
setupScreenshotProtectionObserver()
// Accept mouse moved events for proper hover behavior
acceptsMouseMovedEvents = true
}
@ -134,6 +139,29 @@ class ClipboardPanel: NSPanel {
setFrameOrigin(NSPoint(x: xPosition, y: yPosition))
}
private func setupScreenshotProtectionObserver() {
// Observe changes to hidePanelsFromScreenCapture setting
Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in
DispatchQueue.main.async {
self?.updateScreenshotProtection()
}
}
}
private func updateScreenshotProtection() {
let shouldHide = Defaults[.hidePanelsFromScreenCapture]
if shouldHide {
// Exclude from screenshots and screen recordings
self.sharingType = .none
print("🙈 ClipboardPanel: Protected from screenshots and screen recordings")
} else {
// Allow normal screenshot inclusion
self.sharingType = .readOnly
print("👁️ ClipboardPanel: Visible in screenshots and screen recordings")
}
}
}
struct ClipboardPanelView: View {

View file

@ -93,6 +93,10 @@ class ColorPickerPanel: NSPanel {
.fullScreenAuxiliary // Float above full-screen apps
]
// Apply screen capture hiding setting
updateScreenCaptureVisibility()
setupScreenCaptureObserver()
// Accept mouse moved events for proper hover behavior
acceptsMouseMovedEvents = true
@ -156,6 +160,29 @@ class ColorPickerPanel: NSPanel {
// Save position whenever it changes (user dragging)
saveCurrentPosition()
}
private func setupScreenCaptureObserver() {
// Observe changes to hidePanelsFromScreenCapture setting
Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in
DispatchQueue.main.async {
self?.updateScreenCaptureVisibility()
}
}
}
private func updateScreenCaptureVisibility() {
let shouldHide = Defaults[.hidePanelsFromScreenCapture]
if shouldHide {
// Hide from screen capture and recording
self.sharingType = .none
print("🙈 ColorPickerPanel: Hidden from screen capture and recordings")
} else {
// Allow normal screen capture
self.sharingType = .readOnly
print("👁️ ColorPickerPanel: Visible in screen capture and recordings")
}
}
}
struct ColorPickerPanelView: View {

View file

@ -0,0 +1,830 @@
//
// ChatPanels.swift
// DynamicIsland
//
// Created by Hariharan Mudaliar
import AppKit
import SwiftUI
import Defaults
// MARK: - Chat Messages Panel (Left Side)
class ChatMessagesPanel: NSPanel {
init() {
super.init(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: true
)
setupWindow()
setupContentView()
}
override var canBecomeKey: Bool {
return false // Don't steal focus from input panel
}
override var canBecomeMain: Bool {
return false
}
private func setupWindow() {
backgroundColor = .clear
isOpaque = false
hasShadow = true
level = .floating
isMovableByWindowBackground = false // Fixed position
titlebarAppearsTransparent = true
titleVisibility = .hidden
isFloatingPanel = true
collectionBehavior = [
.canJoinAllSpaces,
.stationary,
.fullScreenAuxiliary
]
// Apply screen capture hiding setting
updateScreenCaptureVisibility()
setupScreenCaptureObserver()
acceptsMouseMovedEvents = true
}
private func setupContentView() {
let contentView = ChatMessagesView()
let hostingView = NSHostingView(rootView: contentView)
self.contentView = hostingView
// Set size for chat messages panel (wider and taller)
let preferredSize = CGSize(width: 600, height: 500)
hostingView.setFrameSize(preferredSize)
setContentSize(preferredSize)
}
func positionOnLeftSide() {
guard let screen = NSScreen.main else { return }
let screenFrame = screen.visibleFrame
let panelFrame = frame
// Position on the left side of the screen
let xPosition = screenFrame.minX + 50 // 50pt from left edge
let yPosition = screenFrame.maxY - panelFrame.height - 100 // 100pt from top
setFrameOrigin(NSPoint(x: xPosition, y: yPosition))
}
private func setupScreenCaptureObserver() {
// Observe changes to hidePanelsFromScreenCapture setting
Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in
DispatchQueue.main.async {
self?.updateScreenCaptureVisibility()
}
}
}
private func updateScreenCaptureVisibility() {
let shouldHide = Defaults[.hidePanelsFromScreenCapture]
if shouldHide {
// Hide from screen capture and recording
self.sharingType = .none
print("🙈 ChatMessagesPanel: Hidden from screen capture and recordings")
} else {
// Allow normal screen capture
self.sharingType = .readOnly
print("👁️ ChatMessagesPanel: Visible in screen capture and recordings")
}
}
}
// MARK: - Chat Input Panel (Center)
class ChatInputPanel: NSPanel {
init() {
super.init(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: true
)
setupWindow()
setupContentView()
}
override var canBecomeKey: Bool {
return true // Can receive focus for text input
}
override var canBecomeMain: Bool {
return true
}
// Handle ESC key globally for the panel
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { // ESC key
ScreenAssistantManager.shared.closePanels()
} else {
super.keyDown(with: event)
}
}
private func setupWindow() {
backgroundColor = .clear
isOpaque = false
hasShadow = true
level = .floating
isMovableByWindowBackground = true // Enable dragging
titlebarAppearsTransparent = true
titleVisibility = .hidden
isFloatingPanel = true
styleMask.insert(.fullSizeContentView)
collectionBehavior = [
.canJoinAllSpaces,
.stationary,
.fullScreenAuxiliary
]
// Apply screen capture hiding setting
updateScreenCaptureVisibility()
setupScreenCaptureObserver()
acceptsMouseMovedEvents = true
}
private func setupContentView() {
let contentView = ChatInputView()
let hostingView = NSHostingView(rootView: contentView)
self.contentView = hostingView
// Set compact size for single-line input panel
let preferredSize = CGSize(width: 500, height: 60)
hostingView.setFrameSize(preferredSize)
setContentSize(preferredSize)
}
func positionInCenter() {
guard let screen = NSScreen.main else { return }
let screenFrame = screen.visibleFrame
let panelFrame = frame
// Position in the center-bottom of the screen (like a search bar)
let xPosition = (screenFrame.width - panelFrame.width) / 2 + screenFrame.minX
let yPosition = screenFrame.minY + 100 // 100pt from bottom
setFrameOrigin(NSPoint(x: xPosition, y: yPosition))
}
private func setupScreenCaptureObserver() {
// Observe changes to hidePanelsFromScreenCapture setting
Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in
DispatchQueue.main.async {
self?.updateScreenCaptureVisibility()
}
}
}
private func updateScreenCaptureVisibility() {
let shouldHide = Defaults[.hidePanelsFromScreenCapture]
if shouldHide {
// Hide from screen capture and recording
self.sharingType = .none
print("🙈 ChatInputPanel: Hidden from screen capture and recordings")
} else {
// Allow normal screen capture
self.sharingType = .readOnly
print("👁️ ChatInputPanel: Visible in screen capture and recordings")
}
}
}
// MARK: - Chat Messages View (Redesigned for standalone panel)
struct ChatMessagesView: View {
@ObservedObject var screenAssistantManager = ScreenAssistantManager.shared
var body: some View {
VStack(spacing: 0) {
// Header with close button
HStack {
Text("AI Assistant")
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
Spacer()
Button(action: {
screenAssistantManager.closePanels()
}) {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundColor(.secondary)
}
.buttonStyle(PlainButtonStyle())
.help("Close assistant")
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Color.gray.opacity(0.05))
Divider()
// Chat content
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
if screenAssistantManager.chatMessages.isEmpty {
VStack(spacing: 20) {
Image(systemName: "brain.head.profile")
.font(.system(size: 60))
.foregroundColor(.blue.opacity(0.6))
VStack(spacing: 8) {
Text("AI Assistant")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
Text("Start a conversation to see your chat history here")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 80)
} else {
ForEach(screenAssistantManager.chatMessages) { message in
StreamingChatMessageBubble(message: message)
.id(message.id)
}
if screenAssistantManager.isLoading {
HStack(spacing: 12) {
ProgressView()
.scaleEffect(0.8)
Text("AI is thinking...")
.font(.body)
.foregroundColor(.secondary)
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
}
.padding(.vertical, 20)
}
.onChange(of: screenAssistantManager.chatMessages.count) { _, _ in
if let lastMessage = screenAssistantManager.chatMessages.last {
withAnimation(.easeOut(duration: 0.5)) {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
.onChange(of: screenAssistantManager.isLoading) { _, _ in
if screenAssistantManager.isLoading {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.easeOut(duration: 0.5)) {
if let lastMessage = screenAssistantManager.chatMessages.last {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
}
}
}
.background(ChatPanelsVisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
.cornerRadius(16)
.shadow(color: .black.opacity(0.3), radius: 15, x: 0, y: 8)
}
}
// MARK: - Chat Input View (Single Line Panel)
struct ChatInputView: View {
@ObservedObject var screenAssistantManager = ScreenAssistantManager.shared
@State private var messageText = ""
@State private var isDraggingFiles = false
@State private var showingApiKeyAlert = false
@FocusState private var isTextFieldFocused: Bool
// Current model information
private var currentProvider: AIModelProvider {
Defaults[.selectedAIProvider]
}
private var currentModel: AIModel? {
Defaults[.selectedAIModel]
}
var body: some View {
VStack(spacing: 0) {
// Current model indicator
HStack {
HStack(spacing: 6) {
Image(systemName: iconForProvider(currentProvider))
.font(.caption)
.foregroundColor(.blue)
Text(currentModel?.name ?? currentProvider.displayName)
.font(.caption)
.foregroundColor(.secondary)
if currentModel?.supportsThinking == true && Defaults[.enableThinkingMode] {
Text("• Thinking")
.font(.caption2)
.foregroundColor(.purple)
}
}
Spacer()
Button("Change", action: openModelSelection)
.font(.caption)
.foregroundColor(.blue)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.05))
// File attachments row (if any)
if !screenAssistantManager.attachedFiles.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(screenAssistantManager.attachedFiles) { file in
AttachedFileChip(file: file) {
screenAssistantManager.removeFile(file)
}
}
}
.padding(.horizontal, 12)
}
.padding(.vertical, 8)
.background(Color.gray.opacity(0.05))
Divider()
}
// Single line input row
HStack(spacing: 12) {
// Add files button
AddFilesButton()
// Screenshot snipping button
ScreenshotButton()
// Text input - SINGLE LINE
TextField("Ask me anything...", text: $messageText)
.textFieldStyle(.plain)
.font(.system(size: 14))
.focused($isTextFieldFocused)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.onSubmit {
sendMessage()
}
// Model selection button
Button(action: openModelSelection) {
Image(systemName: "brain.head.profile.fill")
.foregroundColor(.purple)
.font(.system(size: 20))
}
.buttonStyle(PlainButtonStyle())
.help("Choose AI model")
// Recording button
RecordingButton()
// Send button
Button(action: sendMessage) {
Image(systemName: "paperplane.fill")
.foregroundColor(.white)
.font(.system(size: 12))
.padding(8)
.background(canSend ? Color.blue : Color.gray)
.cornerRadius(6)
}
.buttonStyle(PlainButtonStyle())
.disabled(!canSend)
}
.padding(12)
}
.background(ChatPanelsVisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
.cornerRadius(16)
.shadow(color: .black.opacity(0.3), radius: 15, x: 0, y: 8)
.onDrop(of: [.fileURL], isTargeted: $isDraggingFiles) { providers in
handleFilesDrop(providers)
}
.alert("API Key Required", isPresented: $showingApiKeyAlert) {
Button("Open Model Settings") {
openModelSelection()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Please configure your API key for the selected AI provider in model settings.")
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isTextFieldFocused = true
}
}
}
private func iconForProvider(_ provider: AIModelProvider) -> String {
switch provider {
case .gemini: return "sparkles"
case .openai: return "brain.head.profile"
case .claude: return "doc.text"
case .local: return "server.rack"
}
}
private var canSend: Bool {
!messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !screenAssistantManager.attachedFiles.isEmpty
}
private func sendMessage() {
// Check if API key is configured for the selected provider
let provider = Defaults[.selectedAIProvider]
var apiKey = ""
switch provider {
case .gemini:
apiKey = Defaults[.geminiApiKey]
case .openai:
apiKey = Defaults[.openaiApiKey]
case .claude:
apiKey = Defaults[.claudeApiKey]
case .local:
// Local models don't need API keys
apiKey = "local"
}
if apiKey.isEmpty {
showingApiKeyAlert = true
return
}
// Prepare the message
let userMessage = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
if userMessage.isEmpty && screenAssistantManager.attachedFiles.isEmpty {
return
}
// Send message through manager
screenAssistantManager.sendMessage(userMessage)
messageText = ""
}
private func openModelSelection() {
let panel = ModelSelectionPanel()
panel.positionInCenter()
panel.makeKeyAndOrderFront(nil)
panel.orderFrontRegardless()
// Activate the app to ensure proper focus handling
NSApp.activate(ignoringOtherApps: true)
}
private func handleFilesDrop(_ providers: [NSItemProvider]) -> Bool {
for provider in providers {
_ = provider.loadObject(ofClass: URL.self) { url, error in
if let url = url {
DispatchQueue.main.async {
screenAssistantManager.addFiles([url])
}
}
}
}
return true
}
}
// MARK: - Enhanced Chat Message Bubble (No Auto-Streaming)
struct StreamingChatMessageBubble: View {
let message: ChatMessage
var body: some View {
HStack(alignment: .top, spacing: 12) {
if message.isFromUser {
Spacer()
}
// Avatar
if !message.isFromUser {
Image(systemName: "brain.head.profile")
.font(.title2)
.foregroundColor(.blue)
.frame(width: 32, height: 32)
.background(Color.blue.opacity(0.1))
.clipShape(Circle())
}
VStack(alignment: message.isFromUser ? .trailing : .leading, spacing: 8) {
// Header with name and timestamp
HStack {
Text(message.isFromUser ? "You" : "AI Assistant")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(message.isFromUser ? .blue : .green)
Spacer()
Text(message.timestamp, style: .time)
.font(.caption2)
.foregroundColor(.secondary)
}
// File attachments (if any)
if let files = message.attachedFiles, !files.isEmpty {
HStack(spacing: 6) {
ForEach(files.prefix(3)) { file in
HStack(spacing: 4) {
Image(systemName: file.type.iconName)
.font(.caption2)
Text(file.name)
.font(.caption2)
.lineLimit(1)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
if files.count > 3 {
Text("+\(files.count - 3) more")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
// Message content - NO AUTO STREAMING
MarkdownText(content: message.content)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(message.isFromUser ? Color.blue : Color.gray.opacity(0.15))
)
.foregroundColor(message.isFromUser ? .white : .primary)
}
.frame(maxWidth: 400, alignment: message.isFromUser ? .trailing : .leading)
// User avatar
if message.isFromUser {
Image(systemName: "person.circle.fill")
.font(.title2)
.foregroundColor(.blue)
.frame(width: 32, height: 32)
} else {
Spacer()
}
}
.padding(.horizontal, 20)
}
}
// MARK: - Note: Shared Components (MarkdownText, AttachedFileChip, AddFilesButton, RecordingButton, ApiKeyAlertView)
// are defined in ScreenAssistantPanel.swift to avoid redeclaration conflicts.
// MARK: - Screenshot Button Component
struct ScreenshotButton: View {
@ObservedObject var screenAssistantManager = ScreenAssistantManager.shared
@StateObject private var screenshotTool = ScreenshotSnippingTool.shared
@State private var showingScreenshotOptions = false
var body: some View {
HStack(spacing: 4) {
// Main screenshot button
Button(action: startQuickScreenshot) {
Image(systemName: getIconName())
.foregroundColor(getIconColor())
.font(.system(size: 20))
}
.buttonStyle(PlainButtonStyle())
.help("Take area screenshot")
.disabled(screenshotTool.isSnipping)
.scaleEffect(screenshotTool.isSnipping ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.2), value: screenshotTool.isSnipping)
// Options dropdown button
Button(action: { showingScreenshotOptions.toggle() }) {
Image(systemName: "chevron.down")
.foregroundColor(.secondary)
.font(.system(size: 12))
}
.buttonStyle(PlainButtonStyle())
.help("Screenshot options")
.disabled(screenshotTool.isSnipping)
.popover(isPresented: $showingScreenshotOptions) {
ScreenshotOptionsPopover { type in
startScreenshot(type: type)
showingScreenshotOptions = false
}
}
}
}
private func getIconName() -> String {
if screenshotTool.isSnipping {
return "camera.viewfinder"
} else {
return "camera.aperture"
}
}
private func getIconColor() -> Color {
if screenshotTool.isSnipping {
return .red
} else {
return .green
}
}
private func startQuickScreenshot() {
// Default to area screenshot for quick action
startScreenshot(type: .area)
}
private func startScreenshot(type: ScreenshotSnippingTool.ScreenshotType) {
// Start snipping with direct callback (ScreenshotApp-based approach)
screenshotTool.startSnipping(type: type) { [weak screenAssistantManager] screenshotURL in
guard let manager = screenAssistantManager else {
print("❌ ScreenshotTool: ScreenAssistantManager deallocated during callback")
return
}
print("📁 ScreenshotTool: Adding \(type.displayName.lowercased()) screenshot to chat: \(screenshotURL.lastPathComponent)")
manager.addFiles([screenshotURL])
print("📸 \(type.displayName) screenshot captured and added to chat successfully")
}
}
}
// MARK: - Visual Effect View for Chat Panels (to avoid conflicts)
struct ChatPanelsVisualEffectView: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = material
view.blendingMode = blendingMode
view.state = .active
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
// MARK: - Screenshot Options Popover (Hidden from Screen Recording)
struct ScreenshotOptionsPopover: View {
let onOptionSelected: (ScreenshotSnippingTool.ScreenshotType) -> Void
var body: some View {
VStack(spacing: 8) {
Text("Screenshot Type")
.font(.headline)
.padding(.top, 8)
VStack(spacing: 4) {
ScreenshotOptionButton(
type: .area,
description: "Select an area",
onTap: onOptionSelected
)
ScreenshotOptionButton(
type: .window,
description: "Select a window",
onTap: onOptionSelected
)
ScreenshotOptionButton(
type: .full,
description: "Capture full screen",
onTap: onOptionSelected
)
}
.padding(.bottom, 8)
}
.padding(.horizontal, 12)
.frame(width: 200)
.background(
ScreenshotPopoverBackground()
)
}
}
// MARK: - Screenshot Option Button
struct ScreenshotOptionButton: View {
let type: ScreenshotSnippingTool.ScreenshotType
let description: String
let onTap: (ScreenshotSnippingTool.ScreenshotType) -> Void
var body: some View {
Button(action: { onTap(type) }) {
HStack(spacing: 12) {
Image(systemName: type.iconName)
.foregroundColor(.blue)
.font(.system(size: 16))
.frame(width: 20, alignment: .center)
VStack(alignment: .leading, spacing: 2) {
Text(type.displayName)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
Text(description)
.font(.system(size: 12))
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.clear)
.contentShape(Rectangle())
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.clear, lineWidth: 1)
)
}
.buttonStyle(PlainButtonStyle())
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(NSColor.controlBackgroundColor))
.opacity(0.5)
)
.onHover { isHovered in
// Add subtle hover effect if needed
}
}
}
// MARK: - Screenshot Popover Background (Hidden from Screen Recording)
struct ScreenshotPopoverBackground: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
// Apply screen capture protection like our other panels
DispatchQueue.main.async {
if let window = view.window {
self.updateScreenCaptureVisibility(window: window)
self.setupScreenCaptureObserver(window: window)
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
// Update screen capture protection when view updates
if let window = nsView.window {
updateScreenCaptureVisibility(window: window)
}
}
private func updateScreenCaptureVisibility(window: NSWindow) {
let shouldHide = Defaults[.hidePanelsFromScreenCapture]
if shouldHide {
// Hide from screen capture and recording
window.sharingType = .none
print("🙈 ScreenshotPopover: Hidden from screen capture and recordings")
} else {
// Allow normal screen capture
window.sharingType = .readOnly
print("👁️ ScreenshotPopover: Visible in screen capture and recordings")
}
}
private func setupScreenCaptureObserver(window: NSWindow) {
// Observe changes to hidePanelsFromScreenCapture setting
Defaults.observe(.hidePanelsFromScreenCapture) { [weak window] change in
DispatchQueue.main.async {
guard let window = window else { return }
self.updateScreenCaptureVisibility(window: window)
}
}
}
}

View file

@ -0,0 +1,535 @@
//
// ModelSelectionPanel.swift
// DynamicIsland
//
// Created by Hariharan Mudaliar
import AppKit
import SwiftUI
import Defaults
// MARK: - Model Selection Panel
class ModelSelectionPanel: NSPanel {
init() {
super.init(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: true
)
setupWindow()
setupContentView()
}
override var canBecomeKey: Bool {
return true // Can receive focus for interaction
}
override var canBecomeMain: Bool {
return true
}
// Handle ESC key globally for the panel
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { // ESC key
close()
} else {
super.keyDown(with: event)
}
}
private func setupWindow() {
backgroundColor = .clear
isOpaque = false
hasShadow = true
level = .floating
isMovableByWindowBackground = true // Enable dragging
titlebarAppearsTransparent = true
titleVisibility = .hidden
isFloatingPanel = true
styleMask.insert(.fullSizeContentView)
collectionBehavior = [
.canJoinAllSpaces,
.stationary,
.fullScreenAuxiliary
]
// Apply screen capture hiding setting
updateScreenCaptureVisibility()
setupScreenCaptureObserver()
acceptsMouseMovedEvents = true
}
private func setupContentView() {
let contentView = ModelSelectionView()
let hostingView = NSHostingView(rootView: contentView)
self.contentView = hostingView
// Set size for model selection panel
let preferredSize = CGSize(width: 450, height: 600)
hostingView.setFrameSize(preferredSize)
setContentSize(preferredSize)
}
func positionInCenter() {
guard let screen = NSScreen.main else { return }
let screenFrame = screen.visibleFrame
let panelFrame = frame
// Position in the center of the screen
let xPosition = (screenFrame.width - panelFrame.width) / 2 + screenFrame.minX
let yPosition = (screenFrame.height - panelFrame.height) / 2 + screenFrame.minY
setFrameOrigin(NSPoint(x: xPosition, y: yPosition))
}
private func setupScreenCaptureObserver() {
// Observe changes to hidePanelsFromScreenCapture setting
Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in
DispatchQueue.main.async {
self?.updateScreenCaptureVisibility()
}
}
}
private func updateScreenCaptureVisibility() {
let shouldHide = Defaults[.hidePanelsFromScreenCapture]
if shouldHide {
// Hide from screen capture and recording
self.sharingType = .none
print("🙈 ModelSelectionPanel: Hidden from screen capture and recordings")
} else {
// Allow normal screen capture
self.sharingType = .readOnly
print("👁️ ModelSelectionPanel: Visible in screen capture and recordings")
}
}
}
// MARK: - Model Selection View
struct ModelSelectionView: View {
@State private var selectedProvider: AIModelProvider = Defaults[.selectedAIProvider]
@State private var selectedModel: AIModel? = Defaults[.selectedAIModel]
@State private var enableThinking: Bool = Defaults[.enableThinkingMode]
// API Keys
@State private var geminiApiKey: String = Defaults[.geminiApiKey]
@State private var openaiApiKey: String = Defaults[.openaiApiKey]
@State private var claudeApiKey: String = Defaults[.claudeApiKey]
@State private var localEndpoint: String = Defaults[.localModelEndpoint]
@State private var showingApiKeyAlert = false
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("AI Model Selection")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.primary)
Text("Choose your preferred AI model and configuration")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button(action: closePanel) {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundColor(.secondary)
}
.buttonStyle(PlainButtonStyle())
.help("Close")
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
.background(Color.gray.opacity(0.05))
Divider()
// Content
ScrollView {
VStack(spacing: 24) {
// Provider Selection
VStack(alignment: .leading, spacing: 12) {
Text("AI Provider")
.font(.headline)
.foregroundColor(.primary)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 12) {
ForEach(AIModelProvider.allCases) { provider in
ProviderCard(
provider: provider,
isSelected: selectedProvider == provider,
onSelect: { selectedProvider = provider }
)
}
}
}
Divider()
// Model Selection
if !selectedProvider.supportedModels.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("\(selectedProvider.displayName) Models")
.font(.headline)
.foregroundColor(.primary)
VStack(spacing: 8) {
ForEach(selectedProvider.supportedModels) { model in
ModelRow(
model: model,
isSelected: selectedModel?.id == model.id,
onSelect: { selectedModel = model }
)
}
}
}
}
Divider()
// Thinking Mode Toggle
if selectedModel?.supportsThinking == true {
VStack(alignment: .leading, spacing: 12) {
Text("Reasoning Mode")
.font(.headline)
.foregroundColor(.primary)
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Enable Thinking Mode")
.font(.body)
.foregroundColor(.primary)
Text("Shows the model's reasoning process before the final answer")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle("", isOn: $enableThinking)
.toggleStyle(SwitchToggleStyle())
}
.padding(16)
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
}
}
Divider()
// API Configuration
VStack(alignment: .leading, spacing: 12) {
Text("API Configuration")
.font(.headline)
.foregroundColor(.primary)
ApiConfigurationSection(
provider: selectedProvider,
geminiApiKey: $geminiApiKey,
openaiApiKey: $openaiApiKey,
claudeApiKey: $claudeApiKey,
localEndpoint: $localEndpoint
)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 20)
}
Divider()
// Footer with Save/Cancel buttons
HStack {
Button("Cancel") {
closePanel()
}
.buttonStyle(PlainButtonStyle())
Spacer()
Button("Save Configuration") {
saveConfiguration()
}
.buttonStyle(.borderedProminent)
.disabled(!isConfigurationValid)
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
.background(ModelSelectionVisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
.cornerRadius(16)
.shadow(color: .black.opacity(0.3), radius: 20, x: 0, y: 10)
.onAppear {
loadCurrentConfiguration()
}
}
private var isConfigurationValid: Bool {
switch selectedProvider {
case .gemini:
return !geminiApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case .openai:
return !openaiApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case .claude:
return !claudeApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case .local:
return !localEndpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
private func loadCurrentConfiguration() {
selectedProvider = Defaults[.selectedAIProvider]
// Default to the latest Gemini 2.5 Flash if no model is selected
selectedModel = Defaults[.selectedAIModel] ?? AIModel(id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", supportsThinking: true)
enableThinking = Defaults[.enableThinkingMode]
geminiApiKey = Defaults[.geminiApiKey]
openaiApiKey = Defaults[.openaiApiKey]
claudeApiKey = Defaults[.claudeApiKey]
localEndpoint = Defaults[.localModelEndpoint]
}
private func saveConfiguration() {
Defaults[.selectedAIProvider] = selectedProvider
Defaults[.selectedAIModel] = selectedModel
Defaults[.enableThinkingMode] = enableThinking
Defaults[.geminiApiKey] = geminiApiKey
Defaults[.openaiApiKey] = openaiApiKey
Defaults[.claudeApiKey] = claudeApiKey
Defaults[.localModelEndpoint] = localEndpoint
closePanel()
// Notify that configuration changed
NotificationCenter.default.post(name: .aiModelConfigurationChanged, object: nil)
}
private func closePanel() {
if let window = NSApp.windows.first(where: { $0 is ModelSelectionPanel }) {
window.close()
}
}
}
// MARK: - Provider Card
struct ProviderCard: View {
let provider: AIModelProvider
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
VStack(spacing: 12) {
// Icon
ZStack {
Circle()
.fill(isSelected ? Color.blue : Color.gray.opacity(0.2))
.frame(width: 50, height: 50)
Image(systemName: iconForProvider(provider))
.font(.title2)
.foregroundColor(isSelected ? .white : .primary)
}
// Name and description
VStack(spacing: 4) {
Text(provider.displayName)
.font(.headline)
.foregroundColor(.primary)
Text(provider.description)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isSelected ? Color.blue.opacity(0.1) : Color.gray.opacity(0.05))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
)
.onTapGesture {
onSelect()
}
.animation(.easeInOut(duration: 0.2), value: isSelected)
}
private func iconForProvider(_ provider: AIModelProvider) -> String {
switch provider {
case .gemini: return "sparkles"
case .openai: return "brain.head.profile"
case .claude: return "doc.text"
case .local: return "server.rack"
}
}
}
// MARK: - Model Row
struct ModelRow: View {
let model: AIModel
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(model.name)
.font(.body)
.foregroundColor(.primary)
if model.supportsThinking {
Text("Supports reasoning mode")
.font(.caption)
.foregroundColor(.blue)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
.font(.title3)
} else {
Circle()
.stroke(Color.gray.opacity(0.5), lineWidth: 2)
.frame(width: 20, height: 20)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.blue.opacity(0.1) : Color.clear)
)
.onTapGesture {
onSelect()
}
.animation(.easeInOut(duration: 0.2), value: isSelected)
}
}
// MARK: - API Configuration Section
struct ApiConfigurationSection: View {
let provider: AIModelProvider
@Binding var geminiApiKey: String
@Binding var openaiApiKey: String
@Binding var claudeApiKey: String
@Binding var localEndpoint: String
var body: some View {
VStack(spacing: 12) {
switch provider {
case .gemini:
ApiKeyField(
title: "Gemini API Key",
placeholder: "Enter your Gemini API key",
value: $geminiApiKey,
helpText: "Get your API key from Google AI Studio"
)
case .openai:
ApiKeyField(
title: "OpenAI API Key",
placeholder: "Enter your OpenAI API key",
value: $openaiApiKey,
helpText: "Get your API key from OpenAI Platform"
)
case .claude:
ApiKeyField(
title: "Claude API Key",
placeholder: "Enter your Claude API key",
value: $claudeApiKey,
helpText: "Get your API key from Anthropic Console"
)
case .local:
ApiKeyField(
title: "Local Endpoint",
placeholder: "http://localhost:11434",
value: $localEndpoint,
helpText: "Ollama or compatible API endpoint",
isSecure: false
)
}
}
.padding(16)
.background(Color.gray.opacity(0.05))
.cornerRadius(12)
}
}
// MARK: - API Key Field
struct ApiKeyField: View {
let title: String
let placeholder: String
@Binding var value: String
let helpText: String
var isSecure: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
if isSecure {
SecureField(placeholder, text: $value)
.textFieldStyle(.roundedBorder)
} else {
TextField(placeholder, text: $value)
.textFieldStyle(.roundedBorder)
}
Text(helpText)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
// MARK: - Visual Effect View
struct ModelSelectionVisualEffectView: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = material
view.blendingMode = blendingMode
view.state = .active
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
// MARK: - Notification Extension
extension Notification.Name {
static let aiModelConfigurationChanged = Notification.Name("aiModelConfigurationChanged")
}

View file

@ -0,0 +1,272 @@
//
// ScreenAssistantPanel.swift
// DynamicIsland
//
// Created by Hariharan Mudaliar
import AppKit
import SwiftUI
import Defaults
import UniformTypeIdentifiers
// MARK: - Backward Compatibility Wrapper
class ScreenAssistantPanel: NSPanel {
init() {
super.init(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: true
)
// This is now a compatibility wrapper
// The actual functionality is handled by the new ChatPanels
setupWindow()
}
override var canBecomeKey: Bool {
return false
}
override var canBecomeMain: Bool {
return false
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { // ESC key
close()
} else {
super.keyDown(with: event)
}
}
private func setupWindow() {
backgroundColor = .clear
isOpaque = false
hasShadow = false
level = .floating
isMovableByWindowBackground = false
titlebarAppearsTransparent = true
titleVisibility = .hidden
isFloatingPanel = true
collectionBehavior = [
.canJoinAllSpaces,
.stationary,
.fullScreenAuxiliary
]
// Apply screen capture hiding setting
updateScreenCaptureVisibility()
setupScreenCaptureObserver()
// Set minimal size since this is just a wrapper
setContentSize(CGSize(width: 1, height: 1))
setFrameOrigin(CGPoint(x: -1000, y: -1000)) // Move offscreen
}
// Trigger the new panel system
override func makeKeyAndOrderFront(_ sender: Any?) {
ScreenAssistantManager.shared.showPanels()
// Don't actually show this wrapper panel
}
override func close() {
ScreenAssistantManager.shared.closePanels()
super.close()
}
private func setupScreenCaptureObserver() {
// Observe changes to hidePanelsFromScreenCapture setting
Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in
DispatchQueue.main.async {
self?.updateScreenCaptureVisibility()
}
}
}
private func updateScreenCaptureVisibility() {
let shouldHide = Defaults[.hidePanelsFromScreenCapture]
if shouldHide {
// Hide from screen capture and recording
self.sharingType = .none
print("🙈 ScreenAssistantPanel: Hidden from screen capture and recordings")
} else {
// Allow normal screen capture
self.sharingType = .readOnly
print("👁️ ScreenAssistantPanel: Visible in screen capture and recordings")
}
}
}
// Keep the old panel view for any existing references, but make it redirect
struct ScreenAssistantPanelView: View {
let onClose: () -> Void
var body: some View {
VStack {
Text("Screen Assistant")
.onAppear {
// Redirect to new panel system
ScreenAssistantManager.shared.showPanels()
onClose()
}
}
.frame(width: 1, height: 1)
}
}
// MARK: - Shared Components (moved to ChatPanels.swift for the new implementation)
// These are kept here for backward compatibility only
struct MarkdownText: View {
let content: String
var body: some View {
// Simple markdown parsing for now
Text(parseMarkdown(content))
.font(.system(size: 14))
.textSelection(.enabled)
}
private func parseMarkdown(_ text: String) -> AttributedString {
var attributedString = AttributedString(text)
// Simple bold parsing (**text**)
let boldPattern = #"\*\*(.*?)\*\*"#
if let regex = try? NSRegularExpression(pattern: boldPattern) {
let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
for match in matches.reversed() {
if let range = Range(match.range, in: text) {
let boldText = String(text[range]).replacingOccurrences(of: "**", with: "")
if let attrRange = Range(match.range, in: attributedString) {
var boldAttributedText = AttributedString(boldText)
boldAttributedText.font = .system(size: 14, weight: .bold)
attributedString.replaceSubrange(attrRange, with: boldAttributedText)
}
}
}
}
return attributedString
}
}
struct AttachedFileChip: View {
let file: ScreenAssistantFile
let onRemove: () -> Void
var body: some View {
HStack(spacing: 6) {
Image(systemName: file.type.iconName)
.foregroundColor(.blue)
.font(.system(size: 12))
Text(file.name)
.font(.system(size: 11))
.lineLimit(1)
.truncationMode(.middle)
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
.font(.system(size: 10))
}
.buttonStyle(PlainButtonStyle())
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.cornerRadius(6)
.frame(maxWidth: 120)
}
}
struct AddFilesButton: View {
@ObservedObject var screenAssistantManager = ScreenAssistantManager.shared
var body: some View {
Button(action: selectFiles) {
Image(systemName: "plus.circle.fill")
.foregroundColor(.blue)
.font(.system(size: 20))
}
.buttonStyle(PlainButtonStyle())
.help("Add files")
}
private func selectFiles() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = [.data, .image, .movie, .audio, .text, .pdf]
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
panel.beginSheetModal(for: window) { response in
if response == .OK {
screenAssistantManager.addFiles(panel.urls)
}
}
} else {
if panel.runModal() == .OK {
screenAssistantManager.addFiles(panel.urls)
}
}
}
}
struct RecordingButton: View {
@ObservedObject var screenAssistantManager = ScreenAssistantManager.shared
var body: some View {
Button(action: {
screenAssistantManager.toggleRecording()
}) {
ZStack {
Circle()
.fill(screenAssistantManager.isRecording ? Color.red : Color.blue.opacity(0.2))
.frame(width: 32, height: 32)
Image(systemName: screenAssistantManager.isRecording ? "stop.fill" : "mic.fill")
.foregroundColor(screenAssistantManager.isRecording ? .white : .blue)
.font(.system(size: 14))
}
}
.buttonStyle(PlainButtonStyle())
.help(screenAssistantManager.isRecording ? "Stop recording" : "Start recording")
.scaleEffect(screenAssistantManager.isRecording ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.2), value: screenAssistantManager.isRecording)
}
}
struct ApiKeyAlertView: View {
@State private var apiKey = ""
var body: some View {
VStack(spacing: 12) {
TextField("Enter your Gemini API Key", text: $apiKey)
.textFieldStyle(.roundedBorder)
HStack {
Button("Cancel") {
// Dialog will close automatically
}
Button("Save") {
Defaults[.geminiApiKey] = apiKey
}
.disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.frame(width: 300)
}
}
#Preview {
ScreenAssistantPanelView {
print("Close panel")
}
}

View file

@ -0,0 +1,190 @@
//
// ScreenshotSnippingTool.swift
// DynamicIsland
//
// Created by Assistant based on ScreenshotApp-main research
import AppKit
import SwiftUI
import Foundation
// MARK: - Simplified Screenshot Tool (Based on ScreenshotApp Research)
class ScreenshotSnippingTool: NSObject, ObservableObject {
static let shared = ScreenshotSnippingTool()
@Published var isSnipping = false
private var completion: ((URL) -> Void)?
// MARK: - Screenshot Types (Based on ScreenshotApp)
enum ScreenshotType {
case full
case window
case area
var processArguments: [String] {
switch self {
case .full:
return ["-c"] // -c = clipboard
case .window:
return ["-cw"] // -c = clipboard, -w = window selection
case .area:
return ["-cs"] // -c = clipboard, -s = area selection
}
}
var displayName: String {
switch self {
case .full: return "Full Screen"
case .window: return "Window"
case .area: return "Area"
}
}
var iconName: String {
switch self {
case .full: return "rectangle.dashed"
case .window: return "macwindow"
case .area: return "viewfinder.rectangular"
}
}
}
enum ScreenshotError: Error {
case captureFailed
case noImageInPasteboard
case saveFailed
}
override init() {
super.init()
}
// MARK: - Enhanced API (Based on ScreenshotApp Implementation)
func startSnipping(type: ScreenshotType = .area, completion: @escaping (URL) -> Void) {
guard !isSnipping else { return }
print("🖼️ ScreenshotTool: Starting \(type.displayName.lowercased()) screenshot using screencapture tool")
self.completion = completion
isSnipping = true
// Use the same approach as ScreenshotApp - direct screencapture command
takeScreenshot(type: type)
}
// MARK: - Convenience Methods for Different Types
func startAreaScreenshot(completion: @escaping (URL) -> Void) {
startSnipping(type: .area, completion: completion)
}
func startFullScreenshot(completion: @escaping (URL) -> Void) {
startSnipping(type: .full, completion: completion)
}
func startWindowScreenshot(completion: @escaping (URL) -> Void) {
startSnipping(type: .window, completion: completion)
}
// MARK: - ScreenshotApp-Style Implementation
private func takeScreenshot(type: ScreenshotType) {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
task.arguments = type.processArguments
do {
print("📸 ScreenshotTool: Running screencapture \(type.processArguments.joined(separator: " ")) command")
try task.run()
task.waitUntilExit()
// Process completed - check if successful
if task.terminationStatus == 0 {
print("✅ ScreenshotTool: screencapture completed successfully")
getImageFromPasteboard()
} else {
print("❌ ScreenshotTool: screencapture failed with status: \(task.terminationStatus)")
finishSnipping()
}
} catch {
print("❌ ScreenshotTool: Failed to run screencapture: \(error)")
finishSnipping()
}
}
// MARK: - Pasteboard Integration (ScreenshotApp Pattern)
private func getImageFromPasteboard() {
print("<EFBFBD> ScreenshotTool: Checking pasteboard for screenshot")
guard NSPasteboard.general.canReadItem(withDataConformingToTypes: NSImage.imageTypes) else {
print("❌ ScreenshotTool: No image data in pasteboard")
finishSnipping()
return
}
guard let image = NSImage(pasteboard: NSPasteboard.general) else {
print("❌ ScreenshotTool: Failed to create NSImage from pasteboard")
finishSnipping()
return
}
print("✅ ScreenshotTool: Got image from pasteboard: \(image.size)")
saveImageAndComplete(image: image)
}
// MARK: - Image Saving
private func saveImageAndComplete(image: NSImage) {
let filename = "screenshot_\(Int(Date().timeIntervalSince1970)).png"
let screenshotDir = ScreenAssistantManager.screenshotDataDirectory
// Ensure directory exists
if !FileManager.default.fileExists(atPath: screenshotDir.path) {
try? FileManager.default.createDirectory(at: screenshotDir, withIntermediateDirectories: true)
}
let screenshotURL = screenshotDir.appendingPathComponent(filename)
// Convert NSImage to PNG data
guard let imageData = image.tiffRepresentation,
let bitmapRep = NSBitmapImageRep(data: imageData),
let pngData = bitmapRep.representation(using: NSBitmapImageRep.FileType.png, properties: [:]) else {
print("❌ ScreenshotTool: Failed to convert image to PNG")
finishSnipping()
return
}
do {
try pngData.write(to: screenshotURL)
print("✅ ScreenshotTool: Screenshot saved to: \(screenshotURL.path)")
// Execute completion callback
let callback = self.completion
self.completion = nil
finishSnipping()
// Call completion on main thread
DispatchQueue.main.async {
callback?(screenshotURL)
}
} catch {
print("❌ ScreenshotTool: Failed to save image: \(error)")
finishSnipping()
}
}
// MARK: - State Management
private func finishSnipping() {
print("🔄 ScreenshotTool: Finishing snipping process")
DispatchQueue.main.async {
self.isSnipping = false
self.completion = nil
print("✅ ScreenshotTool: Snipping process completed")
}
}
func cancelSnipping() {
print("❌ ScreenshotTool: Snipping cancelled")
finishSnipping()
}
}

View file

@ -56,6 +56,9 @@ struct SettingsView: View {
NavigationLink(value: "Clipboard") {
Label("Clipboard", systemImage: "clipboard")
}
NavigationLink(value: "ScreenAssistant") {
Label("Screen Assistant", systemImage: "brain.head.profile")
}
NavigationLink(value: "ColorPicker") {
Label("Color Picker", systemImage: "eyedropper")
}
@ -102,6 +105,8 @@ struct SettingsView: View {
StatsSettings()
case "Clipboard":
ClipboardSettings()
case "ScreenAssistant":
ScreenAssistantSettings()
case "ColorPicker":
ColorPickerSettings()
case "Downloads":
@ -183,6 +188,7 @@ struct GeneralSettings: View {
NotificationCenter.default.post(name: Notification.Name.automaticallySwitchDisplayChanged, object: nil)
}
.disabled(showOnAllDisplays)
Defaults.Toggle("Hide panels from screenshots & screen recordings", key: .hidePanelsFromScreenCapture)
} header: {
Text("System features")
}
@ -1260,6 +1266,29 @@ struct Shortcuts: View {
.font(.caption)
}
Section {
HStack {
VStack(alignment: .leading) {
KeyboardShortcuts.Recorder("Screen Assistant:", name: .screenAssistantPanel)
.disabled(!enableShortcuts || !Defaults[.enableScreenAssistant])
if !Defaults[.enableScreenAssistant] {
Text("Screen Assistant feature is disabled")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 2)
}
}
Spacer()
}
} header: {
Text("AI Assistant")
} footer: {
Text("Opens the AI assistant panel for file analysis and conversation. Default is Cmd+Shift+A. Only works when screen assistant feature is enabled.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
HStack {
VStack(alignment: .leading) {
@ -1877,6 +1906,176 @@ struct ClipboardSettings: View {
}
}
struct ScreenAssistantSettings: View {
@ObservedObject var screenAssistantManager = ScreenAssistantManager.shared
@Default(.enableScreenAssistant) var enableScreenAssistant
@Default(.screenAssistantDisplayMode) var screenAssistantDisplayMode
@Default(.geminiApiKey) var geminiApiKey
@State private var apiKeyText = ""
@State private var showingApiKey = false
var body: some View {
Form {
Section {
Defaults.Toggle("Enable Screen Assistant", key: .enableScreenAssistant)
} header: {
Text("AI Assistant")
} footer: {
Text("AI-powered assistant that can analyze files, images, and provide conversational help. Use Cmd+Shift+A to quickly access the assistant.")
}
if enableScreenAssistant {
Section {
HStack {
Text("Gemini API Key")
Spacer()
if geminiApiKey.isEmpty {
Text("Not Set")
.foregroundColor(.red)
} else {
Text("••••••••")
.foregroundColor(.green)
}
Button(showingApiKey ? "Hide" : (geminiApiKey.isEmpty ? "Set" : "Change")) {
if showingApiKey {
showingApiKey = false
if !apiKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Defaults[.geminiApiKey] = apiKeyText
}
apiKeyText = ""
} else {
showingApiKey = true
apiKeyText = geminiApiKey
}
}
}
if showingApiKey {
VStack(alignment: .leading, spacing: 8) {
SecureField("Enter your Gemini API Key", text: $apiKeyText)
.textFieldStyle(.roundedBorder)
Text("Get your free API key from Google AI Studio")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Button("Open Google AI Studio") {
NSWorkspace.shared.open(URL(string: "https://aistudio.google.com/app/apikey")!)
}
.buttonStyle(.link)
Spacer()
Button("Save") {
Defaults[.geminiApiKey] = apiKeyText
showingApiKey = false
apiKeyText = ""
}
.disabled(apiKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
HStack {
Text("Display Mode")
Spacer()
Picker("Display Mode", selection: $screenAssistantDisplayMode) {
ForEach(ScreenAssistantDisplayMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.menu)
.frame(width: 100)
}
HStack {
Text("Attached Files")
Spacer()
Text("\(screenAssistantManager.attachedFiles.count)")
.foregroundColor(.secondary)
}
HStack {
Text("Recording Status")
Spacer()
Text(screenAssistantManager.isRecording ? "Recording" : "Ready")
.foregroundColor(screenAssistantManager.isRecording ? .red : .secondary)
}
} header: {
Text("Configuration")
} footer: {
switch screenAssistantDisplayMode {
case .popover:
Text("Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch.")
case .panel:
Text("Panel mode shows the assistant in a floating window near the notch. Popover mode shows the assistant as a dropdown attached to the AI button.")
}
}
Section {
Button("Clear All Files") {
screenAssistantManager.clearAllFiles()
}
.foregroundColor(.red)
.disabled(screenAssistantManager.attachedFiles.isEmpty)
} header: {
Text("Actions")
} footer: {
Text("Clear all files removes all attached files and audio recordings. This action is permanent.")
}
if !screenAssistantManager.attachedFiles.isEmpty {
Section {
ForEach(screenAssistantManager.attachedFiles) { file in
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: file.type.iconName)
.foregroundColor(.blue)
.frame(width: 16)
Text(file.type.displayName)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(timeAgoString(from: file.timestamp))
.font(.caption2)
.foregroundColor(.secondary)
}
Text(file.name)
.font(.system(.body, design: .monospaced))
.lineLimit(2)
}
.padding(.vertical, 2)
}
} header: {
Text("Attached Files")
}
}
}
}
.formStyle(.grouped)
.navigationTitle("Screen Assistant")
}
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 {
return "Just now"
} else if interval < 3600 {
let minutes = Int(interval / 60)
return "\(minutes)m ago"
} else if interval < 86400 {
let hours = Int(interval / 3600)
return "\(hours)h ago"
} else {
let days = Int(interval / 86400)
return "\(days)d ago"
}
}
}
struct ColorPickerSettings: View {
@ObservedObject var colorPickerManager = ColorPickerManager.shared
@Default(.enableColorPickerFeature) var enableColorPickerFeature

View file

@ -64,6 +64,10 @@ class SettingsWindowController: NSWindowController {
// Handle window closing
window.delegate = self
// Apply screen capture hiding setting
updateScreenCaptureVisibility()
setupScreenCaptureObserver()
}
func showWindow() {
@ -110,6 +114,29 @@ class SettingsWindowController: NSWindowController {
// Set app back to accessory mode immediately
NSApp.setActivationPolicy(.accessory)
}
private func setupScreenCaptureObserver() {
// Observe changes to hidePanelsFromScreenCapture setting
Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in
DispatchQueue.main.async {
self?.updateScreenCaptureVisibility()
}
}
}
private func updateScreenCaptureVisibility() {
let shouldHide = Defaults[.hidePanelsFromScreenCapture]
if shouldHide {
// Hide from screen capture and recording
window?.sharingType = .none
print("🙈 SettingsWindow: Hidden from screen capture and recordings")
} else {
// Allow normal screen capture
window?.sharingType = .readOnly
print("👁️ SettingsWindow: Visible in screen capture and recordings")
}
}
}
extension SettingsWindowController: NSWindowDelegate {

View file

@ -7,6 +7,7 @@
import AppKit
import SwiftUI
import Defaults
class StatsPanel: NSPanel {
@ -51,6 +52,10 @@ class StatsPanel: NSPanel {
.fullScreenAuxiliary // Float above full-screen apps
]
// Apply screen capture hiding setting
updateScreenCaptureVisibility()
setupScreenCaptureObserver()
// Accept mouse moved events for proper hover behavior
acceptsMouseMovedEvents = true
}
@ -99,4 +104,27 @@ class StatsPanel: NSPanel {
setFrameOrigin(NSPoint(x: xPosition, y: yPosition))
}
private func setupScreenCaptureObserver() {
// Observe changes to hidePanelsFromScreenCapture setting
Defaults.observe(.hidePanelsFromScreenCapture) { [weak self] change in
DispatchQueue.main.async {
self?.updateScreenCaptureVisibility()
}
}
}
private func updateScreenCaptureVisibility() {
let shouldHide = Defaults[.hidePanelsFromScreenCapture]
if shouldHide {
// Hide from screen capture and recording
self.sharingType = .none
print("🙈 StatsPanel: Hidden from screen capture and recordings")
} else {
// Allow normal screen capture
self.sharingType = .readOnly
print("👁️ StatsPanel: Visible in screen capture and recordings")
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
//
// ScreenAssistantPanelManager.swift
// DynamicIsland
//
// Created by Hariharan Mudaliar
import AppKit
import SwiftUI
import Defaults
class ScreenAssistantPanelManager: ObservableObject {
static let shared = ScreenAssistantPanelManager()
// Backward compatibility wrapper - delegates to the new ScreenAssistantManager
private var screenAssistantPanel: ScreenAssistantPanel?
private init() {}
func showScreenAssistantPanel() {
hideScreenAssistantPanel() // Close any existing panel
// Use the new dual-panel system
ScreenAssistantManager.shared.showPanels()
// Create a dummy panel for backward compatibility
let panel = ScreenAssistantPanel()
self.screenAssistantPanel = panel
// The actual panels are managed by ScreenAssistantManager
// This is just for compatibility with existing code
print("ScreenAssistant: New dual-panel system activated")
}
func hideScreenAssistantPanel() {
// Close the new panel system
ScreenAssistantManager.shared.closePanels()
// Clean up compatibility panel
screenAssistantPanel?.close()
screenAssistantPanel = nil
print("ScreenAssistant: Panels hidden")
}
func toggleScreenAssistantPanel() {
if ScreenAssistantManager.shared.arePanelsVisible() {
hideScreenAssistantPanel()
} else {
showScreenAssistantPanel()
}
}
var isPanelVisible: Bool {
return ScreenAssistantManager.shared.arePanelsVisible()
}
}

View file

@ -49,6 +49,25 @@ enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable
}
}
enum ScreenAssistantDisplayMode: String, CaseIterable, Codable, Defaults.Serializable {
case popover = "popover" // Traditional popover attached to button
case panel = "panel" // Floating panel near notch
var displayName: String {
switch self {
case .popover: return "Popover"
case .panel: return "Panel"
}
}
var description: String {
switch self {
case .popover: return "Shows screen assistant as a dropdown attached to the AI button"
case .panel: return "Shows screen assistant in a floating panel near the notch"
}
}
}
enum ColorPickerDisplayMode: String, CaseIterable, Codable, Defaults.Serializable {
case popover = "popover" // Traditional popover attached to button
case panel = "panel" // Floating panel near notch
@ -105,12 +124,86 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable
var id: String { self.rawValue }
}
// AI Model types for screen assistant
enum AIModelProvider: String, CaseIterable, Identifiable, Defaults.Serializable {
case gemini = "Gemini"
case openai = "OpenAI GPT"
case claude = "Claude"
case local = "Local Model"
var id: String { self.rawValue }
var displayName: String {
return self.rawValue
}
var description: String {
switch self {
case .gemini: return "Google's Gemini AI with multimodal capabilities"
case .openai: return "OpenAI's GPT models with advanced reasoning"
case .claude: return "Anthropic's Claude with strong analytical skills"
case .local: return "Local AI model (Ollama or similar)"
}
}
var supportedModels: [AIModel] {
switch self {
case .gemini:
return [
// Gemini 2.5 Models (Latest)
AIModel(id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", supportsThinking: true),
AIModel(id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", supportsThinking: true),
AIModel(id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash-Lite", supportsThinking: false),
AIModel(id: "gemini-2.5-flash-live", name: "Gemini 2.5 Flash Live", supportsThinking: false),
AIModel(id: "gemini-2.5-flash-native-audio", name: "Gemini 2.5 Flash Native Audio", supportsThinking: true),
// Gemini 2.0 Models
AIModel(id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", supportsThinking: false),
AIModel(id: "gemini-2.0-flash-lite", name: "Gemini 2.0 Flash-Lite", supportsThinking: false),
AIModel(id: "gemini-2.0-flash-live", name: "Gemini 2.0 Flash Live", supportsThinking: false),
// Legacy 1.5 Models (for compatibility)
AIModel(id: "gemini-1.5-pro", name: "Gemini 1.5 Pro", supportsThinking: false),
AIModel(id: "gemini-1.5-flash", name: "Gemini 1.5 Flash", supportsThinking: false)
]
case .openai:
return [
AIModel(id: "gpt-4o", name: "GPT-4o", supportsThinking: false),
AIModel(id: "gpt-4o-mini", name: "GPT-4o Mini", supportsThinking: false),
AIModel(id: "o1-preview", name: "o1 Preview", supportsThinking: true),
AIModel(id: "o1-mini", name: "o1 Mini", supportsThinking: true)
]
case .claude:
return [
AIModel(id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", supportsThinking: false),
AIModel(id: "claude-3-haiku", name: "Claude 3 Haiku", supportsThinking: false)
]
case .local:
return [
AIModel(id: "llama3.2", name: "Llama 3.2", supportsThinking: false),
AIModel(id: "qwen2.5", name: "Qwen 2.5", supportsThinking: false)
]
}
}
}
struct AIModel: Codable, Identifiable, Defaults.Serializable {
let id: String
let name: String
let supportsThinking: Bool
var displayName: String {
return name + (supportsThinking ? " (Thinking)" : "")
}
}
extension Defaults.Keys {
// MARK: General
static let menubarIcon = Key<Bool>("menubarIcon", default: true)
static let showOnAllDisplays = Key<Bool>("showOnAllDisplays", default: false)
static let automaticallySwitchDisplay = Key<Bool>("automaticallySwitchDisplay", default: true)
static let releaseName = Key<String>("releaseName", default: "alpha 0.0.1")
static let hidePanelsFromScreenCapture = Key<Bool>("hideFromScreenCapture", default: false)
// MARK: Behavior
static let minimumHoverDuration = Key<TimeInterval>("minimumHoverDuration", default: 0.3)
@ -228,6 +321,17 @@ extension Defaults.Keys {
static let showClipboardIcon = Key<Bool>("showClipboardIcon", default: true)
static let clipboardDisplayMode = Key<ClipboardDisplayMode>("clipboardDisplayMode", default: .panel)
// MARK: Screen Assistant Feature
static let enableScreenAssistant = Key<Bool>("enableScreenAssistant", default: true)
static let screenAssistantDisplayMode = Key<ScreenAssistantDisplayMode>("screenAssistantDisplayMode", default: .panel)
static let geminiApiKey = Key<String>("geminiApiKey", default: "")
static let openaiApiKey = Key<String>("openaiApiKey", default: "")
static let claudeApiKey = Key<String>("claudeApiKey", default: "")
static let selectedAIProvider = Key<AIModelProvider>("selectedAIProvider", default: .gemini)
static let selectedAIModel = Key<AIModel?>("selectedAIModel", default: nil)
static let enableThinkingMode = Key<Bool>("enableThinkingMode", default: false)
static let localModelEndpoint = Key<String>("localModelEndpoint", default: "http://localhost:11434")
// MARK: Keyboard Shortcuts
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)