feat(app): add crash visibility, memory relief, and clipboard reply work

Squashes the working tree that had accumulated on cloud-agent. Two
strands of work, kept in one commit at the maintainer's request.

Crash and stability (this session):

- Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics
  are recorded on device. Keyboard-extension memory kills are the most
  likely way a user loses the keyboard mid-sentence and iOS does not
  classify them as crashes, so they never reach App Store Connect.
- Upload those reports to POST /v1/analytics/diagnostics from internal
  builds only (local Debug and TestFlight), reusing the analytics
  installation identifier and skipping entirely when analytics are off.
  App Store builds keep everything on device.
- Shed the Chinese engine, English lexicon, and in-flight pipeline work
  at the 40 MiB and 48 MiB budget thresholds instead of waiting for a
  system memory warning that jetsam often preempts.
- Install audio taps with the input node's live format through one
  shared adaptive resampler, so an audio-route change mid-recording no
  longer raises an uncatchable format-mismatch exception. Covers the
  dictation preview and the Mac recorder, which still used the pattern
  the Flow capture path had already moved away from.
- Harden the librime bridge: never pass a nil string across a nonnull
  boundary when a user dictionary holds malformed UTF-8, null-check the
  engine API vector, and distrust a candidate count without its array.
- Guard the expanded candidate grid against a list that shrinks while
  the collection view is still laying out against the previous count.
- Resolve localized text without the App Group so the screen shown when
  that container is missing renders its message instead of crashing on
  launch, which is the one situation it exists to handle.
- Gate CI on the App Group and Keychain wiring that the keyboard
  extension hard-depends on, with a self-test proving the check fails.

Clipboard, reply, and semantics (pre-existing working tree):

- Unify the reply center, refresh keyboard AI features, and add native
  event and reminder exporters.
- Expand clipboard semantic routing with authored corpora and a
  product-blind holdout, plus the scripts that build and validate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 17:31:29 -07:00
