A native macOS application and Swift Package providing a graphical user interface for Claude Code SDK, enabling seamless AI-powered coding assistance with a beautiful, intuitive interface.
CCOpenSourceDemo.mp4
ClaudeCodeUI has been completely restructured to work as both a standalone macOS application AND a reusable Swift Package. This means you can:
- Use the macOS app directly - Download and run the full application
- Integrate into your own apps - Import
ClaudeCodeCore
and use all the functionality in your projects - Customize and extend - Build on top of the comprehensive component library
ClaudeCodeUI is a SwiftUI-based macOS application that wraps the Claude Code SDK, providing users with a desktop experience for interacting with Claude's coding capabilities. The app features markdown rendering, syntax highlighting, file management, terminal integration, and a complete MCP (Model Context Protocol) approval system - all within a native macOS interface.
The ClaudeCodeCore
package includes everything:
- β Complete chat interface with streaming support
- β Session management and persistence
- β Code syntax highlighting and markdown rendering
- β File diff visualization
- β Terminal integration
- β Xcode project detection and integration
- β MCP approval system for secure tool usage
- β Custom permissions handling
- β All UI components and view models
- β Complete dependency injection system
- Download the latest release from the Releases page
- Move ClaudeCodeUI.app to your Applications folder
- Launch the app
-
Clone the repository:
git clone https://github.com/jamesrochabrun/ClaudeCodeUI.git cd ClaudeCodeUI
-
Open in Xcode:
open ClaudeCodeUI.xcodeproj
-
Build and run (βR)
Add ClaudeCodeUI to your project as a Swift Package dependency:
- File β Add Package Dependencies
- Enter:
https://github.com/jamesrochabrun/ClaudeCodeUI.git
- Choose the
ClaudeCodeCore
library product - Click "Add Package"
dependencies: [
.package(url: "https://github.com/jamesrochabrun/ClaudeCodeUI.git", branch: "main")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "ClaudeCodeCore", package: "ClaudeCodeUI")
]
)
]
The simplest integration - get the full ClaudeCodeUI experience in just 3 lines:
import SwiftUI
import ClaudeCodeCore
@main
struct MyApp: App {
var body: some Scene {
// That's it! You get the complete app
ClaudeCodeUIApp().body
}
}
Want to integrate ClaudeCode into your existing app? Use individual components:
import SwiftUI
import ClaudeCodeCore
struct MyCustomApp: App {
@State private var globalPreferences = GlobalPreferencesStorage()
var body: some Scene {
WindowGroup {
NavigationSplitView {
// Your custom sidebar
MySidebarView()
} detail: {
// ClaudeCode chat interface
RootView()
.environment(globalPreferences)
}
}
}
}
For maximum control and performance, use ChatScreen directly without session management overhead. This approach completely avoids session storage initialization and file system checks:
import ClaudeCodeCore
import ClaudeCodeSDK
struct DirectChatApp: App {
@State private var globalPreferences = GlobalPreferencesStorage()
@State private var viewModel: ChatViewModel?
@State private var dependencies: DependencyContainer?
var body: some Scene {
WindowGroup {
if let viewModel = viewModel, let deps = dependencies {
ChatScreen(
viewModel: viewModel,
contextManager: deps.contextManager,
xcodeObservationViewModel: deps.xcodeObservationViewModel,
permissionsService: deps.permissionsService,
terminalService: deps.terminalService,
customPermissionService: deps.customPermissionService,
columnVisibility: .constant(.detailOnly), // No sidebar
uiConfiguration: UIConfiguration(appName: "My Chat App")
)
.environment(globalPreferences)
} else {
ProgressView("Loading...")
.onAppear { setupChatScreen() }
}
}
}
func setupChatScreen() {
// Use optimized factory method that avoids all session storage overhead
let deps = DependencyContainer.forDirectChatScreen(globalPreferences: globalPreferences)
// Create ChatViewModel without session management for better performance
let vm = deps.createChatViewModelWithoutSessions(
claudeClient: ClaudeCodeClient(configuration: .default),
workingDirectory: "/path/to/your/project" // Optional: set working directory
)
self.viewModel = vm
self.dependencies = deps
}
}
If you need session switching capabilities, enable session management:
import ClaudeCodeCore
import ClaudeCodeSDK
struct MyCustomChatView: View {
@StateObject private var dependencyContainer: DependencyContainer
@StateObject private var chatViewModel: ChatViewModel
init() {
let globalPrefs = GlobalPreferencesStorage()
let container = DependencyContainer(globalPreferences: globalPrefs)
_dependencyContainer = StateObject(wrappedValue: container)
_chatViewModel = StateObject(wrappedValue: ChatViewModel(
claudeClient: ClaudeCodeClient(configuration: .default),
sessionStorage: container.sessionStorage,
settingsStorage: container.settingsStorage,
globalPreferences: globalPrefs,
customPermissionService: container.customPermissionService,
shouldManageSessions: true // Enable session management
))
}
var body: some View {
ChatScreen(
viewModel: chatViewModel,
contextManager: dependencyContainer.contextManager,
xcodeObservationViewModel: dependencyContainer.xcodeObservationViewModel,
permissionsService: dependencyContainer.permissionsService,
terminalService: dependencyContainer.terminalService,
customPermissionService: dependencyContainer.customPermissionService,
columnVisibility: .constant(.all)
)
}
}
Customize the behavior with your own settings:
import ClaudeCodeCore
// Use custom storage implementations
let customStorage = MyCustomSessionStorage()
let customSettings = MyCustomSettingsStorage()
// Configure the dependency container
let container = DependencyContainer(
globalPreferences: globalPreferences,
sessionStorage: customStorage,
settingsStorage: customSettings
)
ClaudeCodeUI/
βββ Package.swift # Swift Package manifest
βββ Sources/
β βββ ClaudeCodeCore/ # Main library (all reusable components)
β β βββ App/ # App structure and entry points
β β βββ ViewModels/ # Business logic and state management
β β βββ Views/UI/ # All SwiftUI views and components
β β βββ Services/ # Core services (permissions, terminal, etc.)
β β βββ Storage/ # Session and settings persistence
β β βββ Models/ # Data models
β β βββ DependencyInjection/ # DI container
β β βββ Diff/ # File diff visualization
β β βββ FileSearch/ # File search functionality
β β βββ ToolFormatting/ # Tool output formatting
β β βββ Extensions/ # Utility extensions
β βββ ClaudeCodeUI/ # Minimal executable wrapper
β βββ main.swift # App entry point
βββ modules/ # Local Swift packages
β βββ AccessibilityService/ # macOS accessibility APIs
β βββ ApprovalMCPServer/ # MCP approval server
β βββ CustomPermissionService/ # Permission handling
β βββ TerminalService/ # Terminal integration
β βββ XcodeObserverService/ # Xcode integration
βββ ClaudeCodeUI.xcodeproj # Xcode project (for app distribution)
ClaudeCodeUI communicates with the Claude Code SDK through the ClaudeCodeClient
class. The app:
- Initializes the SDK: Creates a
ClaudeCodeConfiguration
with the current working directory and debug settings - Manages Sessions: Uses
SessionStorage
to persist chat histories and session metadata - Handles Permissions: Implements a custom permission service to manage file system access
- Processes Responses: Renders Claude's responses with markdown formatting and code syntax highlighting
- Executes Actions: Handles file operations, terminal commands, and other SDK capabilities through a unified interface
If you've been using ClaudeCodeUI before the package restructure:
- No changes needed for app users - The macOS app works exactly the same
- For developers: The source code is now in
Sources/ClaudeCodeCore/
instead ofClaudeCodeUI/
- Package imports: Use
import ClaudeCodeCore
instead of importing individual files
When adding ClaudeCodeUI as a dependency:
- The main library product is
ClaudeCodeCore
- All components are public and can be used individually
- The package includes all necessary dependencies (you don't need to add them separately)
ClaudeCodeUI provides a powerful external storage system that allows you to integrate with your own databases, cloud storage, or custom persistence solutions. This enables you to:
- Save messages to your database - Automatically persist all conversation history
- Restore sessions on app launch - Resume conversations from where users left off
- Inject working directories - Skip manual directory selection by providing project paths
- Manage sessions externally - Implement custom session organization and sharing
- Sync across devices - Build multi-device conversation synchronization
The external storage system consists of three main components:
SessionStorageProtocol
- Interface for custom storage backendsChatViewModel.injectSession()
- Method to inject sessions with messages and working directory- Automatic message persistence - Real-time saving as conversations progress
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β Your Database βββββΊβ SessionStorage βββββΊβ ClaudeCodeUI β
β (Any Backend) β β Protocol β β ChatViewModel β
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β β β
β β β
βββββββΌβββββββ βββββββββΌβββββββββ ββββββββΌββββββββββ
β Messages β β Session β β UI Updates β
β Save β β Injection β β Automatically β
βAutomaticallyβ β & Restore β β β
ββββββββββββββ ββββββββββββββββββ ββββββββββββββββββ
Implement this protocol to connect ClaudeCodeUI to your storage backend:
import ClaudeCodeCore
class DatabaseSessionStorage: SessionStorageProtocol {
let database: YourDatabaseLayer
init(database: YourDatabaseLayer) {
self.database = database
}
// REQUIRED: Save a new session
func saveSession(id: String, firstMessage: String) async throws {
let session = YourSessionModel(
id: id,
createdAt: Date(),
firstMessage: firstMessage,
lastAccessedAt: Date()
)
try await database.save(session)
}
// REQUIRED: Get all sessions (sorted by last accessed)
func getAllSessions() async throws -> [StoredSession] {
let dbSessions = try await database.fetchAllSessions()
return dbSessions.map { dbSession in
StoredSession(
id: dbSession.id,
createdAt: dbSession.createdAt,
firstUserMessage: dbSession.firstMessage,
lastAccessedAt: dbSession.lastAccessedAt,
messages: dbSession.messages.map { convertToClaudeMessage($0) }
)
}.sorted { $0.lastAccessedAt > $1.lastAccessedAt }
}
// REQUIRED: Get specific session
func getSession(id: String) async throws -> StoredSession? {
guard let dbSession = try await database.fetchSession(id: id) else { return nil }
return StoredSession(
id: dbSession.id,
createdAt: dbSession.createdAt,
firstUserMessage: dbSession.firstMessage,
lastAccessedAt: dbSession.lastAccessedAt,
messages: dbSession.messages.map { convertToClaudeMessage($0) }
)
}
// REQUIRED: Update session messages (called automatically!)
func updateSessionMessages(id: String, messages: [ChatMessage]) async throws {
// This is called every time new messages are added to the conversation
let dbMessages = messages.map { convertFromClaudeMessage($0) }
try await database.updateSessionMessages(sessionId: id, messages: dbMessages)
// Also update last accessed time
try await database.updateLastAccessed(sessionId: id, date: Date())
}
// REQUIRED: Update last accessed time
func updateLastAccessed(id: String) async throws {
try await database.updateLastAccessed(sessionId: id, date: Date())
}
// REQUIRED: Delete session
func deleteSession(id: String) async throws {
try await database.deleteSession(id: id)
}
// REQUIRED: Delete all sessions
func deleteAllSessions() async throws {
try await database.deleteAllSessions()
}
// REQUIRED: Update session ID (when Claude changes session ID internally)
func updateSessionId(oldId: String, newId: String) async throws {
try await database.updateSessionId(oldId: oldId, newId: newId)
}
// Helper: Convert between your data models and ClaudeCodeUI models
private func convertToClaudeMessage(_ dbMessage: YourMessageModel) -> ChatMessage {
ChatMessage(
id: dbMessage.id,
role: MessageRole(rawValue: dbMessage.role) ?? .user,
content: dbMessage.content,
isComplete: dbMessage.isComplete,
messageType: MessageType(rawValue: dbMessage.type) ?? .text,
toolName: dbMessage.toolName,
toolInputData: dbMessage.toolInputData,
isError: dbMessage.isError
)
}
private func convertFromClaudeMessage(_ claudeMessage: ChatMessage) -> YourMessageModel {
YourMessageModel(
id: claudeMessage.id,
role: claudeMessage.role.rawValue,
content: claudeMessage.content,
isComplete: claudeMessage.isComplete,
type: claudeMessage.messageType.rawValue,
toolName: claudeMessage.toolName,
toolInputData: claudeMessage.toolInputData,
isError: claudeMessage.isError
)
}
}
CoreData Implementation:
class CoreDataSessionStorage: SessionStorageProtocol {
let container: NSPersistentContainer
init() {
container = NSPersistentContainer(name: "ChatSessions")
container.loadPersistentStores { _, error in
if let error = error {
fatalError("CoreData failed to load: \(error)")
}
}
}
func updateSessionMessages(id: String, messages: [ChatMessage]) async throws {
await withCheckedContinuation { continuation in
let context = container.newBackgroundContext()
context.perform {
// Find or create session entity
let request: NSFetchRequest<SessionEntity> = SessionEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)
do {
let sessions = try context.fetch(request)
let session = sessions.first ?? SessionEntity(context: context)
session.id = id
session.lastAccessedAt = Date()
// Clear existing messages
session.messages?.forEach { context.delete($0 as! NSManagedObject) }
// Add new messages
for message in messages {
let messageEntity = MessageEntity(context: context)
messageEntity.id = message.id
messageEntity.content = message.content
messageEntity.role = message.role.rawValue
messageEntity.isComplete = message.isComplete
messageEntity.session = session
}
try context.save()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
// ... implement other required methods
}
SQLite Implementation:
import SQLite3
class SQLiteSessionStorage: SessionStorageProtocol {
private var db: OpaquePointer?
init(dbPath: String) throws {
if sqlite3_open(dbPath, &db) != SQLITE_OK {
throw DatabaseError.cannotOpen
}
try createTables()
}
func updateSessionMessages(id: String, messages: [ChatMessage]) async throws {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .background).async {
do {
// Begin transaction
try self.executeSQL("BEGIN TRANSACTION")
// Delete existing messages for this session
try self.executeSQL("DELETE FROM messages WHERE session_id = ?", parameters: [id])
// Insert new messages
for message in messages {
try self.executeSQL("""
INSERT INTO messages (id, session_id, role, content, is_complete, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", parameters: [
message.id.uuidString,
id,
message.role.rawValue,
message.content,
message.isComplete ? 1 : 0,
Date().timeIntervalSince1970
])
}
// Update session last accessed
try self.executeSQL("""
UPDATE sessions SET last_accessed_at = ? WHERE id = ?
""", parameters: [Date().timeIntervalSince1970, id])
// Commit transaction
try self.executeSQL("COMMIT")
continuation.resume()
} catch {
try? self.executeSQL("ROLLBACK")
continuation.resume(throwing: error)
}
}
}
}
// ... implement other required methods
}
CloudKit Implementation:
import CloudKit
class CloudKitSessionStorage: SessionStorageProtocol {
private let container: CKContainer
private let database: CKDatabase
init() {
container = CKContainer.default()
database = container.privateCloudDatabase
}
func updateSessionMessages(id: String, messages: [ChatMessage]) async throws {
// Fetch existing session record
let sessionID = CKRecord.ID(recordName: "session_\(id)")
do {
let sessionRecord = try await database.record(for: sessionID)
sessionRecord["lastAccessedAt"] = Date()
// Save session updates
_ = try await database.save(sessionRecord)
// Delete existing message records
let messageQuery = CKQuery(recordType: "Message", predicate: NSPredicate(format: "sessionID == %@", id))
let existingMessages = try await database.records(matching: messageQuery).matchResults
var recordsToDelete: [CKRecord.ID] = []
for (_, result) in existingMessages {
switch result {
case .success(let record):
recordsToDelete.append(record.recordID)
case .failure:
break
}
}
if !recordsToDelete.isEmpty {
_ = try await database.modifyRecords(saving: [], deleting: recordsToDelete)
}
// Create new message records
var newMessageRecords: [CKRecord] = []
for message in messages {
let messageRecord = CKRecord(recordType: "Message", recordID: CKRecord.ID(recordName: "message_\(message.id)"))
messageRecord["sessionID"] = id
messageRecord["role"] = message.role.rawValue
messageRecord["content"] = message.content
messageRecord["isComplete"] = message.isComplete
messageRecord["createdAt"] = Date()
newMessageRecords.append(messageRecord)
}
// Batch save all message records
if !newMessageRecords.isEmpty {
_ = try await database.modifyRecords(saving: newMessageRecords, deleting: [])
}
} catch CKError.unknownItem {
// Session doesn't exist, create it
let newSessionRecord = CKRecord(recordType: "Session", recordID: sessionID)
newSessionRecord["id"] = id
newSessionRecord["createdAt"] = Date()
newSessionRecord["lastAccessedAt"] = Date()
newSessionRecord["firstMessage"] = messages.first?.content ?? ""
_ = try await database.save(newSessionRecord)
// Recursively call to save messages
try await updateSessionMessages(id: id, messages: messages)
}
}
// ... implement other required methods
}
Note: When using ChatScreen directly without RootView, consider using
createChatViewModelWithoutSessions()
as shown in the Advanced section above to avoid unnecessary session loading operations.
The injectSession()
method allows you to restore conversations with pre-loaded messages and working directory:
import ClaudeCodeCore
class MyApp: App {
@State private var globalPreferences = GlobalPreferencesStorage()
@State private var viewModel: ChatViewModel?
@State private var dependencies: DependencyContainer?
var body: some Scene {
WindowGroup {
if let viewModel = viewModel, let deps = dependencies {
ChatScreen(
viewModel: viewModel,
contextManager: deps.contextManager,
xcodeObservationViewModel: deps.xcodeObservationViewModel,
permissionsService: deps.permissionsService,
terminalService: deps.terminalService,
customPermissionService: deps.customPermissionService,
columnVisibility: .constant(.detailOnly),
uiConfiguration: UIConfiguration(appName: "My Database App")
)
} else {
ProgressView("Loading conversation...")
.onAppear {
Task { await restoreLastSession() }
}
}
}
}
func restoreLastSession() async {
// 1. Set up dependencies with your custom storage
let deps = DependencyContainer(globalPreferences: globalPreferences)
let customStorage = DatabaseSessionStorage(database: myDatabase)
// 2. Create ChatViewModel with your storage
let vm = ChatViewModel(
claudeClient: ClaudeCodeClient(configuration: .default),
sessionStorage: customStorage, // Your custom storage
settingsStorage: deps.settingsStorage,
globalPreferences: globalPreferences,
customPermissionService: deps.customPermissionService
)
// 3. Load last session from your database
do {
if let lastSession = try await customStorage.getAllSessions().first {
// 4. Inject the session with messages and working directory
vm.injectSession(
sessionId: lastSession.id,
messages: lastSession.messages, // Pre-loaded from your DB
workingDirectory: "/path/to/project" // Skip directory selection
)
print("β
Restored session '\(lastSession.id)' with \(lastSession.messages.count) messages")
} else {
// No previous sessions, start fresh but still inject working directory
vm.injectSession(
sessionId: UUID().uuidString,
messages: [],
workingDirectory: "/default/project/path"
)
print("β
Started new session with default working directory")
}
} catch {
print("β Failed to restore session: \(error)")
// Fallback to fresh session
vm.injectSession(
sessionId: UUID().uuidString,
messages: [],
workingDirectory: nil // User will need to select manually
)
}
await MainActor.run {
self.viewModel = vm
self.dependencies = deps
}
}
}
Multi-Session App with Session Switching:
class MultiSessionApp: ObservableObject {
@Published var currentViewModel: ChatViewModel?
@Published var availableSessions: [StoredSession] = []
private let storage: DatabaseSessionStorage
private let dependencies: DependencyContainer
init() {
self.storage = DatabaseSessionStorage(database: MyDatabase.shared)
self.dependencies = DependencyContainer(globalPreferences: GlobalPreferencesStorage())
Task {
await loadAvailableSessions()
}
}
func loadAvailableSessions() async {
do {
let sessions = try await storage.getAllSessions()
await MainActor.run {
self.availableSessions = sessions
}
} catch {
print("Failed to load sessions: \(error)")
}
}
func switchToSession(_ session: StoredSession) async {
// Create new view model for this session
let vm = ChatViewModel(
claudeClient: ClaudeCodeClient(configuration: .default),
sessionStorage: storage,
settingsStorage: dependencies.settingsStorage,
globalPreferences: dependencies.globalPreferences,
customPermissionService: dependencies.customPermissionService
)
// Inject the selected session
vm.injectSession(
sessionId: session.id,
messages: session.messages,
workingDirectory: getWorkingDirectoryForSession(session.id)
)
await MainActor.run {
self.currentViewModel = vm
}
print("β
Switched to session: \(session.title)")
}
func createNewSession(withWorkingDirectory workingDir: String? = nil) async {
let newSessionId = UUID().uuidString
let vm = ChatViewModel(
claudeClient: ClaudeCodeClient(configuration: .default),
sessionStorage: storage,
settingsStorage: dependencies.settingsStorage,
globalPreferences: dependencies.globalPreferences,
customPermissionService: dependencies.customPermissionService
)
// Inject empty session with working directory
vm.injectSession(
sessionId: newSessionId,
messages: [],
workingDirectory: workingDir
)
await MainActor.run {
self.currentViewModel = vm
}
// Refresh available sessions
await loadAvailableSessions()
print("β
Created new session: \(newSessionId)")
}
private func getWorkingDirectoryForSession(_ sessionId: String) -> String? {
// Load working directory from your database/preferences
return MyDatabase.shared.getWorkingDirectory(forSession: sessionId)
}
}
ClaudeCodeUI supports three ways to inject working directories:
// Set working directory when creating the app configuration
RootView(configuration: ClaudeCodeAppConfiguration(
appName: "My Project App",
workingDirectory: "/Users/me/my-project" // No manual selection needed!
))
// Set working directory when injecting a session
viewModel.injectSession(
sessionId: "session-123",
messages: savedMessages,
workingDirectory: "/path/to/project" // Ready to work immediately!
)
// Update working directory during the conversation
viewModel.updateWorkingDirectory("/new/project/path")
Strategy 1: Per-Session Storage
class SessionWorkingDirectoryManager {
private let storage: YourDatabaseLayer
func saveWorkingDirectory(_ path: String, forSession sessionId: String) async throws {
try await storage.execute("""
INSERT OR REPLACE INTO session_settings (session_id, working_directory, updated_at)
VALUES (?, ?, ?)
""", parameters: [sessionId, path, Date().timeIntervalSince1970])
}
func getWorkingDirectory(forSession sessionId: String) async -> String? {
return try? await storage.fetchValue("""
SELECT working_directory FROM session_settings WHERE session_id = ?
""", parameters: [sessionId])
}
func restoreSessionWithWorkingDirectory(_ sessionId: String) async -> (messages: [ChatMessage], workingDirectory: String?) {
async let messages = storage.fetchMessages(sessionId: sessionId)
async let workingDir = getWorkingDirectory(forSession: sessionId)
return await (messages: messages, workingDirectory: workingDir)
}
}
// Usage in your app:
let (messages, workingDir) = await directoryManager.restoreSessionWithWorkingDirectory(sessionId)
viewModel.injectSession(
sessionId: sessionId,
messages: messages,
workingDirectory: workingDir
)
Strategy 2: Project-Based Storage
class ProjectManager {
func getLastSessionForProject(_ projectPath: String) async -> String? {
return try? await storage.fetchValue("""
SELECT session_id FROM project_sessions
WHERE project_path = ?
ORDER BY last_accessed_at DESC
LIMIT 1
""", parameters: [projectPath])
}
func restoreProjectSession(_ projectPath: String) async {
if let lastSessionId = await getLastSessionForProject(projectPath) {
let messages = try await storage.fetchMessages(sessionId: lastSessionId)
viewModel.injectSession(
sessionId: lastSessionId,
messages: messages,
workingDirectory: projectPath // Always use the project path
)
}
}
}
When you implement SessionStorageProtocol
, the updateSessionMessages()
method is called automatically whenever:
- A user sends a message
- Claude responds with a message
- Tool calls are made
- Any conversation update occurs
Performance Optimization Example:
class OptimizedSessionStorage: SessionStorageProtocol {
private var saveQueue = DispatchQueue(label: "message-save-queue", qos: .utility)
private var lastSaveTime: [String: Date] = [:]
private var pendingSaves: [String: [ChatMessage]] = [:]
private let saveDelay: TimeInterval = 2.0 // Batch saves every 2 seconds
func updateSessionMessages(id: String, messages: [ChatMessage]) async throws {
// Store messages in memory first for immediate UI updates
pendingSaves[id] = messages
// Debounce saves to avoid excessive database writes
let now = Date()
let lastSave = lastSaveTime[id] ?? Date.distantPast
if now.timeIntervalSince(lastSave) < saveDelay {
// Schedule a delayed save
saveQueue.asyncAfter(deadline: .now() + saveDelay) {
Task {
await self.performDelayedSave(sessionId: id)
}
}
} else {
// Save immediately
await performImmediateSave(sessionId: id, messages: messages)
}
}
private func performImmediateSave(sessionId: String, messages: [ChatMessage]) async {
do {
try await database.saveMessages(sessionId: sessionId, messages: messages)
lastSaveTime[sessionId] = Date()
pendingSaves.removeValue(forKey: sessionId)
print("β
Saved \(messages.count) messages for session \(sessionId)")
} catch {
print("β Failed to save messages for session \(sessionId): \(error)")
}
}
private func performDelayedSave(sessionId: String) async {
guard let messages = pendingSaves[sessionId] else { return }
await performImmediateSave(sessionId: sessionId, messages: messages)
}
// Call this when app is backgrounded or terminating
func flushPendingSaves() async {
for (sessionId, messages) in pendingSaves {
await performImmediateSave(sessionId: sessionId, messages: messages)
}
}
}
class SyncedSessionStorage: SessionStorageProtocol {
private let localStorage: LocalDatabaseStorage
private let cloudStorage: CloudSyncStorage
private let conflictResolver: ConflictResolver
func updateSessionMessages(id: String, messages: [ChatMessage]) async throws {
// Save locally first for immediate response
try await localStorage.updateSessionMessages(id: id, messages: messages)
// Queue for cloud sync
await cloudSyncQueue.enqueue(SyncOperation(
sessionId: id,
messages: messages,
timestamp: Date()
))
print("π€ Queued session \(id) for cloud sync")
}
func syncWithCloud() async throws {
let localSessions = try await localStorage.getAllSessions()
let cloudSessions = try await cloudStorage.getAllSessions()
for localSession in localSessions {
if let cloudSession = cloudSessions.first(where: { $0.id == localSession.id }) {
// Resolve conflicts and merge
let mergedMessages = try await conflictResolver.merge(
local: localSession.messages,
cloud: cloudSession.messages
)
try await localStorage.updateSessionMessages(id: localSession.id, messages: mergedMessages)
try await cloudStorage.updateSessionMessages(id: localSession.id, messages: mergedMessages)
print("π Synced session \(localSession.id)")
}
}
}
}
Graceful Degradation:
class RobustSessionStorage: SessionStorageProtocol {
private let primaryStorage: DatabaseStorage
private let fallbackStorage: UserDefaultsStorage
func updateSessionMessages(id: String, messages: [ChatMessage]) async throws {
do {
// Try primary storage first
try await primaryStorage.updateSessionMessages(id: id, messages: messages)
} catch {
print("β οΈ Primary storage failed, using fallback: \(error)")
do {
// Fallback to UserDefaults
try await fallbackStorage.updateSessionMessages(id: id, messages: messages)
// Queue for retry with primary storage
await retryQueue.enqueue(RetryOperation(sessionId: id, messages: messages))
} catch {
print("β Both primary and fallback storage failed: \(error)")
throw StorageError.allBackendsFailed([error])
}
}
}
}
class MonitoredSessionStorage: SessionStorageProtocol {
private let wrappedStorage: SessionStorageProtocol
private let performanceLogger: PerformanceLogger
func updateSessionMessages(id: String, messages: [ChatMessage]) async throws {
let startTime = CFAbsoluteTimeGetCurrent()
do {
try await wrappedStorage.updateSessionMessages(id: id, messages: messages)
let duration = CFAbsoluteTimeGetCurrent() - startTime
await performanceLogger.log(
operation: "updateSessionMessages",
sessionId: id,
messageCount: messages.count,
duration: duration
)
if duration > 2.0 {
print("β οΈ Slow storage operation: \(duration)s for \(messages.count) messages")
}
} catch {
await performanceLogger.logError(
operation: "updateSessionMessages",
sessionId: id,
error: error,
duration: CFAbsoluteTimeGetCurrent() - startTime
)
throw error
}
}
}
import XCTest
@testable import YourApp
class SessionStorageTests: XCTestCase {
var storage: YourSessionStorage!
override func setUp() {
super.setUp()
storage = YourSessionStorage(testDatabase: createTestDB())
}
func testSessionPersistence() async throws {
let sessionId = "test-session-1"
let messages = [
ChatMessage(id: UUID(), role: .user, content: "Hello", isComplete: true),
ChatMessage(id: UUID(), role: .assistant, content: "Hi there!", isComplete: true)
]
// Test saving
try await storage.updateSessionMessages(id: sessionId, messages: messages)
// Test retrieval
let retrievedSession = try await storage.getSession(id: sessionId)
XCTAssertNotNil(retrievedSession)
XCTAssertEqual(retrievedSession?.messages.count, 2)
XCTAssertEqual(retrievedSession?.messages.first?.content, "Hello")
}
func testWorkingDirectoryInjection() async throws {
let sessionId = "test-session-2"
let workingDir = "/test/project/path"
// Create view model with test storage
let viewModel = ChatViewModel(
claudeClient: MockClaudeClient(),
sessionStorage: storage,
settingsStorage: MockSettingsStorage(),
globalPreferences: GlobalPreferencesStorage(),
customPermissionService: MockPermissionService()
)
// Inject session with working directory
viewModel.injectSession(
sessionId: sessionId,
messages: [],
workingDirectory: workingDir
)
// Verify working directory is set
XCTAssertEqual(viewModel.projectPath, workingDir)
}
func testAutomaticMessageSaving() async throws {
let sessionId = "test-session-3"
// Set up view model to track saves
let mockStorage = MockSessionStorage()
let viewModel = ChatViewModel(
claudeClient: MockClaudeClient(),
sessionStorage: mockStorage,
settingsStorage: MockSettingsStorage(),
globalPreferences: GlobalPreferencesStorage(),
customPermissionService: MockPermissionService()
)
viewModel.injectSession(sessionId: sessionId, messages: [], workingDirectory: nil)
// Simulate sending a message
await viewModel.sendMessage("Test message", attachments: [])
// Wait for async save
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
// Verify save was called
XCTAssertTrue(mockStorage.updateSessionMessagesCalled)
XCTAssertEqual(mockStorage.lastSavedSessionId, sessionId)
XCTAssertGreaterThan(mockStorage.lastSavedMessages.count, 0)
}
}
class StorageMigration {
static func migrateFromBuiltIn() async throws {
let builtInStorage = UserDefaultsSessionStorage()
let externalStorage = DatabaseSessionStorage(database: MyDatabase.shared)
let existingSessions = try await builtInStorage.getAllSessions()
print("π¦ Migrating \(existingSessions.count) sessions to external storage...")
for session in existingSessions {
do {
// Save to new storage
try await externalStorage.saveSession(
id: session.id,
firstMessage: session.firstUserMessage
)
try await externalStorage.updateSessionMessages(
id: session.id,
messages: session.messages
)
// Update timestamps
try await externalStorage.updateLastAccessed(id: session.id)
print("β
Migrated session: \(session.title)")
} catch {
print("β Failed to migrate session \(session.id): \(error)")
}
}
print("π Migration completed!")
}
}
This comprehensive guide provides everything needed to implement external storage with ClaudeCodeUI, including session injection, automatic message saving, working directory management, and advanced patterns for real-world applications.
ClaudeCodeUI can automatically detect your active Xcode project and selected code, making it seamless to work with your current development context.
To enable Xcode integration, you need to grant Accessibility permissions:
- Open System Settings > Privacy & Security > Accessibility
- Click the lock to make changes (you'll need to authenticate)
- Click the + button and add ClaudeCodeUI to the list
- Enable the toggle next to ClaudeCodeUI
Once enabled, ClaudeCodeUI can:
- Detect Active Project: Automatically sets the working directory to your currently open Xcode project
- Read Selected Code: Access code selections in Xcode to provide context-aware assistance
- Monitor Active Files: Track which files you're working on for better context
- Sync Project Paths: Automatically update the working directory when you switch between Xcode projects
The MCP (Model Context Protocol) approval tool provides a secure permission system for Claude Code operations. When Claude needs to perform actions like file operations or execute commands, the approval tool presents a native macOS dialog for user consent.
When using ClaudeCodeCore as a package dependency, you need to build and include the MCP approval server in your app:
-
Add the MCP server module to your project:
- The server source is included in the package at
modules/ApprovalMCPServer
- You'll need to build it as part of your app's build process
- The server source is included in the package at
-
Option A: Add Build Phase to Your App:
# Add this script to your app's build phases cd "$BUILD_DIR/../../SourcePackages/checkouts/ClaudeCodeUI/modules/ApprovalMCPServer" swift build -c release cp .build/release/ApprovalMCPServer "$BUILT_PRODUCTS_DIR/$PRODUCT_NAME.app/Contents/Resources/"
-
Option B: Build Manually and Include:
# Build the server manually cd path/to/ClaudeCodeUI/modules/ApprovalMCPServer swift build -c release # Copy the binary to your app's Resources folder
-
Option C: Use without MCP (Simplest):
- The app will work without the MCP server, but you won't get approval dialogs
- Claude will use default permissions based on your settings
When building the ClaudeCodeUI app itself from source:
- Open
ClaudeCodeUI.xcodeproj
- Select the
ClaudeCodeUI
target - Go to "Build Phases" tab
- Click "+" β "New Run Script Phase"
- Important: Drag the new phase to run before "Compile Sources"
- Paste this script:
"${PROJECT_DIR}/Scripts/build-approval-server.sh"
- Rename to "Build MCP Approval Server" (optional)
- Build and run!
- Automatic Detection: ClaudeCodeCore looks for the server in multiple locations
- Smart Path Detection: Checks app bundle, build directories, and package paths
- Permission Dialogs: Native macOS UI for approving/denying Claude's requests
- Session-Based: Permissions are managed per chat session for security
- macOS 15.2 or later (due to package dependencies)
- Xcode 15.0 or later
- Swift 5.9 or later
- Claude Code SDK
- Clone the repository:
git clone https://github.com/jamesrochabrun/ClaudeCodeUI.git
cd ClaudeCodeUI
- Using Xcode Project (for app development):
open ClaudeCodeUI.xcodeproj
# Build and run with βR
- Using Swift Package Manager (for package development):
swift build
swift run ClaudeCodeUI
# Run tests for the core library
swift test
# Or in Xcode
# Product β Test (βU)
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature
) - Commit your changes (
git commit -m 'Add some amazing feature'
) - Push to the branch (
git push origin feature/amazing-feature
) - Open a Pull Request
Please ensure your contributions:
- Follow Swift naming conventions
- Include appropriate documentation
- Add tests where applicable
- Update the README if adding new features
- Work with both the Xcode project and Swift Package
"MCP tool not found" error on first run
- You need to add the build phase! See First-Time Setup
- This is only needed when building from source
Package resolution issues
- Clean the package cache:
swift package clean
- Reset package resolved:
swift package reset
- In Xcode: File β Packages β Reset Package Caches
App doesn't launch
- Ensure you're running macOS 15.2 or later
- Check that all Swift Package dependencies are resolved
Claude Code SDK not responding
- Verify your API credentials are properly configured
- Check the debug logs in Console.app for detailed error messages
- Ensure you have an active internet connection
- Fix diffing UI - improve visual diff presentation and user interaction
- Improve MCP approval flow - enhance the permission dialog UX
- Add iOS/iPadOS support (the package structure now makes this possible!)
- Create example apps demonstrating different integration patterns
Special thanks to cmd - The code for diff visualization and markdown rendering has been adapted from this excellent project. Their implementation provided a solid foundation for these features in ClaudeCodeUI.
MIT License