// // AgentSkillEditorSheet.swift // oAI // // Create or edit a SKILL.md-style agent skill, with optional support files // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . import SwiftUI 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 } }