Added skills, shortcuts, and bugifixes++

This commit is contained in:
2026-02-18 11:58:45 +01:00
parent 09463d7620
commit 54a8c47df4
24 changed files with 3172 additions and 239 deletions

3
.gitignore vendored
View File

@@ -116,4 +116,5 @@ Temporary Items
CLAUDE.md
ANTHROPIC_DEVELOPER_PROMPT.txt
GIT_SYNC_PHASE1_COMPLETE.md
build-dmg.sh
build-dmg.sh
.claude/

View File

@@ -279,7 +279,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.2;
MARKETING_VERSION = 2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -323,7 +323,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.2;
MARKETING_VERSION = 2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;

View File

@@ -0,0 +1,42 @@
//
// AgentSkill.swift
// oAI
//
// SKILL.md-style behavioral skills markdown instruction files injected into the system prompt
//
import Foundation
struct AgentSkill: Codable, Identifiable {
var id: UUID
var name: String // display name, e.g. "Code Review"
var skillDescription: String // short summary shown in the list
var content: String // full markdown content (the actual instructions)
var isActive: Bool // when true, injected into the system prompt
var createdAt: Date
var updatedAt: Date
init(id: UUID = UUID(), name: String, skillDescription: String = "", content: String,
isActive: Bool = true, createdAt: Date = Date(), updatedAt: Date = Date()) {
self.id = id
self.name = name
self.skillDescription = skillDescription
self.content = content
self.isActive = isActive
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Extract a brief description from the content if skillDescription is empty
var resolvedDescription: String {
guard skillDescription.isEmpty else { return skillDescription }
// Return first non-heading, non-empty line
for line in content.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty && !trimmed.hasPrefix("#") {
return String(trimmed.prefix(100))
}
}
return name
}
}

32
oAI/Models/Skill.swift Normal file
View File

