feat(keyboard): add Skills tab and extract-todos Shortcut export

Ship a Skills catalog with drag-to-reorder chips and a companion Shortcut that receives extracted titles and writes them to Reminders.
This commit is contained in:
Rocky
2026-08-13 18:20:42 +08:00
parent ff99b7bff1
commit 148ff807f2
33 changed files with 1628 additions and 23 deletions
+1
View File
@@ -69,6 +69,7 @@
<string>zalo</string>
<string>skype</string>
<string>zoomus</string>
<string>shortcuts</string>
<string>things</string>
<string>todoist</string>
<string>evernote</string>
@@ -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)
}
}
}
@@ -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
}
}
+341
View File
@@ -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)
}
}
@@ -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)
}
+6
View File
@@ -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.
+2
View File
@@ -14,6 +14,8 @@ struct MainTabContent: View {
switch tab {
case .keyboard:
HomeView()
case .skills:
AIAgentSkillsView()
case .styles:
PolishStylesView()
case .settings:
+27
View File
@@ -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. Dont 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";
@@ -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" = "添加风格";