New version v2.3.6

This commit is contained in:
2026-03-04 10:19:16 +01:00
parent 65a35cd508
commit 49f842f119
52 changed files with 14034 additions and 358 deletions

4750
oAI/Localizable.xcstrings Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -63,6 +63,9 @@ struct Message: Identifiable, Codable, Equatable {
// Tool call details (not persisted in-memory only for expandable display)
var toolCalls: [ToolCallDetail]? = nil
// Reasoning/thinking content (not persisted in-memory only)
var thinkingContent: String? = nil
init(
id: UUID = UUID(),
role: MessageRole,

View File

@@ -45,6 +45,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
let tools: Bool // Function calling
let online: Bool // Web search
var imageGeneration: Bool = false // Image output
var thinking: Bool = false // Reasoning/thinking tokens
}
struct Architecture: Codable, Hashable {

View File

@@ -61,6 +61,11 @@ struct ProviderCapabilities: Codable {
// MARK: - Chat Request
struct ReasoningConfig: Sendable {
let effort: String // "high", "medium", "low", "minimal"
let exclude: Bool // true = use reasoning internally, hide from response
}
struct ChatRequest {
let messages: [Message]
let model: String
@@ -72,6 +77,7 @@ struct ChatRequest {
let tools: [Tool]?
let onlineMode: Bool
let imageGeneration: Bool
let reasoning: ReasoningConfig?
init(
messages: [Message],
@@ -83,7 +89,8 @@ struct ChatRequest {
systemPrompt: String? = nil,
tools: [Tool]? = nil,
onlineMode: Bool = false,
imageGeneration: Bool = false
imageGeneration: Bool = false,
reasoning: ReasoningConfig? = nil
) {
self.messages = messages
self.model = model
@@ -95,6 +102,7 @@ struct ChatRequest {
self.tools = tools
self.onlineMode = onlineMode
self.imageGeneration = imageGeneration
self.reasoning = reasoning
}
}
@@ -174,6 +182,14 @@ struct StreamChunk {
let content: String?
let role: String?
let images: [Data]?
let thinking: String? // reasoning/thinking tokens (nil if excluded or not supported)
init(content: String?, role: String?, images: [Data]? = nil, thinking: String? = nil) {
self.content = content
self.role = role
self.images = images
self.thinking = thinking
}
}
var deltaContent: String? {

View File

@@ -27,6 +27,16 @@ import Foundation
// MARK: - API Request
struct ReasoningAPIConfig: Codable {
let effort: String
let exclude: Bool?
enum CodingKeys: String, CodingKey {
case effort
case exclude
}
}
struct OpenRouterChatRequest: Codable {
let model: String
let messages: [APIMessage]
@@ -37,6 +47,7 @@ struct OpenRouterChatRequest: Codable {
let tools: [Tool]?
let toolChoice: String?
let modalities: [String]?
let reasoning: ReasoningAPIConfig?
struct APIMessage: Codable {
let role: String
@@ -126,6 +137,7 @@ struct OpenRouterChatRequest: Codable {
case tools
case toolChoice = "tool_choice"
case modalities
case reasoning
}
}
@@ -205,7 +217,46 @@ struct OpenRouterStreamChunk: Codable {
struct Delta: Codable {
let role: String?
let content: String?
let reasoning: String?
// images[] from top-level delta field (custom OpenRouter format)
let images: [OpenRouterChatResponse.ImageOutput]?
// images extracted from content[] array (standard OpenAI content-block format)
let contentBlockImages: [OpenRouterChatResponse.ImageOutput]
private struct ContentBlock: Codable {
let type: String
let text: String?
let imageUrl: OpenRouterChatResponse.ImageOutput.ImageURL?
enum CodingKeys: String, CodingKey {
case type, text
case imageUrl = "image_url"
}
}
enum CodingKeys: String, CodingKey {
case role, content, images, reasoning
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
role = try c.decodeIfPresent(String.self, forKey: .role)
images = try c.decodeIfPresent([OpenRouterChatResponse.ImageOutput].self, forKey: .images)
reasoning = try c.decodeIfPresent(String.self, forKey: .reasoning)
// content can be a plain String OR an array of content blocks
if let text = try? c.decodeIfPresent(String.self, forKey: .content) {
content = text
contentBlockImages = []
} else if let blocks = try? c.decodeIfPresent([ContentBlock].self, forKey: .content) {
content = blocks.compactMap { $0.text }.joined().nonEmptyOrNil
contentBlockImages = blocks.compactMap { block in
guard block.type == "image_url", let url = block.imageUrl else { return nil }
return OpenRouterChatResponse.ImageOutput(imageUrl: url)
}
} else {
content = nil
contentBlockImages = []
}
}
}
enum CodingKeys: String, CodingKey {

View File

@@ -110,7 +110,8 @@ class OpenRouterProvider: AIProvider {
return output.contains("image")
}
return false
}()
}(),
thinking: modelData.supportedParameters?.contains("reasoning") ?? false
),
architecture: modelData.architecture.map { arch in
ModelInfo.Architecture(
@@ -368,6 +369,10 @@ class OpenRouterProvider: AIProvider {
effectiveModel = request.model
}
let reasoningConfig: ReasoningAPIConfig? = request.reasoning.map {
ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil)
}
return OpenRouterChatRequest(
model: effectiveModel,
messages: apiMessages,
@@ -377,7 +382,8 @@ class OpenRouterProvider: AIProvider {
topP: request.topP,
tools: request.tools,
toolChoice: request.tools != nil ? "auto" : nil,
modalities: request.imageGeneration ? ["text", "image"] : nil
modalities: request.imageGeneration ? ["text", "image"] : nil,
reasoning: reasoningConfig
)
}
@@ -416,7 +422,11 @@ class OpenRouterProvider: AIProvider {
throw ProviderError.invalidResponse
}
let images = choice.delta.images.flatMap { decodeImageOutputs($0) }
// Merge images from both sources: top-level `images` field and content-block images
let topLevelImages = choice.delta.images.flatMap { decodeImageOutputs($0) } ?? []
let blockImages = decodeImageOutputs(choice.delta.contentBlockImages) ?? []
let allImages = topLevelImages + blockImages
let images: [Data]? = allImages.isEmpty ? nil : allImages
return StreamChunk(
id: apiChunk.id,
@@ -424,7 +434,8 @@ class OpenRouterProvider: AIProvider {
delta: StreamChunk.Delta(
content: choice.delta.content,
role: choice.delta.role,
images: images
images: images,
thinking: choice.delta.reasoning
),
finishReason: choice.finishReason,
usage: apiChunk.usage.map { usage in

View File

@@ -35,6 +35,9 @@
<li><a href="#email-handler">Email Handler (AI Assistant)</a></li>
<li><a href="#shortcuts">Shortcuts (Prompt Templates)</a></li>
<li><a href="#agent-skills">Agent Skills (SKILL.md)</a></li>
<li><a href="#bash-execution">Bash Execution</a></li>
<li><a href="#icloud-backup">iCloud Backup</a></li>
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
<li><a href="#keyboard-shortcuts">Keyboard Shortcuts</a></li>
<li><a href="#settings">Settings</a></li>
<li><a href="#system-prompts">System Prompts</a></li>
@@ -45,7 +48,7 @@
<!-- Getting Started -->
<section id="getting-started">
<h2>Getting Started</h2>
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama.</p>
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama. The app is available in English, Norwegian Bokmål, Swedish, Danish, and German — it follows your macOS language preference automatically.</p>
<div class="steps">
<h3>Quick Start</h3>
@@ -101,10 +104,32 @@
<li>Click the model name in the header</li>
</ul>
<h3>Searching &amp; Filtering</h3>
<p>Type in the search bar to filter by model name, ID, or description. Quick-filter buttons narrow results by capability:</p>
<ul>
<li><strong>👁️ Vision</strong> — models that can read images</li>
<li><strong>🔧 Tools</strong> — models that can call tools (MCP, web search, bash)</li>
<li><strong>🌐 Online</strong> — models with built-in web search</li>
<li><strong>🎨 Image Gen</strong> — models that generate images</li>
<li><strong>🧠 Thinking</strong> — models that support reasoning / thinking tokens</li>
</ul>
<h3>Sorting</h3>
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
<ul>
<li><strong>Default</strong> — provider order</li>
<li><strong>Price: Low to High</strong> — cheapest per million tokens first</li>
<li><strong>Price: High to Low</strong> — most capable/expensive first</li>
<li><strong>Context: High to Low</strong> — largest context window first</li>
</ul>
<h3>Model Information</h3>
<p>View details about any model:</p>
<p>Click the <strong></strong> icon on any model row to open a full details sheet — context length, pricing, capabilities, and description — without selecting that model. You can also type:</p>
<code class="command">/info</code>
<p class="note">Shows information about the currently selected model including context length, pricing, and capabilities.</p>
<p class="note">Shows information about the currently selected model.</p>
<h3>Keyboard Navigation</h3>
<p>Use <kbd></kbd> / <kbd></kbd> to move through the list, <kbd>Return</kbd> to select the highlighted model.</p>
<h3>Default Model</h3>
<p>Your selected model is automatically saved and will be restored when you restart the app.</p>
@@ -133,6 +158,9 @@
<p>If you're not satisfied with a response:</p>
<code class="command">/retry</code>
<p class="note">Resends your last message to generate a new response.</p>
<h3>Tool Call Inspection</h3>
<p>When the AI uses tools (file access, web search, bash commands), a <strong>🔧 Calling: …</strong> system message appears in the chat. Click it to expand an inline view showing each tool's input arguments and result as pretty-printed JSON. A spinner indicates a pending tool call; a green checkmark shows when it completes.</p>
</section>
<!-- File Attachments -->
@@ -408,10 +436,14 @@
<h3>Setting Up MCP</h3>
<ol>
<li>Enable MCP: <code>/mcp on</code></li>
<li>Add a folder: <code>/mcp add ~/Projects/myapp</code></li>
<li>Add a folder: <code>/mcp add ~/Projects/myapp</code>, click <strong>+ Add Folder…</strong> in Settings → MCP, or drag a folder from Finder onto the allowed folders list</li>
<li>Ask the AI questions about your files</li>
</ol>
<div class="tip">
<strong>💡 Drag &amp; Drop:</strong> You can drag one or more folders directly from Finder onto the allowed folders area in Settings → MCP. The list highlights when you drag over it, and multiple folders can be dropped at once.
</div>
<h3>What the AI Can Do</h3>
<p><strong>Read permissions (always enabled when MCP is on):</strong></p>
<ul>
@@ -456,6 +488,20 @@
<code class="command">/save my-project-chat</code>
<p class="note">Saves all current messages under the specified name.</p>
<p>From the <strong>File menu</strong> you also have:</p>
<ul>
<li><strong>Save Chat (<kbd>⌘S</kbd>)</strong> — Re-saves under the current name, or prompts for a name if the conversation hasn't been saved yet.</li>
<li><strong>Save Chat As…</strong> — Always prompts for a new name and creates a fresh copy, switching the session to that copy. Useful for branching a conversation.</li>
</ul>
<h3>Renaming Conversations</h3>
<p>In the Conversations list (<kbd>⌘L</kbd>):</p>
<ul>
<li>Click the <strong>✏️ pencil icon</strong> next to any conversation to rename it inline.</li>
<li>Swipe left on a conversation row to reveal <strong>Rename</strong>, <strong>Export</strong>, and <strong>Delete</strong> actions.</li>
</ul>
<p class="note">If the renamed conversation is currently open, the session name updates immediately so <kbd>⌘S</kbd> saves under the new name.</p>
<h3>Loading Conversations</h3>
<code class="command">/load</code>
<p class="note">Opens a list of saved conversations. Select one to load.</p>
@@ -1155,6 +1201,165 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
</div>
</section>
<!-- Bash Execution -->
<section id="bash-execution">
<h2>Bash Execution</h2>
<p>When enabled, the AI can run shell commands directly on your Mac via <code>/bin/zsh</code>. This lets it read output, run scripts, compile code, check system state, and more — all without leaving the chat.</p>
<div class="warning">
<strong>⚠️ Security:</strong> Bash execution gives the AI the ability to run arbitrary commands on your machine. Only enable it when you need it, and use the approval prompt to review each command before it runs.
</div>
<h3>Enabling Bash Execution</h3>
<ol>
<li>Press <kbd>⌘,</kbd> to open Settings</li>
<li>Go to the <strong>MCP</strong> tab</li>
<li>Scroll to the <strong>Bash Execution</strong> section</li>
<li>Toggle <strong>Enable Bash Execution</strong> on</li>
</ol>
<div class="note">
<strong>Note:</strong> Bash execution is disabled by default and must be explicitly turned on. It is independent of MCP — you do not need MCP folders configured to use it.
</div>
<h3>Approval Prompt</h3>
<p>When <strong>Require Approval</strong> is enabled (the default), a sheet appears before each command showing:</p>
<ul>
<li>The full command to be run</li>
<li>The working directory</li>
<li>A security warning</li>
</ul>
<p>You can then choose:</p>
<ul>
<li><strong>Allow Once</strong> — run this command and ask again next time</li>
<li><strong>Allow for Session</strong> — skip the approval prompt for the rest of this chat</li>
<li><strong>Deny</strong> — cancel the command; the AI is told it was rejected</li>
</ul>
<div class="tip">
<strong>💡 Session Scope:</strong> "Allow for Session" resets when you start a new chat, switch models, or load a saved conversation. It is never permanent.
</div>
<h3>Configuration Options</h3>
<dl class="commands">
<dt>Working Directory</dt>
<dd>The directory commands run in (default: <code>~</code>). Set to your project folder to avoid needing full paths.</dd>
<dt>Timeout</dt>
<dd>Maximum seconds a command can run before being killed (default: 30 s). Increase for long-running builds.</dd>
<dt>Require Approval</dt>
<dd>Show the approval sheet before each command (default: on). Disable only if you fully trust the AI's judgment for the current session.</dd>
</dl>
<h3>Output Limits</h3>
<p>To keep the context manageable, output is capped:</p>
<ul>
<li><strong>stdout</strong>: first 20,000 characters</li>
<li><strong>stderr</strong>: first 5,000 characters</li>
</ul>
<p>If output is truncated, the AI sees a note indicating how much was omitted.</p>
<h3>Example Usage</h3>
<div class="example">
<p><strong>You:</strong> "Run the test suite and tell me what's failing."</p>
<p><strong>AI:</strong> <em>(bash approval sheet appears with <code>cd ~/myproject &amp;&amp; swift test</code>)</em></p>
<p><strong>After Allow:</strong> "3 tests failed: testLogin, testSignup, testLogout. The error in testLogin is…"</p>
</div>
</section>
<!-- iCloud Backup -->
<section id="icloud-backup">
<h2>iCloud Backup</h2>
<p>Back up and restore all your oAI settings with one click. Backups are saved to iCloud Drive so they're available on any Mac where you're signed in.</p>
<div class="note">
<strong>What is included:</strong> All settings and preferences — providers, model defaults, MCP configuration, appearance, advanced options, shortcuts, skills, and more.<br><br>
<strong>What is excluded:</strong> API keys, passwords, and other credentials. These are intentionally left out for security and must be re-entered after restoring on a new machine.
</div>
<h3>Backing Up</h3>
<ol>
<li>Press <kbd>⌘,</kbd> to open Settings</li>
<li>Go to the <strong>Backup</strong> tab</li>
<li>Click <strong>Back Up Now</strong></li>
</ol>
<p>The backup file is saved to <code>~/iCloud Drive/oAI/oai_backup.json</code>. If iCloud Drive is not available, it falls back to your Downloads folder. The date of the last backup is shown in the tab.</p>
<h3>Restoring</h3>
<ol>
<li>Open Settings → <strong>Backup</strong> tab</li>
<li>Click <strong>Restore from File…</strong></li>
<li>Select your <code>oai_backup.json</code> file</li>
<li>Settings are applied immediately — no restart required</li>
<li>Re-enter your API keys in Settings → <strong>General</strong></li>
</ol>
<div class="tip">
<strong>💡 New Mac Setup:</strong> Back up on your old Mac, sign in to iCloud on your new Mac, open oAI, restore from the backup file — and all your settings are restored in seconds. You only need to re-enter API keys.
</div>
<h3>Backup Format</h3>
<p>Backups are plain JSON files (versioned for forward compatibility). You can inspect them in any text editor. The format is designed to be safe to share — no credentials are ever included.</p>
</section>
<!-- Reasoning / Thinking Tokens -->
<section id="reasoning">
<h2>Reasoning / Thinking Tokens</h2>
<p>Some AI models can "think out loud" before giving their final answer — reasoning through the problem step by step. oAI streams this thinking content live and displays it in a collapsible block above the response.</p>
<h3>Supported Models</h3>
<p>Reasoning is available on models that support extended thinking, including:</p>
<ul>
<li>DeepSeek R1 and DeepSeek R1 variants (via OpenRouter)</li>
<li>Qwen thinking models (via OpenRouter)</li>
<li>Claude 3.7+ with extended thinking (via Anthropic or OpenRouter)</li>
<li>OpenAI o1, o3, and similar reasoning models</li>
<li>Grok reasoning models (via OpenRouter)</li>
</ul>
<p>Look for the <strong>🧠</strong> badge in the model selector to identify thinking-capable models. Use the <strong>🧠 Thinking</strong> quick-filter to show only reasoning models.</p>
<h3>Enabling Reasoning</h3>
<ol>
<li>Press <kbd>⌘,</kbd> to open Settings</li>
<li>Go to the <strong>General</strong> tab → <strong>Features</strong> section</li>
<li>Toggle <strong>Reasoning</strong> on</li>
</ol>
<p class="note">Reasoning only activates when the selected model supports extended thinking (🧠 badge). The setting has no effect on standard models.</p>
<h3>Effort Level</h3>
<p>The effort level controls how many tokens the model spends on reasoning (as a share of its total token budget):</p>
<dl class="commands">
<dt>High (~80%)</dt>
<dd>Deep, methodical reasoning. Best for math, logic, multi-step planning, and complex coding tasks. More expensive.</dd>
<dt>Medium (~50%)</dt>
<dd>Balanced reasoning. Good for most tasks that benefit from thinking (default).</dd>
<dt>Low (~20%)</dt>
<dd>Light reasoning pass. Suitable for moderately complex questions without a large token budget.</dd>
<dt>Minimal (~10%)</dt>
<dd>Minimal internal thinking — just a quick check before answering. Fastest and cheapest.</dd>
</dl>
<h3>Hiding Reasoning Content</h3>
<p>Enable <strong>Hide reasoning content</strong> in Settings → General → Features to keep reasoning internal. The model still thinks at the selected effort level, but the thinking block is not shown — only the final answer appears in chat.</p>
<h3>In-Chat Experience</h3>
<p>When reasoning is enabled and a thinking-capable model responds:</p>
<ol>
<li>A <strong>🧠 Thinking…</strong> block appears above the response and auto-expands</li>
<li>Reasoning content streams in live as the model thinks</li>
<li>When the final answer starts arriving, the thinking block collapses automatically</li>
<li>Click the block header at any time to manually expand or collapse it</li>
</ol>
<div class="tip">
<strong>💡 Tip:</strong> Medium effort is a great default. Switch to High for math problems, deep code analysis, or anything requiring careful step-by-step reasoning.
</div>
</section>
<!-- Keyboard Shortcuts -->
<section id="keyboard-shortcuts">
<h2>Keyboard Shortcuts</h2>
@@ -1183,6 +1388,9 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<dt><kbd>⌘K</kbd></dt>
<dd>Clear Chat</dd>
<dt><kbd>⌘S</kbd></dt>
<dd>Save Chat (re-saves if already named, prompts for name otherwise)</dd>
<dt><kbd>⇧⌘S</kbd></dt>
<dd>Show Statistics</dd>
</dl>
@@ -1211,18 +1419,25 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<h2>Settings</h2>
<p>Customize oAI to your preferences. Press <kbd>⌘,</kbd> to open Settings.</p>
<h3>Providers Tab</h3>
<h3>General Tab</h3>
<ul>
<li>Add and manage API keys for different providers</li>
<li>Switch between providers</li>
<li>Set default models</li>
<li>Add and manage API keys for all providers (OpenAI, Anthropic, OpenRouter, Google, Ollama)</li>
<li>Switch between providers and set the default model</li>
<li>Configure streaming, memory, online mode, and max tokens</li>
<li><strong>Features</strong>:
<ul>
<li><strong>Reasoning</strong> — enable thinking tokens, set effort level (High / Medium / Low / Minimal), optionally hide reasoning content from chat (see <a href="#reasoning">Reasoning / Thinking Tokens</a>)</li>
</ul>
</li>
</ul>
<h3>MCP Tab</h3>
<ul>
<li>Manage folder access permissions</li>
<li>Enable/disable write operations</li>
<li>Enable/disable MCP file access</li>
<li>Add folders via the <strong>+ Add Folder…</strong> button, or drag from Finder</li>
<li>Enable/disable write, delete, move, and bash execution permissions</li>
<li>Configure gitignore respect</li>
<li><strong>Bash Execution</strong> — enable AI shell access, set working directory, timeout, and approval behaviour (see <a href="#bash-execution">Bash Execution</a>)</li>
</ul>
<h3>Sync Tab</h3>
@@ -1294,6 +1509,16 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<strong>⚠️ Security Note:</strong> All email credentials are encrypted with AES-256-GCM. For Gmail, use app-specific passwords (not your main password). Keep your subject identifier private to prevent abuse.
</div>
<h3>Paperless Tab <span style="font-size: 0.75em; background: #f90; color: #fff; border-radius: 4px; padding: 1px 5px; vertical-align: middle;">Beta</span></h3>
<p>Connect oAI to a self-hosted <a href="https://docs.paperless-ngx.com" target="_blank">Paperless-NGX</a> instance so the AI can search and read your document archive.</p>
<ul>
<li><strong>URL</strong> — Base URL of your Paperless instance (e.g. <code>https://paperless.yourdomain.com</code>)</li>
<li><strong>API Token</strong> — Found in Paperless → Settings → API Tokens</li>
<li><strong>Test Connection</strong> — Verify connectivity and display document count</li>
</ul>
<p>When enabled, the AI can use <code>paperless_search</code>, <code>paperless_get_document</code>, <code>paperless_list_tags</code>, and other tools to interact with your document library.</p>
<p class="note"><strong>Beta:</strong> Paperless integration is under active development. Some features may be incomplete or behave unexpectedly.</p>
<h3>Shortcuts Tab</h3>
<p>Create and manage personal prompt template commands. See <a href="#shortcuts">Shortcuts</a> section for full details.</p>
@@ -1312,9 +1537,17 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<li>Set maximum tokens (response length limit)</li>
<li>Adjust temperature (creativity vs focus)</li>
<li>Configure system prompts (see below)</li>
<li><strong>Smart Context Selection</strong> - Intelligently select relevant messages to reduce token usage</li>
<li><strong>Semantic Search</strong> - Enable AI-powered conversation search using embeddings</li>
<li><strong>Progressive Summarization</strong> - Automatically summarize old portions of long conversations</li>
<li><strong>Smart Context Selection</strong> Intelligently select relevant messages to reduce token usage</li>
<li><strong>Semantic Search</strong> Enable AI-powered conversation search using embeddings</li>
<li><strong>Progressive Summarization</strong> Automatically summarize old portions of long conversations</li>
</ul>
<h3>Backup Tab</h3>
<p>One-click backup and restore of all settings to iCloud Drive. See <a href="#icloud-backup">iCloud Backup</a> for full details.</p>
<ul>
<li><strong>Back Up Now</strong> — exports settings to <code>~/iCloud Drive/oAI/oai_backup.json</code></li>
<li><strong>Restore from File…</strong> — imports settings from a backup file</li>
<li>API keys and credentials are excluded from backups and must be re-entered after restore</li>
</ul>
</section>

View File

@@ -410,7 +410,7 @@ class AnytypeMCPService {
updated[idx] = line
let newMarkdown = updated.joined(separator: "\n")
let patchResult = try await request(
_ = try await request(
endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)",
method: "PATCH",
body: ["markdown": newMarkdown]

View File

@@ -59,7 +59,7 @@ class IMAPClient {
let hostName = self.host
return try await withCheckedThrowingContinuation { continuation in
final class ResumeOnce {
final class ResumeOnce: @unchecked Sendable {
var resumed = false
let lock = NSLock()
}

View File

@@ -54,7 +54,7 @@ class SMTPClient {
let hostName = self.host
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
final class ResumeOnce {
final class ResumeOnce: @unchecked Sendable {
var resumed = false
let lock = NSLock()
}
@@ -92,7 +92,7 @@ class SMTPClient {
let hostName = self.host
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
final class ResumeOnce {
final class ResumeOnce: @unchecked Sendable {
var resumed = false
let lock = NSLock()
}
@@ -251,7 +251,7 @@ class SMTPClient {
self.connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
final class ResumeOnce {
final class ResumeOnce: @unchecked Sendable {
var resumed = false
let lock = NSLock()
}

View File

@@ -222,6 +222,34 @@ class SettingsService {
}
}
// MARK: - Reasoning Settings
var reasoningEnabled: Bool {
get { cache["reasoningEnabled"] == "true" }
set {
cache["reasoningEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "reasoningEnabled", value: String(newValue))
}
}
/// "high", "medium", "low", "minimal" default "medium"
var reasoningEffort: String {
get { cache["reasoningEffort"] ?? "medium" }
set {
cache["reasoningEffort"] = newValue
DatabaseService.shared.setSetting(key: "reasoningEffort", value: newValue)
}
}
/// When true, model reasons internally but thinking content is excluded from response
var reasoningExclude: Bool {
get { cache["reasoningExclude"] == "true" }
set {
cache["reasoningExclude"] = String(newValue)
DatabaseService.shared.setSetting(key: "reasoningExclude", value: String(newValue))
}
}
// MARK: - Text Size Settings
/// GUI text size (headers, labels, buttons) default 13

View File

@@ -93,6 +93,13 @@ extension String {
return max(1, count / 4)
}
// MARK: - Nil coalescing helpers
/// Returns nil if the string is empty, otherwise self.
var nonEmptyOrNil: String? {
isEmpty ? nil : self
}
// MARK: - Truncation
func truncated(to length: Int, trailing: String = "...") -> String {

View File

@@ -640,7 +640,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
case "/memory":
if let arg = args.first?.lowercased() {
memoryEnabled = arg == "on"
showSystemMessage("Memory \(memoryEnabled ? "enabled" : "disabled")")
showSystemMessage(memoryEnabled ? "Memory enabled" : "Memory disabled")
} else {
showSystemMessage("Usage: /memory on|off")
}
@@ -648,7 +648,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
case "/online":
if let arg = args.first?.lowercased() {
onlineMode = arg == "on"
showSystemMessage("Online mode \(onlineMode ? "enabled" : "disabled")")
showSystemMessage(onlineMode ? "Online mode enabled" : "Online mode disabled")
} else {
showSystemMessage("Usage: /online on|off")
}
@@ -881,6 +881,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
finalSystemPrompt = summariesText + "\n\n---\n\n" + effectiveSystemPrompt
}
let reasoningConfig: ReasoningConfig? = {
guard settings.reasoningEnabled,
selectedModel?.capabilities.thinking == true,
!isImageGen else { return nil }
return ReasoningConfig(effort: settings.reasoningEffort, exclude: settings.reasoningExclude)
}()
let chatRequest = ChatRequest(
messages: contextWindow.messages,
model: modelId,
@@ -891,7 +898,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
systemPrompt: finalSystemPrompt,
tools: nil,
onlineMode: onlineMode,
imageGeneration: isImageGen
imageGeneration: isImageGen,
reasoning: reasoningConfig
)
if isImageGen {
@@ -934,6 +942,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
} else {
// Regular text: stream response
var fullContent = ""
var fullThinking = ""
var collectedImages: [Data] = []
var totalTokens: ChatResponse.Usage? = nil
var wasCancelled = false
@@ -943,6 +953,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
break
}
if let thinking = chunk.delta.thinking {
fullThinking += thinking
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].thinkingContent = fullThinking
}
}
if let content = chunk.deltaContent {
fullContent += content
if let index = messages.firstIndex(where: { $0.id == messageId }) {
@@ -950,6 +967,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
}
if let images = chunk.delta.images {
collectedImages.append(contentsOf: images)
}
if let usage = chunk.usage {
totalTokens = usage
}
@@ -968,6 +989,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages[index].isStreaming = false
messages[index].responseTime = responseTime
messages[index].wasInterrupted = wasCancelled
if !collectedImages.isEmpty {
messages[index].generatedImages = collectedImages
}
if let usage = totalTokens {
messages[index].tokens = usage.completionTokens
@@ -1151,7 +1175,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
mcpEnabled = true
settings.mcpEnabled = true
mcpStatus = "MCP"
showSystemMessage("MCP enabled (\(mcp.allowedFolders.count) folder\(mcp.allowedFolders.count == 1 ? "" : "s") registered)")
showSystemMessage("MCP enabled (^[\(mcp.allowedFolders.count) folder](inflect: true) registered)")
case "off":
mcpEnabled = false
@@ -1165,7 +1189,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
if let error = mcp.addFolder(path) {
showSystemMessage("MCP: \(error)")
} else {
showSystemMessage("MCP: Added folder — \(mcp.allowedFolders.count) folder\(mcp.allowedFolders.count == 1 ? "" : "s") registered")
showSystemMessage("MCP: Added folder — ^[\(mcp.allowedFolders.count) folder](inflect: true) registered")
}
} else {
showSystemMessage("Usage: /mcp add <path>")
@@ -1230,7 +1254,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
if settings.mcpCanCreateDirectories { perms.append("mkdir") }
if settings.mcpCanMoveFiles { perms.append("move/copy") }
let permStr = perms.isEmpty ? "read-only" : "read + \(perms.joined(separator: ", "))"
showSystemMessage("MCP: \(enabled), \(folders) folder\(folders == 1 ? "" : "s"), \(permStr), gitignore: \(settings.mcpRespectGitignore ? "on" : "off")")
showSystemMessage("MCP: \(enabled), ^[\(folders) folder](inflect: true), \(permStr), gitignore: \(settings.mcpRespectGitignore ? "on" : "off")")
default:
showSystemMessage("MCP subcommands: on, off, status, add, remove, list, write")
@@ -1525,10 +1549,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
@discardableResult
private func showSystemMessage(_ text: String) -> UUID {
private func showSystemMessage(_ text: String.LocalizationValue) -> UUID {
let message = Message(
role: .system,
content: text,
content: String(localized: text),
tokens: nil,
cost: nil,
timestamp: Date(),

View File

@@ -200,7 +200,7 @@ struct ContentView: View {
// Helper view for toolbar labels
struct ToolbarLabel: View {
let title: String
let title: LocalizedStringKey
let systemImage: String
let showLabels: Bool
let scale: Image.Scale

View File

@@ -47,19 +47,19 @@ struct FooterView: View {
HStack(spacing: 16) {
FooterItem(
icon: "message",
label: "Messages",
label: "Messages:",
value: "\(stats.messageCount)"
)
FooterItem(
icon: "chart.bar.xaxis",
label: "Tokens",
label: "Tokens:",
value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)"
)
FooterItem(
icon: "dollarsign.circle",
label: "Cost",
label: "Cost:",
value: stats.totalCostDisplay
)
@@ -134,7 +134,7 @@ struct SaveIndicator: View {
return .secondary
}
private var tooltip: String {
private var tooltip: LocalizedStringKey {
if isModified { return "Click to re-save \"\(conversationName ?? "")\"" }
if isSaved { return "Saved — no changes" }
return "Not saved — use /save <name>"
@@ -162,7 +162,7 @@ struct SaveIndicator: View {
struct FooterItem: View {
let icon: String
let label: String
let label: LocalizedStringKey
let value: String
private let guiSize = SettingsService.shared.guiTextSize
@@ -172,7 +172,7 @@ struct FooterItem: View {
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
Text(label + ":")
Text(label)
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
@@ -187,9 +187,24 @@ struct SyncStatusFooter: View {
private let gitSync = GitSyncService.shared
private let settings = SettingsService.shared
private let guiSize = SettingsService.shared.guiTextSize
@State private var syncText = "Not Synced"
private enum SyncState { case off, notInitialized, ready, syncing, error, synced(Date) }
@State private var syncState: SyncState = .off
@State private var syncColor: Color = .secondary
private var syncText: LocalizedStringKey {
switch syncState {
case .off: return "Sync: Off"
case .notInitialized: return "Sync: Not Initialized"
case .ready: return "Sync: Ready"
case .syncing: return "Syncing..."
case .error: return "Sync Error"
case .synced(let date):
let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now)
return "Last Sync: \(rel)"
}
}
var body: some View {
HStack(spacing: 6) {
Image(systemName: "arrow.triangle.2.circlepath")
@@ -200,61 +215,27 @@ struct SyncStatusFooter: View {
.font(.system(size: guiSize - 2, weight: .medium))
.foregroundColor(syncColor)
}
.onAppear {
updateSyncStatus()
}
.onChange(of: gitSync.syncStatus.lastSyncTime) {
updateSyncStatus()
}
.onChange(of: gitSync.syncStatus.isCloned) {
updateSyncStatus()
}
.onChange(of: gitSync.lastSyncError) {
updateSyncStatus()
}
.onChange(of: gitSync.isSyncing) {
updateSyncStatus()
}
.onChange(of: settings.syncConfigured) {
updateSyncStatus()
}
.onAppear { updateSyncStatus() }
.onChange(of: gitSync.syncStatus.lastSyncTime) { updateSyncStatus() }
.onChange(of: gitSync.syncStatus.isCloned) { updateSyncStatus() }
.onChange(of: gitSync.lastSyncError) { updateSyncStatus() }
.onChange(of: gitSync.isSyncing) { updateSyncStatus() }
.onChange(of: settings.syncConfigured) { updateSyncStatus() }
}
private func updateSyncStatus() {
if gitSync.lastSyncError != nil {
syncText = "Sync Error"
syncColor = .red
syncState = .error; syncColor = .red
} else if gitSync.isSyncing {
syncText = "Syncing..."
syncColor = .orange
syncState = .syncing; syncColor = .orange
} else if let lastSync = gitSync.syncStatus.lastSyncTime {
syncText = "Last Sync: \(timeAgo(lastSync))"
syncColor = .green
syncState = .synced(lastSync); syncColor = .green
} else if gitSync.syncStatus.isCloned {
syncText = "Sync: Ready"
syncColor = .secondary
syncState = .ready; syncColor = .secondary
} else if settings.syncConfigured {
syncText = "Sync: Not Initialized"
syncColor = .orange
syncState = .notInitialized; syncColor = .orange
} else {
syncText = "Sync: Off"
syncColor = .secondary
}
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
if seconds < 60 {
return "just now"
} else if seconds < 3600 {
let minutes = seconds / 60
return "\(minutes)m ago"
} else if seconds < 86400 {
let hours = seconds / 3600
return "\(hours)h ago"
} else {
let days = seconds / 86400
return "\(days)d ago"
syncState = .off; syncColor = .secondary
}
}
}

View File

@@ -190,7 +190,7 @@ struct StatItem: View {
struct StatusPill: View {
let icon: String
let label: String
let label: LocalizedStringKey
let color: Color
var body: some View {
@@ -210,9 +210,31 @@ struct StatusPill: View {
struct SyncStatusPill: View {
private let gitSync = GitSyncService.shared
private enum SyncPillState { case notConfigured, syncing, error(String), synced(Date?) }
@State private var syncState: SyncPillState = .notConfigured
@State private var syncColor: Color = .secondary
@State private var syncLabel: String = "Sync"
@State private var tooltipText: String = ""
private var syncLabel: LocalizedStringKey {
switch syncState {
case .notConfigured: return "Sync"
case .syncing: return "Syncing"
case .error: return "Error"
case .synced: return "Synced"
}
}
private var tooltipText: LocalizedStringKey {
switch syncState {
case .notConfigured: return "Sync not configured"
case .syncing: return "Syncing..."
case .error(let msg): return "Sync failed: \(msg)"
case .synced(let date):
guard let date else { return "Synced" }
let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now)
return "Last synced: \(rel)"
}
}
var body: some View {
HStack(spacing: 3) {
@@ -227,58 +249,21 @@ struct SyncStatusPill: View {
.padding(.vertical, 2)
.background(syncColor.opacity(0.1), in: Capsule())
.help(tooltipText)
.onAppear {
updateState()
}
.onChange(of: gitSync.syncStatus) {
updateState()
}
.onChange(of: gitSync.isSyncing) {
updateState()
}
.onChange(of: gitSync.lastSyncError) {
updateState()
}
.onAppear { updateState() }
.onChange(of: gitSync.syncStatus) { updateState() }
.onChange(of: gitSync.isSyncing) { updateState() }
.onChange(of: gitSync.lastSyncError) { updateState() }
}
private func updateState() {
// Determine sync state
if let error = gitSync.lastSyncError {
syncColor = .red
syncLabel = "Error"
tooltipText = "Sync failed: \(error)"
syncState = .error(error); syncColor = .red
} else if gitSync.isSyncing {
syncColor = .orange
syncLabel = "Syncing"
tooltipText = "Syncing..."
syncState = .syncing; syncColor = .orange
} else if gitSync.syncStatus.isCloned {
syncColor = .green
syncLabel = "Synced"
if let lastSync = gitSync.syncStatus.lastSyncTime {
tooltipText = "Last synced: \(timeAgo(lastSync))"
} else {
tooltipText = "Synced"
}
syncState = .synced(gitSync.syncStatus.lastSyncTime); syncColor = .green
} else {
syncColor = .secondary
syncLabel = "Sync"
tooltipText = "Sync not configured"
}
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
if seconds < 60 {
return "just now"
} else if seconds < 3600 {
let minutes = seconds / 60
return "\(minutes)m ago"
} else if seconds < 86400 {
let hours = seconds / 3600
return "\(hours)h ago"
} else {
let days = seconds / 86400
return "\(days)d ago"
syncState = .notConfigured; syncColor = .secondary
}
}
}

View File

@@ -271,7 +271,7 @@ struct CommandSuggestionsView: View {
let selectedIndex: Int
let onSelect: (String) -> Void
static let builtInCommands: [(command: String, description: String)] = [
static let builtInCommands: [(command: String, description: LocalizedStringKey)] = [
("/help", "Show help and available commands"),
("/history", "View command history"),
("/model", "Select AI model"),
@@ -302,19 +302,19 @@ struct CommandSuggestionsView: View {
("/mcp write off", "Disable MCP write permissions"),
]
static func allCommands() -> [(command: String, description: String)] {
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
let shortcuts = SettingsService.shared.userShortcuts.map { s in
(s.command, "\(s.description)")
(s.command, LocalizedStringKey("\(s.description)"))
}
return builtInCommands + shortcuts
}
static func filteredCommands(for searchText: String) -> [(command: String, description: String)] {
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
let search = searchText.lowercased()
return allCommands().filter { $0.command.contains(search) || search == "/" }
}
private var suggestions: [(command: String, description: String)] {
private var suggestions: [(command: String, description: LocalizedStringKey)] {
Self.filteredCommands(for: searchText)
}

View File

@@ -34,6 +34,7 @@ struct MessageRow: View {
private let settings = SettingsService.shared
@State private var isExpanded = false
@State private var isThinkingExpanded = true // auto-expand while streaming, collapse after
#if os(macOS)
@State private var isHovering = false
@@ -107,6 +108,27 @@ struct MessageRow: View {
.foregroundColor(.oaiSecondary)
}
// Thinking / reasoning block (collapsible)
if let thinking = message.thinkingContent, !thinking.isEmpty {
thinkingBlock(thinking)
.onChange(of: message.content) { _, newContent in
// Auto-collapse when response content starts arriving
if !newContent.isEmpty && isThinkingExpanded && !message.isStreaming {
withAnimation(.easeInOut(duration: 0.2)) {
isThinkingExpanded = false
}
}
}
.onChange(of: message.isStreaming) { _, streaming in
// Collapse when streaming finishes
if !streaming && !message.content.isEmpty {
withAnimation(.easeInOut(duration: 0.3)) {
isThinkingExpanded = false
}
}
}
}
// Content
if !message.content.isEmpty {
messageContent
@@ -186,6 +208,64 @@ struct MessageRow: View {
// Close standardMessageLayout - the above closing braces close it
// The body: some View now handles the split between compact and standard
// MARK: - Thinking Block
@ViewBuilder
private func thinkingBlock(_ thinking: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Header button
Button(action: {
withAnimation(.easeInOut(duration: 0.18)) { isThinkingExpanded.toggle() }
}) {
HStack(spacing: 6) {
if message.isStreaming && message.content.isEmpty {
ProgressView()
.scaleEffect(0.5)
.frame(width: 12, height: 12)
Text("Thinking…")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} else {
Image(systemName: "brain")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Text("Reasoning")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isThinkingExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.secondary.opacity(0.5))
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isThinkingExpanded {
Divider()
.padding(.horizontal, 6)
ScrollView {
Text(thinking)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
}
.frame(maxHeight: 220)
}
}
.background(Color.secondary.opacity(0.07))
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.secondary.opacity(0.15), lineWidth: 1)
)
}
// MARK: - Compact System Message
@ViewBuilder

View File

@@ -38,7 +38,7 @@ struct AgentSkillsView: View {
@Bindable private var settings = SettingsService.shared
@State private var editContext: SkillEditContext? = nil
@State private var statusMessage: String? = nil
@State private var statusMessage: LocalizedStringKey? = nil
private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count }
@@ -166,7 +166,7 @@ struct AgentSkillsView: View {
if importMarkdown(url) { imported += 1 }
}
}
if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") }
if imported > 0 { show("^[\(imported) skill](inflect: true) imported") }
}
@discardableResult
@@ -256,7 +256,7 @@ struct AgentSkillsView: View {
}
exported += 1
}
show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads")
show("^[\(exported) skill](inflect: true) exported to Downloads")
}
private func exportOne(_ skill: AgentSkill) {
@@ -335,7 +335,7 @@ struct AgentSkillsView: View {
return ""
}
private func show(_ text: String) {
private func show(_ text: LocalizedStringKey) {
statusMessage = text
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
}
@@ -380,7 +380,7 @@ private struct AgentSkillRow: View {
// File count badge
if fileCount > 0 {
Label("\(fileCount) file\(fileCount == 1 ? "" : "s")", systemImage: "doc")
Label("^[\(fileCount) file](inflect: true)", systemImage: "doc")
.font(.caption2).foregroundStyle(.secondary)
.padding(.horizontal, 6).padding(.vertical, 3)
.background(.blue.opacity(0.1), in: Capsule())
@@ -411,7 +411,7 @@ struct AgentSkillsTabContent: View {
@Bindable private var settings = SettingsService.shared
@State private var editContext: SkillEditContext? = nil
@State private var statusMessage: String? = nil
@State private var statusMessage: LocalizedStringKey? = nil
private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count }
@@ -431,7 +431,7 @@ struct AgentSkillsTabContent: View {
.background(.purple.opacity(0.07), in: RoundedRectangle(cornerRadius: 8))
if activeCount > 0 {
Label("\(activeCount) skill\(activeCount == 1 ? "" : "s") active — appended to every system prompt", systemImage: "checkmark.circle.fill")
Label("^[\(activeCount) skill](inflect: true) active — appended to every system prompt", systemImage: "checkmark.circle.fill")
.font(.caption).foregroundStyle(.green)
}
@@ -516,7 +516,7 @@ struct AgentSkillsTabContent: View {
if importMarkdown(url) { imported += 1 }
}
}
if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") }
if imported > 0 { show("^[\(imported) skill](inflect: true) imported") }
}
@discardableResult
@@ -602,7 +602,7 @@ struct AgentSkillsTabContent: View {
}
exported += 1
}
show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads")
show("^[\(exported) skill](inflect: true) exported to Downloads")
}
private func exportOne(_ skill: AgentSkill) {
@@ -671,7 +671,7 @@ struct AgentSkillsTabContent: View {
return ""
}
private func show(_ text: String) {
private func show(_ text: LocalizedStringKey) {
statusMessage = text
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
}

View File

@@ -155,7 +155,7 @@ struct CreditsView: View {
}
struct CreditRow: View {
let label: String
let label: LocalizedStringKey
let value: String
var highlight: Bool = false

View File

@@ -190,7 +190,7 @@ struct EmailLogView: View {
private var bottomActions: some View {
HStack {
Text("\(logs.count) \(logs.count == 1 ? "entry" : "entries")")
Text("^[\(logs.count) entry](inflect: true)")
.font(.system(size: 13))
.foregroundColor(.secondary)
@@ -327,7 +327,7 @@ struct EmailLogRow: View {
// MARK: - Stat Item
struct EmailStatItem: View {
let title: String
let title: LocalizedStringKey
let value: String
let color: Color

View File

@@ -38,7 +38,7 @@ struct CommandDetail: Identifiable {
struct CommandCategory: Identifiable {
let id = UUID()
let name: String
let name: LocalizedStringKey
let icon: String
let commands: [CommandDetail]
}
@@ -358,7 +358,7 @@ struct HelpView: View {
.padding(.vertical, 4)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
Spacer()
Text(shortcut.description)
Text(LocalizedStringKey(shortcut.description))
.font(.callout)
.foregroundStyle(.secondary)
}
@@ -463,7 +463,7 @@ private struct CommandRow: View {
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundStyle(.primary)
Text(command.brief)
Text(LocalizedStringKey(command.brief))
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -493,14 +493,14 @@ private struct CommandRow: View {
// Expanded detail
if isExpanded {
VStack(alignment: .leading, spacing: 10) {
Text(command.detail)
Text(LocalizedStringKey(command.detail))
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
if !command.examples.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text(command.examples.count == 1 ? "Example" : "Examples")
Text(command.examples.count == 1 ? "Example" as LocalizedStringKey : "Examples")
.font(.caption)
.foregroundStyle(.secondary)
.fontWeight(.medium)

View File

@@ -132,6 +132,7 @@ struct ModelInfoView: View {
capabilityBadge(icon: "wrench.fill", label: "Tools", active: model.capabilities.tools)
capabilityBadge(icon: "globe", label: "Online", active: model.capabilities.online)
capabilityBadge(icon: "photo.fill", label: "Image Gen", active: model.capabilities.imageGeneration)
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
}
// Architecture (if available)
@@ -172,14 +173,14 @@ struct ModelInfoView: View {
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func infoRow(_ label: String, _ value: String) -> some View {
private func infoRow(_ label: LocalizedStringKey, _ value: String) -> some View {
HStack {
Text(label)
.font(.body)
@@ -193,7 +194,7 @@ struct ModelInfoView: View {
}
@ViewBuilder
private func costExample(label: String, inputTokens: Int) -> some View {
private func costExample(label: LocalizedStringKey, inputTokens: Int) -> some View {
let cost = (Double(inputTokens) * model.pricing.prompt / 1_000_000) +
(Double(inputTokens) * model.pricing.completion / 1_000_000)
VStack(spacing: 2) {
@@ -211,7 +212,7 @@ struct ModelInfoView: View {
}
@ViewBuilder
private func capabilityBadge(icon: String, label: String, active: Bool) -> some View {
private func capabilityBadge(icon: String, label: LocalizedStringKey, active: Bool) -> some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.title3)

View File

@@ -36,20 +36,37 @@ struct ModelSelectorView: View {
@State private var filterTools = false
@State private var filterOnline = false
@State private var filterImageGen = false
@State private var filterThinking = false
@State private var keyboardIndex: Int = -1
@State private var sortOrder: ModelSortOrder = .default
@State private var selectedInfoModel: ModelInfo? = nil
private var filteredModels: [ModelInfo] {
models.filter { model in
let q = searchText.lowercased()
let filtered = models.filter { model in
let matchesSearch = searchText.isEmpty ||
model.name.lowercased().contains(searchText.lowercased()) ||
model.id.lowercased().contains(searchText.lowercased())
model.name.lowercased().contains(q) ||
model.id.lowercased().contains(q) ||
model.description?.lowercased().contains(q) == true
let matchesVision = !filterVision || model.capabilities.vision
let matchesTools = !filterTools || model.capabilities.tools
let matchesOnline = !filterOnline || model.capabilities.online
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
let matchesThinking = !filterThinking || model.capabilities.thinking
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking
}
switch sortOrder {
case .default:
return filtered
case .priceLowHigh:
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
case .priceHighLow:
return filtered.sorted { $0.pricing.prompt > $1.pricing.prompt }
case .contextHighLow:
return filtered.sorted { $0.contextLength > $1.contextLength }
}
}
@@ -61,16 +78,47 @@ struct ModelSelectorView: View {
.textFieldStyle(.roundedBorder)
.padding()
.onChange(of: searchText) {
// Reset keyboard index when search changes
keyboardIndex = -1
}
// Filters
// Filters + Sort
HStack(spacing: 12) {
FilterToggle(isOn: $filterVision, icon: "\u{1F441}\u{FE0F}", label: "Vision")
FilterToggle(isOn: $filterTools, icon: "\u{1F527}", label: "Tools")
FilterToggle(isOn: $filterOnline, icon: "\u{1F310}", label: "Online")
FilterToggle(isOn: $filterImageGen, icon: "\u{1F3A8}", label: "Image Gen")
FilterToggle(isOn: $filterThinking, icon: "\u{1F9E0}", label: "Thinking")
Spacer()
// Sort menu
Menu {
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
Button {
sortOrder = order
keyboardIndex = -1
} label: {
if sortOrder == order {
Label(order.label, systemImage: "checkmark")
} else {
Text(order.label)
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.up.arrow.down")
Text("Sort")
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(sortOrder != .default ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.foregroundColor(sortOrder != .default ? .blue : .secondary)
.cornerRadius(6)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
.padding(.horizontal)
.padding(.bottom, 12)
@@ -91,13 +139,11 @@ struct ModelSelectorView: View {
ModelRowView(
model: model,
isSelected: model.id == selectedModel?.id,
isKeyboardHighlighted: index == keyboardIndex
isKeyboardHighlighted: index == keyboardIndex,
onSelect: { onSelect(model) },
onInfo: { selectedInfoModel = model }
)
.id(model.id)
.contentShape(Rectangle())
.onTapGesture {
onSelect(model)
}
}
.listStyle(.plain)
.onChange(of: keyboardIndex) { _, newIndex in
@@ -143,20 +189,42 @@ struct ModelSelectorView: View {
}
}
.onAppear {
// Initialize keyboard index to current selection
if let selected = selectedModel,
let index = filteredModels.firstIndex(where: { $0.id == selected.id }) {
keyboardIndex = index
}
}
.sheet(item: $selectedInfoModel) { model in
ModelInfoView(model: model)
}
}
}
}
// MARK: - Sort Order
enum ModelSortOrder: String, CaseIterable {
case `default`
case priceLowHigh
case priceHighLow
case contextHighLow
var label: LocalizedStringKey {
switch self {
case .default: return "Default"
case .priceLowHigh: return "Price: Low to High"
case .priceHighLow: return "Price: High to Low"
case .contextHighLow: return "Context: High to Low"
}
}
}
// MARK: - Filter Toggle
struct FilterToggle: View {
@Binding var isOn: Bool
let icon: String
let label: String
let label: LocalizedStringKey
var body: some View {
Button(action: { isOn.toggle() }) {
@@ -175,51 +243,74 @@ struct FilterToggle: View {
}
}
// MARK: - Model Row
struct ModelRowView: View {
let model: ModelInfo
let isSelected: Bool
var isKeyboardHighlighted: Bool = false
let onSelect: () -> Void
let onInfo: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(model.name)
.font(.headline)
.foregroundColor(isSelected ? .blue : .primary)
HStack(alignment: .top, spacing: 8) {
// Selectable main content
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(model.name)
.font(.headline)
.foregroundColor(isSelected ? .blue : .primary)
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
// Capabilities
Text(model.id)
.font(.caption)
.foregroundColor(.secondary)
if let description = model.description {
Text(description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
HStack(spacing: 12) {
Label(model.contextLengthDisplay, systemImage: "text.alignleft")
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
}
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { onSelect() }
// Right side: capabilities + info button
VStack(alignment: .trailing, spacing: 6) {
// Capability icons
HStack(spacing: 4) {
if model.capabilities.vision { Text("\u{1F441}\u{FE0F}").font(.caption) }
if model.capabilities.tools { Text("\u{1F527}").font(.caption) }
if model.capabilities.online { Text("\u{1F310}").font(.caption) }
if model.capabilities.imageGeneration { Text("\u{1F3A8}").font(.caption) }
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
}
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
// Info button
Button(action: onInfo) {
Image(systemName: "info.circle")
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 20, height: 20)
}
.buttonStyle(.plain)
.help("Show model info")
}
Text(model.id)
.font(.caption)
.foregroundColor(.secondary)
if let description = model.description {
Text(description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
HStack(spacing: 12) {
Label(model.contextLengthDisplay, systemImage: "text.alignleft")
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
}
.font(.caption2)
.foregroundColor(.secondary)
.padding(.top, 2)
}
.padding(.vertical, 6)
.padding(.horizontal, isKeyboardHighlighted ? 4 : 0)

View File

@@ -142,7 +142,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
tabButton(7, icon: "brain", label: "Skills")
tabButton(4, icon: "arrow.triangle.2.circlepath", label: "Sync")
tabButton(5, icon: "envelope", label: "Email")
tabButton(8, icon: "doc.text", label: "Paperless")
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
}
.padding(.horizontal, 16)
@@ -283,10 +283,42 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.toggleStyle(.switch)
}
rowDivider()
row("MCP (File Access)") {
Toggle("", isOn: $settingsService.mcpEnabled)
row("Reasoning (Thinking)") {
Toggle("", isOn: $settingsService.reasoningEnabled)
.toggleStyle(.switch)
}
if settingsService.reasoningEnabled {
rowDivider()
row("Reasoning Effort") {
Picker("", selection: $settingsService.reasoningEffort) {
Text("High (~80%)").tag("high")
Text("Medium (~50%)").tag("medium")
Text("Low (~20%)").tag("low")
Text("Minimal (~10%)").tag("minimal")
}
.labelsHidden()
.fixedSize()
}
VStack(alignment: .leading, spacing: 2) {
Text(reasoningEffortDescription)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
rowDivider()
row("Hide Reasoning in Response") {
Toggle("", isOn: $settingsService.reasoningExclude)
.toggleStyle(.switch)
}
VStack(alignment: .leading, spacing: 2) {
Text("Model thinks internally but reasoning is not shown in chat")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
}
}
@@ -2050,13 +2082,25 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
// MARK: - Tab Navigation
private func tabButton(_ tag: Int, icon: String, label: String) -> some View {
private func tabButton(_ tag: Int, icon: String, label: LocalizedStringKey, beta: Bool = false) -> some View {
Button(action: { selectedTab = tag }) {
VStack(spacing: 3) {
Image(systemName: icon)
.font(.system(size: 22))
.frame(height: 28)
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
ZStack(alignment: .topTrailing) {
Image(systemName: icon)
.font(.system(size: 22))
.frame(height: 28)
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
if beta {
Text("β")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 3)
.padding(.vertical, 1)
.background(Color.orange)
.clipShape(Capsule())
.offset(x: 6, y: -2)
}
}
Text(label)
.font(.system(size: 11))
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
@@ -2070,7 +2114,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.buttonStyle(.plain)
}
private func tabTitle(_ tag: Int) -> String {
private func tabTitle(_ tag: Int) -> LocalizedStringKey {
switch tag {
case 0: return "General"
case 1: return "MCP"
@@ -2086,13 +2130,23 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// MARK: - Reasoning Helpers
private var reasoningEffortDescription: LocalizedStringKey {
switch settingsService.reasoningEffort {
case "high": return "Uses ~80% of max tokens for reasoning — best for hard problems"
case "medium": return "Uses ~50% of max tokens for reasoning — balanced default"
case "low": return "Uses ~20% of max tokens for reasoning — faster, cheaper"
case "minimal": return "Uses ~10% of max tokens for reasoning — lightest thinking"
default: return "Uses ~50% of max tokens for reasoning — balanced default"
}
}
// MARK: - Layout Helpers
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
private func row<Content: View>(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .center, spacing: 12) {
if !label.isEmpty {
Text(label).font(.system(size: 14))
}
Text(label).font(.system(size: 14))
Spacer()
content()
}
@@ -2100,7 +2154,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.padding(.vertical, 10)
}
private func sectionHeader(_ title: String) -> some View {
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
@@ -2205,7 +2259,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
return .green
}
private var syncStatusText: String {
private var syncStatusText: LocalizedStringKey {
guard settingsService.syncEnabled else { return "Disabled" }
guard settingsService.syncConfigured else { return "Not configured" }
guard gitSync.syncStatus.isCloned else { return "Not cloned" }
@@ -2318,19 +2372,9 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
private func timeAgo(_ date: Date) -> String {
let seconds = Int(Date().timeIntervalSince(date))
if seconds < 60 {
return "just now"
} else if seconds < 3600 {
let minutes = seconds / 60
return "\(minutes) minute\(minutes == 1 ? "" : "s") ago"
} else if seconds < 86400 {
let hours = seconds / 3600
return "\(hours) hour\(hours == 1 ? "" : "s") ago"
} else {
let days = seconds / 86400
return "\(days) day\(days == 1 ? "" : "s") ago"
}
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: .now)
}
}

View File

@@ -120,7 +120,7 @@ struct StatsView: View {
}
struct StatRow: View {
let label: String
let label: LocalizedStringKey
let value: String
var body: some View {
@@ -137,7 +137,7 @@ struct StatRow: View {
struct CapabilityBadge: View {
let icon: String
let label: String
let label: LocalizedStringKey
var body: some View {
HStack(spacing: 2) {

View File

@@ -0,0 +1,42 @@
{
"sourceLanguage" : "en",
"strings" : {
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "oAI"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "oAI"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "oAI"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "oAI"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "oAI"
}
}
}
}
},
"version" : "1.1"
}