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() {