Describe the bug
When dictating repeatedly into Ghostty, the first insertion is instant (~45 ms). A second dictation started shortly after is transcribed correctly, but the text insertion stalls for seconds before the text appears.
The stall happens entirely inside the insertion call. Nothing is logged during it. The same rapid-fire pattern in a normal Cocoa text view (CotEditor) never stalls.
Steps to Reproduce
- Focus Ghostty (
com.mitchellh.ghostty).
- Dictate a short sentence, stop. Text appears immediately.
- Within a few seconds, dictate a second short sentence and stop.
- Observe the delay before the second text is inserted.
- Repeat in CotEditor (or any standard
NSTextView) — no delay.
Actual behavior
Measured from my own ~/Library/Logs/Fluid/Fluid.log, one session, 40 insertions:
- 37 insertions:
totalMs between 42 and 53
- 3 insertions:
totalMs = 2342, 7450, 8889
All three slow insertions targeted Ghostty. All 8 CotEditor insertions in the same session completed at totalMs=0.
The full delay sits between insert_call and insert_return, with no log output in between:
[14:53:10.483] [INFO] [AppBenchmark] APP_BENCH text_ready chars=31
[14:53:10.483] [DEBUG] [ContentView] Typing decision → frontmost: Ghostty, fluidFrontmost: false, editorFocused: true, willTypeExternally: true
[14:53:10.483] [INFO] [AppBenchmark] APP_BENCH focus_restore_result activated=false element=true elapsedMs=0 reason=already_focused
[14:53:10.483] [INFO] [TypingBenchmark] TYPING_BENCH request chars=31 mode=standard autocompleteSteps=1 preferredPID=2181 textReadyAgeMs=8
[14:53:10.483] [INFO] [TypingBenchmark] TYPING_BENCH worker_start queueDelayMs=0
[14:53:10.483] [INFO] [TypingBenchmark] TYPING_BENCH settle_delay_done delayMs=0 elapsedMs=0
[14:53:10.483] [INFO] [TypingBenchmark] TYPING_BENCH insert_call
[14:53:19.372] [INFO] [TypingBenchmark] TYPING_BENCH insert_return elapsedMs=8889 totalMs=8889
ASR is not involved: final_done elapsedMs=48, text_ready 8 ms later, focus restore 0 ms, queue delay 0 ms.
Expected behavior
Consecutive dictations into a terminal insert as fast as into any other app. Clipboard restoration is an internal concern and must not block the next insertion.
Root cause
In Sources/Fluid/Services/TypingService.swift:
-
Ghostty is detected by bundle identifier and, in standard mode, forced onto the reliable-paste path:
if self.textInsertionMode == .standard,
let ghosttyTargetPID = self.ghosttyTargetPID(preferredTargetPID: preferredTargetPID)
{
self.log("[TypingService] Ghostty target detected in standard mode (PID \(ghosttyTargetPID)); forcing Reliable Paste path")
-
That path calls withTemporaryPasteboardString(text, restoreDelayMicros: 5_000_000).
-
withTemporaryPasteboardString acquires the process-global pasteboardSessionSemaphore (DispatchSemaphore(value: 1)) on entry and deliberately does not release it on return — it sets releasesPasteboardSessionOnReturn = false and hands the release to an async task on pasteboardRestoreQueue.
-
That task first runs waitForFocusedTextVerification(..., timeoutMicros: 5_000_000), polling every 50 ms, before signalling the semaphore.
-
waitForFocusedTextVerification can only succeed via one of four checks — appScriptValue.contains(expectedText), an AppleScript caret-distance match, current.value.contains(expectedText), or an AX caret-distance match. A GPU-rendered terminal exposes neither an AX text value nor a text-field-style selected range, so none of the four can ever match. The loop always runs the full 5 s and returns .timeout.
-
The next insertion blocks at pasteboardSessionSemaphore.wait() for the remainder of that window. With several dictations in quick succession the windows stack, which is where the 7–9 s figures come from.
So the stall is not Ghostty being slow — it is a global lock held for a fixed 5 s whenever the target app cannot satisfy the verification, which is always true for terminals.
Suggested fix
The semaphore currently guards two unrelated concerns: (a) not clobbering another session's pasteboard snapshot, and (b) waiting long enough before restoring the clipboard. Only (a) needs to serialize insertions, and it does not need to be held across the poll.
Options, roughly in order of preference:
- Release the semaphore once the paste has been dispatched and the snapshot is owned, and let the restore task carry the snapshot as its own state. Serialize restores on
pasteboardRestoreQueue rather than blocking the insertion path.
- Skip verification entirely when the target cannot support it — if
captureFocusedTextSnapshot() yields no usable AX text value and no selected range (the terminal case), fall back to a short fixed delay instead of a 5 s poll. Ghostty is already special-cased by bundle ID, so the information is available at that point.
- Bound the wait for a queued insertion: if
pasteboardSessionSemaphore.wait(timeout:) expires, proceed rather than block indefinitely.
Note that switching TextInsertionMode is not a workaround: standard explicitly routes Ghostty onto the reliable-paste path, and reliablePaste uses the same function with the same 5 s value.
Environment
- FluidVoice: 1.6.7
- macOS: 26.6 (25G72), Apple Silicon (arm64)
- Target app: Ghostty 1.3.1 (
com.mitchellh.ghostty)
- Model: Parakeet TDT v3 (Multilingual)
TextInsertionMode = standard ("Clipboard Free Insert")
Possibly related
Describe the bug
When dictating repeatedly into Ghostty, the first insertion is instant (~45 ms). A second dictation started shortly after is transcribed correctly, but the text insertion stalls for seconds before the text appears.
The stall happens entirely inside the insertion call. Nothing is logged during it. The same rapid-fire pattern in a normal Cocoa text view (CotEditor) never stalls.
Steps to Reproduce
com.mitchellh.ghostty).NSTextView) — no delay.Actual behavior
Measured from my own
~/Library/Logs/Fluid/Fluid.log, one session, 40 insertions:totalMsbetween 42 and 53totalMs= 2342, 7450, 8889All three slow insertions targeted Ghostty. All 8 CotEditor insertions in the same session completed at
totalMs=0.The full delay sits between
insert_callandinsert_return, with no log output in between:ASR is not involved:
final_done elapsedMs=48,text_ready8 ms later, focus restore 0 ms, queue delay 0 ms.Expected behavior
Consecutive dictations into a terminal insert as fast as into any other app. Clipboard restoration is an internal concern and must not block the next insertion.
Root cause
In
Sources/Fluid/Services/TypingService.swift:Ghostty is detected by bundle identifier and, in
standardmode, forced onto the reliable-paste path:That path calls
withTemporaryPasteboardString(text, restoreDelayMicros: 5_000_000).withTemporaryPasteboardStringacquires the process-globalpasteboardSessionSemaphore(DispatchSemaphore(value: 1)) on entry and deliberately does not release it on return — it setsreleasesPasteboardSessionOnReturn = falseand hands the release to an async task onpasteboardRestoreQueue.That task first runs
waitForFocusedTextVerification(..., timeoutMicros: 5_000_000), polling every 50 ms, before signalling the semaphore.waitForFocusedTextVerificationcan only succeed via one of four checks —appScriptValue.contains(expectedText), an AppleScript caret-distance match,current.value.contains(expectedText), or an AX caret-distance match. A GPU-rendered terminal exposes neither an AX text value nor a text-field-style selected range, so none of the four can ever match. The loop always runs the full 5 s and returns.timeout.The next insertion blocks at
pasteboardSessionSemaphore.wait()for the remainder of that window. With several dictations in quick succession the windows stack, which is where the 7–9 s figures come from.So the stall is not Ghostty being slow — it is a global lock held for a fixed 5 s whenever the target app cannot satisfy the verification, which is always true for terminals.
Suggested fix
The semaphore currently guards two unrelated concerns: (a) not clobbering another session's pasteboard snapshot, and (b) waiting long enough before restoring the clipboard. Only (a) needs to serialize insertions, and it does not need to be held across the poll.
Options, roughly in order of preference:
pasteboardRestoreQueuerather than blocking the insertion path.captureFocusedTextSnapshot()yields no usable AX text value and no selected range (the terminal case), fall back to a short fixed delay instead of a 5 s poll. Ghostty is already special-cased by bundle ID, so the information is available at that point.pasteboardSessionSemaphore.wait(timeout:)expires, proceed rather than block indefinitely.Note that switching
TextInsertionModeis not a workaround:standardexplicitly routes Ghostty onto the reliable-paste path, andreliablePasteuses the same function with the same 5 s value.Environment
com.mitchellh.ghostty)TextInsertionMode = standard("Clipboard Free Insert")Possibly related