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
@@ -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:
@@ -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)
}