Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.3</string>
<string>1.0.4</string>
<key>CFBundleVersion</key>
<string>1.0.3</string>
<string>1.0.4</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
71 changes: 58 additions & 13 deletions Sources/EchoType/Paster.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ enum Paster {

@discardableResult
static func deliver(_ text: String) -> Outcome {
if focusedElementIsEditable() {
if hasTextTarget() {
paste(text)
return .pasted
}
Expand Down Expand Up @@ -44,32 +44,77 @@ enum Paster {
}
}

private static func focusedElementIsEditable() -> Bool {
private static let editableRoles: Set<String> = [
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<String> = [
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 = "<none>"
var roleValue: AnyObject?
if AXUIElementCopyAttributeValue(axElement, kAXRoleAttribute as CFString, &roleValue) == .success,
let role = roleValue as? String {
let editableRoles: Set<String> = [
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() {
Expand Down
Loading