Added skills, shortcuts, and bugifixes++
This commit is contained in:
@@ -111,6 +111,12 @@ struct ChatView: View {
|
||||
FooterView(stats: viewModel.sessionStats)
|
||||
}
|
||||
.background(Color.oaiBackground)
|
||||
.sheet(isPresented: $viewModel.showShortcuts) {
|
||||
ShortcutsView()
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showSkills) {
|
||||
AgentSkillsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
294
oAI/Views/Screens/AgentSkillEditorSheet.swift
Normal file
294
oAI/Views/Screens/AgentSkillEditorSheet.swift
Normal 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 }
|
||||
}
|
||||
662
oAI/Views/Screens/AgentSkillsView.swift
Normal file
662
oAI/Views/Screens/AgentSkillsView.swift
Normal 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() }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
147
oAI/Views/Screens/SkillEditorSheet.swift
Normal file
147
oAI/Views/Screens/SkillEditorSheet.swift
Normal 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 }
|
||||
}
|
||||
413
oAI/Views/Screens/SkillsView.swift
Normal file
413
oAI/Views/Screens/SkillsView.swift
Normal 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() }
|
||||
Reference in New Issue
Block a user