Files
oai-swift/oAI/Views/Screens/AgentSkillEditorSheet.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

311 lines
13 KiB
Swift

//
// AgentSkillEditorSheet.swift
// oAI
//
// Create or edit a SKILL.md-style agent skill, with optional support files
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
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 }
}