ADR-004: Correction-on-Commit via Selection-Replace¶
Context¶
cap-002 requires streaming partial transcription text to be displayed in the active window during recording, and then corrected to the final transcript when the user releases the hold-to-talk hotkey. This creates a state management problem: text that has been injected into a third-party application must later be replaced atomically and correctly, even though the daemon has no direct access to the application's text buffer or cursor state.
The inject/streaming.py:StreamingInjector component tracks how many characters have been injected as partial hypotheses since the start of the utterance (_chars_injected counter). When the final transcript is ready, the injector must:
- Remove exactly
_chars_injectedcharacters of partial text from the active window. - Replace them with the final transcript (after disfluency filtering, cap-004).
- Leave the cursor at the end of the newly injected text.
The challenge is to do this atomically and without corrupting the user's other content in the window. Four approaches were evaluated.
Decision¶
After the final transcript is ready, select all partial text injected since the start of the utterance using Shift+Left × _chars_injected (or Shift+Home when injected text is on the current line only), then inject the final text to replace the selection. Cursor offset tracking is maintained by StreamingInjector._chars_injected.
Implementation in src/yazses/inject/streaming.py:
def commit(self, final_text: str) -> None:
if self._chars_injected > 0:
# Select backward by the number of partial characters injected
self._backend.inject_key_sequence(
["shift+left"] * self._chars_injected
)
# Inject final text; the selection is replaced by the first keystroke
self._backend.inject(final_text)
self._chars_injected = 0
def cancel(self) -> None:
# User cancelled (hotkey released before speech, or command detected)
if self._chars_injected > 0:
self._backend.inject_backspaces(self._chars_injected)
self._chars_injected = 0
The inject_key_sequence method is added to InjectorBackend protocol and implemented in each platform backend using the same mechanisms as inject_backspaces (xdotool key shift+Left, ydotool key --key shift+left, SendInput with SHIFT modifier on Windows, CGEventPost with kVK_LeftArrow + NSShiftKeyMask on macOS).
For applications that do not support Shift+cursor selection (certain terminal emulators in raw mode, some embedded editors), the daemon falls back to the backspace-delete path described in FR-002.5: inject_backspaces(_chars_injected) followed by inject(final_text). The fallback is triggered when the platform injector reports selection_replace_supported = False.
WhisperPipe's 2026 architecture [EVIDENCE src-004] describes a similar cursor-offset approach for partial-to-final text replacement and reports a correction operation latency of 40–80 ms, well within NFR-002.2's 200 ms budget.
Rationale¶
Why not never inject partials and wait for final transcript (alternative a)?
This is the v0.2 behaviour and remains available via config.streaming.enabled = false. However, it defeats the primary user experience goal of cap-002: seeing live feedback during speech. The PRD explicitly states that users with RSI need to know the dictation is working and track their speech in real time. A 300–800 ms delay between speech and any visible feedback (current v0.2 latency for tiny.en) is sufficient for users to lose their place in a sentence and introduce speech errors [EVIDENCE src-002]. This alternative is kept as a config opt-out, not adopted as the default.
Why not a floating overlay window (alternative b)?
A floating overlay (a translucent borderless window positioned near the cursor) would display partial text without modifying the active application's text buffer. No selection-replace would be required. This approach is used by Superwhisper [EVIDENCE src-013] on macOS.
Problems on Linux and cross-platform: - On Linux/Wayland, creating a floating always-on-top borderless window requires a compositor-specific protocol (e.g., wlr-layer-shell for wlroots compositors, ext-session-lock for GNOME). There is no portable standard. A GTK/Qt overlay would work on X11 but not on all Wayland compositors, directly contradicting the platform-agnostic goal. - On Linux/X11, a floating window does not follow cursor position across application windows without polling xdotool getactivewindow to get the active application's window geometry — complex and brittle. - An overlay provides read-only feedback; the user cannot click into partial text to correct it mid-utterance. - The overlay approach requires the tray component (currently optional on Linux) to be mandatory, which increases the dependency surface.
The selection-replace approach works in every application that supports text selection — including terminals, browsers, IDEs, and text editors — via the same injection backends already used for text input.
Why not a clipboard diff (alternative c)?
A clipboard-based approach would work as follows: (1) read the clipboard before injection; (2) write final text to clipboard; (3) use Ctrl+Z to undo all partial keystrokes; (4) paste final text. Problems:
- Clipboard corruption: Reading and writing the clipboard has a side effect visible to the user and to other applications that monitor the clipboard (clipboard managers, password managers). The user's previous clipboard content is destroyed.
- Undo depth: Ctrl+Z undo is application-specific. Some applications (terminal emulators, vim) do not map Ctrl+Z to text undo at all; some have limited undo depth that would fail after long dictations; some (browsers) undo in DOM operation units rather than character units.
- Race condition: Between writing to the clipboard and pasting, another application may overwrite the clipboard. This is a real problem on Linux under X11 where clipboard ownership is event-driven.
- Latency: A clipboard write triggers a D-Bus/X11 event that propagates to all clipboard listeners before the paste can occur, adding 10–50 ms of OS event latency.
Why selection-replace is the right choice:
- It is atomic from the user's perspective: the Shift+Left sequence selects exactly the right characters, and the first character of the injected final text replaces the selection.
- It does not touch the clipboard.
- It works in all GUI applications where the injection backend has key-sequence injection capability (xdotool
key shift+Leftis universally supported). - The
_chars_injectedcounter is the only state needed; it is reset to 0 oncommit()orcancel(). - NFR-002.2 (≤200 ms for correction) is met: Shift+Left × N at 2 ms/keypress × max 200 chars = 400 ms worst case. For practical dictation (30–80 characters of partials), the correction takes 60–160 ms.
For long utterances where _chars_injected > 100, the daemon optimises by using Shift+Home (select to line start) if the partial text is on a single line, reducing the keypress count from N to 1.
Consequences¶
Positive: - Selection-replace is a well-understood, universal text editing operation supported by all GUI frameworks. - The _chars_injected counter is simple, deterministic, and testable without a real application window (mock injector in unit tests). - No clipboard side effects. User clipboard content is preserved throughout the dictation session. - Correction latency is proportional to _chars_injected, which in practice is small because only stable-prefix text is injected as partials (ADR-002). The common case is 20–60 characters, giving a correction time of 40–120 ms.
Negative: - If the user moves the cursor manually during a streaming session (e.g., clicks to reposition the cursor mid-utterance), _chars_injected will be wrong and the Shift+Left sequence will select the wrong range. The daemon cannot detect cursor movement in third-party applications without platform-specific accessibility APIs (ATK on Linux, Accessibility on macOS, UIAutomation on Windows). - Mitigation: The daemon treats any cursor movement detected via the hotkey state machine as a cancel signal. If the user clicks (releasing mouse button while hotkey is held), the hotkey backend emits an on_cursor_moved event and the daemon calls cancel() instead of commit(). - Residual risk: Keyboard-driven cursor movement (arrow keys) cannot be distinguished from the user's intentional navigation. This is a known limitation documented in the UX guidance. - The fallback (backspace-delete) for terminals in raw mode generates N backspace keypress events, which may be slower than ideal for long partials. This is acceptable because streaming partials in raw-mode terminals are less common (most users work in a normal editor). - inject_key_sequence is a new method on the InjectorBackend protocol. All three platform injectors (Linux xdotool/ydotool/wtype, macOS CGEventPost, Windows SendInput) must be updated to implement it before cap-002 can ship. This is estimated at ~40 LOC per platform backend.
Alternatives Considered¶
| Alternative | Reason Rejected |
|---|---|
| (a) Never inject partials; wait for final transcript | Defeats the UX goal of cap-002; remains available as streaming.enabled = false opt-out. |
| (b) Floating overlay window displaying partial text | No portable Linux/Wayland standard exists; requires compositor-specific protocol; read-only feedback only. [EVIDENCE src-013] |
| © Clipboard diff + Ctrl+Z undo | Corrupts user clipboard; undo is application-specific and unreliable in terminals/vim; clipboard write adds OS event latency; race condition under X11. |
| (d) Per-character streaming with no correction | Inject one character at a time as it is decoded with no correction mechanism. Rejected because Whisper does not produce per-character outputs; its token granularity is sub-word BPE tokens, and tokens at the acoustic boundary are frequently revised in the next decode pass. |