Skip to content

Output: restore the clipboard only after the paste has read it - #66

Merged
dinooo13 merged 2 commits into
mainfrom
clipboard-receipt-restore
Sep 28, 2026
Merged

dinooo13 merged 2 commits into
mainfrom
clipboard-receipt-restore

Conversation

@dinooo13

@dinooo13 dinooo13 commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Experiment 3 from #40. The clipboard restore now waits for the target app to read the transcript, not only for a clock.

What changes

PasteboardOutput writes the transcript as a pasteboard promise (TranscriptPromise), with the same nspasteboard.org marker types as before. The target app's read comes back to Pladder as a call, and the old clipboard returns:

  • 400 ms after Cmd+V, as today, if the transcript has been read by then;
  • otherwise 200 ms after the read;
  • or after 8 s if nothing reads it (a paste into something that takes no text). The transcript stays on the clipboard that long, which is the safe way to be wrong.

A read never brings the clipboard back sooner than 400 ms. That differs from Handy's scheme (200 ms after the last read) on purpose: Chromium sometimes reads once as Cmd+V arrives and again when the page gets round to the paste, and the pasteboard serves the second read itself, so it never reaches us. Restoring 200 ms after the first read handed such a page the old clipboard.

The clipboard-only path without Accessibility and the send key's Return are unchanged. A new paste log category records when the target read the transcript, measured from Cmd+V.

Why

With the fixed 400 ms, an app whose main thread is busy when Cmd+V arrives reads the pasteboard after the restore and pastes the user's old clipboard. Measured on #40 with a prototype, and below with this branch's PasteboardOutput itself.

Correctness: the real PasteboardOutput, main vs this branch

A throwaway harness (not committed) drove prepare() and insert() into windows it opened itself, then selected all, copied, and checked what had landed. The busy page blocks its main thread for a second on Cmd+V, standing in for a heavy web or Electron app. M1, macOS 27.0.

Target main: transcript / old clipboard this branch: transcript / old clipboard Clipboard back after (branch)
TextEdit 5 / 0 5 / 0 406–460 ms
Safari 3 / 0 3 / 0 409–411 ms
Safari, busy page 0 / 6 6 / 0 1,217–1,227 ms
Helium (Chromium) 3 / 0 3 / 0 411–420 ms
Helium, busy page 0 / 6 5 / 1 1,215–1,221 ms (the miss: 407 ms)
Terminal 3 / 0 3 / 0 410–415 ms
Total 14 / 12 25 / 1

The one miss is the first paste into a freshly opened Helium page: Chromium read at 13 ms, then pasted a second later. It is the case the 400 ms floor exists for; main fails it too. Normal targets read 1 to 25 ms after Cmd+V (from the new log line).

Release-to-paste

The write is on the release-to-paste path, so per CLAUDE.md, before and after. The stage it changes is paste (TextOutput.insert): 30 pastes into TextEdit per run, alternating builds.

Build Run 1 median / p90 Run 2 median / p90
main 2.11 / 5.94 ms 2.69 / 6.55 ms
this branch 2.21 / 7.06 ms 2.29 / 8.26 ms

No difference beyond noise. The engine is untouched, so pladder-cli bench was not rerun.

Trade-offs

  • Pladder's main thread is now on the target's paste. AppKit serves promises on the main thread whatever thread wrote them, so a busy Pladder main thread delays the target's read. Tested standalone: with the main thread blocked for 1.5 s, an outside read waited 1.3 s. The main thread is mostly idle right after a paste; the clipboard read … after Cmd+V line shows it if not. That time comes after release-to-paste, which ends when Cmd+V is posted.
  • It relies on the marker types. Something in macOS (pboard handing the change to useractivityd, i.e. Universal Clipboard) reads every unmarked write about 15 ms after it lands, paste or not. With the markers, nothing read early in any check.
  • Anything in Pladder that reads the general pasteboard while the promise is pending counts as a read. prepare() was the only such reader and now skips its snapshot while our transcript is on the pasteboard. The harness hit exactly this before it was fixed.
  • Quitting Pladder within the wait leaves an unserved promise on the clipboard. Today it leaves the transcript. Rare either way.

Tests

swift test: all pass. New: the restore deadline rule (prompt read, late read, no read, read near the cap, read before Cmd+V), and a promise on a private named pasteboard serves the text with the markers, reports the read and round-trips a restore.

In the bundled app, with real dictation

scripts/bundle.sh build of this branch, launched as a test copy with its own settings file and hotkey (F19), dictating with say through the speakers into the microphone, into windows the driver opened itself:

Target Pasted Old clipboard back after release
TextEdit ×2, Helium, Terminal transcript (all words) 615–644 ms
Safari, busy page ×2 transcript 1,443–1,462 ms
Helium, busy page ×2 transcript once; old clipboard once (Chromium's early read, first paste into the fresh page) 1,396 / 638 ms
Finder (takes no text) — 623 ms (Finder reads the pasteboard too)

release-to-paste 0.179 to 0.221 s with paste at 1 to 2 ms; clipboard read 5 to 25 ms after Cmd+V for normal targets and about 1.01 to 1.04 s for the busy pages, so Pladder's main thread was never what the target waited for.

Not done

  • No Electron app was tested directly; Electron shares Chromium's clipboard code, and every Electron app on the test machine holds real work.

Refs #40

🤖 Generated with Claude Code

dinooo13 and others added 2 commits September 28, 2026 10:59
The transcript goes on the pasteboard as a promise, so the target app's
read comes back as a call. The old clipboard returns 400 ms after Cmd+V
as before if the transcript has been read by then, otherwise 200 ms after
the read, or after 8 s if nothing reads it. An app busy when Cmd+V
arrives reads late, and the fixed 400 ms alone handed it the user's old
clipboard: a page blocked for a second got the old clipboard 12 of 12
times on main and the transcript 11 of 12 times here. The twelfth is
Chromium reading once at Cmd+V and again at the paste, which main fails
too; a read never shortens the 400 ms for that reason.

The promise is served on the main thread, so the time from Cmd+V to the
first read is logged in the new `paste` category. prepare() no longer
snapshots the clipboard while our own transcript is still on it, which
would read our own promise from off the main thread. The clipboard-only
path without Accessibility is unchanged.

Refs #40

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLAUDE.md's Output row and clipboard risk, PERFORMANCE.md's restore line,
and the new `paste` log lines in BENCHMARKS.md.

Refs #40

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@dinooo13
dinooo13 merged commit 3bfdd25 into main Sep 28, 2026
1 check passed
@dinooo13
dinooo13 deleted the clipboard-receipt-restore branch September 28, 2026 18:42
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.

1 participant