How to Build a Mac-Native AI Coding Assistant with SwiftUI
The Problem
I tried using popular AI coding assistants on my Mac. Every one of them was an Electron app.
# Activity Monitor told the storyApp Memory SizeCursor 450 MB 280 MBVS Code + Copilot 520 MB 310 MBContinue 380 MB 250 MB
# And they took forever to startCold start: 3-5 secondsWarm start: 1-2 secondsMy MacBook fans spun up. Battery drained. All for a chat interface.
I thought: “This is just a text box and a message list. Why does it need 500 MB of RAM?”
The answer: Electron. Every Electron app bundles an entire Chromium browser and Node.js runtime. That’s the price of cross-platform convenience.
But I only use Mac. I don’t need cross-platform. I need native.
The Native Alternative
I decided to build a Mac-native AI coding assistant using SwiftUI. Here’s what I found:
Aspect Electron (Web) SwiftUI (Native)App Size 150-300 MB 5-10 MBMemory Usage 200-500 MB 50-100 MBStartup Time 2-5 seconds InstantSystem Access Limited FullBattery Impact High LowThe numbers don’t lie. Native wins on every metric that matters for daily use.
How I Started
I created a new Xcode project:
# File > New > Project > macOS > App# Interface: SwiftUI# Language: Swift# Storage: None (I'll add my own)The generated app structure:
import SwiftUI
@mainstruct AICodingAssistantApp: App { var body: some Scene { WindowGroup { ContentView() .frame(minWidth: 800, minHeight: 600) } .windowStyle(.hiddenTitleBar) .commands { CommandGroup(replacing: .newItem) { } } }}I ran it. Instant startup. 12 MB memory. This was promising.
The Chat Interface
An AI coding assistant needs a chat interface. I built it with SwiftUI’s declarative syntax:
import SwiftUI
struct ChatView: View { @StateObject private var viewModel: ChatViewModel @FocusState private var isInputFocused: Bool
var body: some View { VStack(spacing: 0) { // Message list (top 80%) MessageListView(messages: viewModel.messages) .frame(maxHeight: .infinity)
Divider()
// Input bar (bottom) InputBar( text: $viewModel.inputText, onSend: viewModel.sendMessage, isFocused: $isInputFocused ) .padding() } }}The key insight: SwiftUI uses @StateObject for view models. This keeps the conversation state alive across view updates.
Message List with Streaming
The hard part was streaming AI responses. Users want to see text appear gradually, not wait for the entire response.
I tried this first:
struct MessageListView: View { let messages: [Message]
var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 12) { ForEach(messages) { message in MessageBubble(message: message) } } .padding() } }}This worked for static messages but not streaming. The issue: SwiftUI redraws the entire list on every update. Streaming means dozens of updates per second.
I fixed it with id stability:
struct MessageListView: View { let messages: [Message]
var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 12) { ForEach(messages) { message in MessageBubble(message: message) .id(message.id) // Stable ID prevents flicker } } .padding() } .onChange(of: messages.count) { _ in // Auto-scroll to newest message if let last = messages.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } } }}Secure API Key Storage
I didn’t want to store API keys in plain text. macOS has Keychain for this.
import Securityimport Foundation
class KeychainManager { static let shared = KeychainManager()
private let service = "com.myapp.aicodingassistant"
func saveAPIKey(_ key: String, for provider: String) throws { // Delete existing first (Keychain doesn't update) try? deleteAPIKey(for: provider)
let data = key.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: provider, kSecValueData as String: data ]
let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.saveFailed(status) } }
func retrieveAPIKey(for provider: String) throws -> String { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: provider, kSecReturnData as String: true ]
var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data, let key = String(data: data, encoding: .utf8) else { throw KeychainError.retrieveFailed(status) }
return key }
func deleteAPIKey(for provider: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: provider ]
let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.deleteFailed(status) } }}
enum KeychainError: Error { case saveFailed(OSStatus) case retrieveFailed(OSStatus) case deleteFailed(OSStatus)}Now users enter their API key once, and it’s stored securely in macOS Keychain.
Multiple AI Providers
Unlike Codex (OpenAI only), I wanted to support multiple providers:
import Foundation
struct ModelConfig: Codable, Identifiable { let id: UUID let name: String let provider: AIProvider let modelIdentifier: String let maxTokens: Int
enum AIProvider: String, Codable, CaseIterable { case openai = "OpenAI" case anthropic = "Anthropic" case openrouter = "OpenRouter" case local = "Local (Ollama)"
var baseURL: String { switch self { case .openai: return "https://api.openai.com/v1" case .anthropic: return "https://api.anthropic.com/v1" case .openrouter: return "https://openrouter.ai/api/v1" case .local: return "http://localhost:11434/v1" } } }}This lets users choose between GPT-4, Claude, or even local models via Ollama.
Streaming Implementation
The trickiest part was implementing Server-Sent Events (SSE) for streaming responses.
I tried URLSession first:
import Foundation
class StreamingClient { func streamCompletion( request: ChatRequest, onChunk: @escaping (String) -> Void, onComplete: @escaping () -> Void, onError: @escaping (Error) -> Void ) { var urlRequest = URLRequest(url: request.url) urlRequest.httpMethod = "POST" urlRequest.httpBody = request.body urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept")
let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in // This doesn't work - dataTask waits for complete response } task.resume() }}This didn’t work. dataTask waits for the entire response. I needed a streaming approach.
The fix: use URLSessionDataDelegate:
import Foundation
class StreamingClient: NSObject, URLSessionDataDelegate { private var accumulatedData = Data() private var onChunk: ((String) -> Void)? private var onComplete: (() -> Void)? private var onError: ((Error) -> Void)?
func streamCompletion( url: URL, body: Data, onChunk: @escaping (String) -> Void, onComplete: @escaping () -> Void, onError: @escaping (Error) -> Void ) { self.onChunk = onChunk self.onComplete = onComplete self.onError = onError
var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = body request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) let task = session.dataTask(with: request) task.resume() }
// Called as data arrives func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { accumulatedData.append(data) processBuffer() }
// Called when complete func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error = error { onError?(error) } else { onComplete?() } }
private func processBuffer() { // Parse SSE format: "data: {...}\n\n" while let separator = accumulatedData.range(of: "\n\n".data(using: .utf8)!) { let chunkData = accumulatedData.subdata(in: 0..<separator.lowerBound) accumulatedData.removeSubrange(0..<separator.upperBound)
if let chunkString = String(data: chunkData, encoding: .utf8) { parseSSEChunk(chunkString) } } }
private func parseSSEChunk(_ chunk: String) { for line in chunk.split(separator: "\n") { if line.hasPrefix("data: ") { let json = String(line.dropFirst(6)) if json == "[DONE]" { continue }
if let data = json.data(using: .utf8), let response = try? JSONDecoder().decode(StreamingResponse.self, from: data), let content = response.choices.first?.delta.content { onChunk?(content) } } } }}Now text streams in character by character, just like ChatGPT’s web interface.
Mac-Native Features
The real advantage of SwiftUI is deep macOS integration.
Menu Bar Quick Actions
import SwiftUI
struct MenuBarCommands: Commands { var body: some Commands { CommandGroup(after: .newItem) { Button("New Conversation") { NotificationCenter.default.post(name: .newConversation, object: nil) } .keyboardShortcut("n", modifiers: .command)
Divider()
Button("Clear API Keys") { // Keychain clear action } }
CommandGroup(replacing: .help) { Button("Documentation") { NSWorkspace.shared.open(URL(string: "https://docs.example.com")!) } } }}Global Keyboard Shortcuts
I wanted users to trigger the app from anywhere:
import Carbonimport AppKit
class GlobalShortcutManager { private var eventHandler: EventHandlerRef?
func registerGlobalShortcut(keyCode: Int, modifiers: UInt32, action: @escaping () -> Void) { var eventType = EventTypeSpec( eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed) )
let callback: EventHandlerUPP = { _, event, userData -> OSStatus in guard let userData = userData else return OSStatus(eventNotHandledErr) }
// Get hotkey ID from event var hotKeyID = EventHotKeyID() let status = GetEventParameter( event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout<EventHotKeyID>.size, nil, &hotKeyID )
if status == noErr { // Call action on main thread DispatchQueue.main.async { action() } }
return noErr }
InstallEventHandler( GetEventDispatcherTarget(), callback, 1, &eventType, nil, &eventHandler )
// Register the hotkey var hotKeyID = EventHotKeyID(signature: OSType(0x1234), id: 1) var hotKeyRef: EventHotKeyRef?
RegisterEventHotKey( UInt32(keyCode), modifiers, hotKeyID, GetEventDispatcherTarget(), 0, &hotKeyRef ) }}This lets users press Cmd+Shift+A anywhere to bring my app to front.
Drag and Drop
import SwiftUIimport UniformTypeIdentifiers
struct FileDropView: View { @State private var droppedFiles: [URL] = []
var body: some View { VStack { if droppedFiles.isEmpty { Text("Drop files here for context") .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.1)) } else { List(droppedFiles, id: \.self) { url in Text(url.lastPathComponent) } } } .onDrop(of: [.fileURL], isTargeted: nil) { providers in for provider in providers { _ = provider.loadObject(ofClass: URL.self) { url, _ in if let url = url { DispatchQueue.main.async { droppedFiles.append(url) } } } } return true } }}Now users drag source files into the app for context.
What I Learned
After building this, I understand why Electron dominates:
- Cross-platform - Write once, run everywhere
- Web skills - Most devs know React, not SwiftUI
- Ecosystem - npm has everything
But native development has real advantages:
- Performance - 5x smaller, 5x less memory
- Integration - Keychain, shortcuts, notifications
- Trust - Users trust App Store apps more than random downloads
When to Choose Native vs Electron
Choose SwiftUI (Native) when:
- You target macOS only
- Performance matters (daily driver app)
- You need system integration
- Battery life is a concern
- App Store distribution helps your users
Choose Electron when:
- You need Windows + Linux support
- Your team knows web tech
- Quick prototyping matters more than polish
- You don’t need deep system access
The Architecture
Here’s the final architecture I ended up with:
+------------------+ +-----------------+ +------------------+| SwiftUI Views | --> | View Models | --> | Services |+------------------+ +-----------------+ +------------------+ | | | v v v - ChatView - ChatViewModel - StreamingClient - MessageListView - SettingsViewModel - KeychainManager - InputBar - ModelsViewModel - ModelProviderFactory - SettingsView - ConversationStoreThe separation keeps things testable. View models handle business logic. Services handle external communication.
Summary
In this post, I showed how to build a Mac-native AI coding assistant with SwiftUI. The key points: native apps use 5x less memory, start instantly, and integrate deeply with macOS features like Keychain, global shortcuts, and drag-and-drop. The tradeoff is learning SwiftUI instead of React—but for Mac-only apps, the performance and user experience gains are worth it.
If you’re frustrated by slow, memory-hungry Electron AI apps, building native is a viable alternative. SwiftUI makes it approachable even if you’re new to Apple development.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments