Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
@@ -18,6 +18,7 @@ final class AIKeyboardCoordinator {
|
||||
private var requestInsertionFingerprint: String?
|
||||
private var conversationInsertionFingerprint: String?
|
||||
private var hasConversationInsertionTarget = false
|
||||
private var requestOOBEFeature: ManagedGatewayOOBEFeature?
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
@@ -37,6 +38,7 @@ final class AIKeyboardCoordinator {
|
||||
endConversationIfNeeded()
|
||||
state.aiSession.enter()
|
||||
requestInsertionFingerprint = nil
|
||||
requestOOBEFeature = nil
|
||||
conversationInsertionFingerprint = nil
|
||||
hasConversationInsertionTarget = false
|
||||
}
|
||||
@@ -53,6 +55,7 @@ final class AIKeyboardCoordinator {
|
||||
endConversationIfNeeded()
|
||||
state.aiSession.leave()
|
||||
requestInsertionFingerprint = nil
|
||||
requestOOBEFeature = nil
|
||||
conversationInsertionFingerprint = nil
|
||||
hasConversationInsertionTarget = false
|
||||
}
|
||||
@@ -66,7 +69,12 @@ final class AIKeyboardCoordinator {
|
||||
case .idle, .awaitingSend, .inserted, .sent, .failed:
|
||||
prepareConversationForRequest()
|
||||
guard let conversationID = state.aiSession.conversationID else { return }
|
||||
let disposition = flow.beginAIRecording(conversationID: conversationID)
|
||||
let oobeFeature = expectedOOBEFeature(.askAI)
|
||||
requestOOBEFeature = oobeFeature
|
||||
let disposition = flow.beginAIRecording(
|
||||
conversationID: conversationID,
|
||||
oobeFeature: oobeFeature
|
||||
)
|
||||
if case .rejected(let rejection) = disposition {
|
||||
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
||||
}
|
||||
@@ -81,8 +89,23 @@ final class AIKeyboardCoordinator {
|
||||
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
|
||||
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
||||
guard canAcceptIdleSubmit else { return }
|
||||
guard !skill.requiresShortcut
|
||||
|| state.confirmedClipboardShortcutIDs.contains(skill.id) else {
|
||||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
|
||||
return
|
||||
}
|
||||
prepareConversationForRequest()
|
||||
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
let oobeFeature = oobeFeature(for: skill)
|
||||
let material: String?
|
||||
if let oobeFeature {
|
||||
// OOBE clipboard lessons are intentionally isolated from the
|
||||
// user's real clipboard history.
|
||||
material = oobeMaterial(for: oobeFeature)
|
||||
} else if state.oobePracticeSession != nil {
|
||||
material = nil
|
||||
} else {
|
||||
material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
}
|
||||
if skill.kind == .export {
|
||||
state.pendingClipboardSkillID = skill.id
|
||||
state.pendingClipboardSkillSource = material
|
||||
@@ -113,6 +136,7 @@ final class AIKeyboardCoordinator {
|
||||
submitResolvedPrompt(
|
||||
resolution,
|
||||
taskKind: skill.managedGatewayTaskKind,
|
||||
oobeFeature: oobeFeature,
|
||||
thinkingEnabled: skill.thinkingEnabled
|
||||
)
|
||||
}
|
||||
@@ -120,12 +144,19 @@ final class AIKeyboardCoordinator {
|
||||
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
||||
func submitHintCard(_ card: AIHintCard) {
|
||||
guard canAcceptIdleSubmit else { return }
|
||||
// The OOBE ask-AI lesson must use the explicit hold-to-talk path. Idle
|
||||
// cards can otherwise pull unrelated clipboard material into a request.
|
||||
guard state.oobePracticeSession == nil else { return }
|
||||
prepareConversationForRequest()
|
||||
let resolution = AIHintPool.resolvePrompt(
|
||||
for: card,
|
||||
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
)
|
||||
submitResolvedPrompt(resolution, taskKind: .aiQuestion)
|
||||
submitResolvedPrompt(
|
||||
resolution,
|
||||
taskKind: .aiQuestion,
|
||||
oobeFeature: nil
|
||||
)
|
||||
}
|
||||
|
||||
private var canAcceptIdleSubmit: Bool {
|
||||
@@ -141,6 +172,7 @@ final class AIKeyboardCoordinator {
|
||||
private func submitResolvedPrompt(
|
||||
_ resolution: AIClipboardPrompt.Resolution,
|
||||
taskKind: ManagedGatewayTaskKind,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
thinkingEnabled: Bool? = nil
|
||||
) {
|
||||
guard case .ready(let prompt) = resolution else {
|
||||
@@ -156,10 +188,12 @@ final class AIKeyboardCoordinator {
|
||||
clearPendingExportSkill()
|
||||
return
|
||||
}
|
||||
requestOOBEFeature = oobeFeature
|
||||
let disposition = flow.submitAIQuestion(
|
||||
text: prompt,
|
||||
conversationID: conversationID,
|
||||
taskKind: taskKind,
|
||||
oobeFeature: oobeFeature,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
if case .rejected(let rejection) = disposition {
|
||||
@@ -172,6 +206,7 @@ final class AIKeyboardCoordinator {
|
||||
guard state.aiSession.isBusy else { return }
|
||||
clearPendingExportSkill()
|
||||
requestInsertionFingerprint = nil
|
||||
requestOOBEFeature = nil
|
||||
flow.cancelAIRecording()
|
||||
state.aiSession.cancelCurrentWork()
|
||||
}
|
||||
@@ -179,6 +214,7 @@ final class AIKeyboardCoordinator {
|
||||
func confirmPendingAnswer() {
|
||||
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
||||
guard insertAnswer(answer) else { return }
|
||||
markOOBECompletedAfterInsertion()
|
||||
state.aiSession.markAnswerInserted(
|
||||
offersSend: state.returnKeyRole.usesActionFill
|
||||
)
|
||||
@@ -190,6 +226,7 @@ final class AIKeyboardCoordinator {
|
||||
func discardPendingAnswer() {
|
||||
state.aiSession.discardReadyAnswer()
|
||||
requestInsertionFingerprint = nil
|
||||
requestOOBEFeature = nil
|
||||
}
|
||||
|
||||
func performCurrentFieldAction() {
|
||||
@@ -274,6 +311,7 @@ final class AIKeyboardCoordinator {
|
||||
// fallback when the field or caret changed during generation.
|
||||
return
|
||||
}
|
||||
markOOBECompletedAfterInsertion()
|
||||
state.aiSession.markAnswerInserted(
|
||||
offersSend: state.returnKeyRole.usesActionFill
|
||||
)
|
||||
@@ -284,6 +322,7 @@ final class AIKeyboardCoordinator {
|
||||
func fail(_ message: String, utteranceID: UUID?) {
|
||||
clearPendingExportSkill()
|
||||
requestInsertionFingerprint = nil
|
||||
requestOOBEFeature = nil
|
||||
state.aiSession.fail(message, utteranceID: utteranceID)
|
||||
}
|
||||
|
||||
@@ -319,16 +358,51 @@ final class AIKeyboardCoordinator {
|
||||
requestInsertionFingerprint = currentFingerprint
|
||||
}
|
||||
|
||||
private func oobeFeature(for skill: AIClipboardSkill) -> ManagedGatewayOOBEFeature? {
|
||||
switch skill.id {
|
||||
case AIClipboardSkillCatalog.replyID:
|
||||
return expectedOOBEFeature(.clipboardReply)
|
||||
case AIClipboardSkillCatalog.translateID:
|
||||
return expectedOOBEFeature(.clipboardTranslate)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func expectedOOBEFeature(
|
||||
_ feature: ManagedGatewayOOBEFeature
|
||||
) -> ManagedGatewayOOBEFeature? {
|
||||
guard state.oobePracticeSession?.expectedFeature == feature else { return nil }
|
||||
return feature
|
||||
}
|
||||
|
||||
private func oobeMaterial(for feature: ManagedGatewayOOBEFeature?) -> String? {
|
||||
guard let feature,
|
||||
feature == .clipboardReply || feature == .clipboardTranslate,
|
||||
let session = state.oobePracticeSession else {
|
||||
return nil
|
||||
}
|
||||
return KeyboardSetupBridge.oobeClipboardMaterial(sessionID: session.sessionID)
|
||||
}
|
||||
|
||||
private func markOOBECompletedAfterInsertion() {
|
||||
guard let feature = requestOOBEFeature,
|
||||
let session = state.oobePracticeSession else {
|
||||
return
|
||||
}
|
||||
_ = KeyboardSetupBridge.markOOBEPracticeCompleted(
|
||||
sessionID: session.sessionID,
|
||||
feature: feature
|
||||
)
|
||||
}
|
||||
|
||||
private var isPendingExportSkill: Bool {
|
||||
guard let id = state.pendingClipboardSkillID else { return false }
|
||||
return resolvedSkill(id: id)?.kind == .export
|
||||
}
|
||||
|
||||
private func resolvedSkill(id: String) -> AIClipboardSkill? {
|
||||
AIClipboardSkillCatalog.skill(
|
||||
id: id,
|
||||
userCatalog: AppGroupStore().agentUserSkillCatalog
|
||||
)
|
||||
state.enabledClipboardSkills.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Parse an export skill. Empty → in-keyboard tip, stay in the host app.
|
||||
|
||||
@@ -11,6 +11,7 @@ final class AnalyticsExtensionService: Sendable {
|
||||
static let shared = AnalyticsExtensionService()
|
||||
|
||||
let client: any AnalyticsClient
|
||||
let keyboardUsageRecorder: any KeyboardUsageRecording
|
||||
|
||||
private init() {
|
||||
let environment = Self.environment
|
||||
@@ -18,7 +19,15 @@ final class AnalyticsExtensionService: Sendable {
|
||||
environment: environment,
|
||||
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint)
|
||||
)
|
||||
let keyboardUsageRuntime = KeyboardUsageRuntime(
|
||||
environment: environment,
|
||||
analyticsRepository: runtime.repository,
|
||||
uploadConfiguration: KeyboardUsageUploadConfiguration(
|
||||
endpoint: Self.keyboardUsageEndpoint
|
||||
)
|
||||
)
|
||||
client = runtime.client
|
||||
keyboardUsageRecorder = keyboardUsageRuntime.recorder
|
||||
}
|
||||
|
||||
func recordPresentation(hasFullAccess _: Bool) {
|
||||
@@ -32,6 +41,10 @@ final class AnalyticsExtensionService: Sendable {
|
||||
string: "https://account.osglab.com/v1/analytics/events"
|
||||
)!
|
||||
|
||||
private static let keyboardUsageEndpoint = URL(
|
||||
string: "https://account.osglab.com/v1/analytics/keyboard-usage"
|
||||
)!
|
||||
|
||||
private static var environment: AnalyticsEnvironment {
|
||||
let appVersion = Bundle.main.object(
|
||||
forInfoDictionaryKey: "CFBundleShortVersionString"
|
||||
|
||||
@@ -42,7 +42,7 @@ public struct AppGroupPersistor {
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||
applySkillSnapshot(store: store, into: state)
|
||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||
applyAPIKeyAvailability(store: store, into: state)
|
||||
|
||||
@@ -97,11 +97,30 @@ public struct AppGroupPersistor {
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||
applySkillSnapshot(store: store, into: state)
|
||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||
applyAPIKeyAvailability(store: store, into: state)
|
||||
}
|
||||
|
||||
/// Resolve once outside SwiftUI body evaluation. Assigning the complete
|
||||
/// value snapshot publishes remote copy changes even when slot IDs are stable.
|
||||
private func applySkillSnapshot(
|
||||
store: AppGroupStore,
|
||||
into state: KeyboardViewController.State
|
||||
) {
|
||||
let layout = store.agentSkillLayout
|
||||
let language = store.uiLanguage
|
||||
state.uiLanguage = language
|
||||
state.enabledClipboardSkillIDs = layout.enabledIDs
|
||||
state.confirmedClipboardShortcutIDs = layout.confirmedShortcutIDs
|
||||
state.enabledClipboardSkills = AIClipboardSkillCatalog.visible(
|
||||
enabledIDs: layout.enabledIDs,
|
||||
officialCatalog: store.officialSkillCatalog,
|
||||
userCatalog: store.agentUserSkillCatalog,
|
||||
uiLanguage: language
|
||||
)
|
||||
}
|
||||
|
||||
/// Cloud without ASR/LLM keys blocks the mic. Local ASR still works when
|
||||
/// the polish key is missing — show a soft tip above the mic instead.
|
||||
private func applyAPIKeyAvailability(
|
||||
|
||||
@@ -34,6 +34,7 @@ final class ClipboardCaptureCoordinator {
|
||||
|
||||
private let state: KeyboardState
|
||||
private let history: ClipboardHistoryStore
|
||||
private let semanticRanking: ClipboardSemanticRankingStore
|
||||
private let pasteboard: ClipboardPasteboardProviding
|
||||
private var pollTimer: Timer?
|
||||
private var isSecureProvider: () -> Bool = { false }
|
||||
@@ -48,10 +49,12 @@ final class ClipboardCaptureCoordinator {
|
||||
init(
|
||||
state: KeyboardState,
|
||||
history: ClipboardHistoryStore = .shared,
|
||||
semanticRanking: ClipboardSemanticRankingStore = .shared,
|
||||
pasteboard: ClipboardPasteboardProviding = SystemClipboardPasteboard()
|
||||
) {
|
||||
self.state = state
|
||||
self.history = history
|
||||
self.semanticRanking = semanticRanking
|
||||
self.pasteboard = pasteboard
|
||||
}
|
||||
|
||||
@@ -79,6 +82,9 @@ final class ClipboardCaptureCoordinator {
|
||||
// A suggestion belongs to one keyboard presentation. Clear any
|
||||
// presentation state left behind by a reused extension controller.
|
||||
endCurrentSuggestion()
|
||||
if let newest = history.newestAIHintEligibleEntry() {
|
||||
semanticRanking.analyze(newest)
|
||||
}
|
||||
forcesNextSample = true
|
||||
// Delay the system pasteboard read until the first poll tick. A
|
||||
// Universal Clipboard fetch or paste alert during the appear sequence
|
||||
@@ -94,6 +100,7 @@ final class ClipboardCaptureCoordinator {
|
||||
stopPolling()
|
||||
// A1 policy: closing the keyboard ends this generation's suggestion.
|
||||
endCurrentSuggestion()
|
||||
semanticRanking.clear()
|
||||
}
|
||||
|
||||
func refreshFlagsFromStore() {
|
||||
@@ -102,6 +109,9 @@ final class ClipboardCaptureCoordinator {
|
||||
if !state.clipboardHistoryEnabled || !state.clipboardCandidateBarEnabled {
|
||||
endCurrentSuggestion()
|
||||
}
|
||||
if !state.clipboardHistoryEnabled {
|
||||
semanticRanking.clear()
|
||||
}
|
||||
}
|
||||
|
||||
func secureEntryDidChange(isSecure: Bool) {
|
||||
@@ -117,6 +127,7 @@ final class ClipboardCaptureCoordinator {
|
||||
return
|
||||
}
|
||||
endCurrentSuggestion()
|
||||
semanticRanking.clear()
|
||||
state.clipboardOverlay = .none
|
||||
}
|
||||
|
||||
@@ -153,12 +164,16 @@ final class ClipboardCaptureCoordinator {
|
||||
|
||||
func clearHistory() {
|
||||
endCurrentSuggestion()
|
||||
semanticRanking.clear()
|
||||
history.clearAll()
|
||||
}
|
||||
|
||||
func deleteEntry(id: UUID) {
|
||||
let deletedChangeCount = history.entries.first(where: { $0.id == id })?.changeCount
|
||||
history.remove(id: id)
|
||||
if semanticRanking.snapshot?.entryID == id {
|
||||
semanticRanking.clear()
|
||||
}
|
||||
if deletedChangeCount == state.clipboardSuggestionChangeCount {
|
||||
endCurrentSuggestion()
|
||||
}
|
||||
@@ -233,6 +248,7 @@ final class ClipboardCaptureCoordinator {
|
||||
|
||||
// Prefer hasStrings peek before reading body (reduces empty reads).
|
||||
guard pasteboard.hasStrings else {
|
||||
semanticRanking.clear()
|
||||
history.lastObservedChangeCount = changeCount
|
||||
return
|
||||
}
|
||||
@@ -243,7 +259,9 @@ final class ClipboardCaptureCoordinator {
|
||||
if isCurrentGeneration {
|
||||
return
|
||||
}
|
||||
semanticRanking.clear()
|
||||
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
|
||||
semanticRanking.analyze(entry)
|
||||
updateSuggestion(with: entry, changeCount: changeCount)
|
||||
} else {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
@@ -306,6 +324,7 @@ final class ClipboardCaptureCoordinator {
|
||||
// including generations that contain no acceptable text.
|
||||
clearSuggestion()
|
||||
guard sample.hasStrings else {
|
||||
semanticRanking.clear()
|
||||
history.lastObservedChangeCount = changeCount
|
||||
return
|
||||
}
|
||||
@@ -313,7 +332,9 @@ final class ClipboardCaptureCoordinator {
|
||||
// republish content from an already observed generation.
|
||||
guard !isCurrentGeneration else { return }
|
||||
|
||||
semanticRanking.clear()
|
||||
if let entry = history.ingest(rawText: sample.text, changeCount: changeCount) {
|
||||
semanticRanking.analyze(entry)
|
||||
updateSuggestion(with: entry, changeCount: changeCount)
|
||||
} else {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
|
||||
@@ -92,7 +92,21 @@ final class KeyboardConfigSync {
|
||||
// Keychain fallback: a reboot must not resurrect the mic gate when
|
||||
// App Group transiently reads empty.
|
||||
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
|
||||
state.isOnboardingPracticeActive = KeyboardSetupBridge.isOnboardingPracticeActive
|
||||
let practice = KeyboardSetupBridge.activeOOBEPracticeSession
|
||||
state.oobePracticeSession = practice
|
||||
state.isOnboardingPracticeActive = practice != nil
|
||||
|| KeyboardSetupBridge.isOnboardingPracticeActive
|
||||
switch practice?.expectedFeature {
|
||||
case .voiceInput, .askAI:
|
||||
state.surface = .voice
|
||||
case .clipboardTranslate, .clipboardReply:
|
||||
state.surface = .ai
|
||||
if !state.aiSession.isActive {
|
||||
state.aiSession.enter()
|
||||
}
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func persistLocale(_ id: String) {
|
||||
|
||||
@@ -677,15 +677,22 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
|
||||
func beginAIRecording(
|
||||
conversationID: UUID
|
||||
conversationID: UUID,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil
|
||||
) -> FlowUtteranceStartDisposition {
|
||||
startUtterance(.aiQuestion(conversationID: conversationID))
|
||||
startUtterance(
|
||||
.aiQuestion(
|
||||
conversationID: conversationID,
|
||||
oobeFeature: oobeFeature
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func submitAIQuestion(
|
||||
text: String,
|
||||
conversationID: UUID,
|
||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
thinkingEnabled: Bool? = nil
|
||||
) -> FlowUtteranceStartDisposition {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -695,6 +702,7 @@ final class KeyboardFlowCoordinator {
|
||||
conversationID: conversationID,
|
||||
prefilledQuestion: trimmed,
|
||||
taskKind: taskKind,
|
||||
oobeFeature: oobeFeature,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
)
|
||||
@@ -1181,8 +1189,14 @@ final class KeyboardFlowCoordinator {
|
||||
let mode: FlowUtteranceMode? = request.mode == .dictation
|
||||
? nil
|
||||
: request.mode
|
||||
let implicitOOBEFeature: ManagedGatewayOOBEFeature? =
|
||||
state.oobePracticeSession?.expectedFeature == .voiceInput
|
||||
&& request.mode == .dictation
|
||||
? .voiceInput
|
||||
: nil
|
||||
let managedOOBEFeature = request.managedOOBEFeature ?? implicitOOBEFeature
|
||||
let managedRequestPurpose = request.managedRequestPurpose
|
||||
?? (state.isOnboardingPracticeActive && request.mode == .dictation ? .oobe : nil)
|
||||
?? (managedOOBEFeature == nil ? nil : .oobe)
|
||||
let command = FlowCommand(
|
||||
sessionId: activeSessionId,
|
||||
utteranceId: currentUtteranceId,
|
||||
@@ -1201,6 +1215,7 @@ final class KeyboardFlowCoordinator {
|
||||
aiConversationID: request.aiConversationID,
|
||||
aiTaskKind: request.aiTaskKind,
|
||||
managedRequestPurpose: managedRequestPurpose,
|
||||
managedOOBEFeature: managedOOBEFeature,
|
||||
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
|
||||
processingDeadlineAt: action == .stopRecording && request.isEdit
|
||||
? Date().timeIntervalSince1970
|
||||
@@ -1909,6 +1924,8 @@ final class KeyboardFlowCoordinator {
|
||||
aiConversationID: currentUtteranceRequest?.aiConversationID,
|
||||
aiQuestionText: text,
|
||||
aiTaskKind: currentUtteranceRequest?.aiTaskKind,
|
||||
managedRequestPurpose: currentUtteranceRequest?.managedRequestPurpose,
|
||||
managedOOBEFeature: currentUtteranceRequest?.managedOOBEFeature,
|
||||
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
|
||||
startDeadlineAt: currentStartDeadlineAt
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ final class KeyboardTextInserter {
|
||||
private static let caretVerificationLimit = 80
|
||||
|
||||
private let state: KeyboardState
|
||||
private let insertText: (String) -> Void
|
||||
private let insertText: (String, KeyboardTextInsertionSource) -> Void
|
||||
private let deleteBackward: () -> Void
|
||||
private let contextBeforeInput: () -> String?
|
||||
private let fieldContextProvider: () -> FlowFieldContext?
|
||||
@@ -41,7 +41,7 @@ final class KeyboardTextInserter {
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
insertText: @escaping (String) -> Void,
|
||||
insertText: @escaping (String, KeyboardTextInsertionSource) -> Void,
|
||||
deleteBackward: @escaping () -> Void,
|
||||
contextBeforeInput: @escaping () -> String?,
|
||||
fieldContextProvider: @escaping () -> FlowFieldContext?,
|
||||
@@ -87,8 +87,16 @@ final class KeyboardTextInserter {
|
||||
insertion: trimmed
|
||||
)
|
||||
let inserted = separator + trimmed
|
||||
insertText(inserted)
|
||||
insertText(inserted, .voiceTranscription)
|
||||
KeyboardSetupBridge.markVoiceInsertion()
|
||||
if delivery.polishWarning == nil,
|
||||
let practice = state.oobePracticeSession,
|
||||
practice.expectedFeature == .voiceInput {
|
||||
_ = KeyboardSetupBridge.markOOBEPracticeCompleted(
|
||||
sessionID: practice.sessionID,
|
||||
feature: .voiceInput
|
||||
)
|
||||
}
|
||||
state.noteUserDidInputText()
|
||||
recordLastInsertion(
|
||||
inserted,
|
||||
@@ -120,7 +128,7 @@ final class KeyboardTextInserter {
|
||||
insertion: trimmed
|
||||
)
|
||||
let inserted = separator + trimmed
|
||||
insertText(inserted)
|
||||
insertText(inserted, .aiGenerated)
|
||||
state.noteUserDidInputText()
|
||||
|
||||
let mutation = HistoryMutation(
|
||||
@@ -152,7 +160,7 @@ final class KeyboardTextInserter {
|
||||
guard !text.isEmpty else { return }
|
||||
// Verbatim on purpose — paste must reproduce exactly what was copied,
|
||||
// unlike dictation which needs word-boundary hygiene.
|
||||
insertText(text)
|
||||
insertText(text, .pasteboard)
|
||||
recordUndoableInsertion(text)
|
||||
// The paste pushed any previous input away from the caret, so the
|
||||
// "editable last input" hint no longer applies.
|
||||
@@ -207,7 +215,7 @@ final class KeyboardTextInserter {
|
||||
state.redoAvailable = false
|
||||
return
|
||||
}
|
||||
insertText(text)
|
||||
insertText(text, .redo)
|
||||
recordUndoableInsertion(text)
|
||||
OSGLog.keyboardExt.info("redo length=\(text.count, privacy: .public)")
|
||||
}
|
||||
@@ -375,7 +383,7 @@ final class KeyboardTextInserter {
|
||||
let inserted = separator + result
|
||||
transaction.appliedInsertedText = inserted
|
||||
PendingTextEditTransactionStore.save(transaction)
|
||||
insertText(inserted)
|
||||
insertText(inserted, .editGenerated)
|
||||
let verificationSuffix = String(inserted.suffix(80))
|
||||
guard contextBeforeInput()?.hasSuffix(verificationSuffix) == true else {
|
||||
// Never blindly delete after a partial/opaque host insertion. The
|
||||
@@ -448,7 +456,7 @@ final class KeyboardTextInserter {
|
||||
|
||||
switch transaction.deliveryMode {
|
||||
case .replace:
|
||||
insertText(transaction.beforeText)
|
||||
insertText(transaction.beforeText, .editGenerated)
|
||||
let restore = HistoryMutation(
|
||||
action: transaction.historyMutation.action == .append ? .delete : .restore,
|
||||
entryID: transaction.historyMutation.entryID,
|
||||
|
||||
Reference in New Issue
Block a user