co-authored by Claude Opus 5
parent 628e983794
commit 0023741455
210 changed files with 14731 additions and 2940 deletions
+92 -5
View File
@@ -47,6 +47,10 @@ public final class KeyboardViewController: UIInputViewController {
return created
}
private let persistor = AppGroupPersistor()
/// Uptime of the most recent key-driven document change. Memory relief uses
/// it to tell "the user is mid-sentence" from "the typing surface is merely
/// on screen" — see `isMidTypingBurst`.
private var lastTypingActivityUptime: TimeInterval = -1
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
private var keyboardHeightConstraint: NSLayoutConstraint?
@@ -199,6 +203,11 @@ public final class KeyboardViewController: UIInputViewController {
)
configSync.installDarwinObservers()
flowCoordinator.refreshSessionState()
// Installed last: the handler tears down surfaces, so it must not be
// reachable until the whole controller is wired up.
KeyboardExtensionMemoryTelemetry.reliefHandler = { [weak self] level in
self?.shedHeavyResources(level: level, reason: "budget") ?? false
}
recordMemory(
"KVC.viewDidLoad.done",
details: "sessionActive=\(FlowSessionBridge.isSessionActive() ? 1 : 0) "
@@ -234,6 +243,8 @@ public final class KeyboardViewController: UIInputViewController {
details: "preserve=\(flowCoordinator.preservesLifecycleOnDisappear ? 1 : 0)"
)
heightPhase = .idle
// A hidden keyboard cannot grow; stop waking the process to sample it.
KeyboardExtensionMemoryTelemetry.stopSampling()
flowCoordinator.stopSessionMonitor()
// Remember what the user left on, then pre-position a reused
// extension instance for the next open policy (no first-frame jump).
@@ -295,6 +306,10 @@ public final class KeyboardViewController: UIInputViewController {
}
clipboardCapture.keyboardDidAppear()
recordMemory("KVC.viewWillAppear.afterClipboard")
// Re-presented keyboards reuse the same process, so resume pressure
// polling here. No-ops while the first-launch boot burst is still
// running, so the startup samples are never cut short.
KeyboardExtensionMemoryTelemetry.startSustainedSamplingIfIdle()
recordMemory("KVC.viewWillAppear.done")
OSGDiag.log(
"KVC.viewWillAppear done surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
@@ -405,12 +420,79 @@ public final class KeyboardViewController: UIInputViewController {
"KVC.didReceiveMemoryWarning surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
flowCoordinator.cancelPipelineUnlessAwaitingResult()
if state.surface == .typing {
applySurface(.voice)
} else {
deactivateTypingSurface()
// The system signal is not an advance warning — jetsam may be moments
// away — so this path sheds even mid-composition.
_ = shedHeavyResources(level: .critical, reason: "systemWarning", force: true)
}
/// Whether the user is actively composing right now. A non-empty preedit is
/// an unfinished Pinyin syllable, and a keystroke within the last window
/// means a burst is in progress even between syllables.
private var isMidTypingBurst: Bool {
if let session = typingSessionStorage, !session.composition.preedit.isEmpty {
return true
}
guard lastTypingActivityUptime >= 0 else { return false }
return ProcessInfo.processInfo.systemUptime - lastTypingActivityUptime
< Self.typingBurstWindow
}
/// How long after a keystroke the keyboard still counts as "being typed on".
private static let typingBurstWindow: TimeInterval = 1.5
/// Releases the keyboard's heavy caches (Rime engine, English lexicon,
/// in-flight pipeline work).
///
/// Two callers: the system memory warning above, and — crucially, *earlier*
/// — `KeyboardExtensionMemoryTelemetry.reliefHandler`. Jetsam kills
/// keyboards at the ~60 MiB boundary without reliably delivering a warning
/// first, so waiting for the system signal means the keyboard simply
/// vanishes mid-sentence. Our own 40 / 48 MiB thresholds fire while there
/// is still headroom to recover.
/// Returns whether relief actually happened, so a declined attempt keeps the
/// level armed for the next poll instead of counting as a completed shed.
@discardableResult
private func shedHeavyResources(
level: KeyboardExtensionMemoryBudget.Level,
reason: String,
force: Bool = false
) -> Bool {
// Relief can be requested from the boot sampler before `installServices`
// has run; nothing below is safe to touch until the view is loaded.
guard isViewLoaded, flowCoordinator != nil else { return false }
OSGDiag.log(
"KVC.shed level=\(level.rawValue) reason=\(reason) "
+ "surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "memory"
)
flowCoordinator.cancelPipelineUnlessAwaitingResult()
guard level == .critical else {
// `.high`: release only what the user is not looking at. Yanking an
// active typing surface at 40 MiB would be more disruptive than the
// risk it avoids.
if state.surface != .typing {
deactivateTypingSurface()
}
recordMemory("KVC.shed.soft", details: "reason=\(reason)")
return true
}
guard state.surface == .typing else {
deactivateTypingSurface()
recordMemory("KVC.shed.hard", details: "reason=\(reason)")
return true
}
// Swapping the surface out from under someone mid-syllable loses the
// composition and drops them into voice mode with no explanation. A
// jetsam kill costs the same composition but reads as "the keyboard
// crashed", which is the lesser of the two for the user's trust in the
// keyboard — and the poll comes back in a second to try again.
guard force || !isMidTypingBurst else {
recordMemory("KVC.shed.deferred", details: "reason=\(reason) cause=typing")
return false
}
applySurface(.voice)
recordMemory("KVC.shed.hard", details: "reason=\(reason)")
return true
}
public override func viewDidLayoutSubviews() {
@@ -623,6 +705,9 @@ public final class KeyboardViewController: UIInputViewController {
SettingsDeepLink.setPending(.clipboard)
self?.openHostApp(path: "settings/clipboard")
}
state.tryAutoReplyFromGuide = { [weak self] in
self?.clipboardCapture.confirmAutoReplyGuide()
}
state.openClipboardPanel = { [weak self] in
self?.clipboardCapture.openPanelFromTopButton()
}
@@ -887,6 +972,7 @@ public final class KeyboardViewController: UIInputViewController {
source: KeyboardTextInsertionSource
) {
guard !text.isEmpty else { return }
lastTypingActivityUptime = ProcessInfo.processInfo.systemUptime
textDocumentProxy.insertText(text)
if source.contributesToKeyboardUsage {
let counts = KeyboardUsageCharacterClassifier.classify(text)
@@ -902,6 +988,7 @@ public final class KeyboardViewController: UIInputViewController {
}
private func deleteBackwardFromDocument() {
lastTypingActivityUptime = ProcessInfo.processInfo.systemUptime
textDocumentProxy.deleteBackward()
refreshAssistantFieldAction()
scheduleAssistantFieldActionRefresh()
@@ -88,6 +88,10 @@ final class AIKeyboardCoordinator {
pendingStructuredReplyResult = false
conversationInsertionFingerprint = nil
hasConversationInsertionTarget = false
// The reply session is gone; drop any pending auto-reply surface restore
// so the next presentation's surface is chosen normally.
state.autoReplyReturnSurface = nil
state.autoResultReadOnly = false
}
func toggleMicrophone() {
@@ -116,12 +120,19 @@ final class AIKeyboardCoordinator {
}
}
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
/// Tap a clipboard skill capsule: same fail-closed material path as hint cards.
func submitClipboardSkill(
_ skill: AIClipboardSkill,
replyScene: AIClipboardReplyScene? = nil
) {
guard canAcceptIdleSubmit else { return }
// Block only while a request is actively running. A result that is merely
// displayed (.ready) is replaceable: the user tapped another skill capsule
// to switch what the AI does with this copy, so clear it and continue.
guard !state.aiSession.isBusy else { return }
if state.aiSession.phase == .ready {
discardPendingReplyFeedback()
state.aiSession.discardReadyAnswer()
}
guard !skill.requiresShortcut
|| state.confirmedClipboardShortcutIDs.contains(skill.id) else {
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
@@ -167,6 +178,9 @@ final class AIKeyboardCoordinator {
}
prepareConversationForRequest()
// Remember which skill this result came from so the result panel can
// offer the *other* applicable skills without repeating this one.
state.activeClipboardSkillID = skill.id
requestReplyFeedbackSource = skill.id == AIClipboardSkillCatalog.replyID
&& oobeFeature == nil
? material
@@ -198,15 +212,35 @@ final class AIKeyboardCoordinator {
if skill.kind == .export {
instruction += "\nPreserve the source language, addresses, names, and proper nouns."
}
// Recent same-chat context: only for a real reply, only when we have the
// incoming message, and only from exchanges inside the 5-minute window.
// iOS gives a keyboard no source-app identity, so time proximity is the
// approximation of "the same conversation".
let conversationContext: String? = {
guard skill.id == AIClipboardSkillCatalog.replyID,
oobeFeature == nil,
let material else { return nil }
let turns = replyFeedbackStore.recentConversationTurns(
excludingIncoming: material
)
return replyConversationContext(from: turns)
}()
if conversationContext != nil {
instruction += "\n" + Self.replyConversationContextGuide
}
AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
if let material {
AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
} else {
AIAgentShortcutRun.trace("keyboard.clipboard missing")
}
if conversationContext != nil {
AIAgentShortcutRun.trace("keyboard.replyContext attached")
}
let resolution = AIClipboardPrompt.resolve(
instruction: instruction,
material: material
material: material,
conversationContext: conversationContext
)
if case .materialUnavailable = resolution {
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
@@ -337,6 +371,7 @@ final class AIKeyboardCoordinator {
conversationInsertionFingerprint = captureInsertionFingerprint()
hasConversationInsertionTarget = true
resetStructuredReplyConversationIfNeeded()
restoreAutoReplySurfaceIfNeeded()
}
}
@@ -353,6 +388,7 @@ final class AIKeyboardCoordinator {
conversationInsertionFingerprint = captureInsertionFingerprint()
hasConversationInsertionTarget = true
resetStructuredReplyConversationIfNeeded()
restoreAutoReplySurfaceIfNeeded()
}
func discardPendingAnswer() {
@@ -361,6 +397,28 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = nil
requestOOBEFeature = nil
resetStructuredReplyConversationIfNeeded()
// Tapping the result's X means the user is done with this clipboard
// generation — retire the whole assistant affordance for it. This routes
// through the shared dismiss so it both marks the skill top bar dismissed
// (`dismissedClipboardSkillEntryID`) AND clears the standalone paste
// suggestion (`clipboardSuggestionText`). Setting only the skill marker
// left the paste capsule + X alive on the surface we return to below,
// because the two are independent state.
state.dismissClipboardSuggestion()
restoreAutoReplySurfaceIfNeeded()
}
/// If auto mode moved the user onto the assistant surface to show a reply,
/// finishing it — using a reply or closing — returns them to the keyboard
/// they were last using instead of leaving them on voice input.
private func restoreAutoReplySurfaceIfNeeded() {
// A read-only auto result (auto-translate) is finished once dismissed.
state.autoResultReadOnly = false
// The result panel is closing, so its "other skills" row retires too.
state.activeClipboardSkillID = nil
guard let surface = state.autoReplyReturnSurface else { return }
state.autoReplyReturnSurface = nil
state.setSurface(surface)
}
func performCurrentFieldAction() {
@@ -482,6 +540,12 @@ final class AIKeyboardCoordinator {
beginReplyFeedback(answer: answer)
}
defer { requestInsertionFingerprint = nil }
// Auto-translate is staged like a reply: show the result on the keyboard
// and let the user drop it into the field with the glass Insert button,
// never straight into the host field.
if state.autoResultReadOnly {
return
}
guard state.aiSession.canInsert,
let expected = requestInsertionFingerprint,
captureInsertionFingerprint() == expected,
@@ -530,6 +594,9 @@ final class AIKeyboardCoordinator {
}
private func prepareConversationForRequest() {
// Any fresh request drops the previous result's "active skill" marker;
// submitClipboardSkill re-sets it right after calling this.
state.activeClipboardSkillID = nil
requestExpectsReplyVariants = false
requestReplyVariantSet = .generic
requestReplySourceText = nil
@@ -548,6 +615,33 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = currentFingerprint
}
/// Tells the model how to read the untrusted `<conversation_context>` block.
/// English to match the other inline directives in this file.
private static let replyConversationContextGuide =
"A <conversation_context> block precedes the clipboard text: it is the "
+ "earlier back-and-forth in this same chat, where \"Them:\" is the "
+ "other person and \"You:\" is the user. Use it only as background to "
+ "keep the reply consistent and on-topic; reply to the latest message "
+ "in clipboard_text, and never quote or answer the context lines "
+ "themselves."
/// Formats recent turns into the plain `Them:/You:` transcript embedded in
/// the prompt. Each line is length-capped so a long history can't crowd out
/// the message being replied to. Returns nil when there is nothing to add.
private func replyConversationContext(
from turns: [ClipboardReplyFeedbackStore.ConversationTurn]
) -> String? {
guard !turns.isEmpty else { return nil }
let cap = 500
let lines = turns.flatMap { turn -> [String] in
[
"Them: " + String(turn.incoming.prefix(cap)),
"You: " + String(turn.reply.prefix(cap))
]
}
return lines.joined(separator: "\n")
}
private func beginReplyFeedback(variants: [AIReplyVariant]) {
guard let sourceText = requestReplyFeedbackSource else { return }
requestReplyFeedbackSource = nil
@@ -660,12 +754,19 @@ final class AIKeyboardCoordinator {
clarificationQuestion: ask one essential missing question.
clarificationConfirm: briefly confirm understanding, then ask the key question.
"""
case .yesNoQuestion:
roleGuidance = """
answerAffirmative: answer yes / it works, only when the source supports it.
answerNegative: answer no / it does not, when the source implies a problem or blocker.
answerConditional: answer that it depends, stating the specific condition.
answerDefer: say you need to confirm first, without inventing when.
"""
}
return """
MULTI-REPLY OUTPUT CONTRACT (highest priority):
Return only one valid JSON object with exactly this shape and no Markdown fence or extra keys:
{"variants":[\(items)]}
Include exactly these three kinds in the shown order. Every text must be a complete reply in the source language.
Include exactly these \(variantSet.kinds.count) kinds in the shown order. Every text must be a complete reply in the source language.
\(roleGuidance)
Every item must advance the conversation with a reaction, answer, question, decision, or next step. Never restate, paraphrase, summarize, or synonymically rewrite the clipboard text. In particular, do not begin a reply by repeating the source's subject and event. For a declarative update, react to its implication or emotion instead of reporting the update back to its sender.
Apply any <reply_scene> constraint to every item. It overrides the kind-specific tone guidance below when they conflict.
@@ -786,6 +887,10 @@ final class AIKeyboardCoordinator {
switch skillID {
case AIClipboardSkillCatalog.navigateID:
tipKey = "keyboard.ai.skill.openingMaps"
case AIClipboardSkillCatalog.extractTodosID:
tipKey = "keyboard.ai.skill.savingReminders"
case AIClipboardSkillCatalog.extractEventsID:
tipKey = "keyboard.ai.skill.savingEvents"
default:
tipKey = "keyboard.ai.skill.runningShortcut"
}
+31 -10
View File
@@ -42,6 +42,9 @@ public struct AppGroupPersistor {
state.handednessPreference = store.handednessPreference
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
state.clipboardAutoModeEnabled = store.clipboardAutoModeEnabled
state.clipboardAutoTranslateEnabled = store.clipboardAutoTranslateEnabled
state.clipboardAutoEmailReplyEnabled = store.clipboardAutoEmailReplyEnabled
applySkillSnapshot(store: store, into: state)
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
@@ -98,6 +101,9 @@ public struct AppGroupPersistor {
state.handednessPreference = store.handednessPreference
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
state.clipboardAutoModeEnabled = store.clipboardAutoModeEnabled
state.clipboardAutoTranslateEnabled = store.clipboardAutoTranslateEnabled
state.clipboardAutoEmailReplyEnabled = store.clipboardAutoEmailReplyEnabled
applySkillSnapshot(store: store, into: state)
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
@@ -112,24 +118,39 @@ public struct AppGroupPersistor {
let layout = store.agentSkillLayout
let language = store.uiLanguage
state.uiLanguage = language
state.enabledClipboardSkillIDs = layout.enabledIDs
let personalReplyStyle = store.personalReplyStyle
let hasPersonalReplyStyle = personalReplyStyle != nil
let enabledIDs = AIClipboardSkillCatalog.availableEnabledIDs(
layout.enabledIDs,
layout: layout,
hasPersonalReplyStyle: hasPersonalReplyStyle
)
state.enabledClipboardSkillIDs = enabledIDs
state.confirmedClipboardShortcutIDs = layout.confirmedShortcutIDs
state.enabledClipboardSkills = AIClipboardSkillCatalog.visible(
enabledIDs: layout.enabledIDs,
enabledIDs: enabledIDs,
officialCatalog: store.officialSkillCatalog,
userCatalog: store.agentUserSkillCatalog,
uiLanguage: language
)
state.clipboardSkillCatalog = AIClipboardSkillCatalog.all(
officialCatalog: store.officialSkillCatalog,
userCatalog: store.agentUserSkillCatalog,
uiLanguage: language
// The keyboard's chips are ranked from this full catalog, not from the
// enabled list, so an unavailable skill has to be removed here or it
// still reaches the surface (and `AIKeyboardCoordinator` could run it).
state.clipboardSkillCatalog = AIClipboardSkillCatalog.availableForKeyboard(
AIClipboardSkillCatalog.all(
officialCatalog: store.officialSkillCatalog,
userCatalog: store.agentUserSkillCatalog,
uiLanguage: language
),
layout: layout,
hasPersonalReplyStyle: hasPersonalReplyStyle
)
let activeStyle = store.activePolishStyle
// Built-in formal/corporate personalities must not make ordinary
// clipboard replies sound less like the user.
// Independent of the voice-polish selection: the Styles page shapes
// dictation, this shapes AI replies. Sharing one selector previously
// made "clean dictation" and "replies that sound like me" mutually
// exclusive.
state.clipboardReplyStyle = AIClipboardReplyStyleContext.resolve(
activeStyle: activeStyle
personalReplyStyle: personalReplyStyle
)
}
@@ -4,6 +4,7 @@
// Samples the general pasteboard on keyboard appear and while visible
// (changeCount-driven). Writes accepted text into ClipboardHistoryStore.
import Combine
import Foundation
import OSGKeyboardShared
import UIKit
@@ -45,6 +46,14 @@ final class ClipboardCaptureCoordinator {
private var isSampling = false
private var forcesNextSample = true
private var isKeyboardVisible = false
/// OOBE reply / translate practice auto-fires once per feature so the user
/// sees the keyboard draft the result without tapping the chip. Tracked per
/// feature (the host keeps one session id across every practice step).
private var oobeAutoFiredSessionID: UUID?
private var oobeAutoFiredFeatures: Set<ManagedGatewayOOBEFeature> = []
/// Fires the auto-reply evaluation whenever a fresh semantic analysis lands,
/// independent of which keyboard surface (voice / Chinese / English) is shown.
private var snapshotObserver: AnyCancellable?
init(
state: KeyboardState,
@@ -56,6 +65,17 @@ final class ClipboardCaptureCoordinator {
self.history = history
self.semanticRanking = semanticRanking
self.pasteboard = pasteboard
snapshotObserver = semanticRanking.$snapshot
.sink { [weak self] snapshot in
// `@Published` fires in willSet, so the store's property is not
// updated yet; hop to the main actor so the trigger reads the
// published snapshot (and satisfies actor isolation).
guard snapshot != nil else { return }
Task { @MainActor in
self?.autoTriggerActionIfNeeded()
self?.presentAutoReplyGuideIfNeeded()
}
}
}
func configure(
@@ -86,6 +106,7 @@ final class ClipboardCaptureCoordinator {
semanticRanking.analyze(newest)
}
forcesNextSample = true
autoTriggerOOBESkillIfNeeded()
// Delay the system pasteboard read until the first poll tick. A
// Universal Clipboard fetch or paste alert during the appear sequence
// can otherwise freeze the keyboard before SwiftUI draws.
@@ -104,12 +125,10 @@ final class ClipboardCaptureCoordinator {
}
func refreshFlagsFromStore() {
// Settings changes may hide the active suggestion, but enabling the
// strip must wait for a new pasteboard generation.
if !state.clipboardHistoryEnabled || !state.clipboardCandidateBarEnabled {
endCurrentSuggestion()
}
// Paste is available whenever clipboard history is on; only turning
// history off hides it (the old candidate-bar toggle is gone).
if !state.clipboardHistoryEnabled {
endCurrentSuggestion()
semanticRanking.clear()
}
}
@@ -150,6 +169,11 @@ final class ClipboardCaptureCoordinator {
}
func dismissSuggestion() {
// Dismissing from either surface must suppress both the paste capsule
// AND the AI skill top bar for this clipboard generation. The typing
// surface's X only routes here, so set the skill marker centrally
// rather than relying on the AI surface's own dismiss handler.
state.dismissedClipboardSkillEntryID = history.newestEntry?.id
endCurrentSuggestion()
}
@@ -186,6 +210,7 @@ final class ClipboardCaptureCoordinator {
let timer = Timer(timeInterval: Self.pollInterval, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.captureIfNeeded()
self?.autoTriggerOOBESkillIfNeeded()
}
}
timer.tolerance = Self.pollInterval / 4
@@ -342,8 +367,9 @@ final class ClipboardCaptureCoordinator {
}
private func updateSuggestion(with entry: ClipboardHistoryEntry, changeCount: Int) {
// The one-tap Paste capsule is always on (its old opt-in toggle was
// removed), so this only depends on clipboard history + a secure field.
guard state.canShowClipboardEntry,
state.clipboardCandidateBarEnabled,
changeCount != secureFieldSuppressedChangeCount
else {
clearSuggestion()
@@ -372,4 +398,273 @@ final class ClipboardCaptureCoordinator {
state.clipboardSuggestionText = nil
state.clipboardSuggestionChangeCount = nil
}
// MARK: - Auto mode
/// Auto mode: on every fresh semantic analysis, pick at most one automatic
/// action for the newest clipboard and route it into the assistant surface
/// with no tap. Surface-independent (voice / Chinese / English) and fired at
/// most once per pasteboard generation, persisted so a reopen never repeats.
///
/// Precedence when several toggles apply to the same paste:
/// email reply (a detected email should be answered, not just translated —
/// even in another language) → translate (foreign, non-email) → generic reply.
private func autoTriggerActionIfNeeded() {
guard state.clipboardHistoryEnabled,
state.aiServiceAvailable,
state.oobePracticeSession == nil,
isKeyboardVisible,
isRestingForAutoReply else {
return
}
guard state.clipboardAutoModeEnabled
|| state.clipboardAutoTranslateEnabled
|| state.clipboardAutoEmailReplyEnabled else {
return
}
guard let newest = history.newestEntry,
let changeCount = newest.changeCount else {
return
}
// One auto action per copy, even across keyboard close/reopen.
guard history.lastAutoRepliedChangeCount != changeCount else { return }
guard AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: newest
) else {
return
}
guard let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return
}
let catalog = state.clipboardSkillCatalog
// 1. Auto-draft a single, email-formatted reply for a detected email.
// Checked before translate so a foreign-language email is answered
// rather than merely translated. Staged like auto-translate: shown on
// the keyboard, inserted only on the glass button.
if state.clipboardAutoEmailReplyEnabled,
ClipboardEmailDetector.isEmail(newest.text) {
fireAutoAction(
AIClipboardSkillCatalog.emailReplySkill,
scene: nil,
changeCount: changeCount,
readOnlyResult: true,
insertLabelKey: "keyboard.assistant.insertReply"
)
return
}
// 2. Auto-translate a non-system-language, non-email paste (staged).
if state.clipboardAutoTranslateEnabled,
ClipboardSkillSemanticRanker.isForeignLanguage(snapshot.analysis),
let translate = catalog.first(where: { $0.id == AIClipboardSkillCatalog.translateID }) {
fireAutoAction(translate, scene: nil, changeCount: changeCount, readOnlyResult: true)
return
}
// 3. Auto-reply for any interpersonal message worth answering — a task,
// question, invitation, complaint, follow-up, or an explicit "please
// reply". Gated on reply intent (not strict #1 ranking) so a message
// that also scores Events / Todos / Summary still auto-replies. Bare
// links / phones / foreign text keep their own actions.
if state.clipboardAutoModeEnabled,
ClipboardSkillSemanticRanker.isAutoReplyEligible(
sourceText: newest.text,
analysis: snapshot.analysis
),
let reply = catalog.first(where: { $0.id == AIClipboardSkillCatalog.replyID }) {
fireAutoAction(
reply,
scene: AIClipboardReplyScene.resolve(
from: snapshot.analysis,
sourceText: newest.text
),
changeCount: changeCount,
readOnlyResult: false
)
}
}
/// Submit the chosen auto action, remembering the keyboard to return to and
/// taking over the assistant surface so the result is visible.
private func fireAutoAction(
_ skill: AIClipboardSkill,
scene: AIClipboardReplyScene?,
changeCount: Int,
readOnlyResult: Bool,
insertLabelKey: String = "keyboard.assistant.insertTranslation"
) {
// Mark handled before submitting so re-entrant publishes cannot double-fire.
history.lastAutoRepliedChangeCount = changeCount
// Remember the keyboard to return to once the result is used or closed:
// the surface the user is actively on, or — when the keyboard opened
// straight onto voice — the one they last left, so finishing never
// strands them on voice input. nil means "already where we'd land".
let returnSurface: KeyboardState.Surface = state.surface != .voice
? state.surface
: TypingInputConfiguration.lastLeftSurface()
state.autoReplyReturnSurface = returnSurface == .voice ? nil : returnSurface
// Staged auto results (translate / email reply) wait for a glass-button
// tap; generic replies use their own variant surface.
state.autoResultReadOnly = readOnlyResult
state.autoResultInsertLabelKey = insertLabelKey
// The result UI only renders on the assistant surface, so take it over.
if state.surface != .voice {
state.setSurface(.voice)
}
state.submitAIClipboardSkill(skill, scene)
}
// MARK: - Auto-reply guide (one-time nudge)
/// Persisted "the user has already responded to the nudge once" flag. Stored
/// in the extension's own defaults; the guide is a keyboard-local affordance.
private static let autoReplyGuideTriedKey = "keyboard.assistant.autoReplyGuidanceTried"
private static var autoReplyGuideTried: Bool {
get { UserDefaults.standard.bool(forKey: autoReplyGuideTriedKey) }
set { UserDefaults.standard.set(newValue, forKey: autoReplyGuideTriedKey) }
}
/// Only auto-present once per pasteboard generation, so dismissing the layer
/// does not immediately resurface it for the same copy.
private var lastGuidedChangeCount: Int?
/// For users who never turned auto mode on: when a freshly copied message
/// reads as replyable, raise the clean full-keyboard guide instead of firing
/// a reply. Shown on every replyable copy until the user acts on it once.
private func presentAutoReplyGuideIfNeeded() {
guard !state.clipboardAutoModeEnabled,
!Self.autoReplyGuideTried,
state.clipboardHistoryEnabled,
state.aiServiceAvailable,
state.oobePracticeSession == nil,
state.canShowClipboardEntry,
state.clipboardOverlay == .none,
isKeyboardVisible,
isRestingForAutoReply else {
return
}
guard let newest = history.newestEntry,
let changeCount = newest.changeCount,
lastGuidedChangeCount != changeCount else {
return
}
guard AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: newest
) else {
return
}
guard let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return
}
let recommended = ClipboardSkillSemanticRanker.recommended(
skills: state.clipboardSkillCatalog,
sourceText: newest.text,
analysis: snapshot.analysis,
uiLanguage: state.uiLanguage,
limit: 5
)
guard recommended.first?.id == AIClipboardSkillCatalog.replyID else { return }
lastGuidedChangeCount = changeCount
state.clipboardOverlay = .autoReplyGuide
}
/// The guide's primary action: opt into auto mode (persisted so it sticks),
/// close the layer, and draft the reply for the copy that prompted it — the
/// same submission every future auto reply now makes on its own.
func confirmAutoReplyGuide() {
Self.autoReplyGuideTried = true
AppGroupStore().setClipboardAutoModeEnabled(true)
state.clipboardAutoModeEnabled = true
state.clipboardOverlay = .none
guard let newest = history.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id,
let reply = state.clipboardSkillCatalog.first(where: {
$0.id == AIClipboardSkillCatalog.replyID
}) else {
return
}
let scene = AIClipboardReplyScene.resolve(
from: snapshot.analysis,
sourceText: newest.text
)
fireAutoAction(
reply,
scene: scene,
changeCount: newest.changeCount ?? history.lastObservedChangeCount,
readOnlyResult: false
)
}
/// OOBE reply / translate practice: once the host has seeded the demo
/// message, run the skill automatically — the same submission the chip makes
/// — so the user experiences the auto skill before any setting is turned on.
/// Runs off the poll tick (OOBE material is not semantic-ranked), and fires
/// at most once per practice feature within the host session. The translate
/// result is staged (glass Insert button) exactly like the live auto flow.
private func autoTriggerOOBESkillIfNeeded() {
guard let session = state.oobePracticeSession,
state.aiServiceAvailable,
isKeyboardVisible,
isRestingForAutoReply else {
return
}
let feature = session.expectedFeature
let skillID: String
switch feature {
case .clipboardReply:
skillID = AIClipboardSkillCatalog.replyID
case .clipboardTranslate:
skillID = AIClipboardSkillCatalog.translateID
case .voiceInput, .askAI:
return
}
// The host keeps one session id across steps, so gate per feature.
if oobeAutoFiredSessionID != session.sessionID {
oobeAutoFiredSessionID = session.sessionID
oobeAutoFiredFeatures = []
}
guard !oobeAutoFiredFeatures.contains(feature) else { return }
guard KeyboardSetupBridge.oobeClipboardMaterial(
sessionID: session.sessionID
) != nil else {
return
}
guard let skill = state.clipboardSkillCatalog.first(where: {
$0.id == skillID
}) else {
return
}
// Mark handled before submitting so a re-entrant tick cannot double-fire.
oobeAutoFiredFeatures.insert(feature)
// Stage the translation like the live auto-translate: shown on the
// keyboard, inserted only when the user taps the glass button.
if feature == .clipboardTranslate {
state.autoResultReadOnly = true
state.autoResultInsertLabelKey = "keyboard.assistant.insertTranslation"
}
// The result UI only renders on the assistant surface, so take it over.
if state.surface != .voice {
state.setSurface(.voice)
}
state.submitAIClipboardSkill(skill, nil)
}
/// The voice pipeline must be idle before auto mode takes over the surface.
private var isRestingForAutoReply: Bool {
guard !state.aiSession.isBusy,
!state.aiSession.canInsert,
!state.aiSession.canSelectReplyVariant,
!state.editSession.isActive else {
return false
}
switch state.phase {
case .idle, .error, .denied:
return true
case .requestingPermissions, .recording, .processing:
return false
}
}
}
@@ -39,6 +39,14 @@ final class KeyboardFlowCoordinator {
private var isPendingFlowStart = false
private var flowStartDeadline: TimeInterval = 0
/// Absolute cap for a cold-start jump. The soft `flowStartDeadline` is
/// pushed forward while the host keeps acknowledging it is warming, but
/// never past this — bounds the wait when the host is stuck in the
/// background and the user never brings it forward.
private var flowStartHardDeadline: TimeInterval = 0
/// One-shot guard so the "bring the app forward" hint is shown once per
/// start attempt instead of on every 200 ms watchdog poll.
private var coldStartForegroundHintShown = false
private var isFlowRecording = false
private var flowWatchdogTask: Task<Void, Never>?
private var utteranceTimerTask: Task<Void, Never>?
@@ -995,6 +1003,7 @@ final class KeyboardFlowCoordinator {
clearUnissuedUtterance()
return .rejected(.missingAPIKey)
case .unavailable(.noFullAccess):
persistStartupFailure("noFullAccess")
let msg = ExtL10n.string("keyboard.error.fullAccessRequired")
state.phase = .error(.fullAccessRequired, message: msg)
scheduleAutoClearError()
@@ -1002,6 +1011,9 @@ final class KeyboardFlowCoordinator {
clearUnissuedUtterance()
return .rejected(.noFullAccess)
case .unavailable(.appGroupUnavailable):
// Falls back to this extension's own container — the App Group is
// exactly what is missing here — so it will not show in Settings.
persistStartupFailure("appGroupUnavailable")
let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
state.phase = .error(.appGroupUnavailable, message: msg)
scheduleAutoClearError()
@@ -1031,6 +1043,9 @@ final class KeyboardFlowCoordinator {
if isPendingFlowStart || recordWhenHostReady {
return .waitingForHost(utteranceID)
}
// Availability said ready, yet no command was issued and no wait is
// pending: the press is dropped with no error UI at all.
persistStartupFailure("pressDroppedHostUnavailable")
clearUnissuedUtterance()
return .rejected(.hostUnavailable)
case .waitForHostReady(let recordWhenReady):
@@ -1087,6 +1102,7 @@ final class KeyboardFlowCoordinator {
func beginFlowStart(recordAfterHandoff: Bool = false) {
guard voiceSetupReady else {
persistStartupFailure("voiceSetupIncomplete")
promptFinishSetupInApp()
return
}
@@ -1099,8 +1115,15 @@ final class KeyboardFlowCoordinator {
coldStartDebouncer.reset()
isPendingFlowStart = true
isFlowRecording = false
coldStartForegroundHintShown = false
let now = Date().timeIntervalSince1970
flowStartDeadline = currentStartDeadlineAt ?? (now + FlowWatchdog.startTimeout)
// This path always jumps to the host (`openHostApp("startflow")`), so
// the budget must cover host foreground + PiP arm + first ready publish.
// Anchor it at the jump (not the mic press) with the cold-launch budget;
// reusing the press-anchored 8 s charged app-launch latency against the
// recording budget and timed out legitimate cold starts.
flowStartDeadline = now + FlowSessionKeys.coldStartHostLaunchBudget
flowStartHardDeadline = now + FlowSessionKeys.coldStartHostLaunchHardCap
currentStartDeadlineAt = flowStartDeadline
state.lastTranscript = ""
recomputeMicVoiceAvailability()
@@ -1131,6 +1154,9 @@ final class KeyboardFlowCoordinator {
flowStartDeadline = 0
stopFlowWatchdog()
traceState("openHostApp.failed", extra: "path=startflow cancelPending=1")
// iOS refused the jump (no Full Access, or the open was blocked):
// the host process never even had a chance to record anything.
persistStartupFailure("hostOpenRejected")
if currentUtteranceRequest?.isEdit == true {
onEditFailure(ExtL10n.string("keyboard.error.manualOpenForFlow"))
resetEditTransportState()
@@ -1645,6 +1671,10 @@ final class KeyboardFlowCoordinator {
}
private func failHostDisconnected() {
// Host was alive enough to be asked, then went away (jetsam, force-quit,
// suspended mid-utterance). Its own process is gone, so this side is the
// only one that can record it.
persistStartupFailure("hostDisconnected")
if let id = currentUtteranceId,
cancelledDictationUtteranceIDs.remove(id) != nil {
stopUtteranceCountdown()
@@ -1769,6 +1799,7 @@ final class KeyboardFlowCoordinator {
}
private func showFlowSessionExpiredHint() {
persistStartupFailure("sessionExpired")
let message = ExtL10n.string("keyboard.flow.sessionExpired")
state.phase = .error(.flowSessionExpired, message: message)
scheduleAutoClearError()
@@ -1804,6 +1835,9 @@ final class KeyboardFlowCoordinator {
private func startFlowRecording() {
if let deadline = currentStartDeadlineAt,
Date().timeIntervalSince1970 >= deadline {
// The 8 s budget started at the mic press and was spent waiting for
// the host; recording never even got attempted.
persistStartupFailure("startBudgetExpiredBeforeRecord")
if currentUtteranceRequest?.isEdit == true {
onEditFailure(ExtL10n.string("keyboard.edit.error.startTimeout"))
abortEditRecording()
@@ -2002,11 +2036,41 @@ final class KeyboardFlowCoordinator {
return
}
let now = Date().timeIntervalSince1970
// The host acknowledged the request and is alive & warming
// (cold launch / foreground handoff). Extend the soft budget up
// to the hard cap so a legitimate cold start is not failed at
// the initial launch budget while the host is provably working.
if let ackReason = FlowSessionBridge.hostStartInProgress() {
let extended = min(
now + FlowSessionKeys.hostStartAckExtension,
self.flowStartHardDeadline
)
if extended > self.flowStartDeadline {
self.flowStartDeadline = extended
self.currentStartDeadlineAt = extended
}
// Host is up but parked waiting for the user to bring it to
// the foreground — PiP cannot arm from the background. Prompt
// once so the user can tap back instead of watching a silent
// spinner run out the clock. Edit mode has no manual-open UI,
// so keep letting it wait out the hard cap.
if ackReason == .waitingForForeground,
!self.coldStartForegroundHintShown,
self.currentUtteranceRequest?.isEdit != true {
self.coldStartForegroundHintShown = true
self.showManualOpenHint(path: "startflow")
}
}
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
self.isPendingFlowStart = false
self.recordAfterHandoff = false
self.flowStartDeadline = 0
self.traceState("startWatchdog.timeout")
// The single most common user-visible failure: the host was
// asked to start and never published ready inside the
// budget. Persist before clearing the deadline so the
// report still carries it.
self.persistStartupFailure("startTimeout")
self.flowStartDeadline = 0
if self.currentUtteranceRequest?.isEdit == true {
self.onEditFailure(
ExtL10n.string("keyboard.edit.error.startTimeout")
@@ -2027,6 +2091,8 @@ final class KeyboardFlowCoordinator {
isPendingFlowStart = false
recordAfterHandoff = false
flowStartDeadline = 0
flowStartHardDeadline = 0
coldStartForegroundHintShown = false
stopFlowWatchdog()
state.lastTranscript = ""
refreshSessionState()
@@ -2322,9 +2388,44 @@ final class KeyboardFlowCoordinator {
}
private func debug(_ message: String) {
// Also feeds the shared breadcrumb window, so a terminal start failure
// on this side ships with the keyboard's own timeline instead of only
// whatever the host happened to record.
FlowFailureDiagnostics.record(message)
OSGLog.keyboardExt.info("\(message, privacy: .public)")
}
/// Writes a startup-failure report for a keyboard-observed dead end and
/// asks the host for its companion window. This is the capture path that
/// survives the host never launching at all.
private func persistStartupFailure(_ reason: String) {
FlowFailureDiagnostics.persistKeyboardStartupFailure(
reason: reason,
context: keyboardStartupFailureContext()
)
}
private func keyboardStartupFailureContext() -> [String: String] {
let now = Date().timeIntervalSince1970
return [
"pendingFlowStart": isPendingFlowStart ? "true" : "false",
"startDeadlineRemainingSeconds": flowStartDeadline > 0
? String(format: "%.3f", flowStartDeadline - now)
: "nil",
"startBudgetSeconds": String(format: "%.1f", FlowWatchdog.startTimeout),
"recordAfterHandoff": recordAfterHandoff ? "true" : "false",
"flowRecording": isFlowRecording ? "true" : "false",
"awaitingResult": isAwaitingFlowResult ? "true" : "false",
"sessionProvenReady": sessionProvenReady ? "true" : "false",
"fullAccess": hasFullAccess() ? "true" : "false",
"voiceSetupReady": voiceSetupReady ? "true" : "false",
"requestKind": currentUtteranceRequest?.isEdit == true
? "edit"
: (currentUtteranceRequest?.isAIQuestion == true ? "aiQuestion" : "dictation"),
"keyboardPhase": String(describing: state.phase)
]
}
private func traceState(_ event: String, extra: String? = nil) {
let staleness = FlowSessionBridge.heartbeatStaleness().map { String(format: "%.1f", $0) } ?? "nil"
let sessionId = activeSessionId?.uuidString ?? "nil"
@@ -88,10 +88,26 @@ struct CandidateExpandGridView: UIViewRepresentable {
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
let dequeued = collectionView.dequeueReusableCell(
withReuseIdentifier: Cell.reuseID,
for: indexPath
) as! Cell
)
guard let cell = dequeued as? Cell else { return dequeued }
// Rime refreshes the candidate list on every keystroke, so the
// collection view can still be laying out against the *previous*
// count when a new list lands. An out-of-range read here would
// crash the whole keyboard; render an empty cell instead — the
// pending `reloadData` repaints it a frame later.
guard candidates.indices.contains(indexPath.item) else {
cell.configure(
text: "",
emphasized: false,
textColor: textColor,
dividerColor: dividerColor,
showDivider: false
)
return cell
}
let index = indexPath.item
cell.configure(
text: candidates[index].text,
@@ -20,6 +20,17 @@ struct KeyboardSurfaceRoot: View {
colorScheme == .dark ? Palette.dark : Palette.light
}
/// True only for overlays that replace the keyboard surface. The auto-reply
/// guide is excluded — it dims the keyboard but keeps it visible underneath.
private var surfacesHidden: Bool {
switch state.clipboardOverlay {
case .enableGuide, .historyPanel:
return true
case .none, .autoReplyGuide:
return false
}
}
/// Height is deliberately independent of the surface: the voice surface
/// adopts the typing surface's content-driven height and parks the surplus
/// above its action cluster, so switching surfaces never resizes the
@@ -59,14 +70,25 @@ struct KeyboardSurfaceRoot: View {
)
}
}
.opacity(state.clipboardOverlay == .none ? 1 : 0)
.allowsHitTesting(state.clipboardOverlay == .none)
// The enable-guide / history panel replace the surface; the
// auto-reply guide instead layers over the still-visible keyboard.
.opacity(surfacesHidden ? 0 : 1)
.allowsHitTesting(!surfacesHidden)
// Match every surface's outer chrome so the panel title / X sit in
// the same slot as the logo + clipboard chip (not 4 pt higher).
clipboardOverlayLayer
.padding(.top, TypingSurfaceMetrics.outerPaddingTop)
.padding(.bottom, TypingSurfaceMetrics.outerPaddingBottom)
// Scrim-based nudge sits on top of the live keyboard, no padding so
// the dim reaches every edge.
if state.clipboardOverlay == .autoReplyGuide, state.canShowClipboardEntry {
ClipboardAutoReplyGuideView(
onClose: state.dismissClipboardOverlay,
onTry: state.tryAutoReplyFromGuide
)
}
}
// Overlays are siblings of the surfaces, so the palette has to be
// injected here or they fall back to the environment's dark default.
@@ -123,6 +145,9 @@ struct KeyboardSurfaceRoot: View {
? ExtL10n.string("keyboard.clipboard.panel.fullAccessHint")
: nil
)
case .autoReplyGuide:
// Rendered as a scrim layer over the live keyboard, not here.
EmptyView()
}
}
}
+43 -16
View File
@@ -125,12 +125,33 @@ struct TypingRootView: View {
} else if state.canShowClipboardEntry,
let suggestion = state.clipboardSuggestionText,
!suggestion.isEmpty {
// Same slot as logo + capsule tabs — hide chrome until dismissed.
ClipboardSuggestionBar(
text: suggestion,
onInsert: { state.insertClipboardText(suggestion) },
onDismiss: state.dismissClipboardSuggestion
)
// A fresh copy: a "Paste" glass capsule (matching the assistant
// surface) replaces the input tabs until inserted or dismissed.
HStack(spacing: 6) {
Button {
state.insertClipboardText(suggestion)
} label: {
Text(ExtL10n.string("keyboard.clipboard.paste"))
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.padding(.horizontal, 14)
.frame(height: 30)
.glassEffect(.regular.interactive(), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityIdentifier("typing.clipboard.paste")
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.paste"))
Spacer(minLength: 0)
KeyboardCancelButton(
action: state.dismissClipboardSuggestion,
accessibilityLabel: ExtL10n.text("keyboard.assistant.dismissClipboard"),
accessibilityHint: ExtL10n.text("keyboard.assistant.dismissClipboardHint"),
accessibilityIdentifier: "typing.clipboard.dismiss"
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else {
idleTopBar
@@ -191,7 +212,7 @@ struct TypingRootView: View {
Button(action: state.openInputMethodSetup) {
HStack(spacing: 2) {
Text(message)
.font(.system(size: 11))
.font(TypeStyle.caption2.weight(.regular))
.lineLimit(1)
Image(systemName: "arrow.up.right")
.font(.system(size: 9, weight: .semibold))
@@ -203,7 +224,7 @@ struct TypingRootView: View {
.accessibilityHint(ExtL10n.text("keyboard.typing.setupA11yHint"))
} else {
Text(message)
.font(.system(size: 11))
.font(TypeStyle.caption2.weight(.regular))
.foregroundStyle(palette.danger)
.lineLimit(1)
}
@@ -316,7 +337,7 @@ struct TypingRootView: View {
}
} else {
Text(candidate.text)
.font(.system(size: 20, weight: .regular))
.font(TypeStyle.title3.weight(.regular))
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, 10)
.frame(height: 40)
@@ -367,7 +388,13 @@ struct TypingRootView: View {
}
.buttonStyle(.plain)
.padding(.trailing, KeyboardTopBarMetrics.nestedHorizontalInset)
.accessibilityLabel(typing.isCandidatePanelExpanded ? "收起候选" : "更多候选")
.accessibilityLabel(
ExtL10n.string(
typing.isCandidatePanelExpanded
? "keyboard.typing.candidates.collapseA11y"
: "keyboard.typing.candidates.expandA11y"
)
)
}
private var expandChevronFill: Color {
@@ -414,7 +441,7 @@ struct TypingRootView: View {
) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 15, weight: .medium))
.font(TypeStyle.bodyEmph)
.foregroundStyle(enabled ? palette.textSecondary : palette.textTertiary)
.frame(width: 34, height: 34)
.background(enabled ? editingToolbarButtonFill : .clear, in: Circle())
@@ -595,7 +622,7 @@ struct TypingRootView: View {
Group {
if key.label == "⌫" {
Image(systemName: "delete.left")
.font(.system(size: 20, weight: .medium))
.font(TypeStyle.title3.weight(.medium))
.foregroundStyle(keyTextColor)
} else if key.label == "⇧" {
Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift")
@@ -684,8 +711,8 @@ struct TypingRootView: View {
case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue:
return key.label
default:
if key.label == "⇧" { return "shift" }
if key.label == "⌫" { return "delete" }
if key.label == "⇧" { return ExtL10n.string("keyboard.key.shiftA11y") }
if key.label == "⌫" { return ExtL10n.string("keyboard.key.deleteA11y") }
return key.label
}
}
@@ -722,7 +749,7 @@ struct TypingRootView: View {
private var returnKeyLabel: some View {
if state.returnKeyRole.usesActionFill {
ExtL10n.text(state.returnKeyRole.titleKey)
.font(.system(size: 15, weight: .semibold))
.font(TypeStyle.body.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.6)
} else {
@@ -803,7 +830,7 @@ struct TypingRootView: View {
if state.returnKeyRole.usesActionFill {
return ExtL10n.string(state.returnKeyRole.titleKey)
}
return "return"
return ExtL10n.string("keyboard.key.returnA11y")
}
private var returnKeyTextColor: Color {
+269 -98
View File
@@ -76,7 +76,6 @@ struct AIKeyboardView: View {
@State private var carouselBag = AIHintCarouselBag()
@State private var poolCards: [AIHintCard] = []
@State private var selectedSkillPage = 0
@State private var dismissedClipboardEntryID: UUID?
@State private var debugSkillsDismissed = false
@State private var micLongPressConsumed = false
@State private var micIsHoldingForAI = false
@@ -121,7 +120,7 @@ struct AIKeyboardView: View {
}
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in
dismissedClipboardEntryID = nil
state.dismissedClipboardSkillEntryID = nil
debugSkillsDismissed = false
selectedSkillPage = 0
resetCarousel()
@@ -191,27 +190,46 @@ struct AIKeyboardView: View {
private var pendingAnswerSurface: some View {
VStack(spacing: 0) {
topBar.frame(height: Layout.topBarHeight)
ScrollView(.vertical) {
Text(state.aiSession.answer?.text ?? "")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
.scrollIndicators(.visible)
if state.autoResultReadOnly {
// Auto-translate / email-reply stay staged: the result is shown,
// and lands in the field via the labeled liquid-glass button.
ScrollView(.vertical) {
Text(state.aiSession.answer?.text ?? "")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
.scrollIndicators(.visible)
Button(action: state.confirmPendingAIAnswer) {
Image(systemName: "plus")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(OSGColor.fixedLightContent)
.frame(width: Layout.fieldActionWidth, height: 44)
.background(palette.accent, in: Capsule())
Button(action: state.confirmPendingAIAnswer) {
HStack(spacing: 6) {
Image(systemName: "arrow.down.to.line")
.font(TypeStyle.body.weight(.semibold))
ExtL10n.text(state.autoResultInsertLabelKey)
.font(TypeStyle.body.weight(.semibold))
}
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, 22)
.frame(height: 44)
.glassEffect(.regular.interactive(), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityIdentifier("assistant.autoResult.insert")
.frame(height: 55)
.accessibilityLabel(ExtL10n.text(state.autoResultInsertLabelKey))
} else {
// A single reply is one tappable liquid-glass card — same style
// as the multi-variant cards; tapping it inserts.
ScrollView(.vertical) {
pendingAnswerCard
.padding(.horizontal, Spacing.xs)
.padding(.vertical, Spacing.xs)
}
.scrollIndicators(.visible)
.scrollBounceBehavior(.basedOnSize)
}
.buttonStyle(.plain)
.accessibilityIdentifier("assistant.pending.insert")
.frame(height: 55)
.accessibilityLabel(ExtL10n.text("keyboard.assistant.insertPending"))
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
@@ -220,6 +238,38 @@ struct AIKeyboardView: View {
.frame(height: resolvedHeight)
}
private var pendingAnswerCard: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium)
return Button(action: state.confirmPendingAIAnswer) {
HStack(alignment: .top, spacing: Spacing.sm) {
Text(state.aiSession.answer?.text ?? "")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "arrow.down.to.line")
.font(TypeStyle.body.weight(.semibold))
.foregroundStyle(palette.textSecondary)
.accessibilityHidden(true)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(shape)
.glassEffect(
.regular
.tint(palette.textPrimary.opacity(0.04))
.interactive(),
in: shape
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("assistant.pending.insert")
.accessibilityLabel(Text(state.aiSession.answer?.text ?? ""))
.accessibilityHint(ExtL10n.text("keyboard.assistant.insertPending"))
}
private var replyVariantsSurface: some View {
VStack(spacing: 0) {
topBar.frame(height: Layout.topBarHeight)
@@ -322,14 +372,12 @@ struct AIKeyboardView: View {
labelKey: "keyboard.voice.cancel",
hintKey: "keyboard.voice.cancelHint"
)
} else if shouldShowClipboardSuggestion {
ClipboardSuggestionBar(
text: state.clipboardSuggestionText ?? "",
onInsert: insertClipboardSuggestion,
onDismiss: dismissClipboardPresentation
)
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else if showsClipboardSkills || semanticBadgeContent != nil {
} else if showsClipboardSkillBar {
// Fresh copy, no AI result yet: the top bar becomes the skill entry
// point (paste + every applicable skill + dismiss).
clipboardSkillTopBar
} else if showsClipboardSkills {
// OOBE / debug chip pager keeps its dismiss bar.
cancelTopBar(
action: dismissClipboardPresentation,
labelKey: "keyboard.assistant.dismissClipboard",
@@ -344,7 +392,7 @@ struct AIKeyboardView: View {
onInsert: onInsert
)
HStack {
KeyboardBrandLogo(action: state.openSettings)
leadingBrandOrPaste
Spacer(minLength: 0)
}
}
@@ -352,14 +400,75 @@ struct AIKeyboardView: View {
}
}
/// Fresh-copy top bar (no active result): paste + scrollable skills + dismiss.
private var clipboardSkillTopBar: some View {
HStack(spacing: 6) {
leadingScrollRow
KeyboardCancelButton(
action: dismissClipboardPresentation,
accessibilityLabel: ExtL10n.text("keyboard.assistant.dismissClipboard"),
accessibilityHint: ExtL10n.text("keyboard.assistant.dismissClipboardHint"),
accessibilityIdentifier: "assistant.clipboard.dismiss"
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
/// Fresh copy with applicable skills and no active AI result to defer to.
private var showsClipboardSkillBar: Bool {
guard assistantIsResting,
state.oobePracticeSession == nil,
state.clipboardHistoryEnabled,
state.aiServiceAvailable,
!state.aiSession.canInsert,
!state.aiSession.canSelectReplyVariant,
let newest = clipboardHistory.newestEntry,
newest.id != state.dismissedClipboardSkillEntryID,
!topBarSkills.isEmpty else {
return false
}
return AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: newest
)
}
/// The top-bar leading slot: a "Paste" glass capsule whenever a fresh copy is
/// available (replacing the OSG logo), otherwise the logo itself. Copying is
/// frequent, so this keeps a one-tap paste always in the same spot without a
/// full-width suggestion strip.
@ViewBuilder
private var leadingBrandOrPaste: some View {
if shouldShowClipboardSuggestion {
pasteCapsule
} else {
KeyboardBrandLogo(action: state.openSettings)
}
}
private var pasteCapsule: some View {
Button(action: insertClipboardSuggestion) {
Text(ExtL10n.string("keyboard.clipboard.paste"))
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.padding(.horizontal, 14)
.frame(height: 30)
.glassEffect(.regular.interactive(), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityIdentifier("assistant.clipboard.paste")
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.paste"))
}
private func cancelTopBar(
action: @escaping () -> Void,
labelKey: String,
hintKey: String
) -> some View {
HStack(spacing: Spacing.xs) {
KeyboardBrandLogo(action: state.openSettings)
Spacer(minLength: 0)
HStack(spacing: 6) {
// Paste + every applicable skill scroll together; the X stays put.
leadingScrollRow
KeyboardCancelButton(
action: action,
accessibilityLabel: ExtL10n.text(labelKey),
@@ -370,6 +479,110 @@ struct AIKeyboardView: View {
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
/// Leading region of the clipboard top bars: the OSG logo when there's no
/// fresh copy, otherwise a horizontally scrollable row whose first item is the
/// Paste capsule followed by every applicable skill — so Paste scrolls along
/// with the skills instead of staying pinned.
@ViewBuilder
private var leadingScrollRow: some View {
let paste = shouldShowClipboardSuggestion
let skills = topBarSkills
HStack(spacing: 6) {
if !paste {
KeyboardBrandLogo(action: state.openSettings)
}
if paste || !skills.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
if paste {
pasteCapsule
}
ForEach(skills) { skill in
otherSkillCapsule(skill)
}
}
.padding(.vertical, 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollClipDisabled()
} else {
Spacer(minLength: 0)
}
}
}
private func otherSkillCapsule(_ skill: AIClipboardSkill) -> some View {
Button {
state.submitAIClipboardSkill(skill, replyScene(for: skill))
} label: {
Text(clipboardSkillTitle(skill))
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.padding(.horizontal, 12)
.frame(height: 30)
.glassEffect(.regular.interactive(), in: Capsule())
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityIdentifier("assistant.otherSkill.\(skill.id)")
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
/// Skills that apply to the current copy, most-relevant first. This is the
/// sole skill entry point now that the circular chips are gone; it shows with
/// or without an active result (the invoked skill is excluded when active).
///
/// Purely relevance-ranked: a skill appears only when its signal is present
/// (a URL → Open / Summarize page; a phone → Call; a date/list → Events /
/// Todos / Organize; long text → Summary; plus Reply as the message baseline).
/// No blanket fallback, so Summary / Organize / Events / Notes do NOT show on
/// an ordinary short message.
private var topBarSkills: [AIClipboardSkill] {
guard state.oobePracticeSession == nil,
state.aiServiceAvailable,
let newest = clipboardHistory.newestEntry else {
return []
}
let translatable = clipboardIsTranslatable
let activeID = state.activeClipboardSkillID
func present(_ list: [AIClipboardSkill]) -> [AIClipboardSkill] {
list.filter {
$0.id != activeID
// Don't offer "Translate" for text already in the user's language.
&& ($0.id != AIClipboardSkillCatalog.translateID || translatable)
}
}
guard let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
// Analysis not ready yet — keep Reply reachable in the meantime.
let reply = state.enabledClipboardSkills.first {
$0.id == AIClipboardSkillCatalog.replyID
}
return present([reply].compactMap { $0 })
}
return present(
ClipboardSkillSemanticRanker.recommended(
skills: state.enabledClipboardSkills,
sourceText: newest.text,
analysis: snapshot.analysis,
uiLanguage: state.uiLanguage,
limit: 6
)
)
}
/// True only when the current copy is in a language other than the user's,
/// so offering Translate makes sense.
private var clipboardIsTranslatable: Bool {
guard let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return false
}
return ClipboardSkillSemanticRanker.isForeignLanguage(snapshot.analysis)
}
private func cancelIdentifier(for labelKey: String) -> String {
switch labelKey {
case "keyboard.assistant.dismissClipboard":
@@ -390,7 +603,7 @@ struct AIKeyboardView: View {
clipboardSkillPager
} else if let onboardingPracticeHint, activeStatus == nil {
Text(onboardingPracticeHint)
.font(.system(size: 13, weight: .semibold))
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.padding(.horizontal, 14)
@@ -422,7 +635,7 @@ struct AIKeyboardView: View {
statusText(status.text, color: status.color)
} else if showsLongPressCoach {
Text(ExtL10n.string("keyboard.assistant.longPressCoach"))
.font(.system(size: 13, weight: .medium))
.font(TypeStyle.footnote.weight(.medium))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.padding(.horizontal, 14)
@@ -442,7 +655,7 @@ struct AIKeyboardView: View {
.font(.system(size: 10, weight: .semibold))
.accessibilityHidden(true)
Text(content.text)
.font(.system(size: 12, weight: .semibold))
.font(TypeStyle.caption.weight(.semibold))
.lineLimit(1)
}
.foregroundStyle(palette.textSecondary)
@@ -463,44 +676,9 @@ struct AIKeyboardView: View {
)
}
#endif
guard assistantIsResting,
state.clipboardHistoryEnabled,
let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id,
AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: newest
) else {
return nil
}
let intentKey = semanticIntentKey(snapshot.analysis)
let domainKey: String? = snapshot.analysis.domain.flatMap { domain in
guard let confidence = snapshot.analysis.domainConfidence,
confidence > 0 else {
return nil
}
return domain.localizationKey
}
guard intentKey != nil || domainKey != nil else { return nil }
return SemanticBadgeContent(intentKey: intentKey, domainKey: domainKey)
}
private func semanticIntentKey(
_ analysis: ClipboardSemanticAnalysis
) -> String? {
let candidates = [
(analysis.assistantCommand, "keyboard.semantic.intent.assistantCommand"),
(analysis.informationQuery, "keyboard.semantic.intent.informationQuery"),
(analysis.systemNotification, "keyboard.semantic.intent.systemNotification")
].filter { label, _ in
label.isDetected
&& label.confidence > 0
&& label.confidence >= label.threshold
}
return candidates.max {
$0.0.confidence < $1.0.confidence
}?.1
// Retired alongside the idle skill chips it accompanied: the copy flow now
// goes straight to an auto result, so no standalone badge is shown.
return nil
}
private func statusText(_ text: String, color: Color) -> some View {
@@ -600,11 +778,11 @@ struct AIKeyboardView: View {
HStack(spacing: 6) {
if let hint = currentHint {
Image(systemName: hint.visualKind.systemImage)
.font(.system(size: 13, weight: .semibold))
.font(TypeStyle.footnote.weight(.semibold))
}
Text(currentHint.map(\.resolvedDisplayText)
?? ExtL10n.string("keyboard.ai.placeholder"))
.font(.system(size: 13, weight: .medium))
.font(TypeStyle.footnote.weight(.medium))
.lineLimit(1)
.truncationMode(.tail)
}
@@ -706,16 +884,15 @@ struct AIKeyboardView: View {
snapshot.entryID == newest.id else {
return nil
}
return AIClipboardReplyScene.resolve(from: snapshot.analysis)
return AIClipboardReplyScene.resolve(
from: snapshot.analysis,
sourceText: newest.text
)
}
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
if skill.id == AIClipboardSkillCatalog.translateID {
return AIClipboardSkillCatalog.translateButtonTitle(
translationTargetLocaleId: state.translationTargetLocaleId,
uiLanguage: state.uiLanguage
)
}
// Capsules stay short: use the plain skill title, not the verbose
// "Translate to <language>" form.
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
!custom.isEmpty {
return custom
@@ -734,16 +911,10 @@ struct AIKeyboardView: View {
if showsOOBEClipboardSkill {
return assistantIsResting
}
guard assistantIsResting, state.clipboardHistoryEnabled else { return false }
guard !visibleClipboardSkills.isEmpty,
let newest = clipboardHistory.newestEntry,
newest.id != dismissedClipboardEntryID else {
return false
}
return AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: newest
)
// The idle circular skill chips are retired: auto mode runs one skill and
// the result panel's top-bar capsules offer the rest. Only OOBE / debug
// still present the chip pager above.
return false
}
private var visibleClipboardSkills: [AIClipboardSkill] {
@@ -909,7 +1080,7 @@ struct AIKeyboardView: View {
.foregroundStyle(OSGColor.fixedLightContent)
case .idle, .denied:
Image(systemName: "mic.fill")
.font(.system(size: 28, weight: .semibold))
.font(TypeStyle.title.weight(.semibold))
.foregroundStyle(OSGColor.fixedLightContent)
.symbolEffect(.breathe, isActive: micIsHoldingForAI)
}
@@ -1131,7 +1302,7 @@ struct AIKeyboardView: View {
private var fieldActionButton: some View {
Button(action: performFieldAction) {
Image(systemName: fieldActionSystemImage)
.font(.system(size: 20, weight: .semibold))
.font(TypeStyle.title3)
.foregroundStyle(fieldActionButtonForeground)
.frame(
width: fieldActionButtonWidth,
@@ -1219,7 +1390,7 @@ struct AIKeyboardView: View {
ZStack {
Color.clear
Image(systemName: "delete.left")
.font(.system(size: 17, weight: .semibold))
.font(TypeStyle.headline)
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
}
.frame(width: lowerCircleSize, height: lowerCircleSize)
@@ -1272,7 +1443,7 @@ struct AIKeyboardView: View {
}
private func insertClipboardSuggestion() {
dismissedClipboardEntryID = clipboardHistory.newestEntry?.id
state.dismissedClipboardSkillEntryID = clipboardHistory.newestEntry?.id
if let text = state.clipboardSuggestionText {
state.insertClipboardText(text)
}
@@ -1284,7 +1455,7 @@ struct AIKeyboardView: View {
debugSkillsDismissed = true
}
#endif
dismissedClipboardEntryID = clipboardHistory.newestEntry?.id
state.dismissedClipboardSkillEntryID = clipboardHistory.newestEntry?.id
state.dismissClipboardSuggestion()
}
@@ -23,7 +23,7 @@ struct ClipboardSuggestionBar: View {
Button(action: onInsert) {
Text(text)
.font(.system(size: 15))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -96,14 +96,14 @@ struct ClipboardEnableGuideView: View {
VStack(spacing: 16) {
ExtL10n.text("keyboard.clipboard.guide.body")
.font(.system(size: 15))
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 28)
Button(action: onOpenSettings) {
ExtL10n.text("keyboard.clipboard.guide.cta")
.font(.system(size: 15, weight: .semibold))
.font(TypeStyle.body.weight(.semibold))
.foregroundStyle(OSGColor.fixedLightContent)
.padding(.horizontal, 20)
.padding(.vertical, 10)
@@ -119,6 +119,85 @@ struct ClipboardEnableGuideView: View {
}
}
// MARK: - Auto-reply guide
/// One-time nudge layered *over* the live keyboard: a soft scrim keeps the keys
/// faintly visible while a floating pitch + primary action invite the user to
/// turn auto mode on. Tapping the scrim (or the ✕) dismisses it.
struct ClipboardAutoReplyGuideView: View {
let onClose: () -> Void
let onTry: () -> Void
var body: some View {
ZStack {
// Dim the keyboard beneath; a tap anywhere off the card dismisses.
Rectangle()
.fill(Color.black.opacity(0.5))
.contentShape(Rectangle())
.onTapGesture(perform: onClose)
.accessibilityIdentifier("assistant.autoReply.guide.scrim")
.accessibilityLabel(ExtL10n.text("keyboard.assistant.autoReply.guide.close"))
VStack(spacing: 12) {
Image(systemName: "sparkles")
.font(TypeStyle.title.weight(.semibold))
.foregroundStyle(OSGColor.fixedLightContent)
ExtL10n.text("keyboard.assistant.autoReply.guide.title")
.font(TypeStyle.headline)
.foregroundStyle(OSGColor.fixedLightContent)
.multilineTextAlignment(.center)
ExtL10n.text("keyboard.assistant.autoReply.guide.body")
.font(TypeStyle.footnote)
.foregroundStyle(OSGColor.fixedLightContent.opacity(0.85))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button(action: onTry) {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(TypeStyle.body.weight(.semibold))
ExtL10n.text("keyboard.assistant.autoReply.guidance.cta")
.font(TypeStyle.body.weight(.semibold))
}
.foregroundStyle(OSGColor.fixedLightContent)
.padding(.horizontal, 22)
.padding(.vertical, 11)
.background(Palette.light.accent, in: Capsule())
}
.buttonStyle(.plain)
.padding(.top, 2)
.accessibilityIdentifier("assistant.autoReply.guide.try")
}
.padding(.horizontal, 20)
VStack(spacing: 0) {
HStack(spacing: 0) {
Spacer(minLength: 0)
Button(action: onClose) {
Image(systemName: "xmark")
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(OSGColor.fixedLightContent)
.frame(width: 30, height: 30)
.background(Color.black.opacity(0.28), in: Circle())
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("assistant.autoReply.guide.close")
.accessibilityLabel(ExtL10n.text("keyboard.assistant.autoReply.guide.close"))
.accessibilityHint(ExtL10n.text("keyboard.assistant.autoReply.guide.closeHint"))
}
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
.padding(.top, 6)
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier("assistant.autoReply.guide")
}
}
// MARK: - History panel
struct ClipboardHistoryPanelView: View {
@@ -156,7 +235,7 @@ struct ClipboardHistoryPanelView: View {
if let pastePermissionHint, !pastePermissionHint.isEmpty {
Text(pastePermissionHint)
.font(.system(size: 12))
.font(TypeStyle.caption.weight(.regular))
.foregroundStyle(palette.warning)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
@@ -207,7 +286,7 @@ struct ClipboardHistoryPanelView: View {
.background(palette.surface.opacity(0.35), in: Circle())
ExtL10n.text("keyboard.clipboard.clear.title")
.font(.system(size: 15, weight: .semibold))
.font(TypeStyle.body.weight(.semibold))
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
@@ -216,7 +295,7 @@ struct ClipboardHistoryPanelView: View {
showClearConfirmation = false
} label: {
ExtL10n.text("common.cancel")
.font(.system(size: 13, weight: .semibold))
.font(TypeStyle.footnote.weight(.semibold))
.frame(maxWidth: .infinity)
.frame(height: 36)
}
@@ -231,14 +310,15 @@ struct ClipboardHistoryPanelView: View {
onClear()
} label: {
ExtL10n.text("keyboard.clipboard.clear.confirm")
.font(.system(size: 13, weight: .semibold))
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(palette.background)
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.glassProminent)
.buttonBorderShape(.capsule)
.tint(palette.textPrimary)
// Match the system alert's destructive styling used in-app.
.tint(palette.danger)
}
}
.padding(16)
@@ -269,7 +349,7 @@ private struct ClipboardHistoryRow: View {
HStack(alignment: .top, spacing: 8) {
Button(action: onInsert) {
Text(entry.text)
.font(.system(size: 15))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(3)
.multilineTextAlignment(.leading)
@@ -302,7 +382,7 @@ private struct ClipboardHistoryRow: View {
onInsertToken(token)
} label: {
Text(token)
.font(.system(size: 12, weight: .medium))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
@@ -321,7 +401,7 @@ private struct ClipboardHistoryRow: View {
// Half opacity so the keyboard chrome still reads through the card.
.background(
palette.surface.opacity(0.5),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
}
}
@@ -57,7 +57,7 @@ struct SystemGlobeKey: View {
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
) {
Image(systemName: "globe")
.font(.system(size: 17, weight: .medium))
.font(TypeStyle.headline.weight(.medium))
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
.accessibilityHidden(true)
}
@@ -322,7 +322,7 @@ struct KeyboardTranslationMenuButton: View, Equatable {
ZStack {
Color.clear
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
.font(.system(size: 17, weight: .semibold))
.font(TypeStyle.headline)
.foregroundStyle(
isEnabled
? palette.accent
@@ -113,7 +113,7 @@ struct RepeatingPressButton<Label: View>: View {
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text("delete"))
.accessibilityLabel(ExtL10n.string("keyboard.key.deleteA11y"))
.accessibilityAddTraits(.isButton)
.onDisappear { stopRepeating() }
}
@@ -0,0 +1,4 @@
/* Permission prompts (keyboard extension) — English */
"NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard transcribes your voice with on-device speech recognition by default. If you explicitly switch to a cloud engine in Settings, recordings are sent to the ASR provider you configure.";
+25 -6
View File
@@ -16,7 +16,7 @@
"onboarding.api.localReady.body" = "Recognition works on-device. Add an API key in Settings for AI polish.";
"keyboard.onboarding.practice.mic" = "Tap the mic, then tap again when done";
"keyboard.onboarding.practice.translate" = "Tap Translate, then insert the generated text";
"keyboard.onboarding.practice.reply" = "Tap Reply and let AI draft a response";
"keyboard.onboarding.practice.reply" = "AI is drafting a ready-to-send reply for you";
"keyboard.onboarding.practice.askAI" = "Hold the mic and ask your question";
/* Common navigation */
@@ -219,6 +219,7 @@
"keyboard.clipboard.clear.confirm" = "Clear history";
"keyboard.clipboard.suggestion.dismissA11y" = "Dismiss clipboard suggestion";
"keyboard.clipboard.suggestion.dismissHint" = "Hide this clipboard suggestion strip.";
"keyboard.clipboard.paste" = "Paste";
"keyboard.voice.cancel" = "Cancel voice input";
"keyboard.voice.cancelHint" = "Discard the current recording, recognition, and polish result.";
"keyboard.scenario.a11y" = "Polish scenario";
@@ -229,9 +230,6 @@
"mode.transcribe" = "Transcribe";
"mode.polish" = "Polish";
/* Locale chip labels (preview stub) */
"locale.zh-Hans" = "ZH-Hans";
/* Locale menu items (settings) */
"locale.auto" = "Auto";
"locale.zh-Hans" = "Chinese (Simplified)";
@@ -301,6 +299,11 @@
"keyboard.ai.startA11y" = "Start asking AI";
"keyboard.ai.stopA11y" = "Finish the question and send it to AI";
"keyboard.assistant.longPressCoach" = "Tap to dictate · Hold to ask AI";
"keyboard.assistant.autoReply.guidance.cta" = "Try AI auto-reply";
"keyboard.assistant.autoReply.guide.title" = "Let AI reply for you";
"keyboard.assistant.autoReply.guide.body" = "Turn on auto-reply and the keyboard drafts a ready-to-send reply the moment you copy a message.";
"keyboard.assistant.autoReply.guide.close" = "Not now";
"keyboard.assistant.autoReply.guide.closeHint" = "Dismiss the auto-reply suggestion.";
"keyboard.assistant.micA11y" = "Voice and AI";
"keyboard.assistant.micHint" = "Tap to dictate. Hold to ask AI.";
"keyboard.assistant.dictationAction" = "Start voice dictation";
@@ -309,6 +312,8 @@
"keyboard.assistant.delete" = "Delete";
"keyboard.assistant.sendHint" = "Performs the current field's Send, Search, Done, or Go action.";
"keyboard.assistant.insertPending" = "Insert retained AI answer";
"keyboard.assistant.insertTranslation" = "Insert translation";
"keyboard.assistant.insertReply" = "Insert reply";
"keyboard.assistant.discardPending" = "Discard AI answer";
"keyboard.assistant.discardPendingHint" = "Discard the answer retained because the cursor or input field changed.";
"keyboard.ai.replyVariant.ordinary" = "Ordinary";
@@ -326,6 +331,10 @@
"keyboard.ai.replyVariant.clarificationDirect" = "Direct reply";
"keyboard.ai.replyVariant.clarificationQuestion" = "Ask key detail";
"keyboard.ai.replyVariant.clarificationConfirm" = "Confirm & ask";
"keyboard.ai.replyVariant.answerAffirmative" = "Yes";
"keyboard.ai.replyVariant.answerNegative" = "No";
"keyboard.ai.replyVariant.answerConditional" = "It depends";
"keyboard.ai.replyVariant.answerDefer" = "Need to check";
"keyboard.ai.replyVariant.insertHint" = "Insert this complete reply.";
"keyboard.assistant.dismissClipboard" = "Dismiss clipboard suggestions";
"keyboard.assistant.dismissClipboardHint" = "Hide the current clipboard summary and skills.";
@@ -351,9 +360,10 @@
"keyboard.ai.error.requestFailed" = "AI response failed. Try again";
"keyboard.ai.error.clipboardUnavailable" = "This clipboard suggestion expired. Copy the text again";
"keyboard.ai.skill.reply" = "Reply";
"keyboard.ai.skill.speakAsMe" = "My words";
"keyboard.ai.skill.playfulReply" = "Playful";
"keyboard.ai.skill.replyInSourceLanguage" = "Reply";
"keyboard.ai.skill.summarize" = "Key points";
"keyboard.ai.skill.summarize" = "Summary";
"keyboard.ai.skill.openLink" = "Open";
"keyboard.ai.skill.summarizeWebPage" = "Web summary";
"keyboard.ai.skill.callPhone" = "Call";
@@ -364,7 +374,7 @@
"keyboard.ai.skill.declineInvitation" = "Decline";
"keyboard.ai.skill.acceptTask" = "Acknowledge";
"keyboard.ai.skill.clarifyRequest" = "Clarify";
"keyboard.ai.skill.empathyReply" = "Empathize";
"keyboard.ai.skill.empathyReply" = "Empathy";
"keyboard.ai.skill.blessingReply" = "Reply to wishes";
"keyboard.ai.skill.askForDetails" = "Ask details";
"keyboard.ai.skill.businessReply" = "Business";
@@ -386,9 +396,18 @@
"keyboard.ai.skill.extractingAddress" = "Finding an address…";
"keyboard.ai.skill.namingNote" = "Naming the note…";
"keyboard.ai.skill.runningShortcut" = "Running Shortcut…";
"keyboard.ai.skill.savingReminders" = "Adding to Reminders…";
"keyboard.ai.skill.savingEvents" = "Adding to Calendar…";
"keyboard.ai.skill.openingMaps" = "Opening Maps…";
"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";
"keyboard.ai.skill.openLinkFailed" = "Couldn’t open this link";
"keyboard.ai.skill.callPhoneFailed" = "Couldn’t start this call";
"keyboard.ai.skill.createContactFailed" = "Couldn’t open the new contact form";
/* Key / panel accessibility labels */
"keyboard.typing.candidates.collapseA11y" = "Collapse candidates";
"keyboard.typing.candidates.expandA11y" = "More candidates";
"keyboard.key.shiftA11y" = "Shift";
"keyboard.key.deleteA11y" = "Delete";
"keyboard.key.returnA11y" = "Return";
@@ -0,0 +1,4 @@
/* 权限说明(键盘扩展)— 简体中文 */
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,包括你在其他 App 中输入时保持启用的 Flow 会话。";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。";
+43 -24
View File
@@ -3,20 +3,20 @@
"OSGKeyboard" = "OSGKeyboard";
/* Onboarding */
"onboarding.welcome.subtitle" = "点按麦克风说话,再点一次结束,文字即出现。";
"onboarding.welcome.subtitle" = "轻点麦克风说话,再轻点一次结束,文字即出现。";
"onboarding.enable.title" = "启用 OSGKeyboard";
"onboarding.enable.step1" = "设置 → 通用 → 键盘 → 键盘";
"onboarding.enable.step2" = "点击「添加新键盘…」并选择 OSGKeyboard";
"onboarding.enable.step3" = "点击 OSGKeyboard 并启用「允许完全访问」";
"onboarding.enable.step2" = "轻点「添加新键盘…」并选择 OSGKeyboard";
"onboarding.enable.step3" = "轻点 OSGKeyboard 并启用「允许完全访问」";
"onboarding.enable.step4" = "允许完全访问是麦克风和网络调用的前提。";
"onboarding.enable.openSettings" = "打开 iOS 设置";
"onboarding.api.title" = "选择引擎";
"onboarding.api.subtitle" = "本地识别无需 Key;填写 API Key 后可开启 AI 润色。";
"onboarding.api.localReady.title" = "本地识别已就绪";
"onboarding.api.localReady.body" = "识别在端侧完成。请在设置中填写 API Key 以开启 AI 润色。";
"keyboard.onboarding.practice.mic" = "点按麦克风,说完后再点一次";
"keyboard.onboarding.practice.translate" = "点击「翻译」,再插入生成的文字";
"keyboard.onboarding.practice.reply" = "点击「回复」,让 AI 起草可发送的回复";
"keyboard.onboarding.practice.mic" = "轻点麦克风,说完后再轻点一次";
"keyboard.onboarding.practice.translate" = "轻点「翻译」,再插入生成的文字";
"keyboard.onboarding.practice.reply" = "AI 正在自动为你起草可发送的回复";
"keyboard.onboarding.practice.askAI" = "长按麦克风,说出你想问的问题";
/* Common navigation */
@@ -118,8 +118,8 @@
/* Keyboard preview */
"preview.title" = "键盘预览";
"preview.subtitle" = "点击「开始录音」/「停止录音」按钮测试;真实键盘为长按麦克风圆盘。";
"preview.placeholder" = "试着输入或点击按钮录音";
"preview.subtitle" = "轻点「开始录音」/「停止录音」按钮测试;真实键盘为长按麦克风圆盘。";
"preview.placeholder" = "试着输入或轻轻点钮录音";
"preview.clear" = "清空";
"preview.openSettingsA11y" = "打开 OSGKeyboard 设置";
"preview.modeChip.cycle" = "切换输入模式";
@@ -134,7 +134,7 @@
"keyboard.tab.voice.a11y" = "切换到语音输入";
"keyboard.tab.chinese.a11y" = "切换到中文输入";
"keyboard.tab.english.a11y" = "切换到英文输入";
"keyboard.placeholder.idle" = "点按说话";
"keyboard.placeholder.idle" = "轻点说话";
"keyboard.placeholder.preparing" = "准备中…";
"keyboard.placeholder.preparingRecording" = "准备录音…";
"keyboard.placeholder.processing" = "处理中…";
@@ -147,7 +147,7 @@
"keyboard.denied.speech" = "语音识别被拒绝";
"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置";
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
"keyboard.tapToTalkA11y" = "点按说话";
"keyboard.tapToTalkA11y" = "轻点说话";
"keyboard.undoA11y" = "撤销上次输入";
"keyboard.redoA11y" = "重做";
"keyboard.copyA11y" = "拷贝";
@@ -219,6 +219,7 @@
"keyboard.clipboard.clear.confirm" = "清空历史";
"keyboard.clipboard.suggestion.dismissA11y" = "关闭剪贴板建议";
"keyboard.clipboard.suggestion.dismissHint" = "隐藏本次剪贴板建议条。";
"keyboard.clipboard.paste" = "粘贴";
"keyboard.voice.cancel" = "取消本次语音输入";
"keyboard.voice.cancelHint" = "放弃当前录音、识别和润色结果。";
"keyboard.scenario.a11y" = "润色场景";
@@ -229,9 +230,6 @@
"mode.transcribe" = "转写";
"mode.polish" = "润色";
/* Locale chip labels (preview stub) */
"locale.zh-Hans" = "简体";
/* Locale menu items (settings) */
"locale.auto" = "跟随系统";
"locale.zh-Hans" = "中文(简体)";
@@ -245,7 +243,7 @@
/* v0.3.0:键盘顶栏的输入场景芯片 */
"keyboard.appContext.a11y" = "润色场景";
"keyboard.appContext.a11yHint" = "点击可手动切换输入场景(代码/邮件/聊天/文档),覆盖自动检测结果。";
"keyboard.appContext.a11yHint" = "轻点可手动切换输入场景(代码/邮件/聊天/文档),覆盖自动检测结果。";
"keyboard.appContext.chip.code" = "代码";
"keyboard.appContext.chip.email" = "邮件";
"keyboard.appContext.chip.chat" = "聊天";
@@ -261,7 +259,7 @@
"keyboard.edit.hint.available" = "长按编辑上一条";
"keyboard.edit.error.noTarget" = "没有可编辑的上一条输入";
"keyboard.edit.error.llmUnavailable" = "请先在主 App 配置可用的 AI 服务";
"keyboard.edit.error.startTimeout" = "麦克风启动超时,点击重试";
"keyboard.edit.error.startTimeout" = "麦克风启动超时,轻点重试";
"keyboard.edit.error.processing" = "编辑失败,请重试";
"keyboard.edit.error.processingTimeout" = "编辑超时,请重试";
"keyboard.edit.error.unchanged" = "内容没有变化,请换一种说法";
@@ -273,9 +271,9 @@
"keyboard.edit.page.original" = "原文";
"keyboard.edit.page.edited" = "编辑后";
"keyboard.edit.helper.speak" = "说话编辑文字";
"keyboard.edit.helper.finish" = "点击完成编辑";
"keyboard.edit.helper.finish" = "轻点完成编辑";
"keyboard.edit.helper.compare" = "左右滑动对比";
"keyboard.edit.helper.apply" = "点击应用编辑";
"keyboard.edit.helper.apply" = "轻点应用编辑";
"keyboard.edit.helper.append" = "插入当前位置";
"keyboard.edit.close" = "关闭编辑模式";
"keyboard.edit.closeHint" = "放弃本次编辑并返回语音输入。";
@@ -284,7 +282,7 @@
"keyboard.edit.stop" = "完成编辑指令";
/* AI 问答模式 */
"keyboard.ai.placeholder" = "点击麦克风向 AI 提问";
"keyboard.ai.placeholder" = "轻点麦克风向 AI 提问";
"keyboard.ai.hintA11yPrefix" = "建议:";
"keyboard.ai.hint" = "先插入 AI 回答,再按发送";
"keyboard.ai.listening" = "正在聆听…";
@@ -297,10 +295,15 @@
"keyboard.ai.sent" = "已发送";
"keyboard.ai.cancel" = "取消 AI 问答";
"keyboard.ai.cancelHint" = "取消当前录音、识别或 AI 请求。";
"keyboard.ai.sendA11y" = "首次点击插入 AI 回答;在支持发送的输入框中再次点击发送";
"keyboard.ai.sendA11y" = "首次轻点插入 AI 回答;在支持发送的输入框中再次轻点发送";
"keyboard.ai.startA11y" = "开始向 AI 提问";
"keyboard.ai.stopA11y" = "结束提问并发送给 AI";
"keyboard.assistant.longPressCoach" = "轻点听写 · 长按问 AI";
"keyboard.assistant.autoReply.guidance.cta" = "试试 AI 自动回复";
"keyboard.assistant.autoReply.guide.title" = "让 AI 替你回复";
"keyboard.assistant.autoReply.guide.body" = "开启自动回复后,一复制消息,键盘就自动为你起草可发送的回复。";
"keyboard.assistant.autoReply.guide.close" = "暂不";
"keyboard.assistant.autoReply.guide.closeHint" = "关闭自动回复建议。";
"keyboard.assistant.micA11y" = "语音与 AI";
"keyboard.assistant.micHint" = "轻点开始语音听写,长按向 AI 提问。";
"keyboard.assistant.dictationAction" = "开始语音听写";
@@ -309,6 +312,8 @@
"keyboard.assistant.delete" = "删除";
"keyboard.assistant.sendHint" = "执行当前输入框的发送、搜索、完成或前往操作。";
"keyboard.assistant.insertPending" = "插入保留的 AI 回答";
"keyboard.assistant.insertTranslation" = "插入译文";
"keyboard.assistant.insertReply" = "插入回信";
"keyboard.assistant.discardPending" = "丢弃 AI 回答";
"keyboard.assistant.discardPendingHint" = "丢弃因光标或输入框变化而保留的回答。";
"keyboard.ai.replyVariant.ordinary" = "普通";
@@ -326,6 +331,10 @@
"keyboard.ai.replyVariant.clarificationDirect" = "直接回应";
"keyboard.ai.replyVariant.clarificationQuestion" = "追问关键点";
"keyboard.ai.replyVariant.clarificationConfirm" = "确认并追问";
"keyboard.ai.replyVariant.answerAffirmative" = "肯定";
"keyboard.ai.replyVariant.answerNegative" = "否定";
"keyboard.ai.replyVariant.answerConditional" = "视情况";
"keyboard.ai.replyVariant.answerDefer" = "待确认";
"keyboard.ai.replyVariant.insertHint" = "插入这条完整回复。";
"keyboard.assistant.dismissClipboard" = "关闭剪贴板建议";
"keyboard.assistant.dismissClipboardHint" = "隐藏当前剪贴板摘要和技能。";
@@ -351,9 +360,10 @@
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
"keyboard.ai.skill.reply" = "回复";
"keyboard.ai.skill.playfulReply" = "俏皮回复";
"keyboard.ai.skill.speakAsMe" = "我来说";
"keyboard.ai.skill.playfulReply" = "俏皮";
"keyboard.ai.skill.replyInSourceLanguage" = "回复";
"keyboard.ai.skill.summarize" = "总结要点";
"keyboard.ai.skill.summarize" = "总结";
"keyboard.ai.skill.openLink" = "打开";
"keyboard.ai.skill.summarizeWebPage" = "总结网页";
"keyboard.ai.skill.callPhone" = "拨打";
@@ -364,11 +374,11 @@
"keyboard.ai.skill.declineInvitation" = "委婉拒绝";
"keyboard.ai.skill.acceptTask" = "确认任务";
"keyboard.ai.skill.clarifyRequest" = "澄清追问";
"keyboard.ai.skill.empathyReply" = "共情回复";
"keyboard.ai.skill.empathyReply" = "共情";
"keyboard.ai.skill.blessingReply" = "回复祝福";
"keyboard.ai.skill.askForDetails" = "询问细节";
"keyboard.ai.skill.businessReply" = "商务回复";
"keyboard.ai.skill.organizeList" = "整理清单";
"keyboard.ai.skill.businessReply" = "商务";
"keyboard.ai.skill.organizeList" = "整理";
"keyboard.ai.skill.extractTodos" = "待办";
"keyboard.ai.skill.extractEvents" = "日程";
"keyboard.ai.skill.saveToNotes" = "备忘录";
@@ -386,9 +396,18 @@
"keyboard.ai.skill.extractingAddress" = "正在识别地址…";
"keyboard.ai.skill.namingNote" = "正在生成标题…";
"keyboard.ai.skill.runningShortcut" = "正在运行捷径…";
"keyboard.ai.skill.savingReminders" = "正在加入提醒事项…";
"keyboard.ai.skill.savingEvents" = "正在加入日历…";
"keyboard.ai.skill.openingMaps" = "正在打开地图…";
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
"keyboard.ai.skill.openLinkFailed" = "无法打开该链接";
"keyboard.ai.skill.callPhoneFailed" = "无法拨打该号码";
"keyboard.ai.skill.createContactFailed" = "无法打开新建联系人页面";
/* 按键 / 面板无障碍标签 */
"keyboard.typing.candidates.collapseA11y" = "收起候选";
"keyboard.typing.candidates.expandA11y" = "更多候选";
"keyboard.key.shiftA11y" = "上档";
"keyboard.key.deleteA11y" = "删除";
"keyboard.key.returnA11y" = "回车";