Promptshot has one job: you select text anywhere on macOS, press a hotkey, and the selection is replaced with a better version. No window appears, no app switches, no copy and paste. Describing it takes one sentence. Making it work in Slack, Mail, Notion, Xcode, Figma and a Chrome text area took considerably longer.

Here is what actually happens between the keypress and the replacement.

There is no API for “the selected text”

macOS does not offer a system-wide way to read the current selection. What it offers is the Accessibility API, which exposes an element tree per application, and if you are lucky the focused element implements AXSelectedText.

let system = AXUIElementCreateSystemWide()
var focused: CFTypeRef?
AXUIElementCopyAttributeValue(
  system, kAXFocusedUIElementAttribute as CFString, &focused
)

guard let element = focused as! AXUIElement? else { return nil }

var selection: CFTypeRef?
let status = AXUIElementCopyAttributeValue(
  element, kAXSelectedTextAttribute as CFString, &selection
)

When status comes back .success, we are done in under a millisecond and nothing about the user’s environment is disturbed. That is the happy path, and it covers native AppKit and Catalyst apps: Mail, Notes, Xcode, TextEdit, most of the system.

When the happy path fails

Electron apps, Chrome, and anything drawing its own text engine frequently return .attributeUnsupported, or return an element that reports no selection even though the user can plainly see one highlighted. Slack’s desktop client is in this category. So is Figma. So is Google Docs in any browser.

For those we fall back to synthesising ⌘C, reading the pasteboard, and restoring it afterwards:

let saved = NSPasteboard.general.pasteboardItems?.map { item in
  // Snapshot every representation, not just the string — otherwise we
  // destroy a copied image when we borrow the pasteboard.
  let copy = NSPasteboardItem()
  for type in item.types {
    if let data = item.data(forType: type) { copy.setData(data, forType: type) }
  }
  return copy
}

defer {
  NSPasteboard.general.clearContents()
  if let saved { NSPasteboard.general.writeObjects(saved) }
}

That defer block is the part worth stealing. The naive version of pasteboard borrowing saves stringForType: .string and restores that, which quietly destroys whatever image or rich-text payload the user had copied. Snapshotting every representation costs a few hundred microseconds and avoids a bug report that is very hard to diagnose from the user’s side.

Synthesised keystrokes are inherently racy. There is no completion callback for “the app finished handling ⌘C”, so we poll changeCount on the pasteboard with a 250 ms ceiling. Below about 40 ms of polling we miss slow Electron apps; above 300 ms the interaction stops feeling instant.

Writing the result back

Replacement has the same two-tier structure. If the element accepted an accessibility read, it will usually accept a write:

AXUIElementSetAttributeValue(
  element, kAXSelectedTextAttribute as CFString, replacement as CFString
)

This is the ideal path because it preserves the app’s own undo stack. ⌘Z restores the original text because the app recorded the change as a normal edit, not because we implemented undo ourselves.

Where writes are unsupported we place the replacement on the pasteboard and synthesise ⌘V. Undo still works, for the same reason — the app saw a paste.

The compromises

Three things we chose not to solve:

  1. Secure input fields. When any app enables secure input, the Accessibility API and synthetic events are both blocked. This is correct behaviour and we do not attempt to work around it. Promptshot shows a “not available here” indicator instead.
  2. Terminal selections. Terminal.app and iTerm2 expose selection but not writable selection, and pasting into a shell has consequences we should not guess at. We read, we do not replace.
  3. Very large selections. Above roughly 12,000 characters, the round trip stops feeling like an edit and starts feeling like a job. We cap it and say so rather than spinning.

What this buys

The result of all of the above is that the feature has no interface. There is no window to arrange, no panel to dismiss, no context to rebuild afterwards. You are in the Slack thread, you press a key, the sentence is better, and you keep typing.

Most of the engineering in Promptshot exists to protect that.

← All posts