How to Build a Mac-Native AI Coding Assistant with SwiftUI
The Problem
I wanted an AI coding assistant that felt native on my Mac. Every desktop app I tried had the same issues: slow startup, high memory usage, and poor integration with macOS. They were all Electron apps wrapped in a thin native shell.
When I found NeoCode on Reddit—a Mac-native alternative built with SwiftUI—I realized there was a better approach. The author had built something that felt like it belonged on macOS, with native performance and proper system integration.
This post documents what I learned about building a Mac-native AI coding assistant with SwiftUI, from architecture decisions to implementation patterns.
Why Native Matters
The difference between native and Electron is stark:
Aspect SwiftUI (Native) Electron (Web-based)─────────────────────────────────────────────────────────────App Size ~5-10 MB ~150-300 MBMemory Usage 50-100 MB 200-500 MBStartup Time Instant 2-5 secondsSystem Integration Full LimitedBundle Size Small Large (Chromium)Numbers tell part of the story. The real difference shows in daily use. Native apps respond instantly. They respect keyboard shortcuts I’ve configured in System Settings. They show up in the right places—menu bar, Dock, Spaces. They don’t fight the operating system.
Project Architecture
Core Components
A Mac AI coding assistant needs several key pieces:
┌─────────────────────────────────────────────────────────────┐│ SwiftUI App │├─────────────┬─────────────┬─────────────┬──────────────────┤│ Chat UI │ File Tree │ Code View │ Settings Panel ││ (SwiftUI) │ (SwiftUI) │ (AppKit+) │ (SwiftUI) │├─────────────┴─────────────┴─────────────┴──────────────────┤│ View Models ││ (Combine + ObservableObject) │├────────────────────────────────────────────────────────────┤│ Services Layer │├─────────────┬─────────────┬─────────────┬──────────────────┤│ AI Client │ Keychain │ Network │ File Manager ││ (Streaming)│ (Security) │ (URLSession)│ (FileManager) │└─────────────┴─────────────┴─────────────┴──────────────────┘App Entry Point
The entry point sets up the main window and hides the default “New Item” menu command that macOS adds:
import SwiftUI
@mainstruct AICodingAssistantApp: App { var body: some Scene { WindowGroup { ContentView() .frame(minWidth: 800, minHeight: 600) } .windowStyle(.hiddenTitleBar) .commands { // Remove File > New menu item CommandGroup(replacing: .newItem) { } } }}I went with .hiddenTitleBar style to get more vertical space for the chat interface. The title bar area becomes useful content area instead of wasted space.
Chat Interface Pattern
The chat interface is the core of the app. I built it as a vertical stack with messages on top and input at the bottom:
import SwiftUI
struct ChatView: View { @StateObject private var viewModel: ChatViewModel @FocusState private var isInputFocused: Bool
var body: some View { VStack(spacing: 0) { // Messages scroll from bottom MessageListView(messages: viewModel.messages) .frame(maxHeight: .infinity)
// Divider Divider()
// Input area stays at bottom InputBar( text: $viewModel.inputText, onSend: viewModel.sendMessage, isFocused: $isInputFocused, isLoading: viewModel.isLoading ) .padding() } .onAppear { isInputFocused = true } }}The @FocusState property wrapper handles keyboard focus. When the view appears, the input field gets focus automatically, so I can start typing immediately.
Message List with Scrolling
The message list needs to scroll to the bottom when new messages arrive:
import SwiftUI
struct MessageListView: View { let messages: [Message] @Namespace private var bottomAnchor
var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 12) { ForEach(messages) { message in MessageBubble(message: message) .id(message.id) }
// Invisible anchor at bottom Color.clear .frame(height: 1) .id("bottom") } .padding() } .onChange(of: messages.count) { _ in withAnimation { proxy.scrollTo("bottom", anchor: .bottom) } } } }}The ScrollViewReader gives me programmatic control over scroll position. When the message count changes, it scrolls to the bottom anchor.
Input Bar with Keyboard Shortcuts
The input bar handles text entry and sends on Return (or Cmd+Return for newline):
import SwiftUI
struct InputBar: View { @Binding var text: String let onSend: () -> Void let isFocused: FocusState<Bool>.Binding let isLoading: Bool
var body: some View { HStack(alignment: .bottom, spacing: 12) { TextEditor(text: $text) .focused(isFocused) .frame(minHeight: 36, maxHeight: 150) .padding(8) .background(Color.textEditorBackground) .cornerRadius(8) .onSubmit { if !text.isEmpty { onSend() } }
Button(action: onSend) { if isLoading { ProgressView() .frame(width: 24, height: 24) } else { Image(systemName: "paperplane.fill") } } .buttonStyle(.borderedProminent) .disabled(text.isEmpty || isLoading) } }}Streaming AI Responses
The most important feature is streaming responses. AI models take time to generate text, and showing responses as they arrive feels responsive:
import Foundation
class AIClient { func streamCompletion( messages: [ChatMessage], onToken: @escaping (String) -> Void, onComplete: @escaping () -> Void, onError: @escaping (Error) -> Void ) { var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [ "model": "gpt-4", "messages": messages.map { ["role": $0.role, "content": $0.content] }, "stream": true ] request.httpBody = try? JSONSerialization.data(withJSONObject: body)
let task = URLSession.shared.dataTask(with: request) { data, response, error in // Handle SSE streaming if let data = data { self.parseSSE(data: data, onToken: onToken, onComplete: onComplete) } if let error = error { onError(error) } } task.resume() }
private func parseSSE(data: Data, onToken: (String) -> Void, onComplete: () -> Void) { guard let text = String(data: data, encoding: .utf8) else { return }
for line in text.components(separatedBy: "\n") { if line.hasPrefix("data: ") { let jsonStr = String(line.dropFirst(6)) if jsonStr == "[DONE]" { onComplete() return } // Parse JSON and extract token if let jsonData = jsonStr.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], let choices = json["choices"] as? [[String: Any]], let delta = choices.first?["delta"] as? [String: Any], let content = delta["content"] as? String { onToken(content) } } } }}The key is parsing Server-Sent Events (SSE) format. Each line starting with data: contains a JSON object with the token.
View Model with Combine
I used Combine to handle the async streaming updates:
import SwiftUIimport Combine
class ChatViewModel: ObservableObject { @Published var messages: [Message] = [] @Published var inputText: String = "" @Published var isLoading: Bool = false
private let client = AIClient() private var cancellables = Set<AnyCancellable>()
func sendMessage() { let userMessage = Message(role: .user, content: inputText) messages.append(userMessage) inputText = "" isLoading = true
// Create placeholder for assistant response let assistantMessage = Message(role: .assistant, content: "") messages.append(assistantMessage) let assistantIndex = messages.count - 1
client.streamCompletion( messages: messages.map { ChatMessage(role: $0.role.rawValue, content: $0.content) }, onToken: { [weak self] token in DispatchQueue.main.async { self?.messages[assistantIndex].content += token } }, onComplete: { [weak self] in DispatchQueue.main.async { self?.isLoading = false } }, onError: { [weak self] error in DispatchQueue.main.async { self?.isLoading = false self?.messages[assistantIndex].content = "Error: \(error.localizedDescription)" } } ) }}The @Published properties automatically update the SwiftUI views when they change. Combine’s ObservableObject protocol makes this work.
Secure API Key Storage
Storing API keys in plain text is a bad idea. I use the macOS Keychain through the Security framework:
import Securityimport Foundation
enum KeychainError: Error { case saveFailed case retrieveFailed case deleteFailed case duplicateEntry}
class KeychainManager { static let shared = KeychainManager()
private init() {}
func saveAPIKey(_ key: String, for service: String) throws { // Check if key already exists if let _ = try? retrieveAPIKey(for: service) { try updateAPIKey(key, for: service) return }
let data = key.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked ]
let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.saveFailed } }
func retrieveAPIKey(for service: String) throws -> String { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ]
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 }
return key }
func updateAPIKey(_ key: String, for service: String) throws { let data = key.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service ] let attributes: [String: Any] = [ kSecValueData as String: data ]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) guard status == errSecSuccess else { throw KeychainError.saveFailed } }
func deleteAPIKey(for service: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service ]
let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.deleteFailed } }}The key is using kSecAttrAccessibleWhenUnlocked so the key is only accessible when the device is unlocked. This prevents attackers from extracting keys from a stolen Mac.
Model Configuration
I wanted to support multiple AI providers, not just one. Here’s my configuration model:
import Foundation
struct ModelConfig: Codable, Identifiable { let id: UUID let name: String let provider: AIProvider let apiKeyRef: String // Keychain reference, not the actual key let baseURL: String let defaultModel: String
enum AIProvider: String, Codable, CaseIterable { case openai = "OpenAI" case anthropic = "Anthropic" case openrouter = "OpenRouter" case local = "Local"
var defaultBaseURL: 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" } }
var defaultModel: String { switch self { case .openai: return "gpt-4" case .anthropic: return "claude-3-opus-20240229" case .openrouter: return "anthropic/claude-3-opus" case .local: return "llama3" } } }}The apiKeyRef stores the service name used to look up the key in Keychain, not the key itself.
Mac-Native Features
SwiftUI gives access to macOS-specific features that web apps can’t match.
Keyboard Shortcuts
Global keyboard shortcuts let users trigger actions from anywhere:
import SwiftUIimport KeyboardShortcuts
extension KeyboardShortcuts.Name { static let newConversation = Self("newConversation", default: .init(.n, modifiers: .command)) static let sendMessage = Self("sendMessage", default: .init(.return, modifiers: .command)) static let focusInput = Self("focusInput", default: .init(.i, modifiers: .command))}
// In ContentView.keyboardShortcut(.newConversation) { viewModel.startNewConversation()}I used the KeyboardShortcuts library for global shortcuts that work even when the app is in the background.
Menu Bar Quick Actions
A menu bar icon lets users quickly start a new conversation:
import SwiftUI
class MenuBarController: ObservableObject { private var statusItem: NSStatusItem?
func setup() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button { button.image = NSImage(systemSymbolName: "bubble.left.and.bubble.right", accessibilityDescription: "AI Assistant") button.image?.isTemplate = true }
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "New Conversation", action: #selector(newConversation), keyEquivalent: "n")) menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
statusItem?.menu = menu }
@objc func newConversation() { NotificationCenter.default.post(name: .newConversation, object: nil) NSApp.activate(ignoringOtherApps: true) }
@objc func quit() { NSApp.terminate(nil) }}Drag and Drop Support
Dragging files into the chat window to add context:
import SwiftUIimport UniformTypeIdentifiers
struct FileDropDelegate: DropDelegate { let onDrop: ([URL]) -> Void
func performDrop(info: DropInfo) -> Bool { let providers = info.itemProviders(for: [.item])
var urls: [URL] = [] let group = DispatchGroup()
for provider in providers { group.enter() _ = provider.loadObject(ofClass: URL.self) { url, _ in if let url = url { urls.append(url) } group.leave() } }
group.notify(queue: .main) { onDrop(urls) }
return true }
func validateDrop(info: DropInfo) -> Bool { return info.hasItemsConforming(to: [.item]) }}
// Usage in ChatView.onDrop(of: [.item], delegate: FileDropDelegate(onDrop: { urls in viewModel.attachFiles(urls)}))URL Scheme for External Integration
Register a URL scheme so other apps can send prompts:
<key>CFBundleURLTypes</key><array> <dict> <key>CFBundleURLSchemes</key> <array> <string>aiassistant</string> </array> <key>CFBundleURLName</key> <string>com.myapp.aiassistant</string> </dict></array>import SwiftUI
class URLHandler: ObservableObject { func handle(url: URL) { guard url.scheme == "aiassistant" else { return }
switch url.host { case "prompt": if let query = url.query, let prompt = URLComponents(string: url.absoluteString)? .queryItems?.first(where: { $0.name == "text" })?.value { NotificationCenter.default.post( name: .externalPrompt, object: nil, userInfo: ["prompt": prompt] ) } default: break } }}
// In AppDelegatefunc application(_ app: NSApplication, open urls: [URL]) { urls.forEach { urlHandler.handle(url: $0) }}This lets other apps send prompts via URLs like aiassistant://prompt?text=Explain%20this%20code.
Code Syntax Highlighting
AI coding assistants need to display code with syntax highlighting. I tried two approaches:
Option 1: Highlightr (WebView Wrapper)
Highlightr wraps highlight.js in a web view:
import SwiftUIimport Highlightr
struct CodeView: NSViewRepresentable { let code: String let language: String
func makeNSView(context: Context) -> ATASyntaxTextView { let view = ATASyntaxTextView() view.theme = "atom-one-light" return view }
func updateNSView(_ nsView: ATASyntaxTextView, context: Context) { nsView.text = code nsView.language = language }}This is quick to implement but adds web view overhead.
Option 2: Native TextKit
A pure native approach using TextKit:
import AppKit
class SyntaxHighlighter { let textStorage = NSTextStorage()
func highlight(code: String, language: String) -> NSAttributedString { let attributedString = NSMutableAttributedString(string: code)
// Define colors let keywordColor = NSColor.systemPurple let stringColor = NSColor.systemRed let commentColor = NSColor.systemGreen let numberColor = NSColor.systemBlue
// Simple regex-based highlighting (expand for full syntax) let patterns: [(String, NSColor)] = [ ("\\b(func|let|var|if|else|for|while|return|import|struct|class|enum)\\b", keywordColor), ("\"[^\"]*\"", stringColor), ("//.*$", commentColor), ("\\b\\d+\\b", numberColor) ]
for (pattern, color) in patterns { guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { continue } let range = NSRange(location: 0, length: code.utf16.count) regex.enumerateMatches(in: code, options: [], range: range) { match, _, _ in if let matchRange = match?.range { attributedString.addAttribute(.foregroundColor, value: color, range: matchRange) } } }
return attributedString }}This is more work but avoids web view dependencies.
What I Learned
Building this taught me several lessons about native Mac development:
1. SwiftUI is Good Enough for Most Things
I could build 80% of the UI in pure SwiftUI. The declarative syntax made it easy to reason about state changes. The remaining 20% (advanced text editing, custom window chrome) required dropping down to AppKit.
2. Keychain is Non-Negotiable
Security frameworks feel verbose at first, but they’re necessary. The alternative—storing API keys in UserDefaults or files—is unacceptable for an app handling sensitive credentials.
3. Streaming Changes Everything
Streaming responses are harder to implement than batch responses, but the UX improvement is massive. Users see progress immediately instead of waiting for the entire response.
4. Native Integrations Matter
Menu bar icons, global shortcuts, and drag-and-drop seem like small features. But they’re the difference between an app that feels like a web page and an app that feels like part of macOS.
5. Model Flexibility is Essential
Users want to switch between providers. Locking them into one AI service is a feature gap. Building abstraction layers from the start saves refactoring later.
Trade-offs to Consider
Native development has downsides:
SwiftUI Pros SwiftUI Cons────────────────────────────────────────────────────Native performance Mac only (no Windows/Linux)Small app size Steeper learning curveFull system integration Newer framework (fewer examples)Better accessibility Requires Apple hardware
Electron Pros Electron Cons────────────────────────────────────────────────────Cross-platform Large app sizeLarge ecosystem High memory usageMore examples Poor system integrationWeb tech stack Slower startupChoose based on your target users. If your audience is Mac developers, SwiftUI is the right choice. If you need Windows and Linux support, Electron might be necessary despite its drawbacks.
Next Steps
If you’re building your own AI coding assistant, here’s a reasonable order of implementation:
Phase 1: Foundation - SwiftUI app structure - Basic chat UI - Message model - Settings storage (UserDefaults)
Phase 2: AI Integration - API client with streaming - Keychain for API keys - Model configuration UI - Error handling
Phase 3: Native Features - Keyboard shortcuts - Menu bar icon - Drag and drop - URL scheme
Phase 4: Polish - Code syntax highlighting - Markdown rendering - Conversation export - Search and historyThe NeoCode project proves that developers want native alternatives to web-based tools. SwiftUI makes it practical to build them without the overhead of web technologies.
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:
- 👨💻 Reddit: NeoCode - Mac-native OpenCode desktop replacement
- 👨💻 Apple SwiftUI Documentation
- 👨💻 Apple Security Framework
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments