Skip to content

fix: add clipboard fallback for Write Mode selection reading (#259)#387

Open
postoso wants to merge 4 commits into
altic-dev:mainfrom
postoso:fix/259-writemode-clipboard
Open

fix: add clipboard fallback for Write Mode selection reading (#259)#387
postoso wants to merge 4 commits into
altic-dev:mainfrom
postoso:fix/259-writemode-clipboard

Conversation

@postoso

@postoso postoso commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

What

Adds a clipboard (synthetic Cmd+C) fallback so Write/Rewrite mode can read the selected text in apps whose Accessibility tree doesn't expose it — Electron / GPU-rendered editors (Zed, VS Code, Chrome/Docs, Obsidian). Previously selection capture returned nil there and the feature silently degraded to a fresh prompt instead of rewriting the selection.

How

  • TextSelectionService.getSelectedText() gains a third fallback after the two AX strategies: synthesize Cmd+C, poll the pasteboard changeCount until the target app writes (bounded timeout, since Cmd+C is async), read the copied string, then restore the user's previous pasteboard.
  • The pasteboard snapshot/restore logic is extracted from TypingService into a shared PasteboardSnapshot value type so the paste path and this read path share one implementation.

Robustness

  • Layout-aware Cmd+C — the copy key code is resolved against the active keyboard layout via the same TIS / UCKeyTranslate mechanism the Cmd+V paste path uses (now extracted into a shared LayoutAwareKeyCode), so it lands on the correct physical key on non-QWERTY layouts (Dvorak/AZERTY/…), falling back to the ANSI key code when layout data is unavailable.
  • Restore defended against a late copy — if a slow app processes the synthetic Cmd+C after the read timeout, its delayed pasteboard write would otherwise clobber the restored clipboard. After restoring, a short, doubly-bounded settle watch (≤200 ms / ≤3 re-restores) re-restores the user's snapshot if a late write lands, so the user's clipboard is reliably preserved and the copied selection is never left behind.

Verification

swiftlint --strict clean; xcodebuild build succeeds; unit tests for the shared layout-aware key-code util pass. (The pasteboard settle-watch involves real async copy timing that isn't unit-simulable; its correctness is argued from the bounded change-count bookkeeping.)

Closes #259

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f46be944e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +69 to +71
let pasteboard = NSPasteboard.general
let snapshot = PasteboardSnapshot.capture(from: pasteboard)
let changeCountBeforeCopy = pasteboard.changeCount

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize selection-copy fallback with pasteboard writes

This fallback snapshots and restores NSPasteboard.general without coordinating with TypingService.withTemporaryPasteboardString, whose reliable-paste path keeps a temporary string on the same pasteboard until its async restore completes under pasteboardSessionSemaphore. If the user starts another rewrite during that restore window, TypingService's restore can change changeCount while this code is waiting for Cmd+C, causing the previous clipboard/temporary insertion text to be treated as the selected context and then restoring the wrong snapshot. Please run this copy fallback under the same pasteboard session/lock or otherwise wait for Fluid's pending pasteboard restore to finish before sampling changeCount.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47516f8a8f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// timeout we proceed unguarded (no worse than the uncoordinated behavior this guard
// replaces), the main run loop drains, the paste's `main.sync` completes, and the
// session frees. We always release before returning (the deferred `endExclusive`).
let acquiredSession = PasteboardSession.tryBeginExclusive(timeoutMicros: Self.pasteboardSessionWaitMicros)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid freezing Edit hotkey behind paste restores

In the Edit/Write hotkey path this selection capture runs on the MainActor via RewriteModeService.captureSelectedText(), so if a previous reliable-paste/clipboard insertion is still holding PasteboardSession for its up-to-5s verification window (common when the target app cannot expose readable AX text and verification times out), this semaphore wait blocks the main thread for the full 5.5s before recording can start and the app appears unresponsive. Please avoid synchronously waiting the whole restore window on the main thread; use a non-blocking/short wait fallback or move the capture off-main.

Useful? React with 👍 / 👎.

// Always restore the user's previous pasteboard, then defend that restore against a
// late synthetic-copy write (a slow/busy target app processing our Cmd+C after the
// read timeout would otherwise clobber the restored clipboard — see the method doc).
self.restoreClipboardDefensively(snapshot, to: pasteboard)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not rewrite the clipboard when copy timed out

When the target app ignores Cmd+C because there is no selection, didChange is false, but this still clears and rewrites the user's previous clipboard and then watches for late writes. Since PasteboardSnapshot only restores types for which data(forType:) was captured, entering Write Mode with no selection can unnecessarily damage complex clipboard contents even though Fluid never observed a copy; restore only after an actual pasteboard change, or wait for a late change before restoring.

Useful? React with 👍 / 👎.

@postoso

postoso commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto 1.6.0 (current main). Clean replay, no conflicts, the fix is unchanged. xcodebuild builds green and swiftlint --strict is clean. Ready to pull.

postoso and others added 4 commits June 26, 2026 00:17
…ev#259)

Write/Rewrite mode could not read selected text from external apps
(Zed, VS Code, Chrome/Docs, Obsidian) because TextSelectionService was
AX-tree-only: when kAXFocusedUIElementAttribute returns nothing for
Electron / GPU-rendered editors, both AX strategies fail and selection
capture returned nil, silently degrading to a fresh prompt instead of
rewriting the selection.

Add a third fallback in getSelectedText(): synthesize Cmd+C, poll the
pasteboard changeCount until the target app writes (bounded timeout,
since Cmd+C is async), read the copied string, then always restore the
user's previous pasteboard contents. An empty selection produces no
pasteboard write, so it times out and returns nil, preserving the
existing write-mode behavior.

Extract the existing pasteboard snapshot/restore logic from
TypingService into a shared PasteboardSnapshot value type so both the
paste-insertion path and the new selection-read path use one
full-fidelity implementation instead of duplicating it.
…t-aware Cmd+C (altic-dev#259)

Address two issues found in code review of the synthetic-Cmd+C selection
fallback added in d25796b.

1. Late-write clipboard clobber. getSelectedTextViaClipboard() restored the
   user's pasteboard unconditionally right after the 300ms copy-wait timeout.
   A slow/busy target app could process the synthetic Cmd+C *after* that
   restore, and its delayed pasteboard write would clobber the restored
   clipboard — leaving the copied selection where the user's content belongs,
   breaking the "always restore the user's clipboard" guarantee.

   Replace the unconditional restore with restoreClipboardDefensively(_:to:):
   restore, record the change count our own restore produced, then poll a
   short bounded settle window; any further write (the app's late copy) is
   re-restored. Doubly bounded (200ms total / max 3 re-restores) so it cannot
   hang, and every exit path ends having just re-restored the user's snapshot
   so it cannot leave the selection on the clipboard. Synchronous on the main
   thread, consistent with the existing copy-wait; only paid on this
   last-resort fallback path.

2. Cmd+C key code was not layout-aware. postSyntheticCopy() hard-coded
   kVK_ANSI_C, so on Dvorak/AZERTY/QWERTZ it posted Cmd+<wrong-char> and never
   copied. Extract TypingService's existing TIS/UCKeyTranslate layout lookup
   (used for Cmd+V) into a shared LayoutAwareKeyCode enum and route both the
   Cmd+V paste path and the new Cmd+C path through it, re-evaluated per call so
   runtime layout switches are picked up, with the ANSI key code as fallback.

Add LayoutAwareKeyCode unit tests (resolves Latin chars, falls back for an
unmappable character, deterministic across calls).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… session (altic-dev#259)

The Write/Rewrite selection-read fallback (TextSelectionService.
getSelectedTextViaClipboard) snapshotted and restored NSPasteboard.general
around a synthetic Cmd+C without coordinating with TypingService's paste
path. TypingService.withTemporaryPasteboardString keeps a temporary string
on the same pasteboard and defers the user-clipboard restore onto a
background queue, holding pasteboardSessionSemaphore until that async restore
completes (up to ~5s later). If a new rewrite's selection read sampled
changeCount while a prior paste's restore was still pending, the paste's
writes were mistaken for the user's copy (wrong selection captured) and the
two snapshot/restores fought over the real pasteboard.

Extract the semaphore + restore queue out of TypingService into a shared
PasteboardSession type (mirroring the PasteboardSnapshot / LayoutAwareKeyCode
extractions) so both subsystems share one mutual-exclusion primitive instead
of racing parallel locks. The selection read now acquires the same session
(bounded wait) around its snapshot -> Cmd+C -> read -> restore sequence, so it
cannot sample changeCount or touch the pasteboard while a paste session is in
flight.

The acquire is a timed wait (tryBeginExclusive). This is load-bearing for
deadlock-freedom: the paste path resolves the layout-aware Cmd+V key code via
LayoutAwareKeyCode, which does DispatchQueue.main.sync when off-main, so a
background paste can hold the session while waiting on the main thread; a
blocking acquire on the main-thread selection read would let the two wait on
each other. The timeout breaks that inversion (on expiry the read proceeds
unguarded, no worse than the prior uncoordinated behavior) and bounds any
main-thread stall.

TypingService keeps identical behavior (pure rename to the shared session).
Adds PasteboardSession unit tests; LayoutAwareKeyCode tests still pass.
Use a short non-blocking pasteboard session wait and skip the synthetic-copy selection fallback on contention to avoid main-thread stalls.

Only restore the pasteboard after an observed copy write, preserving complex clipboard contents when Cmd+C times out without changing the pasteboard.
@postoso postoso force-pushed the fix/259-writemode-clipboard branch from 110b975 to c7fbc7f Compare June 26, 2026 04:22

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7fbc7fdb5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

layoutPtr,
keyCode,
UInt16(kUCKeyActionDisplay),
0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve shortcut keys with Command held

For input sources with a separate Command layer, such as macOS's Dvorak-QWERTY ⌘ layout, the key that produces c/v with no modifiers is not the key that produces the Copy/Paste menu equivalent while Command is down. Because this scan passes modifier state 0 but the callers later post the returned key with .maskCommand, the clipboard fallback can send a different shortcut instead of Cmd+C/Cmd+V on those layouts. Please translate using the Command modifier state that will be posted, or otherwise preserve the ANSI key for command-preserving layouts.

Useful? React with 👍 / 👎.

@postoso

postoso commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7fbc7fdb5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

)

// Read the copied selection only if the pasteboard actually changed.
let copiedText = didChange ? pasteboard.string(forType: .string) : nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid treating copy-current-line as selected text

When the AX paths fail in editors that copy the current line on an empty selection (for example VS Code with its default empty-selection clipboard behavior), this fallback sees the Cmd+C pasteboard change and returns that line as if it were selected text. That makes pressing the Write/Edit hotkey with only a caret enter rewrite mode using the current line as context instead of the intended no-selection Write Mode path.

Useful? React with 👍 / 👎.

@postoso

postoso commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7fbc7fdb5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +632 to +633
let cKey = LayoutAwareKeyCode.virtualKeyCode(for: "c", qwertyFallback: sentinel)
let vKey = LayoutAwareKeyCode.virtualKeyCode(for: "v", qwertyFallback: sentinel)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid depending on the user's keyboard layout

This test is tied to whatever input source is active on the machine running the suite. When a developer or CI host has a non-Latin input method selected, no GUI keyboard layout data available, or any input source where TISGetInputSourceProperty(...UnicodeKeyLayoutData) is nil, LayoutAwareKeyCode correctly returns the supplied fallback by design, but the sentinel here makes that path fail the test even though production behavior is valid. Consider forcing a known test layout, or making this assertion conditional on layout data being available.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[🐞 BUG] Write Mode cannot read selected text from any Application

1 participant