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

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() }