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
CV%atT
literal 0
HcmV?d00001
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
new file mode 100644
index 0000000000000000000000000000000000000000..0286763cfa763ed6fba34f80edb8e6196f402dd8
GIT binary patch
literal 22481
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
CV%atT
literal 0
HcmV?d00001
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut
new file mode 100644
index 0000000000000000000000000000000000000000..25f2bb2daaeaa1d3fd27de35fdd8d22a486b1216
GIT binary patch
literal 1948
zcmaJ>TWl0%6rPRF@XI+iqJf<8=4$?$Ftpb!OJK
z5P6cANMd5527EBp#1~_Th7e8EXn4?9+X4xR;mHS$F(#Urn2_N4cMH2{5h%ua0EHX3eYN%G2{nu$>h3QcMe;@a6PQmL&I{MDXTbRa(xd(a~01I
zi}i5Og`_ddx2-7$1*cj$gGF3&N83TQ23L;NQLxftZ(9sk(LFbWMh8Pw)g3Dc5Wv{k
z$~j`YcC`*6jOKpNd;UKm7y7naZfk4EssaaXZ4G%12pXzbuh^b%a=TjM>Y?mhEX9LHYj#k6J$T6oLq#fE`3Sa~My4baXHc=LI7VdCgDaw`ud&;%srv&`;>~a=*##
ziK|jtP?G{LYog988A0HaYE0#gbRr{af-a}x#!~mAkL?>Bvz$86w7Fp95J>CTXDlol
zhPt$KSI=IP+hm2IRjgD2SfI_1SZ*1O1r4$ndv}kW75aO`R~P&C+YS#%;FaF-VAb^A^~}iKLLubMghhj4PYC><<2Lc>gS^-2MXWE`qO$ZZ1XtpP7yC?v-PwxXr!rsMR@wy}{eX;F3
z;!s^Jmwn4E*=JA#;$(~Ml~>I9!igr?%^(0src7?vY5;sxLsl3?K-u*JozFab@VP_J
z1BnB^SFgc
zO?n&psQ0A?h{gb{$Z8O+QprWLKC0So3Hdh;cMiVrV)jUOC<~JbZZWHSU;qKiRS<&u
zum-y2Ah%@sc;VX;U`Jr?+JkvJbkIqdMW|A!Aa|tht_6@vTFeNBEc1zk4&y9{3a`a7
zabA=)Nd<^$K~@0bFxK}d=zDBg>Kn=BM^D_dE4(`kP$K2aqu5T6m>5K8;+BEJc1NH;`M%{bWCR
zh|H1!IY*u&-zG1Sm&vQ-7vwkOHS&A%CV7kejr@b!Om$KHlt_(JQ&gF9D36+>-k{#0
z7N`%X%hVO>26dbIlU_q_q~r8S+N4W#m3HZQ`Yrlh`b+vd`WF2cv!3ZsX7nRCo}<`d>JbA|be`I@=L+-Ck}?ywP-WEr-bmDxCZfE{M@>;zk4FR*X2
V7uich<3~oCPvYg3=gNb9?;ka?eK-IB
literal 0
HcmV?d00001
diff --git a/OSGKeyboard/Services/AIAgentShortcutInstaller.swift b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift
new file mode 100644
index 0000000..8d973a5
--- /dev/null
+++ b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift
@@ -0,0 +1,41 @@
+// AIAgentShortcutInstaller.swift
+// OSGKeyboard · Main App
+//
+// Opening the HTTPS iCloud share page from the app is claimed as a
+// Universal Link and often lands on Gallery. `shortcuts://shortcuts/TOKEN`
+// opens the Add sheet in Shortcuts directly.
+
+import Foundation
+import UIKit
+import OSGKeyboardShared
+
+enum AIAgentShortcutInstaller {
+ static let bundledResourceName = "OSGExtractTodos"
+
+ @MainActor
+ static func openInstallPage(for skill: AIClipboardSkill) {
+ if let shareURL = skill.shortcutICloudURL,
+ let installURL = AIAgentShortcutRun.shortcutsInstallURL(from: shareURL) {
+ UIApplication.shared.open(installURL)
+ return
+ }
+ openBundledShortcut()
+ }
+
+ @MainActor
+ private static func openBundledShortcut() {
+ guard let bundled = Bundle.main.url(
+ forResource: bundledResourceName,
+ withExtension: "shortcut"
+ ) else { return }
+ let tmp = FileManager.default.temporaryDirectory
+ .appendingPathComponent("\(AIClipboardSkillCatalog.extractTodosShortcutName).shortcut")
+ try? FileManager.default.removeItem(at: tmp)
+ do {
+ try FileManager.default.copyItem(at: bundled, to: tmp)
+ UIApplication.shared.open(tmp)
+ } catch {
+ UIApplication.shared.open(bundled)
+ }
+ }
+}
diff --git a/OSGKeyboard/Services/AIAgentShortcutRunner.swift b/OSGKeyboard/Services/AIAgentShortcutRunner.swift
new file mode 100644
index 0000000..95d61f2
--- /dev/null
+++ b/OSGKeyboard/Services/AIAgentShortcutRunner.swift
@@ -0,0 +1,71 @@
+// AIAgentShortcutRunner.swift
+// OSGKeyboard · Main App
+//
+// Consumes the keyboard's pending extract-todos payload and opens the
+// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add
+// x-callback URLs so Console can record success / error / cancel.
+
+import UIKit
+import OSGKeyboardShared
+
+enum AIAgentShortcutRunner {
+ @MainActor
+ static func runPendingIfNeeded() {
+ AIAgentShortcutRun.trace("host.runPending begin")
+ guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
+ guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID),
+ let name = skill.shortcutName else {
+ AIAgentShortcutRun.trace(
+ "host.runPending skip unknownSkill=\(payload.skillID)"
+ )
+ return
+ }
+ AIAgentShortcutRun.traceBody("host.titlesToShortcut", payload.joinedTitles)
+ guard let url = shortcutsURL(name: name, text: payload.joinedTitles) else {
+ AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)")
+ return
+ }
+ let textItem = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first { $0.name == "text" }?
+ .value
+ AIAgentShortcutRun.trace(
+ "host.openShortcuts scheme=\(url.scheme ?? "") host=\(url.host ?? "") "
+ + "path=\(url.path) urlChars=\(url.absoluteString.count) "
+ + "textQueryChars=\(textItem?.count ?? -1) name=\(name)"
+ )
+ AIAgentShortcutRun.traceBody("host.textQuery", textItem ?? "")
+ UIApplication.shared.open(url) { success in
+ AIAgentShortcutRun.trace(
+ "host.openShortcuts result success=\(success) "
+ + "(iOS accepted the URL; not proof Reminders were created)"
+ )
+ }
+ }
+
+ static func logShortcutCallback(_ url: URL) {
+ let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let status = items.first { $0.name == "status" }?.value ?? "unknown"
+ let errorMessage = items.first { $0.name == "errorMessage" }?.value
+ let errorCode = items.first { $0.name == "errorCode" }?.value
+ AIAgentShortcutRun.trace(
+ "host.shortcutCallback status=\(status) errorCode=\(errorCode ?? "-") "
+ + "errorMessage=\(errorMessage ?? "-") "
+ + "(Shortcuts finished; does not prove reminder rows exist)"
+ )
+ }
+
+ private static func shortcutsURL(name: String, text: String) -> URL? {
+ #if DEBUG
+ return AIAgentShortcutRun.shortcutsRunURL(
+ name: name,
+ text: text,
+ xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
+ xError: "osgkeyboard://skill/shortcut-result?status=error",
+ xCancel: "osgkeyboard://skill/shortcut-result?status=cancel"
+ )
+ #else
+ return AIAgentShortcutRun.shortcutsRunURL(name: name, text: text)
+ #endif
+ }
+}
diff --git a/OSGKeyboard/Views/AIAgentSkillsView.swift b/OSGKeyboard/Views/AIAgentSkillsView.swift
new file mode 100644
index 0000000..72884fb
--- /dev/null
+++ b/OSGKeyboard/Views/AIAgentSkillsView.swift
@@ -0,0 +1,341 @@
+// AIAgentSkillsView.swift
+// OSGKeyboard · Main App
+//
+// Catalog of AI Agent clipboard skills. Enabled cards (max 8) appear on
+// the keyboard after a copy; long-press drag reorders that row. Export
+// skills confirm a companion Shortcut before they can occupy a slot.
+
+import SwiftUI
+import UIKit
+import OSGKeyboardShared
+
+struct AIAgentSkillsView: View {
+ @Environment(\.themePalette) private var palette
+ @ObservedObject private var config = ProviderConfig.shared
+ @ObservedObject private var store = AIAgentSkillLayoutStore.shared
+
+ @State private var viewingSkill: AIClipboardSkill?
+ @State private var showFullAlert = false
+
+ private let columns = [
+ GridItem(.flexible(), spacing: Spacing.sm),
+ GridItem(.flexible(), spacing: Spacing.sm),
+ ]
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ CardPageContent(spacing: Spacing.xl) {
+ if !config.clipboardHistoryEnabled {
+ clipboardHistoryBanner
+ }
+ enabledSection
+ if !store.availableSkills.isEmpty {
+ availableSection
+ }
+ }
+ .tabBarScrollBottomPadding()
+ }
+ .background(palette.background)
+ .navigationTitle("skills.title")
+ .navigationBarTitleDisplayMode(.large)
+ }
+ .sheet(item: $viewingSkill) { skill in
+ SkillDetailSheet(
+ store: store,
+ skill: skill,
+ onDismiss: { viewingSkill = nil },
+ onAddOrWarn: addOrWarn,
+ onConfirmInstall: confirmShortcutInstall
+ )
+ }
+ .alert(
+ AppL10n.string("skills.full.title", language: config.uiLanguage),
+ isPresented: $showFullAlert
+ ) {
+ Button("common.done") { showFullAlert = false }
+ } message: {
+ Text(AppL10n.string("skills.full.message", language: config.uiLanguage))
+ }
+ .onAppear { store.reload() }
+ }
+
+ private var clipboardHistoryBanner: some View {
+ Text("skills.clipboardHistory.banner")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(Spacing.md)
+ .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(palette.divider, lineWidth: 0.5)
+ )
+ }
+
+ private var enabledSection: some View {
+ CardSection(
+ title: AppL10n.format(
+ "skills.enabled.section",
+ language: config.uiLanguage,
+ store.enabledSkills.count,
+ AIAgentSkillLayout.maximumEnabled
+ )
+ ) {
+ if store.enabledSkills.isEmpty {
+ Text("skills.enabled.empty")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textTertiary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ LazyVGrid(columns: columns, spacing: Spacing.sm) {
+ ForEach(store.enabledSkills) { skill in
+ skillCard(skill, showsEnabledBadge: true)
+ .onTapGesture { viewingSkill = skill }
+ .draggable(skill.id) {
+ skillCard(skill, showsEnabledBadge: true)
+ .frame(width: 160)
+ }
+ .dropDestination(for: String.self) { items, _ in
+ guard let dragged = items.first else { return false }
+ store.moveEnabled(id: dragged, onto: skill.id)
+ return true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private var availableSection: some View {
+ CardSection("skills.available.section") {
+ LazyVGrid(columns: columns, spacing: Spacing.sm) {
+ ForEach(store.availableSkills) { skill in
+ Button {
+ viewingSkill = skill
+ } label: {
+ skillCard(skill, showsEnabledBadge: false)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ private func skillCard(_ skill: AIClipboardSkill, showsEnabledBadge: Bool) -> some View {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
+ HStack(spacing: Spacing.sm) {
+ Image(systemName: skill.systemImage)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ Spacer(minLength: 0)
+ if showsEnabledBadge {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ }
+ }
+ Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage))
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+ .lineLimit(1)
+ Text(AppL10n.string(skill.descriptionKey, language: config.uiLanguage))
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textTertiary)
+ .lineLimit(2)
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
+ .padding(Spacing.md)
+ .background(
+ showsEnabledBadge ? palette.accentMuted : palette.surface,
+ in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(
+ showsEnabledBadge ? palette.accent : palette.divider,
+ lineWidth: showsEnabledBadge ? 1.5 : 0.5
+ )
+ )
+ .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ .contentShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ }
+
+ private func addOrWarn(_ skill: AIClipboardSkill) {
+ switch store.enable(skill.id) {
+ case .enabled, .alreadyEnabled:
+ viewingSkill = nil
+ case .full:
+ showFullAlert = true
+ case .needsShortcut, .unknown:
+ break
+ }
+ }
+
+ private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
+ switch store.confirmShortcutAndEnable(skill.id) {
+ case .enabled, .alreadyEnabled:
+ viewingSkill = nil
+ case .full:
+ showFullAlert = true
+ case .needsShortcut, .unknown:
+ break
+ }
+ }
+}
+
+private struct SkillDetailSheet: View {
+ @Environment(\.themePalette) private var palette
+ @ObservedObject var store: AIAgentSkillLayoutStore
+ @ObservedObject private var config = ProviderConfig.shared
+
+ let skill: AIClipboardSkill
+ let onDismiss: () -> Void
+ let onAddOrWarn: (AIClipboardSkill) -> Void
+ let onConfirmInstall: (AIClipboardSkill) -> Void
+
+ /// Content stack height; nav chrome is added for the detent.
+ @State private var contentHeight: CGFloat = 240
+ private let navigationChrome: CGFloat = 56
+
+ var body: some View {
+ NavigationStack {
+ CardPageContent(
+ spacing: Spacing.md,
+ topPadding: Spacing.md,
+ bottomPadding: Spacing.lg
+ ) {
+ header
+ explanation
+ skillActions
+ }
+ .background(palette.background)
+ .fixedSize(horizontal: false, vertical: true)
+ .onGeometryChange(for: CGFloat.self) { proxy in
+ proxy.size.height
+ } action: { newHeight in
+ let next = newHeight + navigationChrome
+ if abs(contentHeight - next) > 1 {
+ contentHeight = next
+ }
+ }
+ .navigationTitle("skills.detail.title")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("common.done", action: onDismiss)
+ }
+ }
+ }
+ .presentationDetents([.height(contentHeight)])
+ .presentationDragIndicator(.visible)
+ .presentationContentInteraction(.resizes)
+ .animation(.easeInOut(duration: 0.2), value: contentHeight)
+ }
+
+ private var explanation: some View {
+ Text(AppL10n.string(explanationKey, language: config.uiLanguage))
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textSecondary)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, minHeight: 80, maxHeight: 80, alignment: .topLeading)
+ }
+
+ private var explanationKey: String {
+ if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
+ return "skills.install.lead"
+ }
+ if skill.requiresShortcut, store.layout.isEnabled(skill.id) {
+ return "skills.action.turnOffHint"
+ }
+ return skill.descriptionKey
+ }
+
+ private var header: some View {
+ HStack(alignment: .center, spacing: Spacing.md) {
+ Image(systemName: skill.systemImage)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ .frame(width: 36, height: 36)
+ .background(palette.accentMuted, in: Circle())
+ VStack(alignment: .leading, spacing: 2) {
+ Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage))
+ .font(TypeStyle.title3)
+ .foregroundStyle(palette.textPrimary)
+ if skill.isDefault {
+ Text("skills.badge.default")
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textSecondary)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ @ViewBuilder
+ private var skillActions: some View {
+ let enabled = store.layout.isEnabled(skill.id)
+ if skill.requiresShortcut {
+ if !store.layout.hasConfirmedShortcut(skill.id) {
+ shortcutInstallBlock
+ } else {
+ if enabled {
+ fullWidthButton("skills.action.turnOff", prominent: false) {
+ store.disable(skill.id)
+ onDismiss()
+ }
+ } else {
+ fullWidthButton("skills.action.addToKeyboard", prominent: true) {
+ onAddOrWarn(skill)
+ }
+ }
+ fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
+ openShortcutInstall()
+ }
+ }
+ } else if enabled {
+ fullWidthButton("skills.action.turnOff", prominent: false) {
+ store.disable(skill.id)
+ onDismiss()
+ }
+ } else {
+ fullWidthButton("skills.action.addToKeyboard", prominent: true) {
+ onAddOrWarn(skill)
+ }
+ }
+ }
+
+ private var shortcutInstallBlock: some View {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
+ fullWidthButton("skills.install.openShortcuts", prominent: true) {
+ openShortcutInstall()
+ }
+ fullWidthButton("skills.install.confirmAdded", prominent: false) {
+ onConfirmInstall(skill)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func fullWidthButton(
+ _ titleKey: LocalizedStringKey,
+ prominent: Bool,
+ action: @escaping () -> Void
+ ) -> some View {
+ let label = Text(titleKey)
+ .frame(maxWidth: .infinity, minHeight: 38)
+ if prominent {
+ Button(action: action) { label }
+ .buttonStyle(.borderedProminent)
+ .tint(palette.accent)
+ } else {
+ Button(action: action) { label }
+ .buttonStyle(.bordered)
+ }
+ }
+
+ private func openShortcutInstall() {
+ AIAgentShortcutInstaller.openInstallPage(for: skill)
+ }
+}
diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift
index 91ce86e..5b72805 100644
--- a/OSGKeyboard/Views/Components/MinimalTabBar.swift
+++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift
@@ -1,7 +1,7 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
-// Bottom tab bar — icon + label for Home / Styles / Settings.
+// Bottom tab bar — icon + label for Home / Skills / Styles / Settings.
// The dock capsule is iOS 26 Liquid Glass; the selected tab is a green
// fill inside that capsule (Photos-style), not a second glass layer.
// History + dictionary live as Home cards (not dock tabs).
@@ -11,13 +11,14 @@ import OSGKeyboardShared
enum AppTab: Int, CaseIterable {
case keyboard
+ case skills
case styles
case settings
var icon: MaterialIconName {
switch self {
case .keyboard: return .keyboard
- case .styles: return .menuBook // unused — styles uses SF Symbol
+ case .skills, .styles: return .menuBook // unused — these tabs use SF Symbols
case .settings: return .settings
}
}
@@ -25,6 +26,7 @@ enum AppTab: Int, CaseIterable {
/// SF Symbol overrides shared with the Mac and iPad sidebars.
var sfSymbol: String? {
switch self {
+ case .skills: return "sparkles"
case .styles: return "text.badge.star"
default: return nil
}
@@ -33,6 +35,7 @@ enum AppTab: Int, CaseIterable {
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
+ case .skills: return "tab.skills"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
@@ -44,6 +47,7 @@ enum AppTab: Int, CaseIterable {
var sidebarSystemImage: String {
switch self {
case .keyboard: return "house"
+ case .skills: return "sparkles"
case .styles: return "text.badge.star"
case .settings: return "gearshape"
}
@@ -110,7 +114,7 @@ struct MinimalTabBar: View {
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
.glassEffect(.regular.interactive(), in: .capsule)
- .frame(maxWidth: 280)
+ .frame(maxWidth: 360)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, TabBarDockMetrics.bottomPadding)
}
diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift
index 8f97a6b..45f46cf 100644
--- a/OSGKeyboard/Views/MainAppRoot.swift
+++ b/OSGKeyboard/Views/MainAppRoot.swift
@@ -214,6 +214,12 @@ struct MainAppRoot: View {
switch url.host {
case "startflow":
flowManager.startSession(coldStart: true, reason: "url.startflow")
+ case "skill":
+ if url.path.contains("shortcut-result") {
+ AIAgentShortcutRunner.logShortcutCallback(url)
+ } else if url.path.contains("run") {
+ AIAgentShortcutRunner.runPendingIfNeeded()
+ }
case "deployrime":
// The keyboard sends the user here precisely because typing
// resources are missing — deploy without waiting for warmup.
diff --git a/OSGKeyboard/Views/MainTabContent.swift b/OSGKeyboard/Views/MainTabContent.swift
index 9a3b3de..df1014e 100644
--- a/OSGKeyboard/Views/MainTabContent.swift
+++ b/OSGKeyboard/Views/MainTabContent.swift
@@ -14,6 +14,8 @@ struct MainTabContent: View {
switch tab {
case .keyboard:
HomeView()
+ case .skills:
+ AIAgentSkillsView()
case .styles:
PolishStylesView()
case .settings:
diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings
index df445f2..efaeca5 100644
--- a/OSGKeyboard/en.lproj/Localizable.strings
+++ b/OSGKeyboard/en.lproj/Localizable.strings
@@ -427,8 +427,35 @@
"tab.history" = "History";
"tab.dictionary" = "Dictionary";
"tab.styles" = "Styles";
+"tab.skills" = "Skills";
"tab.settings" = "Settings";
+/* AI Agent skills */
+"skills.title" = "Skills";
+"skills.enabled.section" = "In use (%d/%d)";
+"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
+"skills.available.section" = "Available";
+"skills.clipboardHistory.banner" = "Turn on Clipboard History in Settings to use these skills after you copy text.";
+"skills.detail.title" = "Skill";
+"skills.badge.default" = "Default skill";
+"skills.action.turnOff" = "Turn off";
+"skills.action.turnOffHint" = "Removes it from the keyboard only.";
+"skills.action.addToKeyboard" = "Add to keyboard";
+"skills.action.reinstallShortcut" = "Reinstall Shortcut";
+"skills.full.title" = "Skill slots full";
+"skills.full.message" = "You can enable up to 8 skills. Turn one off, then add this skill from its card.";
+"skills.reply.name" = "Reply";
+"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
+"skills.summarize.name" = "Summarize";
+"skills.summarize.description" = "Keep the key facts and conclusions, without rewriting it as a chat message.";
+"skills.translate.name" = "Translate";
+"skills.translate.description" = "Translate the copied text using your keyboard language setting.";
+"skills.extractTodos.name" = "Extract tasks";
+"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
+"skills.install.lead" = "Add it, then confirm. Don’t rename it.";
+"skills.install.openShortcuts" = "Add Shortcut";
+"skills.install.confirmAdded" = "I've added it";
+
/* Polish style packs */
"polishStyles.title" = "Polish styles";
"polishStyles.add" = "Add style";
diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
index f41e7da..891f7b8 100644
--- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings
+++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
@@ -426,8 +426,35 @@
"tab.history" = "历史";
"tab.dictionary" = "词库";
"tab.styles" = "风格";
+"tab.skills" = "技能";
"tab.settings" = "设置";
+/* AI Agent 技能 */
+"skills.title" = "技能";
+"skills.enabled.section" = "使用中(%d/%d)";
+"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
+"skills.available.section" = "可添加";
+"skills.clipboardHistory.banner" = "请先在设置中打开剪贴板历史,复制文字后才能在键盘上使用这些技能。";
+"skills.detail.title" = "技能";
+"skills.badge.default" = "默认技能";
+"skills.action.turnOff" = "关闭";
+"skills.action.turnOffHint" = "只从键盘拿掉,不会删除捷径。";
+"skills.action.addToKeyboard" = "添加到键盘";
+"skills.action.reinstallShortcut" = "重新安装捷径";
+"skills.full.title" = "技能已满";
+"skills.full.message" = "最多同时启用 8 个技能。请先关闭一个,再从卡片手动添加。";
+"skills.reply.name" = "回复";
+"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
+"skills.summarize.name" = "总结";
+"skills.summarize.description" = "保留关键事实与结论,不会改写成可发送的短消息。";
+"skills.translate.name" = "翻译";
+"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
+"skills.extractTodos.name" = "提取待办";
+"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
+"skills.install.lead" = "添加后点「我已添加」,请勿改名。";
+"skills.install.openShortcuts" = "添加捷径";
+"skills.install.confirmAdded" = "我已添加";
+
/* 润色风格包 */
"polishStyles.title" = "润色风格";
"polishStyles.add" = "添加风格";
diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift
index 42f608b..9d13838 100644
--- a/OSGKeyboardExt/KeyboardViewController.swift
+++ b/OSGKeyboardExt/KeyboardViewController.swift
@@ -520,6 +520,11 @@ public final class KeyboardViewController: UIInputViewController {
state.submitAIClipboardSkill = { [weak self] skill in
self?.aiKeyboardCoordinator.submitClipboardSkill(skill)
}
+ state.runClipboardExportSkill = { [weak self] skillID, titles in
+ AppGroupStore().setPendingShortcutRun(skillID: skillID, titles: titles)
+ AIAgentShortcutRun.trace("keyboard.openHost osgkeyboard://skill/run")
+ self?.openSkillShortcutRun()
+ }
state.openSettings = { [weak self] in self?.openHostApp() }
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
state.openClipboardSettings = { [weak self] in
@@ -942,6 +947,19 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - Open host app
+ private func openSkillShortcutRun() {
+ guard hasFullAccess else {
+ state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
+ return
+ }
+ guard let url = URL(string: "osgkeyboard://skill/run") else { return }
+ HostAppLauncher.open(url: url, from: self) { [weak self] success in
+ AIAgentShortcutRun.trace("keyboard.openHost result success=\(success)")
+ if success { return }
+ self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.handoffFailed")
+ }
+ }
+
private func openHostApp(path: String = "settings") {
guard hasFullAccess else {
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
diff --git a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
index ddaa662..6cbe42f 100644
--- a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
+++ b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
@@ -70,15 +70,26 @@ final class AIKeyboardCoordinator {
func submitClipboardSkill(_ skill: AIClipboardSkill) {
guard canAcceptIdleSubmit else { return }
enterIfNeeded()
+ state.pendingClipboardSkillID = skill.kind == .export ? skill.id : nil
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: AIHintLocaleResolver.packLocale(),
translationTargetLocaleId: state.translationTargetLocaleId
)
+ let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
+ AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
+ if let material {
+ AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
+ } else {
+ AIAgentShortcutRun.trace("keyboard.clipboard missing")
+ }
let resolution = AIClipboardPrompt.resolve(
instruction: instruction,
- material: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
+ material: material
)
+ if case .materialUnavailable = resolution {
+ AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
+ }
submitResolvedPrompt(resolution)
}
@@ -106,24 +117,30 @@ final class AIKeyboardCoordinator {
private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) {
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
+ state.pendingClipboardSkillID = nil
state.aiSession.fail(
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
utteranceID: nil
)
return
}
- guard let conversationID = state.aiSession.conversationID else { return }
+ guard let conversationID = state.aiSession.conversationID else {
+ state.pendingClipboardSkillID = nil
+ return
+ }
let disposition = flow.submitAIQuestion(
text: prompt,
conversationID: conversationID
)
if case .rejected(let rejection) = disposition {
+ state.pendingClipboardSkillID = nil
state.aiSession.fail(message(for: rejection), utteranceID: nil)
}
}
func cancel() {
guard state.aiSession.isBusy else { return }
+ state.pendingClipboardSkillID = nil
flow.cancelAIRecording()
state.aiSession.cancelCurrentWork()
}
@@ -181,6 +198,7 @@ final class AIKeyboardCoordinator {
}
func receivePartialAnswer(_ draft: String, utteranceID: UUID) {
+ if isPendingExportSkill { return }
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
}
@@ -188,6 +206,11 @@ final class AIKeyboardCoordinator {
guard result.resolvedUtteranceMode == .aiQuestion else {
return
}
+ if isPendingExportSkill {
+ guard result.aiConversationID == state.aiSession.conversationID else { return }
+ finishExportSkill(answer: result.text ?? "")
+ return
+ }
guard result.aiConversationID == state.aiSession.conversationID,
let answer = result.text,
!answer.isEmpty else {
@@ -201,14 +224,59 @@ final class AIKeyboardCoordinator {
}
func fail(_ message: String, utteranceID: UUID?) {
+ state.pendingClipboardSkillID = nil
state.aiSession.fail(message, utteranceID: utteranceID)
}
private func endConversationIfNeeded() {
+ state.pendingClipboardSkillID = nil
guard let conversationID = state.aiSession.conversationID else { return }
flow.endAIConversation(conversationID)
}
+ private var isPendingExportSkill: Bool {
+ guard let id = state.pendingClipboardSkillID else { return false }
+ return AIClipboardSkillCatalog.skill(id: id)?.kind == .export
+ }
+
+ /// Parse extract-todos. Empty → in-keyboard tip, stay in the host app.
+ /// Titles → hand off to the host to run the companion Shortcut.
+ private func finishExportSkill(answer: String) {
+ let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
+ let items = AITodoExtraction.items(from: answer, sourceClipboard: source)
+ AIAgentShortcutRun.traceBody("keyboard.llmRaw", answer)
+ AIAgentShortcutRun.trace(
+ "keyboard.parse items=\(items.count) clipboardChars=\(source?.count ?? 0)"
+ )
+ #if DEBUG
+ if !items.isEmpty {
+ AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
+ }
+ #endif
+ let skillID = state.pendingClipboardSkillID
+ state.pendingClipboardSkillID = nil
+ if state.aiSession.isBusy {
+ state.aiSession.cancelCurrentWork()
+ }
+ if state.aiSession.isActive {
+ state.aiSession.resetConversationPreservingAnswer()
+ }
+ guard !items.isEmpty else {
+ AIAgentShortcutRun.trace("keyboard.parse empty — skip Shortcuts")
+ state.skillTipText = ExtL10n.string("keyboard.ai.skill.noTodos")
+ return
+ }
+ guard let skillID,
+ AIClipboardSkillCatalog.skill(id: skillID)?.shortcutName != nil else {
+ AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")")
+ state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
+ return
+ }
+ AIAgentShortcutRun.trace("keyboard.handoffToHost skill=\(skillID) items=\(items.count)")
+ state.skillTipText = ExtL10n.string("keyboard.ai.skill.runningShortcut")
+ state.runClipboardExportSkill(skillID, items)
+ }
+
private func message(for rejection: FlowUtteranceStartRejection) -> String {
switch rejection {
case .onboardingIncomplete:
diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift
index f66561e..a3df9d4 100644
--- a/OSGKeyboardExt/Services/AppGroupPersistor.swift
+++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift
@@ -43,6 +43,7 @@ public struct AppGroupPersistor {
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
+ state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
@@ -98,6 +99,7 @@ public struct AppGroupPersistor {
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
+ state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
}
diff --git a/OSGKeyboardExt/Views/AIKeyboardView.swift b/OSGKeyboardExt/Views/AIKeyboardView.swift
index fb43f32..16bb6e9 100644
--- a/OSGKeyboardExt/Views/AIKeyboardView.swift
+++ b/OSGKeyboardExt/Views/AIKeyboardView.swift
@@ -55,6 +55,17 @@ struct AIKeyboardView: View {
}
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
+ .onChange(of: state.enabledClipboardSkillIDs) { _, _ in resetCarousel() }
+ .onChange(of: state.skillTipText) { _, tip in
+ guard let tip, !tip.isEmpty else { return }
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 2_800_000_000)
+ if state.skillTipText == tip {
+ state.skillTipText = nil
+ }
+ }
+ }
+ .animation(Motion.soft, value: state.skillTipText)
.onReceive(
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
) { _ in
@@ -172,6 +183,19 @@ struct AIKeyboardView: View {
statusLine
.frame(height: Layout.statusHeight)
.padding(.horizontal, Spacing.md)
+
+ if let tip = state.skillTipText, !tip.isEmpty {
+ Text(tip)
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textPrimary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 8)
+ .glassEffect(.regular, in: Capsule())
+ .padding(.bottom, Layout.statusHeight + Spacing.sm)
+ .transition(.opacity)
+ .allowsHitTesting(false)
+ }
}
}
@@ -212,8 +236,9 @@ struct AIKeyboardView: View {
}
private var clipboardSkillRow: some View {
- HStack(spacing: Spacing.lg) {
- ForEach(AIClipboardSkillCatalog.visible()) { skill in
+ let skills = visibleClipboardSkills
+ let row = HStack(spacing: Spacing.lg) {
+ ForEach(skills) { skill in
Button {
state.submitAIClipboardSkill(skill)
} label: {
@@ -233,6 +258,15 @@ struct AIKeyboardView: View {
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
}
+ return Group {
+ if skills.count > 4 {
+ ScrollView(.horizontal, showsIndicators: false) {
+ row.padding(.horizontal, Spacing.md)
+ }
+ } else {
+ row
+ }
+ }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -250,12 +284,17 @@ struct AIKeyboardView: View {
/// Copy-then-30s window: skill chips replace the rotating hint.
private var showsClipboardSkills: Bool {
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
+ guard !visibleClipboardSkills.isEmpty else { return false }
return AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: clipboardHistory.newestEntry
)
}
+ private var visibleClipboardSkills: [AIClipboardSkill] {
+ AIClipboardSkillCatalog.visible(enabledIDs: state.enabledClipboardSkillIDs)
+ }
+
/// No draft/answer yet — show the centered hint carousel instead of a scroll body.
private var showsPlaceholder: Bool {
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
@@ -432,6 +471,9 @@ struct AIKeyboardView: View {
}
return state.aiSession.transcript
case .generating:
+ if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
+ return ExtL10n.string("keyboard.ai.skill.extracting")
+ }
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
return ExtL10n.string("keyboard.ai.generating")
}
diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings
index 39367d3..46f10dc 100644
--- a/OSGKeyboardExt/en.lproj/Keyboard.strings
+++ b/OSGKeyboardExt/en.lproj/Keyboard.strings
@@ -297,3 +297,9 @@
"keyboard.ai.skill.reply" = "Reply";
"keyboard.ai.skill.summarize" = "Summarize";
"keyboard.ai.skill.translate" = "Translate";
+"keyboard.ai.skill.extractTodos" = "Tasks";
+"keyboard.ai.skill.noTodos" = "No tasks in the clipboard";
+"keyboard.ai.skill.extracting" = "Finding tasks…";
+"keyboard.ai.skill.runningShortcut" = "Running Shortcut…";
+"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
+"keyboard.ai.skill.handoffFailed" = "Couldn’t open the app to run the Shortcut";
diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
index b16546a..3280535 100644
--- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
+++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
@@ -297,3 +297,9 @@
"keyboard.ai.skill.reply" = "回复";
"keyboard.ai.skill.summarize" = "总结";
"keyboard.ai.skill.translate" = "翻译";
+"keyboard.ai.skill.extractTodos" = "待办";
+"keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项";
+"keyboard.ai.skill.extracting" = "正在提取待办…";
+"keyboard.ai.skill.runningShortcut" = "正在运行捷径…";
+"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
+"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
diff --git a/OSGKeyboardShared/Models/AIAgentSkillLayout.swift b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift
new file mode 100644
index 0000000..2194185
--- /dev/null
+++ b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift
@@ -0,0 +1,78 @@
+// AIAgentSkillLayout.swift
+// OSGKeyboard · Shared
+//
+// Enabled clipboard-skill slots (max 8, ordered) plus which export skills
+// the user confirmed a companion Shortcut for. Missing storage hydrates
+// to the three default transform skills; an explicit empty list is kept
+// so turning every skill off is distinct from a fresh install.
+
+import Foundation
+
+public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
+ public static let maximumEnabled = 8
+ public static let defaultEnabledIDs = [
+ AIClipboardSkillCatalog.replyID,
+ AIClipboardSkillCatalog.summarizeID,
+ AIClipboardSkillCatalog.translateID,
+ ]
+
+ /// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
+ public var enabledIDs: [String]
+ /// Export skills whose companion Shortcut the user marked as added.
+ public var confirmedShortcutIDs: [String]
+
+ public static let `default` = AIAgentSkillLayout(
+ enabledIDs: defaultEnabledIDs,
+ confirmedShortcutIDs: []
+ )
+
+ public init(enabledIDs: [String], confirmedShortcutIDs: [String]) {
+ self.enabledIDs = enabledIDs
+ self.confirmedShortcutIDs = confirmedShortcutIDs
+ }
+
+ public var isFull: Bool {
+ enabledIDs.count >= Self.maximumEnabled
+ }
+
+ public func isEnabled(_ id: String) -> Bool {
+ enabledIDs.contains(id)
+ }
+
+ public func hasConfirmedShortcut(_ id: String) -> Bool {
+ confirmedShortcutIDs.contains(id)
+ }
+
+ /// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
+ public func sanitized() -> AIAgentSkillLayout {
+ let known = Dictionary(uniqueKeysWithValues: AIClipboardSkillCatalog.catalog.map { ($0.id, $0) })
+ var seenEnabled = Set()
+ let enabled = enabledIDs.filter { id in
+ guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
+ if skill.requiresShortcut {
+ return confirmedShortcutIDs.contains(id)
+ }
+ return true
+ }
+ .prefix(Self.maximumEnabled)
+
+ var seenConfirmed = Set()
+ let confirmed = confirmedShortcutIDs.filter { id in
+ guard known[id]?.requiresShortcut == true else { return false }
+ return seenConfirmed.insert(id).inserted
+ }
+
+ return AIAgentSkillLayout(
+ enabledIDs: Array(enabled),
+ confirmedShortcutIDs: confirmed
+ )
+ }
+}
+
+public enum AIAgentSkillEnableResult: Equatable, Sendable {
+ case enabled
+ case alreadyEnabled
+ case needsShortcut
+ case full
+ case unknown
+}
diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
index 5e63e66..044dd28 100644
--- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift
+++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
@@ -69,6 +69,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
"config.flowInactivityDuration.migratedToFiveMinuteDefault"
/// Diagnostic switch: when false, local ASR skips the custom language model.
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
+ /// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
+ public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
}
// MARK: - Stored fields
@@ -113,8 +115,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var flowSkipAppSwitch: Bool
/// Idle timeout before the Flow session ends; resets on each utterance.
public var flowInactivityDuration: FlowInactivityDuration
- /// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
- public var localASRCustomLanguageModelEnabled: Bool
+ /// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
+ public var localASRCustomLanguageModelEnabled: Bool
// MARK: - Derived
diff --git a/OSGKeyboardShared/Services/AIAgentShortcutRun.swift b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift
new file mode 100644
index 0000000..206e4c8
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift
@@ -0,0 +1,128 @@
+// AIAgentShortcutRun.swift
+// OSGKeyboard · Shared
+//
+// Ephemeral App Group payload for one keyboard → host → Shortcuts hop.
+// The keyboard writes titles here, then opens `osgkeyboard://skill/run`.
+// The host consumes the payload (once) and opens the Shortcuts URL.
+
+import Foundation
+
+public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable {
+ public let skillID: String
+ public let titles: [String]
+ public let createdAt: Date
+
+ public init(skillID: String, titles: [String], createdAt: Date = Date()) {
+ self.skillID = skillID
+ self.titles = titles
+ self.createdAt = createdAt
+ }
+
+ public var joinedTitles: String {
+ titles.joined(separator: "\n")
+ }
+}
+
+public enum AIAgentShortcutRun {
+ public static let pendingKey = "config.aiAgentSkills.pendingRun.v1"
+ /// Drop payloads older than this; a leftover write must not fire later.
+ public static let payloadTTL: TimeInterval = 60
+
+ public static func shortcutsRunURL(
+ name: String,
+ text: String,
+ xSuccess: String? = nil,
+ xError: String? = nil,
+ xCancel: String? = nil
+ ) -> URL? {
+ var components = URLComponents()
+ components.scheme = "shortcuts"
+ let usesCallback = xSuccess != nil || xError != nil || xCancel != nil
+ if usesCallback {
+ components.host = "x-callback-url"
+ components.path = "/run-shortcut"
+ } else {
+ components.host = "run-shortcut"
+ }
+ var items = [
+ URLQueryItem(name: "name", value: name),
+ URLQueryItem(name: "input", value: "text"),
+ URLQueryItem(name: "text", value: text),
+ ]
+ if let xSuccess {
+ items.append(URLQueryItem(name: "x-success", value: xSuccess))
+ }
+ if let xError {
+ items.append(URLQueryItem(name: "x-error", value: xError))
+ }
+ if let xCancel {
+ items.append(URLQueryItem(name: "x-cancel", value: xCancel))
+ }
+ components.queryItems = items
+ return components.url
+ }
+
+ /// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies.
+ public static func trace(_ message: String) {
+ OSGDiag.log(message, category: "skills")
+ }
+
+ /// Single-line preview so Console keeps the format (`\\n` for newlines).
+ public static func preview(_ text: String, limit: Int = 1200) -> String {
+ let escaped = text
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\r\n", with: "\\n")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ .replacingOccurrences(of: "\r", with: "\\n")
+ .replacingOccurrences(of: "\t", with: "\\t")
+ if escaped.count <= limit { return escaped }
+ return String(escaped.prefix(limit)) + "…(chars=\(text.count))"
+ }
+
+ public static func traceBody(_ label: String, _ text: String) {
+ #if DEBUG
+ trace("\(label) chars=\(text.count) body=\(preview(text))")
+ #else
+ trace("\(label) chars=\(text.count)")
+ #endif
+ }
+
+ /// Opens the Shortcuts Add sheet for an iCloud share token.
+ /// Do not `open` the HTTPS share page from the app — Universal Links
+ /// often land on Gallery and drop the token.
+ public static func shortcutsInstallURL(from shareURL: URL) -> URL? {
+ guard let token = iCloudShareToken(from: shareURL) else { return nil }
+ return URL(string: "shortcuts://shortcuts/\(token)")
+ }
+
+ public static func iCloudShareToken(from shareURL: URL) -> String? {
+ guard let host = shareURL.host, host.contains("icloud.com") else { return nil }
+ let parts = shareURL.path.split(separator: "/").map(String.init)
+ guard let index = parts.firstIndex(of: "shortcuts"),
+ parts.count > index + 1 else { return nil }
+ let token = parts[index + 1]
+ guard token != "api", !token.isEmpty else { return nil }
+ return token
+ }
+
+ public static func openShortcutURL(name: String) -> URL? {
+ var components = URLComponents()
+ components.scheme = "shortcuts"
+ components.host = "open-shortcut"
+ components.queryItems = [URLQueryItem(name: "name", value: name)]
+ return components.url
+ }
+
+ public static func encode(_ payload: AIAgentShortcutRunPayload) -> Data? {
+ try? JSONEncoder().encode(payload)
+ }
+
+ public static func decode(_ data: Data, now: Date = Date()) -> AIAgentShortcutRunPayload? {
+ guard let payload = try? JSONDecoder().decode(AIAgentShortcutRunPayload.self, from: data) else {
+ return nil
+ }
+ guard now.timeIntervalSince(payload.createdAt) <= payloadTTL else { return nil }
+ guard !payload.titles.isEmpty else { return nil }
+ return payload
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift
new file mode 100644
index 0000000..be6d884
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift
@@ -0,0 +1,110 @@
+// AIAgentSkillLayoutStore.swift
+// OSGKeyboard · Shared
+//
+// Observable facade over the persisted skill layout. The Skills tab mutates
+// this; the keyboard reads the same App Group snapshot on each config poll.
+
+import Foundation
+import Combine
+
+@MainActor
+public final class AIAgentSkillLayoutStore: ObservableObject {
+ public static let shared = AIAgentSkillLayoutStore()
+
+ @Published public private(set) var layout: AIAgentSkillLayout
+
+ private let defaults: UserDefaults?
+ private let persist: (AIAgentSkillLayout) -> Void
+ private let load: () -> AIAgentSkillLayout
+
+ public init(defaults: UserDefaults? = nil) {
+ if let defaults {
+ self.defaults = defaults
+ self.load = { AppGroupStore(defaults: defaults).agentSkillLayout }
+ self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
+ } else {
+ self.defaults = nil
+ self.load = { AppGroupStore().agentSkillLayout }
+ self.persist = { AppGroupStore().setAgentSkillLayout($0) }
+ }
+ self.layout = self.load()
+ }
+
+ public func reload() {
+ layout = load()
+ }
+
+ public var enabledSkills: [AIClipboardSkill] {
+ AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs)
+ }
+
+ public var availableSkills: [AIClipboardSkill] {
+ AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) }
+ }
+
+ @discardableResult
+ public func enable(_ id: String) -> AIAgentSkillEnableResult {
+ let current = layout.sanitized()
+ guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown }
+ if current.isEnabled(id) { return .alreadyEnabled }
+ if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
+ return .needsShortcut
+ }
+ if current.isFull { return .full }
+ commit(
+ AIAgentSkillLayout(
+ enabledIDs: current.enabledIDs + [id],
+ confirmedShortcutIDs: current.confirmedShortcutIDs
+ )
+ )
+ return .enabled
+ }
+
+ /// 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(
+ AIAgentSkillLayout(
+ enabledIDs: current.enabledIDs.filter { $0 != id },
+ confirmedShortcutIDs: current.confirmedShortcutIDs
+ )
+ )
+ }
+
+ /// 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 {
+ return .unknown
+ }
+ var current = layout.sanitized()
+ if !current.confirmedShortcutIDs.contains(id) {
+ current.confirmedShortcutIDs.append(id)
+ }
+ commit(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 }
+ ids.move(
+ fromOffsets: IndexSet(integer: from),
+ toOffset: to > from ? to + 1 : to
+ )
+ commit(
+ AIAgentSkillLayout(
+ enabledIDs: ids,
+ confirmedShortcutIDs: layout.confirmedShortcutIDs
+ )
+ )
+ }
+
+ private func commit(_ layout: AIAgentSkillLayout) {
+ persist(layout)
+ self.layout = load()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIClipboardSkill.swift b/OSGKeyboardShared/Services/AIClipboardSkill.swift
index f7d0b56..3001289 100644
--- a/OSGKeyboardShared/Services/AIClipboardSkill.swift
+++ b/OSGKeyboardShared/Services/AIClipboardSkill.swift
@@ -2,20 +2,57 @@
// OSGKeyboard · Shared
//
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
-// Settings can later persist a subset or permutation without changing the view.
+// Settings / the Skills tab can persist a subset or permutation without
+// changing the view. Transform skills insert into the current field;
+// export skills hand off to a companion Shortcut after the model runs.
import Foundation
+public enum AIClipboardSkillKind: String, Sendable {
+ /// LLM output is reviewed and inserted into the current text field.
+ case transform
+ /// LLM output is parsed and sent to a companion Shortcut. Never inserted.
+ case export
+}
+
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public let id: String
public let systemImage: String
- /// Keyboard.strings key for the short button title.
+ /// Keyboard.strings key for the short chip title.
public let titleKey: String
+ /// App Localizable key for the Skills-tab card title. Falls back to `titleKey`.
+ public let cardTitleKey: String
+ public let descriptionKey: String
+ public let kind: AIClipboardSkillKind
+ /// Default skills can be turned off but not removed from the catalog.
+ public let isDefault: Bool
+ /// Frozen companion Shortcut name. Nil for transform skills.
+ public let shortcutName: String?
+ /// Optional `icloud.com/shortcuts/` share URL. Nil → open the bundled file.
+ public let shortcutICloudURL: URL?
- public init(id: String, systemImage: String, titleKey: String) {
+ public var requiresShortcut: Bool { kind == .export }
+
+ public init(
+ id: String,
+ systemImage: String,
+ titleKey: String,
+ cardTitleKey: String,
+ descriptionKey: String,
+ kind: AIClipboardSkillKind,
+ isDefault: Bool,
+ shortcutName: String? = nil,
+ shortcutICloudURL: URL? = nil
+ ) {
self.id = id
self.systemImage = systemImage
self.titleKey = titleKey
+ self.cardTitleKey = cardTitleKey
+ self.descriptionKey = descriptionKey
+ self.kind = kind
+ self.isDefault = isDefault
+ self.shortcutName = shortcutName
+ self.shortcutICloudURL = shortcutICloudURL
}
}
@@ -23,31 +60,68 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let replyID = "reply"
public static let summarizeID = "summarize"
public static let translateID = "translate"
+ public static let extractTodosID = "extractTodos"
+ public static let extractTodosShortcutName = "OSG · 提取待办"
+ public static let extractTodosShortcutICloudURL = URL(
+ string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81"
+ )!
- /// Default set, in display order. Future skills append here.
- public static let builtIn: [AIClipboardSkill] = [
+ /// Full built-in catalog, in a stable display order for the Skills tab.
+ public static let catalog: [AIClipboardSkill] = [
AIClipboardSkill(
id: replyID,
systemImage: "arrowshape.turn.up.left.fill",
- titleKey: "keyboard.ai.skill.reply"
+ titleKey: "keyboard.ai.skill.reply",
+ cardTitleKey: "skills.reply.name",
+ descriptionKey: "skills.reply.description",
+ kind: .transform,
+ isDefault: true
),
AIClipboardSkill(
id: summarizeID,
systemImage: "doc.text.magnifyingglass",
- titleKey: "keyboard.ai.skill.summarize"
+ titleKey: "keyboard.ai.skill.summarize",
+ cardTitleKey: "skills.summarize.name",
+ descriptionKey: "skills.summarize.description",
+ kind: .transform,
+ isDefault: true
),
AIClipboardSkill(
id: translateID,
systemImage: "character.bubble.fill",
- titleKey: "keyboard.ai.skill.translate"
+ titleKey: "keyboard.ai.skill.translate",
+ cardTitleKey: "skills.translate.name",
+ descriptionKey: "skills.translate.description",
+ kind: .transform,
+ isDefault: true
+ ),
+ AIClipboardSkill(
+ id: extractTodosID,
+ systemImage: "checklist",
+ titleKey: "keyboard.ai.skill.extractTodos",
+ cardTitleKey: "skills.extractTodos.name",
+ descriptionKey: "skills.extractTodos.description",
+ kind: .export,
+ isDefault: false,
+ shortcutName: extractTodosShortcutName,
+ shortcutICloudURL: extractTodosShortcutICloudURL
),
]
- /// `enabledIDs` is the future Settings hook: `nil` keeps the built-in list.
+ /// Legacy alias: the three default transform skills used to be the whole list.
+ public static let builtIn: [AIClipboardSkill] = catalog
+
+ public static func skill(id: String) -> AIClipboardSkill? {
+ catalog.first { $0.id == id }
+ }
+
+ /// `enabledIDs` is the Skills-tab order. `nil` keeps the default three.
+ /// An explicit empty array shows no chips (carousel fallback).
public static func visible(enabledIDs: [String]? = nil) -> [AIClipboardSkill] {
- guard let enabledIDs, !enabledIDs.isEmpty else { return builtIn }
- let byID = Dictionary(uniqueKeysWithValues: builtIn.map { ($0.id, $0) })
- return enabledIDs.compactMap { byID[$0] }
+ let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
+ guard !ids.isEmpty else { return [] }
+ let byID = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
+ return ids.compactMap { byID[$0] }
}
public static func instruction(
@@ -102,6 +176,18 @@ public enum AIClipboardSkillCatalog: Sendable {
locale: locale,
translationTargetLocaleId: translationTargetLocaleId
)
+ case extractTodosID:
+ return zh
+ ? """
+ 请从剪贴板中只提取明确的待办事项。每条一行,只要标题,不要编号、不要项目符号、不要解释。最多 20 条。
+ 若没有任何可执行的待办,只输出 NONE,不要把整段原文当成一条待办。
+ 若原文本身就是一句短待办(例如「买牛奶」),输出那一句即可。
+ """
+ : """
+ Extract only explicit to-do items from the clipboard. One title per line; no numbering, bullets, or commentary. Maximum 20 lines.
+ If there are no actionable tasks, output NONE and nothing else. Do not treat the whole clipboard as one task.
+ If the clipboard itself is already one short task (for example "buy milk"), output that single line.
+ """
default:
return zh
? "请根据剪贴板内容完成用户选择的操作。"
diff --git a/OSGKeyboardShared/Services/AITodoExtraction.swift b/OSGKeyboardShared/Services/AITodoExtraction.swift
new file mode 100644
index 0000000..262dfc3
--- /dev/null
+++ b/OSGKeyboardShared/Services/AITodoExtraction.swift
@@ -0,0 +1,82 @@
+// AITodoExtraction.swift
+// OSGKeyboard · Shared
+//
+// Parses the LLM's extract-todos reply into reminder titles. Fail closed:
+// empty / NONE / "no tasks" never become a Shortcut run. A single long
+// echo of the clipboard is also rejected so the model cannot dump the
+// whole paste as one reminder.
+
+import Foundation
+
+public enum AITodoExtraction: Sendable {
+ public static let maximumItems = 20
+
+ private static let emptyTokens: Set = [
+ "none", "no", "n/a", "na", "nil", "null",
+ "无", "没有", "没有待办", "没有待办事项", "无待办", "无待办事项",
+ "no tasks", "no task", "no todos", "no to-dos", "no to-do",
+ "no actionable items", "no action items",
+ ]
+
+ /// Titles to send to the companion Shortcut. Empty → do not run it.
+ public static func items(from raw: String, sourceClipboard: String? = nil) -> [String] {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return [] }
+ if isEmptyToken(trimmed) { return [] }
+
+ var seen = Set()
+ var items: [String] = []
+ for line in trimmed.components(separatedBy: .newlines) {
+ let title = stripBullet(line)
+ guard !title.isEmpty, !isEmptyToken(title) else { continue }
+ let key = title.lowercased()
+ guard seen.insert(key).inserted else { continue }
+ items.append(title)
+ if items.count == maximumItems { break }
+ }
+
+ if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
+ return []
+ }
+ return items
+ }
+
+ private static func isEmptyToken(_ text: String) -> Bool {
+ let folded = text
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "。.!!"))
+ .lowercased()
+ return emptyTokens.contains(folded)
+ }
+
+ private static func stripBullet(_ line: String) -> String {
+ var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ let prefixes = ["- ", "* ", "• ", "、"]
+ for prefix in prefixes where text.hasPrefix(prefix) {
+ text = String(text.dropFirst(prefix.count))
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
+ text = String(text[dotted.upperBound...])
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ return text
+ }
+
+ /// One long line that is essentially the clipboard body is not a todo.
+ private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
+ guard let source, source.count > 80, item.count > 80 else { return false }
+ let a = collapse(item)
+ let b = collapse(source)
+ guard !a.isEmpty, !b.isEmpty else { return false }
+ if a == b { return true }
+ return a.contains(b) || b.contains(a)
+ }
+
+ private static func collapse(_ text: String) -> String {
+ text.components(separatedBy: .whitespacesAndNewlines)
+ .filter { !$0.isEmpty }
+ .joined(separator: " ")
+ .lowercased()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift
index 0357a30..80b2ab4 100644
--- a/OSGKeyboardShared/Services/AppGroupStore.swift
+++ b/OSGKeyboardShared/Services/AppGroupStore.swift
@@ -97,6 +97,10 @@ public struct AppGroupStore: @unchecked Sendable {
public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
+ /// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it.
+ public var agentSkillLayout: AIAgentSkillLayout {
+ Self.decodeAgentSkillLayout(from: defaults)
+ }
// MARK: - Writes
@@ -199,6 +203,59 @@ public struct AppGroupStore: @unchecked Sendable {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
}
+ public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
+ do {
+ let data = try JSONEncoder().encode(layout.sanitized())
+ defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
+ } catch {
+ OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)")
+ }
+ AppGroupConfigDarwin.postConfigChanged()
+ }
+
+ public func setPendingShortcutRun(skillID: String, titles: [String]) {
+ let payload = AIAgentShortcutRunPayload(skillID: skillID, titles: titles)
+ if let data = AIAgentShortcutRun.encode(payload) {
+ defaults.set(data, forKey: AIAgentShortcutRun.pendingKey)
+ AIAgentShortcutRun.trace(
+ "appGroup.writePending skill=\(skillID) items=\(titles.count) bytes=\(data.count)"
+ )
+ } else {
+ AIAgentShortcutRun.trace("appGroup.writePending FAILED encode skill=\(skillID)")
+ }
+ }
+
+ public func consumePendingShortcutRun(now: Date = Date()) -> AIAgentShortcutRunPayload? {
+ let data = defaults.data(forKey: AIAgentShortcutRun.pendingKey)
+ defaults.removeObject(forKey: AIAgentShortcutRun.pendingKey)
+ guard let data else {
+ AIAgentShortcutRun.trace("appGroup.consumePending missing")
+ return nil
+ }
+ guard let payload = AIAgentShortcutRun.decode(data, now: now) else {
+ AIAgentShortcutRun.trace(
+ "appGroup.consumePending dropped bytes=\(data.count) (expired or empty titles)"
+ )
+ return nil
+ }
+ AIAgentShortcutRun.trace(
+ "appGroup.consumePending ok skill=\(payload.skillID) items=\(payload.titles.count)"
+ )
+ return payload
+ }
+
+ private static func decodeAgentSkillLayout(from defaults: UserDefaults) -> AIAgentSkillLayout {
+ guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
+ return .default
+ }
+ do {
+ return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data).sanitized()
+ } catch {
+ OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
+ return .default
+ }
+ }
+
public var hasCompletedOnboarding: Bool {
get { configuration.hasCompletedOnboarding }
set { setHasCompletedOnboarding(newValue) }
diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift
index ee49223..6e923ee 100644
--- a/OSGKeyboardShared/Services/KeyboardState.swift
+++ b/OSGKeyboardShared/Services/KeyboardState.swift
@@ -140,6 +140,12 @@ public final class KeyboardState: ObservableObject {
@Published public var clipboardHistoryEnabled: Bool = false
/// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false
+ /// Skills-tab order for clipboard chips (max 8). Empty → hint carousel.
+ @Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
+ /// Export skill currently waiting on the LLM. Nil for transform skills.
+ @Published public var pendingClipboardSkillID: String?
+ /// In-keyboard toast (e.g. no todos). Does not leave the host app.
+ @Published public var skillTipText: String?
/// Host field is a password / secure entry — never read pasteboard.
@Published public var isSecureTextEntry: Bool = false
/// Secure fields hide every clipboard-history entry point.
@@ -239,8 +245,10 @@ public final class KeyboardState: ObservableObject {
public var sendAIAnswer: () -> Void = {}
/// Sends a tapped idle hint card as the AI question (skip microphone).
public var submitAIHint: (AIHintCard) -> Void = { _ in }
- /// Sends a clipboard skill (reply / summarize / translate / future).
+ /// Sends a clipboard skill (reply / summarize / translate / export).
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
+ /// Writes extract-todos titles and opens the host to run the Shortcut.
+ public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
public var openSettings: () -> Void = {}
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.
diff --git a/OSGKeyboardShared/Utilities/OSGDiag.swift b/OSGKeyboardShared/Utilities/OSGDiag.swift
index 83d5d05..b0eef3c 100644
--- a/OSGKeyboardShared/Utilities/OSGDiag.swift
+++ b/OSGKeyboardShared/Utilities/OSGDiag.swift
@@ -18,6 +18,8 @@ public enum OSGDiag {
OSGLog.keyboardExt.info("\(line, privacy: .public)")
case "flow", "asr":
OSGLog.flow.info("\(line, privacy: .public)")
+ case "skills":
+ OSGLog.config.info("\(line, privacy: .public)")
default:
OSGLog.config.info("\(line, privacy: .public)")
}
diff --git a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift
new file mode 100644
index 0000000..1b46f05
--- /dev/null
+++ b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift
@@ -0,0 +1,199 @@
+// AIAgentSkillLayoutTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+@MainActor
+final class AIAgentSkillLayoutTests: XCTestCase {
+ private func makeDefaults() -> UserDefaults {
+ let suite = "group.com.osgkeyboard.shared.tests.skills.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ return defaults
+ }
+
+ func testFreshInstallEnablesDefaultTransformSkills() {
+ let defaults = makeDefaults()
+ let layout = AppGroupStore(defaults: defaults).agentSkillLayout
+ XCTAssertEqual(layout.enabledIDs, AIAgentSkillLayout.defaultEnabledIDs)
+ XCTAssertTrue(layout.confirmedShortcutIDs.isEmpty)
+ }
+
+ func testEmptyEnabledListIsPreserved() {
+ let defaults = makeDefaults()
+ let store = AppGroupStore(defaults: defaults)
+ store.setAgentSkillLayout(
+ AIAgentSkillLayout(enabledIDs: [], confirmedShortcutIDs: [])
+ )
+ XCTAssertEqual(store.agentSkillLayout.enabledIDs, [])
+ }
+
+ func testCannotEnableExportSkillBeforeShortcutConfirmation() {
+ let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
+ XCTAssertEqual(
+ store.enable(AIClipboardSkillCatalog.extractTodosID),
+ .needsShortcut
+ )
+ XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.extractTodosID))
+ }
+
+ func testConfirmShortcutAutoEnablesWhenSlotAvailable() {
+ let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
+ XCTAssertEqual(
+ store.confirmShortcutAndEnable(AIClipboardSkillCatalog.extractTodosID),
+ .enabled
+ )
+ XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.extractTodosID))
+ XCTAssertEqual(
+ store.layout.enabledIDs.last,
+ AIClipboardSkillCatalog.extractTodosID
+ )
+ }
+
+ func testSanitizedDropsUnconfirmedExportAndUnknownIDs() {
+ let layout = AIAgentSkillLayout(
+ enabledIDs: ["reply", "extractTodos", "unknown"],
+ confirmedShortcutIDs: []
+ ).sanitized()
+ XCTAssertEqual(layout.enabledIDs, ["reply"])
+ }
+
+ func testSanitizedKeepsConfirmedExport() {
+ let layout = AIAgentSkillLayout(
+ enabledIDs: ["reply", "extractTodos"],
+ confirmedShortcutIDs: ["extractTodos"]
+ ).sanitized()
+ XCTAssertEqual(layout.enabledIDs, ["reply", "extractTodos"])
+ }
+
+ func testIsFullUsesEnabledCount() {
+ let full = AIAgentSkillLayout(
+ enabledIDs: (0.. dict:
+ return {"Value": value, "WFSerializationType": "WFTextTokenAttachment"}
+
+
+def token_string(attachment_value: dict) -> dict:
+ return {
+ "Value": {
+ "attachmentsByRange": {"{0, 1}": attachment_value},
+ "string": OBJECT_REPLACEMENT,
+ },
+ "WFSerializationType": "WFTextTokenString",
+ }
+
+
+def shortcut_input_text() -> dict:
+ return token_string({"Type": "ExtensionInput"})
+
+
+def action_output_text(output_name: str, output_uuid: str) -> dict:
+ return token_string(
+ {
+ "Aggrandizements": [],
+ "OutputName": output_name,
+ "OutputUUID": output_uuid,
+ "Type": "ActionOutput",
+ }
+ )
+
+
+def named_variable_text(variable_name: str) -> dict:
+ return token_string(
+ {
+ "Aggrandizements": [],
+ "Type": "Variable",
+ "VariableName": variable_name,
+ }
+ )
+
+
+def workflow() -> dict:
+ return {
+ "WFWorkflowClientVersion": "3600.0.4",
+ "WFWorkflowClientRelease": "26.0",
+ "WFWorkflowMinimumClientVersion": 900,
+ "WFWorkflowMinimumClientVersionString": "900",
+ "WFWorkflowName": "OSG · 提取待办",
+ "WFWorkflowHasShortcutInputVariables": True,
+ "WFWorkflowHasOutputFallback": False,
+ "WFWorkflowImportQuestions": [],
+ # Empty types: a normal library shortcut. QuickActions/ActionExtension
+ # made `run-shortcut?name=` open Shortcuts without running it.
+ "WFWorkflowTypes": [],
+ "WFWorkflowInputContentItemClasses": [
+ "WFStringContentItem",
+ "WFRichTextContentItem",
+ ],
+ "WFWorkflowOutputContentItemClasses": [],
+ "WFWorkflowNoInputBehavior": {
+ "Name": "WFWorkflowNoInputBehaviorAskForInput",
+ "Parameters": {
+ "ItemClass": "WFStringContentItem",
+ },
+ },
+ "WFWorkflowIcon": {
+ "WFWorkflowIconGlyphNumber": 59511,
+ "WFWorkflowIconStartColor": 2071128575,
+ },
+ "WFWorkflowActions": [
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
+ "WFWorkflowActionParameters": {
+ "UUID": TEXT_UUID,
+ "CustomOutputName": "快捷指令文本",
+ "WFTextActionText": shortcut_input_text(),
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
+ "WFWorkflowActionParameters": {
+ "UUID": SPLIT_UUID,
+ # Split Text reads `text`, not `WFInput`.
+ "text": action_output_text("快捷指令文本", TEXT_UUID),
+ "WFTextSeparator": "New Lines",
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
+ "WFWorkflowActionParameters": {
+ "UUID": REPEAT_START,
+ "GroupingIdentifier": REPEAT_GROUP,
+ "WFControlFlowMode": 0,
+ "WFInput": attachment(
+ {
+ "Aggrandizements": [],
+ "OutputName": "Split Text",
+ "OutputUUID": SPLIT_UUID,
+ "Type": "ActionOutput",
+ }
+ ),
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.addnewreminder",
+ "WFWorkflowActionParameters": {
+ "UUID": REMINDER_UUID,
+ "WFCalendarItemTitle": named_variable_text("Repeat Item"),
+ "WFCalendarItemShowComposer": False,
+ "ShowComposeSheet": False,
+ "WFShowWhenRun": False,
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
+ "WFWorkflowActionParameters": {
+ "UUID": REPEAT_END,
+ "GroupingIdentifier": REPEAT_GROUP,
+ "WFControlFlowMode": 2,
+ },
+ },
+ ],
+ }
+
+
+def main() -> None:
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
+ UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY))
+ print(f"wrote {UNSIGNED}")
+ print(
+ "sign with: shortcuts sign --mode anyone "
+ f"--input {UNSIGNED} --output {SIGNED}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json
index dd21bd0..5486e70 100644
--- a/Tests/suite-manifest.json
+++ b/Tests/suite-manifest.json
@@ -132,6 +132,8 @@
"OSGKeyboardTests/CursorNavigationTests",
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
"OSGKeyboardTests/AIHintPoolTests",
+ "OSGKeyboardTests/AIHintKeywordExtractorTests",
+ "OSGKeyboardTests/AIAgentSkillLayoutTests",
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
"OSGKeyboardTests/ClipboardHistoryStoreTests"
]
diff --git a/project.yml b/project.yml
index 00a0157..48c18a4 100644
--- a/project.yml
+++ b/project.yml
@@ -76,6 +76,7 @@ targets:
# crashes when both are passed. iOS 26 uses Icon Composer only.
- "Assets.xcassets/AppIcon.appiconset"
- "Resources/CustomLanguageModel/**"
+ - "Resources/Shortcuts/**"
# Real keyboard chrome for DEBUG what's-new recording (same UI as Ext).
- path: OSGKeyboardExt/Utilities/ExtL10n.swift
- path: OSGKeyboardExt/Views/AIKeyboardView.swift
@@ -120,6 +121,7 @@ targets:
- path: OSGKeyboard/Resources/Typing/Rime/manifest.json
- path: OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin
- path: OSGKeyboard/Resources/HostCLM/v1/compiled-manifest.json
+ - path: OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
- path: OSGKeyboard/Resources/LocalASR/local-asr-catalog.json
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
@@ -194,6 +196,7 @@ targets:
- zalo
- skype
- zoomus
+ - shortcuts
- things
- todoist
- evernote