@@ -0,0 +1,32 @@
//
// Shortcut.swift
// oAI
//
// User-defined slash command templates (prompt shortcuts/macros)
//
import Foundation
struct Shortcut: Codable, Identifiable {
var id: UUID
var command: String // e.g. "/summarize" (always starts with /)
var description: String // shown in dropdown
var template: String // prompt text, may contain {{input}}
var createdAt: Date
var updatedAt: Date
init(id: UUID = UUID(), command: String, description: String, template: String,
createdAt: Date = Date(), updatedAt: Date = Date()) {
self.id = id
self.command = command
self.description = description
self.template = template
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// True when the template uses {{input}} and needs the user to provide text
var needsInput: Bool {
template.contains("{{input}}")
}
}

View File

@@ -36,15 +36,11 @@ class ProviderRegistry {
provider = OpenRouterProvider(apiKey: apiKey)
case .anthropic:
if AnthropicOAuthService.shared.isAuthenticated {
// OAuth (Pro/Max subscription) takes precedence
provider = AnthropicProvider(oauth: true)
} else if let apiKey = settings.anthropicAPIKey, !apiKey.isEmpty {
provider = AnthropicProvider(apiKey: apiKey)
} else {
Log.api.warning("No API key or OAuth configured for Anthropic")
guard let apiKey = settings.anthropicAPIKey, !apiKey.isEmpty else {
Log.api.warning("No API key configured for Anthropic")
return nil
}
provider = AnthropicProvider(apiKey: apiKey)
case .openai:
guard let apiKey = settings.openaiAPIKey, !apiKey.isEmpty else {

View File

@@ -33,6 +33,8 @@
<li><a href="#conversations">Managing Conversations</a></li>
<li><a href="#git-sync">Git Sync (Backup &amp; Sync)</a></li>
<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="#keyboard-shortcuts">Keyboard Shortcuts</a></li>
<li><a href="#settings">Settings</a></li>
<li><a href="#system-prompts">System Prompts</a></li>
@@ -234,6 +236,18 @@
<dd>Enable/disable write permissions</dd>
</dl>
<h3>Shortcuts &amp; Skills Commands</h3>
<dl class="commands">
<dt>/shortcuts</dt>
<dd>Open the Shortcuts manager to create, edit, and manage your custom prompt template commands</dd>
<dt>/skills</dt>
<dd>Open the Agent Skills manager to install and manage SKILL.md-style behavioral instruction files</dd>
<dt>/&lt;your-command&gt;</dt>
<dd>Run any shortcut you've created. Example: <code>/summarize</code> to run your "Summarize" shortcut</dd>
</dl>
<h3>Settings Commands</h3>
<dl class="commands">
<dt>/config, /settings</dt>
@@ -929,6 +943,218 @@ AI Assistant</code></pre>
</div>
</section>
<!-- Shortcuts -->
<section id="shortcuts">
<h2>Shortcuts (Prompt Templates)</h2>
<p>Shortcuts are personal slash commands you define yourself. Each shortcut expands to a prompt template and can optionally ask for input. Think of them as saved prompts with custom command names.</p>
<div class="tip">
<strong>💡 Example:</strong> Create a <code>/summarize</code> shortcut that sends "Please summarize the following text concisely:\n\n{{input}}" — then just type <code>/summarize</code> and paste any text after it.
</div>
<h3>Creating a Shortcut</h3>
<ol>
<li>Type <code>/shortcuts</code> or go to Settings → Shortcuts tab</li>
<li>Click <strong>New Shortcut</strong></li>
<li>Set a command (e.g. <code>/summarize</code>)</li>
<li>Add a short description shown in the dropdown</li>
<li>Write your prompt template</li>
<li>Click <strong>Save</strong></li>
</ol>
<h3>Using {{input}} Placeholder</h3>
<p>If your template contains <code>{{input}}</code>, the AI waits for you to type something after the command. Your text replaces <code>{{input}}</code> before sending.</p>
<div class="example">
<strong>With {{input}}:</strong>
<pre><code>Command: /translate-no
Template: Translate the following text to Norwegian:\n\n{{input}}
Usage: /translate-no Hello, how are you today?
→ Sends: "Translate the following text to Norwegian:\n\nHello, how are you today?"</code></pre>
</div>
<p>If your template has <strong>no</strong> <code>{{input}}</code>, the shortcut executes immediately when selected — no extra input needed.</p>
<div class="example">
<strong>Without {{input}}:</strong>
<pre><code>Command: /hello
Template: Say hello in 5 different languages with a brief cultural note for each.
Usage: Type /hello → executes immediately</code></pre>
</div>
<h3>Shortcuts in the Command Dropdown</h3>
<p>Your shortcuts appear in the <code>/</code> dropdown with a <strong></strong> prefix. Type the first letters of your command to filter them.</p>
<h3>More Shortcut Examples</h3>
<div class="example">
<pre><code>/roast "Give me a light-hearted roast of this text: {{input}}"
/eli5 "Explain {{input}} as if I were 5 years old"
/debug "Find the bug in this code and explain the fix:\n\n{{input}}"
/tweet "Rewrite this as a punchy tweet under 280 chars: {{input}}"
/standup "Based on these notes, write a daily standup update: {{input}}"</code></pre>
</div>
<h3>Import &amp; Export</h3>
<ul>
<li><strong>Export All</strong> — saves all shortcuts as <code>shortcuts.json</code> in Downloads</li>
<li><strong>Import</strong> — load shortcuts from a JSON file (single shortcut or pack)</li>
<li>When importing a duplicate command, you can choose: <strong>Replace</strong>, <strong>Keep Both</strong>, or <strong>Skip</strong></li>
</ul>
</section>
<!-- Agent Skills -->
<section id="agent-skills">
<h2>Agent Skills (SKILL.md)</h2>
<p>Agent Skills are markdown instruction files that teach the AI how to behave. Active skills are automatically injected into the system prompt for every conversation — the AI reads them and applies the instructions when relevant.</p>
<p>Skills follow the <strong>SKILL.md open standard</strong>, making them compatible with a growing ecosystem of skill marketplaces and community collections.</p>
<div class="tip">
<strong>💡 Example:</strong> Install a "Code Review" skill and the AI will automatically apply code review best practices whenever you share code — no need to ask every time.
</div>
<h3>Installing a Skill</h3>
<ol>
<li>Type <code>/skills</code> or go to Settings → Skills tab</li>
<li>Click <strong>Import</strong> to load a <code>.md</code> file or a <code>.zip</code> skill bundle, <em>or</em></li>
<li>Click <strong>New Skill</strong> to write your own from scratch</li>
<li>Toggle the skill <strong>Active</strong> to inject it into the system prompt</li>
</ol>
<h3>SKILL.md Format</h3>
<p>A skill is a plain Markdown file. The first <code># Heading</code> becomes the skill name. Write instructions in natural language — the AI decides when to apply them.</p>
<div class="example">
<pre><code># Security Auditor
When reviewing code, always:
- Check for injection vulnerabilities (SQL, command, XSS)
- Look for insecure storage of credentials or secrets
- Verify authentication and authorization logic
- Suggest specific fixes, not just observations
- Rate severity: Critical / High / Medium / Low</code></pre>
</div>
<div class="example">
<pre><code># Norwegian Translator
Whenever the user asks you to translate something, translate it to Norwegian Bokmål.
- Maintain the original tone and register
- Keep technical terms in English unless there is a well-established Norwegian equivalent
- If the text is already in Norwegian, translate it to English instead</code></pre>
</div>
<h3>Writing Your Own Skills</h3>
<ul>
<li>Start with a <code># Title</code> — this becomes the skill name in the list</li>
<li>Use bullet points for rules and guidelines</li>
<li>Be specific: "always check X" is better than "be careful with X"</li>
<li>Keep skills focused on one area — create multiple skills rather than one huge one</li>
<li>The AI reads all active skills before responding — shorter, clearer skills work better</li>
</ul>
<h3>How Skills Are Applied</h3>
<p>Active skills are appended to the system prompt under an <strong>"Installed Skills"</strong> section. The AI sees them with every message and applies relevant guidance automatically. You can toggle skills on/off without deleting them.</p>
<h3>Skill Support Files</h3>
<p>Each skill can have data files attached — JSON, YAML, CSV, TXT, or any plain-text format. When a skill is active, its files are injected into the system prompt right after the skill's markdown content, so the AI can read and reason over them.</p>
<div class="tip">
<strong>💡 Example use cases:</strong>
<ul>
<li><code>news_sources.json</code> — a list of URLs for a "Daily AI News" skill</li>
<li><code>search_queries.md</code> — template search strings for a research skill</li>
<li><code>output_templates.md</code> — report formats for a writing skill</li>
<li><code>config.yaml</code> — parameters a skill should follow</li>
</ul>
</div>
<h4>Adding Files to a Skill</h4>
<ol>
<li>Open the skill editor (click <strong>Edit</strong> on any skill)</li>
<li>Scroll to the <strong>Files</strong> section at the bottom</li>
<li>Click <strong>Add File</strong> and select one or more text files</li>
<li>Files appear in a list with name and size</li>
<li>Click the trash icon next to any file to remove it</li>
</ol>
<h4>How Files Are Injected</h4>
<p>In the system prompt, each file is included as a labelled fenced code block immediately after the skill's instructions:</p>
<div class="example">
<pre><code>### Daily AI News
[skill instructions here]
**Skill Data Files:**
**news_sources.json:**
```json
{ "sources": [...] }
```
**search_queries.md:**
```md
...
```</code></pre>
</div>
<div class="warning">
<strong>⚠️ Token budget:</strong> Large files inflate the system prompt. A warning appears in the editor if any file exceeds 200 KB. For very large datasets, consider summarizing or splitting the data.
</div>
<h4>File Storage</h4>
<p>Files are stored locally at:</p>
<div class="example">
<pre><code>~/Library/Application Support/oAI/skills/&lt;skill-uuid&gt;/
├── news_sources.json
└── search_queries.md</code></pre>
</div>
<p>Deleting a skill also deletes its file directory automatically.</p>
<h3>Import &amp; Export</h3>
<h4>Importing</h4>
<p>Click <strong>Import</strong> in the Skills manager. You can select:</p>
<ul>
<li><strong><code>.md</code> file</strong> — plain SKILL.md-format file. The <code>#</code> heading becomes the skill name</li>
<li><strong><code>.zip</code> bundle</strong> — a skill with attached data files. The archive should contain a <code>skill.md</code> and any number of data files (in any directory structure — they are flattened into the skill's file directory)</li>
</ul>
<p>If a skill with the same name already exists, it is replaced. Data files from a zip are merged into the existing skill's directory.</p>
<h4>Exporting</h4>
<ul>
<li><strong>Export (single skill)</strong> — click the upload icon on a skill row:
<ul>
<li>Skill with no files → exports as <code>skill-name.md</code> to Downloads</li>
<li>Skill with files → exports as <code>skill-name.zip</code> containing <code>skill.md</code> + all data files</li>
</ul>
</li>
<li><strong>Export All</strong> — exports every skill using the same logic (each becomes either <code>.md</code> or <code>.zip</code>)</li>
</ul>
<div class="tip">
<strong>💡 Sharing skills:</strong> Zip bundles are the recommended format for sharing skills that include reference data — import the <code>.zip</code> directly on another machine to restore both the instructions and all attached files.
</div>
<h3>Finding Skills to Download</h3>
<p>The SKILL.md community has produced hundreds of ready-made skills you can import directly:</p>
<ul>
<li><a href="https://skill0.io/" target="_blank">skill0.io</a> — The original SKILL.md marketplace</li>
<li><a href="https://skillsmp.com" target="_blank">skillsmp.com</a> — Agent Skills Marketplace</li>
<li><a href="https://agent-skills.md" target="_blank">agent-skills.md</a> — Community skill directory</li>
<li><a href="https://github.com/anthropics/skills" target="_blank">github.com/anthropics/skills</a> — Anthropic's official skill library</li>
<li><a href="https://skills.sh/" target="_blank">skills.sh</a> — The Agent Skills Directory</li>
<li><a href="https://agentskills.io/home" target="_blank">agentskills.io</a> — Agent skills overview and search</li>
<li><a href="https://github.com/heilcheng/awesome-agent-skills" target="_blank">awesome-agent-skills</a> — Curated community list on GitHub</li>
</ul>
<div class="note">
<strong>Learn more:</strong> Read the <a href="https://www.mintlify.com/blog/skill-md" target="_blank">SKILL.md open standard article</a> to understand the format in depth, or browse the <a href="https://github.com/anthropics/skills" target="_blank">Anthropic skills repository</a> for high-quality examples.
</div>
</section>
<!-- Keyboard Shortcuts -->
<section id="keyboard-shortcuts">
<h2>Keyboard Shortcuts</h2>
@@ -999,6 +1225,81 @@ AI Assistant</code></pre>
<li>Configure gitignore respect</li>
</ul>
<h3>Sync Tab</h3>
<p>Configure Git synchronization for backing up and syncing conversations across devices.</p>
<ul>
<li><strong>Repository URL</strong> - Git repository URL (GitHub, GitLab, Gitea, or custom)</li>
<li><strong>Authentication Method</strong> - Choose SSH, Password, or Access Token</li>
<li><strong>Credentials</strong> - Enter username/password or access token (encrypted storage)</li>
<li><strong>Local Path</strong> - Where to clone the repository locally (default: ~/oAI-Sync)</li>
<li><strong>Auto-Save Settings</strong>:
<ul>
<li><strong>Enable Auto-Save</strong> - Automatically sync conversations</li>
<li><strong>Minimum Messages</strong> - Only sync conversations with at least N messages</li>
<li><strong>Triggers</strong> - Sync on app start, idle, goodbye phrases, model switch, or app quit</li>
</ul>
</li>
<li><strong>Manual Sync</strong>:
<ul>
<li><strong>Initialize Repository</strong> - Clone repository for first-time setup</li>
<li><strong>Sync Now</strong> - Full sync (export + pull + import + push)</li>
</ul>
</li>
<li><strong>Test Connection</strong> - Verify repository credentials and connectivity</li>
<li><strong>Sync Status</strong> - Shows last sync time, uncommitted changes, and remote status</li>
</ul>
<div class="tip">
<strong>💡 Tip:</strong> Use access tokens instead of passwords for GitHub (Settings → Developer settings → Personal access tokens). Enable auto-sync on your primary machine only to avoid conflicts.
</div>
<h3>Email Tab</h3>
<p>Configure AI-powered email auto-responder to automatically reply to incoming emails.</p>
<ul>
<li><strong>Enable Email Handler</strong> - Master toggle for email monitoring</li>
<li><strong>Subject Identifier</strong> - Filter emails by subject (e.g., <code>[JARVIS]</code>, case-sensitive)</li>
<li><strong>IMAP Settings</strong>:
<ul>
<li><strong>Server</strong> - IMAP server address (e.g., imap.gmail.com)</li>
<li><strong>Port</strong> - IMAP port (default: 993 for TLS)</li>
<li><strong>Username</strong> - Email account username (encrypted)</li>
<li><strong>Password</strong> - Email account password (encrypted, may need app-specific password)</li>
</ul>
</li>
<li><strong>SMTP Settings</strong>:
<ul>
<li><strong>Server</strong> - SMTP server address (e.g., smtp.gmail.com)</li>
<li><strong>Port</strong> - SMTP port (465 for direct TLS recommended, 587 for STARTTLS)</li>
</ul>
</li>
<li><strong>AI Configuration</strong>:
<ul>
<li><strong>Provider</strong> - Which AI provider to use for responses</li>
<li><strong>Model</strong> - Specific model to use</li>
<li><strong>Max Tokens</strong> - Response length limit</li>
<li><strong>Enable Online Mode</strong> - Allow AI to search the web for context</li>
</ul>
</li>
<li><strong>Rate Limiting</strong>:
<ul>
<li><strong>Enable Rate Limit</strong> - Prevent excessive email processing</li>
<li><strong>Max Per Hour</strong> - Maximum emails to process per hour</li>
</ul>
</li>
<li><strong>Test Connection</strong> - Verify IMAP/SMTP connectivity and credentials</li>
<li><strong>Email Log</strong> - View history of processed emails with success/error status</li>
</ul>
<div class="warning">
<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>Shortcuts Tab</h3>
<p>Create and manage personal prompt template commands. See <a href="#shortcuts">Shortcuts</a> section for full details.</p>
<h3>Skills Tab</h3>
<p>Manage SKILL.md-style behavioral instruction files injected into the system prompt. See <a href="#agent-skills">Agent Skills</a> section for full details.</p>
<h3>Appearance Tab</h3>
<ul>
<li>Adjust text sizes for input and dialog</li>
@@ -1011,6 +1312,9 @@ AI Assistant</code></pre>
<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>
</ul>
</section>
@@ -1064,6 +1368,7 @@ AI Assistant</code></pre>
<li>Default Prompt (always included)</li>
<li>+ Your Custom Prompt (if set)</li>
<li>+ MCP Instructions (if MCP is enabled)</li>
<li>+ Active Agent Skills (if any skills are active)</li>
</ol>
<p><em>This combined prompt is sent with every message to ensure consistent behavior.</em></p>
</div>
@@ -1094,7 +1399,7 @@ AI Assistant</code></pre>
</main>
<footer>
<p>© 2026 oAI. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="mailto:support@fubar.pm?subject=oAI Support&body=What can I help you with?">Contact Us</a>.</p>
<p>© 2026 oAI - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="mailto:support@fubar.pm?subject=oAI Support&body=What can I help you with?">Contact Us</a>.</p>
</footer>
</div>
</body>

View File

@@ -0,0 +1,71 @@
//
// AgentSkillFilesService.swift
// oAI
//
// Manages per-skill file directories in Application Support/oAI/skills/<uuid>/
//
import Foundation
import UniformTypeIdentifiers
final class AgentSkillFilesService {
static let shared = AgentSkillFilesService()
private let baseDirectory: URL = {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first!
return appSupport.appendingPathComponent("oAI/skills", isDirectory: true)
}()
func skillDirectory(for id: UUID) -> URL {
baseDirectory.appendingPathComponent(id.uuidString, isDirectory: true)
}
func ensureDirectory(for id: UUID) {
try? FileManager.default.createDirectory(
at: skillDirectory(for: id), withIntermediateDirectories: true)
}
func listFiles(for id: UUID) -> [URL] {
guard let contents = try? FileManager.default.contentsOfDirectory(
at: skillDirectory(for: id),
includingPropertiesForKeys: [.fileSizeKey],
options: .skipsHiddenFiles)
else { return [] }
return contents.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
func addFile(from sourceURL: URL, to id: UUID) throws {
ensureDirectory(for: id)
let dest = skillDirectory(for: id).appendingPathComponent(sourceURL.lastPathComponent)
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.copyItem(at: sourceURL, to: dest)
}
func deleteFile(at url: URL) {
try? FileManager.default.removeItem(at: url)
}
func deleteAll(for id: UUID) {
try? FileManager.default.removeItem(at: skillDirectory(for: id))
}
func hasFiles(for id: UUID) -> Bool {
!listFiles(for: id).isEmpty
}
/// Returns (filename, content) for all readable text files
func readTextFiles(for id: UUID) -> [(name: String, content: String)] {
listFiles(for: id).compactMap { url in
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }
return (url.lastPathComponent, content)
}
}
/// Returns file size in bytes, or nil if unavailable
func fileSize(at url: URL) -> Int? {
(try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize
}
}

View File

@@ -0,0 +1,542 @@
//
// AnytypeMCPService.swift
// oAI
//
// Anytype MCP integration via local HTTP API at http://127.0.0.1:31009
//
import Foundation
import os
@Observable
class AnytypeMCPService {
static let shared = AnytypeMCPService()
private let settings = SettingsService.shared
private let log = Logger(subsystem: "com.oai.oAI", category: "mcp")
private let apiVersion = "2025-11-08"
private let timeout: TimeInterval = 10
private(set) var isConnected = false
private init() {}
// MARK: - Connection Test
func testConnection() async -> Result<String, Error> {
do {
let result = try await request(endpoint: "/v1/spaces", method: "GET", body: nil)
if let spaces = result["data"] as? [[String: Any]] {
isConnected = true
return .success("Connected (\(spaces.count) space\(spaces.count == 1 ? "" : "s"))")
} else {
isConnected = true
return .success("Connected to Anytype")
}
} catch {
isConnected = false
return .failure(error)
}
}
// MARK: - Tool Schemas
func getToolSchemas() -> [Tool] {
return [
makeTool(
name: "anytype_search_global",
description: "Search across all Anytype spaces for objects matching a query. Returns matching objects with their IDs, names, types, and space info.",
properties: [
"query": prop("string", "The search query text"),
"limit": prop("number", "Maximum number of results to return (default: 20)")
],
required: ["query"]
),
makeTool(
name: "anytype_list_spaces",
description: "List all available Anytype spaces (workspaces). Returns space IDs, names, and basic info.",
properties: [:],
required: []
),
makeTool(
name: "anytype_get_space_objects",
description: "Get objects in a specific Anytype space. Returns a list of objects with their IDs, names, and types. Use search tools to find specific objects rather than listing all.",
properties: [
"space_id": prop("string", "The ID of the Anytype space"),
"limit": prop("number", "Maximum number of objects to return (default: 20, max recommended: 50)")
],
required: ["space_id"]
),
makeTool(
name: "anytype_get_object",
description: "Get the full details and content of a specific Anytype object by ID. The 'body' field contains the COMPLETE markdown including Anytype internal links (anytype://object?...) and file links that MUST be preserved exactly when updating.",
properties: [
"space_id": prop("string", "The ID of the space containing the object"),
"object_id": prop("string", "The ID of the object to retrieve")
],
required: ["space_id", "object_id"]
),
makeTool(
name: "anytype_create_object",
description: "Create a new object (note, task, or page) in an Anytype space.",
properties: [
"space_id": prop("string", "The ID of the space to create the object in"),
"name": prop("string", "The name/title of the object"),
"body": prop("string", "The text content/body of the object"),
"type": prop("string", "The type of object: 'note', 'task', or 'page' (default: note)")
],
required: ["space_id", "name"]
),
makeTool(
name: "anytype_update_object",
description: """
Replace the full markdown body or rename an Anytype object. \
IMPORTANT: For toggling a checkbox (to-do item), use anytype_toggle_checkbox instead — \
it is safer and does not risk modifying other content. \
Use anytype_update_object ONLY for large content changes (adding paragraphs, rewriting sections, etc.). \
CRITICAL RULES when using this tool: \
1) Always call anytype_get_object first to get the current EXACT markdown. \
2) Make ONLY the minimal requested change — nothing else. \
3) Copy ALL other content CHARACTER-FOR-CHARACTER, including anytype://object?objectId=... links, \
file links, headings, and all formatting. Do NOT summarise, paraphrase, or reformat anything you are not asked to change.
""",
properties: [
"space_id": prop("string", "The ID of the space containing the object"),
"object_id": prop("string", "The ID of the object to update"),
"name": prop("string", "New name/title for the object (optional)"),
"body": prop("string", "COMPLETE replacement markdown content — must preserve ALL existing content with ONLY the minimal intended change applied")
],
required: ["space_id", "object_id"]
),
makeTool(
name: "anytype_set_done",
description: "Mark a task as done or undone in Anytype by setting its 'done' relation. This is the correct way to check/uncheck tasks — do not use anytype_update_object body text for this.",
properties: [
"space_id": prop("string", "The ID of the space containing the task"),
"object_id": prop("string", "The ID of the task to update"),
"done": prop("boolean", "true to mark as done, false to mark as undone")
],
required: ["space_id", "object_id", "done"]
),
makeTool(
name: "anytype_toggle_checkbox",
description: """
Surgically toggle a specific checkbox in an Anytype object without rewriting the full body. \
Use this INSTEAD of anytype_update_object when marking a to-do item done/undone. \
Provide partial text that uniquely identifies the checkbox line (do not include the '- [ ]' or '- [x]' prefix). \
The tool finds the matching line and flips its checkbox state, preserving all other content exactly.
""",
properties: [
"space_id": prop("string", "The ID of the space containing the object"),
"object_id": prop("string", "The ID of the object containing the checkbox"),
"line_text": prop("string", "Partial text of the checkbox line to find (e.g. 'Buy groceries'). Do NOT include the '- [ ]' prefix."),
"done": prop("boolean", "true to check the box, false to uncheck it")
],
required: ["space_id", "object_id", "line_text", "done"]
),
makeTool(
name: "anytype_search_space",
description: "Search within a specific Anytype space for objects matching a query.",
properties: [
"space_id": prop("string", "The ID of the Anytype space to search in"),
"query": prop("string", "The search query text"),
"limit": prop("number", "Maximum number of results (default: 20)")
],
required: ["space_id", "query"]
)
]
}
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) async -> [String: Any] {
log.info("Executing Anytype tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
return ["error": "Invalid arguments JSON"]
}
return await executeToolAsync(name: name, args: args)
}
private func executeToolAsync(name: String, args: [String: Any]) async -> [String: Any] {
do {
switch name {
case "anytype_search_global":
guard let query = args["query"] as? String else {
return ["error": "Missing required parameter: query"]
}
let limit = args["limit"] as? Int ?? 20
return try await searchGlobal(query: query, limit: limit)
case "anytype_list_spaces":
return try await listSpaces()
case "anytype_get_space_objects":
guard let spaceId = args["space_id"] as? String else {
return ["error": "Missing required parameter: space_id"]
}
let limit = min(args["limit"] as? Int ?? 20, 50)
return try await getSpaceObjects(spaceId: spaceId, limit: limit)
case "anytype_get_object":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String else {
return ["error": "Missing required parameters: space_id, object_id"]
}
return try await getObject(spaceId: spaceId, objectId: objectId)
case "anytype_create_object":
guard let spaceId = args["space_id"] as? String,
let name = args["name"] as? String else {
return ["error": "Missing required parameters: space_id, name"]
}
let body = args["body"] as? String ?? ""
let type_ = args["type"] as? String ?? "note"
return try await createObject(spaceId: spaceId, name: name, body: body, type: type_)
case "anytype_update_object":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String else {
return ["error": "Missing required parameters: space_id, object_id"]
}
let name = args["name"] as? String
let body = args["body"] as? String
return try await updateObject(spaceId: spaceId, objectId: objectId, name: name, body: body)
case "anytype_set_done":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String else {
return ["error": "Missing required parameters: space_id, object_id"]
}
// Accept done as Bool or as Int (1/0) since JSON can arrive either way
let done: Bool
if let b = args["done"] as? Bool {
done = b
} else if let n = args["done"] as? Int {
done = n != 0
} else {
return ["error": "Missing or invalid parameter: done (expected boolean)"]
}
return try await setDone(spaceId: spaceId, objectId: objectId, done: done)
case "anytype_toggle_checkbox":
guard let spaceId = args["space_id"] as? String,
let objectId = args["object_id"] as? String,
let lineText = args["line_text"] as? String else {
return ["error": "Missing required parameters: space_id, object_id, line_text"]
}
let done: Bool
if let b = args["done"] as? Bool { done = b }
else if let n = args["done"] as? Int { done = n != 0 }
else { return ["error": "Missing or invalid parameter: done (expected boolean)"] }
return try await toggleCheckbox(spaceId: spaceId, objectId: objectId, lineText: lineText, done: done)
case "anytype_search_space":
guard let spaceId = args["space_id"] as? String,
let query = args["query"] as? String else {
return ["error": "Missing required parameters: space_id, query"]
}
let limit = args["limit"] as? Int ?? 20
return try await searchSpace(spaceId: spaceId, query: query, limit: limit)
default:
return ["error": "Unknown Anytype tool: \(name)"]
}
} catch AnytypeError.notRunning {
return ["error": "Cannot connect to Anytype. Make sure the desktop app is running."]
} catch AnytypeError.unauthorized {
return ["error": "Invalid API key. Check your Anytype API key in Settings > MCP."]
} catch AnytypeError.httpError(let code, let msg) {
return ["error": "Anytype API error \(code): \(msg)"]
} catch {
return ["error": "Anytype error: \(error.localizedDescription)"]
}
}
// MARK: - API Operations
private func searchGlobal(query: String, limit: Int) async throws -> [String: Any] {
let body: [String: Any] = ["query": query, "limit": limit]
let result = try await request(endpoint: "/v1/search", method: "POST", body: body)
if let objects = result["data"] as? [[String: Any]] {
let formatted = objects.map { formatObject($0) }
return ["count": formatted.count, "objects": formatted]
}
return ["count": 0, "objects": [], "message": "No results found"]
}
private func listSpaces() async throws -> [String: Any] {
let result = try await request(endpoint: "/v1/spaces", method: "GET", body: nil)
if let spaces = result["data"] as? [[String: Any]] {
let formatted = spaces.map { space -> [String: Any] in
[
"id": space["id"] ?? "",
"name": space["name"] ?? "Unnamed Space",
"type": space["spaceType"] ?? "unknown"
]
}
return ["count": formatted.count, "spaces": formatted]
}
return ["count": 0, "spaces": []]
}
private func getSpaceObjects(spaceId: String, limit: Int) async throws -> [String: Any] {
let result = try await request(
endpoint: "/v1/spaces/\(spaceId)/objects",
method: "GET",
body: nil,
queryParams: ["limit": String(limit)]
)
if let objects = result["data"] as? [[String: Any]] {
let formatted = Array(objects.prefix(limit).map { formatObject($0) })
return ["count": formatted.count, "objects": formatted]
}
return ["count": 0, "objects": []]
}
private func getObject(spaceId: String, objectId: String) async throws -> [String: Any] {
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil)
if let object = result["object"] as? [String: Any] {
return formatObjectDetail(object)
}
// Return full raw response if expected structure not found
return result
}
private func createObject(spaceId: String, name: String, body: String, type: String) async throws -> [String: Any] {
let typeKey: String
switch type.lowercased() {
case "task": typeKey = "ot-task"
case "page": typeKey = "ot-page"
default: typeKey = "ot-note"
}
let requestBody: [String: Any] = [
"name": name,
"body": body, // some API versions use "body"
"markdown": body, // newer API versions use "markdown"
"typeKey": typeKey,
"template_id": ""
]
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects", method: "POST", body: requestBody)
if let object = result["object"] as? [String: Any] {
return ["success": true, "id": object["id"] ?? "", "name": name, "message": "Object created successfully"]
}
return ["success": true, "message": "Object created"]
}
private func updateObject(spaceId: String, objectId: String, name: String?, body: String?) async throws -> [String: Any] {
var requestBody: [String: Any] = [:]
if let name = name { requestBody["name"] = name }
// The Anytype API GET response uses "markdown" for body content, not "body"
if let body = body { requestBody["markdown"] = body }
guard !requestBody.isEmpty else {
return ["error": "No fields to update. Provide name or body."]
}
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "PATCH", body: requestBody)
if result.isEmpty {
return ["success": true, "message": "Object updated (empty response from Anytype API)"]
}
return result
}
private func toggleCheckbox(spaceId: String, objectId: String, lineText: String, done: Bool) async throws -> [String: Any] {
// Fetch current content
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil)
guard let object = result["object"] as? [String: Any] else {
return ["error": "Object not found"]
}
let markdown: String
if let md = object["markdown"] as? String { markdown = md }
else if let body = object["body"] as? String { markdown = body }
else { return ["error": "Object has no markdown body"] }
// Find the line containing the search text (case-insensitive)
let lines = markdown.components(separatedBy: "\n")
let searchLower = lineText.lowercased()
guard let idx = lines.firstIndex(where: {
let stripped = $0.replacingOccurrences(of: "- [ ] ", with: "")
.replacingOccurrences(of: "- [x] ", with: "")
.replacingOccurrences(of: "- [X] ", with: "")
return stripped.lowercased().contains(searchLower)
}) else {
return ["error": "No checkbox line found containing: '\(lineText)'"]
}
var line = lines[idx]
let wasDone = line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ")
if done == wasDone {
return ["success": true, "message": "Checkbox was already \(done ? "checked" : "unchecked")", "line": line]
}
// Flip the checkbox marker, preserving the rest of the line exactly
if done {
if line.hasPrefix("- [ ] ") { line = "- [x] " + line.dropFirst(6) }
} else {
if line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ") { line = "- [ ] " + line.dropFirst(6) }
}
var updated = lines
updated[idx] = line
let newMarkdown = updated.joined(separator: "\n")
let patchResult = try await request(
endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)",
method: "PATCH",
body: ["markdown": newMarkdown]
)
return ["success": true, "done": done, "line": line, "message": "Checkbox updated"]
}
private func setDone(spaceId: String, objectId: String, done: Bool) async throws -> [String: Any] {
// Anytype stores task completion as a relation called "done"
let requestBody: [String: Any] = [
"properties": [
"done": done
]
]
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "PATCH", body: requestBody)
if result.isEmpty {
return ["success": true, "done": done, "message": "setDone sent (empty response from Anytype API — verify in app)"]
}
return result
}
private func searchSpace(spaceId: String, query: String, limit: Int) async throws -> [String: Any] {
let body: [String: Any] = ["query": query, "limit": limit]
let result = try await request(endpoint: "/v1/spaces/\(spaceId)/search", method: "POST", body: body)
if let objects = result["data"] as? [[String: Any]] {
let formatted = objects.map { formatObject($0) }
return ["count": formatted.count, "objects": formatted]
}
return ["count": 0, "objects": [], "message": "No results found"]
}
// MARK: - HTTP Client
private func request(endpoint: String, method: String, body: [String: Any]?, queryParams: [String: String] = [:]) async throws -> [String: Any] {
guard let apiKey = settings.anytypeMcpAPIKey, !apiKey.isEmpty else {
throw AnytypeError.unauthorized
}
let baseURL = settings.anytypeMcpEffectiveURL
var urlString = baseURL + endpoint
if !queryParams.isEmpty {
var comps = URLComponents(string: urlString) ?? URLComponents()
comps.queryItems = queryParams.map { URLQueryItem(name: $0.key, value: $0.value) }
urlString = comps.url?.absoluteString ?? urlString
}
guard let url = URL(string: urlString) else {
throw AnytypeError.httpError(0, "Invalid URL: \(urlString)")
}
var urlRequest = URLRequest(url: url, timeoutInterval: timeout)
urlRequest.httpMethod = method
urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue(apiVersion, forHTTPHeaderField: "Anytype-Version")
if let body = body, method != "GET" {
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw AnytypeError.httpError(0, "Invalid response")
}
if httpResponse.statusCode == 401 {
throw AnytypeError.unauthorized
}
guard (200...299).contains(httpResponse.statusCode) else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
throw AnytypeError.httpError(httpResponse.statusCode, msg)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return [:]
}
return json
} catch let error as AnytypeError {
throw error
} catch {
// Connection refused or network error = Anytype not running
throw AnytypeError.notRunning
}
}
// MARK: - Helpers
private func formatObject(_ obj: [String: Any]) -> [String: Any] {
[
"id": obj["id"] ?? "",
"name": obj["name"] ?? "Unnamed",
"type": obj["type"] ?? obj["objectType"] ?? "unknown",
"space_id": obj["spaceId"] ?? ""
]
}
private func formatObjectDetail(_ obj: [String: Any]) -> [String: Any] {
var result: [String: Any] = formatObject(obj)
// Anytype API returns content as "markdown" not "body"
if let markdown = obj["markdown"] as? String { result["body"] = markdown }
else if let body = obj["body"] as? String { result["body"] = body }
if let snippet = obj["snippet"] as? String { result["snippet"] = snippet }
return result
}
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
Tool(
type: "function",
function: Tool.Function(
name: name,
description: description,
parameters: Tool.Function.Parameters(
type: "object",
properties: properties,
required: required
)
)
)
}
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
}
}
// MARK: - Error Types
enum AnytypeError: LocalizedError {
case notRunning
case unauthorized
case httpError(Int, String)
var errorDescription: String? {
switch self {
case .notRunning:
return "Cannot connect to Anytype. Make sure the desktop app is running."
case .unauthorized:
return "Invalid API key. Check your Anytype API key in Settings > MCP."
case .httpError(let code, let msg):
return "Anytype API error \(code): \(msg)"
}
}
}

