Files
oai-swift/oAI/Views/Screens/AgentSkillsView.swift
T
runeandClaude Sonnet 5 0af644456e 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:34:04 +02:00

693 lines
29 KiB
Swift

//
// AgentSkillsView.swift
// oAI
//
// Modal for managing SKILL.md-style agent skills (opened via /skills command)
//
// 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
/// 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: LocalizedStringKey? = 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) },
isTrusted: settings.trustSecondBrainSkill,
onToggleTrust: { settings.trustSecondBrainSkill.toggle() }
)
}
}
.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) skill](inflect: true) imported") }
}
@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) skill](inflect: true) exported 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: LocalizedStringKey) {
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
var isTrusted: Bool = false
var onToggleTrust: (() -> Void)? = nil
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()
// 2nd Brain: let the user mark bash calls to its helper script as always-trusted,
// skipping the bash approval dialog. Only shown for this specific skill while active.
if skill.isActive, skill.isSecondBrainSkill, let onToggleTrust {
Toggle("Trust", isOn: Binding(get: { isTrusted }, set: { _ in onToggleTrust() }))
.toggleStyle(.switch).controlSize(.small)
.help("When on, bash commands that call the 2nd Brain helper script run without asking for approval each time.")
}
// File count badge
if fileCount > 0 {
Label("^[\(fileCount) file](inflect: true)", 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: LocalizedStringKey? = 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](inflect: true) 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) },
isTrusted: settings.trustSecondBrainSkill,
onToggleTrust: { settings.trustSecondBrainSkill.toggle() }
)
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) skill](inflect: true) imported") }
}
@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) skill](inflect: true) exported 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: LocalizedStringKey) {
statusMessage = text
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
}
}
#Preview { AgentSkillsView() }