From 0a373926feac75b5cbc9cf44f6c8e5f0a8eefc2a Mon Sep 17 00:00:00 2001 From: Amir KELLOU SIDHOUM Date: Sat, 26 Sep 2026 21:17:59 +0200 Subject: [PATCH 1/3] paste: check what landed, not just that the field grew, and repair scraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-09-26 (0024-8368): 156 characters typed into the Claude desktop composer, 21 arrived — the last character of each 16-char chunk, then the whole last chunk — and paste.verify said `observed` because the field had grown. The message was sent like that. - landing.rs compares the field's text with what we typed. Scraps of our text (a subsequence, under 90%) are `Partial`; anything the app itself rewrote or drew is not. - When scraps sit still for 80 ms, the verifier selects them over AX (read back to confirm) and retypes the text over them, once, unless the user moved app, started a recording, or touched the field. `paste.repair` records what it did. - If it cannot, the verdict is `partial`, the full text goes on the clipboard, and the pill says ⌘V (`paste_partial`). - paste.verify now counts hardware and foreign key-downs during the injection and when the user hit Return, to test the interleaving theory next time it happens. - TTP_FAULT_GARBLE= reproduces the incident on a real build. - scripts/inject-bench: the bench that measured injection in the Claude composer (everything lands with focus settled; typing 0 ms after the app regains focus loses the first chunks). Co-Authored-By: Claude Opus 5.5 --- docs/tracing.md | 26 +++ scripts/inject-bench/bench.swift | 225 ++++++++++++++++++++++ scripts/trace_analyser/invariants.py | 19 +- src-tauri/src/fnkey.rs | 7 + src-tauri/src/paste/accessibility.rs | 73 +++++++ src-tauri/src/paste/input_marks.rs | 75 ++++++++ src-tauri/src/paste/landing.rs | 203 ++++++++++++++++++++ src-tauri/src/paste/mod.rs | 5 +- src-tauri/src/paste/outcome.rs | 16 ++ src-tauri/src/paste/simulate.rs | 25 ++- src-tauri/src/transcription/pipeline.rs | 242 +++++++++++++++++++----- src/hooks/usePasteCompletion.test.tsx | 12 ++ src/hooks/usePasteCompletion.ts | 8 +- src/i18n/locales/en.json | 2 + src/i18n/locales/fr.json | 2 + src/lib/pasteOutcome.test.ts | 3 +- src/lib/pasteOutcome.ts | 14 ++ src/windows/FloatingBar.test.tsx | 7 + 18 files changed, 905 insertions(+), 59 deletions(-) create mode 100644 scripts/inject-bench/bench.swift create mode 100644 src-tauri/src/paste/input_marks.rs create mode 100644 src-tauri/src/paste/landing.rs diff --git a/docs/tracing.md b/docs/tracing.md index b4a32ae..c43ab62 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -221,6 +221,27 @@ anything from a missing launch line. ## `paste.verify` is the important one +Where the field reads back as text, the verifier also compares *what* arrived +with what was typed (`paste/landing.rs`). `text_complete` says whether any read +held the whole text. When scraps sit unchanged for 80 ms, a `paste.repair` line +follows: the verifier selected the scraps (AX range, read back to confirm) and +retyped the text over them. Its `outcome` is `repaired`, `late_complete` (a slow field finished on its own), `repair_failed` or +`skipped_` (`recording`, `app_changed`, `field_changed`: the user edited +or sent it, `no_selection`); `landed` carries the scraps when diagnostics are +on. `repaired` and `late_complete` give `observed` with that `reason`; any +other outcome gives `verdict:"partial"`, puts the full text on the clipboard +(`clipboard.recovery`) and the pill says ⌘V. Seen 2026-09-26 (`0024-8368`): 156 +characters typed into the Claude composer, 21 arrived, the last character of +each 16-character chunk and then the whole last chunk. Before this, that read +as `observed`. + +`user_keys`, `foreign_keys` and `user_return_ms` count the key-downs that +reached the HID tap between injection and the verdict: from the hardware +(Karabiner's virtual keyboard counts as hardware), from another process, and +how long after injection began the user pressed Return. Counts only, never +which key. They are there to test whether something interleaved with the +injection. + `verdict: "observed"` with `reason: "cleared_after_landing"` means the field grew (`peak_delta_chars` > 0) and then went back to how it started — a chat app that sent or queued the message as soon as it arrived. Before 2026-09-14 that read as `swallowed` and the pill claimed nothing had been pasted. `paste.result {"ok":true}` only means the events were handed to the window @@ -304,6 +325,7 @@ later on the `paste.verify` line with the same trace id. | --- | --- | | `observed` | Someone read the target and it changed. | | `swallowed` | Someone read the target and it did not. | +| `partial` | Someone read the target and only scraps of the text were there, and the repair could not fix it. | | `ax_unreadable` | There was no baseline, so no evidence was ever going to arrive. | | `pending` | The verifier has not concluded yet. Grep the trace id for `paste.verify`. | | `inconclusive` | The verifier looked at both sides and still could not tell. | @@ -316,6 +338,7 @@ later on the `paste.verify` line with the same trace id. | `pasted` | **Observed.** Changed meaning in Polaris: it used to mean only that the events were posted, which is what let 219 blind pastes across the corpus — including `0112-bf80`, the one dictation where `Fn/Globe` was demonstrably held at injection — report themselves as successes. | | `pasted_unverified` | Posted, nobody saw it land. `verification` says whether the evidence is still in flight (`pending`) or was never coming (`ax_unreadable`). Not a failure: the text very probably went in. | | `paste_swallowed` | Observed *not* to have landed. | +| `paste_partial` | Observed to have landed incomplete or garbled. The full text is on the clipboard. Usually only on the `paste-verified` event, since `dictation.finish` is written before the verdict. | | `clipboard_fallback` | The injection did not happen. The text is on the clipboard. | | `aborted` | No text was produced at all. See below. | @@ -399,6 +422,9 @@ grep '"outcome":"aborted"' ttp-trace.log # Every dictation whose keystrokes were verifiably swallowed grep 'paste.verify' ttp-trace.log | grep '"verdict":"swallowed"' +# Every dictation that landed as scraps, and what the repair did +grep -E 'paste.repair|"verdict":"partial"' ttp-trace.log + # Every dictation we could not verify at all, and why grep 'paste.verify' ttp-trace.log | grep '"verdict":"unverified"' diff --git a/scripts/inject-bench/bench.swift b/scripts/inject-bench/bench.swift new file mode 100644 index 0000000..8d9ac8c --- /dev/null +++ b/scripts/inject-bench/bench.swift @@ -0,0 +1,225 @@ +// Injection bench: replays TTP's text-injection strategies into the focused +// field of a target app and reads back, over Accessibility, what landed. +// +// swiftc -O scripts/inject-bench/bench.swift -o /tmp/inject-bench +// /tmp/inject-bench probe com.anthropic.claudefordesktop +// /tmp/inject-bench run com.anthropic.claudefordesktop 6 [ttp,clip,...] +// +// REFOCUS_MS=0 bounce focus through Finder, return, wait N ms, then type +// HOLD_MS=800 leave a key-down without key-up before typing +// +// It takes the keyboard: the target comes to the front and text is typed into +// its focused field, then selected over Accessibility and deleted. Nothing is +// sent. Never uses a ⌘-shortcut to select: keycode 0 is Q on AZERTY, and ⌘ + +// keycode 0 quit the Claude app on the first attempt (2026-09-26). +// +// Findings on 2026-09-26, Claude desktop composer, 128 injections: every +// strategy lands intact when the field has focus; typed within 0 ms of the +// app regaining focus, the first two 16-char chunks are lost and ⌘V loses +// everything; at 100 ms nothing is lost. The 0024-8368 pattern (last char of +// each chunk) did not reproduce. +import AppKit +import ApplicationServices +import Foundation + +setvbuf(stdout, nil, _IONBF, 0) + +let texts = [ + "Je vais te demander des promptes, donc des choses à copier et des choses à coller. D'accord. Ne les surcomplique pas, juste donne l'idée globale, simplifie.", + "Bon, alors là on a un vrai problème de fiabilité : quand je dicte une phrase un peu longue dans l'application, il arrive que seules quelques lettres éparpillées apparaissent au lieu du texte complet, et je ne m'en rends compte qu'après avoir envoyé le message. Il faut que ce soit réglé proprement.", +] + +struct Variant { + let name: String + let chunk: Int // UTF-16 units per event; 0 = clipboard ⌘V + let keyUp: Bool + let gapMs: Double + let tap: CGEventTapLocation +} + +let variants: [String: Variant] = [ + "ttp": Variant(name: "ttp", chunk: 16, keyUp: false, gapMs: 0, tap: .cghidEventTap), + "ttp_up": Variant(name: "ttp_up", chunk: 16, keyUp: true, gapMs: 0, tap: .cghidEventTap), + "ttp_gap4": Variant(name: "ttp_gap4", chunk: 16, keyUp: false, gapMs: 4, tap: .cghidEventTap), + "up_gap4": Variant(name: "up_gap4", chunk: 16, keyUp: true, gapMs: 4, tap: .cghidEventTap), + "c8_up_gap4": Variant(name: "c8_up_gap4", chunk: 8, keyUp: true, gapMs: 4, tap: .cghidEventTap), + "c1_up": Variant(name: "c1_up", chunk: 1, keyUp: true, gapMs: 1, tap: .cghidEventTap), + "session": Variant(name: "session", chunk: 16, keyUp: false, gapMs: 0, tap: .cgAnnotatedSessionEventTap), + "clip": Variant(name: "clip", chunk: 0, keyUp: true, gapMs: 0, tap: .cgAnnotatedSessionEventTap), +] + +// ── AX helpers ──────────────────────────────────────────────────────────── +func wakeElectron(pid: pid_t) { + AXUIElementSetAttributeValue(AXUIElementCreateApplication(pid), "AXManualAccessibility" as CFString, kCFBooleanTrue) +} + +func focusedElement(pid: pid_t) -> AXUIElement? { + let app = AXUIElementCreateApplication(pid) + var v: CFTypeRef? + guard AXUIElementCopyAttributeValue(app, kAXFocusedUIElementAttribute as CFString, &v) == .success else { return nil } + return (v as! AXUIElement) +} + +func readText(pid: pid_t) -> String? { + guard let el = focusedElement(pid: pid) else { return nil } + var v: CFTypeRef? + if AXUIElementCopyAttributeValue(el, kAXValueAttribute as CFString, &v) == .success, let s = v as? String { return s } + return nil +} + +func describe(pid: pid_t) -> String { + guard let el = focusedElement(pid: pid) else { return "no focused element" } + var role: CFTypeRef?; var sub: CFTypeRef?; var desc: CFTypeRef? + AXUIElementCopyAttributeValue(el, kAXRoleAttribute as CFString, &role) + AXUIElementCopyAttributeValue(el, kAXSubroleAttribute as CFString, &sub) + AXUIElementCopyAttributeValue(el, kAXDescriptionAttribute as CFString, &desc) + let t = readText(pid: pid) + return "role=\(role as? String ?? "-") sub=\(sub as? String ?? "-") desc=\(desc as? String ?? "-") value_chars=\(t.map { String($0.count) } ?? "unreadable")" +} + +// ── Injection ───────────────────────────────────────────────────────────── +let source = CGEventSource(stateID: .privateState)! + +func sleepMs(_ ms: Double) { if ms > 0 { usleep(useconds_t(ms * 1000)) } } + +func chunks(_ text: String, _ max: Int) -> [String] { + var out: [String] = []; var cur = ""; var units = 0 + for ch in text { + let w = String(ch).utf16.count + if units + w > max && !cur.isEmpty { out.append(cur); cur = ""; units = 0 } + cur.append(ch); units += w + } + if !cur.isEmpty { out.append(cur) } + return out +} + +func postKey(_ code: CGKeyCode, flags: CGEventFlags, tap: CGEventTapLocation) { + let d = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: true)! + d.flags = flags; d.post(tap: tap) + usleep(8000) + let u = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: false)! + u.flags = flags; u.post(tap: tap) +} + +func type(_ text: String, _ v: Variant) { + for c in chunks(text, v.chunk) { + let units = Array(c.utf16) + let d = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true)! + d.flags = [] + d.keyboardSetUnicodeString(stringLength: units.count, unicodeString: units) + d.post(tap: v.tap) + if v.keyUp { + let u = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false)! + u.flags = [] + u.keyboardSetUnicodeString(stringLength: units.count, unicodeString: units) + u.post(tap: v.tap) + } + sleepMs(v.gapMs) + } +} + +func paste(_ text: String) { + let pb = NSPasteboard.general + let saved = pb.string(forType: .string) + pb.clearContents(); pb.setString(text, forType: .string) + postKey(9, flags: .maskCommand, tap: .cgAnnotatedSessionEventTap) + usleep(600_000) + pb.clearContents(); if let s = saved { pb.setString(s, forType: .string) } +} + +/// Select the whole field through Accessibility, then Delete (keycode 51 is +/// the same key on every layout). Never a ⌘-shortcut: keycode 0 is Q on AZERTY, +/// and ⌘+keycode0 quit the Claude app on the first attempt. +func clearField(pid: pid_t) { + guard let el = focusedElement(pid: pid), let t = readText(pid: pid), !t.isEmpty else { return } + var range = CFRange(location: 0, length: (t as NSString).length) + let v = AXValueCreate(.cfRange, &range)! + AXUIElementSetAttributeValue(el, kAXSelectedTextRangeAttribute as CFString, v) + usleep(60_000) + postKey(51, flags: [], tap: .cgAnnotatedSessionEventTap) + usleep(150_000) +} + +/// Poll until the field stops changing for 400 ms (max 3 s). +func settledText(pid: pid_t) -> (String?, Int) { + var last = readText(pid: pid); var stableSince = Date(); let start = Date() + while Date().timeIntervalSince(start) < 3 { + usleep(20_000) + let t = readText(pid: pid) + if t != last { last = t; stableSince = Date() } + else if Date().timeIntervalSince(stableSince) > 0.4 { break } + } + return (last, Int(Date().timeIntervalSince(start) * 1000)) +} + +// ── Main ────────────────────────────────────────────────────────────────── +let args = CommandLine.arguments +guard args.count >= 3, let target = NSRunningApplication.runningApplications(withBundleIdentifier: args[2]).first else { + print("usage: bench.swift probe|run [reps] [variants]"); exit(2) +} +let pid = target.processIdentifier +wakeElectron(pid: pid) +usleep(300_000) + +if args[1] == "probe" { print(describe(pid: pid)); exit(0) } + +let reps = args.count > 3 ? Int(args[3]) ?? 5 : 5 +let chosen = args.count > 4 ? args[4].split(separator: ",").map(String.init) : Array(variants.keys).sorted() +let previous = NSWorkspace.shared.frontmostApplication +let refocusMs: Int? = ProcessInfo.processInfo.environment["REFOCUS_MS"].flatMap { Int($0) } +let bounce = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first + +target.activate() +usleep(700_000) +print("# target:", describe(pid: pid)) +guard readText(pid: pid) != nil else { print("focused element is not readable — aborting"); exit(1) } +clearField(pid: pid) + +var tally: [String: (n: Int, exact: Int, lost: Int)] = [:] +for rep in 0.. 40 { + print("# field did not clear (\(left.count) chars left) — stopping"); previous?.activate(); exit(1) + } + } +} + +print("\n# variant runs exact chars_lost") +for name in chosen { if let t = tally[name] { print(String(format: " %-12@ %4d %5d %10d", name as NSString, t.n, t.exact, t.lost)) } } +previous?.activate() diff --git a/scripts/trace_analyser/invariants.py b/scripts/trace_analyser/invariants.py index e8fa58e..68cb9a5 100644 --- a/scripts/trace_analyser/invariants.py +++ b/scripts/trace_analyser/invariants.py @@ -53,7 +53,7 @@ # `pasted` need names of their own, and both are documented. DOCUMENTED_OUTCOMES = { "pasted", "aborted", "clipboard_fallback", - "pasted_unverified", "paste_swallowed", + "pasted_unverified", "paste_swallowed", "paste_partial", } # The `PasteVerdict` vocabulary being added to `paste.verify` alongside this @@ -65,6 +65,7 @@ PASTE_VERDICT_OBSERVED = "observed" # the text was seen to land PASTE_VERDICT_SWALLOWED = "swallowed" # it was readable, and it did not PASTE_VERDICT_UNVERIFIED = "unverified" # the target could not be read at all +PASTE_VERDICT_PARTIAL = "partial" # scraps landed, the repair failed # `paste.verify`'s other two new fields, named for the same reason. `verdict` # is the answer, `evidence` is what it was read off ("text" or "length") and @@ -1699,13 +1700,17 @@ def check_paste_swallowed(corpus: Corpus): verdict = v.get("verdict") if verdict is not None: if verdict not in (PASTE_VERDICT_SWALLOWED, - PASTE_VERDICT_UNVERIFIED): + PASTE_VERDICT_UNVERIFIED, + PASTE_VERDICT_PARTIAL): continue # observed, or vocabulary this file does not know - sev = (ERROR if verdict == PASTE_VERDICT_SWALLOWED else WARN) - gloss = ("target unchanged after injection" - if verdict == PASTE_VERDICT_SWALLOWED else - "the target could not be read, so the landing is " - "unproven — this is not evidence the text was lost") + sev = (WARN if verdict == PASTE_VERDICT_UNVERIFIED else ERROR) + gloss = { + PASTE_VERDICT_SWALLOWED: "target unchanged after injection", + PASTE_VERDICT_PARTIAL: "only part of it landed and the " + "repair could not fix it (see " + "paste.repair)", + }.get(verdict, "the target could not be read, so the landing is " + "unproven — this is not evidence the text was lost") elif v.get("ax_readable") is True and v.get("changed") is False: # The pre-verdict shape. Same claim, weaker instrument. sev, gloss = ERROR, "target unchanged" diff --git a/src-tauri/src/fnkey.rs b/src-tauri/src/fnkey.rs index e4b4bd2..68f864a 100644 --- a/src-tauri/src/fnkey.rs +++ b/src-tauri/src/fnkey.rs @@ -131,6 +131,8 @@ const KCG_EVENT_TAP_DISABLED_BY_TIMEOUT: u32 = 0xFFFF_FFFE; /// kCGEventTapDisabledByUserInput — same consequence, different trigger. const KCG_EVENT_TAP_DISABLED_BY_USER_INPUT: u32 = 0xFFFF_FFFF; const KCG_KEYBOARD_EVENT_KEYCODE: u32 = 9; +/// `kCGEventSourceUnixProcessID`: 0 for hardware, the poster's pid otherwise. +const KCG_EVENT_SOURCE_UNIX_PROCESS_ID: u32 = 41; const KCG_KEYBOARD_EVENT_AUTOREPEAT: u32 = 8; const KCG_MOUSE_EVENT_BUTTON_NUMBER: u32 = 3; const KCG_SESSION_EVENT_TAP: u32 = 1; @@ -911,6 +913,11 @@ unsafe extern "C" fn fkey_tap_callback( let raw = raw_event(event_type, event); let keycode = raw.keycode; + if event_type == KCG_EVENT_KEY_DOWN { + let pid = CGEventGetIntegerValueField(event, KCG_EVENT_SOURCE_UNIX_PROCESS_ID); + crate::paste::input_marks::note_key_down(pid, keycode); + } + // Choosing a new trigger: the press belongs to the capture, and must not // also start a recording with the old one. if CAPTURING.load(Ordering::Relaxed) { diff --git a/src-tauri/src/paste/accessibility.rs b/src-tauri/src/paste/accessibility.rs index 0a0b357..8abe929 100644 --- a/src-tauri/src/paste/accessibility.rs +++ b/src-tauri/src/paste/accessibility.rs @@ -155,6 +155,11 @@ pub enum PasteVerdict { /// We could not look. **Not** evidence of failure, and not evidence of /// success either. Unverified, + /// The field changed and what arrived is not the text: some of it, or + /// scraps of it. See `landing::assess`. Seen 2026-09-26 in the Claude + /// desktop app — 21 of 156 characters, reported as `Observed` because + /// the field had grown. + Partial, } impl PasteVerdict { @@ -163,6 +168,7 @@ impl PasteVerdict { PasteVerdict::Observed => "observed", PasteVerdict::Swallowed => "swallowed", PasteVerdict::Unverified => "unverified", + PasteVerdict::Partial => "partial", } } } @@ -306,6 +312,12 @@ extern "C" { result: *mut CFTypeRef, ) -> AXError; fn AXValueCreate(value_type: u32, value_ptr: *const c_void) -> CFTypeRef; + fn AXValueGetValue(value: CFTypeRef, value_type: u32, value_ptr: *mut c_void) -> bool; + fn AXUIElementSetAttributeValue( + element: AXUIElementRef, + attribute: CFStringRef, + value: CFTypeRef, + ) -> AXError; } /// Read the focused UI element, reporting *how* the read went. @@ -539,6 +551,67 @@ unsafe fn read_ax_string_for_range(element: AXUIElementRef, count: usize) -> Opt } } +/// Select `len` UTF-16 units from `start` in the focused field, and confirm by +/// reading the selection back. +/// +/// The repair's first half: the garbled landing is selected, then retyped +/// over. Returns `false` — and the repair does not type — unless the field +/// reports exactly the requested range afterwards. Typing over a selection we +/// did not verify could overwrite text the user wrote. +#[cfg(target_os = "macos")] +pub fn select_focused_range(start: usize, len: usize) -> bool { + #[repr(C)] + #[derive(Default, PartialEq, Debug)] + struct CFRange { + location: i64, + length: i64, + } + + unsafe { + let system_wide = AXUIElementCreateSystemWide(); + if system_wide.is_null() { + return false; + } + let focused_attr = CFString::new("AXFocusedUIElement"); + let mut focused: CFTypeRef = std::ptr::null_mut(); + let err = AXUIElementCopyAttributeValue(system_wide, focused_attr.as_concrete_TypeRef(), &mut focused); + CFRelease(system_wide as CFTypeRef); + if err != AX_ERROR_SUCCESS || focused.is_null() { + return false; + } + let element = focused as AXUIElementRef; + + let wanted = CFRange { location: start as i64, length: len as i64 }; + let value = AXValueCreate(K_AX_VALUE_TYPE_CF_RANGE, &wanted as *const _ as *const c_void); + if value.is_null() { + CFRelease(focused); + return false; + } + let attr = CFString::new("AXSelectedTextRange"); + let set_err = AXUIElementSetAttributeValue(element, attr.as_concrete_TypeRef(), value); + CFRelease(value); + + let mut back: CFTypeRef = std::ptr::null_mut(); + let read_err = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef(), &mut back); + CFRelease(focused); + if set_err != AX_ERROR_SUCCESS || read_err != AX_ERROR_SUCCESS || back.is_null() { + if !back.is_null() { + CFRelease(back); + } + return false; + } + let mut got = CFRange::default(); + let ok = AXValueGetValue(back, K_AX_VALUE_TYPE_CF_RANGE, &mut got as *mut _ as *mut c_void); + CFRelease(back); + ok && got == wanted + } +} + +#[cfg(not(target_os = "macos"))] +pub fn select_focused_range(_start: usize, _len: usize) -> bool { + false +} + /// No equivalent read exists on Windows, so every paste there is unverified /// and the trace says so rather than reporting a false negative. #[cfg(not(target_os = "macos"))] diff --git a/src-tauri/src/paste/input_marks.rs b/src-tauri/src/paste/input_marks.rs new file mode 100644 index 0000000..f98fbeb --- /dev/null +++ b/src-tauri/src/paste/input_marks.rs @@ -0,0 +1,75 @@ +// TTP - Who else was typing while we injected +// +// 0024-8368 (2026-09-26): 156 characters typed into the Claude composer, 21 +// arrived, and the message was sent a fraction of a second later. The trace +// could say what TTP posted and what the field held, and nothing about what +// else reached the keyboard queue at the same moment — a key the user pressed, +// or another program injecting. Both are hypotheses for how the chunks were +// lost, and neither could be checked. +// +// The hotkey's HID event tap already sees every key-down. It tallies them here +// by origin, counts only, never which key except Return, so `paste.verify` can +// say how many foreign key-downs overlapped the injection and when the user +// hit Return. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Key-downs from the hardware (including Karabiner's virtual keyboard). +static USER_KEYS: AtomicU64 = AtomicU64::new(0); +/// Key-downs synthesised by another process. +static FOREIGN_KEYS: AtomicU64 = AtomicU64::new(0); +/// Wall-clock ms of the last hardware Return / keypad Enter. +static LAST_USER_RETURN_MS: AtomicU64 = AtomicU64::new(0); + +const KVK_RETURN: u16 = 36; +const KVK_KEYPAD_ENTER: u16 = 76; + +fn now_ms() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) +} + +/// Called by the event tap for every key-down. `source_pid` is the event's +/// `kCGEventSourceUnixProcessID`: 0 for hardware, ours for our own injection. +pub fn note_key_down(source_pid: i64, keycode: u16) { + if source_pid == std::process::id() as i64 { + return; + } + if source_pid == 0 { + USER_KEYS.fetch_add(1, Ordering::Relaxed); + if keycode == KVK_RETURN || keycode == KVK_KEYPAD_ENTER { + LAST_USER_RETURN_MS.store(now_ms(), Ordering::Relaxed); + } + } else { + FOREIGN_KEYS.fetch_add(1, Ordering::Relaxed); + } +} + +/// A point to measure from: take one before injecting, diff it afterwards. +#[derive(Debug, Clone, Copy)] +pub struct InputMark { + user: u64, + foreign: u64, + at_ms: u64, +} + +pub fn mark() -> InputMark { + InputMark { + user: USER_KEYS.load(Ordering::Relaxed), + foreign: FOREIGN_KEYS.load(Ordering::Relaxed), + at_ms: now_ms(), + } +} + +impl InputMark { + /// Trace fields for everything that happened since this mark. + pub fn since(&self) -> serde_json::Value { + let ret = LAST_USER_RETURN_MS.load(Ordering::Relaxed); + serde_json::json!({ + "user_keys": USER_KEYS.load(Ordering::Relaxed).saturating_sub(self.user), + "foreign_keys": FOREIGN_KEYS.load(Ordering::Relaxed).saturating_sub(self.foreign), + // How long after the injection began the user pressed Return. + "user_return_ms": (ret >= self.at_ms).then(|| ret - self.at_ms), + }) + } +} diff --git a/src-tauri/src/paste/landing.rs b/src-tauri/src/paste/landing.rs new file mode 100644 index 0000000..f8c347f --- /dev/null +++ b/src-tauri/src/paste/landing.rs @@ -0,0 +1,203 @@ +// TTP - What actually landed in the field +// +// `classify` answers "did the field change?". That is not the question the +// user cares about, which is "is my text there?", and on 2026-09-26 the two +// came apart: 156 characters typed into the Claude desktop composer, 21 +// arrived — the last character of each 16-character chunk and then the whole +// last chunk, "nseee ene, simplifie." — and the verifier said `observed` +// because the field had grown. The message was sent like that. +// +// The verifier already reads the field's *text* on most targets (AXValue or +// AXStringForRange). This module compares that text against what we typed, +// so a garbled landing is named for what it is, and it hands the repair the +// exact range to replace. +// +// Pure — no Accessibility calls — so every shape seen in the trace is a test. + +/// How much of the dictated text is in the field. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Landing { + /// Everything we typed is there (allowing for what text fields do to + /// text on the way in: smart quotes, newline flavours, our own ZWSP), or + /// the field changed in a way that is not scraps of our text. + Complete, + /// Something arrived and it is not the text. `range` is where it sits in + /// the field, in UTF-16 units — the unit `AXSelectedTextRange` speaks. + Partial { landed: String, range: (usize, usize) }, + /// The field is as it was. + Absent, +} + +/// A landing counts as complete from this share of the expected characters. +/// +/// Not 100%: native fields rewrite on the way in (TextEdit turns ' into ’, +/// some composers trim a trailing space), and calling that "incomplete" would +/// retype text the user already has. The failure this exists for lands a +/// fraction — 21 of 156, 55 of 70, 305 of 455 in the Claude app — nowhere near. +const COMPLETE_SHARE: f64 = 0.9; + +/// Strip what the injection or the field adds without the user having said it. +/// +/// * U+200B: `injection_chunks` prefixes chunks that start with a newline. +/// * `\r\n` / `\r`: fields disagree on newlines. +/// * Any run of whitespace, NBSP included, compares as one space: polish puts +/// NBSP before French punctuation and some fields store a plain space. +pub fn normalize(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut in_space = false; + for ch in text.chars() { + if ch == '\u{200B}' { + continue; + } + if ch.is_whitespace() { + if !in_space { + out.push(' '); + } + in_space = true; + } else { + out.push(ch); + in_space = false; + } + } + out.trim().to_string() +} + +/// The span of `after` that is not in `before`: longest common prefix and +/// suffix removed. Returns the inserted text and its UTF-16 range in `after`. +/// +/// Typing over a selection or into a placeholder still yields the right span: +/// the prefix and suffix are what survived, the middle is what we put there. +pub fn inserted(before: &str, after: &str) -> (String, (usize, usize)) { + let b: Vec = before.chars().collect(); + let a: Vec = after.chars().collect(); + let mut pre = 0; + while pre < a.len() && pre < b.len() && a[pre] == b[pre] { + pre += 1; + } + let mut suf = 0; + while suf < a.len() - pre && suf < b.len() - pre && a[a.len() - 1 - suf] == b[b.len() - 1 - suf] { + suf += 1; + } + let middle: String = a[pre..a.len() - suf].iter().collect(); + let start: usize = a[..pre].iter().map(|c| c.len_utf16()).sum(); + let len: usize = middle.encode_utf16().count(); + (middle, (start, len)) +} + +/// Compare one read of the field against what we typed. +pub fn assess(expected: &str, before: &str, after: &str) -> Landing { + if before == after { + return Landing::Absent; + } + let want = normalize(expected); + if want.is_empty() || normalize(after).contains(&want) { + return Landing::Complete; + } + let (landed, range) = inserted(before, after); + let got = normalize(&landed); + if got.is_empty() { + // The field changed without gaining anything: text was removed. Not + // ours to judge — the user, or the app, edited. + return Landing::Absent; + } + let share = got.chars().count() as f64 / want.chars().count() as f64; + if share >= COMPLETE_SHARE { + return Landing::Complete; + } + // Scraps *of our text*: every character that arrived is one we sent, in + // order. Lost chunks, a lost head, "nseee ene, simplifie." all qualify. A + // terminal redrawing its prompt, or a composer that reformats, puts + // characters we never typed into the diff — that is the app's business, + // not a failed injection, and it must never trigger a retype. + if !is_subsequence(&got, &want) { + return Landing::Complete; + } + Landing::Partial { landed, range } +} + +fn is_subsequence(needle: &str, hay: &str) -> bool { + let mut hay = hay.chars(); + needle.chars().all(|c| hay.any(|h| h == c)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SENT: &str = "Je vais te demander des promptes, donc des choses à copier et des choses à coller. D'accord. Ne les surcomplique pas, juste donne l'idée globale, simplifie."; + + /// 0024-8368, 2026-09-26: the incident this module exists for. + #[test] + fn the_claude_composer_incident_is_partial() { + match assess(SENT, "", "nseee ene, simplifie.") { + Landing::Partial { landed, range } => { + assert_eq!(landed, "nseee ene, simplifie."); + assert_eq!(range, (0, 21)); + } + other => panic!("{other:?}"), + } + } + + /// Reproduced by the bench: focus returned to the app 0 ms before typing, + /// the first two chunks vanished. + #[test] + fn a_lost_head_is_partial_and_the_range_covers_what_landed() { + let after = &SENT[32..]; + match assess(SENT, "", after) { + Landing::Partial { range, .. } => assert_eq!(range, (0, after.encode_utf16().count())), + other => panic!("{other:?}"), + } + } + + #[test] + fn a_full_landing_is_complete() { + assert_eq!(assess(SENT, "", SENT), Landing::Complete); + } + + #[test] + fn text_typed_into_existing_text_is_found_where_it_landed() { + let before = "Salut. \n"; + let after = format!("Salut. {SENT}\n"); + assert_eq!(assess(SENT, before, &after), Landing::Complete); + let (landed, range) = inserted(before, &format!("Salut. nseee\n")); + assert_eq!(landed, "nseee"); + assert_eq!(range, (7, 5)); + } + + /// The empty Claude composer reads as one character, and typing replaces + /// a placeholder in some fields. Neither is a partial landing. + #[test] + fn a_replaced_placeholder_is_complete() { + assert_eq!(assess("bonjour à tous", "Reply to Claude…", "bonjour à tous"), Landing::Complete); + assert_eq!(assess("bonjour à tous", "\n", "bonjour à tous\n"), Landing::Complete); + } + + #[test] + fn what_fields_do_to_text_is_not_a_loss() { + // Smart quote, NBSP before punctuation, CRLF, our own ZWSP. + assert_eq!(assess("l'idée : oui", "", "l’idée : oui"), Landing::Complete); + assert_eq!(assess("a\nb", "", "a\r\nb"), Landing::Complete); + assert_eq!(assess("a\nb", "", "a\u{200B}\nb"), Landing::Complete); + } + + #[test] + fn an_unchanged_field_is_absent_not_partial() { + assert_eq!(assess(SENT, "x", "x"), Landing::Absent); + // Shrunk: somebody deleted, we did not type that. + assert_eq!(assess(SENT, "abc", "ab"), Landing::Absent); + } + + /// A TUI redrawing around the text puts characters we never typed into the + /// diff. Not a loss, and above all not something to retype over. + #[test] + fn foreign_characters_are_the_app_not_a_loss() { + assert_eq!(assess(SENT, "│ > │", "│ > Je vais │\n│ te demander │"), Landing::Complete); + } + + /// AX ranges are UTF-16: an emoji before the landing shifts it by two. + #[test] + fn ranges_are_utf16() { + let (_, range) = inserted("🎉 ", "🎉 abc"); + assert_eq!(range, (3, 3)); + } +} diff --git a/src-tauri/src/paste/mod.rs b/src-tauri/src/paste/mod.rs index e327f93..996cb31 100644 --- a/src-tauri/src/paste/mod.rs +++ b/src-tauri/src/paste/mod.rs @@ -4,15 +4,18 @@ pub mod accessibility; pub mod clipboard; pub mod frontmost; +pub mod input_marks; +pub mod landing; pub mod outcome; pub mod permissions; pub mod simulate; pub use accessibility::{ - account_for_cleared_field, classify, probe_focused_text, read_focused_text, FocusSnapshot, FocusSource, PasteVerdict, + account_for_cleared_field, classify, probe_focused_text, read_focused_text, select_focused_range, FocusSnapshot, FocusSource, PasteVerdict, Verification, }; pub use frontmost::frontmost_bundle_id; +pub use landing::{assess, Landing}; pub use outcome::{ describe_verification, finish_outcome, read_verdict, record_verdict, PasteVerdictSlot, }; diff --git a/src-tauri/src/paste/outcome.rs b/src-tauri/src/paste/outcome.rs index 45cbf18..a8face4 100644 --- a/src-tauri/src/paste/outcome.rs +++ b/src-tauri/src/paste/outcome.rs @@ -65,6 +65,8 @@ pub fn read_verdict(slot: &PasteVerdictSlot) -> Option { /// the same trace id, in `paste.verify`. /// * `inconclusive` — the verifier looked at both sides and still could not /// tell (`read_back_failed`, `length_unchanged`, `shape_changed`). +/// * `partial` — the field changed and what arrived is not the text (see +/// `landing::assess`), and the repair could not put it right. /// * `not_pasted` — the injection failed or was skipped. Nothing to verify. /// /// `verifiable` is the pre-injection answer to "is there a baseline at all", @@ -84,6 +86,7 @@ pub fn describe_verification( return match v.verdict { PasteVerdict::Observed => "observed", PasteVerdict::Swallowed => "swallowed", + PasteVerdict::Partial => "partial", // "we could not look" and "we looked and it was ambiguous" need // different fixes, so they are not the same word. PasteVerdict::Unverified => { @@ -113,6 +116,8 @@ pub fn describe_verification( /// /// * `pasted` — observed. Someone read the target and it changed. /// * `paste_swallowed` — observed *not* to have landed. +/// * `paste_partial` — observed to have landed incomplete or garbled; the full +/// text has been put on the clipboard. /// * `pasted_unverified` — posted, not observed. Covers both "the evidence is /// still in flight" and "there was never going to be any". /// * `clipboard_fallback` — the injection itself did not happen. Unchanged @@ -124,6 +129,7 @@ pub fn finish_outcome(paste_success: bool, verification: &str) -> &'static str { match verification { "observed" => "pasted", "swallowed" => "paste_swallowed", + "partial" => "paste_partial", // `pending`, `ax_unreadable`, `inconclusive`. All three mean the same // thing to a reader of the terminal line: nobody saw it land *yet*. // `verification` on the same line says which. @@ -199,6 +205,15 @@ mod tests { assert_eq!(finish_outcome(true, v), "paste_swallowed"); } + /// 0024-8368: 21 of 156 characters is not "pasted". + #[test] + fn a_partial_landing_is_not_pasted() { + let partial = Some(verified(PasteVerdict::Partial, "text", "incomplete")); + let v = describe_verification(true, true, partial); + assert_eq!(v, "partial"); + assert_eq!(finish_outcome(true, v), "paste_partial"); + } + /// A settled verdict overrides the pre-injection guess, in both directions. #[test] fn evidence_beats_the_pre_injection_guess() { @@ -238,6 +253,7 @@ mod tests { assert_eq!(describe_verification(false, false, None), "not_pasted"); assert_eq!(finish_outcome(true, "observed"), "pasted"); assert_eq!(finish_outcome(true, "swallowed"), "paste_swallowed"); + assert_eq!(finish_outcome(true, "partial"), "paste_partial"); assert_eq!(finish_outcome(true, "pending"), "pasted_unverified"); assert_eq!(finish_outcome(false, "anything"), "clipboard_fallback"); } diff --git a/src-tauri/src/paste/simulate.rs b/src-tauri/src/paste/simulate.rs index 0876ec9..9bf71f9 100644 --- a/src-tauri/src/paste/simulate.rs +++ b/src-tauri/src/paste/simulate.rs @@ -382,6 +382,19 @@ pub fn simulate_paste() -> Result<(), String> { Ok(()) } +/// Fault injection for the partial-landing repair, off unless +/// `TTP_FAULT_GARBLE=` is in the environment: the first `n` typing +/// injections of the process land the way 0024-8368 did (2026-09-26), so the +/// verifier and the repair can be exercised on a real build and a real +/// target. Set it with `launchctl setenv` before launching; unset after. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn fault_garble_this_injection() -> bool { + use std::sync::atomic::{AtomicU64, Ordering}; + static USED: AtomicU64 = AtomicU64::new(0); + let budget: u64 = std::env::var("TTP_FAULT_GARBLE").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(0); + budget > 0 && USED.fetch_add(1, Ordering::Relaxed) < budget +} + /// Type `text` directly into the focused application via synthetic keystrokes. /// /// On macOS this goes through `CGEventKeyboardSetUnicodeString`, so: @@ -404,7 +417,17 @@ pub fn simulate_typing(text: &str) -> Result<(), String> { let source = CGEventSource::new(CGEventSourceStateID::Private) .map_err(|_| "Failed to create event source")?; - for chunk in mac::injection_chunks(text, mac::chunk_budget()) { + let chunks = mac::injection_chunks(text, mac::chunk_budget()); + let garble = fault_garble_this_injection(); + let last = chunks.len().saturating_sub(1); + for (i, chunk) in chunks.into_iter().enumerate() { + // Fault injection: reproduce 0024-8368 — only the last character + // of each chunk, then the whole last chunk. + let chunk = if garble && i < last { + chunk.chars().last().map(String::from).unwrap_or_default() + } else { + chunk + }; // keycode 0 + a unicode string is the layout-independent "insert // this text" event; the keycode itself is ignored by the target. let event = CGEvent::new_keyboard_event(source.clone(), 0, true) diff --git a/src-tauri/src/transcription/pipeline.rs b/src-tauri/src/transcription/pipeline.rs index f7f61ab..6c02e05 100644 --- a/src-tauri/src/transcription/pipeline.rs +++ b/src-tauri/src/transcription/pipeline.rs @@ -95,6 +95,15 @@ const PASTE_VERIFY_POLL_MS: u64 = 25; /// probes in a 600 ms window would be hammering; six is a retry. const PASTE_VERIFY_RETRY_MS: u64 = 100; +/// How long a partial landing must sit unchanged before the verifier treats it +/// as the result rather than a field still filling up. +/// +/// Chromium consumes a whole injection in one burst — the bench saw every +/// change land within ~50 ms of the first — so 80 ms of stillness means the +/// events are spent. Kept short because it is on the clock: in 0024-8368 the +/// scraps sat for ~140 ms before the user's Return sent them. +const PASTE_PARTIAL_QUIET_MS: u64 = 80; + /// How long to wait after Cmd+V before restoring the user's pre-record /// clipboard, when we have to use the clipboard path (text > DIRECT_TYPING_MAX_CHARS). /// Slow Electron apps (Slack, Notion, Mail, Discord) can take 200–600 ms to @@ -4040,6 +4049,7 @@ pub async fn process_recording(app: &AppHandle, audio_path: String) -> Result Result, + input_mark: crate::paste::input_marks::InputMark, slot: crate::paste::PasteVerdictSlot, ) { tauri::async_runtime::spawn(async move { let started = std::time::Instant::now(); let timeout = Duration::from_millis(PASTE_VERIFY_TIMEOUT_MS); + let expected_chars = expected_text.chars().count(); let delta_of = |after: &FocusSnapshot| -> Option { match (focused_before.chars, after.chars) { @@ -4415,6 +4430,14 @@ fn spawn_paste_verification( _ => None, } }; + // What the field holds against what we typed. Only when both reads + // carry text: a length cannot tell 21 right characters from 21 wrong. + let landing_of = |after: &FocusSnapshot| -> Option { + match (&focused_before.text, &after.text) { + (Some(b), Some(a)) => Some(crate::paste::assess(&expected_text, b, a)), + _ => None, + } + }; // `None` means we never looked, which is a different fact from "we // looked and saw nothing" and is rendered as a null `ax_after`. @@ -4425,25 +4448,43 @@ fn spawn_paste_verification( let mut peak_delta: Option = None; let mut reads: u32 = 0; let mut retries: u32 = 0; + // Any read held the whole text. + let mut seen_complete = false; + // A partial landing the field held, unchanged, for PASTE_PARTIAL_QUIET_MS. + let mut stable_partial: Option<(String, (usize, usize))> = None; + let mut last_change = std::time::Instant::now(); if focused_before.observable() { let mut snapshot = crate::paste::probe_focused_text(); reads += 1; peak_delta = delta_of(&snapshot); + seen_complete |= matches!(landing_of(&snapshot), Some(crate::paste::Landing::Complete)); // Poll until the target has consumed everything we sent, not until // it first reacts. We inject in chunks, so the first read after the // first chunk lands shows a delta of exactly one chunk — stopping // there reported "16 characters arrived" for a 500-character paste, // which reads as a truncation bug that is not happening. Keep - // watching until the delta covers what we sent, or the window - // closes. + // watching until the text is there, or the window closes. while started.elapsed() < timeout { - if snapshot.observable() + if seen_complete { + break; + } + if focused_before.text.is_none() + && snapshot.observable() && delta_of(&snapshot).is_some_and(|d| d >= expected_chars as i64) { break; } + // Scraps that have stopped moving are the result, not a field + // still filling up. Stop here rather than at the timeout: the + // repair below races the user's Return key. + if last_change.elapsed() >= Duration::from_millis(PASTE_PARTIAL_QUIET_MS) { + if let Some(crate::paste::Landing::Partial { landed, range }) = landing_of(&snapshot) { + stable_partial = Some((landed, range)); + break; + } + } // The second means of verification, and the reason this loop no // longer gives up on the first blind read the way the previous @@ -4467,9 +4508,11 @@ fn spawn_paste_verification( if let Some(d) = delta_of(&next) { peak_delta = Some(peak_delta.map_or(d, |p| p.max(d))); } + seen_complete |= matches!(landing_of(&next), Some(crate::paste::Landing::Complete)); if next != snapshot { settled_ms = started.elapsed().as_millis() as u64; first_change_ms.get_or_insert(settled_ms); + last_change = std::time::Instant::now(); snapshot = next; } } @@ -4480,10 +4523,49 @@ fn spawn_paste_verification( // returns `no_baseline` before it looks at it — so passing the // baseline itself is honest rather than clever: it says "there was // never a pair here". - let verification = crate::paste::account_for_cleared_field( + let mut verification = crate::paste::account_for_cleared_field( crate::paste::classify(&focused_before, after.as_ref().unwrap_or(&focused_before)), peak_delta, ); + + // Scraps held still: put the text right if nothing else has touched + // the field, and say so plainly if we cannot. + let mut repair: Option = None; + if let (Some((landed, range)), Some(settled)) = (&stable_partial, &after) { + let outcome = repair_partial_landing(&expected_text, &focused_before, settled, *range, target_app.as_deref()).await; + verification = if outcome == "repaired" || outcome == "late_complete" { + crate::paste::Verification { + verdict: crate::paste::PasteVerdict::Observed, + evidence: "text", + reason: outcome, + } + } else { + crate::paste::Verification { + verdict: crate::paste::PasteVerdict::Partial, + evidence: "text", + reason: "incomplete", + } + }; + let mut fields = serde_json::json!({ + "outcome": outcome, + "landed_chars": landed.chars().count(), + "expected_chars": expected_chars, + "range": [range.0, range.1], + }); + if crate::trace::verbose_enabled() { + fields["landed"] = serde_json::Value::String(landed.clone()); + } + repair = Some(fields); + } + + if verification.verdict == crate::paste::PasteVerdict::Partial { + // The clipboard was restored to the user's own contents right + // after injection. Their text is the thing they need now, and the + // pill tells them it is there. + use tauri_plugin_clipboard_manager::ClipboardExt; + let ok = app.clipboard().write_text(&expected_text).is_ok(); + trace.stage("clipboard.recovery", serde_json::json!({ "ok": ok })); + } crate::paste::record_verdict(&slot, verification); // The verdict lands a median 44 ms after `dictation.finish`, so the @@ -4500,50 +4582,116 @@ fn spawn_paste_verification( ) .ok(); - trace.stage( - "paste.verify", - serde_json::json!({ - // The verdict, and the kind of evidence it rests on. There is - // deliberately no `changed` boolean any more: it was computed - // by comparing `Option`s, so an unreadable before plus - // a readable after rendered as `changed:true` — a self-report - // wearing an observation's clothes. - "verdict": verification.verdict.as_str(), - "evidence": verification.evidence, - "reason": verification.reason, - // Which read strategy answered, on each side. One boolean used - // to collapse four different failures — no focused element, an - // app that does not serve AX at all, an element with no - // readable attribute, and not-macOS — into `ax_readable:false`, - // and they need four different fixes. - "ax_before": focused_before.source.as_str(), - "ax_before_err": focused_before.ax_err, - "ax_after": after.as_ref().map(|a| a.source.as_str()), - "ax_after_err": after.as_ref().map(|a| a.ax_err), - // Kept: same meaning it always had, so a year of corpus stays - // comparable. - "ax_readable": after.as_ref().is_some_and(|a| a.observable()), - "before_chars": focused_before.chars, - "after_chars": after.as_ref().and_then(|a| a.chars), - "delta_chars": after.as_ref().and_then(delta_of), - "peak_delta_chars": peak_delta, - "expected_chars": expected_chars, - // How fast the target reacted at all, vs when it stopped - // changing. A large gap between them means a slow consumer; - // first_change absent means it never reacted. - "first_change_ms": first_change_ms, - "settled_ms": settled_ms, - // What the observation cost, and how much of it was spent on a - // target that was not answering. `reads:1` with a decided - // verdict is the cheap happy path; a high `retries` names the - // apps worth a third read strategy. - "reads": reads, - "retries": retries, - }), - ); + if let Some(fields) = repair { + trace.stage("paste.repair", fields); + } + + let mut fields = serde_json::json!({ + // The verdict, and the kind of evidence it rests on. There is + // deliberately no `changed` boolean any more: it was computed + // by comparing `Option`s, so an unreadable before plus + // a readable after rendered as `changed:true` — a self-report + // wearing an observation's clothes. + "verdict": verification.verdict.as_str(), + "evidence": verification.evidence, + "reason": verification.reason, + // Which read strategy answered, on each side. One boolean used + // to collapse four different failures — no focused element, an + // app that does not serve AX at all, an element with no + // readable attribute, and not-macOS — into `ax_readable:false`, + // and they need four different fixes. + "ax_before": focused_before.source.as_str(), + "ax_before_err": focused_before.ax_err, + "ax_after": after.as_ref().map(|a| a.source.as_str()), + "ax_after_err": after.as_ref().map(|a| a.ax_err), + // Kept: same meaning it always had, so a year of corpus stays + // comparable. + "ax_readable": after.as_ref().is_some_and(|a| a.observable()), + "before_chars": focused_before.chars, + "after_chars": after.as_ref().and_then(|a| a.chars), + "delta_chars": after.as_ref().and_then(delta_of), + "peak_delta_chars": peak_delta, + "expected_chars": expected_chars, + // Whether any read held the whole text. `null` when the target + // only ever gave us lengths. + "text_complete": focused_before.text.is_some().then_some(seen_complete), + // How fast the target reacted at all, vs when it stopped + // changing. A large gap between them means a slow consumer; + // first_change absent means it never reacted. + "first_change_ms": first_change_ms, + "settled_ms": settled_ms, + // What the observation cost, and how much of it was spent on a + // target that was not answering. `reads:1` with a decided + // verdict is the cheap happy path; a high `retries` names the + // apps worth a third read strategy. + "reads": reads, + "retries": retries, + }); + // Keys from anyone but us between injection and now: the user's own + // (and when they hit Return), or another program injecting. + if let (Some(map), serde_json::Value::Object(extra)) = (fields.as_object_mut(), input_mark.since()) { + map.extend(extra); + } + trace.stage("paste.verify", fields); }); } +/// Replace a partial landing with the text, once, if it is still ours to fix. +/// +/// Returns the trace slug: `repaired`, `late_complete` (the field finished +/// filling on its own), `repair_failed`, or `skipped_`. +/// Every guard is a way the field could now hold something that is not ours: +/// the user moved to another app, started the next dictation, or edited (or +/// sent) the message. In each case the scraps stay, the clipboard gets the +/// text, and the pill says so — retyping over somebody's edit would be worse +/// than the bug. +async fn repair_partial_landing( + expected: &str, + before: &FocusSnapshot, + settled: &FocusSnapshot, + range: (usize, usize), + target_app: Option<&str>, +) -> &'static str { + if crate::state::recording_in_progress() { + return "skipped_recording"; + } + if target_app.is_some() && crate::paste::frontmost_bundle_id().as_deref() != target_app { + return "skipped_app_changed"; + } + let now = crate::paste::probe_focused_text(); + if &now != settled { + // A slow consumer that paused past the quiet window and then finished + // is not a partial landing at all. + if let (Some(b), Some(a)) = (before.text.as_deref(), now.text.as_deref()) { + if crate::paste::assess(expected, b, a) == crate::paste::Landing::Complete { + return "late_complete"; + } + } + return "skipped_field_changed"; + } + if !crate::paste::select_focused_range(range.0, range.1) { + return "skipped_no_selection"; + } + let text = expected.to_string(); + match tauri::async_runtime::spawn_blocking(move || simulate_typing(&text)).await { + Ok(Ok(())) => {} + _ => return "repair_failed", + } + let Some(before_text) = before.text.as_deref() else { + return "repair_failed"; + }; + let started = std::time::Instant::now(); + while started.elapsed() < Duration::from_millis(PASTE_VERIFY_TIMEOUT_MS) { + sleep(Duration::from_millis(PASTE_VERIFY_POLL_MS)).await; + if let Some(now) = crate::paste::probe_focused_text().text { + if crate::paste::assess(expected, before_text, &now) == crate::paste::Landing::Complete { + return "repaired"; + } + } + } + "repair_failed" +} + /// Tauri command to process a completed recording /// /// Called by frontend after mic-recorder stops and returns the file path. diff --git a/src/hooks/usePasteCompletion.test.tsx b/src/hooks/usePasteCompletion.test.tsx index fab2903..c79f9a3 100644 --- a/src/hooks/usePasteCompletion.test.tsx +++ b/src/hooks/usePasteCompletion.test.tsx @@ -144,6 +144,18 @@ describe('usePasteCompletion', () => { expect(result.current).toBe('paste_swallowed'); }); + /* 0024-8368: scraps landed and were sent. The verdict comes after the + frame, and the user has to learn that ⌘V holds the whole text. */ + it('re-opens for a partial landing that arrives after the frame closed', async () => { + const { result, rerender } = mount(); + await settleSubscription(); + rerender({ stage: 'complete', params: { outcome: 'pasted_unverified' } }); + act(() => { vi.advanceTimersByTime(SETTLE_MS + 1500); }); + expect(result.current).toBeNull(); + verify('paste_partial'); + expect(result.current).toBe('paste_partial'); + }); + it('does not re-open twice for the same swallow', async () => { const { result, rerender } = mount(); await settleSubscription(); diff --git a/src/hooks/usePasteCompletion.ts b/src/hooks/usePasteCompletion.ts index ad7512f..5f27734 100644 --- a/src/hooks/usePasteCompletion.ts +++ b/src/hooks/usePasteCompletion.ts @@ -31,7 +31,7 @@ // itself half a second later is a flicker that tells the user nothing they can // act on. // -// `paste_swallowed` is the exception, in both directions: it may arrive after +// `paste_swallowed` and `paste_partial` are the exception, in both directions: it may arrive after // the frame has committed, and it may arrive after the frame has closed // entirely, and in both cases it re-opens the pill. The asymmetry is the whole // point. An upgrade from "did not see it" to "saw it" changes nothing for the @@ -57,6 +57,10 @@ import { asPasteOutcome, treatmentFor, type PasteOutcome } from '../lib/pasteOut */ export const PASTE_VERIFIED_EVENT = 'paste-verified'; +/** Verdicts that re-open the pill after it committed or closed: the user's + * text is not (all) on screen and they need to know where it is. */ +const BAD_NEWS: ReadonlySet = new Set(['paste_swallowed', 'paste_partial']); + /** How long to wait for a settled verdict before drawing. */ export const SETTLE_MS = 140; @@ -157,7 +161,7 @@ export function usePasteCompletion( // Past the hold, only the bad news gets through — and it gets through even // if the frame had already closed. - if (outcome === 'paste_swallowed' && shownRef.current !== 'paste_swallowed') { + if (BAD_NEWS.has(outcome) && shownRef.current !== outcome) { commit(outcome); } }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ee7204a..c2ed7ee 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -402,6 +402,8 @@ "arrivedAnnouncement": "The text arrived.", "swallowed": "Not pasted · find it in History", "swallowedAnnouncement": "The text could not be pasted. You can find it in Settings, History.", + "partial": "Incomplete · ⌘V pastes it in full", + "partialAnnouncement": "Only part of the text arrived. The full text is on your clipboard — press Cmd+V to paste it.", "clipboard": "Not inserted — the text is on your clipboard", "clipboardAnnouncement": "The text was not inserted. It is on your clipboard — press Cmd+V to paste it." } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a34cf89..42bd9b0 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -402,6 +402,8 @@ "arrivedAnnouncement": "Le texte est arrivé.", "swallowed": "Pas collé · retrouve-le dans l'historique", "swallowedAnnouncement": "Le texte n'a pas pu être collé. Tu le retrouves dans Réglages, Historique.", + "partial": "Texte incomplet · ⌘V le colle en entier", + "partialAnnouncement": "Le texte n'est arrivé qu'en partie. Il est en entier dans ton presse-papiers ; fais Cmd+V pour le coller.", "clipboard": "Rien n'a été inséré — le texte est dans ton presse-papiers", "clipboardAnnouncement": "Le texte n'a pas été inséré. Il est dans ton presse-papiers ; fais Cmd+V pour le coller." } diff --git a/src/lib/pasteOutcome.test.ts b/src/lib/pasteOutcome.test.ts index 48e5f1e..f1965f9 100644 --- a/src/lib/pasteOutcome.test.ts +++ b/src/lib/pasteOutcome.test.ts @@ -35,7 +35,7 @@ function leaf(locale: unknown, dotted: string): unknown { } describe('the outcome vocabulary', () => { - it('is exactly the four slugs Rust can finish with', () => { + it('is exactly the slugs Rust can finish with', () => { // `finish_outcome` in src-tauri/src/paste/outcome.rs. `aborted` is not // here on purpose: a dictation that produced no text never reaches a // paste, and the pill shows an error for it. @@ -43,6 +43,7 @@ describe('the outcome vocabulary', () => { 'pasted', 'pasted_unverified', 'paste_swallowed', + 'paste_partial', 'clipboard_fallback', ]); }); diff --git a/src/lib/pasteOutcome.ts b/src/lib/pasteOutcome.ts index 439d929..780b9e1 100644 --- a/src/lib/pasteOutcome.ts +++ b/src/lib/pasteOutcome.ts @@ -38,6 +38,10 @@ // History. Calm: no red, no tremble, a short line saying where to find // them. Amir, 2026-09-15: "il ne faut pas faire peur aux gens", just tell // them they can get it back. +// * `paste_partial` — scraps of the text arrived and the automatic repair +// could not put it right (2026-09-26: 21 of 156 characters, sent like +// that). The full text is on the clipboard; the line says ⌘V. Same calm +// treatment as a swallow. // * `clipboard_fallback` — unchanged in meaning: the injection never // happened and the text is on the clipboard. // @@ -57,6 +61,7 @@ export const PASTE_OUTCOMES = [ 'pasted', 'pasted_unverified', 'paste_swallowed', + 'paste_partial', 'clipboard_fallback', ] as const; @@ -138,6 +143,15 @@ const TREATMENTS: Record = { tone: 'active', holdMs: 3000, }, + // The field holds scraps; the whole text is on the clipboard. 4 s: the + // line is an instruction, and it has to outlast a glance at the field. + paste_partial: { + mark: 'history', + line: 'floatingBar.outcome.partial', + announcement: 'floatingBar.outcome.partialAnnouncement', + tone: 'active', + holdMs: 4000, + }, // Reached through the `error` stage in practice — Rust routes a failed // injection there with `error.paste_failed` — but kept total over the four // slugs so a payload carrying it can never fall through to a success frame. diff --git a/src/windows/FloatingBar.test.tsx b/src/windows/FloatingBar.test.tsx index 4900a1b..fbab12f 100644 --- a/src/windows/FloatingBar.test.tsx +++ b/src/windows/FloatingBar.test.tsx @@ -234,6 +234,13 @@ describeSuite('the pill at the end of a dictation', () => { expectFrame(container.querySelector('.bg-app-danger\\/95')).toBeNull(); }); + itFrame('says ⌘V holds the whole text when only part of it landed', async () => { + const { container } = await mountBar(); + completeWith('paste_partial'); + expectFrame(screen.getByText('Incomplete · ⌘V pastes it in full')).toBeInTheDocument(); + expectFrame(container.querySelector('.bg-app-danger\\/95')).toBeNull(); + }); + itFrame('speaks French', async () => { setLanguage('fr'); await mountBar(); From d4c2cc60f777cd64ccc878acf15b3d64adb53511 Mon Sep 17 00:00:00 2001 From: Amir KELLOU SIDHOUM Date: Sat, 26 Sep 2026 21:40:53 +0200 Subject: [PATCH 2/3] paste repair: wait for Chromium to apply the selection before giving up First live test on 0a37392 (0000-1f28): the partial landing was detected ("u dicte ?", 9 of 39) and the clipboard got the text, but the repair stopped at `skipped_no_selection`. The selection was read back straight after being set, and Chromium applies it asynchronously in the renderer, so the read still answered the old caret. Poll the read-back for up to 150 ms, off the async runtime, and say which step failed. Co-Authored-By: Claude Opus 5.5 --- docs/tracing.md | 3 +- src-tauri/src/paste/accessibility.rs | 56 +++++++++++++++++-------- src-tauri/src/transcription/pipeline.rs | 11 ++++- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/docs/tracing.md b/docs/tracing.md index c43ab62..470d3ba 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -227,7 +227,8 @@ held the whole text. When scraps sit unchanged for 80 ms, a `paste.repair` line follows: the verifier selected the scraps (AX range, read back to confirm) and retyped the text over them. Its `outcome` is `repaired`, `late_complete` (a slow field finished on its own), `repair_failed` or `skipped_` (`recording`, `app_changed`, `field_changed`: the user edited -or sent it, `no_selection`); `landed` carries the scraps when diagnostics are +or sent it, `selection_no_focus` / `selection_refused` / `selection_unconfirmed`: +the AX selection could not be placed or confirmed); `landed` carries the scraps when diagnostics are on. `repaired` and `late_complete` give `observed` with that `reason`; any other outcome gives `verdict:"partial"`, puts the full text on the clipboard (`clipboard.recovery`) and the pill says ⌘V. Seen 2026-09-26 (`0024-8368`): 156 diff --git a/src-tauri/src/paste/accessibility.rs b/src-tauri/src/paste/accessibility.rs index 8abe929..b7f9e41 100644 --- a/src-tauri/src/paste/accessibility.rs +++ b/src-tauri/src/paste/accessibility.rs @@ -551,15 +551,20 @@ unsafe fn read_ax_string_for_range(element: AXUIElementRef, count: usize) -> Opt } } +/// How long `select_focused_range` waits for the field to report the new +/// selection. Blocking: call it from a blocking thread. +#[cfg(target_os = "macos")] +const SELECTION_CONFIRM_MS: u64 = 150; + /// Select `len` UTF-16 units from `start` in the focused field, and confirm by /// reading the selection back. /// /// The repair's first half: the garbled landing is selected, then retyped -/// over. Returns `false` — and the repair does not type — unless the field -/// reports exactly the requested range afterwards. Typing over a selection we +/// over. Returns an error slug — and the repair does not type — unless the +/// field reports exactly the requested range afterwards. Typing over a selection we /// did not verify could overwrite text the user wrote. #[cfg(target_os = "macos")] -pub fn select_focused_range(start: usize, len: usize) -> bool { +pub fn select_focused_range(start: usize, len: usize) -> Result<(), &'static str> { #[repr(C)] #[derive(Default, PartialEq, Debug)] struct CFRange { @@ -570,14 +575,14 @@ pub fn select_focused_range(start: usize, len: usize) -> bool { unsafe { let system_wide = AXUIElementCreateSystemWide(); if system_wide.is_null() { - return false; + return Err("no_focus"); } let focused_attr = CFString::new("AXFocusedUIElement"); let mut focused: CFTypeRef = std::ptr::null_mut(); let err = AXUIElementCopyAttributeValue(system_wide, focused_attr.as_concrete_TypeRef(), &mut focused); CFRelease(system_wide as CFTypeRef); if err != AX_ERROR_SUCCESS || focused.is_null() { - return false; + return Err("no_focus"); } let element = focused as AXUIElementRef; @@ -585,31 +590,46 @@ pub fn select_focused_range(start: usize, len: usize) -> bool { let value = AXValueCreate(K_AX_VALUE_TYPE_CF_RANGE, &wanted as *const _ as *const c_void); if value.is_null() { CFRelease(focused); - return false; + return Err("set_failed"); } let attr = CFString::new("AXSelectedTextRange"); let set_err = AXUIElementSetAttributeValue(element, attr.as_concrete_TypeRef(), value); CFRelease(value); + if set_err != AX_ERROR_SUCCESS { + CFRelease(focused); + return Err("set_refused"); + } - let mut back: CFTypeRef = std::ptr::null_mut(); - let read_err = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef(), &mut back); - CFRelease(focused); - if set_err != AX_ERROR_SUCCESS || read_err != AX_ERROR_SUCCESS || back.is_null() { - if !back.is_null() { + // Chromium applies the selection in the renderer, asynchronously: a + // read straight after the set still answers the old caret. That is + // what made the first live test (0000-1f28, 2026-09-26) give up with + // `skipped_no_selection` on a selection that would have taken. + let started = std::time::Instant::now(); + let mut confirmed = false; + while started.elapsed() < std::time::Duration::from_millis(SELECTION_CONFIRM_MS) { + let mut back: CFTypeRef = std::ptr::null_mut(); + let read_err = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef(), &mut back); + if read_err == AX_ERROR_SUCCESS && !back.is_null() { + let mut got = CFRange::default(); + let ok = AXValueGetValue(back, K_AX_VALUE_TYPE_CF_RANGE, &mut got as *mut _ as *mut c_void); + CFRelease(back); + if ok && got == wanted { + confirmed = true; + break; + } + } else if !back.is_null() { CFRelease(back); } - return false; + std::thread::sleep(std::time::Duration::from_millis(10)); } - let mut got = CFRange::default(); - let ok = AXValueGetValue(back, K_AX_VALUE_TYPE_CF_RANGE, &mut got as *mut _ as *mut c_void); - CFRelease(back); - ok && got == wanted + CFRelease(focused); + if confirmed { Ok(()) } else { Err("not_confirmed") } } } #[cfg(not(target_os = "macos"))] -pub fn select_focused_range(_start: usize, _len: usize) -> bool { - false +pub fn select_focused_range(_start: usize, _len: usize) -> Result<(), &'static str> { + Err("unsupported") } /// No equivalent read exists on Windows, so every paste there is unverified diff --git a/src-tauri/src/transcription/pipeline.rs b/src-tauri/src/transcription/pipeline.rs index 6c02e05..1b24887 100644 --- a/src-tauri/src/transcription/pipeline.rs +++ b/src-tauri/src/transcription/pipeline.rs @@ -4669,8 +4669,15 @@ async fn repair_partial_landing( } return "skipped_field_changed"; } - if !crate::paste::select_focused_range(range.0, range.1) { - return "skipped_no_selection"; + let selected = tauri::async_runtime::spawn_blocking(move || crate::paste::select_focused_range(range.0, range.1)) + .await + .unwrap_or(Err("join_failed")); + match selected { + Ok(()) => {} + Err("no_focus") => return "skipped_selection_no_focus", + Err("set_refused") => return "skipped_selection_refused", + Err("not_confirmed") => return "skipped_selection_unconfirmed", + Err(_) => return "skipped_no_selection", } let text = expected.to_string(); match tauri::async_runtime::spawn_blocking(move || simulate_typing(&text)).await { From b2fb2d64de2b502a5fc31e2f80a30843f3825c2b Mon Sep 17 00:00:00 2001 From: Amir KELLOU SIDHOUM Date: Sun, 27 Sep 2026 16:13:16 +0200 Subject: [PATCH 3/3] TTP 3.2.4: check that the text arrived whole, and repair it when it did not Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/i18n/locales/en.json | 1 + src/i18n/locales/fr.json | 1 + 6 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 7287350..8360289 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ttp", "private": true, - "version": "3.2.3", + "version": "3.2.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 27d1b37..725c9c7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6611,7 +6611,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp" -version = "3.2.3" +version = "3.2.4" dependencies = [ "block", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c466fd7..c6ecedc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ttp" -version = "3.2.3" +version = "3.2.4" description = "Talk To Paste - Voice to text transcription" authors = ["TTP Team"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1806569..ac2c3c2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "TTP by AmirKS", - "version": "3.2.3", + "version": "3.2.4", "identifier": "com.ttp.desktop", "plugins": { "updater": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c2ed7ee..97d824b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -417,6 +417,7 @@ "subtitle": "TTP has been updated.", "dismiss": "Got it", "notes": { + "3-2-4": "TTP now checks that your text arrived whole.\n• If only part of a dictation lands in the app, TTP notices and retypes the full text over it, usually before you have time to press Return.\n• If it cannot fix it, the full text is on your clipboard and the pill says so: ⌘V pastes it in full.", "3-2-3": "TTP no longer loses your dictations in the pauses.\n• Long dictations with pauses, or spoken quietly, are no longer mistaken for silence and thrown away.\n• If TTP still judges a recording silent, it keeps it for 24 h instead of deleting it.\n• Updates without interruption: TTP downloads the new version in the background, then relaunches itself during a two-minute pause.\n• “Auto” means French or English, never a third language.\n• Settings no longer freeze.", "3-2-2": "TTP is simpler, and still completely free.\n• Any shortcut you like: Fn, a key, a modifier or a mouse button. Add a second one if you want, handy with an external keyboard.\n• Your screen: setup now asks whether TTP may look at the text around your cursor to spell names right. Turn it on or off in Settings → Dictation.\n• Setup says plainly where your voice goes (to Groq) before asking for your key.\n• Guided setup: allow TTP by dragging it into System Settings, then try a first dictation.\n• Redesigned settings: a few short pages instead of one long list.\n• Faster at the end of every dictation.\n• Numbers stay whole: “3.2.2” no longer turns into “3.2”, and the word “point” is no longer swapped for a “.”.\n• Two dictations in a row work every time. If you start the next one before the first is pasted, the pill tells you it lands when you let go.\n• ✓✓ when the text is pasted. If it could not be, it is waiting in History.\n• Something wrong? Settings → Advanced → “Report a problem” prepares an e-mail with a report, without anything you dictated.\n• At the end of setup, you choose whether TTP sends crash reports.", "3-2-1": "TTP is simpler, and still completely free.\n• Any shortcut you like: Fn, a key, a modifier or a mouse button. Add a second one if you want, handy with an external keyboard.\n• TTP reads the screen: names shown around your cursor and the ones in your dictionary come out spelled right. Only what your dictation needs goes to Groq with it, never a password field. Turn it off in Settings → Dictation → “Use what's on screen”.\n• Guided setup: allow TTP by dragging it into System Settings, then try a first dictation.\n• Redesigned settings: a few short pages instead of one long list.\n• Two dictations in a row work every time.\n• ✓✓ when the text is pasted. If it could not be, it is waiting in History.\n• AI polish works again: Groq had retired the old model.\n• Something wrong? Settings → Advanced → “Report a problem” prepares an e-mail with a report, without anything you dictated.\n• At the end of setup, you choose whether TTP sends crash reports.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 42bd9b0..508a2b5 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -417,6 +417,7 @@ "subtitle": "TTP a été mis à jour.", "dismiss": "Compris", "notes": { + "3-2-4": "TTP vérifie maintenant que ton texte est arrivé en entier.\n• Si seule une partie d'une dictée arrive dans l'app, TTP s'en rend compte et retape le texte complet par-dessus, en général avant que tu aies le temps d'appuyer sur Entrée.\n• S'il ne peut pas réparer, le texte complet est dans ton presse-papiers et la pilule te le dit : ⌘V le colle en entier.", "3-2-3": "TTP ne perd plus tes dictées dans les silences.\n• Les longues dictées avec des pauses, ou dites à voix basse, ne sont plus prises pour du silence et jetées.\n• Si TTP juge quand même un enregistrement silencieux, il le garde 24 h au lieu de l'effacer.\n• Mises à jour sans coupure : TTP télécharge la nouvelle version en arrière-plan, puis se relance tout seul pendant une pause de deux minutes.\n• « Auto » veut dire français ou anglais, jamais une troisième langue.\n• Les Réglages ne se figent plus.", "3-2-2": "TTP est plus simple, et toujours entièrement gratuit.\n• Le raccourci de ton choix : Fn, une touche, un modificateur ou un bouton de souris. Et un deuxième si tu veux, pratique avec un clavier externe.\n• Ton écran : l'installation te demande maintenant si TTP peut regarder le texte autour de ton curseur pour bien écrire les noms. Réglages → Dictée pour l'activer ou le couper.\n• L'installation dit clairement où part ta voix (chez Groq) avant de te demander ta clé.\n• Installation guidée : tu autorises TTP en le glissant dans les Réglages Système, puis tu fais un premier essai.\n• Réglages repensés : quelques pages courtes au lieu d'une longue liste.\n• Plus rapide à la fin de chaque dictée.\n• Les nombres restent entiers : « 3.2.2 » ne devient plus « 3.2 », et « du point de vue » n'est plus coupé par un « . ».\n• Deux dictées d'affilée marchent à chaque fois. Si tu relances avant que la première soit collée, la pilule te dit qu'elle arrive quand tu lâches.\n• ✓✓ quand le texte est collé. S'il n'a pas pu l'être, il t'attend dans l'historique.\n• Un souci ? Réglages → Avancé → « Signaler un problème » prépare un e-mail avec un rapport, sans rien de ce que tu as dicté.\n• À la fin de l'installation, tu choisis si TTP envoie ses rapports de plantage.", "3-2-1": "TTP est plus simple, et toujours entièrement gratuit.\n• Le raccourci de ton choix : Fn, une touche, un modificateur ou un bouton de souris. Et un deuxième si tu veux, pratique avec un clavier externe.\n• TTP tient compte de l'écran : les noms affichés autour de ton curseur et ceux de ton dictionnaire sont écrits correctement. Seul ce qui sert à ta dictée part chez Groq avec elle, jamais un champ de mot de passe. Réglages → Dictée → « Tenir compte de l'écran » pour le couper.\n• Installation guidée : tu autorises TTP en le glissant dans les Réglages Système, puis tu fais un premier essai.\n• Réglages repensés : quelques pages courtes au lieu d'une longue liste.\n• Deux dictées d'affilée marchent à chaque fois.\n• ✓✓ quand le texte est collé. S'il n'a pas pu l'être, il t'attend dans l'historique.\n• La correction IA remarche : Groq avait retiré l'ancien modèle.\n• Un souci ? Réglages → Avancé → « Signaler un problème » prépare un e-mail avec un rapport, sans rien de ce que tu as dicté.\n• À la fin de l'installation, tu choisis si TTP envoie ses rapports de plantage.",