From bfee2c5afa05ca4d7bdc161b92dc679ce42fc362 Mon Sep 17 00:00:00 2001 From: IQBAL HASAN Date: Sun, 6 Sep 2026 08:58:06 +0600 Subject: [PATCH] fix: paste transcript into terminals and low-accessibility apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1.0.3 clipboard-fallback check was too strict: it treated a focus as editable only for a matched AX role or a settable AXValue. Terminals (Terminal.app, iTerm2) and opaque apps (Warp, Ghostty) expose text through caret attributes or a non-standard focused view, so they fell through to the clipboard and the transcript was never typed at the cursor. Bias the detection toward pasting: paste unless there is clearly no text target — no focused element, or a recognized non-text control (button, checkbox, menu, slider, image, link…). Accept editable roles, a settable value, or the presence of AXSelectedTextRange / AXInsertionPointLineNumber as positive text-target signals. Log the focused role, settable, and caret flags so field behavior is diagnosable. Bump to 1.0.4. --- CHANGELOG.md | 6 +++ Resources/Info.plist | 4 +- Sources/EchoType/Paster.swift | 71 ++++++++++++++++++++++++++++------- 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e526bd..6895f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.4] - 2026-09-06 + +### Fixed + +- Dictation not pasting into terminals (Terminal.app, iTerm2, Warp, Ghostty) and other apps with non-standard accessibility: the transcript was diverted to the clipboard instead of being typed at the cursor. Paste detection is now biased toward pasting — it only falls back to the clipboard when there is clearly no text target (no focused element, or a recognized non-text control such as a button). The focused element's role and text attributes are logged to aid diagnosis. + ## [1.0.3] - 2026-09-05 ### Fixed diff --git a/Resources/Info.plist b/Resources/Info.plist index 32e299e..b7fc9d7 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.3 + 1.0.4 CFBundleVersion - 1.0.3 + 1.0.4 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/Sources/EchoType/Paster.swift b/Sources/EchoType/Paster.swift index 35520eb..c78bd2b 100644 --- a/Sources/EchoType/Paster.swift +++ b/Sources/EchoType/Paster.swift @@ -13,7 +13,7 @@ enum Paster { @discardableResult static func deliver(_ text: String) -> Outcome { - if focusedElementIsEditable() { + if hasTextTarget() { paste(text) return .pasted } @@ -44,32 +44,77 @@ enum Paster { } } - private static func focusedElementIsEditable() -> Bool { + private static let editableRoles: Set = [ + kAXTextFieldRole as String, + kAXTextAreaRole as String, + kAXComboBoxRole as String, + "AXSearchField", + ] + + // Recognized controls that never accept typed text — the only case where we + // divert to the clipboard instead of pasting. + private static let nonTextRoles: Set = [ + kAXButtonRole as String, + kAXCheckBoxRole as String, + kAXRadioButtonRole as String, + kAXPopUpButtonRole as String, + kAXMenuButtonRole as String, + kAXMenuItemRole as String, + kAXMenuRole as String, + kAXMenuBarRole as String, + kAXSliderRole as String, + kAXImageRole as String, + "AXLink", + "AXTab", + kAXDisclosureTriangleRole as String, + kAXColorWellRole as String, + ] + + /// Whether ⌘V would land somewhere useful. Biased toward pasting: only the + /// clear no-text cases (no focused element, or a recognized non-text control) + /// fall through to the clipboard. Poorly-accessible apps (terminals like + /// Warp/Ghostty) expose an opaque focused view — treat that as a text target + /// rather than losing the paste, matching pre-1.0.3 behavior. + private static func hasTextTarget() -> Bool { let system = AXUIElementCreateSystemWide() var focused: AnyObject? guard AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focused) == .success, let element = focused else { + Log.write("paste: no focused element — copying to clipboard") return false } let axElement = element as! AXUIElement + var role = "" var roleValue: AnyObject? if AXUIElementCopyAttributeValue(axElement, kAXRoleAttribute as CFString, &roleValue) == .success, - let role = roleValue as? String { - let editableRoles: Set = [ - kAXTextFieldRole as String, - kAXTextAreaRole as String, - kAXComboBoxRole as String, - "AXSearchField", - ] - if editableRoles.contains(role) { return true } + let r = roleValue as? String { + role = r } + let settable = isValueSettable(axElement) + let hasCaret = hasAttribute(axElement, kAXSelectedTextRangeAttribute as String) + || hasAttribute(axElement, kAXInsertionPointLineNumberAttribute as String) + Log.write("paste: focused role=\(role) settable=\(settable) caret=\(hasCaret)") + + if editableRoles.contains(role) { return true } + if settable || hasCaret { return true } + if nonTextRoles.contains(role) { return false } + // Unknown/opaque focus with something focused — paste rather than lose it. + return true + } + + private static func isValueSettable(_ element: AXUIElement) -> Bool { var settable: DarwinBoolean = false - if AXUIElementIsAttributeSettable(axElement, kAXValueAttribute as CFString, &settable) == .success { - return settable.boolValue + guard AXUIElementIsAttributeSettable(element, kAXValueAttribute as CFString, &settable) == .success else { + return false } - return false + return settable.boolValue + } + + private static func hasAttribute(_ element: AXUIElement, _ attr: String) -> Bool { + var value: AnyObject? + return AXUIElementCopyAttributeValue(element, attr as CFString, &value) == .success } private static func synthesizeCmdV() {