View File

@@ -463,7 +463,7 @@ final class DatabaseService: Sendable {
.filter(Column("conversationId") == record.id)
.fetchCount(db)) ?? 0
// Get last message date
// Get last message (for date + model fallback)
let lastMsg = try? MessageRecord
.filter(Column("conversationId") == record.id)
.order(Column("sortOrder").desc)
@@ -471,15 +471,18 @@ final class DatabaseService: Sendable {
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt
// Derive primary model: prefer the stored field, fall back to last message's modelId
let primaryModel = record.primaryModel ?? lastMsg?.modelId
// Create conversation with empty messages array but correct metadata
var conv = Conversation(
id: id,
name: record.name,
messages: Array(repeating: Message(role: .user, content: ""), count: messageCount),
createdAt: createdAt,
updatedAt: lastDate
updatedAt: lastDate,
primaryModel: primaryModel
)
// We store placeholder messages just for the count; lastMessageDate uses updatedAt
conv.updatedAt = lastDate
return conv
}

View File

@@ -130,31 +130,37 @@ final class EmailService {
// Search for ALL unseen emails first
let allUnseenUIDs = try await client.searchUnseen()
// Remove UIDs that are no longer unseen (emails were deleted/marked read)
checkedUIDs = checkedUIDs.intersection(Set(allUnseenUIDs))
// Check each email for the subject identifier
for uid in allUnseenUIDs {
if !checkedUIDs.contains(uid) {
checkedUIDs.insert(uid)
// Skip if we've already checked this UID (for non-matching emails only)
if checkedUIDs.contains(uid) {
continue
}
do {
let email = try await client.fetchEmail(uid: uid)
do {
let email = try await client.fetchEmail(uid: uid)
// Check if email has the correct subject identifier
if email.subject.contains(settings.emailSubjectIdentifier) {
// Valid email - process it
log.info("New email found: \(email.subject) from \(email.from)")
// Check if email has the correct subject identifier
if email.subject.contains(self.settings.emailSubjectIdentifier) {
// Valid email - process it (don't add to checkedUIDs, handler will delete it)
log.info("Found matching email: \(email.subject) from \(email.from)")
// Call callback on main thread
await MainActor.run {
onNewEmail?(email)
}
} else {
// Wrong subject - delete it
log.warning("Deleting email without subject identifier: \(email.subject) from \(email.from)")
try await client.deleteEmail(uid: uid)
// Call callback on main thread
await MainActor.run {
onNewEmail?(email)
}
} catch {
log.error("Failed to fetch/process email \(uid): \(error.localizedDescription)")
} else {
// Wrong subject - delete it and remember we checked it
log.warning("Deleting email without subject identifier: \(email.subject) from \(email.from)")
try await client.deleteEmail(uid: uid)
checkedUIDs.insert(uid) // Only track non-matching emails
}
} catch {
log.error("Failed to fetch/process email \(uid): \(error.localizedDescription)")
checkedUIDs.insert(uid) // Track failed emails to avoid retry loops
}
}

View File

@@ -16,6 +16,12 @@ class GitSyncService {
// Debounce tracking
private var pendingSyncTask: Task<Void, Never>?
private init() {
// Check if repository is cloned at initialization (synchronous check)
let localPath = expandPath(settings.syncLocalPath)
syncStatus.isCloned = FileManager.default.fileExists(atPath: localPath + "/.git")
}
// MARK: - Repository Operations
/// Test connection to remote repository
@@ -366,9 +372,17 @@ class GitSyncService {
/// Sync on app startup (pull + import only, no push)
/// Runs silently in background to fetch changes from other devices
func syncOnStartup() async {
// First, update status to check if repo is actually cloned
await updateStatus()
// Only run if configured and cloned
guard settings.syncConfigured && syncStatus.isCloned else {
log.debug("Skipping startup sync (not configured or not cloned)")
guard settings.syncConfigured else {
log.debug("Skipping startup sync (sync not configured)")
return
}
guard syncStatus.isCloned else {
log.debug("Skipping startup sync (repository not cloned)")
return
}

View File

@@ -91,6 +91,8 @@ class MCPService {
var canMoveFiles: Bool { settings.mcpCanMoveFiles }
var respectGitignore: Bool { settings.mcpRespectGitignore }
private let anytypeService = AnytypeMCPService.shared
// MARK: - Tool Schema Generation
func getToolSchemas() -> [Tool] {
@@ -189,6 +191,11 @@ class MCPService {
))
}
// Add Anytype tools if enabled and configured
if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured {
tools.append(contentsOf: anytypeService.getToolSchemas())
}
return tools
}
@@ -213,7 +220,7 @@ class MCPService {
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) -> [String: Any] {
func executeTool(name: String, arguments: String) async -> [String: Any] {
Log.mcp.info("Executing tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
@@ -303,6 +310,10 @@ class MCPService {
return copyFile(source: source, destination: destination)
default:
// Route anytype_* tools to AnytypeMCPService
if name.hasPrefix("anytype_") {
return await anytypeService.executeTool(name: name, arguments: arguments)
}
return ["error": "Unknown tool: \(name)"]
}
}

View File

@@ -23,6 +23,7 @@ class SettingsService {
static let openaiAPIKey = "openaiAPIKey"
static let googleAPIKey = "googleAPIKey"
static let googleSearchEngineID = "googleSearchEngineID"
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
}
// Old keychain keys (for migration only)
@@ -313,6 +314,120 @@ class SettingsService {
}
}
// MARK: - User Shortcuts (prompt template macros)
var userShortcuts: [Shortcut] {
get {
guard let json = cache["userShortcuts"],
let data = json.data(using: .utf8) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([Shortcut].self, from: data)) ?? []
}
set {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(newValue),
let json = String(data: data, encoding: .utf8) {
cache["userShortcuts"] = json
DatabaseService.shared.setSetting(key: "userShortcuts", value: json)
}
}
}
func addShortcut(_ shortcut: Shortcut) { userShortcuts = userShortcuts + [shortcut] }
func updateShortcut(_ shortcut: Shortcut) {
userShortcuts = userShortcuts.map { $0.id == shortcut.id ? shortcut : $0 }
}
func deleteShortcut(id: UUID) { userShortcuts = userShortcuts.filter { $0.id != id } }
// MARK: - Agent Skills (SKILL.md-style behavioral instructions)
var agentSkills: [AgentSkill] {
get {
guard let json = cache["agentSkills"],
let data = json.data(using: .utf8) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([AgentSkill].self, from: data)) ?? []
}
set {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(newValue),
let json = String(data: data, encoding: .utf8) {
cache["agentSkills"] = json
DatabaseService.shared.setSetting(key: "agentSkills", value: json)
}
}
}
func addAgentSkill(_ skill: AgentSkill) { agentSkills = agentSkills + [skill] }
func updateAgentSkill(_ skill: AgentSkill) {
agentSkills = agentSkills.map { $0.id == skill.id ? skill : $0 }
}
func deleteAgentSkill(id: UUID) {
agentSkills = agentSkills.filter { $0.id != id }
AgentSkillFilesService.shared.deleteAll(for: id)
}
func toggleAgentSkill(id: UUID) {
agentSkills = agentSkills.map { s in
s.id == id ? AgentSkill(id: s.id, name: s.name, skillDescription: s.skillDescription,
content: s.content, isActive: !s.isActive,
createdAt: s.createdAt, updatedAt: Date()) : s
}
}
// MARK: - Anytype MCP Settings
var anytypeMcpEnabled: Bool {
get { cache["anytypeMcpEnabled"] == "true" }
set {
cache["anytypeMcpEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "anytypeMcpEnabled", value: String(newValue))
}
}
var anytypeMcpURL: String {
get { cache["anytypeMcpURL"] ?? "" }
set {
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
cache.removeValue(forKey: "anytypeMcpURL")
DatabaseService.shared.deleteSetting(key: "anytypeMcpURL")
} else {
cache["anytypeMcpURL"] = trimmed
DatabaseService.shared.setSetting(key: "anytypeMcpURL", value: trimmed)
}
}
}
var anytypeMcpEffectiveURL: String {
let url = anytypeMcpURL
return url.isEmpty ? "http://127.0.0.1:31009" : url
}
var anytypeMcpAPIKey: String? {
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey) }
set {
if let value = newValue, !value.isEmpty {
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey, value: value)
} else {
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey)
}
}
}
var anytypeMcpConfigured: Bool {
guard let key = anytypeMcpAPIKey else { return false }
return !key.isEmpty
}
// MARK: - Search Settings
var searchProvider: Settings.SearchProvider {

View File

@@ -33,6 +33,8 @@ class ChatViewModel {
var showHelp: Bool = false
var showCredits: Bool = false
var showHistory: Bool = false
var showShortcuts: Bool = false
var showSkills: Bool = false
var modelInfoTarget: ModelInfo? = nil
var commandHistory: [String] = []
var historyIndex: Int = 0
@@ -139,6 +141,23 @@ Don't narrate future actions ("Let me...") - just use the tools.
prompt += "\n\n---\n\nAdditional Instructions:\n" + customPrompt
}
// Append active agent skills (SKILL.md-style behavioral instructions)
let activeSkills = settings.agentSkills.filter { $0.isActive }
if !activeSkills.isEmpty {
prompt += "\n\n---\n\n## Installed Skills\n\nThe following skills are active. Apply them when relevant:\n\n"
for skill in activeSkills {
prompt += "### \(skill.name)\n\n\(skill.content)\n\n"
let files = AgentSkillFilesService.shared.readTextFiles(for: skill.id)
if !files.isEmpty {
prompt += "**Skill Data Files:**\n\n"
for (name, content) in files {
let ext = URL(fileURLWithPath: name).pathExtension.lowercased()
prompt += "**\(name):**\n```\(ext)\n\(content)\n```\n\n"
}
}
}
}
return prompt
}
@@ -367,10 +386,79 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
showSystemMessage("Loaded conversation '\(conversation.name)'")
// Auto-switch to the provider/model this conversation was created with
if let modelId = conversation.primaryModel {
Task { await switchToConversationModel(modelId) }
}
} catch {
showSystemMessage("Failed to load: \(error.localizedDescription)")
}
}
/// Infer which provider owns a given model ID based on naming conventions.
private func inferProvider(from modelId: String) -> Settings.Provider? {
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
if modelId.contains("/") { return .openrouter }
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
if modelId.hasPrefix("claude-") { return .anthropic }
// OpenAI direct
if modelId.hasPrefix("gpt-") || modelId.hasPrefix("o1") || modelId.hasPrefix("o3")
|| modelId.hasPrefix("dall-e-") || modelId.hasPrefix("chatgpt-") { return .openai }
// Ollama uses short local names with no vendor prefix
return .ollama
}
/// Silently switch provider + model to match a loaded conversation.
/// Shows a system message only on failure or on a successful switch.
@MainActor
private func switchToConversationModel(_ modelId: String) async {
guard let targetProvider = inferProvider(from: modelId) else {
showSystemMessage("⚠️ Could not determine provider for model '\(modelId)' — keeping current model")
return
}
guard providerRegistry.hasValidAPIKey(for: targetProvider) else {
showSystemMessage("⚠️ No API key for \(targetProvider.displayName) — keeping current model")
return
}
// Switch provider if needed, or load models if not yet loaded
if targetProvider != currentProvider || availableModels.isEmpty {
if targetProvider != currentProvider {
settings.defaultProvider = targetProvider
currentProvider = targetProvider
selectedModel = nil
availableModels = []
providerRegistry.clearCache()
}
isLoadingModels = true
do {
guard let provider = providerRegistry.getProvider(for: targetProvider) else {
isLoadingModels = false
showSystemMessage("⚠️ Could not connect to \(targetProvider.displayName) — keeping current model")
return
}
availableModels = try await provider.listModels()
isLoadingModels = false
} catch {
isLoadingModels = false
showSystemMessage("⚠️ Could not load \(targetProvider.displayName) models — keeping current model")
return
}
}
guard let model = availableModels.first(where: { $0.id == modelId }) else {
showSystemMessage("⚠️ Model '\(modelId)' not available — keeping current model")
return
}
guard selectedModel?.id != modelId else { return } // already on it, no message needed
selectedModel = model
showSystemMessage("Switched to \(model.name) · \(targetProvider.displayName)")
}
func retryLastMessage() {
guard let lastUserMessage = messages.last(where: { $0.role == .user }) else {
@@ -517,11 +605,38 @@ Don't narrate future actions ("Let me...") - just use the tools.
case "/credits":
showCredits = true
case "/shortcuts":
showShortcuts = true
case "/skills":
showSkills = true
case "/mcp":
handleMCPCommand(args: args)
default:
// Check user-defined shortcuts
if let shortcut = settings.userShortcuts.first(where: { $0.command == cmd.lowercased() }) {
let userInput = args.joined(separator: " ")
let prompt = shortcut.needsInput
? shortcut.template.replacingOccurrences(of: "{{input}}", with: userInput)
: shortcut.template
let msg = Message(
role: .user,
content: prompt,
tokens: prompt.estimateTokens(),
cost: nil,
timestamp: Date(),
attachments: nil,
modelId: selectedModel?.id
)
messages.append(msg)
sessionStats.addMessage(inputTokens: msg.tokens, outputTokens: nil, cost: nil)
generateEmbeddingForMessage(msg)
generateAIResponse(to: prompt, attachments: nil)
return
}
showSystemMessage("Unknown command: \(cmd)\nType /help for available commands")
}
}
@@ -544,12 +659,13 @@ Don't narrate future actions ("Let me...") - just use the tools.
Log.ui.info("Sending message: model=\(modelId), messages=\(self.messages.count)")
// Dispatch to tool-aware path when MCP is enabled with folders
// Dispatch to tool-aware path when MCP is enabled with folders or Anytype is enabled
// Skip for image generation models they don't support tool calling
let mcp = MCPService.shared
let mcpActive = mcpEnabled || settings.mcpEnabled
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
let modelSupportTools = selectedModel?.capabilities.tools ?? false
if mcpActive && !mcp.allowedFolders.isEmpty && modelSupportTools {
if modelSupportTools && (anytypeActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
generateAIResponseWithTools(provider: provider, modelId: modelId)
return
}
@@ -777,7 +893,16 @@ Don't narrate future actions ("Let me...") - just use the tools.
for rawPath in paths {
// Expand ~ and resolve path
let expanded = (rawPath as NSString).expandingTildeInPath
let resolvedPath = expanded.hasPrefix("/") ? expanded : (fm.currentDirectoryPath as NSString).appendingPathComponent(expanded)
var resolvedPath = expanded.hasPrefix("/") ? expanded : (fm.currentDirectoryPath as NSString).appendingPathComponent(expanded)
// If not found, try iCloud Drive path
if !fm.fileExists(atPath: resolvedPath) {
let icloudBase = (("~/Library/Mobile Documents" as NSString).expandingTildeInPath as NSString)
let candidate = icloudBase.appendingPathComponent(rawPath)
if fm.fileExists(atPath: candidate) {
resolvedPath = candidate
}
}
// Check file exists
guard fm.fileExists(atPath: resolvedPath) else {
@@ -835,6 +960,46 @@ Don't narrate future actions ("Let me...") - just use the tools.
return attachments
}
// MARK: - Text Tool Call Parsing
/// Fallback parser for models that write tool calls as text instead of using structured tool_calls.
/// Handles two patterns:
/// tool_name{"arg": "val"} (no space between name and args)
/// tool_name({"arg": "val"}) (with wrapping parens)
private func parseTextToolCalls(from content: String) -> [ToolCallInfo] {
var results: [ToolCallInfo] = []
// Match: word_chars optionally followed by ( then { ... } optionally followed by )
// Use a broad pattern and validate JSON manually
let pattern = #"([a-z_][a-z0-9_]*)\s*\(?\s*(\{[\s\S]*?\})\s*\)?"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return []
}
let nsContent = content as NSString
let matches = regex.matches(in: content, range: NSRange(location: 0, length: nsContent.length))
let knownTools = Set(MCPService.shared.getToolSchemas().map { $0.function.name })
for match in matches {
guard let nameRange = Range(match.range(at: 1), in: content),
let argsRange = Range(match.range(at: 2), in: content) else { continue }
let name = String(content[nameRange])
let argsStr = String(content[argsRange])
// Only handle known tool names to avoid false positives
guard knownTools.contains(name) else { continue }
// Validate the JSON
guard let _ = try? JSONSerialization.jsonObject(with: Data(argsStr.utf8)) else { continue }
Log.ui.info("Parsed text tool call: \(name)")
results.append(ToolCallInfo(id: UUID().uuidString, type: "function", functionName: name, arguments: argsStr))
}
return results
}
// MARK: - MCP Command Handling
private func handleMCPCommand(args: [String]) {
@@ -955,17 +1120,28 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
// Build initial messages as raw dictionaries for the tool loop
let folderList = mcp.allowedFolders.joined(separator: "\n - ")
var capabilities = "You can read files, list directories, and search for files."
var writeCapabilities: [String] = []
if mcp.canWriteFiles { writeCapabilities.append("write and edit files") }
if mcp.canDeleteFiles { writeCapabilities.append("delete files") }
if mcp.canCreateDirectories { writeCapabilities.append("create directories") }
if mcp.canMoveFiles { writeCapabilities.append("move and copy files") }
if !writeCapabilities.isEmpty {
capabilities += " You can also \(writeCapabilities.joined(separator: ", "))."
var systemParts: [String] = []
let mcpActive = mcpEnabled || settings.mcpEnabled
if mcpActive && !mcp.allowedFolders.isEmpty {
let folderList = mcp.allowedFolders.joined(separator: "\n - ")
var capabilities = "You can read files, list directories, and search for files."
var writeCapabilities: [String] = []
if mcp.canWriteFiles { writeCapabilities.append("write and edit files") }
if mcp.canDeleteFiles { writeCapabilities.append("delete files") }
if mcp.canCreateDirectories { writeCapabilities.append("create directories") }
if mcp.canMoveFiles { writeCapabilities.append("move and copy files") }
if !writeCapabilities.isEmpty {
capabilities += " You can also \(writeCapabilities.joined(separator: ", "))."
}
systemParts.append("You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths.")
}
var systemContent = "You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths."
if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured {
systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.")
}
var systemContent = systemParts.joined(separator: "\n\n")
// Append the complete system prompt (default + custom)
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
@@ -994,7 +1170,25 @@ Don't narrate future actions ("Let me...") - just use the tools.
]
var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in
["role": msg.role.rawValue, "content": msg.content]
let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false
if hasAttachments {
var contentArray: [[String: Any]] = [["type": "text", "text": msg.content]]
for attachment in msg.attachments ?? [] {
guard let data = attachment.data else { continue }
switch attachment.type {
case .image, .pdf:
let base64String = data.base64EncodedString()
let dataURL = "data:\(attachment.mimeType);base64,\(base64String)"
contentArray.append(["type": "image_url", "image_url": ["url": dataURL]])
case .text:
let filename = (attachment.path as NSString).lastPathComponent
let textContent = String(data: data, encoding: .utf8) ?? ""
contentArray.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
}
}
return ["role": msg.role.rawValue, "content": contentArray]
}
return ["role": msg.role.rawValue, "content": msg.content]
}
let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit
@@ -1019,8 +1213,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
if let usage = response.usage { totalUsage = usage }
// Check if the model wants to call tools
guard let toolCalls = response.toolCalls, !toolCalls.isEmpty else {
// Also parse text-based tool calls for models that don't use structured tool_calls
let structuredCalls = response.toolCalls ?? []
let textCalls = structuredCalls.isEmpty ? parseTextToolCalls(from: response.content) : []
let toolCalls = structuredCalls.isEmpty ? textCalls : structuredCalls
guard !toolCalls.isEmpty else {
// No tool calls this is the final text response
// Strip any unparseable tool call text from display
finalContent = response.content
break
}
@@ -1029,46 +1229,71 @@ Don't narrate future actions ("Let me...") - just use the tools.
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
showSystemMessage("🔧 Calling: \(toolNames)")
// Append assistant message with tool_calls to conversation
var assistantMsg: [String: Any] = ["role": "assistant"]
if !response.content.isEmpty {
assistantMsg["content"] = response.content
}
let toolCallDicts: [[String: Any]] = toolCalls.map { tc in
[
"id": tc.id,
"type": tc.type,
"function": [
"name": tc.functionName,
"arguments": tc.arguments
let usingTextCalls = !textCalls.isEmpty
if usingTextCalls {
// Text-based tool calls: keep assistant message as-is (the text content)
apiMessages.append(["role": "assistant", "content": response.content])
} else {
// Structured tool_calls: append assistant message with tool_calls field
var assistantMsg: [String: Any] = ["role": "assistant"]
if !response.content.isEmpty {
assistantMsg["content"] = response.content
}
let toolCallDicts: [[String: Any]] = toolCalls.map { tc in
[
"id": tc.id,
"type": tc.type,
"function": [
"name": tc.functionName,
"arguments": tc.arguments
]
]
]
}
assistantMsg["tool_calls"] = toolCallDicts
apiMessages.append(assistantMsg)
}
assistantMsg["tool_calls"] = toolCallDicts
apiMessages.append(assistantMsg)
// Execute each tool and append results
var toolResultLines: [String] = []
for tc in toolCalls {
if Task.isCancelled {
wasCancelled = true
break
}
let result = mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
let resultJSON: String
if let data = try? JSONSerialization.data(withJSONObject: result),
let str = String(data: data, encoding: .utf8) {
resultJSON = str
// Cap tool results at 50 KB to avoid HTTP 413 on the next API call
let maxBytes = 50_000
if str.utf8.count > maxBytes {
let truncated = String(str.utf8.prefix(maxBytes))!
resultJSON = truncated + "\n... (result truncated, use a smaller limit or more specific query)"
} else {
resultJSON = str
}
} else {
resultJSON = "{\"error\": \"Failed to serialize result\"}"
}
apiMessages.append([
"role": "tool",
"tool_call_id": tc.id,
"name": tc.functionName,
"content": resultJSON
])
if usingTextCalls {
// Inject results as a user message for text-call models
toolResultLines.append("Tool result for \(tc.functionName):\n\(resultJSON)")
} else {
apiMessages.append([
"role": "tool",
"tool_call_id": tc.id,
"name": tc.functionName,
"content": resultJSON
])
}
}
if usingTextCalls && !toolResultLines.isEmpty {
let combined = toolResultLines.joined(separator: "\n\n")
apiMessages.append(["role": "user", "content": combined])
}
// If this was the last iteration, note it

View File

@@ -111,6 +111,12 @@ struct ChatView: View {
FooterView(stats: viewModel.sessionStats)
}
.background(Color.oaiBackground)
.sheet(isPresented: $viewModel.showShortcuts) {
ShortcutsView()
}
.sheet(isPresented: $viewModel.showSkills) {
AgentSkillsView()
}
}
}

View File

@@ -23,7 +23,7 @@ struct InputBar: View {
/// Commands that execute immediately without additional arguments
private static let immediateCommands: Set<String> = [
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
"/settings", "/credits", "/list", "/load",
"/settings", "/credits", "/list", "/load", "/shortcuts", "/skills",
"/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
"/mcp write on", "/mcp write off",
@@ -197,6 +197,13 @@ struct InputBar: View {
// 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()
}
} else {
// Put in input for user to complete
text = command + " "
@@ -214,7 +221,8 @@ struct InputBar: View {
guard panel.runModal() == .OK else { return }
let paths = panel.urls.map { $0.path }
let attachmentText = paths.map { "@\($0)" }.joined(separator: " ")
// Use @<path> format (angle brackets) to safely handle paths with spaces
let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ")
if text.isEmpty {
text = attachmentText + " "
@@ -245,12 +253,14 @@ struct CommandSuggestionsView: View {
let selectedIndex: Int
let onSelect: (String) -> Void
static let allCommands: [(command: String, description: String)] = [
static let builtInCommands: [(command: String, description: String)] = [
("/help", "Show help and available commands"),
("/history", "View command history"),
("/model", "Select AI model"),
("/clear", "Clear chat history"),
("/retry", "Retry last message"),
("/shortcuts", "Manage your prompt shortcuts"),
("/skills", "Manage your agent skills"),
("/memory on", "Enable conversation memory"),
("/memory off", "Disable conversation memory"),
("/online on", "Enable web search"),
@@ -274,9 +284,16 @@ struct CommandSuggestionsView: View {
("/mcp write off", "Disable MCP write permissions"),
]
static func allCommands() -> [(command: String, description: String)] {
let shortcuts = SettingsService.shared.userShortcuts.map { s in
(s.command, "\(s.description)")
}
return builtInCommands + shortcuts
}
static func filteredCommands(for searchText: String) -> [(command: String, description: String)] {
let search = searchText.lowercased()
return allCommands.filter { $0.command.contains(search) || search == "/" }
return allCommands().filter { $0.command.contains(search) || search == "/" }
}
private var suggestions: [(command: String, description: String)] {

View File

@@ -0,0 +1,294 @@
//
// AgentSkillEditorSheet.swift
// oAI
//
// Create or edit a SKILL.md-style agent skill, with optional support files
//
import SwiftUI
import UniformTypeIdentifiers
struct AgentSkillEditorSheet: View {
@Environment(\.dismiss) var dismiss
let isNew: Bool
let onSave: (AgentSkill) -> Void
@State private var skillID: UUID
@State private var name: String
@State private var skillDescription: String
@State private var content: String
@State private var isActive: Bool
@State private var createdAt: Date
// File management
@State private var skillFiles: [URL] = []
@State private var didSave = false
init(skill: AgentSkill? = nil, onSave: @escaping (AgentSkill) -> Void) {
self.isNew = skill == nil
self.onSave = onSave
let s = skill ?? AgentSkill(name: "", content: "")
_skillID = State(initialValue: s.id)
_name = State(initialValue: s.name)
_skillDescription = State(initialValue: s.skillDescription)
_content = State(initialValue: s.content)
_isActive = State(initialValue: s.isActive)
_createdAt = State(initialValue: s.createdAt)
}
private var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty
&& !content.trimmingCharacters(in: .whitespaces).isEmpty
}
private var hasLargeFile: Bool {
skillFiles.contains { url in
let size = AgentSkillFilesService.shared.fileSize(at: url) ?? 0
return size > 200_000
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text(isNew ? "New Skill" : "Edit Skill")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2).foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Name
VStack(alignment: .leading, spacing: 6) {
Text("Name")
.font(.system(size: 13, weight: .semibold))
TextField("e.g. Code Review, Test Writer, Security Auditor", text: $name)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13))
}
// Description (optional)
VStack(alignment: .leading, spacing: 6) {
Text("Description")
.font(.system(size: 13, weight: .semibold))
TextField("Brief summary (optional — auto-extracted from content if left blank)", text: $skillDescription)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13))
}
// Active toggle
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Active")
.font(.system(size: 13, weight: .semibold))
Text("Inject into system prompt for every conversation")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $isActive).labelsHidden()
}
.padding(10)
.background(.secondary.opacity(0.07), in: RoundedRectangle(cornerRadius: 8))
// Content (markdown)
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Content (Markdown)")
.font(.system(size: 13, weight: .semibold))
Spacer()
Text("\(content.count) chars")
.font(.caption).foregroundStyle(.secondary)
}
TextEditor(text: $content)
.font(.system(size: 12, design: .monospaced))
.frame(minHeight: 200)
.scrollContentBackground(.hidden)
.padding(6)
.background(Color(nsColor: .textBackgroundColor))
.cornerRadius(6)
.overlay(RoundedRectangle(cornerRadius: 6)
.stroke(Color(nsColor: .separatorColor), lineWidth: 1))
// Format hint
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "doc.text").foregroundStyle(.purple).font(.callout)
Text("SKILL.md format — write instructions in plain Markdown.")
.font(.caption).fontWeight(.medium)
}
Text("The AI reads this content and decides when to apply it. Describe **what** the AI should do and **how** — be specific and concise.")
.font(.caption).foregroundStyle(.secondary)
Text("Example structure:")
.font(.caption).foregroundStyle(.secondary).fontWeight(.medium)
Text("# When reviewing code, always:\n- Check for security vulnerabilities\n- Verify error handling\n- Suggest tests for edge cases")
.font(.system(size: 11, design: .monospaced))
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.purple.opacity(0.06), in: RoundedRectangle(cornerRadius: 6))
}
.padding(10)
.background(.purple.opacity(0.05), in: RoundedRectangle(cornerRadius: 8))
}
// Files section
filesSection
}
.padding(.horizontal, 24).padding(.vertical, 16)
}
Divider()
HStack {
Button("Cancel") { dismiss() }.buttonStyle(.bordered)
Spacer()
Button("Save") {
let skill = AgentSkill(
id: skillID,
name: name.trimmingCharacters(in: .whitespaces),
skillDescription: skillDescription.trimmingCharacters(in: .whitespaces),
content: content,
isActive: isActive,
createdAt: createdAt,
updatedAt: Date()
)
didSave = true
onSave(skill)
dismiss()
}
.buttonStyle(.borderedProminent)
.disabled(!isValid)
.keyboardShortcut(.return, modifiers: [.command])
}
.padding(.horizontal, 24).padding(.vertical, 12)
}
.frame(minWidth: 560, idealWidth: 640, minHeight: 640, idealHeight: 760)
.onAppear {
skillFiles = AgentSkillFilesService.shared.listFiles(for: skillID)
}
.onDisappear {
// If this was a new skill that was cancelled, clean up any files the user added
if isNew && !didSave {
AgentSkillFilesService.shared.deleteAll(for: skillID)
}
}
}
// MARK: - Files Section
@ViewBuilder
private var filesSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Files")
.font(.system(size: 13, weight: .semibold))
Spacer()
Button {
addFiles()
} label: {
Label("Add File", systemImage: "plus")
.font(.caption)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
if skillFiles.isEmpty {
Text("No files attached. Add JSON, YAML, CSV or TXT files to inject data into the system prompt alongside this skill.")
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.secondary.opacity(0.05), in: RoundedRectangle(cornerRadius: 6))
} else {
VStack(spacing: 0) {
ForEach(Array(skillFiles.enumerated()), id: \.element) { idx, url in
HStack(spacing: 8) {
Image(systemName: "doc")
.font(.caption).foregroundStyle(.secondary)
Text(url.lastPathComponent)
.font(.system(size: 12)).lineLimit(1)
Spacer()
if let size = AgentSkillFilesService.shared.fileSize(at: url) {
Text(formatBytes(size))
.font(.caption2).foregroundStyle(.tertiary)
}
Button {
AgentSkillFilesService.shared.deleteFile(at: url)
skillFiles = AgentSkillFilesService.shared.listFiles(for: skillID)
} label: {
Image(systemName: "trash").font(.caption2)
}
.buttonStyle(.plain)
.foregroundStyle(.red)
.help("Remove file")
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
if idx < skillFiles.count - 1 { Divider() }
}
}
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6)
.stroke(Color(nsColor: .separatorColor), lineWidth: 1))
}
if hasLargeFile {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("Large files inflate the system prompt and may hit token limits.")
.font(.caption)
}
.padding(8)
.background(.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 6))
}
HStack(spacing: 6) {
Image(systemName: "info.circle").foregroundStyle(.secondary).font(.caption2)
Text("Text files are injected into the system prompt alongside the skill.")
.font(.caption2).foregroundStyle(.secondary)
}
}
}
// MARK: - Actions
private func addFiles() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowedContentTypes = [
.plainText, .json, .xml, .commaSeparatedText,
UTType(filenameExtension: "yaml") ?? .plainText,
UTType(filenameExtension: "toml") ?? .plainText,
UTType(filenameExtension: "md") ?? .plainText
]
panel.message = "Select text data files (JSON, YAML, CSV, TXT, MD…)"
guard panel.runModal() == .OK else { return }
for url in panel.urls {
try? AgentSkillFilesService.shared.addFile(from: url, to: skillID)
}
skillFiles = AgentSkillFilesService.shared.listFiles(for: skillID)
}
private func formatBytes(_ bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1_048_576 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return String(format: "%.1f MB", Double(bytes) / 1_048_576)
}
}
#Preview {
AgentSkillEditorSheet { _ in }
}

