3f60436d84
contains(where:) on String iterates Characters; comparing each to
multi-character strings like "\n " is a type error. Use
tail.contains("\n ") || tail.contains("\t") instead.
Co-authored-by: Rocky <hkgood@users.noreply.github.com>
176 lines
6.8 KiB
Swift
176 lines
6.8 KiB
Swift
// AppContextDetector.swift
|
||
// OSGKeyboard · Shared
|
||
//
|
||
// iOS Custom Keyboard Extensions run in a tight sandbox: we cannot
|
||
// read the foreground app's bundle ID, we cannot query
|
||
// `LSApplicationWorkspace`, and we cannot observe app switches.
|
||
// The only signals available to the extension are:
|
||
//
|
||
// - the text already at the cursor (`textDocumentProxy`)
|
||
// - the current keyboard input language
|
||
// - the time of day (used as a very weak signal)
|
||
//
|
||
// So we infer context with a **3-fallback chain**:
|
||
// 1. **Heuristic on preceding text** — strongest signal when the
|
||
// user has already typed enough. Catches code, email, chat,
|
||
// and document. We only look at the tail of the preceding
|
||
// text (up to `precedingScanWindow` characters) so a long
|
||
// note does not spend cycles scanning the whole buffer.
|
||
// 2. **Cached value** — when the user just opened a new field
|
||
// with no preceding text, reuse the last detection for up to
|
||
// `cacheLifetime`. Most users type in the same app for a
|
||
// while; this avoids a cold-start `unknown` that would force
|
||
// a neutral-tone LLM call.
|
||
// 3. **Environmental fallback** — when both above miss, blend
|
||
// input language + hour-of-day into a soft default.
|
||
//
|
||
// Anything we cannot resolve maps to `.unknown`, which the polish
|
||
// service translates to a neutral-tone prompt.
|
||
|
||
import Foundation
|
||
|
||
public struct AppContextDetector: Sendable {
|
||
/// How many characters of the preceding text we scan for
|
||
/// heuristic matches. Long enough to capture a code block, a
|
||
/// mail header, or a chat thread; short enough to scan in O(n)
|
||
/// on every keystroke.
|
||
public let precedingScanWindow: Int
|
||
|
||
/// How long a cached detection stays valid. 30 minutes matches
|
||
/// the "typical typing session" length and means the cache
|
||
/// rarely outlives a switch to a genuinely new app.
|
||
public let cacheLifetime: TimeInterval
|
||
|
||
public init(
|
||
precedingScanWindow: Int = 2000,
|
||
cacheLifetime: TimeInterval = 30 * 60
|
||
) {
|
||
self.precedingScanWindow = precedingScanWindow
|
||
self.cacheLifetime = cacheLifetime
|
||
}
|
||
|
||
public func detect(
|
||
precedingText: String?,
|
||
storedCache: (context: AppContext, observedAt: Date)?,
|
||
now: Date = Date()
|
||
) -> AppContext {
|
||
// Fallback 1: heuristic on preceding text. Even one strong
|
||
// signal (indented line ending with `{`, `> ` quote,
|
||
// email pattern) is enough — we never mix-and-match.
|
||
if let preceding = precedingText, !preceding.isEmpty,
|
||
let detected = heuristicDetect(preceding: preceding) {
|
||
return detected
|
||
}
|
||
|
||
// Fallback 2: cache. We rely on the caller having written
|
||
// a fresh detection to the App Group on every successful
|
||
// pressBegan; we just consult the timestamp here.
|
||
if let cached = storedCache,
|
||
now.timeIntervalSince(cached.observedAt) < cacheLifetime {
|
||
return cached.context
|
||
}
|
||
|
||
// Fallback 3: environmental. Not great, but better than
|
||
// `unknown` for a polished experience.
|
||
return environmentalFallback(now: now)
|
||
}
|
||
|
||
// MARK: - Heuristic detection
|
||
|
||
/// Inspect the tail of the preceding text. The order of the
|
||
/// branches is significant: more specific signals first (code,
|
||
/// terminal) so they win over more generic ones (chat,
|
||
/// document).
|
||
internal func heuristicDetect(preceding: String) -> AppContext? {
|
||
let tail = preceding.suffix(precedingScanWindow)
|
||
guard !tail.isEmpty else { return nil }
|
||
|
||
// Code: indented line + a code-y keyword in the recent past.
|
||
// The two-condition test avoids false positives on indented
|
||
// lists / block quotes.
|
||
let codeKeywords = [
|
||
"func ", "class ", "struct ", "enum ", "protocol ",
|
||
"import ", "package ", "namespace ",
|
||
"def ", "var ", "let ", "const ",
|
||
"if (", "if (", "} else", "} catch",
|
||
"=> {", "-> {",
|
||
]
|
||
let hasIndentation = tail.contains("\n ") || tail.contains("\t")
|
||
let hasCodeKeyword = codeKeywords.contains(where: { tail.contains($0) })
|
||
if hasIndentation, hasCodeKeyword {
|
||
return .code
|
||
}
|
||
|
||
// Code: shebang / single-line comment / URL-with-query.
|
||
if tail.hasPrefix("#!/") || tail.contains("\n#!/") {
|
||
return .code
|
||
}
|
||
|
||
// Terminal: prompt markers (rough but rarely wrong on
|
||
// dedicated terminal apps). `$ `, `# `, `❯ `, `➜ `.
|
||
if tail.range(of: #"(^|\n)[$#❯➜] "#, options: .regularExpression) != nil {
|
||
return .code
|
||
}
|
||
|
||
// Email: contains an email-shaped token in the recent past.
|
||
// We deliberately keep the regex conservative to avoid
|
||
// matching every "@" in code / handles.
|
||
if tail.range(
|
||
of: #"\b[\w.+-]+@[\w-]+\.[A-Za-z]{2,}\b"#,
|
||
options: .regularExpression
|
||
) != nil {
|
||
return .email
|
||
}
|
||
|
||
// Email: subject-style opening — "Subject:", "To:", "From:",
|
||
// "Cc:", or common CN mail domains in the URL bar.
|
||
let emailOpeners = ["Subject:", "Re: ", "Fwd: ", "From:", "To:"]
|
||
if emailOpeners.contains(where: { tail.contains($0) }) {
|
||
return .email
|
||
}
|
||
|
||
// Chat: lots of short lines, no big paragraphs.
|
||
let lines = tail.split(separator: "\n", omittingEmptySubsequences: false)
|
||
.suffix(20)
|
||
if lines.count >= 3 {
|
||
let nonEmpty = lines.filter { !$0.isEmpty }
|
||
let allShort = nonEmpty.count >= 3
|
||
&& nonEmpty.allSatisfy { $0.count < 60 }
|
||
if allShort {
|
||
return .chat
|
||
}
|
||
}
|
||
|
||
// Document: long unbroken paragraphs.
|
||
let lastParagraph = tail.split(separator: "\n\n").last ?? ""
|
||
if lastParagraph.count > 200 && !lastParagraph.contains("\n") {
|
||
return .document
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// MARK: - Environmental fallback
|
||
|
||
/// Last-resort guess. Deliberately biased toward "document" /
|
||
/// "email" over "chat" because people who can no longer be
|
||
/// classified are usually writing something more formal than
|
||
/// not — and the cost of over-classifying as chat is a casual
|
||
/// prompt that we can easily recover from.
|
||
internal func environmentalFallback(now: Date) -> AppContext {
|
||
let hour = Calendar.current.component(.hour, from: now)
|
||
// 9am-6pm: assume document / work context. 8pm-7am: assume
|
||
// chat. Weekends: lean chat. The signal is weak but it
|
||
// beats random.
|
||
let isWorkHours = (9...18).contains(hour)
|
||
let isWeekend = Calendar.current.isDateInWeekend(now)
|
||
if isWorkHours, !isWeekend {
|
||
return .document
|
||
}
|
||
if !isWorkHours || isWeekend {
|
||
return .chat
|
||
}
|
||
return .unknown
|
||
}
|
||
}
|