Skip to content

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.

Terminal window
# Activity Monitor told the story
App Memory Size
Cursor 450 MB 280 MB
VS Code + Copilot 520 MB 310 MB
Continue 380 MB 250 MB
# And they took forever to start
Cold start: 3-5 seconds
Warm start: 1-2 seconds

My 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 MB
Memory Usage 200-500 MB 50-100 MB
Startup Time 2-5 seconds Instant
System Access Limited Full
Battery Impact High Low

The numbers don’t lie. Native wins on every metric that matters for daily use.

How I Started

I created a new Xcode project:

Terminal window
# File > New > Project > macOS > App
# Interface: SwiftUI
# Language: Swift
# Storage: None (I'll add my own)

The generated app structure:

AICodingAssistantApp.swift
import SwiftUI
@main
struct 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:

ChatView.swift
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:

MessageListView.swift (first attempt)
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:

MessageListView.swift (fixed)
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.

KeychainManager.swift
import Security
import 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:

ModelConfig.swift
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:

StreamingClient.swift
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:

StreamingClient.swift (fixed)
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.

MenuBar.swift
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:

GlobalShortcut.swift
import Carbon
import 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

DragDrop.swift
import SwiftUI
import 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:

  1. Cross-platform - Write once, run everywhere
  2. Web skills - Most devs know React, not SwiftUI
  3. Ecosystem - npm has everything

But native development has real advantages:

  1. Performance - 5x smaller, 5x less memory
  2. Integration - Keychain, shortcuts, notifications
  3. 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 - ConversationStore

The 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