diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj
index 11fd0a9..a2ee833 100644
--- a/oAI.xcodeproj/project.pbxproj
+++ b/oAI.xcodeproj/project.pbxproj
@@ -279,11 +279,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
- MACOSX_DEPLOYMENT_TARGET = 26.2;
- MARKETING_VERSION = 2.3.9;
+ MACOSX_DEPLOYMENT_TARGET = 27.0;
+ MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -323,11 +323,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
- IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
- MACOSX_DEPLOYMENT_TARGET = 26.2;
- MARKETING_VERSION = 2.3.9;
+ MACOSX_DEPLOYMENT_TARGET = 27.0;
+ MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
diff --git a/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme b/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme
index 1ace073..2336f58 100644
--- a/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme
+++ b/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme
@@ -34,7 +34,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
- language = "nb"
+ language = "en"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
diff --git a/oAI/Info.plist b/oAI/Info.plist
index eee344e..5d5967f 100644
--- a/oAI/Info.plist
+++ b/oAI/Info.plist
@@ -6,5 +6,13 @@
oAI.help
CFBundleHelpBookName
oAI Help
+ CFBundleLocalizations
+
+ en
+ nb
+ da
+ de
+ sv
+
diff --git a/oAI/Localizable.xcstrings b/oAI/Localizable.xcstrings
index b7fd5c3..46e7901 100644
--- a/oAI/Localizable.xcstrings
+++ b/oAI/Localizable.xcstrings
@@ -1,6 +1,13 @@
{
"sourceLanguage" : "en",
"strings" : {
+ "·" : {
+ "comment" : "A separator between the message count and the date.",
+ "isCommentAutoGenerated" : true
+ },
+ "· %@" : {
+
+ },
"(always used)" : {
"localizations" : {
"da" : {
@@ -432,6 +439,12 @@
}
}
}
+ },
+ "^[%@ message](inflect: true)" : {
+
+ },
+ "^[%@ token](inflect: true)" : {
+
},
"© 2026 [Rune Olsen](https://blog.rune.pm)" : {
"comment" : "A copyright notice with the copyright holder's name.",
@@ -521,6 +534,7 @@
},
"⌘N New • ⌘M Model • ⌘S Save" : {
"comment" : "A hint that appears on macOS when using keyboard shortcuts.",
+ "extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"da" : {
@@ -549,6 +563,10 @@
}
}
},
+ "⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly." : {
+ "comment" : "A warning displayed in the settings view.",
+ "isCommentAutoGenerated" : true
+ },
"⚠️ Custom prompt active — only this prompt will be sent to the model." : {
"localizations" : {
"da" : {
@@ -894,6 +912,12 @@
}
}
}
+ },
+ "🧠" : {
+
+ },
+ "1. Open Anytype → Settings → Integrations" : {
+
},
"1. Open Paperless-NGX → Settings → API Tokens" : {
"comment" : "A step in the process of getting a Paperless-NGX API token.",
@@ -925,6 +949,10 @@
}
}
},
+ "2. Create a new API key" : {
+ "comment" : "A step in the process of getting an API key from Anytype.",
+ "isCommentAutoGenerated" : true
+ },
"2. Create or copy your token" : {
"comment" : "A step in the process of getting a Paperless-NGX API token.",
"isCommentAutoGenerated" : true,
@@ -1132,6 +1160,9 @@
}
}
}
+ },
+ "Agent" : {
+
},
"Agent Skills" : {
"localizations" : {
@@ -1160,6 +1191,9 @@
}
}
}
+ },
+ "Agents" : {
+
},
"Allow Shell Command?" : {
"comment" : "A title for a modal that asks the user if they want to allow a shell command.",
@@ -1566,6 +1600,9 @@
}
}
}
+ },
+ "Category" : {
+
},
"Changing these values affects how the AI generates responses. The defaults work well for most use cases." : {
"localizations" : {
@@ -1654,6 +1691,9 @@
}
}
}
+ },
+ "Choose an agent from the list to view details and run history" : {
+
},
"Clear All" : {
"comment" : "A button to clear all email activity logs.",
@@ -1918,6 +1958,9 @@
}
}
}
+ },
+ "Cost" : {
+
},
"Cost Examples" : {
"comment" : "A heading for the cost examples of a model.",
@@ -2092,6 +2135,9 @@
}
}
}
+ },
+ "Disabled" : {
+
},
"Each command will require your approval before running." : {
"localizations" : {
@@ -2468,6 +2514,12 @@
}
}
}
+ },
+ "Filter by Category" : {
+
+ },
+ "Generate an API key in your Jarvis settings and paste it above." : {
+
},
"Google (Gemini embedding)" : {
"localizations" : {
@@ -2526,6 +2578,12 @@
}
}
}
+ },
+ "High (~80%)" : {
+
+ },
+ "How to get your API key:" : {
+
},
"How to get your API token:" : {
"comment" : "A heading for a section that describes how to get your API token.",
@@ -2640,6 +2698,9 @@
}
}
}
+ },
+ "Input" : {
+
},
"Large files inflate the system prompt and may hit token limits." : {
"comment" : "A warning displayed when a user adds a large file to a skill.",
@@ -2726,6 +2787,9 @@
}
}
}
+ },
+ "Low (~20%)" : {
+
},
"Lowercase letters, numbers, and hyphens only. No spaces." : {
"comment" : "A description of the format of a shortcut's command.",
@@ -2842,6 +2906,9 @@
}
}
}
+ },
+ "Medium (~50%)" : {
+
},
"messages" : {
"localizations" : {
@@ -2870,6 +2937,9 @@
}
}
}
+ },
+ "Minimal (~10%)" : {
+
},
"Model Context Protocol" : {
"localizations" : {
@@ -2928,6 +2998,9 @@
}
}
}
+ },
+ "Model thinks internally but reasoning is not shown in chat" : {
+
},
"Multi-provider AI chat client" : {
"comment" : "A description of oAI.",
@@ -3018,6 +3091,9 @@
}
}
}
+ },
+ "New Chat" : {
+
},
"No credit data available" : {
"comment" : "A message displayed when there is no credit data available.",
@@ -3196,6 +3272,9 @@
}
}
}
+ },
+ "No runs yet" : {
+
},
"No shortcuts yet" : {
"comment" : "A message displayed when a user has no shortcuts.",
@@ -3347,6 +3426,10 @@
}
}
},
+ "oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates." : {
+ "comment" : "A warning that Intel Macs are no longer supported.",
+ "isCommentAutoGenerated" : true
+ },
"Ollama (Local)" : {
"comment" : "A label displayed above the credits information for the local Ollie.",
"isCommentAutoGenerated" : true,
@@ -3574,6 +3657,9 @@
}
}
}
+ },
+ "OpenRouter Balance" : {
+
},
"OpenRouter Credits" : {
"comment" : "A heading for the user's OpenRouter credits.",
@@ -3604,6 +3690,12 @@
}
}
}
+ },
+ "Output" : {
+
+ },
+ "Prompt" : {
+
},
"Read access (always enabled)" : {
"localizations" : {
@@ -3632,6 +3724,9 @@
}
}
}
+ },
+ "Reasoning" : {
+
},
"Remote: %@" : {
"localizations" : {
@@ -3690,6 +3785,12 @@
}
}
}
+ },
+ "Run History" : {
+
+ },
+ "Run some agents to see usage statistics" : {
+
},
"Running locally — no credits needed!" : {
"comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.",
@@ -3721,6 +3822,10 @@
}
}
},
+ "Runs" : {
+ "comment" : "A column header for the number of runs.",
+ "isCommentAutoGenerated" : true
+ },
"Security Recommendation" : {
"localizations" : {
"da" : {
@@ -3866,6 +3971,9 @@
}
}
}
+ },
+ "Sort" : {
+
},
"SSH Key" : {
"localizations" : {
@@ -4180,6 +4288,9 @@
}
}
}
+ },
+ "Thinking…" : {
+
},
"This default prompt is always included to ensure accurate, helpful responses." : {
"localizations" : {
@@ -4296,6 +4407,15 @@
}
}
}
+ },
+ "Total" : {
+
+ },
+ "Total Credits" : {
+
+ },
+ "Total Used" : {
+
},
"Try adjusting your search or filters" : {
"comment" : "A description of the error that occurs when no models match the user's search.",
@@ -4444,6 +4564,9 @@
}
}
}
+ },
+ "Usage" : {
+
},
"Use @filename to attach files to your message" : {
"comment" : "A description of how to attach files to a message.",
@@ -4565,6 +4688,7 @@
},
"v%@" : {
"comment" : "A label showing the current version of oAI.",
+ "extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"da" : {
@@ -4744,6 +4868,10 @@
}
}
}
+ },
+ "β" : {
+ "comment" : "A beta badge.",
+ "isCommentAutoGenerated" : true
}
},
"version" : "1.1"
diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift
index f689610..ab14649 100644
--- a/oAI/Services/DatabaseService.swift
+++ b/oAI/Services/DatabaseService.swift
@@ -134,15 +134,29 @@ final class DatabaseService: Sendable {
nonisolated static let shared = DatabaseService()
private let dbQueue: DatabaseQueue
- private let isoFormatter: ISO8601DateFormatter
// Command history limit - keep most recent 5000 entries
- private static let maxHistoryEntries = 5000
+ private nonisolated static let maxHistoryEntries = 5000
+
+ // ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable).
+ private nonisolated static let isoStyle = Date.ISO8601FormatStyle(
+ dateSeparator: .dash,
+ dateTimeSeparator: .standard,
+ timeSeparator: .colon,
+ timeZoneSeparator: .colon,
+ includingFractionalSeconds: true,
+ timeZone: .gmt
+ )
+
+ private nonisolated static func isoString(from date: Date) -> String {
+ isoStyle.format(date)
+ }
+
+ private nonisolated static func isoDate(from string: String) -> Date? {
+ (try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
+ }
nonisolated private init() {
- isoFormatter = ISO8601DateFormatter()
- isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-
let fileManager = FileManager.default
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
@@ -156,7 +170,7 @@ final class DatabaseService: Sendable {
try! migrator.migrate(dbQueue)
}
- private var migrator: DatabaseMigrator {
+ private nonisolated var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in
@@ -375,7 +389,7 @@ final class DatabaseService: Sendable {
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
let now = Date()
- let nowString = isoFormatter.string(from: now)
+ let nowString = Self.isoString(from: now)
let convRecord = ConversationRecord(
id: id.uuidString,
@@ -394,7 +408,7 @@ final class DatabaseService: Sendable {
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
- timestamp: isoFormatter.string(from: msg.timestamp),
+ timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index,
modelId: msg.modelId
)
@@ -420,7 +434,7 @@ final class DatabaseService: Sendable {
/// Update an existing conversation in-place: rename it, replace all its messages.
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
- let nowString = isoFormatter.string(from: Date())
+ let nowString = Self.isoString(from: Date())
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
@@ -431,7 +445,7 @@ final class DatabaseService: Sendable {
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
- timestamp: isoFormatter.string(from: msg.timestamp),
+ timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index,
modelId: msg.modelId
)
@@ -466,7 +480,7 @@ final class DatabaseService: Sendable {
let messages = messageRecords.compactMap { record -> Message? in
guard let msgId = UUID(uuidString: record.id),
let role = MessageRole(rawValue: record.role),
- let timestamp = self.isoFormatter.date(from: record.timestamp)
+ let timestamp = Self.isoDate(from: record.timestamp)
else { return nil }
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
@@ -484,8 +498,8 @@ final class DatabaseService: Sendable {
}
guard let convId = UUID(uuidString: convRecord.id),
- let createdAt = self.isoFormatter.date(from: convRecord.createdAt),
- let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt)
+ let createdAt = Self.isoDate(from: convRecord.createdAt),
+ let updatedAt = Self.isoDate(from: convRecord.updatedAt)
else { return nil }
let conversation = Conversation(
@@ -509,8 +523,8 @@ final class DatabaseService: Sendable {
return records.compactMap { record -> Conversation? in
guard let id = UUID(uuidString: record.id),
- let createdAt = self.isoFormatter.date(from: record.createdAt),
- let updatedAt = self.isoFormatter.date(from: record.updatedAt)
+ let createdAt = Self.isoDate(from: record.createdAt),
+ let updatedAt = Self.isoDate(from: record.updatedAt)
else { return nil }
// Fetch message count without loading all messages
@@ -524,7 +538,7 @@ final class DatabaseService: Sendable {
.order(Column("sortOrder").desc)
.fetchOne(db)
- let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt
+ let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt
// Derive primary model: prefer the stored field, fall back to last message's modelId
let primaryModel = record.primaryModel ?? lastMsg?.modelId
@@ -574,7 +588,7 @@ final class DatabaseService: Sendable {
convRecord.name = name
}
- convRecord.updatedAt = self.isoFormatter.string(from: Date())
+ convRecord.updatedAt = Self.isoString(from: Date())
try convRecord.update(db)
if let messages = messages {
@@ -589,7 +603,7 @@ final class DatabaseService: Sendable {
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
- timestamp: self.isoFormatter.string(from: msg.timestamp),
+ timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index
)
}
@@ -610,7 +624,7 @@ final class DatabaseService: Sendable {
let record = HistoryRecord(
id: UUID().uuidString,
input: input,
- timestamp: isoFormatter.string(from: now)
+ timestamp: Self.isoString(from: now)
)
try? dbQueue.write { db in
@@ -643,7 +657,7 @@ final class DatabaseService: Sendable {
.fetchAll(db)
return records.compactMap { record in
- guard let date = isoFormatter.date(from: record.timestamp) else {
+ guard let date = Self.isoDate(from: record.timestamp) else {
return nil
}
return (input: record.input, timestamp: date)
@@ -659,7 +673,7 @@ final class DatabaseService: Sendable {
.fetchAll(db)
return records.compactMap { record in
- guard let date = isoFormatter.date(from: record.timestamp) else {
+ guard let date = Self.isoDate(from: record.timestamp) else {
return nil
}
return (input: record.input, timestamp: date)
@@ -672,7 +686,7 @@ final class DatabaseService: Sendable {
nonisolated func saveEmailLog(_ log: EmailLog) {
let record = EmailLogRecord(
id: log.id.uuidString,
- timestamp: isoFormatter.string(from: log.timestamp),
+ timestamp: Self.isoString(from: log.timestamp),
sender: log.sender,
subject: log.subject,
emailContent: log.emailContent,
@@ -698,7 +712,7 @@ final class DatabaseService: Sendable {
.fetchAll(db)
return records.compactMap { record in
- guard let timestamp = isoFormatter.date(from: record.timestamp),
+ guard let timestamp = Self.isoDate(from: record.timestamp),
let status = EmailLogStatus(rawValue: record.status),
let id = UUID(uuidString: record.id) else {
return nil
@@ -805,7 +819,7 @@ final class DatabaseService: Sendable {
// MARK: - Embedding Operations
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
- let now = isoFormatter.string(from: Date())
+ let now = Self.isoString(from: Date())
let record = MessageEmbeddingRecord(
message_id: messageId.uuidString,
embedding: embedding,
@@ -825,7 +839,7 @@ final class DatabaseService: Sendable {
}
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
- let now = isoFormatter.string(from: Date())
+ let now = Self.isoString(from: Date())
let record = ConversationEmbeddingRecord(
conversation_id: conversationId.uuidString,
embedding: embedding,
@@ -881,7 +895,7 @@ final class DatabaseService: Sendable {
return Array(results.prefix(limit))
}
- private func deserializeEmbedding(_ data: Data) -> [Float] {
+ private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
var embedding: [Float] = []
embedding.reserveCapacity(data.count / 4)
@@ -905,7 +919,7 @@ final class DatabaseService: Sendable {
model: String?,
tokenCount: Int?
) throws {
- let now = isoFormatter.string(from: Date())
+ let now = Self.isoString(from: Date())
let record = ConversationSummaryRecord(
id: UUID().uuidString,
conversation_id: conversationId.uuidString,
diff --git a/oAI/Services/EmbeddingService.swift b/oAI/Services/EmbeddingService.swift
index 391f1d5..29b6f17 100644
--- a/oAI/Services/EmbeddingService.swift
+++ b/oAI/Services/EmbeddingService.swift
@@ -71,7 +71,7 @@ enum EmbeddingProvider {
// MARK: - Embedding Service
final class EmbeddingService {
- static let shared = EmbeddingService()
+ nonisolated static let shared = EmbeddingService()
private let settings = SettingsService.shared
@@ -281,7 +281,7 @@ final class EmbeddingService {
// MARK: - Similarity Calculation
/// Calculate cosine similarity between two embeddings
- func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
+ nonisolated func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
guard a.count == b.count else {
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
return 0.0
diff --git a/oAI/Services/EncryptionService.swift b/oAI/Services/EncryptionService.swift
index b23d92c..4ea0864 100644
--- a/oAI/Services/EncryptionService.swift
+++ b/oAI/Services/EncryptionService.swift
@@ -29,7 +29,7 @@ import CryptoKit
import IOKit
class EncryptionService {
- static let shared = EncryptionService()
+ nonisolated static let shared = EncryptionService()
private let salt = "oAI-secure-storage-v1" // App-specific salt
private lazy var encryptionKey: SymmetricKey = {
@@ -41,7 +41,7 @@ class EncryptionService {
// MARK: - Public Interface
/// Encrypt a string value
- func encrypt(_ value: String) throws -> String {
+ nonisolated func encrypt(_ value: String) throws -> String {
guard let data = value.data(using: .utf8) else {
throw EncryptionError.invalidInput
}
@@ -55,7 +55,7 @@ class EncryptionService {
}
/// Decrypt a string value
- func decrypt(_ encryptedValue: String) throws -> String {
+ nonisolated func decrypt(_ encryptedValue: String) throws -> String {
guard let data = Data(base64Encoded: encryptedValue) else {
throw EncryptionError.invalidInput
}
diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift
index 068c4c4..2605f35 100644
--- a/oAI/Services/GitSyncService.swift
+++ b/oAI/Services/GitSyncService.swift
@@ -212,7 +212,7 @@ class GitSyncService {
// Check if conversation already exists (by ID)
if let existingId = UUID(uuidString: export.id) {
- if let existing = try? db.loadConversation(id: existingId) {
+ if (try? db.loadConversation(id: existingId)) != nil {
// Already exists - skip
log.debug("Skipping existing conversation: \(export.name)")
skipped += 1
diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift
index 67a1403..5191f70 100644
--- a/oAI/Services/SettingsService.swift
+++ b/oAI/Services/SettingsService.swift
@@ -300,6 +300,24 @@ class SettingsService {
}
}
+ /// Input bar height in points — default 80
+ var inputBarHeight: Double {
+ get { cache["inputBarHeight"].flatMap(Double.init) ?? 80.0 }
+ set {
+ cache["inputBarHeight"] = String(newValue)
+ DatabaseService.shared.setSetting(key: "inputBarHeight", value: String(newValue))
+ }
+ }
+
+ /// Whether the sidebar is visible — default true
+ var sidebarVisible: Bool {
+ get { cache["sidebarVisible"] != "false" }
+ set {
+ cache["sidebarVisible"] = String(newValue)
+ DatabaseService.shared.setSetting(key: "sidebarVisible", value: String(newValue))
+ }
+ }
+
// MARK: - MCP Permissions
var mcpCanWriteFiles: Bool {
diff --git a/oAI/Services/UpdateCheckService.swift b/oAI/Services/UpdateCheckService.swift
index d2d3c44..98f087f 100644
--- a/oAI/Services/UpdateCheckService.swift
+++ b/oAI/Services/UpdateCheckService.swift
@@ -35,6 +35,11 @@ final class UpdateCheckService {
var updateAvailable: Bool = false
var latestVersion: String? = nil
+ var downloadURL: URL? = nil
+
+ // Manual check state — drives the update alert in ContentView
+ var isCheckingManually: Bool = false
+ var manualCheckMessage: String? = nil
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
@@ -48,6 +53,24 @@ final class UpdateCheckService {
}
}
+ /// Manual check triggered from the Help menu. Non-blocking — result surfaces via manualCheckMessage.
+ func checkForUpdatesManually() {
+ guard !isCheckingManually else { return }
+ isCheckingManually = true
+ Task.detached(priority: .background) {
+ await self.performCheck()
+ let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
+ await MainActor.run {
+ if self.updateAvailable, let v = self.latestVersion {
+ self.manualCheckMessage = String(localized: "Version \(v) is available.")
+ } else {
+ self.manualCheckMessage = String(localized: "You're up to date (v\(current)).")
+ }
+ self.isCheckingManually = false
+ }
+ }
+ }
+
private func performCheck() async {
guard let url = URL(string: apiURL) else { return }
@@ -69,9 +92,16 @@ final class UpdateCheckService {
let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
+ // Extract direct DMG download URL from release assets
+ let dmgURL: URL? = (release["assets"] as? [[String: Any]])?
+ .first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") }
+ .flatMap { $0["browser_download_url"] as? String }
+ .flatMap { URL(string: $0) }
+
if isNewer(latestVer, than: currentVer) {
await MainActor.run {
self.latestVersion = latestVer
+ self.downloadURL = dmgURL
self.updateAvailable = true
}
}
diff --git a/oAI/Utilities/Logging.swift b/oAI/Utilities/Logging.swift
index 7ca90e1..483fd92 100644
--- a/oAI/Utilities/Logging.swift
+++ b/oAI/Utilities/Logging.swift
@@ -114,41 +114,43 @@ final class FileLogger: @unchecked Sendable {
// MARK: - App Logger (wraps os.Logger + file)
-struct AppLogger {
- let osLogger: Logger
+// os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are
+// nonisolated — FileLogger runs on its own serial queue, os.Logger dispatches to main actor.
+struct AppLogger: Sendable {
+ let subsystem: String
let category: String
- func debug(_ message: String) {
+ nonisolated func debug(_ message: String) {
FileLogger.shared.write(.debug, category: category, message: message)
- osLogger.debug("\(message, privacy: .public)")
+ Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).debug("\(message, privacy: .public)") }
}
- func info(_ message: String) {
+ nonisolated func info(_ message: String) {
FileLogger.shared.write(.info, category: category, message: message)
- osLogger.info("\(message, privacy: .public)")
+ Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).info("\(message, privacy: .public)") }
}
- func warning(_ message: String) {
+ nonisolated func warning(_ message: String) {
FileLogger.shared.write(.warning, category: category, message: message)
- osLogger.warning("\(message, privacy: .public)")
+ Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).warning("\(message, privacy: .public)") }
}
- func error(_ message: String) {
+ nonisolated func error(_ message: String) {
FileLogger.shared.write(.error, category: category, message: message)
- osLogger.error("\(message, privacy: .public)")
+ Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") }
}
}
// MARK: - Log Namespace
enum Log {
- private static let subsystem = "com.oai.oAI"
+ private nonisolated static let subsystem = "com.oai.oAI"
- static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api")
- static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database")
- static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp")
- static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings")
- static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search")
- static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui")
- static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general")
+ nonisolated static let api = AppLogger(subsystem: subsystem, category: "api")
+ nonisolated static let db = AppLogger(subsystem: subsystem, category: "database")
+ nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp")
+ nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings")
+ nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
+ nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
+ nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
}
diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift
index ab5a8e9..d398f93 100644
--- a/oAI/ViewModels/ChatViewModel.swift
+++ b/oAI/ViewModels/ChatViewModel.swift
@@ -1313,7 +1313,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Append the complete system prompt (default + custom)
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
- var messagesToSend: [Message] = memoryEnabled
+ let messagesToSend: [Message] = memoryEnabled
? messages.filter { $0.role != .system }
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
@@ -1659,7 +1659,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
let delay = Double(1 << attempt) // 2s, 4s, 8s
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
- await MainActor.run {
+ _ = await MainActor.run {
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
}
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift
index 52180c1..74c6943 100644
--- a/oAI/Views/Main/ChatView.swift
+++ b/oAI/Views/Main/ChatView.swift
@@ -37,10 +37,6 @@ struct ChatView: View {
HeaderView(
provider: viewModel.currentProvider,
model: viewModel.selectedModel,
- stats: viewModel.sessionStats,
- onlineMode: viewModel.onlineMode,
- mcpEnabled: viewModel.mcpEnabled,
- mcpStatus: viewModel.mcpStatus,
onModelSelect: onModelSelect,
onProviderChange: onProviderChange
)
@@ -85,10 +81,13 @@ struct ChatView: View {
InputBar(
text: $viewModel.inputText,
isGenerating: viewModel.isGenerating,
- mcpStatus: viewModel.mcpStatus,
onlineMode: viewModel.onlineMode,
onSend: viewModel.sendMessage,
- onCancel: viewModel.cancelGeneration
+ onCancel: viewModel.cancelGeneration,
+ onToggleOnline: {
+ viewModel.onlineMode.toggle()
+ SettingsService.shared.onlineMode = viewModel.onlineMode
+ }
)
// Footer
@@ -96,7 +95,9 @@ struct ChatView: View {
stats: viewModel.sessionStats,
conversationName: viewModel.currentConversationName,
hasUnsavedChanges: viewModel.hasUnsavedChanges,
- onQuickSave: viewModel.quickSave
+ onQuickSave: viewModel.quickSave,
+ onlineMode: viewModel.onlineMode,
+ mcpEnabled: viewModel.mcpEnabled
)
}
.background(Color.oaiBackground)
diff --git a/oAI/Views/Main/ContentView.swift b/oAI/Views/Main/ContentView.swift
index 28dba53..0876801 100644
--- a/oAI/Views/Main/ContentView.swift
+++ b/oAI/Views/Main/ContentView.swift
@@ -2,7 +2,7 @@
// ContentView.swift
// oAI
//
-// Root navigation container
+// Root navigation container — NavigationSplitView with collapsible sidebar
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
@@ -24,30 +24,34 @@
import SwiftUI
+#if os(macOS)
+import Darwin // uname, sysctlbyname
+#endif
struct ContentView: View {
@Environment(ChatViewModel.self) var chatViewModel
+ private var updateService = UpdateCheckService.shared
+ @State private var columnVisibility: NavigationSplitViewVisibility = .all
+ @State private var showIntelWarning = false
var body: some View {
@Bindable var vm = chatViewModel
- NavigationStack {
+ NavigationSplitView(columnVisibility: $columnVisibility) {
+ SidebarView()
+ .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
+ } detail: {
ChatView(
onModelSelect: { chatViewModel.showModelSelector = true },
onProviderChange: { newProvider in
chatViewModel.changeProvider(newProvider)
}
)
- .navigationTitle("")
- .toolbar {
- #if os(macOS)
- macOSToolbar
- #endif
- }
}
.frame(minWidth: 860, minHeight: 560)
#if os(macOS)
.onAppear {
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
+ checkIntelWarning()
}
.onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.command) {
@@ -65,7 +69,6 @@ struct ContentView: View {
let oldModel = chatViewModel.selectedModel
chatViewModel.selectModel(model)
chatViewModel.showModelSelector = false
- // Trigger auto-save on model switch
Task {
await chatViewModel.onModelSwitch(from: oldModel, to: model)
}
@@ -113,125 +116,56 @@ struct ContentView: View {
chatViewModel.inputText = input
})
}
+ .alert("Intel Mac Support Ending", isPresented: $showIntelWarning) {
+ Button("Got It") {
+ UserDefaults.standard.set(true, forKey: "hasShownIntelWarning")
+ }
+ } message: {
+ Text("oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.")
+ }
+ .alert("Software Update", isPresented: Binding(
+ get: { updateService.manualCheckMessage != nil },
+ set: { if !$0 { updateService.manualCheckMessage = nil } }
+ )) {
+ if updateService.updateAvailable {
+ if let url = updateService.downloadURL {
+ Button("Download v\(updateService.latestVersion ?? "")") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+ Button("Release Page") { updateService.openReleasesPage() }
+ Button("Later", role: .cancel) { }
+ } else {
+ Button("OK", role: .cancel) { }
+ }
+ } message: {
+ Text(updateService.manualCheckMessage ?? "")
+ }
}
#if os(macOS)
- @ToolbarContentBuilder
- private var macOSToolbar: some ToolbarContent {
- let settings = SettingsService.shared
- let showLabels = settings.showToolbarLabels
- let iconSize = settings.toolbarIconSize
+ private func checkIntelWarning() {
+ guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
+ guard isIntelNative || isRosetta else { return }
+ showIntelWarning = true
+ }
- ToolbarItemGroup(placement: .automatic) {
- // New conversation
- Button(action: { chatViewModel.newConversation() }) {
- ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, iconSize: iconSize)
- }
- .keyboardShortcut("n", modifiers: .command)
- .help("New conversation")
-
- Button(action: { chatViewModel.showConversations = true }) {
- ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize)
- }
- .keyboardShortcut("l", modifiers: .command)
- .help("Saved conversations (Cmd+L)")
-
- Button(action: { chatViewModel.showHistory = true }) {
- ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize)
- }
- .keyboardShortcut("h", modifiers: .command)
- .help("Command history (Cmd+H)")
-
- Spacer()
-
- Button(action: { chatViewModel.showModelSelector = true }) {
- ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize)
- }
- .keyboardShortcut("m", modifiers: .command)
- .help("Select AI model (Cmd+M)")
-
- Button(action: {
- if let model = chatViewModel.selectedModel {
- chatViewModel.modelInfoTarget = model
- }
- }) {
- ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, iconSize: iconSize)
- }
- .keyboardShortcut("i", modifiers: .command)
- .help("Model info (Cmd+I)")
- .disabled(chatViewModel.selectedModel == nil)
-
- Button(action: { chatViewModel.showStats = true }) {
- ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, iconSize: iconSize)
- }
- .help("Session statistics")
-
- Button(action: { chatViewModel.showCredits = true }) {
- ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, iconSize: iconSize)
- }
- .help("Check API credits")
-
- Spacer()
-
- Button(action: { chatViewModel.showSettings = true }) {
- ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, iconSize: iconSize)
- }
- .keyboardShortcut(",", modifiers: .command)
- .help("Settings (Cmd+,)")
-
- Button(action: { chatViewModel.showHelp = true }) {
- ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize)
- }
- .keyboardShortcut("/", modifiers: .command)
- .help("Help & commands (Cmd+/)")
+ private var isIntelNative: Bool {
+ var systemInfo = utsname()
+ uname(&systemInfo)
+ let machine = withUnsafeBytes(of: &systemInfo.machine) {
+ String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
}
+ return machine.contains("x86_64")
+ }
+
+ private var isRosetta: Bool {
+ var ret: Int32 = 0
+ var size = MemoryLayout.size
+ sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
+ return ret == 1
}
#endif
-
-}
-
-// Helper view for toolbar labels
-struct ToolbarLabel: View {
- let title: LocalizedStringKey
- let systemImage: String
- let showLabels: Bool
- let iconSize: Double
-
- // imageScale for the original range (≤32); explicit font size for the new extra-large range (>32)
- private var scale: Image.Scale {
- switch iconSize {
- case ...18: return .small
- case 19...24: return .medium
- default: return .large
- }
- }
-
- var body: some View {
- if iconSize > 32 {
- // Extra-large: explicit font size above the system .large ceiling
- // Offset by 16 so slider 34→18pt, 36→20pt, 38→22pt, 40→24pt
- if showLabels {
- Label(title, systemImage: systemImage)
- .labelStyle(.titleAndIcon)
- .font(.system(size: iconSize - 16))
- } else {
- Label(title, systemImage: systemImage)
- .labelStyle(.iconOnly)
- .font(.system(size: iconSize - 16))
- }
- } else {
- // Original behaviour — imageScale keeps existing look intact
- if showLabels {
- Label(title, systemImage: systemImage)
- .labelStyle(.titleAndIcon)
- .imageScale(scale)
- } else {
- Label(title, systemImage: systemImage)
- .labelStyle(.iconOnly)
- .imageScale(scale)
- }
- }
- }
}
#Preview {
diff --git a/oAI/Views/Main/FooterView.swift b/oAI/Views/Main/FooterView.swift
index 090f200..178be1d 100644
--- a/oAI/Views/Main/FooterView.swift
+++ b/oAI/Views/Main/FooterView.swift
@@ -30,15 +30,22 @@ struct FooterView: View {
let conversationName: String?
let hasUnsavedChanges: Bool
let onQuickSave: (() -> Void)?
+ let onlineMode: Bool
+ let mcpEnabled: Bool
+ private let settings = SettingsService.shared
init(stats: SessionStats,
conversationName: String? = nil,
hasUnsavedChanges: Bool = false,
- onQuickSave: (() -> Void)? = nil) {
+ onQuickSave: (() -> Void)? = nil,
+ onlineMode: Bool = false,
+ mcpEnabled: Bool = false) {
self.stats = stats
self.conversationName = conversationName
self.hasUnsavedChanges = hasUnsavedChanges
self.onQuickSave = onQuickSave
+ self.onlineMode = onlineMode
+ self.mcpEnabled = mcpEnabled
}
var body: some View {
@@ -71,6 +78,21 @@ struct FooterView: View {
Spacer()
+ // Status pills — Online, MCP, Sync
+ #if os(macOS)
+ HStack(spacing: 6) {
+ if onlineMode {
+ StatusPill(icon: "globe", label: "Online", color: .green)
+ }
+ if mcpEnabled {
+ StatusPill(icon: "folder", label: "MCP", color: .blue)
+ }
+ if settings.syncEnabled && settings.syncAutoSave {
+ SyncStatusPill()
+ }
+ }
+ #endif
+
// Save indicator (only when chat has messages)
if stats.messageCount > 0 {
SaveIndicator(
@@ -80,17 +102,11 @@ struct FooterView: View {
)
}
- // Update available badge
+ // Update available badge (shows only when an update exists — no version number)
#if os(macOS)
UpdateBadge()
#endif
- // Shortcuts hint
- #if os(macOS)
- Text("⌘N New • ⌘M Model • ⌘S Save")
- .font(.caption2)
- .foregroundColor(.oaiSecondary)
- #endif
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
@@ -242,7 +258,6 @@ struct SyncStatusFooter: View {
struct UpdateBadge: View {
private let updater = UpdateCheckService.shared
- private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
var body: some View {
if updater.updateAvailable {
@@ -258,10 +273,6 @@ struct UpdateBadge: View {
}
.buttonStyle(.plain)
.help("A new version is available — click to open the releases page")
- } else {
- Text("v\(currentVersion)")
- .font(.caption2)
- .foregroundColor(.oaiSecondary)
}
}
}
diff --git a/oAI/Views/Main/HeaderView.swift b/oAI/Views/Main/HeaderView.swift
index 741be09..4280a9d 100644
--- a/oAI/Views/Main/HeaderView.swift
+++ b/oAI/Views/Main/HeaderView.swift
@@ -2,7 +2,8 @@
// HeaderView.swift
// oAI
//
-// Header bar with provider, model, and stats
+// Slim header — provider, model name, star only.
+// Status pills and stats live in SidebarView and FooterView respectively.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
@@ -28,18 +29,13 @@ import SwiftUI
struct HeaderView: View {
let provider: Settings.Provider
let model: ModelInfo?
- let stats: SessionStats
- let onlineMode: Bool
- let mcpEnabled: Bool
- let mcpStatus: String?
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
private let settings = SettingsService.shared
private let registry = ProviderRegistry.shared
- private let gitSync = GitSyncService.shared
var body: some View {
- HStack(spacing: 20) {
+ HStack(spacing: 12) {
// Provider picker dropdown — only shows configured providers
Menu {
ForEach(registry.configuredProviders, id: \.self) { p in
@@ -75,7 +71,7 @@ struct HeaderView: View {
.fixedSize()
.help("Switch provider")
- // Model info (clickable → model selector)
+ // Model name (clickable → model selector)
Button(action: onModelSelect) {
if let model = model {
HStack(spacing: 6) {
@@ -116,7 +112,6 @@ struct HeaderView: View {
Text("No model selected")
.font(.system(size: settings.guiTextSize))
.foregroundColor(.oaiSecondary)
-
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
@@ -126,6 +121,7 @@ struct HeaderView: View {
.buttonStyle(.plain)
.help("Select model")
+ // Favourite star
if let model = model {
let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) {
@@ -138,40 +134,9 @@ struct HeaderView: View {
}
Spacer()
-
- // Status indicators
- HStack(spacing: 8) {
- if model?.capabilities.imageGeneration == true {
- StatusPill(icon: "paintbrush", label: "Image", color: .purple)
- }
- if onlineMode {
- StatusPill(icon: "globe", label: "Online", color: .green)
- }
- if mcpEnabled {
- StatusPill(icon: "folder", label: "MCP", color: .blue)
- }
- if settings.syncEnabled && settings.syncAutoSave {
- SyncStatusPill()
- }
- }
-
- // Divider between status and stats
- if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) {
- Divider()
- .frame(height: 16)
- .opacity(0.5)
- }
-
- // Quick stats
- HStack(spacing: 16) {
- StatItem(icon: "message", value: "\(stats.messageCount)")
- StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay)
- StatItem(icon: "dollarsign", value: stats.totalCostDisplay)
- }
- .font(.caption)
}
.padding(.horizontal, 16)
- .padding(.vertical, 10)
+ .padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
@@ -182,22 +147,7 @@ struct HeaderView: View {
}
}
-struct StatItem: View {
- let icon: String
- let value: String
- private let settings = SettingsService.shared
-
- var body: some View {
- HStack(spacing: 4) {
- Image(systemName: icon)
- .font(.system(size: settings.guiTextSize - 3))
- .foregroundColor(.oaiSecondary)
- Text(value)
- .font(.system(size: settings.guiTextSize - 1, weight: .medium))
- .foregroundColor(.oaiPrimary)
- }
- }
-}
+// MARK: - Status Pills (used by SidebarView)
struct StatusPill: View {
let icon: String
@@ -284,15 +234,6 @@ struct SyncStatusPill: View {
HeaderView(
provider: .openrouter,
model: ModelInfo.mockModels.first,
- stats: SessionStats(
- totalInputTokens: 125,
- totalOutputTokens: 434,
- totalCost: 0.00111,
- messageCount: 4
- ),
- onlineMode: true,
- mcpEnabled: true,
- mcpStatus: "MCP",
onModelSelect: {},
onProviderChange: { _ in }
)
diff --git a/oAI/Views/Main/InputBar.swift b/oAI/Views/Main/InputBar.swift
index 7bc7181..9985223 100644
--- a/oAI/Views/Main/InputBar.swift
+++ b/oAI/Views/Main/InputBar.swift
@@ -2,7 +2,7 @@
// InputBar.swift
// oAI
//
-// Message input bar with status indicators
+// Message input bar with resizable height and online toggle
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
@@ -24,20 +24,31 @@
import SwiftUI
+#if os(macOS)
+import AppKit
+#endif
struct InputBar: View {
@Binding var text: String
let isGenerating: Bool
- let mcpStatus: String?
let onlineMode: Bool
let onSend: () -> Void
let onCancel: () -> Void
+ let onToggleOnline: () -> Void
private let settings = SettingsService.shared
+
+ // Resizable input height — persisted to settings
+ @State private var inputHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
+ @State private var dragStartHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
+
@State private var showCommandDropdown = false
@State private var selectedSuggestionIndex: Int = 0
@FocusState private var isInputFocused: Bool
+ private static let minInputHeight: CGFloat = 56
+ private static let maxInputHeight: CGFloat = 320
+
/// Commands that execute immediately without additional arguments
private static let immediateCommands: Set = [
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
@@ -56,48 +67,42 @@ struct InputBar: View {
CommandSuggestionsView(
searchText: text,
selectedIndex: selectedSuggestionIndex,
- onSelect: { command in
- selectCommand(command)
- }
+ onSelect: selectCommand
)
.frame(width: 400)
.frame(maxHeight: 200)
.transition(.move(edge: .bottom).combined(with: .opacity))
Spacer()
}
- .padding(.leading, 96) // Align with input box (status badges + spacing)
+ .padding(.leading, 16)
}
- // Input area
- HStack(alignment: .bottom, spacing: 12) {
- // Status indicators
- HStack(spacing: 6) {
- if let mcp = mcpStatus {
- StatusBadge(text: mcp, color: .blue)
- }
- if onlineMode {
- StatusBadge(text: "🌐", color: .green)
- }
- }
- .frame(width: 80, alignment: .leading)
+ // Drag-to-resize handle
+ dragHandle
- // Text input
+ // Input row
+ HStack(alignment: .bottom, spacing: 12) {
+ // Text input with globe toggle in bottom-left corner
ZStack(alignment: .topLeading) {
+ // Placeholder
if text.isEmpty {
Text("Type a message or / for commands...")
.font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiSecondary)
.padding(.horizontal, 12)
- .padding(.vertical, 10)
+ .padding(.top, 10)
+ .allowsHitTesting(false)
}
+ // Editor — fills the fixed-height box, bottom area reserved for globe
TextEditor(text: $text)
.font(.system(size: settings.inputTextSize))
.foregroundColor(.oaiPrimary)
.scrollContentBackground(.hidden)
- .frame(minHeight: 44, maxHeight: 120)
.padding(.horizontal, 8)
- .padding(.vertical, 6)
+ .padding(.top, 6)
+ .padding(.bottom, 30)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
.focused($isInputFocused)
.onChange(of: text) {
showCommandDropdown = text.hasPrefix("/")
@@ -105,7 +110,6 @@ struct InputBar: View {
}
#if os(macOS)
.onKeyPress(.upArrow) {
- // Navigate command dropdown
if showCommandDropdown && selectedSuggestionIndex > 0 {
selectedSuggestionIndex -= 1
return .handled
@@ -113,7 +117,6 @@ struct InputBar: View {
return .ignored
}
.onKeyPress(.downArrow) {
- // Navigate command dropdown
if showCommandDropdown {
let count = CommandSuggestionsView.filteredCommands(for: text).count
if selectedSuggestionIndex < count - 1 {
@@ -124,25 +127,12 @@ struct InputBar: View {
return .ignored
}
.onKeyPress(.escape) {
- // If command dropdown is showing, close it
- if showCommandDropdown {
- showCommandDropdown = false
- return .handled
- }
- // If model is generating, cancel it
- if isGenerating {
- onCancel()
- return .handled
- }
+ if showCommandDropdown { showCommandDropdown = false; return .handled }
+ if isGenerating { onCancel(); return .handled }
return .ignored
}
.onKeyPress(.return, phases: .down) { press in
- // Shift+Return: always insert newline (let system handle)
- if press.modifiers.contains(.shift) {
- return .ignored
- }
-
- // If command dropdown is showing, select the highlighted command
+ if press.modifiers.contains(.shift) { return .ignored }
if showCommandDropdown {
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
@@ -150,27 +140,40 @@ struct InputBar: View {
return .handled
}
}
- // Return (plain or with Cmd): send message
- if !text.isEmpty {
- onSend()
- return .handled
- }
- // Empty text: do nothing
+ if !text.isEmpty { onSend(); return .handled }
return .handled
}
#endif
+
+ // Online / offline toggle — bottom-left of the text box
+ VStack {
+ Spacer()
+ HStack {
+ Button(action: onToggleOnline) {
+ Image(systemName: onlineMode ? "globe" : "network.slash")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(onlineMode ? Color.green : Color.secondary)
+ .padding(8)
+ }
+ .buttonStyle(.plain)
+ .help(onlineMode
+ ? "Online mode on — click to go offline"
+ : "Offline — click to go online")
+ Spacer()
+ }
+ }
}
+ .frame(height: inputHeight)
.background(Color.oaiSurface)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
)
-
- // Action buttons
+
+ // Send / stop + attach buttons
VStack(spacing: 8) {
#if os(macOS)
- // File attach button
Button(action: pickFile) {
Image(systemName: "paperclip")
.font(.title2)
@@ -209,21 +212,47 @@ struct InputBar: View {
}
}
+ // MARK: - Drag handle
+
+ private var dragHandle: some View {
+ Color.clear
+ .frame(height: 8)
+ .frame(maxWidth: .infinity)
+ .contentShape(Rectangle())
+ .overlay {
+ Capsule()
+ .fill(Color.secondary.opacity(0.25))
+ .frame(width: 36, height: 3)
+ }
+ .gesture(
+ DragGesture(minimumDistance: 1)
+ .onChanged { value in
+ let proposed = dragStartHeight - value.translation.height
+ inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed))
+ }
+ .onEnded { _ in
+ dragStartHeight = inputHeight
+ settings.inputBarHeight = Double(inputHeight)
+ }
+ )
+ #if os(macOS)
+ .onHover { hovering in
+ if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
+ }
+ #endif
+ }
+
+ // MARK: - Helpers
+
private func selectCommand(_ command: String) {
showCommandDropdown = false
if Self.immediateCommands.contains(command) {
- // Execute immediately
text = command
onSend()
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
- if shortcut.needsInput {
- text = command + " "
- } else {
- text = command
- onSend()
- }
+ text = shortcut.needsInput ? command + " " : command
+ if !shortcut.needsInput { onSend() }
} else {
- // Put in input for user to complete
text = command + " "
}
}
@@ -235,36 +264,14 @@ struct InputBar: View {
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.message = "Select files to attach"
-
guard panel.runModal() == .OK else { return }
-
- let paths = panel.urls.map { $0.path }
- // Use @ format (angle brackets) to safely handle paths with spaces
- let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ")
-
- if text.isEmpty {
- text = attachmentText + " "
- } else {
- text += " " + attachmentText
- }
+ let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ")
+ text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText
}
#endif
}
-struct StatusBadge: View {
- let text: String
- let color: Color
-
- var body: some View {
- Text(text)
- .font(.caption)
- .foregroundColor(color)
- .padding(.horizontal, 6)
- .padding(.vertical, 3)
- .background(color.opacity(0.15))
- .cornerRadius(4)
- }
-}
+// MARK: - Command suggestions
struct CommandSuggestionsView: View {
let searchText: String
@@ -304,10 +311,9 @@ struct CommandSuggestionsView: View {
]
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
- let shortcuts = SettingsService.shared.userShortcuts.map { s in
+ SettingsService.shared.userShortcuts.map { s in
(s.command, LocalizedStringKey("⚡ \(s.description)"))
- }
- return builtInCommands + shortcuts
+ } + builtInCommands
}
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
@@ -344,26 +350,20 @@ struct CommandSuggestionsView: View {
.id(suggestion.command)
if index < suggestions.count - 1 {
- Divider()
- .background(Color.oaiBorder)
+ Divider().background(Color.oaiBorder)
}
}
}
}
.onChange(of: selectedIndex) {
if selectedIndex < suggestions.count {
- withAnimation {
- proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
- }
+ withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) }
}
}
}
.background(Color.oaiSurface)
.cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.oaiBorder, lineWidth: 1)
- )
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1))
}
}
@@ -373,10 +373,10 @@ struct CommandSuggestionsView: View {
InputBar(
text: .constant(""),
isGenerating: false,
- mcpStatus: "📁 Files",
onlineMode: true,
onSend: {},
- onCancel: {}
+ onCancel: {},
+ onToggleOnline: {}
)
}
.background(Color.oaiBackground)
diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift
new file mode 100644
index 0000000..eb73e14
--- /dev/null
+++ b/oAI/Views/Main/SidebarView.swift
@@ -0,0 +1,216 @@
+//
+// SidebarView.swift
+// oAI
+//
+// Collapsible sidebar: new chat, conversation list, status pills
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Rune Olsen
+//
+// This file is part of oAI.
+//
+// oAI is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// oAI is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+// Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public
+// License along with oAI. If not, see .
+
+
+import SwiftUI
+#if os(macOS)
+import AppKit
+#endif
+
+struct SidebarView: View {
+ @Environment(ChatViewModel.self) private var chatViewModel
+ @State private var conversations: [Conversation] = []
+ @State private var searchText = ""
+
+ private var filteredConversations: [Conversation] {
+ guard !searchText.isEmpty else { return conversations }
+ return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) }
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // New Chat button
+ Button(action: { chatViewModel.newConversation() }) {
+ HStack(spacing: 8) {
+ Image(systemName: "square.and.pencil")
+ .font(.system(size: 14))
+ Text("New Chat")
+ .font(.system(size: 14, weight: .medium))
+ Spacer()
+ }
+ .foregroundColor(.oaiPrimary)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+
+ // Search field
+ HStack(spacing: 6) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ TextField("Search conversations…", text: $searchText)
+ .textFieldStyle(.plain)
+ .font(.system(size: 13))
+ if !searchText.isEmpty {
+ Button {
+ searchText = ""
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.tertiary)
+ }
+ .buttonStyle(.plain)
+ }
+ Divider().frame(height: 12)
+ Button {
+ chatViewModel.showConversations = true
+ } label: {
+ Image(systemName: "slider.horizontal.3")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .help("Advanced search — semantic search, bulk delete, export")
+ }
+ .padding(7)
+ .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
+ .padding(.horizontal, 8)
+ .padding(.bottom, 6)
+
+ Divider()
+
+ // Conversation list
+ if filteredConversations.isEmpty {
+ Spacer()
+ VStack(spacing: 8) {
+ Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
+ .font(.title2)
+ .foregroundStyle(.tertiary)
+ Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ } else {
+ List {
+ ForEach(filteredConversations) { conversation in
+ SidebarConversationRow(conversation: conversation)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ chatViewModel.loadConversation(conversation)
+ }
+ .listRowBackground(
+ chatViewModel.currentConversationName == conversation.name
+ ? Color.oaiAccent.opacity(0.15)
+ : Color.clear
+ )
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button(role: .destructive) {
+ deleteConversation(conversation)
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ Button {
+ renameConversation(conversation)
+ } label: {
+ Label("Rename", systemImage: "pencil")
+ }
+ .tint(.orange)
+ }
+ }
+ }
+ .listStyle(.sidebar)
+ }
+
+ }
+ .onAppear { loadConversations() }
+ .onChange(of: chatViewModel.currentConversationName) { loadConversations() }
+ .onChange(of: chatViewModel.messages.count) { loadConversations() }
+ }
+
+ private func loadConversations() {
+ conversations = (try? DatabaseService.shared.listConversations()) ?? []
+ }
+
+ private func deleteConversation(_ conversation: Conversation) {
+ _ = try? DatabaseService.shared.deleteConversation(id: conversation.id)
+ withAnimation {
+ conversations.removeAll { $0.id == conversation.id }
+ }
+ }
+
+ private func renameConversation(_ conversation: Conversation) {
+ #if os(macOS)
+ let alert = NSAlert()
+ alert.messageText = "Rename Conversation"
+ alert.addButton(withTitle: "Rename")
+ alert.addButton(withTitle: "Cancel")
+ let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
+ input.stringValue = conversation.name
+ input.selectText(nil)
+ alert.accessoryView = input
+ alert.window.initialFirstResponder = input
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
+ let newName = input.stringValue.trimmingCharacters(in: .whitespaces)
+ guard !newName.isEmpty, newName != conversation.name else { return }
+ do {
+ _ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil)
+ if let i = conversations.firstIndex(where: { $0.id == conversation.id }) {
+ conversations[i].name = newName
+ conversations[i].updatedAt = Date()
+ }
+ chatViewModel.didRenameConversation(id: conversation.id, newName: newName)
+ } catch {
+ Log.db.error("Failed to rename conversation: \(error.localizedDescription)")
+ }
+ #endif
+ }
+}
+
+// MARK: - Sidebar conversation row
+
+struct SidebarConversationRow: View {
+ let conversation: Conversation
+
+ private var formattedDate: String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "dd.MM.yyyy"
+ return formatter.string(from: conversation.updatedAt)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(conversation.name)
+ .font(.system(size: 13, weight: .medium))
+ .lineLimit(1)
+ HStack(spacing: 4) {
+ Text("^[\(conversation.messageCount) message](inflect: true)")
+ .font(.system(size: 11))
+ Text("·")
+ .font(.system(size: 11))
+ Text(formattedDate)
+ .font(.system(size: 11))
+ }
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+#Preview {
+ SidebarView()
+ .environment(ChatViewModel())
+ .frame(width: 240, height: 600)
+}
diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift
index abdd8b6..2717fe3 100644
--- a/oAI/oAIApp.swift
+++ b/oAI/oAIApp.swift
@@ -92,6 +92,8 @@ struct oAIApp: App {
CommandGroup(after: .newItem) {
Button("Open Chat…") { chatViewModel.showConversations = true }
.keyboardShortcut("o", modifiers: .command)
+ Button("Search Conversations") { chatViewModel.showConversations = true }
+ .keyboardShortcut("l", modifiers: .command)
}
CommandGroup(replacing: .saveItem) {
@@ -113,10 +115,44 @@ struct oAIApp: App {
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
}
+ // ── View menu ─────────────────────────────────────────────────
+ CommandMenu("View") {
+ Button("Select Model") { chatViewModel.showModelSelector = true }
+ .keyboardShortcut("m", modifiers: .command)
+
+ Button("Model Info") {
+ chatViewModel.modelInfoTarget = chatViewModel.selectedModel
+ }
+ .keyboardShortcut("i", modifiers: .command)
+ .disabled(chatViewModel.selectedModel == nil)
+
+ Divider()
+
+ Button("Command History") { chatViewModel.showHistory = true }
+ .keyboardShortcut("h", modifiers: .command)
+
+ Button("In-App Help") { chatViewModel.showHelp = true }
+ .keyboardShortcut("/", modifiers: .command)
+
+ Button("Credits") { chatViewModel.showCredits = true }
+
+ Divider()
+
+ Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") {
+ chatViewModel.onlineMode.toggle()
+ }
+ .keyboardShortcut("o", modifiers: [.command, .shift])
+ }
+
// ── Help menu ─────────────────────────────────────────────────
CommandGroup(replacing: .help) {
Button("oAI Help") { openHelp() }
.keyboardShortcut("?", modifiers: .command)
+ Divider()
+ Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
+ UpdateCheckService.shared.checkForUpdatesManually()
+ }
+ .disabled(UpdateCheckService.shared.isCheckingManually)
}
}
#endif