feat(keyboard): add custom skills plus events and navigate Shortcuts

Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69.
This commit is contained in:
Rocky
2026-08-13 23:01:43 +08:00
parent 148ff807f2
commit 0a60006d2d
50 changed files with 11612 additions and 251 deletions
@@ -12,46 +12,65 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
public static let shared = AIAgentSkillLayoutStore()
@Published public private(set) var layout: AIAgentSkillLayout
@Published public private(set) var userCatalog: AIUserSkillCatalog
private let defaults: UserDefaults?
private let persist: (AIAgentSkillLayout) -> Void
private let load: () -> AIAgentSkillLayout
private let persistLayout: (AIAgentSkillLayout) -> Void
private let persistUserCatalog: (AIUserSkillCatalog) -> Void
private let loadLayout: () -> AIAgentSkillLayout
private let loadUserCatalog: () -> AIUserSkillCatalog
public init(defaults: UserDefaults? = nil) {
if let defaults {
self.defaults = defaults
self.load = { AppGroupStore(defaults: defaults).agentSkillLayout }
self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
self.loadUserCatalog = { AppGroupStore(defaults: defaults).agentUserSkillCatalog }
self.persistUserCatalog = { AppGroupStore(defaults: defaults).setAgentUserSkillCatalog($0) }
self.loadLayout = { AppGroupStore(defaults: defaults).agentSkillLayout }
self.persistLayout = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
} else {
self.defaults = nil
self.load = { AppGroupStore().agentSkillLayout }
self.persist = { AppGroupStore().setAgentSkillLayout($0) }
self.loadUserCatalog = { AppGroupStore().agentUserSkillCatalog }
self.persistUserCatalog = { AppGroupStore().setAgentUserSkillCatalog($0) }
self.loadLayout = { AppGroupStore().agentSkillLayout }
self.persistLayout = { AppGroupStore().setAgentSkillLayout($0) }
}
self.layout = self.load()
self.userCatalog = self.loadUserCatalog()
self.layout = self.loadLayout()
}
public func reload() {
layout = load()
userCatalog = loadUserCatalog()
layout = loadLayout()
}
public var mergedCatalog: [AIClipboardSkill] {
AIClipboardSkillCatalog.all(userCatalog: userCatalog)
}
public var enabledSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs)
AIClipboardSkillCatalog.visible(
enabledIDs: layout.enabledIDs,
userCatalog: userCatalog
)
}
public var availableSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) }
mergedCatalog.filter { !layout.isEnabled($0.id) }
}
public func userSkill(id: String) -> AIUserSkill? {
userCatalog.skill(id: id)
}
@discardableResult
public func enable(_ id: String) -> AIAgentSkillEnableResult {
let current = layout.sanitized()
guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown }
let current = layout.sanitized(catalog: mergedCatalog)
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog) else {
return .unknown
}
if current.isEnabled(id) { return .alreadyEnabled }
if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
return .needsShortcut
}
if current.isFull { return .full }
commit(
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs + [id],
confirmedShortcutIDs: current.confirmedShortcutIDs
@@ -63,8 +82,8 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
/// Drops the keyboard slot only. Companion Shortcuts stay installed;
/// the user deletes them in the Shortcuts app if they want them gone.
public func disable(_ id: String) {
let current = layout.sanitized()
commit(
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs
@@ -75,27 +94,34 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
/// Marks the companion Shortcut as added, then tries to occupy a slot.
@discardableResult
public func confirmShortcutAndEnable(_ id: String) -> AIAgentSkillEnableResult {
guard let skill = AIClipboardSkillCatalog.skill(id: id), skill.requiresShortcut else {
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog),
skill.requiresShortcut else {
return .unknown
}
var current = layout.sanitized()
var current = layout.sanitized(catalog: mergedCatalog)
if !current.confirmedShortcutIDs.contains(id) {
current.confirmedShortcutIDs.append(id)
}
commit(current)
commitLayout(current)
return enable(id)
}
public func moveEnabled(id draggedID: String, onto targetID: String) {
var ids = layout.sanitized().enabledIDs
guard let from = ids.firstIndex(of: draggedID),
let to = ids.firstIndex(of: targetID),
from != to else { return }
let ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
guard let to = ids.firstIndex(of: targetID) else { return }
moveEnabled(id: draggedID, toIndex: to)
}
public func moveEnabled(id draggedID: String, toIndex: Int) {
var ids = layout.sanitized(catalog: mergedCatalog).enabledIDs
guard let from = ids.firstIndex(of: draggedID), !ids.isEmpty else { return }
let to = min(max(toIndex, 0), ids.count - 1)
guard from != to else { return }
ids.move(
fromOffsets: IndexSet(integer: from),
toOffset: to > from ? to + 1 : to
)
commit(
commitLayout(
AIAgentSkillLayout(
enabledIDs: ids,
confirmedShortcutIDs: layout.confirmedShortcutIDs
@@ -103,8 +129,47 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
)
}
private func commit(_ layout: AIAgentSkillLayout) {
persist(layout)
self.layout = load()
public func saveUserSkill(_ skill: AIUserSkill) throws {
let previousURL = userCatalog.skill(id: skill.id)?.shortcutICloudURL
var catalog = userCatalog
try catalog.upsert(skill)
commitUserCatalog(catalog)
if previousURL != nil, previousURL != skill.shortcutICloudURL {
dropShortcutConfirmation(for: skill.id)
}
}
public func deleteUserSkill(id: String) {
var catalog = userCatalog
catalog.remove(id: id)
commitUserCatalog(catalog)
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
)
)
}
private func dropShortcutConfirmation(for id: String) {
let current = layout.sanitized(catalog: mergedCatalog)
commitLayout(
AIAgentSkillLayout(
enabledIDs: current.enabledIDs.filter { $0 != id },
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
)
)
}
private func commitLayout(_ layout: AIAgentSkillLayout) {
persistLayout(layout)
self.layout = loadLayout()
}
private func commitUserCatalog(_ catalog: AIUserSkillCatalog) {
persistUserCatalog(catalog)
userCatalog = loadUserCatalog()
layout = loadLayout()
}
}