Skip to content

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:

Resource Comparison
Aspect SwiftUI (Native) Electron (Web-based)
─────────────────────────────────────────────────────────────
App Size ~5-10 MB ~150-300 MB
Memory Usage 50-100 MB 200-500 MB
Startup Time Instant 2-5 seconds
System Integration Full Limited
Bundle 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:

Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ 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:

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

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

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

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

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

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

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

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

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

A menu bar icon lets users quickly start a new conversation:

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

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

Info.plist URL Scheme Entry
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>aiassistant</string>
</array>
<key>CFBundleURLName</key>
<string>com.myapp.aiassistant</string>
</dict>
</array>
URLHandler.swift
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 AppDelegate
func 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:

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

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

Native vs Cross-Platform Trade-offs
SwiftUI Pros SwiftUI Cons
────────────────────────────────────────────────────
Native performance Mac only (no Windows/Linux)
Small app size Steeper learning curve
Full system integration Newer framework (fewer examples)
Better accessibility Requires Apple hardware
Electron Pros Electron Cons
────────────────────────────────────────────────────
Cross-platform Large app size
Large ecosystem High memory usage
More examples Poor system integration
Web tech stack Slower startup

Choose 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:

Implementation Phases
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 history

The 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments