feat(keyboard): add Notes skill and system-style typing

Add save-to-Notes export and direct map handoff while aligning multi-touch typing, period shortcuts, return actions, and navigation visuals with system behavior.
This commit is contained in:
Rocky
2026-08-14 18:22:41 +08:00
parent 0a60006d2d
commit 320dc777c9
46 changed files with 1429 additions and 376 deletions
@@ -70,13 +70,18 @@ final class AIKeyboardCoordinator {
func submitClipboardSkill(_ skill: AIClipboardSkill) {
guard canAcceptIdleSubmit else { return }
enterIfNeeded()
state.pendingClipboardSkillID = skill.kind == .export ? skill.id : nil
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
if skill.kind == .export {
state.pendingClipboardSkillID = skill.id
state.pendingClipboardSkillSource = material
} else {
clearPendingExportSkill()
}
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)
@@ -120,7 +125,7 @@ final class AIKeyboardCoordinator {
) {
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
state.pendingClipboardSkillID = nil
clearPendingExportSkill()
state.aiSession.fail(
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
utteranceID: nil
@@ -128,7 +133,7 @@ final class AIKeyboardCoordinator {
return
}
guard let conversationID = state.aiSession.conversationID else {
state.pendingClipboardSkillID = nil
clearPendingExportSkill()
return
}
let disposition = flow.submitAIQuestion(
@@ -137,14 +142,14 @@ final class AIKeyboardCoordinator {
thinkingEnabled: thinkingEnabled
)
if case .rejected(let rejection) = disposition {
state.pendingClipboardSkillID = nil
clearPendingExportSkill()
state.aiSession.fail(message(for: rejection), utteranceID: nil)
}
}
func cancel() {
guard state.aiSession.isBusy else { return }
state.pendingClipboardSkillID = nil
clearPendingExportSkill()
flow.cancelAIRecording()
state.aiSession.cancelCurrentWork()
}
@@ -153,7 +158,7 @@ final class AIKeyboardCoordinator {
if state.aiSession.canInsert, let answer = state.aiSession.answer {
guard insertAnswer(answer) else { return }
state.aiSession.markAnswerInserted(
offersSend: state.returnKeyRole == .send
offersSend: state.returnKeyRole.usesActionFill
)
} else if state.aiSession.canSend {
state.aiSession.markAnswerSent()
@@ -228,16 +233,21 @@ final class AIKeyboardCoordinator {
}
func fail(_ message: String, utteranceID: UUID?) {
state.pendingClipboardSkillID = nil
clearPendingExportSkill()
state.aiSession.fail(message, utteranceID: utteranceID)
}
private func endConversationIfNeeded() {
state.pendingClipboardSkillID = nil
clearPendingExportSkill()
guard let conversationID = state.aiSession.conversationID else { return }
flow.endAIConversation(conversationID)
}
private func clearPendingExportSkill() {
state.pendingClipboardSkillID = nil
state.pendingClipboardSkillSource = nil
}
private var isPendingExportSkill: Bool {
guard let id = state.pendingClipboardSkillID else { return false }
return resolvedSkill(id: id)?.kind == .export
@@ -251,9 +261,10 @@ final class AIKeyboardCoordinator {
}
/// Parse an export skill. Empty in-keyboard tip, stay in the host app.
/// Lines hand off to the host to run the companion Shortcut.
/// Lines hand off to the host (Shortcut, Maps, or Didi).
private func finishExportSkill(answer: String) {
let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
let source = state.pendingClipboardSkillSource
?? ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
let skillID = state.pendingClipboardSkillID
let items: [String]
let emptyTipKey: String
@@ -267,6 +278,13 @@ final class AIKeyboardCoordinator {
case AIClipboardSkillCatalog.navigateID:
items = AIAddressExtraction.lines(from: answer, sourceClipboard: source)
emptyTipKey = "keyboard.ai.skill.noAddress"
case AIClipboardSkillCatalog.saveToNotesID:
items = AINoteExport.items(
from: answer,
sourceClipboard: source,
locale: AIHintLocaleResolver.packLocale()
)
emptyTipKey = "keyboard.ai.skill.noNote"
default:
items = AIGenericSkillExport.items(from: answer)
emptyTipKey = "keyboard.ai.skill.noExportItems"
@@ -280,7 +298,7 @@ final class AIKeyboardCoordinator {
AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
}
#endif
state.pendingClipboardSkillID = nil
clearPendingExportSkill()
if state.aiSession.isBusy {
state.aiSession.cancelCurrentWork()
}
@@ -292,14 +310,25 @@ final class AIKeyboardCoordinator {
state.skillTipText = ExtL10n.string(emptyTipKey)
return
}
guard let skillID,
resolvedSkill(id: skillID)?.shortcutName != nil else {
AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")")
guard let skillID, let skill = resolvedSkill(id: skillID) else {
AIAgentShortcutRun.trace("keyboard.parse missingSkill")
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
return
}
if skill.requiresShortcut, skill.shortcutName == nil {
AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID)")
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")
let tipKey: String
switch skillID {
case AIClipboardSkillCatalog.navigateID:
tipKey = "keyboard.ai.skill.openingMaps"
default:
tipKey = "keyboard.ai.skill.runningShortcut"
}
state.skillTipText = ExtL10n.string(tipKey)
state.runClipboardExportSkill(skillID, items)
}