View File

@@ -0,0 +1,662 @@
//
// AgentSkillsView.swift
// oAI
//
// Modal for managing SKILL.md-style agent skills (opened via /skills command)
//
import SwiftUI
import UniformTypeIdentifiers
/// Wrapper so .sheet(item:) always gets a fresh identity, avoiding the timing bug
/// where the sheet captures state before editingSkill is set.
private struct SkillEditContext: Identifiable {
let id = UUID()
let skill: AgentSkill? // nil new, non-nil edit
}
struct AgentSkillsView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settings = SettingsService.shared
@State private var editContext: SkillEditContext? = nil
@State private var statusMessage: String? = nil
private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count }
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
VStack(alignment: .leading, spacing: 2) {
Label("Agent Skills", systemImage: "brain")
.font(.system(size: 18, weight: .bold))
if activeCount > 0 {
Text("\(activeCount) active — injected into every conversation")
.font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2).foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 12)
// Toolbar
HStack(spacing: 10) {
Button { editContext = SkillEditContext(skill: nil) } label: {
Label("New Skill", systemImage: "plus")
}
.buttonStyle(.bordered)
Button { importSkills() } label: {
Label("Import", systemImage: "square.and.arrow.down")
}
.buttonStyle(.bordered)
Button { exportAll() } label: {
Label("Export All", systemImage: "square.and.arrow.up")
}
.buttonStyle(.bordered)
.disabled(settings.agentSkills.isEmpty)
Spacer()
if let msg = statusMessage {
Text(msg).font(.caption).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 24).padding(.bottom, 12)
Divider()
if settings.agentSkills.isEmpty {
VStack(spacing: 12) {
Image(systemName: "brain")
.font(.system(size: 40)).foregroundStyle(.tertiary)
Text("No skills yet")
.font(.title3).foregroundStyle(.secondary)
Text("Skills are markdown instruction files that teach the AI how to behave. Active skills are automatically injected into the system prompt.")
.font(.callout).foregroundStyle(.secondary)
.multilineTextAlignment(.center).frame(maxWidth: 380)
Text("You can import any SKILL.md file from skill0.io or write your own.")
.font(.caption).foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
} else {
List {
ForEach(settings.agentSkills) { skill in
AgentSkillRow(
skill: skill,
onToggle: { settings.toggleAgentSkill(id: skill.id) },
onEdit: { editContext = SkillEditContext(skill: skill) },
onExport: { exportOne(skill) },
onDelete: { settings.deleteAgentSkill(id: skill.id) }
)
}
}
.listStyle(.inset(alternatesRowBackgrounds: true))
}
Divider()
HStack(spacing: 8) {
Image(systemName: "info.circle").foregroundStyle(.purple).font(.callout)
Text("Active skills are appended to the system prompt. Toggle them per-skill to control what the AI knows.")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.return, modifiers: [])
}
.padding(.horizontal, 24).padding(.vertical, 12)
}
.frame(minWidth: 620, idealWidth: 700, minHeight: 480, idealHeight: 600)
.sheet(item: $editContext) { ctx in
AgentSkillEditorSheet(skill: ctx.skill) { saved in
if ctx.skill != nil { settings.updateAgentSkill(saved) }
else { settings.addAgentSkill(saved) }
}
}
}
// MARK: - Import / Export
private func importSkills() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowedContentTypes = [
.plainText,
UTType(filenameExtension: "md") ?? .plainText,
.zip
]
panel.message = "Select SKILL.md or .zip skill bundles to import"
guard panel.runModal() == .OK else { return }
var imported = 0
for url in panel.urls {
let ext = url.pathExtension.lowercased()
if ext == "zip" {
if importZip(url) { imported += 1 }
} else {
if importMarkdown(url) { imported += 1 }
}
}
if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") }
}
@discardableResult
private func importMarkdown(_ url: URL) -> Bool {
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return false }
let name = skillName(from: content, fallback: url.deletingPathExtension().lastPathComponent)
let description = skillDescription(from: content)
let skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true)
if let existing = settings.agentSkills.first(where: { $0.name == name }) {
var updated = skill; updated.id = existing.id
settings.updateAgentSkill(updated)
} else {
settings.addAgentSkill(skill)
}
return true
}
@discardableResult
private func importZip(_ zipURL: URL) -> Bool {
let tmpDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmpDir) }
unzip(zipURL, to: tmpDir)
// Recursively enumerate all files (zip may contain a subdirectory)
let allFiles = recursiveFiles(in: tmpDir)
// Prefer skill.md by name, fall back to any .md
guard let mdURL = allFiles.first(where: { $0.lastPathComponent.lowercased() == "skill.md" })
?? allFiles.first(where: { $0.pathExtension.lowercased() == "md" }),
let content = try? String(contentsOf: mdURL, encoding: .utf8)
else { return false }
let name = skillName(from: content, fallback: zipURL.deletingPathExtension().lastPathComponent)
let description = skillDescription(from: content)
// Find or create skill
var skill: AgentSkill
if let existing = settings.agentSkills.first(where: { $0.name == name }) {
skill = AgentSkill(id: existing.id, name: name, skillDescription: description,
content: content, isActive: existing.isActive,
createdAt: existing.createdAt, updatedAt: Date())
settings.updateAgentSkill(skill)
} else {
skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true)
settings.addAgentSkill(skill)
}
// Copy all non-skill-md files to skill directory (flatten hierarchy)
let dataFiles = allFiles.filter { $0 != mdURL }
if !dataFiles.isEmpty {
AgentSkillFilesService.shared.ensureDirectory(for: skill.id)
for file in dataFiles {
try? AgentSkillFilesService.shared.addFile(from: file, to: skill.id)
}
}
return true
}
/// Recursively list all regular files under a directory
private func recursiveFiles(in directory: URL) -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: directory,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles])
else { return [] }
return (enumerator.allObjects as? [URL] ?? []).filter {
(try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
}
}
private func exportAll() {
let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
var exported = 0
for skill in settings.agentSkills {
let safeName = skill.name.lowercased()
.components(separatedBy: .whitespaces).joined(separator: "-")
let files = AgentSkillFilesService.shared.listFiles(for: skill.id)
if files.isEmpty {
let url = downloadsURL.appendingPathComponent(safeName + ".md")
try? skill.content.write(to: url, atomically: true, encoding: .utf8)
} else {
let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip")
exportAsZip(skill: skill, files: files, to: zipURL)
}
exported += 1
}
show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads")
}
private func exportOne(_ skill: AgentSkill) {
let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
let safeName = skill.name.lowercased()
.components(separatedBy: .whitespaces).joined(separator: "-")
let files = AgentSkillFilesService.shared.listFiles(for: skill.id)
if files.isEmpty {
let url = downloadsURL.appendingPathComponent(safeName + ".md")
try? skill.content.write(to: url, atomically: true, encoding: .utf8)
show("Exported \(safeName).md")
} else {
let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip")
exportAsZip(skill: skill, files: files, to: zipURL)
show("Exported \(safeName).zip")
}
}
private func exportAsZip(skill: AgentSkill, files: [URL], to zipURL: URL) {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
try? skill.content.write(to: tmp.appendingPathComponent("skill.md"),
atomically: true, encoding: .utf8)
for f in files {
try? FileManager.default.copyItem(at: f, to: tmp.appendingPathComponent(f.lastPathComponent))
}
zip(directory: tmp, to: zipURL)
}
// MARK: - zip / unzip helpers (use system binaries, always present on macOS)
private func unzip(_ zipURL: URL, to destDir: URL) {
try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = ["-o", zipURL.path, "-d", destDir.path]
try? process.run(); process.waitUntilExit()
}
private func zip(directory: URL, to destZip: URL) {
if FileManager.default.fileExists(atPath: destZip.path) {
try? FileManager.default.removeItem(at: destZip)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
process.currentDirectoryURL = directory
process.arguments = ["-r", destZip.path, "."]
try? process.run(); process.waitUntilExit()
}
// MARK: - Helpers
/// Extract skill name from first # heading, fallback to filename
private func skillName(from content: String, fallback: String) -> String {
for line in content.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("# ") {
return String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)
}
}
return fallback.isEmpty ? "Untitled Skill" : fallback
}
/// Extract first non-heading, non-empty line as description
private func skillDescription(from content: String) -> String {
for line in content.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty && !trimmed.hasPrefix("#") {
return String(trimmed.prefix(120))
}
}
return ""
}
private func show(_ text: String) {
statusMessage = text
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
}
}
// MARK: - Agent Skill Row
private struct AgentSkillRow: View {
let skill: AgentSkill
let onToggle: () -> Void
let onEdit: () -> Void
let onExport: () -> Void
let onDelete: () -> Void
private var fileCount: Int {
AgentSkillFilesService.shared.listFiles(for: skill.id).count
}
var body: some View {
HStack(spacing: 12) {
// Active toggle
Toggle("", isOn: Binding(get: { skill.isActive }, set: { _ in onToggle() }))
.labelsHidden()
.help(skill.isActive ? "Skill is active — click to deactivate" : "Skill is inactive — click to activate")
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(skill.name)
.font(.system(size: 13, weight: .semibold))
if skill.isActive {
Text("active")
.font(.caption2).foregroundStyle(.white)
.padding(.horizontal, 5).padding(.vertical, 2)
.background(.purple, in: Capsule())
}
}
Text(skill.resolvedDescription)
.font(.callout).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
// File count badge
if fileCount > 0 {
Label("\(fileCount) file\(fileCount == 1 ? "" : "s")", systemImage: "doc")
.font(.caption2).foregroundStyle(.secondary)
.padding(.horizontal, 6).padding(.vertical, 3)
.background(.blue.opacity(0.1), in: Capsule())
}
Text("\(skill.content.count) chars")
.font(.caption2).foregroundStyle(.tertiary)
.padding(.horizontal, 6).padding(.vertical, 3)
.background(.secondary.opacity(0.1), in: Capsule())
Button(action: onEdit) { Text("Edit").font(.caption) }
.buttonStyle(.bordered).controlSize(.small)
Button(action: onExport) { Image(systemName: "square.and.arrow.up") }
.buttonStyle(.bordered).controlSize(.small)
.help(fileCount > 0 ? "Export as .zip" : "Export as .md")
Button(role: .destructive, action: onDelete) { Image(systemName: "trash") }
.buttonStyle(.bordered).controlSize(.small).help("Delete skill")
}
.padding(.vertical, 4)
}
}
// MARK: - Agent Skills Tab Content (embedded in SettingsView)
struct AgentSkillsTabContent: View {
@Bindable private var settings = SettingsService.shared
@State private var editContext: SkillEditContext? = nil
@State private var statusMessage: String? = nil
private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Intro
HStack(spacing: 10) {
Image(systemName: "brain").foregroundStyle(.purple).font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Agent Skills")
.font(.system(size: 14, weight: .semibold))
Text("Markdown instruction files injected into the system prompt. Compatible with SKILL.md format.")
.font(.caption).foregroundStyle(.secondary)
}
}
.padding(10)
.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")
.font(.caption).foregroundStyle(.green)
}
// Toolbar
HStack(spacing: 10) {
Button { editContext = SkillEditContext(skill: nil) } label: {
Label("New Skill", systemImage: "plus")
}
.buttonStyle(.bordered)
Button { importSkills() } label: {
Label("Import", systemImage: "square.and.arrow.down")
}
.buttonStyle(.bordered)
Button { exportAll() } label: {
Label("Export All", systemImage: "square.and.arrow.up")
}
.buttonStyle(.bordered)
.disabled(settings.agentSkills.isEmpty)
Spacer()
if let msg = statusMessage {
Text(msg).font(.caption).foregroundStyle(.secondary)
}
}
if settings.agentSkills.isEmpty {
HStack {
Spacer()
VStack(spacing: 8) {
Image(systemName: "brain")
.font(.system(size: 32)).foregroundStyle(.tertiary)
Text("No skills yet — click New Skill or Import to get started.")
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
Spacer()
}
.padding(.vertical, 32)
} else {
VStack(spacing: 0) {
ForEach(Array(settings.agentSkills.enumerated()), id: \.element.id) { idx, skill in
AgentSkillRow(
skill: skill,
onToggle: { settings.toggleAgentSkill(id: skill.id) },
onEdit: { editContext = SkillEditContext(skill: skill) },
onExport: { exportOne(skill) },
onDelete: { settings.deleteAgentSkill(id: skill.id) }
)
if idx < settings.agentSkills.count - 1 { Divider() }
}
}
.background(.background, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
}
}
.sheet(item: $editContext) { ctx in
AgentSkillEditorSheet(skill: ctx.skill) { saved in
if ctx.skill != nil { settings.updateAgentSkill(saved) }
else { settings.addAgentSkill(saved) }
}
}
}
private func importSkills() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true; panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowedContentTypes = [
.plainText,
UTType(filenameExtension: "md") ?? .plainText,
.zip
]
panel.message = "Select SKILL.md or .zip skill bundles to import"
guard panel.runModal() == .OK else { return }
var imported = 0
for url in panel.urls {
let ext = url.pathExtension.lowercased()
if ext == "zip" {
if importZip(url) { imported += 1 }
} else {
if importMarkdown(url) { imported += 1 }
}
}
if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") }
}
@discardableResult
private func importMarkdown(_ url: URL) -> Bool {
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return false }
let name = skillName(from: content, fallback: url.deletingPathExtension().lastPathComponent)
let description = skillDescription(from: content)
let skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true)
if let existing = settings.agentSkills.first(where: { $0.name == name }) {
var updated = skill; updated.id = existing.id; settings.updateAgentSkill(updated)
} else {
settings.addAgentSkill(skill)
}
return true
}
@discardableResult
private func importZip(_ zipURL: URL) -> Bool {
let tmpDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmpDir) }
unzip(zipURL, to: tmpDir)
// Recursively enumerate all files (zip may contain a subdirectory)
let allFiles = recursiveFiles(in: tmpDir)
guard let mdURL = allFiles.first(where: { $0.lastPathComponent.lowercased() == "skill.md" })
?? allFiles.first(where: { $0.pathExtension.lowercased() == "md" }),
let content = try? String(contentsOf: mdURL, encoding: .utf8)
else { return false }
let name = skillName(from: content, fallback: zipURL.deletingPathExtension().lastPathComponent)
let description = skillDescription(from: content)
var skill: AgentSkill
if let existing = settings.agentSkills.first(where: { $0.name == name }) {
skill = AgentSkill(id: existing.id, name: name, skillDescription: description,
content: content, isActive: existing.isActive,
createdAt: existing.createdAt, updatedAt: Date())
settings.updateAgentSkill(skill)
} else {
skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true)
settings.addAgentSkill(skill)
}
let dataFiles = allFiles.filter { $0 != mdURL }
if !dataFiles.isEmpty {
AgentSkillFilesService.shared.ensureDirectory(for: skill.id)
for file in dataFiles {
try? AgentSkillFilesService.shared.addFile(from: file, to: skill.id)
}
}
return true
}
/// Recursively list all regular files under a directory
private func recursiveFiles(in directory: URL) -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: directory,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles])
else { return [] }
return (enumerator.allObjects as? [URL] ?? []).filter {
(try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
}
}
private func exportAll() {
let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
var exported = 0
for skill in settings.agentSkills {
let safeName = skill.name.lowercased()
.components(separatedBy: .whitespaces).joined(separator: "-")
let files = AgentSkillFilesService.shared.listFiles(for: skill.id)
if files.isEmpty {
let url = downloadsURL.appendingPathComponent(safeName + ".md")
try? skill.content.write(to: url, atomically: true, encoding: .utf8)
} else {
let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip")
exportAsZip(skill: skill, files: files, to: zipURL)
}
exported += 1
}
show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads")
}
private func exportOne(_ skill: AgentSkill) {
let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
let safeName = skill.name.lowercased()
.components(separatedBy: .whitespaces).joined(separator: "-")
let files = AgentSkillFilesService.shared.listFiles(for: skill.id)
if files.isEmpty {
let url = downloadsURL.appendingPathComponent(safeName + ".md")
try? skill.content.write(to: url, atomically: true, encoding: .utf8)
show("Exported \(safeName).md")
} else {
let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip")
exportAsZip(skill: skill, files: files, to: zipURL)
show("Exported \(safeName).zip")
}
}
private func exportAsZip(skill: AgentSkill, files: [URL], to zipURL: URL) {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
try? skill.content.write(to: tmp.appendingPathComponent("skill.md"),
atomically: true, encoding: .utf8)
for f in files {
try? FileManager.default.copyItem(at: f, to: tmp.appendingPathComponent(f.lastPathComponent))
}
zip(directory: tmp, to: zipURL)
}
private func unzip(_ zipURL: URL, to destDir: URL) {
try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = ["-o", zipURL.path, "-d", destDir.path]
try? process.run(); process.waitUntilExit()
}
private func zip(directory: URL, to destZip: URL) {
if FileManager.default.fileExists(atPath: destZip.path) {
try? FileManager.default.removeItem(at: destZip)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
process.currentDirectoryURL = directory
process.arguments = ["-r", destZip.path, "."]
try? process.run(); process.waitUntilExit()
}
private func skillName(from content: String, fallback: String) -> String {
for line in content.components(separatedBy: .newlines) {
let t = line.trimmingCharacters(in: .whitespaces)
if t.hasPrefix("# ") { return String(t.dropFirst(2)).trimmingCharacters(in: .whitespaces) }
}
return fallback.isEmpty ? "Untitled Skill" : fallback
}
private func skillDescription(from content: String) -> String {
for line in content.components(separatedBy: .newlines) {
let t = line.trimmingCharacters(in: .whitespaces)
if !t.isEmpty && !t.hasPrefix("#") { return String(t.prefix(120)) }
}
return ""
}
private func show(_ text: String) {
statusMessage = text
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
}
}
#Preview { AgentSkillsView() }

View File

@@ -17,6 +17,8 @@ struct ConversationListView: View {
@State private var useSemanticSearch = false
@State private var semanticResults: [Conversation] = []
@State private var isSearching = false
@State private var selectedIndex: Int = 0
@FocusState private var searchFocused: Bool
private let settings = SettingsService.shared
var onLoad: ((Conversation) -> Void)?
@@ -88,11 +90,34 @@ struct ConversationListView: View {
.foregroundStyle(.secondary)
TextField("Search conversations...", text: $searchText)
.textFieldStyle(.plain)
.focused($searchFocused)
.onChange(of: searchText) {
selectedIndex = 0
if useSemanticSearch && settings.embeddingsEnabled && !searchText.isEmpty {
performSemanticSearch()
}
}
#if os(macOS)
.onKeyPress(.upArrow) {
if selectedIndex > 0 {
selectedIndex -= 1
}
return .handled
}
.onKeyPress(.downArrow) {
if selectedIndex < filteredConversations.count - 1 {
selectedIndex += 1
}
return .handled
}
.onKeyPress(.return, phases: .down) { _ in
guard !isSelecting, !filteredConversations.isEmpty else { return .ignored }
let conv = filteredConversations[min(selectedIndex, filteredConversations.count - 1)]
onLoad?(conv)
dismiss()
return .handled
}
#endif
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
@@ -143,80 +168,98 @@ struct ConversationListView: View {
}
Spacer()
} else {
List {
ForEach(filteredConversations) { conversation in
HStack(spacing: 12) {
if isSelecting {
Button {
toggleSelection(conversation.id)
} label: {
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
.font(.title2)
}
.buttonStyle(.plain)
}
ConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
if isSelecting {
ScrollViewReader { proxy in
List {
ForEach(Array(filteredConversations.enumerated()), id: \.element.id) { index, conversation in
HStack(spacing: 12) {
if isSelecting {
Button {
toggleSelection(conversation.id)
} else {
onLoad?(conversation)
dismiss()
} label: {
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
.font(.title2)
}
.buttonStyle(.plain)
}
Spacer()
ConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
if isSelecting {
toggleSelection(conversation.id)
} else {
selectedIndex = index
onLoad?(conversation)
dismiss()
}
}
if !isSelecting {
Button {
Spacer()
if !isSelecting {
Button {
deleteConversation(conversation)
} label: {
Image(systemName: "trash")
.foregroundStyle(.red)
.font(.system(size: 16))
}
.buttonStyle(.plain)
.help("Delete conversation")
}
}
.listRowBackground(
!isSelecting && index == selectedIndex
? Color.oaiAccent.opacity(0.15)
: Color.clear
)
.id(conversation.id)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
deleteConversation(conversation)
} label: {
Image(systemName: "trash")
.foregroundStyle(.red)
.font(.system(size: 16))
Label("Delete", systemImage: "trash")
}
.buttonStyle(.plain)
.help("Delete conversation")
Button {
exportConversation(conversation)
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.tint(.blue)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
deleteConversation(conversation)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
exportConversation(conversation)
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.tint(.blue)
}
.listStyle(.plain)
.onChange(of: selectedIndex) {
guard !filteredConversations.isEmpty else { return }
let clamped = min(selectedIndex, filteredConversations.count - 1)
withAnimation(.easeInOut(duration: 0.1)) {
proxy.scrollTo(filteredConversations[clamped].id, anchor: .center)
}
}
}
.listStyle(.plain)
}
Divider()
// Bottom bar
HStack {
Text("↑↓ navigate ↩ open")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.onAppear {
loadConversations()
searchFocused = true
}
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
}
@@ -251,6 +294,7 @@ struct ConversationListView: View {
selectedConversations.removeAll()
isSelecting = false
}
selectedIndex = 0
}
private func deleteConversation(_ conversation: Conversation) {
@@ -259,6 +303,7 @@ struct ConversationListView: View {
withAnimation {
conversations.removeAll { $0.id == conversation.id }
}
selectedIndex = min(selectedIndex, max(0, filteredConversations.count - 1))
} catch {
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
}
@@ -274,7 +319,6 @@ struct ConversationListView: View {
Task {
do {
// Use user's selected provider, or fall back to best available
guard let provider = EmbeddingService.shared.getSelectedProvider() else {
Log.api.warning("No embedding providers available - skipping semantic search")
await MainActor.run {
@@ -283,13 +327,11 @@ struct ConversationListView: View {
return
}
// Generate embedding for search query
let embedding = try await EmbeddingService.shared.generateEmbedding(
text: searchText,
provider: provider
)
// Search conversations
let results = try DatabaseService.shared.searchConversationsBySemantic(
queryEmbedding: embedding,
limit: 20
@@ -297,6 +339,7 @@ struct ConversationListView: View {
await MainActor.run {
semanticResults = results.map { $0.0 }
selectedIndex = 0
isSearching = false
Log.ui.info("Semantic search found \(results.count) results using \(provider.displayName)")
}
@@ -333,26 +376,47 @@ struct ConversationRow: View {
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "dd.MM.yyyy HH:mm:ss"
formatter.dateFormat = "dd.MM.yyyy HH:mm"
return formatter.string(from: conversation.updatedAt)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(conversation.name)
.font(.system(size: 16, weight: .semibold))
/// Strips the provider prefix from OpenRouter-style IDs (e.g. "anthropic/claude-3" "claude-3")
private var modelDisplayName: String? {
guard let model = conversation.primaryModel, !model.isEmpty else { return nil }
if let slash = model.lastIndex(of: "/") {
return String(model[model.index(after: slash)...])
}
return model
}
HStack(spacing: 8) {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(conversation.name)
.font(.system(size: 15, weight: .semibold))
.lineLimit(1)
HStack(spacing: 6) {
Label("\(conversation.messageCount)", systemImage: "message")
.font(.system(size: 13))
Text("\u{2022}")
.font(.system(size: 13))
.font(.system(size: 12))
Text("")
.font(.system(size: 12))
Text(formattedDate)
.font(.system(size: 13))
.font(.system(size: 12))
if let model = modelDisplayName {
Text("")
.font(.system(size: 12))
Text(model)
.font(.system(size: 12))
.lineLimit(1)
.truncationMode(.middle)
}
}
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
.padding(.vertical, 5)
}
}

View File

@@ -189,11 +189,41 @@ struct HelpView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var expandedCommandID: UUID?
private let settings = SettingsService.shared
private var allCategories: [CommandCategory] {
var cats = helpCategories
let shortcuts = settings.userShortcuts
if !shortcuts.isEmpty {
let shortcutCommands = shortcuts.map { s in
CommandDetail(
command: s.command + (s.needsInput ? " <text>" : ""),
brief: s.description,
detail: "Template: \(s.template)",
examples: s.needsInput ? ["\(s.command) your text here"] : [s.command]
)
}
cats.append(CommandCategory(name: "Your Shortcuts", icon: "bolt.fill", commands: shortcutCommands))
}
let activeSkills = settings.agentSkills.filter { $0.isActive }
if !activeSkills.isEmpty {
let skillCommands = activeSkills.map { skill in
CommandDetail(
command: skill.name,
brief: skill.skillDescription,
detail: "Active skill — injected into system prompt automatically.\n\nContent:\n\(skill.content)",
examples: []
)
}
cats.append(CommandCategory(name: "Active Skills", icon: "brain", commands: skillCommands))
}
return cats
}
private var filteredCategories: [CommandCategory] {
if searchText.isEmpty { return helpCategories }
if searchText.isEmpty { return allCategories }
let q = searchText.lowercased()
return helpCategories.compactMap { cat in
return allCategories.compactMap { cat in
let matched = cat.commands.filter {
$0.command.lowercased().contains(q) ||
$0.brief.lowercased().contains(q) ||

View File

@@ -39,12 +39,6 @@ struct SettingsView: View {
@State private var syncTestResult: String?
@State private var isSyncing = false
// OAuth state
@State private var oauthCode = ""
@State private var oauthError: String?
@State private var showOAuthCodeField = false
private var oauthService = AnthropicOAuthService.shared
// Email handler state
@State private var showEmailLog = false
@State private var showEmailModelSelector = false
@@ -98,6 +92,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Text("Advanced").tag(3)
Text("Sync").tag(4)
Text("Email").tag(5)
Text("Shortcuts").tag(6)
Text("Skills").tag(7)
}
.pickerStyle(.segmented)
.padding(.horizontal, 24)
@@ -120,6 +116,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
syncTab
case 5:
emailTab
case 6:
shortcutsTab
case 7:
agentSkillsTab
default:
generalTab
}
@@ -179,75 +179,15 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
ProviderRegistry.shared.clearCache()
}
}
// Anthropic: OAuth or API key
// Anthropic: API key
row("Anthropic") {
VStack(alignment: .leading, spacing: 8) {
if oauthService.isAuthenticated {
// Logged in via OAuth
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Logged in via Claude Pro/Max")
.font(.system(size: 14))
Spacer()
Button("Logout") {
oauthService.logout()
ProviderRegistry.shared.clearCache()
}
.font(.system(size: 14))
.foregroundStyle(.red)
}
} else if showOAuthCodeField {
// Waiting for code paste
HStack(spacing: 8) {
TextField("Paste authorization code...", text: $oauthCode)
.textFieldStyle(.roundedBorder)
Button("Submit") {
Task { await submitOAuthCode() }
}
.disabled(oauthCode.isEmpty || oauthService.isLoggingIn)
Button("Cancel") {
showOAuthCodeField = false
oauthCode = ""
oauthError = nil
}
.font(.system(size: 14))
}
if let error = oauthError {
Text(error)
.font(.system(size: 13))
.foregroundStyle(.red)
}
} else {
// Login button + API key field
HStack(spacing: 8) {
Button {
startOAuthLogin()
} label: {
HStack(spacing: 4) {
Image(systemName: "person.circle")
Text("Login with Claude Pro/Max")
}
.font(.system(size: 14))
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Text("or")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
SecureField("sk-ant-... (API key)", text: $anthropicKey)
.textFieldStyle(.roundedBorder)
.onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" }
.onChange(of: anthropicKey) {
settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey
ProviderRegistry.shared.clearCache()
}
SecureField("sk-ant-... (API key)", text: $anthropicKey)
.textFieldStyle(.roundedBorder)
.onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" }
.onChange(of: anthropicKey) {
settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey
ProviderRegistry.shared.clearCache()
}
}
.frame(width: 400, alignment: .leading)
}
row("OpenAI") {
SecureField("sk-...", text: $openaiKey)
@@ -531,6 +471,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// Anytype integration UI hidden (work in progress see AnytypeMCPService.swift)
}
// MARK: - Appearance Tab
@@ -1742,6 +1684,20 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// MARK: - Shortcuts Tab
@ViewBuilder
private var shortcutsTab: some View {
ShortcutsTabContent()
}
// MARK: - Agent Skills Tab
@ViewBuilder
private var agentSkillsTab: some View {
AgentSkillsTabContent()
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
@@ -1820,32 +1776,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
isTestingEmailConnection = false
}
// MARK: - OAuth Helpers
private func startOAuthLogin() {
let url = oauthService.generateAuthorizationURL()
#if os(macOS)
NSWorkspace.shared.open(url)
#endif
showOAuthCodeField = true
oauthError = nil
oauthCode = ""
}
private func submitOAuthCode() async {
oauthService.isLoggingIn = true
oauthError = nil
do {
try await oauthService.exchangeCode(oauthCode)
showOAuthCodeField = false
oauthCode = ""
ProviderRegistry.shared.clearCache()
} catch {
oauthError = error.localizedDescription
}
oauthService.isLoggingIn = false
}
// MARK: - Sync Helpers
private func testSyncConnection() async {

View File

@@ -0,0 +1,147 @@
//
// ShortcutEditorSheet.swift
// oAI
//
// Create or edit a user-defined shortcut (prompt template)
//
import SwiftUI
struct ShortcutEditorSheet: View {
@Environment(\.dismiss) var dismiss
let isNew: Bool
let onSave: (Shortcut) -> Void
@State private var shortcutID: UUID
@State private var command: String
@State private var description: String
@State private var template: String
@State private var createdAt: Date
init(shortcut: Shortcut? = nil, onSave: @escaping (Shortcut) -> Void) {
self.isNew = shortcut == nil
self.onSave = onSave
let s = shortcut ?? Shortcut(command: "/", description: "", template: "")
_shortcutID = State(initialValue: s.id)
_command = State(initialValue: s.command)
_description = State(initialValue: s.description)
_template = State(initialValue: s.template)
_createdAt = State(initialValue: s.createdAt)
}
private var normalizedCommand: String {
var c = command.lowercased().trimmingCharacters(in: .whitespaces)
if !c.hasPrefix("/") { c = "/" + c }
c = c.components(separatedBy: .whitespaces).joined()
return c
}
private var isValid: Bool {
normalizedCommand.count > 1
&& !description.trimmingCharacters(in: .whitespaces).isEmpty
&& !template.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text(isNew ? "New Shortcut" : "Edit Shortcut")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2).foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Command").font(.system(size: 13, weight: .semibold))
TextField("/command", text: $command)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.onChange(of: command) {
if !command.isEmpty && !command.hasPrefix("/") {
command = "/" + command
}
}
Text("Lowercase letters, numbers, and hyphens only. No spaces.")
.font(.caption).foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 6) {
Text("Description").font(.system(size: 13, weight: .semibold))
TextField("Brief description shown in the command dropdown", text: $description)
.textFieldStyle(.roundedBorder).font(.system(size: 13))
}
VStack(alignment: .leading, spacing: 6) {
Text("Template").font(.system(size: 13, weight: .semibold))
TextEditor(text: $template)
.font(.system(size: 13, design: .monospaced))
.frame(minHeight: 120)
.scrollContentBackground(.hidden)
.padding(6)
.background(Color(nsColor: .textBackgroundColor))
.cornerRadius(6)
.overlay(RoundedRectangle(cornerRadius: 6)
.stroke(Color(nsColor: .separatorColor), lineWidth: 1))
HStack(alignment: .top, spacing: 8) {
Image(systemName: "info.circle").foregroundStyle(.blue).font(.callout)
VStack(alignment: .leading, spacing: 4) {
Text("Use **{{input}}** to insert whatever you type after the command.")
.font(.caption)
if template.contains("{{input}}") {
Label("Needs input — user types after the command", systemImage: "checkmark.circle.fill")
.font(.caption).foregroundStyle(.green)
} else {
Label("Executes immediately — no extra input needed", systemImage: "bolt.fill")
.font(.caption).foregroundStyle(.orange)
}
}
}
.padding(10)
.background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 8))
}
}
.padding(.horizontal, 24).padding(.vertical, 16)
}
Divider()
HStack {
Button("Cancel") { dismiss() }.buttonStyle(.bordered)
Spacer()
Button("Save") {
let shortcut = Shortcut(
id: shortcutID,
command: normalizedCommand,
description: description.trimmingCharacters(in: .whitespaces),
template: template,
createdAt: createdAt,
updatedAt: Date()
)
onSave(shortcut)
dismiss()
}
.buttonStyle(.borderedProminent)
.disabled(!isValid)
.keyboardShortcut(.return, modifiers: [.command])
}
.padding(.horizontal, 24).padding(.vertical, 12)
}
.frame(minWidth: 480, idealWidth: 540, minHeight: 460, idealHeight: 520)
}
}
#Preview {
ShortcutEditorSheet { _ in }
}

View File

@@ -0,0 +1,413 @@
//
// ShortcutsView.swift
// oAI
//
// Modal for managing user-defined shortcuts (opened via /shortcuts command)
//
import SwiftUI
import UniformTypeIdentifiers
struct ShortcutsView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settings = SettingsService.shared
@State private var showEditor = false
@State private var editingShortcut: Shortcut? = nil
@State private var importConflicts: [ShortcutConflict] = []
@State private var showConflictAlert = false
@State private var pendingImport: [Shortcut] = []
@State private var statusMessage: String? = nil
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Label("Your Shortcuts", systemImage: "bolt.fill")
.font(.system(size: 18, weight: .bold))
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
// Toolbar
HStack(spacing: 10) {
Button { editingShortcut = nil; showEditor = true } label: {
Label("New Shortcut", systemImage: "plus")
}
.buttonStyle(.bordered)
Button { importShortcuts() } label: {
Label("Import", systemImage: "square.and.arrow.down")
}
.buttonStyle(.bordered)
Button { exportAll() } label: {
Label("Export All", systemImage: "square.and.arrow.up")
}
.buttonStyle(.bordered)
.disabled(settings.userShortcuts.isEmpty)
Spacer()
if let msg = statusMessage {
Text(msg).font(.caption).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
if settings.userShortcuts.isEmpty {
VStack(spacing: 12) {
Image(systemName: "bolt.slash")
.font(.system(size: 40))
.foregroundStyle(.tertiary)
Text("No shortcuts yet")
.font(.title3)
.foregroundStyle(.secondary)
Text("Create a shortcut to save a reusable prompt template accessible from the / command dropdown.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 340)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
List {
ForEach(settings.userShortcuts) { shortcut in
ShortcutRow(
shortcut: shortcut,
onEdit: { editingShortcut = shortcut; showEditor = true },
onExport: { exportOne(shortcut) },
onDelete: { settings.deleteShortcut(id: shortcut.id) }
)
}
}
.listStyle(.inset(alternatesRowBackgrounds: true))
}
Divider()
HStack(spacing: 8) {
Image(systemName: "lightbulb")
.foregroundStyle(.yellow)
.font(.callout)
Text("Use **{{input}}** in the template to insert whatever you type after the command.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.return, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 560, idealWidth: 640, minHeight: 440, idealHeight: 560)
.sheet(isPresented: $showEditor) {
ShortcutEditorSheet(shortcut: editingShortcut) { saved in
if editingShortcut != nil { settings.updateShortcut(saved) }
else { settings.addShortcut(saved) }
editingShortcut = nil
}
}
.alert("Duplicate Shortcut", isPresented: $showConflictAlert, presenting: importConflicts.first) { c in
Button("Replace") { resolveConflict(c, action: .replace) }
Button("Keep Both") { resolveConflict(c, action: .keepBoth) }
Button("Skip") { resolveConflict(c, action: .skip) }
} message: { c in
Text("A shortcut with command \(c.incoming.command) already exists.")
}
}
// MARK: - Import / Export
private func importShortcuts() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowedContentTypes = [.json]
panel.message = "Select shortcut JSON files to import"
guard panel.runModal() == .OK else { return }
var incoming: [Shortcut] = []
for url in panel.urls {
guard let data = try? Data(contentsOf: url) else { continue }
if let pack = try? JSONDecoder().decode([Shortcut].self, from: data) {
incoming.append(contentsOf: pack)
} else if let single = try? JSONDecoder().decode(Shortcut.self, from: data) {
incoming.append(single)
}
}
guard !incoming.isEmpty else { show("No valid shortcuts found"); return }
pendingImport = incoming
processNext()
}
private func processNext() {
guard !pendingImport.isEmpty else { importConflicts = []; return }
let item = pendingImport.removeFirst()
if settings.userShortcuts.first(where: { $0.command == item.command }) != nil {
importConflicts.append(ShortcutConflict(incoming: item))
showConflictAlert = true
} else {
settings.addShortcut(item)
show("Imported \(item.command)")
processNext()
}
}
private func resolveConflict(_ c: ShortcutConflict, action: ConflictAction) {
importConflicts.removeFirst()
switch action {
case .replace:
if let ex = settings.userShortcuts.first(where: { $0.command == c.incoming.command }) {
var u = c.incoming; u.id = ex.id; settings.updateShortcut(u)
}
case .keepBoth:
var copy = c.incoming; copy.id = UUID(); copy.command += "-copy"; settings.addShortcut(copy)
case .skip: break
}
processNext()
}
private func exportAll() {
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601
enc.outputFormatting = [.prettyPrinted, .sortedKeys]
guard let data = try? enc.encode(settings.userShortcuts) else { return }
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
.appendingPathComponent("shortcuts.json")
try? data.write(to: url)
show("Exported to Downloads/shortcuts.json")
}
private func exportOne(_ shortcut: Shortcut) {
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601
enc.outputFormatting = [.prettyPrinted, .sortedKeys]
guard let data = try? enc.encode(shortcut) else { return }
let filename = String(shortcut.command.dropFirst()) + ".json"
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
.appendingPathComponent(filename)
try? data.write(to: url)
show("Exported \(filename)")
}
private func show(_ text: String) {
statusMessage = text
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
}
// MARK: - Types
struct ShortcutConflict { let incoming: Shortcut }
enum ConflictAction { case replace, keepBoth, skip }
}
// MARK: - Shortcut Row
private struct ShortcutRow: View {
let shortcut: Shortcut
let onEdit: () -> Void
let onExport: () -> Void
let onDelete: () -> Void
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(shortcut.command)
.font(.system(size: 13, weight: .semibold, design: .monospaced))
Text(shortcut.description)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if shortcut.needsInput {
Label("needs input", systemImage: "text.cursor")
.font(.caption2).foregroundStyle(.secondary)
.padding(.horizontal, 6).padding(.vertical, 3)
.background(.secondary.opacity(0.12), in: Capsule())
} else {
Label("immediate", systemImage: "bolt")
.font(.caption2).foregroundStyle(.orange)
.padding(.horizontal, 6).padding(.vertical, 3)
.background(.orange.opacity(0.12), in: Capsule())
}
Button(action: onEdit) { Text("Edit").font(.caption) }
.buttonStyle(.bordered).controlSize(.small)
Button(action: onExport) { Image(systemName: "square.and.arrow.up") }
.buttonStyle(.bordered).controlSize(.small).help("Export shortcut")
Button(role: .destructive, action: onDelete) { Image(systemName: "trash") }
.buttonStyle(.bordered).controlSize(.small).help("Delete shortcut")
}
.padding(.vertical, 4)
}
}
// MARK: - Shortcuts Tab Content (embedded in SettingsView)
struct ShortcutsTabContent: View {
@Bindable private var settings = SettingsService.shared
@State private var showEditor = false
@State private var editingShortcut: Shortcut? = nil
@State private var importConflicts: [ShortcutsView.ShortcutConflict] = []
@State private var showConflictAlert = false
@State private var pendingImport: [Shortcut] = []
@State private var statusMessage: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) {
Button { editingShortcut = nil; showEditor = true } label: {
Label("New Shortcut", systemImage: "plus")
}
.buttonStyle(.bordered)
Button { importShortcuts() } label: {
Label("Import", systemImage: "square.and.arrow.down")
}
.buttonStyle(.bordered)
Button { exportAll() } label: {
Label("Export All", systemImage: "square.and.arrow.up")
}
.buttonStyle(.bordered)
.disabled(settings.userShortcuts.isEmpty)
Spacer()
if let msg = statusMessage {
Text(msg).font(.caption).foregroundStyle(.secondary)
}
}
if settings.userShortcuts.isEmpty {
HStack {
Spacer()
VStack(spacing: 8) {
Image(systemName: "bolt.slash")
.font(.system(size: 32)).foregroundStyle(.tertiary)
Text("No shortcuts yet — click New Shortcut to create one.")
.font(.callout).foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 32)
} else {
VStack(spacing: 0) {
ForEach(Array(settings.userShortcuts.enumerated()), id: \.element.id) { idx, shortcut in
ShortcutRow(
shortcut: shortcut,
onEdit: { editingShortcut = shortcut; showEditor = true },
onExport: { exportOne(shortcut) },
onDelete: { settings.deleteShortcut(id: shortcut.id) }
)
if idx < settings.userShortcuts.count - 1 { Divider() }
}
}
.background(.background, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
}
HStack(spacing: 8) {
Image(systemName: "lightbulb").foregroundStyle(.yellow).font(.callout)
Text("Use **{{input}}** in the template to insert whatever you type after the command.")
.font(.caption).foregroundStyle(.secondary)
}
.padding(10)
.background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 8))
}
.sheet(isPresented: $showEditor) {
ShortcutEditorSheet(shortcut: editingShortcut) { saved in
if editingShortcut != nil { settings.updateShortcut(saved) }
else { settings.addShortcut(saved) }
editingShortcut = nil
}
}
.alert("Duplicate Shortcut", isPresented: $showConflictAlert, presenting: importConflicts.first) { c in
Button("Replace") { resolveConflict(c, action: .replace) }
Button("Keep Both") { resolveConflict(c, action: .keepBoth) }
Button("Skip") { resolveConflict(c, action: .skip) }
} message: { c in
Text("A shortcut with command \(c.incoming.command) already exists.")
}
}
private func importShortcuts() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true; panel.canChooseFiles = true
panel.canChooseDirectories = false; panel.allowedContentTypes = [.json]
panel.message = "Select shortcut JSON files to import"
guard panel.runModal() == .OK else { return }
var incoming: [Shortcut] = []
for url in panel.urls {
guard let data = try? Data(contentsOf: url) else { continue }
if let pack = try? JSONDecoder().decode([Shortcut].self, from: data) { incoming.append(contentsOf: pack) }
else if let single = try? JSONDecoder().decode(Shortcut.self, from: data) { incoming.append(single) }
}
guard !incoming.isEmpty else { show("No valid shortcuts found"); return }
pendingImport = incoming; processNext()
}
private func processNext() {
guard !pendingImport.isEmpty else { importConflicts = []; return }
let item = pendingImport.removeFirst()
if settings.userShortcuts.first(where: { $0.command == item.command }) != nil {
importConflicts.append(ShortcutsView.ShortcutConflict(incoming: item)); showConflictAlert = true
} else {
settings.addShortcut(item); show("Imported \(item.command)"); processNext()
}
}
private func resolveConflict(_ c: ShortcutsView.ShortcutConflict, action: ShortcutsView.ConflictAction) {
importConflicts.removeFirst()
switch action {
case .replace:
if let ex = settings.userShortcuts.first(where: { $0.command == c.incoming.command }) {
var u = c.incoming; u.id = ex.id; settings.updateShortcut(u)
}
case .keepBoth:
var copy = c.incoming; copy.id = UUID(); copy.command += "-copy"; settings.addShortcut(copy)
case .skip: break
}
processNext()
}
private func exportAll() {
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601
enc.outputFormatting = [.prettyPrinted, .sortedKeys]
guard let data = try? enc.encode(settings.userShortcuts) else { return }
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
.appendingPathComponent("shortcuts.json")
try? data.write(to: url); show("Exported to Downloads/shortcuts.json")
}
private func exportOne(_ shortcut: Shortcut) {
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601
enc.outputFormatting = [.prettyPrinted, .sortedKeys]
guard let data = try? enc.encode(shortcut) else { return }
let filename = String(shortcut.command.dropFirst()) + ".json"
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
.appendingPathComponent(filename)
try? data.write(to: url); show("Exported \(filename)")
}
private func show(_ text: String) {
statusMessage = text
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
}
}
#Preview { ShortcutsView() }

View File

@@ -54,6 +54,13 @@ struct oAIApp: App {
}
}
CommandGroup(replacing: .appSettings) {
Button("Settings...") {
chatViewModel.showSettings = true
}
.keyboardShortcut(",", modifiers: .command)
}
CommandGroup(replacing: .help) {
Button("oAI Help") {
openHelp()