From e00685df13f53a9b1f7f56a083d223a6d8de9051 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 20 Oct 2026 17:11:46 -0400 Subject: [PATCH 1/9] Start fixes and field reads only when no key is on its way to the app Keys typed right after a space reached the app before the fix began, so its backspaces ate them; a field read taken while keys were still queued counted them twice, and the doubled letter was then fixed by deleting the space before the word. The hook now numbers every key and click that reaches the app: fixes and completions start capture only if nothing got through after their key, and field text is read with no key in flight and kept only if it agrees. Arrows, Tab, Esc and edit keys re-read the field later; stale tokens stay out of ghosts. Verified with cargo test --workspace. Claude-Session: https://claude.ai/code/session_01DiRshLaPboWShtRAdGazjy --- crates/cc-app/src/fixgate.rs | 54 ++++++ crates/cc-app/src/main.rs | 180 ++++++++++++++++---- crates/cc-context/src/lib.rs | 39 ++++- crates/cc-hook/examples/dump.rs | 9 +- crates/cc-hook/src/lib.rs | 286 ++++++++++++++++++++++---------- 5 files changed, 431 insertions(+), 137 deletions(-) diff --git a/crates/cc-app/src/fixgate.rs b/crates/cc-app/src/fixgate.rs index 7972bd9..404e7eb 100644 --- a/crates/cc-app/src/fixgate.rs +++ b/crates/cc-app/src/fixgate.rs @@ -12,6 +12,8 @@ const UNDO_LIMIT: usize = 2; pub const PAUSE: Duration = Duration::from_secs(60); /// Characters compared by [`same_tail`]. const TAIL: usize = 32; +/// Characters the field may lag behind the shadow and still count as [`Tail::Behind`]. +const LAG: usize = 8; /// Keys that end a word for auto-fix. An apostrophe or hyphen continues the word /// (`doesn't`, `e-mail`): fixing `doesn` at the apostrophe leaves `does't`. @@ -90,6 +92,41 @@ pub fn same_tail(field: &str, shadow: &str) -> bool { field.ends_with(&shadow[shadow.len().saturating_sub(TAIL)..]) } +/// Field text as UIA gives it, with characters nobody typed turned into spaces one for one, +/// so lengths still match the shadow: U+FFFC stands for an embedded object (an image, a +/// mention chip), and browsers keep a typed space as a no-break space. +pub fn field_text(raw: &str) -> String { + raw.chars().map(|c| if matches!(c, '\u{FFFC}' | '\u{A0}' | '\u{202F}') { ' ' } else { c }).collect() +} + +/// How the end of the field's text relates to the end of the shadow buffer. +#[derive(Debug, PartialEq, Eq)] +pub enum Tail { + Same, + /// The field is the shadow without its last few characters: UIA has not caught up. + Behind, + /// Something changed the text without the shadow seeing it. + Different, +} + +pub fn compare(field: &str, shadow: &str) -> Tail { + if same_tail(field, shadow) { + return Tail::Same; + } + let chars: Vec = shadow.chars().collect(); + for k in 1..=LAG.min(chars.len()) { + let older: String = chars[..chars.len() - k].iter().collect(); + // Too little left to tell a lagging field from a different one. + if comparable(&older).len() < TAIL / 2 { + break; + } + if same_tail(field, &older) { + return Tail::Behind; + } + } + Tail::Different +} + fn comparable(s: &str) -> Vec { let mut out = Vec::new(); for c in s.trim_end().chars() { @@ -295,6 +332,23 @@ mod tests { assert!(!same_tail("previousthe ", "previous the ")); } + #[test] + fn field_text_keeps_lengths_and_drops_what_was_never_typed() { + assert_eq!(field_text("a\u{FFFC}b\u{A0}c"), "a b c"); + assert_eq!(field_text("plain").chars().count(), 5); + } + + #[test] + fn compare_tells_a_lagging_field_from_a_changed_one() { + let shadow = "the quick brown fox jumps over the lazy dog"; + assert_eq!(compare("Once more: the quick brown fox jumps over the lazy dog", shadow), Tail::Same); + assert_eq!(compare("the quick brown fox jumps over the lazy d", shadow), Tail::Behind); + assert_eq!(compare("the quick brown fox jumps over the lazydog", shadow), Tail::Different); + assert_eq!(compare("something else entirely", shadow), Tail::Different); + assert_eq!(compare("anything", ""), Tail::Same, "an empty shadow knows nothing"); + assert_eq!(compare("ab", "abc"), Tail::Different, "too short to call it lag"); + } + #[test] fn breaker_trips_on_two_quick_undos_in_one_window() { let mut b = Breaker::default(); diff --git a/crates/cc-app/src/main.rs b/crates/cc-app/src/main.rs index 9f77264..bf6e6e5 100644 --- a/crates/cc-app/src/main.rs +++ b/crates/cc-app/src/main.rs @@ -50,7 +50,8 @@ const DEFAULT_MODEL: &str = "gemma-3-1b-pt"; const TERMINAL_EXES: &[&str] = &["windowsterminal.exe", "conhost.exe", "openconsole.exe", "wezterm-gui.exe", "alacritty.exe"]; enum Msg { - Key(HookEvent), + /// A hook event and how many keys and clicks had reached the app with it. + Key(u64, HookEvent), Token { gen: u64, text: String, logprob: Option }, Done { gen: u64, result: anyhow::Result }, } @@ -214,8 +215,14 @@ struct App { dont_touch: HashSet, reverts: HashMap, shadow: ShadowBuffer, + /// The count (`SwallowState::passed`) of keys and clicks the shadow has taken in. While + /// the hook's count is higher, keys are still on their way to this thread. + applied: u64, ghost: String, ghost_visible: bool, + /// What was typed along or accepted from the current completion, so that later tokens + /// do not show it again. + consumed: String, gen: u64, cancel: CancellationToken, pending: bool, @@ -228,6 +235,8 @@ struct App { style: GhostStyle, last_exe: String, last_hwnd: isize, + /// `FocusInfo::element` of the last field seen, 0 when unknown. + last_element: u64, in_terminal: bool, } @@ -392,8 +401,12 @@ impl App { self.swallow.set_ghost_visible(false); self.cancel.cancel(); self.cancel = CancellationToken::new(); + // Tokens already on their way belong to the cancelled completion. + self.gen += 1; } + /// Takes the field's text as the shadow. Not while keys are on their way to this thread: + /// the field already shows them, and their events would add them a second time. fn resync(&mut self) { self.cancel_ghost(); self.pending = false; @@ -401,10 +414,17 @@ impl App { self.shadow.reset_unknown(); return; } + self.shadow.reset_unknown(); + if self.keys_in_flight() { + self.resync_at = Some(Instant::now() + Duration::from_millis(50)); + return; + } if let Some(t) = cc_context::text_before_caret(CONTEXT_CHARS) { - self.shadow.reset(&t); - } else { - self.shadow.reset_unknown(); + if self.keys_in_flight() { + self.resync_at = Some(Instant::now() + Duration::from_millis(50)); + } else { + self.shadow.reset(&fixgate::field_text(&t)); + } } } @@ -417,6 +437,30 @@ impl App { self.resync_at = Some(Instant::now() + Duration::from_millis(150)); } + /// Keys or clicks have reached the app whose events this thread has not handled yet. + fn keys_in_flight(&self) -> bool { + self.swallow.passed() > self.applied + } + + /// Takes the field's text as the shadow when it ends with what the shadow says was typed. + /// A field that only lags behind leaves the shadow alone; one that disagrees changed + /// behind the shadow's back, so it is read again once things settle. + fn adopt_field_text(&mut self) { + let Some(field) = cc_context::text_before_caret(CONTEXT_CHARS) else { return }; + if self.keys_in_flight() { + return; + } + let field = fixgate::field_text(&field); + match fixgate::compare(&field, self.shadow.text()) { + fixgate::Tail::Same => self.shadow.reset(&field), + fixgate::Tail::Behind => {} + fixgate::Tail::Different => { + log::debug!("field text differs from the shadow; reading it again"); + self.resync_soon(); + } + } + } + fn field_allowed(&mut self) -> bool { match cc_context::focus_info() { Some(fi) => { @@ -429,6 +473,12 @@ impl App { self.last_hwnd = fi.hwnd; // A different window means the shadow buffer describes the wrong field. self.shadow.reset_unknown(); + } else if fi.element != 0 && self.last_element != 0 && fi.element != self.last_element { + // Another input of the same window, such as the next field of a web form. + self.shadow.reset_unknown(); + } + if fi.element != 0 { + self.last_element = fi.element; } self.in_terminal = TERMINAL_EXES.contains(&fi.exe.as_str()); if let Some(why) = self.policy.denies(&fi.exe, &cc_context::window_title()) { @@ -447,17 +497,15 @@ impl App { fn request_completion(&mut self) { self.pending = false; + if self.keys_in_flight() { + self.pending = true; + return; + } if !self.field_allowed() { return; } - // UIA text is authoritative once it has caught up with what we typed or injected; - // right after an injection it can still be short, so the shadow buffer wins then. if !self.in_terminal { - if let Some(t) = cc_context::text_before_caret(CONTEXT_CHARS) { - if t.len() >= self.shadow.text().len() { - self.shadow.reset(&t); - } - } + self.adopt_field_text(); } let ctx = self.shadow.text().to_string(); if ctx.trim().split_whitespace().count() < 2 { @@ -488,6 +536,7 @@ impl App { let ctx = trimmed.to_string(); self.heal = heal; self.raw.clear(); + self.consumed.clear(); self.tok_count = 0; self.logprob_sum = 0.0; // Recently accepted sentences in this app give the base model your voice. @@ -517,14 +566,22 @@ impl App { } } - fn accept(&mut self, whole: bool) { + /// Types the ghost, or its next word unless `whole`. False when there is none. + fn accept(&mut self, whole: bool) -> bool { if self.ghost.is_empty() { - return; + return false; } // Hide first so the hook stops swallowing Tab/Esc while the text goes in. self.overlay.hide(); self.ghost_visible = false; self.swallow.set_ghost_visible(false); + // A key that reached the app after the Tab would put the text in the wrong place. + if !self.swallow.begin_capture(self.applied) { + log::debug!("completion not inserted: typed ahead of it"); + self.cancel_ghost(); + self.pending = true; + return true; + } if let Some(id) = self.pending_completion_event.take() { self.outcome(id, Outcome::Accepted); } @@ -542,17 +599,19 @@ impl App { head }; log::debug!("inject {text:?}"); - self.swallow.set_capture(true); let result = if use_paste() { cc_inject::paste_text(&text) } else { cc_inject::type_text(&text) }; self.end_capture(); if let Err(e) = result { log::warn!("inject failed: {e:#}"); + self.resync_soon(); + return true; } for c in text.chars() { self.shadow.push(c); } let more = !self.ghost.is_empty(); if more { + self.consumed.push_str(&text); self.show_ghost(); } else { self.cancel_ghost(); @@ -563,6 +622,7 @@ impl App { self.last_key = Instant::now() + settle; self.pending = true; } + true } /// Runs after the word before `boundary` is closed. Auto-applies one candidate that the @@ -643,6 +703,7 @@ impl App { let mut typed = c.replacement.clone(); typed.push(boundary); let del = c.original.chars().count() + 1; + let captured_before = self.swallow.captured(); if !self.replace_tail(del, &typed) { return; } @@ -676,11 +737,14 @@ impl App { let w = (typed.chars().count() as f32 * self.style.font_px * 0.55) as i32; self.overlay.show_underline(cc_overlay::Rect { x: r.x - w, y: r.y, w, h: r.h }, UNDERLINE_FADE_MS); } - self.swallow.set_revert_until(cc_hook::now_ms() + REVERT_WINDOW_MS); if let Some((id, _, _)) = self.pending_fix_event.replace((event_id, Instant::now(), self.shadow.text().chars().count())) { self.outcome(id, Outcome::Kept); } - self.last_fix = Some(Fix { original: c.original, replacement: c.replacement, boundary, event_id }); + // Keys typed while the fix went in come after it, so Backspace then is a Backspace. + if self.swallow.captured() == captured_before { + self.swallow.set_revert_until(cc_hook::now_ms() + REVERT_WINDOW_MS); + self.last_fix = Some(Fix { original: c.original, replacement: c.replacement, boundary, event_id }); + } } /// If the user went back over the previous word and retyped it as a near variant, the @@ -713,10 +777,14 @@ impl App { /// Deletes `del` chars before the caret and types `typed`, keeping the shadow in step. fn replace_tail(&mut self, del: usize, typed: &str) -> bool { let gap = Duration::from_millis(fix_key_gap_ms()); - // Capture the user's keys for the duration, and let the boundary key that triggered - // us settle in the target before deleting behind it. - self.swallow.set_capture(true); - let passed = self.swallow.passed(); + // Capture the user's keys for the duration. A key or click that already reached the + // app after the one being handled means the field no longer ends where the shadow does. + if !self.swallow.begin_capture(self.applied) { + log::info!("fix not applied: typed ahead of it"); + return false; + } + let passed = self.applied; + // Let the boundary key that triggered us settle in the target before deleting behind it. std::thread::sleep(Duration::from_millis(fix_pre_delay_ms())); // Ctrl, Alt or Win held would turn Backspace into delete-word or a shortcut, and a key // or click that got past the capture may have moved the caret: either way the planned @@ -782,7 +850,7 @@ impl App { self.replay_at = Some(now); } } - self.swallow.set_capture(false); + self.swallow.end_capture(); self.capture_deadline = None; } @@ -835,7 +903,7 @@ impl App { HookEvent::Char(c) } }; - self.on_key(ev); + self.on_key(self.applied, ev); } } @@ -868,12 +936,22 @@ impl App { log::info!("reverted {:?} -> {:?}", fix.replacement, fix.original); } - fn on_key(&mut self, ev: HookEvent) { - log::trace!("key {ev:?} ghost_visible={} ghost={:?}", self.ghost_visible, self.ghost); + /// `seq`: how many keys and clicks had reached the app with this event; events replayed + /// from a capture pass `self.applied`. + fn on_key(&mut self, seq: u64, ev: HookEvent) { + log::trace!("key #{seq} {ev:?} ghost_visible={} ghost={:?}", self.ghost_visible, self.ghost); + // The event's own key went to the app (Tab and Esc reach it only with no ghost). + let reached_app = seq > self.applied; + // A skipped number is input that reached the app with no event: a dead key, another + // program's keys, an event lost on the way. + let unseen = seq > self.applied + 1; + self.applied = self.applied.max(seq); if let HookEvent::Captured(c) = ev { - // The hook swallowed it, so it is replayed even while paused. + // The hook swallowed it, so it is replayed even while paused. It comes after + // whatever was going in, so that can no longer be undone with Backspace. self.captured_seen += 1; self.replay.push(c); + self.last_fix = None; return; } if !self.active() { @@ -882,6 +960,10 @@ impl App { self.shadow.reset_unknown(); return; } + if unseen { + log::debug!("input reached the app without an event; reading the field again"); + self.resync_soon(); + } self.last_key = Instant::now(); let modifier_only = matches!(ev, HookEvent::Other(vk) if is_modifier_vk(vk)); if self.last_fix.is_some() && !modifier_only && ev != HookEvent::RevertRequested { @@ -899,6 +981,7 @@ impl App { } if self.ghost_visible && self.ghost.starts_with(c) { self.ghost.remove(0); + self.consumed.push(c); if self.ghost.trim().is_empty() { self.cancel_ghost(); self.pending = true; @@ -927,11 +1010,22 @@ impl App { self.cancel_ghost(); self.pending = true; } - HookEvent::Tab => self.accept(true), - HookEvent::CtrlTab => self.accept(false), - HookEvent::Esc => self.cancel_ghost(), + HookEvent::Tab | HookEvent::CtrlTab => { + // A Tab the hook let through went to the app, so focus may have moved. + if reached_app || !self.accept(ev == HookEvent::Tab) { + self.cancel_ghost(); + self.resync_soon(); + } + } + HookEvent::Esc => { + self.cancel_ghost(); + if reached_app { + self.resync_soon(); + } + } HookEvent::Other(vk) if is_modifier_vk(vk) => {} - HookEvent::Left | HookEvent::Right | HookEvent::Nav | HookEvent::Other(_) => self.resync(), + // The app moves the caret or edits the text only once it has handled the key. + HookEvent::Left | HookEvent::Right | HookEvent::Nav | HookEvent::Other(_) => self.resync_soon(), HookEvent::RevertRequested => self.revert_fix(), HookEvent::Click => self.resync_soon(), HookEvent::Captured(_) => {} @@ -986,6 +1080,12 @@ impl App { self.cancel_ghost(); return; }; + // What was typed along or accepted from this completion is no longer ghost text. + let Some(ghost) = ghost.strip_prefix(self.consumed.as_str()).map(str::to_string) else { + log::debug!("completion no longer matches what was typed: {:?}", self.raw); + self.cancel_ghost(); + return; + }; if ghost.trim().is_empty() { return; } @@ -1002,7 +1102,10 @@ impl App { return; // not enough evidence yet; keep accumulating silently } self.ghost = ghost; - self.show_ghost(); + // The next key decides whether this ghost stays; not worth UIA calls before that. + if !self.keys_in_flight() { + self.show_ghost(); + } } fn on_done(&mut self, gen: u64, result: anyhow::Result) { @@ -1251,7 +1354,11 @@ impl App { let now = Instant::now(); if now >= check.give_up || check.hwnd != self.last_hwnd { self.fix_check = None; - } else if now >= check.due && self.last_key.elapsed() >= DEBOUNCE && self.replay_at.is_none() { + } else if now >= check.due + && self.last_key.elapsed() >= DEBOUNCE + && self.replay_at.is_none() + && !self.keys_in_flight() + { self.fix_check = None; self.verify_field(check.hwnd); } @@ -1271,7 +1378,7 @@ impl App { fn run(rx: Receiver, mut app: App) { loop { match rx.recv_timeout(Duration::from_millis(20)) { - Ok(Msg::Key(ev)) => app.on_key(ev), + Ok(Msg::Key(seq, ev)) => app.on_key(seq, ev), Ok(Msg::Token { gen, text, logprob }) => app.on_token(gen, text, logprob), Ok(Msg::Done { gen, result }) => app.on_done(gen, result), Err(RecvTimeoutError::Timeout) => {} @@ -1371,15 +1478,15 @@ fn start() -> anyhow::Result<()> { let overlay = Overlay::new().context("overlay")?; let swallow = Arc::new(SwallowState::default()); - let (ktx, krx) = unbounded::(); + let (ktx, krx) = unbounded::(); let _hook = cc_hook::start(ktx, swallow.clone()).context("keyboard hook")?; { let tx = tx.clone(); std::thread::Builder::new() .name("cc-keyfwd".into()) .spawn(move || { - for ev in krx { - if tx.send(Msg::Key(ev)).is_err() { + for (seq, ev) in krx { + if tx.send(Msg::Key(seq, ev)).is_err() { break; } } @@ -1476,8 +1583,10 @@ fn start() -> anyhow::Result<()> { dont_touch: HashSet::new(), reverts: HashMap::new(), shadow: ShadowBuffer::new(), + applied: 0, ghost: String::new(), ghost_visible: false, + consumed: String::new(), gen: 0, cancel: CancellationToken::new(), pending: false, @@ -1490,6 +1599,7 @@ fn start() -> anyhow::Result<()> { style: GhostStyle::default(), last_exe: String::new(), last_hwnd: 0, + last_element: 0, in_terminal: false, }; if let Err(e) = app.start_engine(&model_id) { diff --git a/crates/cc-context/src/lib.rs b/crates/cc-context/src/lib.rs index 2eb227b..4e1fd2e 100644 --- a/crates/cc-context/src/lib.rs +++ b/crates/cc-context/src/lib.rs @@ -77,6 +77,9 @@ pub struct Rect { pub x: i32, pub y: i32, pub w: i32, pub h: i32 } #[derive(Debug, Clone)] pub struct FocusInfo { pub hwnd: isize, + /// The focused element's UIA RuntimeId folded into one number, 0 when unknown. It tells + /// apart the inputs of one window, which share its handle (every field of a web page). + pub element: u64, pub exe: String, pub is_password: bool, pub is_editable: bool, @@ -194,6 +197,7 @@ pub fn focus_info() -> Option { let el = ok("GetFocusedElement", uia.GetFocusedElement())?; let ct = ok("CurrentControlType", el.CurrentControlType())?; let is_password = ok("CurrentIsPassword", el.CurrentIsPassword())?.as_bool(); + let element = element_id(&el); let hwnd = ok("CurrentNativeWindowHandle", el.CurrentNativeWindowHandle()) .filter(|h| !h.0.is_null()) .unwrap_or(fg); @@ -214,15 +218,15 @@ pub fn focus_info() -> Option { } else { None }; - Some((hwnd, ct.0, is_password, is_editable, real_exe)) + Some((hwnd, element, ct.0, is_password, is_editable, real_exe)) }); Some(match uia_part { - Some((hwnd, ct, is_password, is_editable, real_exe)) => { + Some((hwnd, element, ct, is_password, is_editable, real_exe)) => { if let Some(e) = real_exe { exe = e; } - FocusInfo { hwnd: hwnd.0 as isize, exe, is_password, is_editable, control_type: Some(ct) } + FocusInfo { hwnd: hwnd.0 as isize, element, exe, is_password, is_editable, control_type: Some(ct) } } None => { let focus = gui_thread_info(tid) @@ -231,6 +235,7 @@ pub fn focus_info() -> Option { .unwrap_or(fg); FocusInfo { hwnd: focus.0 as isize, + element: 0, exe, is_password: win32_is_password(focus), is_editable: false, @@ -533,8 +538,30 @@ unsafe fn bounding_rects(range: &IUIAutomationTextRange) -> Option> { } unsafe fn read_f64_safearray(psa: *const SAFEARRAY) -> Option> { - if (*psa).cDims != 1 || (*psa).cbElements != 8 { - log::debug!("GetBoundingRectangles: unexpected SAFEARRAY shape"); + read_safearray(psa, "GetBoundingRectangles") +} + +/// `el.GetRuntimeId()` hashed (FNV-1a), 0 when the provider gives none. +unsafe fn element_id(el: &IUIAutomationElement) -> u64 { + let Some(psa) = ok("GetRuntimeId", el.GetRuntimeId()) else { return 0 }; + if psa.is_null() { + return 0; + } + let ids = read_safearray::(psa, "GetRuntimeId"); + if let Err(e) = SafeArrayDestroy(psa) { + log::debug!("SafeArrayDestroy failed: {e}"); + } + ids.map_or(0, |ids| { + ids.iter().flat_map(|id| id.to_le_bytes()).fold(0xcbf2_9ce4_8422_2325, |h, b| { + (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3) + }) + }) +} + +/// The elements of a one-dimensional SAFEARRAY of `T`. +unsafe fn read_safearray(psa: *const SAFEARRAY, what: &str) -> Option> { + if (*psa).cDims != 1 || (*psa).cbElements as usize != std::mem::size_of::() { + log::debug!("{what}: unexpected SAFEARRAY shape"); return None; } let lb = ok("SafeArrayGetLBound", SafeArrayGetLBound(psa, 1))?; @@ -548,7 +575,7 @@ unsafe fn read_f64_safearray(psa: *const SAFEARRAY) -> Option> { let vals = if data.is_null() { Vec::new() } else { - std::slice::from_raw_parts(data as *const f64, n).to_vec() + std::slice::from_raw_parts(data as *const T, n).to_vec() }; if let Err(e) = SafeArrayUnaccessData(psa) { log::debug!("SafeArrayUnaccessData failed: {e}"); diff --git a/crates/cc-hook/examples/dump.rs b/crates/cc-hook/examples/dump.rs index 4a8732a..b59b3e2 100644 --- a/crates/cc-hook/examples/dump.rs +++ b/crates/cc-hook/examples/dump.rs @@ -7,12 +7,13 @@ //! ``` //! //! Ctrl+Alt+Z is always swallowed and reported as `RevertRequested`. Key-ups and injected -//! input are never reported. Focus any other window and type; events appear here. +//! input are never reported. Focus any other window and type; events appear here, each +//! with the number of keys and clicks that had reached the app. use std::sync::Arc; use std::time::{Duration, Instant}; -use cc_hook::{now_ms, start, HookEvent, SwallowState}; +use cc_hook::{now_ms, start, Stamped, SwallowState}; use crossbeam_channel::RecvTimeoutError; const RUN_FOR: Duration = Duration::from_secs(20); @@ -22,7 +23,7 @@ fn main() -> anyhow::Result<()> { let ghost = args.iter().any(|a| a == "--ghost"); let revert = args.iter().any(|a| a == "--revert"); - let (tx, rx) = crossbeam_channel::bounded::(1024); + let (tx, rx) = crossbeam_channel::bounded::(1024); let swallow = Arc::new(SwallowState::default()); swallow.set_ghost_visible(ghost); if revert { @@ -43,7 +44,7 @@ fn main() -> anyhow::Result<()> { break; } match rx.recv_timeout(deadline - now) { - Ok(ev) => println!("[{:>6} ms] {:?}", now_ms(), ev), + Ok((seq, ev)) => println!("[{:>6} ms] #{seq} {:?}", now_ms(), ev), Err(RecvTimeoutError::Timeout) => break, Err(RecvTimeoutError::Disconnected) => break, } diff --git a/crates/cc-hook/src/lib.rs b/crates/cc-hook/src/lib.rs index 1c5a9a5..eb782c4 100644 --- a/crates/cc-hook/src/lib.rs +++ b/crates/cc-hook/src/lib.rs @@ -8,6 +8,9 @@ //! Left / Backspace are swallowed only while `now_ms < revert_until_ms` (then reported as //! `RevertRequested`). Ctrl+Alt+Z is always swallowed and reported as `RevertRequested`. //! - Injected input (`LLKHF_INJECTED`) is ignored so our own SendInput never echoes back. +//! - Every key and click that reaches the app is numbered, and each event carries that +//! number, so the orchestrator can tell whether anything reached the app after the +//! event it is handling ([`SwallowState::begin_capture`]). //! - `ShadowBuffer` is a pure data structure; the orchestrator (not the hook) updates it. //! //! Implementation notes: @@ -71,17 +74,17 @@ pub enum HookEvent { #[derive(Debug, Default)] pub struct SwallowState { pub ghost_visible: AtomicBool, - /// Milliseconds since process start; Left/Backspace revert until then. + /// Milliseconds since process start; Left/Backspace revert until then. The hook clears + /// it on the next key, which makes the fix no longer the last thing typed. pub revert_until_ms: AtomicU64, - /// While set, text keys are swallowed and reported as `Captured` so they cannot - /// interleave with an injection in progress. - pub capture: AtomicBool, + /// Bit 0: capture is on, so text keys are swallowed and reported as `Captured` and + /// cannot interleave with an injection in progress. The other bits count the keys and + /// clicks that reached the app. One word, so that starting capture and letting a key + /// through cannot cross. + gate: AtomicU64, /// Keys reported as `Captured` so far. Counted before sending, so a key still on its /// way to the orchestrator shows up here first. pub captured: AtomicU64, - /// Keys and clicks let through while `capture` was set (arrows, shortcuts, mouse - /// buttons): each may have moved the caret or changed the text under an injection. - pub passed: AtomicU64, } impl SwallowState { @@ -91,17 +94,50 @@ impl SwallowState { pub fn set_revert_until(&self, ms: u64) { self.revert_until_ms.store(ms, Ordering::SeqCst); } - pub fn set_capture(&self, v: bool) { - self.capture.store(v, Ordering::SeqCst); + /// Turns capture on, or keeps it on, if nothing has reached the app since the key or + /// click numbered `seen`. False means something got through after it, so the text or + /// the caret may no longer be where the orchestrator thinks. + pub fn begin_capture(&self, seen: u64) -> bool { + self.gate + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |g| (g >> 1 == seen).then_some(g | 1)) + .is_ok() + } + pub fn end_capture(&self) { + self.gate.fetch_and(!1, Ordering::SeqCst); + } + pub fn capturing(&self) -> bool { + self.gate.load(Ordering::SeqCst) & 1 == 1 } pub fn captured(&self) -> u64 { self.captured.load(Ordering::SeqCst) } + /// Keys and clicks that have reached the app so far. pub fn passed(&self) -> u64 { - self.passed.load(Ordering::SeqCst) + self.gate.load(Ordering::SeqCst) >> 1 + } + /// Counts one key or click that goes through to the app and returns its number. + fn pass(&self) -> u64 { + (self.gate.fetch_add(2, Ordering::SeqCst) >> 1) + 1 + } + /// Like [`Self::pass`] for a key that capture swallows: None while capture is on. + fn pass_unless_capturing(&self) -> Option { + let mut g = self.gate.load(Ordering::SeqCst); + loop { + if g & 1 == 1 { + return None; + } + match self.gate.compare_exchange_weak(g, g + 2, Ordering::SeqCst, Ordering::SeqCst) { + Ok(_) => return Some((g >> 1) + 1), + Err(now) => g = now, + } + } } } +/// A hook event and how many keys and clicks had reached the app when it happened, its own +/// key included when that went through ([`SwallowState::passed`]). +pub type Stamped = (u64, HookEvent); + /// Keeps the hook alive; dropping it unhooks and stops the thread. pub struct HookHandle { thread_id: u32, @@ -128,7 +164,7 @@ impl Drop for HookHandle { /// Install the hook on a new thread. Events arrive on `tx`. pub fn start( - tx: crossbeam_channel::Sender, + tx: crossbeam_channel::Sender, swallow: Arc, ) -> anyhow::Result { // Pin the shared clock origin before the first key can arrive. @@ -314,7 +350,7 @@ const OWN_EXTRA_INFO: usize = 0x4343_4F4D; const TOUNICODE_NO_STATE_CHANGE: u32 = 0x4; struct HookCtx { - tx: crossbeam_channel::Sender, + tx: crossbeam_channel::Sender, swallow: Arc, } @@ -325,7 +361,7 @@ thread_local! { } fn hook_thread( - tx: crossbeam_channel::Sender, + tx: crossbeam_channel::Sender, swallow: Arc, ready: mpsc::Sender>, ) { @@ -394,10 +430,8 @@ unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) CTX.with(|cell| { if let Ok(guard) = cell.try_borrow() { if let Some(ctx) = guard.as_ref() { - if ctx.swallow.capture.load(Ordering::SeqCst) { - ctx.swallow.passed.fetch_add(1, Ordering::SeqCst); - } - let _ = ctx.tx.try_send(HookEvent::Click); + let seq = ctx.swallow.pass(); + send(ctx, seq, HookEvent::Click); } } }); @@ -406,14 +440,16 @@ unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM) unsafe { CallNextHookEx(None, code, wparam, lparam) } } -/// Sends a typed character: as `Captured` while capturing, else as `Char`. -fn send_char(ctx: &HookCtx, c: char, capture: bool) { - if capture { - ctx.swallow.captured.fetch_add(1, Ordering::SeqCst); - let _ = ctx.tx.try_send(HookEvent::Captured(c)); - } else { - let _ = ctx.tx.try_send(HookEvent::Char(c)); - } +/// Drops the event when the channel is full: the hook must never block. The orchestrator +/// notices the gap in the numbers. +fn send(ctx: &HookCtx, seq: u64, ev: HookEvent) { + let _ = ctx.tx.try_send((seq, ev)); +} + +/// Reports a key that capture swallowed; '\u{8}' is Backspace, '\n' is Enter. +fn send_captured(ctx: &HookCtx, c: char) { + ctx.swallow.captured.fetch_add(1, Ordering::SeqCst); + send(ctx, ctx.swallow.passed(), HookEvent::Captured(c)); } fn is_modifier(vk: u32) -> bool { @@ -438,81 +474,99 @@ fn handle_key(msg: u32, kb: &KBDLLHOOKSTRUCT) -> bool { log::trace!("own vk={:#x} scan={:#x} flags={:#x}", kb.vkCode, kb.scanCode, kb.flags.0); return false; } - // Synthetic input from other processes is ignored unless the e2e flag is set - // (CC_ACCEPT_INJECTED=1 lets a test harness drive the app with SendKeys). - if kb.flags.0 & LLKHF_INJECTED.0 != 0 && !accept_injected() { - return false; - } CTX.with(|cell| { let Ok(guard) = cell.try_borrow() else { return false }; let Some(ctx) = guard.as_ref() else { return false }; - + let state = &ctx.swallow; let vk = kb.vkCode; + + // Synthetic input from other processes is not reported unless the e2e flag is set + // (CC_ACCEPT_INJECTED=1 lets a test harness drive the app with SendKeys). It still + // reaches the app, so it is counted. + if kb.flags.0 & LLKHF_INJECTED.0 != 0 && !accept_injected() { + if !is_modifier(vk) { + state.pass(); + } + return false; + } + let ctrl = key_down(vk::CONTROL); let alt = key_down(vk::MENU); let win = key_down(vk::LWIN) || key_down(vk::RWIN); - let ghost = ctx.swallow.ghost_visible.load(Ordering::SeqCst); - let in_revert = now_ms() < ctx.swallow.revert_until_ms.load(Ordering::SeqCst); + let ghost = state.ghost_visible.load(Ordering::SeqCst); + let in_revert = now_ms() < state.revert_until_ms.load(Ordering::SeqCst); let (ev, swallow) = decide(vk, ctrl, alt, ghost, in_revert); - let capture = ctx.swallow.capture.load(Ordering::SeqCst); - match ev { - Some(HookEvent::Backspace) if capture => { - send_char(ctx, '\u{8}', true); - return true; - } - Some(HookEvent::Enter) if capture => { - send_char(ctx, '\n', true); - return true; - } - _ => {} + if !is_modifier(vk) && ev != Some(HookEvent::RevertRequested) { + state.revert_until_ms.store(0, Ordering::SeqCst); } - let passes = |ctx: &HookCtx| { - if capture && !is_modifier(vk) { - ctx.swallow.passed.fetch_add(1, Ordering::SeqCst); + if is_modifier(vk) || swallow { + if let Some(e) = ev { + send(ctx, state.passed(), e); } - }; + return swallow; + } // Text keys (decide() says Other), plus Ctrl+Alt+Z, which on AltGr layouts can be a // real character (e.g. Polish AltGr+Z) and must not be eaten as a revert. let altgr_z = vk == vk::Z && ctrl && alt; let try_text = !win && ((matches!(ev, Some(HookEvent::Other(_))) && may_translate(ctrl, alt, win)) || altgr_z); - if try_text { + let text = if !try_text { + None + } else if vk == vk::PACKET { // KEYEVENTF_UNICODE arrives as VK_PACKET with the UTF-16 unit in scanCode. // Calling ToUnicodeEx on it consumes the pending character and corrupts what - // the target app receives, so read the unit directly instead. - if vk == vk::PACKET { - if let Some(c) = char::from_u32(kb.scanCode) { - send_char(ctx, c, capture); - } - return capture; - } + // the target app receives, so read the unit directly instead. Half a surrogate + // pair is not a char and goes through as an unknown key. + char::from_u32(kb.scanCode).map(CharBuf::one) + } else { match translate(vk, kb.scanCode) { - Translation::Text(buf) => { - for &c in &buf.chars[..buf.len] { - send_char(ctx, c, capture); - } - return capture; - } - // Dead key: let the app compose it; the orchestrator sees nothing. + Translation::Text(buf) => Some(buf), + // Dead key: the app composes it with the next key, which the shadow cannot + // follow, so the orchestrator is told an unknown key went through. Translation::Dead => { - passes(ctx); + let seq = state.pass(); + send(ctx, seq, HookEvent::Other(vk)); return false; } - Translation::NoText => {} + Translation::NoText => None, } + }; + if let Some(buf) = text { + let chars = &buf.chars[..buf.len]; + return match state.pass_unless_capturing() { + None => { + chars.iter().for_each(|&c| send_captured(ctx, c)); + true + } + Some(seq) => { + chars.iter().for_each(|&c| send(ctx, seq, HookEvent::Char(c))); + false + } + }; } + let captured = match ev { + Some(HookEvent::Backspace) => Some('\u{8}'), + Some(HookEvent::Enter) => Some('\n'), + _ => None, + }; + let seq = match captured { + Some(c) => match state.pass_unless_capturing() { + Some(seq) => seq, + None => { + send_captured(ctx, c); + return true; + } + }, + None => state.pass(), + }; if let Some(e) = ev { - // Drop on full: the hook must never block. - let _ = ctx.tx.try_send(e); + send(ctx, seq, e); } - if !swallow { - passes(ctx); - } - swallow + false }) } @@ -527,6 +581,12 @@ struct CharBuf { len: usize, } +impl CharBuf { + fn one(c: char) -> CharBuf { + CharBuf { chars: [c, '\0', '\0', '\0'], len: 1 } + } +} + enum Translation { Text(CharBuf), /// Dead key (accent waiting for a base letter). @@ -824,7 +884,7 @@ mod tests { // ---- hook_proc plumbing (called directly on the test thread; needs no real hook) ---- - fn install_ctx() -> (crossbeam_channel::Receiver, Arc) { + fn install_ctx() -> (crossbeam_channel::Receiver, Arc) { let (tx, rx) = crossbeam_channel::bounded(16); let swallow = Arc::new(SwallowState::default()); CTX.with(|c| *c.borrow_mut() = Some(HookCtx { tx, swallow: swallow.clone() })); @@ -852,17 +912,22 @@ mod tests { // These assume no Ctrl/Alt/Win key is physically held while the tests run. + /// A KEYEVENTF_UNICODE key carrying `c`, which the hook reads without the keyboard layout. + fn packet(c: char) -> KBDLLHOOKSTRUCT { + KBDLLHOOKSTRUCT { scanCode: c as u32, ..kb(vk::PACKET, 0, 0) } + } + #[test] fn proc_ghost_tab_swallowed_and_reported() { let (rx, swallow) = install_ctx(); let k = kb(vk::TAB, 0, 0); assert!(!feed(WM_KEYDOWN, &k)); - assert_eq!(rx.try_recv(), Ok(E::Tab)); + assert_eq!(rx.try_recv(), Ok((1, E::Tab)), "a Tab that reaches the app is counted"); swallow.set_ghost_visible(true); assert!(feed(WM_KEYDOWN, &k)); - assert_eq!(rx.try_recv(), Ok(E::Tab)); + assert_eq!(rx.try_recv(), Ok((1, E::Tab))); assert!(feed(WM_SYSKEYDOWN, &k)); - assert_eq!(rx.try_recv(), Ok(E::Tab)); + assert_eq!(rx.try_recv(), Ok((1, E::Tab))); } #[test] @@ -872,30 +937,65 @@ mod tests { let back = kb(vk::BACK, 0, 0); swallow.set_revert_until(now_ms() + 60_000); assert!(feed(WM_KEYDOWN, &left)); - assert_eq!(rx.try_recv(), Ok(E::RevertRequested)); + assert_eq!(rx.try_recv(), Ok((0, E::RevertRequested))); assert!(feed(WM_KEYDOWN, &back)); - assert_eq!(rx.try_recv(), Ok(E::RevertRequested)); + assert_eq!(rx.try_recv(), Ok((0, E::RevertRequested))); swallow.set_revert_until(0); assert!(!feed(WM_KEYDOWN, &left)); - assert_eq!(rx.try_recv(), Ok(E::Left)); + assert_eq!(rx.try_recv(), Ok((1, E::Left))); assert!(!feed(WM_KEYDOWN, &back)); - assert_eq!(rx.try_recv(), Ok(E::Backspace)); + assert_eq!(rx.try_recv(), Ok((2, E::Backspace))); + } + + #[test] + fn proc_any_key_after_a_fix_closes_the_revert_window() { + let (rx, swallow) = install_ctx(); + swallow.set_revert_until(now_ms() + 60_000); + assert!(!feed(WM_KEYDOWN, &kb(vk::SHIFT, 0, 0)), "a modifier keeps it open"); + assert!(!feed(WM_KEYDOWN, &packet('x'))); + assert!(!feed(WM_KEYDOWN, &kb(vk::BACK, 0, 0)), "Backspace after a typed key is a Backspace"); + let events: Vec<_> = rx.try_iter().map(|(_, e)| e).collect(); + assert_eq!(events, [E::Other(vk::SHIFT), E::Char('x'), E::Backspace]); } #[test] fn proc_counts_captured_keys_and_keys_let_through_while_capturing() { let (rx, swallow) = install_ctx(); - swallow.set_capture(true); + assert!(swallow.begin_capture(0)); assert!(feed(WM_KEYDOWN, &kb(vk::BACK, 0, 0))); - assert_eq!(rx.try_recv(), Ok(E::Captured('\u{8}'))); - assert_eq!(swallow.captured(), 1); + assert_eq!(rx.try_recv(), Ok((0, E::Captured('\u{8}')))); + assert!(feed(WM_KEYDOWN, &packet('a'))); + assert_eq!(rx.try_recv(), Ok((0, E::Captured('a')))); + assert_eq!(swallow.captured(), 2); assert!(!feed(WM_KEYDOWN, &kb(vk::DOWN, 0, 0))); - assert_eq!(rx.try_recv(), Ok(E::Nav)); + assert_eq!(rx.try_recv(), Ok((1, E::Nav))); assert!(!feed(WM_KEYDOWN, &kb(vk::SHIFT, 0, 0))); assert_eq!(swallow.passed(), 1, "a modifier alone changes nothing"); - swallow.set_capture(false); + swallow.end_capture(); assert!(!feed(WM_KEYDOWN, &kb(vk::DOWN, 0, 0))); - assert_eq!((swallow.captured(), swallow.passed()), (1, 1)); + assert_eq!((swallow.captured(), swallow.passed()), (2, 2)); + } + + #[test] + fn begin_capture_fails_once_something_got_through() { + let (_rx, swallow) = install_ctx(); + assert!(swallow.begin_capture(0)); + assert!(swallow.begin_capture(0), "keeping capture on"); + swallow.end_capture(); + assert!(!feed(WM_KEYDOWN, &packet('t'))); + assert!(!swallow.begin_capture(0), "the t reached the app after event 0"); + assert!(!swallow.capturing()); + assert!(swallow.begin_capture(1)); + assert!(feed(WM_KEYDOWN, &packet('o')), "captured once capture is on"); + } + + #[test] + fn proc_reports_half_a_surrogate_pair_as_an_unknown_key() { + let (rx, swallow) = install_ctx(); + assert!(swallow.begin_capture(0)); + let half = KBDLLHOOKSTRUCT { scanCode: 0xD83D, ..kb(vk::PACKET, 0, 0) }; + assert!(!feed(WM_KEYDOWN, &half)); + assert_eq!(rx.try_recv(), Ok((1, E::Other(vk::PACKET)))); } #[test] @@ -906,12 +1006,11 @@ mod tests { click(WM_MOUSEMOVE); assert!(rx.try_recv().is_err()); click(WM_LBUTTONDOWN); - assert_eq!(rx.try_recv(), Ok(E::Click)); - assert_eq!(swallow.passed(), 0); - swallow.set_capture(true); + assert_eq!(rx.try_recv(), Ok((1, E::Click))); + assert!(swallow.begin_capture(1)); click(WM_RBUTTONDOWN); - assert_eq!(rx.try_recv(), Ok(E::Click)); - assert_eq!(swallow.passed(), 1); + assert_eq!(rx.try_recv(), Ok((2, E::Click))); + assert_eq!(swallow.passed(), 2); } #[test] @@ -920,25 +1019,28 @@ mod tests { swallow.set_ghost_visible(true); let injected = kb(vk::TAB, LLKHF_INJECTED.0, 0); assert!(!feed(WM_KEYDOWN, &injected)); + assert_eq!(swallow.passed(), 1, "another program's key still reaches the app"); let tagged = kb(vk::TAB, 0, OWN_EXTRA_INFO); assert!(!feed(WM_KEYDOWN, &tagged)); let plain = kb(vk::TAB, 0, 0); const WM_KEYUP: u32 = 0x0101; assert!(!feed(WM_KEYUP, &plain)); assert!(rx.try_recv().is_err(), "no events expected"); + assert_eq!(swallow.passed(), 1); } #[test] fn proc_full_channel_drops_instead_of_blocking() { let (tx, rx) = crossbeam_channel::bounded(1); let swallow = Arc::new(SwallowState::default()); - CTX.with(|c| *c.borrow_mut() = Some(HookCtx { tx, swallow })); + CTX.with(|c| *c.borrow_mut() = Some(HookCtx { tx, swallow: swallow.clone() })); let k = kb(vk::RETURN, 0, 0); for _ in 0..5 { assert!(!feed(WM_KEYDOWN, &k)); } - assert_eq!(rx.try_recv(), Ok(E::Enter)); + assert_eq!(rx.try_recv(), Ok((1, E::Enter))); assert!(rx.try_recv().is_err()); + assert_eq!(swallow.passed(), 5, "dropped events still count, so the gap shows"); } #[test] From b593fc134e074b680c503ded0818303de71f6c1d Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 20 Oct 2026 17:20:17 -0400 Subject: [PATCH 2/9] Read the field before and after typing a fix, and stop where fixes miss A fix is typed only after reading that the text before the caret ends with the word at a word break, so a shadow that counted a key twice or a field the app changed can no longer make it delete a space. The check after typing is read again twice, 200 ms apart, before it counts as a miss; a miss logs a masked shape of both endings and re-reads the field instead of trusting it. Misses are stored per app: over one in fifty of its last 20+ fixes turns auto-fix off there for a week, and the dashboard says so. Verified with cargo test. Claude-Session: https://claude.ai/code/session_01DiRshLaPboWShtRAdGazjy --- crates/cc-app/src/fixgate.rs | 55 +++++++++++++++ crates/cc-app/src/main.rs | 129 +++++++++++++++++++++++++++++------ crates/cc-store/src/lib.rs | 97 ++++++++++++++++++++++++++ crates/cc-ui/assets/app.js | 4 +- crates/cc-ui/src/api.rs | 33 +++++---- crates/cc-ui/src/tests.rs | 9 +++ 6 files changed, 291 insertions(+), 36 deletions(-) diff --git a/crates/cc-app/src/fixgate.rs b/crates/cc-app/src/fixgate.rs index 404e7eb..0c84a5f 100644 --- a/crates/cc-app/src/fixgate.rs +++ b/crates/cc-app/src/fixgate.rs @@ -99,6 +99,42 @@ pub fn field_text(raw: &str) -> String { raw.chars().map(|c| if matches!(c, '\u{FFFC}' | '\u{A0}' | '\u{202F}') { ' ' } else { c }).collect() } +/// Whether `field`, the text before the caret read just before a fix is typed, ends with +/// `delete`, the text the fix will remove, and has a word break before it. The last +/// character of `delete` is the key that triggered the fix; it may not have reached the +/// field yet, and will before the fix does, since input arrives in order. +pub fn ready_for(field: &str, delete: &str) -> bool { + let fold = |c: char| match c { + '\u{2018}' | '\u{2019}' => '\'', + '\u{201C}' | '\u{201D}' => '"', + c if c.is_whitespace() => ' ', + c => c.to_lowercase().next().unwrap_or(c), + }; + let field: Vec = field_text(field).chars().map(fold).collect(); + let delete: Vec = delete.chars().map(fold).collect(); + let fits = |n: usize| { + n <= field.len() + && field[field.len() - n..] == delete[..n] + && field[..field.len() - n].last().is_none_or(|c| !c.is_alphanumeric()) + }; + !delete.is_empty() && (fits(delete.len()) || (delete.len() > 1 && fits(delete.len() - 1))) +} + +/// The end of `s` with letters written as `a` or `A` and digits as `0`, for the log: it +/// shows a joined or doubled word without showing what was typed. +pub fn shape(s: &str) -> String { + let chars: Vec = s.chars().collect(); + chars[chars.len().saturating_sub(TAIL)..] + .iter() + .map(|&c| match c { + c if c.is_uppercase() => 'A', + c if c.is_alphabetic() => 'a', + c if c.is_numeric() => '0', + c => c, + }) + .collect() +} + /// How the end of the field's text relates to the end of the shadow buffer. #[derive(Debug, PartialEq, Eq)] pub enum Tail { @@ -349,6 +385,25 @@ mod tests { assert_eq!(compare("ab", "abc"), Tail::Different, "too short to call it lag"); } + #[test] + fn ready_for_needs_the_whole_word_before_the_caret() { + assert!(ready_for("I want teh ", "teh ")); + assert!(ready_for("I want teh", "teh "), "the space may still be on its way"); + assert!(ready_for("teh ", "teh "), "at the start of the field"); + assert!(ready_for("(Teh\u{a0}", "teh ")); + assert!(!ready_for("I want teh ", "tehh "), "the shadow counted a letter twice"); + assert!(!ready_for("I want xteh ", "teh "), "part of a longer word"); + assert!(!ready_for("I want teh t", "teh "), "a key got typed after the space"); + assert!(!ready_for("I want te", "teh ")); + assert!(!ready_for("anything", "")); + } + + #[test] + fn shape_hides_letters_and_digits_only() { + assert_eq!(shape("Previous the 2nd, ok."), "Aaaaaaaa aaa 0aa, aa."); + assert_eq!(shape(&"x".repeat(40)).len(), TAIL); + } + #[test] fn breaker_trips_on_two_quick_undos_in_one_window() { let mut b = Breaker::default(); diff --git a/crates/cc-app/src/main.rs b/crates/cc-app/src/main.rs index bf6e6e5..c730446 100644 --- a/crates/cc-app/src/main.rs +++ b/crates/cc-app/src/main.rs @@ -25,7 +25,7 @@ use cc_correct::{AutocorrectList, Checker, PersonalDictionary, Severity}; use cc_engine::{Completion, CompletionClient, Sidecar}; use cc_hook::{HookEvent, ShadowBuffer, SwallowState}; use cc_overlay::{GhostStyle, Overlay}; -use cc_store::{Action, NewEvent, Outcome, Source, Store}; +use cc_store::{Action, Landing, NewEvent, Outcome, Source, Store}; use cc_tray::{Tray, TrayEvent, TrayState}; use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender}; use tokio_util::sync::CancellationToken; @@ -44,6 +44,10 @@ const TUNE_EVERY: Duration = Duration::from_secs(6 * 3600); const STATUS_EVERY: Duration = Duration::from_secs(60); const STYLE_EVERY: Duration = Duration::from_secs(300); const UNDERLINE_FADE_MS: u32 = 1500; +/// A fix that reads back wrong is read again this many times before it counts as failed: +/// Electron apps can take a while to update what UIA sees. +const FIX_CHECK_RETRIES: u8 = 2; +const FIX_RECHECK_AFTER: Duration = Duration::from_millis(200); const DEFAULT_MODEL: &str = "gemma-3-1b-pt"; /// Terminals: UIA reports the whole screen buffer, not an edit field, so the shadow buffer /// is the only context and editability is assumed. @@ -105,8 +109,11 @@ struct Fix { #[derive(Clone, Copy)] struct FixCheck { hwnd: isize, + event_id: i64, due: Instant, give_up: Instant, + /// Reads so far that disagreed with the shadow. + tries: u8, } #[derive(serde::Serialize, serde::Deserialize)] @@ -237,6 +244,11 @@ struct App { last_hwnd: isize, /// `FocusInfo::element` of the last field seen, 0 when unknown. last_element: u64, + /// The window whose field last gave its text through UIA. Once a field has, a fix there + /// is typed only after reading that the field ends with the word. + text_hwnd: isize, + /// How fixes landed per app (`Store::landing`), cached; refreshed with each check. + landing: HashMap, in_terminal: bool, } @@ -420,6 +432,7 @@ impl App { return; } if let Some(t) = cc_context::text_before_caret(CONTEXT_CHARS) { + self.text_hwnd = self.last_hwnd; if self.keys_in_flight() { self.resync_at = Some(Instant::now() + Duration::from_millis(50)); } else { @@ -447,6 +460,7 @@ impl App { /// behind the shadow's back, so it is read again once things settle. fn adopt_field_text(&mut self) { let Some(field) = cc_context::text_before_caret(CONTEXT_CHARS) else { return }; + self.text_hwnd = self.last_hwnd; if self.keys_in_flight() { return; } @@ -631,7 +645,7 @@ impl App { /// has already been sent. fn on_word_boundary(&mut self, boundary: char) { let Some(level) = self.auto_level.clone() else { return }; - if self.breaker.paused(self.last_hwnd, Instant::now()) { + if self.breaker.paused(self.last_hwnd, Instant::now()) || self.landing_too_low() { return; } let text = self.shadow.text(); @@ -702,16 +716,12 @@ impl App { let caret = cc_context::caret_rect(); let mut typed = c.replacement.clone(); typed.push(boundary); - let del = c.original.chars().count() + 1; + let mut delete = c.original.clone(); + delete.push(boundary); let captured_before = self.swallow.captured(); - if !self.replace_tail(del, &typed) { + if !self.replace_tail(&delete, &typed) { return; } - if !self.in_terminal { - let now = Instant::now(); - let due = now + inject_settle(del + typed.chars().count()); - self.fix_check = Some(FixCheck { hwnd: self.last_hwnd, due, give_up: now + Duration::from_secs(3) }); - } let event_id = self .store .log(&NewEvent { @@ -733,6 +743,12 @@ impl App { log::warn!("store log failed: {e:#}"); 0 }); + if !self.in_terminal { + let now = Instant::now(); + let due = now + inject_settle(delete.chars().count() + typed.chars().count()); + let give_up = now + Duration::from_secs(3); + self.fix_check = Some(FixCheck { hwnd: self.last_hwnd, event_id, due, give_up, tries: 0 }); + } if let Some(r) = caret { let w = (typed.chars().count() as f32 * self.style.font_px * 0.55) as i32; self.overlay.show_underline(cc_overlay::Rect { x: r.x - w, y: r.y, w, h: r.h }, UNDERLINE_FADE_MS); @@ -774,8 +790,9 @@ impl App { log::info!("learned {:?} -> {:?}", prev, word); } - /// Deletes `del` chars before the caret and types `typed`, keeping the shadow in step. - fn replace_tail(&mut self, del: usize, typed: &str) -> bool { + /// Deletes `delete` from before the caret and types `typed`, keeping the shadow in step. + fn replace_tail(&mut self, delete: &str, typed: &str) -> bool { + let del = delete.chars().count(); let gap = Duration::from_millis(fix_key_gap_ms()); // Capture the user's keys for the duration. A key or click that already reached the // app after the one being handled means the field no longer ends where the shadow does. @@ -799,6 +816,11 @@ impl App { self.end_capture(); return false; } + if !self.in_terminal && !self.field_ends_with(delete) { + log::info!("fix not applied: the field does not end with the word"); + self.end_capture(); + return false; + } let result = match fix_inject_mode().as_str() { "fast" => cc_inject::backspace(del).and_then(|_| cc_inject::type_text(typed)), "paste" => cc_inject::backspace_slow(del, gap).and_then(|_| cc_inject::paste_text(typed)), @@ -826,6 +848,18 @@ impl App { true } + /// Whether the text before the caret ends with `delete` (`fixgate::ready_for`). A field + /// that has never given its text through UIA has only the shadow to go by. + fn field_ends_with(&mut self, delete: &str) -> bool { + match cc_context::text_before_caret(CONTEXT_CHARS) { + Some(field) => { + self.text_hwnd = self.last_hwnd; + fixgate::ready_for(&field, delete) + } + None => self.text_hwnd != self.last_hwnd, + } + } + /// Captured keys are replayed on a later tick, once the hook thread has delivered them. /// Capture stays on until then, so a key typed meanwhile cannot overtake them. fn end_capture(&mut self) { @@ -864,22 +898,71 @@ impl App { } } - /// The field must end with what the shadow buffer says was typed. If it does not, a fix - /// went somewhere else: auto-fix pauses in this window and the field's text is taken as - /// the truth. - fn verify_field(&mut self, hwnd: isize) { + /// The field must end with what the shadow buffer says was typed. A read that disagrees + /// is repeated, since UIA can lag. If it still disagrees, the fix went somewhere else: + /// auto-fix pauses in this window, the field is read again once quiet, and the miss + /// counts against the app. + fn verify_field(&mut self, check: FixCheck) { let Some(field) = cc_context::text_before_caret(CONTEXT_CHARS) else { return }; + if self.keys_in_flight() { + self.fix_check = Some(check); + return; + } + self.text_hwnd = self.last_hwnd; + let field = fixgate::field_text(&field); if fixgate::same_tail(&field, self.shadow.text()) { + self.record_fix_check(check.event_id, true); + return; + } + if check.tries < FIX_CHECK_RETRIES { + let due = Instant::now() + FIX_RECHECK_AFTER; + self.fix_check = Some(FixCheck { due, tries: check.tries + 1, ..check }); return; } log::warn!( - "fix check failed in {}: the field does not end with the typed text; auto-fix paused for {}s", + "fix check failed in {}: the field ends {:?} where {:?} was typed; auto-fix paused for {}s", self.last_exe, + fixgate::shape(&field), + fixgate::shape(self.shadow.text()), fixgate::PAUSE.as_secs() ); - self.breaker.trip(hwnd, Instant::now()); + self.record_fix_check(check.event_id, false); + self.breaker.trip(check.hwnd, Instant::now()); self.tray.set_status("Auto-fix paused here for a minute: a fix did not land as expected"); - self.shadow.reset(&field); + self.resync_soon(); + } + + /// Stores whether a fix read back as typed, and reports when that turns auto-fix off in + /// the app (`Landing::too_low`). + fn record_fix_check(&mut self, event_id: i64, landed: bool) { + if let Err(e) = self.store.log_fix_check(event_id, &self.last_exe, landed) { + log::warn!("store fix check failed: {e:#}"); + return; + } + let was_low = self.landing.get(&self.last_exe).is_some_and(Landing::too_low); + match self.store.landing(&self.last_exe) { + Ok(l) => { + if l.too_low() && !was_low { + let line = format!( + "Auto-fix is off in {}: {} of its last {} fixes did not land as typed", + self.last_exe, l.failed, l.checked + ); + log::warn!("{line}"); + self.tray.set_status(&line); + } + self.landing.insert(self.last_exe.clone(), l); + } + Err(e) => log::warn!("landing failed: {e:#}"), + } + } + + /// Auto-fix is off in the focused app because too many of its fixes landed wrong. + fn landing_too_low(&mut self) -> bool { + if !self.landing.contains_key(&self.last_exe) { + let l = self.store.landing(&self.last_exe).unwrap_or_default(); + self.landing.insert(self.last_exe.clone(), l); + } + self.landing.get(&self.last_exe).is_some_and(Landing::too_low) } fn flush_replay(&mut self) { @@ -913,7 +996,9 @@ impl App { self.cancel_ghost(); let mut typed = fix.original.clone(); typed.push(fix.boundary); - if !self.replace_tail(fix.replacement.chars().count() + 1, &typed) { + let mut delete = fix.replacement.clone(); + delete.push(fix.boundary); + if !self.replace_tail(&delete, &typed) { return; } let n = self.reverts.entry(fix.original.clone()).or_insert(0); @@ -1286,6 +1371,8 @@ impl App { fn run_tuning(&mut self) { self.last_tune = Instant::now(); + // Week-old fix checks age out, which can give an app its auto-fix back. + self.landing.clear(); match self.store.expire_pending(24 * 3600 * 1000) { Ok(n) if n > 0 => log::info!("expired {n} pending events"), Err(e) => log::warn!("expire failed: {e:#}"), @@ -1360,7 +1447,7 @@ impl App { && !self.keys_in_flight() { self.fix_check = None; - self.verify_field(check.hwnd); + self.verify_field(check); } } if self.resync_at.is_some_and(|t| Instant::now() >= t) { @@ -1600,6 +1687,8 @@ fn start() -> anyhow::Result<()> { last_exe: String::new(), last_hwnd: 0, last_element: 0, + text_hwnd: 0, + landing: HashMap::new(), in_terminal: false, }; if let Err(e) = app.start_engine(&model_id) { diff --git a/crates/cc-store/src/lib.rs b/crates/cc-store/src/lib.rs index af52ae7..6493c7f 100644 --- a/crates/cc-store/src/lib.rs +++ b/crates/cc-store/src/lib.rs @@ -33,6 +33,24 @@ const MAX_CONTEXT_CHARS: usize = 200; const DEMOTE_MIN_AUTO: u64 = 20; /// Minimum suggestions before a promotion can be offered. const PROMOTE_MIN_SUGGESTED: u64 = 50; +/// How many of an app's latest fix checks [`Store::landing`] counts. +const LANDING_WINDOW: u32 = 50; +/// Checks an app needs before [`Landing::too_low`] can say it does not take fixes cleanly. +const LANDING_MIN_CHECKS: u64 = 20; +/// Checks older than this are forgotten, so an app gets auto-fix back after a quiet week. +const LANDING_MAX_AGE_MS: i64 = 7 * 86_400_000; + +/// Tables added after version 1. Each is created if missing on every open instead of +/// bumping `SCHEMA_VERSION`, so an older build can still open the file and ignores them. +const SCHEMA_ADDED: &str = " +CREATE TABLE IF NOT EXISTS fix_checks( + event_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + app_exe TEXT NOT NULL, + landed INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_fix_checks_app ON fix_checks(app_exe COLLATE NOCASE, ts); +"; const SCHEMA_V1: &str = " CREATE TABLE IF NOT EXISTS events( @@ -234,6 +252,21 @@ pub struct AppStat { pub accepted: u64, } +/// How an app's latest auto-fixes landed when read back from the field ([`Store::landing`]). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Landing { + pub checked: u64, + /// Read back different from what was typed. + pub failed: u64, +} + +impl Landing { + /// More than one in fifty fixes went wrong, over enough fixes to tell. + pub fn too_low(&self) -> bool { + self.checked >= LANDING_MIN_CHECKS && self.failed * 50 > self.checked + } +} + /// One confidence decile of [`Store::by_confidence`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfidenceBucket { @@ -730,6 +763,26 @@ impl Store { Ok(rows.collect::>>()?) } + /// Records whether the auto-fix `event_id` read back from the field as typed. + pub fn log_fix_check(&self, event_id: i64, app_exe: &str, landed: bool) -> Result<()> { + self.conn + .prepare_cached("INSERT INTO fix_checks(event_id, ts, app_exe, landed) VALUES (?1, ?2, ?3, ?4)")? + .execute(params![event_id, now_ms(), app_exe, landed])?; + Ok(()) + } + + /// The newest [`LANDING_WINDOW`] fix checks in `app_exe` from the last week. + pub fn landing(&self, app_exe: &str) -> Result { + let since = now_ms() - LANDING_MAX_AGE_MS; + let (checked, failed) = self.conn.prepare_cached( + "SELECT COUNT(*), COUNT(CASE WHEN landed = 0 THEN 1 END) FROM ( \ + SELECT landed FROM fix_checks WHERE app_exe = ?1 COLLATE NOCASE AND ts >= ?2 \ + ORDER BY ts DESC LIMIT ?3)", + )? + .query_row(params![app_exe, since, LANDING_WINDOW], |r| Ok((cnt(r.get(0)?), cnt(r.get(1)?))))?; + Ok(Landing { checked, failed }) + } + /// Rules currently capped at "suggest" (see [`Store::tune`]), sorted by name. pub fn demoted_rules(&self) -> Result> { let mut stmt = self.conn.prepare_cached( @@ -757,6 +810,7 @@ fn migrate(conn: &mut Connection) -> Result<()> { if current < 1 { tx.execute_batch(SCHEMA_V1)?; } + tx.execute_batch(SCHEMA_ADDED)?; if current < SCHEMA_VERSION { tx.execute("DELETE FROM schema_version", [])?; tx.execute("INSERT INTO schema_version(version) VALUES (?1)", [SCHEMA_VERSION])?; @@ -971,6 +1025,49 @@ mod tests { assert_eq!(outcome_of(&s, id).0.as_deref(), Some("kept")); } + #[test] + fn landing_counts_the_latest_week_of_checks_per_app() { + let dir = TempDir::new(); + let s = Store::open(&dir.db()).unwrap(); + for i in 0..25 { + s.log_fix_check(i, "Claude.exe", i % 10 != 0).unwrap(); + } + s.log_fix_check(99, "notepad.exe", true).unwrap(); + let l = s.landing("claude.exe").unwrap(); + assert_eq!((l.checked, l.failed), (25, 3)); + assert!(l.too_low(), "3 of 25 is more than one in fifty"); + assert_eq!(s.landing("notepad.exe").unwrap(), Landing { checked: 1, failed: 0 }); + assert!(!Landing { checked: 19, failed: 19 }.too_low(), "too few to tell"); + assert!(!Landing { checked: 50, failed: 1 }.too_low()); + s.conn.execute("UPDATE fix_checks SET ts = ts - 8 * 86400000 WHERE app_exe = 'Claude.exe'", []).unwrap(); + assert_eq!(s.landing("claude.exe").unwrap().checked, 0, "a week later the app starts over"); + } + + #[test] + fn landing_keeps_only_the_newest_checks() { + let dir = TempDir::new(); + let s = Store::open(&dir.db()).unwrap(); + for i in 0..10 { + s.log_fix_check(i, "a.exe", false).unwrap(); + } + s.conn.execute("UPDATE fix_checks SET ts = ts - 1000", []).unwrap(); + for i in 10..60 { + s.log_fix_check(i, "a.exe", true).unwrap(); + } + assert_eq!(s.landing("a.exe").unwrap(), Landing { checked: 50, failed: 0 }); + } + + #[test] + fn an_older_build_can_open_the_file_with_the_added_tables() { + let dir = TempDir::new(); + let path = dir.db(); + Store::open(&path).unwrap().log_fix_check(1, "a.exe", true).unwrap(); + let s = Store::open(&path).unwrap(); + let v: i64 = s.conn.query_row("SELECT version FROM schema_version", [], |r| r.get(0)).unwrap(); + assert_eq!(v, 1); + assert_eq!(s.landing("a.exe").unwrap().checked, 1); + } + #[test] fn rejects_newer_schema() { let dir = TempDir::new(); diff --git a/crates/cc-ui/assets/app.js b/crates/cc-ui/assets/app.js index 2ca07f9..fb86097 100644 --- a/crates/cc-ui/assets/app.js +++ b/crates/cc-ui/assets/app.js @@ -271,7 +271,9 @@ ${fmt(val)}`; }).join(''); - return `
${cells}
${anyWarn ? 'Orange: an app where fixes get undone more than 25% of the time.' : ''}`; + const off = (apps || []).filter((a) => a.autofix_off).map((a) => + `Auto-fix is off in ${esc(appName(a.app_exe))}: ${fmt(a.fixes_misplaced)} of its last ${fmt(a.fixes_checked)} fixes did not land as typed. It comes back a week after those misses.`).join(''); + return `
${cells}
${anyWarn ? 'Orange: an app where fixes get undone more than 25% of the time.' : ''}${off}`; } function confidenceChart(rows, floor) { diff --git a/crates/cc-ui/src/api.rs b/crates/cc-ui/src/api.rs index 73d319c..42f0eb5 100644 --- a/crates/cc-ui/src/api.rs +++ b/crates/cc-ui/src/api.rs @@ -252,21 +252,24 @@ fn latency(ctx: &Ctx, req: &Req) -> Res { fn apps(ctx: &Ctx, req: &Req) -> Res { let days = days_param(req, 30)?; - let rows = open(ctx)?.by_app(Some(since_days(days)))?; - Ok(Value::Array( - rows.into_iter() - .take(MAX_APPS) - .map(|a| { - json!({ - "app_exe": a.app_exe, - "fixes_kept": a.fixes_kept, - "fixes_reverted": a.fixes_reverted, - "shown": a.shown, - "accepted": a.accepted, - }) - }) - .collect(), - )) + let store = open(ctx)?; + let rows = store.by_app(Some(since_days(days)))?; + let mut out = Vec::new(); + for a in rows.into_iter().take(MAX_APPS) { + // How the app's latest fixes read back; independent of the period shown. + let landing = store.landing(&a.app_exe)?; + out.push(json!({ + "app_exe": a.app_exe, + "fixes_kept": a.fixes_kept, + "fixes_reverted": a.fixes_reverted, + "shown": a.shown, + "accepted": a.accepted, + "fixes_checked": landing.checked, + "fixes_misplaced": landing.failed, + "autofix_off": landing.too_low(), + })); + } + Ok(Value::Array(out)) } fn confidence(ctx: &Ctx, req: &Req) -> Res { diff --git a/crates/cc-ui/src/tests.rs b/crates/cc-ui/src/tests.rs index 5e30401..56dfd03 100644 --- a/crates/cc-ui/src/tests.rs +++ b/crates/cc-ui/src/tests.rs @@ -236,6 +236,15 @@ fn read_endpoints_summarise_the_event_log() { assert_eq!(apps[0]["app_exe"], "notepad.exe"); assert_eq!((apps[0]["fixes_kept"].as_u64(), apps[0]["fixes_reverted"].as_u64()), (Some(1), Some(1))); assert_eq!((apps[1]["shown"].as_u64(), apps[1]["accepted"].as_u64()), (Some(2), Some(1))); + assert_eq!((apps[0]["fixes_checked"].as_u64(), apps[0]["autofix_off"].as_bool()), (Some(0), Some(false))); + for i in 0..20 { + e.store.log_fix_check(i, "notepad.exe", i >= 2).unwrap(); + } + let apps = e.get("/api/apps?days=7"); + assert_eq!( + (apps[0]["fixes_checked"].as_u64(), apps[0]["fixes_misplaced"].as_u64(), apps[0]["autofix_off"].as_bool()), + (Some(20), Some(2), Some(true)) + ); let conf = e.get("/api/confidence?days=7"); let conf = conf.as_array().unwrap(); From 889ca713715bdb4cce4315c348af4507b9a4ee9b Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 20 Oct 2026 17:30:16 -0400 Subject: [PATCH 3/9] Leave abbreviations and coined words to the user The typing model fixed `htmlr` to `humor`, `htmml` to `hmm`, `xlsx` to `ls` and cut `healthyep` down to `healthy`. A word without a vowel, or a dictionary word with two or more letters added at the end, is now at most a suggestion. A doubled last letter (`thatt`) is still fixed. Verified with cargo test -p cc-correct. Claude-Session: https://claude.ai/code/session_01DiRshLaPboWShtRAdGazjy --- crates/cc-correct/src/lib.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/cc-correct/src/lib.rs b/crates/cc-correct/src/lib.rs index 8415fb6..ffa1ede 100644 --- a/crates/cc-correct/src/lib.rs +++ b/crates/cc-correct/src/lib.rs @@ -483,6 +483,7 @@ impl Checker { && common_words().contains(best.text.as_str())); if !dialect_variant && !name_risk + && !looks_coined(&lower, &best.text) && long_enough && margin >= S0_MIN_MARGIN && confidence >= S0_MIN_CONFIDENCE @@ -733,6 +734,18 @@ fn common_word_typo(word: &str) -> Option<(String, f32)> { (margin >= S0_MIN_MARGIN && confidence >= S0_MIN_CONFIDENCE).then(|| (best.1.to_string(), confidence)) } +/// `typed` (lowercase) is more likely an abbreviation, code or a coined word than a typo of +/// `fix`: it has no vowel at all (`htmlr`, `xlsx`), or it is `fix` with two or more letters +/// added (`healthyep` for `healthy`), which a fix would cut off. +fn looks_coined(typed: &[char], fix: &str) -> bool { + let no_vowel = !typed.iter().any(|&c| is_vowel(c) || c == 'y'); + let fix: Vec = fix.chars().map(lc).collect(); + let cut = typed.len() >= fix.len() + 2 && typed.starts_with(&fix); + let typed: String = typed.iter().collect(); + let fix: String = fix.iter().collect(); + no_vowel || (cut && !is_doubled_letter_typo(&typed, &fix)) +} + fn lowercase_first(s: &str) -> String { let mut chars = s.chars(); match chars.next() { @@ -1195,6 +1208,24 @@ mod tests { } } + #[test] + fn abbreviations_and_coined_words_are_not_auto_fixed() { + let dict = PersonalDictionary::default(); + for (text, typo) in [ + ("I wrote some htmlr", "htmlr"), + ("Open the xlsx file", "xlsx"), + ("Run the healthyep check", "healthyep"), + ] { + let cands = checker().check(text, &dict); + assert!( + cands.iter().filter(|c| c.original == typo).all(|c| c.severity >= Severity::S2), + "{text}: {cands:?}" + ); + } + let c = checker().check("I want thatt", &dict).into_iter().find(|c| c.original == "thatt").unwrap(); + assert_eq!((c.replacement.as_str(), &c.severity), ("that", &Severity::S0), "a doubled last letter is a typo"); + } + #[test] fn is_code_like_cases() { for w in ["camelCase", "snake_case", "C:\\x\\y", "https://a.b", "a@b.c", "v2", "HTTP"] { From d0ac7f4801feb1cb5291778e323bfa6650caef6b Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 20 Oct 2026 17:30:26 -0400 Subject: [PATCH 4/9] Ask the language model before typing a guessed fix The typing model picks by key distance alone: `morth` became `north` where the sentence wanted `month`, `calory` became `color`. Before an S0 fix that is not a doubled letter goes in, the loaded model is asked for its likeliest next words after the text before the typo, with keys captured meanwhile. A dictionary word close to the typo that it rates four times higher stops the fix. No engine, or no answer within 150 ms, leaves the fix as it was. Verified with cargo test -p cc-app -p cc-engine. Claude-Session: https://claude.ai/code/session_01DiRshLaPboWShtRAdGazjy --- crates/cc-app/src/fixgate.rs | 51 ++++++++++++++++++++++ crates/cc-app/src/main.rs | 69 +++++++++++++++++++++++++++++- crates/cc-engine/src/completion.rs | 65 ++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) diff --git a/crates/cc-app/src/fixgate.rs b/crates/cc-app/src/fixgate.rs index 0c84a5f..315e418 100644 --- a/crates/cc-app/src/fixgate.rs +++ b/crates/cc-app/src/fixgate.rs @@ -120,6 +120,35 @@ pub fn ready_for(field: &str, delete: &str) -> bool { !delete.is_empty() && (fits(delete.len()) || (delete.len() > 1 && fits(delete.len() - 1))) } +/// The dictionary word close to `typo` that the language model clearly prefers over `fix`, +/// given the model's likeliest next tokens (`top`) after the text before the typo: its +/// token is at least four times as likely as any token `fix` starts with. `this morth` +/// gives `month`, where the typing model alone picks `north`. +pub fn model_prefers(typo: &str, fix: &str, top: &[(String, f32)], is_word: impl Fn(&str) -> bool) -> Option { + let typo = typo.to_lowercase(); + let fix = fix.to_lowercase(); + let reach = (typo.chars().count() / 3).max(2); + let mut fix_lp = f32::NEG_INFINITY; + let mut other: Option<(String, f32)> = None; + for (token, lp) in top { + let word = token.trim().to_lowercase(); + if word.is_empty() || !word.chars().all(char::is_alphabetic) { + continue; + } + if fix.starts_with(&word) && (word.chars().count() >= 2 || word == fix) { + fix_lp = fix_lp.max(*lp); + } else if word.chars().count() >= 2 + && cc_correct::edit_distance(&typo, &word) <= reach + && is_word(&word) + && other.as_ref().is_none_or(|(_, best)| lp > best) + { + other = Some((word, *lp)); + } + } + let (word, lp) = other?; + (lp - fix_lp >= 4f32.ln()).then_some(word) +} + /// The end of `s` with letters written as `a` or `A` and digits as `0`, for the log: it /// shows a joined or doubled word without showing what was typed. pub fn shape(s: &str) -> String { @@ -398,6 +427,28 @@ mod tests { assert!(!ready_for("anything", "")); } + #[test] + fn model_prefers_a_close_word_it_rates_far_higher() { + let top = |pairs: &[(&str, f32)]| pairs.iter().map(|(t, lp)| (t.to_string(), *lp)).collect::>(); + let words = |w: &str| ["month", "north", "week", "the", "ten", "calorie", "cal"].contains(&w); + assert_eq!( + model_prefers("morth", "north", &top(&[(" month", -0.7), (" week", -1.9)]), words).as_deref(), + Some("month") + ); + assert_eq!(model_prefers("teh", "the", &top(&[(" the", -0.2), (" ten", -3.0)]), words), None); + assert_eq!( + model_prefers("calory", "calorie", &top(&[(" cal", -0.5), (" month", -0.4)]), words), + None, + "a token the fix starts with counts for the fix, and month is too far from the typo" + ); + assert_eq!(model_prefers("morth", "north", &top(&[(" mxrth", -0.1)]), words), None, "not a word"); + assert_eq!( + model_prefers("morth", "north", &top(&[(" north", -1.0), (" month", -0.5)]), words), + None, + "not four times as likely" + ); + } + #[test] fn shape_hides_letters_and_digits_only() { assert_eq!(shape("Previous the 2nd, ok."), "Aaaaaaaa aaa 0aa, aa."); diff --git a/crates/cc-app/src/main.rs b/crates/cc-app/src/main.rs index c730446..69428f2 100644 --- a/crates/cc-app/src/main.rs +++ b/crates/cc-app/src/main.rs @@ -48,6 +48,11 @@ const UNDERLINE_FADE_MS: u32 = 1500; /// Electron apps can take a while to update what UIA sees. const FIX_CHECK_RETRIES: u8 = 2; const FIX_RECHECK_AFTER: Duration = Duration::from_millis(200); +/// How long a fix waits for the language model's view of the word; past it, the fix goes +/// in on the typing model alone. +const MODEL_CHECK_TIMEOUT: Duration = Duration::from_millis(150); +/// Next-token candidates asked for in that check. +const MODEL_CHECK_TOKENS: u32 = 40; const DEFAULT_MODEL: &str = "gemma-3-1b-pt"; /// Terminals: UIA reports the whole screen buffer, not an edit field, so the shadow buffer /// is the only context and editability is assumed. @@ -190,6 +195,8 @@ struct App { paused_until: Option, /// Rules the nightly tuning demoted to suggest-only. demoted: HashSet, + /// For the language model's second opinion on a fix; completions go through `engine`. + client: CompletionClient, /// (event id, applied at, shadow length right after) of the last auto fix awaiting an outcome. pending_fix_event: Option<(i64, Instant, usize)>, pending_completion_event: Option, @@ -555,13 +562,52 @@ impl App { self.logprob_sum = 0.0; // Recently accepted sentences in this app give the base model your voice. self.last_prompt_tail = ctx.chars().rev().take(200).collect::>().into_iter().rev().collect(); - let ctx = if self.style_lines.is_empty() { ctx } else { format!("{}\n\n{}", self.style_lines.join("\n"), ctx) }; + let ctx = self.prompt_for(ctx); self.gen += 1; self.req_started = Some(Instant::now()); self.first_token_logged = false; let _ = self.engine.send(Req { gen: self.gen, prompt: ctx, cancel: self.cancel.clone() }); } + /// The model prompt for `ctx`: recently accepted sentences in this app give the base + /// model your voice. The model's second opinion on a fix uses the same prompt, so it + /// mostly reuses the completion's cached prefix. + fn prompt_for(&self, ctx: String) -> String { + if self.style_lines.is_empty() { + ctx + } else { + format!("{}\n\n{}", self.style_lines.join("\n"), ctx) + } + } + + /// A dictionary word close to `typo` that the model clearly expects after `before` (the + /// text up to the typo) instead of `fix` (`fixgate::model_prefers`). None when the + /// engine is not running or does not answer in time. + fn model_prefers_other(&self, before: &str, typo: &str, fix: &str) -> Option { + self.sidecar.as_ref()?; + let ctx = before.trim_end_matches(' '); + if ctx.trim().is_empty() { + return None; + } + let prompt = self.prompt_for(ctx.to_string()); + let client = self.client.clone(); + let asked = self.rt.block_on(async move { + tokio::time::timeout(MODEL_CHECK_TIMEOUT, client.next_tokens(&prompt, MODEL_CHECK_TOKENS)).await + }); + let top = match asked { + Ok(Ok(top)) => top, + Ok(Err(e)) => { + log::debug!("model check failed: {e:#}"); + return None; + } + Err(_) => { + log::debug!("model check took over {} ms", MODEL_CHECK_TIMEOUT.as_millis()); + return None; + } + }; + fixgate::model_prefers(typo, fix, &top, |w| self.checker.is_word(w, &self.dict)) + } + fn show_ghost(&mut self) { let Some(rect) = cc_context::caret_rect() else { log::debug!("no caret rect; ghost hidden"); @@ -713,6 +759,22 @@ impl App { ); self.prev_boundary = None; self.cancel_ghost(); + // A typing-model pick other than a doubled letter gets the language model's view of + // the word, with keys captured meanwhile so the field stays put. + if c.rule == "spell" && c.severity == Severity::S0 && !cc_correct::is_doubled_letter_typo(&c.original, &c.replacement) { + if !self.swallow.begin_capture(self.applied) { + log::info!("fix not applied: typed ahead of it"); + return; + } + let text = self.shadow.text(); + let before = text[..text.len() - boundary.len_utf8() - c.original.len()].to_string(); + if let Some(expected) = self.model_prefers_other(&before, &c.original, &c.replacement) { + log::info!("fix {:?} -> {:?} not applied: the model expects {:?}", c.original, c.replacement, expected); + self.end_capture(); + self.prev_boundary = Some((word, self.shadow.text().chars().count())); + return; + } + } let caret = cc_context::caret_rect(); let mut typed = c.replacement.clone(); typed.push(boundary); @@ -1559,8 +1621,10 @@ fn start() -> anyhow::Result<()> { let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?; let (tx, rx) = unbounded::(); + let engine = spawn_engine(CompletionClient::new(&sidecar_url()), tx.clone()); + // Its own client: a pooled connection is driven by the runtime that opened it, and this + // one runs only while the orchestrator waits on a model check. let client = CompletionClient::new(&sidecar_url()); - let engine = spawn_engine(client, tx.clone()); let overlay = Overlay::new().context("overlay")?; let swallow = Arc::new(SwallowState::default()); @@ -1637,6 +1701,7 @@ fn start() -> anyhow::Result<()> { enabled: settings.enabled, paused_until: None, demoted: HashSet::new(), + client, pending_fix_event: None, pending_completion_event: None, completion_latency_ms: 0, diff --git a/crates/cc-engine/src/completion.rs b/crates/cc-engine/src/completion.rs index a85082e..cff8363 100644 --- a/crates/cc-engine/src/completion.rs +++ b/crates/cc-engine/src/completion.rs @@ -124,6 +124,49 @@ impl CompletionClient { // the generation for that slot and keeps the prompt in its KV cache. Ok(acc.finish()) } + + /// The `n` likeliest tokens to follow `prompt`, as (token text, log probability), + /// likeliest first. One short request that generates a single token. + pub async fn next_tokens(&self, prompt: &str, n: u32) -> anyhow::Result> { + let url = format!("{}/completion", self.base_url); + let body = json!({ + "prompt": prompt, + "n_predict": 1, + "temperature": 0, + "stream": false, + "cache_prompt": true, + "n_probs": n, + }); + let resp = self.http.post(&url).json(&body).send().await.with_context(|| format!("POST {url}"))?; + let status = resp.status(); + if !status.is_success() { + bail!("llama-server returned HTTP {status}"); + } + let v: Value = resp.json().await.context("reading next-token probabilities")?; + Ok(top_tokens(&v)) + } +} + +/// The candidates for the first generated token in a non-streaming `/completion` reply, in +/// any of the shapes `entry_logprob` knows. +fn top_tokens(reply: &Value) -> Vec<(String, f32)> { + let Some(first) = reply.pointer("/completion_probabilities/0") else { return Vec::new() }; + for key in ["top_logprobs", "top_probs", "probs"] { + let Some(list) = first.get(key).and_then(Value::as_array) else { continue }; + return list + .iter() + .filter_map(|c| { + let token = c.get("token").or_else(|| c.get("tok_str")).and_then(Value::as_str)?; + let lp = c + .get("logprob") + .and_then(Value::as_f64) + .filter(|x| x.is_finite()) + .or_else(|| c.get("prob").and_then(Value::as_f64).and_then(prob_to_logprob))?; + Some((token.to_string(), lp as f32)) + }) + .collect(); + } + Vec::new() } fn request_body(prompt: &str, n_predict: u32) -> Value { @@ -422,6 +465,28 @@ mod tests { assert!(e.logprobs.is_empty()); } + #[test] + fn top_tokens_reads_every_reply_shape() { + let current: Value = serde_json::from_str( + r#"{"content":" month","completion_probabilities":[{"token":" month","logprob":-0.5, + "top_logprobs":[{"id":1,"token":" month","logprob":-0.5},{"id":2,"token":" week","logprob":-1.5}]}]}"#, + ) + .unwrap(); + assert_eq!(top_tokens(¤t), vec![(" month".to_string(), -0.5), (" week".to_string(), -1.5)]); + let post: Value = serde_json::from_str( + r#"{"completion_probabilities":[{"token":" a","prob":0.5,"top_probs":[{"token":" a","prob":0.5},{"token":" b","prob":0.0}]}]}"#, + ) + .unwrap(); + let t = top_tokens(&post); + assert_eq!(t.len(), 1, "a zero probability has no logprob: {t:?}"); + assert!((t[0].1 - 0.5f32.ln()).abs() < 1e-6); + let old: Value = + serde_json::from_str(r#"{"completion_probabilities":[{"content":" x","probs":[{"tok_str":" x","prob":1.0}]}]}"#) + .unwrap(); + assert_eq!(top_tokens(&old), vec![(" x".to_string(), 0.0)]); + assert!(top_tokens(&json!({"content": ""})).is_empty()); + } + #[test] fn parses_final_chunk_timings() { let e = ev(FINAL); From ff263e19059f8a03910ca8218d01b573ed01b122 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 20 Oct 2026 17:30:26 -0400 Subject: [PATCH 5/9] Remember fixes the user undid, and keep demotions across restarts A fix pair that was undone (reverted, or backspaced over within 3 s) and never kept, or undone three times and at least as often as kept, is no longer typed. Tuning counts those quick overwrites as undos too. Demoted rules were only held in memory from the run that demoted them, so a restart applied them again; they now load from events.db at startup. Verified with cargo test -p cc-app -p cc-store. Claude-Session: https://claude.ai/code/session_01DiRshLaPboWShtRAdGazjy --- crates/cc-app/src/main.rs | 14 ++++++++- crates/cc-store/src/lib.rs | 64 +++++++++++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/crates/cc-app/src/main.rs b/crates/cc-app/src/main.rs index 69428f2..0998d12 100644 --- a/crates/cc-app/src/main.rs +++ b/crates/cc-app/src/main.rs @@ -195,6 +195,8 @@ struct App { paused_until: Option, /// Rules the nightly tuning demoted to suggest-only. demoted: HashSet, + /// `(typed, fix)` pairs, lowercase, the user undid (`Store::undone_pairs`); never typed again. + undone: HashSet<(String, String)>, /// For the language model's second opinion on a fix; completions go through `engine`. client: CompletionClient, /// (event id, applied at, shadow length right after) of the last auto fix awaiting an outcome. @@ -741,6 +743,11 @@ impl App { self.prev_boundary = Some((word, self.shadow.text().chars().count())); return; }; + if self.undone.contains(&(c.original.to_lowercase(), c.replacement.to_lowercase())) { + log::info!("fix {:?} -> {:?} [{}] not applied: undone before", c.original, c.replacement, c.rule); + self.prev_boundary = Some((word, self.shadow.text().chars().count())); + return; + } // The fix deletes `original` back from the caret. Unless the shadow saw what precedes // it, that may be the tail of a longer word, and the result would be a mangled word. let whole_word = body_len > c.original.len() || self.shadow.anchored(); @@ -1075,6 +1082,7 @@ impl App { self.pending_fix_event = None; self.outcome(fix.event_id, Outcome::Reverted); self.note_undo(); + self.undone.insert((fix.original.to_lowercase(), fix.replacement.to_lowercase())); self.dont_touch.insert(fix.original.clone()); self.auto.remove(&fix.original); if let Err(e) = self.auto.save(&self.auto_path) { @@ -1676,6 +1684,9 @@ fn start() -> anyhow::Result<()> { let ui = UiServer::start(&profile.join("events.db"), &profile, ui_state.clone(), ui_tx).context("ui server")?; let windows = Windows::new().context("ui windows")?; log::info!("dashboard: {}", ui.url("dashboard")); + // Demotions and undone pairs from earlier sessions; tuning only reports new demotions. + let demoted: HashSet = store.demoted_rules().unwrap_or_default().into_iter().collect(); + let undone: HashSet<(String, String)> = store.undone_pairs().unwrap_or_default().into_iter().collect(); let auto = AutocorrectList::load(&auto_path); if auto.file_is_stale() { if let Err(e) = auto.save(&auto_path) { @@ -1700,7 +1711,8 @@ fn start() -> anyhow::Result<()> { last_prompt_tail: String::new(), enabled: settings.enabled, paused_until: None, - demoted: HashSet::new(), + demoted, + undone, client, pending_fix_event: None, pending_completion_event: None, diff --git a/crates/cc-store/src/lib.rs b/crates/cc-store/src/lib.rs index 6493c7f..3add2e6 100644 --- a/crates/cc-store/src/lib.rs +++ b/crates/cc-store/src/lib.rs @@ -33,6 +33,8 @@ const MAX_CONTEXT_CHARS: usize = 200; const DEMOTE_MIN_AUTO: u64 = 20; /// Minimum suggestions before a promotion can be offered. const PROMOTE_MIN_SUGGESTED: u64 = 50; +/// An auto-fix backspaced over this soon after it went in counts as undone, like a revert. +const QUICK_OVERWRITE_MS: i64 = 3000; /// How many of an app's latest fix checks [`Store::landing`] counts. const LANDING_WINDOW: u32 = 50; /// Checks an app needs before [`Landing::too_low`] can say it does not take fixes cleanly. @@ -187,6 +189,7 @@ pub struct Metrics { pub struct RuleStat { pub rule: String, pub auto_total: u64, + /// Undone: reverted, or backspaced over within 3 s. pub auto_reverted: u64, pub suggested_total: u64, pub suggested_accepted: u64, @@ -426,13 +429,14 @@ impl Store { let mut stmt = self.conn.prepare_cached( "SELECT rule, \ COUNT(CASE WHEN action = 'auto' THEN 1 END), \ - COUNT(CASE WHEN action = 'auto' AND outcome = 'reverted' THEN 1 END), \ + COUNT(CASE WHEN action = 'auto' AND (outcome = 'reverted' \ + OR (outcome = 'overwritten' AND outcome_ts - ts <= ?2)) THEN 1 END), \ COUNT(CASE WHEN action = 'suggested' THEN 1 END), \ COUNT(CASE WHEN action = 'suggested' AND outcome = 'accepted' THEN 1 END) \ FROM events GROUP BY rule HAVING COUNT(*) >= ?1 \ ORDER BY COUNT(*) DESC, rule", )?; - let rows = stmt.query_map([min], |r| { + let rows = stmt.query_map(params![min, QUICK_OVERWRITE_MS], |r| { Ok(RuleStat { rule: r.get(0)?, auto_total: cnt(r.get(1)?), @@ -472,7 +476,8 @@ impl Store { let mut stmt = tx.prepare_cached( "SELECT e.rule, \ COUNT(CASE WHEN e.action = 'auto' THEN 1 END), \ - COUNT(CASE WHEN e.action = 'auto' AND e.outcome = 'reverted' THEN 1 END), \ + COUNT(CASE WHEN e.action = 'auto' AND (e.outcome = 'reverted' \ + OR (e.outcome = 'overwritten' AND e.outcome_ts - e.ts <= ?1)) THEN 1 END), \ COUNT(CASE WHEN e.action = 'suggested' THEN 1 END), \ COUNT(CASE WHEN e.action = 'suggested' AND e.outcome = 'accepted' THEN 1 END), \ COALESCE(MAX(o.max_severity = 'suggest'), 0) \ @@ -480,7 +485,7 @@ impl Store { WHERE o.rule IS NULL OR o.max_severity <> 'auto' OR e.ts > o.since \ GROUP BY e.rule ORDER BY e.rule", )?; - let it = stmt.query_map([], |r| { + let it = stmt.query_map([QUICK_OVERWRITE_MS], |r| { Ok(( r.get::<_, String>(0)?, cnt(r.get(1)?), @@ -506,7 +511,7 @@ impl Store { .execute(params![rule, now])?; out.push(Tuning::Demote { reason: format!( - "{auto_reverted} of {auto_total} auto-fixes reverted ({}%)", + "{auto_reverted} of {auto_total} auto-fixes undone ({}%)", pct(auto_reverted, auto_total) ), rule: rule.clone(), @@ -763,6 +768,22 @@ impl Store { Ok(rows.collect::>>()?) } + /// `(typed, fix)` pairs, lowercase, of auto-fixes the user rejects: undone (reverted, or + /// backspaced over within 3 s) and never kept, or undone three times and at least as + /// often as kept. + pub fn undone_pairs(&self) -> Result> { + let mut stmt = self.conn.prepare_cached( + "SELECT lower(original), lower(replacement), \ + COUNT(CASE WHEN outcome = 'reverted' \ + OR (outcome = 'overwritten' AND outcome_ts - ts <= ?1) THEN 1 END) AS undone, \ + COUNT(CASE WHEN outcome = 'kept' THEN 1 END) AS kept \ + FROM events WHERE action = 'auto' AND source <> 'llm_complete' \ + GROUP BY 1, 2 HAVING (undone >= 1 AND kept = 0) OR (undone >= 3 AND undone >= kept)", + )?; + let rows = stmt.query_map([QUICK_OVERWRITE_MS], |r| Ok((r.get(0)?, r.get(1)?)))?; + Ok(rows.collect::>>()?) + } + /// Records whether the auto-fix `event_id` read back from the field as typed. pub fn log_fix_check(&self, event_id: i64, app_exe: &str, landed: bool) -> Result<()> { self.conn @@ -1025,6 +1046,39 @@ mod tests { assert_eq!(outcome_of(&s, id).0.as_deref(), Some("kept")); } + #[test] + fn undone_pairs_are_fixes_the_user_rejects() { + let dir = TempDir::new(); + let s = Store::open(&dir.db()).unwrap(); + let auto = |typed: &str, fixed: &str, outcome: Outcome| { + let id = s.log(&fix("spell", Action::Auto, typed, fixed)).unwrap(); + s.set_outcome(id, outcome).unwrap(); + id + }; + auto("ok", "k", Outcome::Overwritten); + auto("thhe", "the", Outcome::Kept); + auto("thhe", "the", Outcome::Overwritten); + auto("Tst", "test", Outcome::Reverted); + let late = auto("ender", "under", Outcome::Overwritten); + s.conn.execute("UPDATE events SET outcome_ts = ts + 9000 WHERE id = ?1", [late]).unwrap(); + let mut pairs = s.undone_pairs().unwrap(); + pairs.sort(); + assert_eq!(pairs, [("ok".to_string(), "k".to_string()), ("tst".to_string(), "test".to_string())]); + } + + #[test] + fn tune_counts_a_quick_overwrite_as_undone() { + let dir = TempDir::new(); + let s = Store::open(&dir.db()).unwrap(); + for i in 0..20 { + let id = s.log(&fix("r", Action::Auto, "teh", "the")).unwrap(); + s.set_outcome(id, if i < 6 { Outcome::Overwritten } else { Outcome::Kept }).unwrap(); + } + let t = s.tune().unwrap(); + assert!(matches!(&t[..], [Tuning::Demote { rule, reason }] if rule == "r" && reason.contains("6 of 20")), "{t:?}"); + assert_eq!(s.rule_stats(0).unwrap()[0].auto_reverted, 6); + } + #[test] fn landing_counts_the_latest_week_of_checks_per_app() { let dir = TempDir::new(); From 2beff7852b8f83568ab8770781b617cb4179328e Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 20 Oct 2026 17:33:48 -0400 Subject: [PATCH 6/9] End the capture the model check starts when the fix then gives up The model check turns capture on before asking; if replace_tail then found a key had got through and gave up, nothing ended that capture, and every key typed afterwards would have been held and never replayed. replace_tail now ends a capture it finds on when it gives up at that point. Verified with cargo test -p cc-app. Claude-Session: https://claude.ai/code/session_01DiRshLaPboWShtRAdGazjy --- crates/cc-app/src/main.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/cc-app/src/main.rs b/crates/cc-app/src/main.rs index 0998d12..2971aa3 100644 --- a/crates/cc-app/src/main.rs +++ b/crates/cc-app/src/main.rs @@ -867,6 +867,11 @@ impl App { // app after the one being handled means the field no longer ends where the shadow does. if !self.swallow.begin_capture(self.applied) { log::info!("fix not applied: typed ahead of it"); + // The caller may have started capture (to ask the model first); end it, or the + // keys it holds would never be replayed. + if self.swallow.capturing() { + self.end_capture(); + } return false; } let passed = self.applied; From 28cb0ce76a3d6413ac11af1beacb1ad7ef7fbabd Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 20 Oct 2026 17:56:16 -0400 Subject: [PATCH 7/9] Auto-fix only spelling slips that keep the first letter A spelling fix is S0 only when the word it picks is doubled keys away from what was typed, or one edit away with the same first letter (or the first two letters swapped, as in htis). The e2e run typed calory, two edits from both color and calorie, and morth, which the keyboard reads as north and the sentence as month; both were auto-fixed to the wrong word. In the owner's history, non-doubled fixes that changed the first letter were kept 3 of 7 times and two-edit fixes 9 of 15. Claude-Session: https://claude.ai/code/session_01DiRshLaPboWShtRAdGazjy --- crates/cc-correct/src/lib.rs | 46 +++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/cc-correct/src/lib.rs b/crates/cc-correct/src/lib.rs index ffa1ede..b01894a 100644 --- a/crates/cc-correct/src/lib.rs +++ b/crates/cc-correct/src/lib.rs @@ -16,7 +16,8 @@ //! suggestion". Instead the candidates in Harper's wider suggestion pool are re-ranked with a //! small typing-error model (doubled keys, QWERTY-adjacent keys, transpositions, schwa vowels //! in `-er`/`-ar`, a common-word prior) and a typo is S0 only when the best candidate beats the -//! runner-up by a clear margin. See [`Checker::analyze`] and the `typo_cost` function. +//! runner-up by a clear margin and is one slip from what was typed: doubled keys, or a single +//! edit that keeps the first letter. See [`Checker::analyze`] and the `typo_cost` function. use std::collections::{HashMap, HashSet}; use std::io; @@ -477,6 +478,12 @@ impl Checker { // unknown word is probably a name: only a very common word may replace it automatically. let dialect_variant = self.dict.contains_word(&orig); let name_risk = first_upper && !common_words().contains(best.text.as_str()); + // Past doubled keys, an automatic fix is one slip from what was typed and keeps its + // first letter. Two slips, or another first letter, leave other words as close: + // `calory` is `calorie` as much as `color`, and `this morth` means `month` though the + // keyboard favours `north`. + let one_slip = is_doubled_letter_typo(original, &best.text) + || (best.distance == 1 && keeps_first_letter(&lower, &best.text)); let long_enough = orig.len() >= MIN_S0_LEN || (orig.len() >= 3 && is_doubled_letter_typo(original, &best.text) @@ -485,6 +492,7 @@ impl Checker { && !name_risk && !looks_coined(&lower, &best.text) && long_enough + && one_slip && margin >= S0_MIN_MARGIN && confidence >= S0_MIN_CONFIDENCE { @@ -503,7 +511,8 @@ impl Checker { const POOL_SIZE: usize = 200; /// Entries in the per-word classification memo before it is reset. const CACHE_LIMIT: usize = 4096; -/// S0 needs a Damerau-Levenshtein distance of at most this. +/// Candidates within this Damerau-Levenshtein distance are scored. S0 needs 1, or more only +/// when every extra letter is a doubled key (`seentencees`). const S0_MAX_DISTANCE: usize = 2; /// S0 needs an original of at least this many chars, unless it is a common word with a /// doubled letter (`soo`). Shorter typos have several likely fixes: `har` is `her` or `has`. @@ -726,7 +735,9 @@ fn common_word_typo(word: &str) -> Option<(String, f32)> { let mut scored = common_neighbours(word); scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(b.1))); let best = scored.first()?; - if len < MIN_S0_LEN && !is_doubled_letter_typo(word, best.1) { + let doubled = is_doubled_letter_typo(word, best.1); + let typed: Vec = word.chars().map(lc).collect(); + if (len < MIN_S0_LEN && !doubled) || !(doubled || keeps_first_letter(&typed, best.1)) { return None; } let margin = scored.get(1).map_or(f32::INFINITY, |second| second.0 - best.0); @@ -746,6 +757,17 @@ fn looks_coined(typed: &[char], fix: &str) -> bool { no_vowel || (cut && !is_doubled_letter_typo(&typed, &fix)) } +/// `fix` starts with the letter typed first (`typed` is lowercase), or with the first two +/// typed letters swapped (`htis` for `this`). +fn keeps_first_letter(typed: &[char], fix: &str) -> bool { + let fix: Vec = fix.chars().map(lc).collect(); + match (typed, fix.as_slice()) { + ([a, ..], [b, ..]) if a == b => true, + ([a, b, ..], [c, d, ..]) => a == d && b == c, + _ => false, + } +} + fn lowercase_first(s: &str) -> String { let mut chars = s.chars(); match chars.next() { @@ -1226,6 +1248,24 @@ mod tests { assert_eq!((c.replacement.as_str(), &c.severity), ("that", &Severity::S0), "a doubled last letter is a typo"); } + #[test] + fn auto_fixes_are_one_slip_that_keeps_the_first_letter() { + let dict = PersonalDictionary::default(); + // Two slips (`calorie`, `color`) or another first letter (`month`, `north`): suggest only. + for (text, typo) in [("It is ok to use it for the calory count", "calory"), ("We check the traffic this morth", "morth")] { + let cands = checker().check(text, &dict); + assert!( + cands.iter().filter(|c| c.original == typo).all(|c| c.severity >= Severity::S2), + "{text}: {cands:?}" + ); + } + // Swapping the first two letters is one slip. + let c = checker().check("I want htis one", &dict).into_iter().find(|c| c.original == "htis").unwrap(); + assert_eq!((c.replacement.as_str(), &c.severity), ("this", &Severity::S0)); + assert!(keeps_first_letter(&['h', 't', 'i', 's'], "this")); + assert!(!keeps_first_letter(&['m', 'o', 'r', 't', 'h'], "north")); + } + #[test] fn is_code_like_cases() { for w in ["camelCase", "snake_case", "C:\\x\\y", "https://a.b", "a@b.c", "v2", "HTTP"] { From 115fb5ea343748d9c1de7abcd2ede3c2377776db Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 20 Oct 2026 17:56:22 -0400 Subject: [PATCH 8/9] Fix only in fields completions may use, and only with the model's answer on_word_boundary never looked at the focused element, so fixes were typed into read-only pages, password fields and apps the privacy settings turn off, and typos were learned from those fields too. Before a fix, and before learning a typo, the app now reads the focus the way completions do. It skips the fix when the field is not one it may type in, or when the focus moved since the word was typed. The model check failed with os error 10053 when it reused a pooled connection that llama-server had closed. Its client now keeps no idle connections. A fix that needs the model's view and gets no answer within 300 ms is skipped instead of typed. Claude-Session: https://claude.ai/code/session_01DiRshLaPboWShtRAdGazjy --- crates/cc-app/src/main.rs | 50 +++++++++++++++++++----------- crates/cc-engine/src/completion.rs | 21 ++++++++++--- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/crates/cc-app/src/main.rs b/crates/cc-app/src/main.rs index 2971aa3..eb37267 100644 --- a/crates/cc-app/src/main.rs +++ b/crates/cc-app/src/main.rs @@ -48,9 +48,9 @@ const UNDERLINE_FADE_MS: u32 = 1500; /// Electron apps can take a while to update what UIA sees. const FIX_CHECK_RETRIES: u8 = 2; const FIX_RECHECK_AFTER: Duration = Duration::from_millis(200); -/// How long a fix waits for the language model's view of the word; past it, the fix goes -/// in on the typing model alone. -const MODEL_CHECK_TIMEOUT: Duration = Duration::from_millis(150); +/// How long a fix waits for the language model's view of the word. A fix that needs that +/// view and gets no answer in time is not typed. +const MODEL_CHECK_TIMEOUT: Duration = Duration::from_millis(300); /// Next-token candidates asked for in that check. const MODEL_CHECK_TOKENS: u32 = 40; const DEFAULT_MODEL: &str = "gemma-3-1b-pt"; @@ -582,10 +582,11 @@ impl App { } } - /// A dictionary word close to `typo` that the model clearly expects after `before` (the - /// text up to the typo) instead of `fix` (`fixgate::model_prefers`). None when the - /// engine is not running or does not answer in time. - fn model_prefers_other(&self, before: &str, typo: &str, fix: &str) -> Option { + /// Why the language model stops the fix of `typo` to `fix`, if it does: it clearly + /// expects another dictionary word close to `typo` after `before` (the text up to the + /// typo; `fixgate::model_prefers`), or it gave no answer in time. None lets the fix go + /// in; so does having no engine, or no text before the typo to ask about. + fn model_objection(&self, before: &str, typo: &str, fix: &str) -> Option { self.sidecar.as_ref()?; let ctx = before.trim_end_matches(' '); if ctx.trim().is_empty() { @@ -593,21 +594,22 @@ impl App { } let prompt = self.prompt_for(ctx.to_string()); let client = self.client.clone(); + let t0 = Instant::now(); let asked = self.rt.block_on(async move { tokio::time::timeout(MODEL_CHECK_TIMEOUT, client.next_tokens(&prompt, MODEL_CHECK_TOKENS)).await }); + let ms = t0.elapsed().as_millis(); let top = match asked { Ok(Ok(top)) => top, Ok(Err(e)) => { - log::debug!("model check failed: {e:#}"); - return None; - } - Err(_) => { - log::debug!("model check took over {} ms", MODEL_CHECK_TIMEOUT.as_millis()); - return None; + log::debug!("model check failed after {ms} ms: {e:#}"); + return Some("the model gave no answer".into()); } + Err(_) => return Some(format!("the model gave no answer within {ms} ms")), }; + log::debug!("model check answered in {ms} ms"); fixgate::model_prefers(typo, fix, &top, |w| self.checker.is_word(w, &self.dict)) + .map(|word| format!("the model expects {word:?}")) } fn show_ghost(&mut self) { @@ -696,6 +698,7 @@ impl App { if self.breaker.paused(self.last_hwnd, Instant::now()) || self.landing_too_low() { return; } + let (hwnd, element) = (self.last_hwnd, self.last_element); let text = self.shadow.text(); let body = &text[..text.len() - boundary.len_utf8()]; let body_len = body.len(); @@ -759,6 +762,16 @@ impl App { self.prev_boundary = Some((word, self.shadow.text().chars().count())); return; } + // A fix goes only where a completion may: an editable field, not a password, in an app + // the privacy settings allow. The focus is read again: the last read may be from + // before a click or a window switch, and then the shadow describes another field. + let allowed = self.field_allowed(); + if !allowed || self.last_hwnd != hwnd || (element != 0 && self.last_element != element) { + let why = if allowed { "the focus moved" } else { "the focus is not a field it may type in" }; + log::info!("fix {:?} -> {:?} [{}] not applied: {why}", c.original, c.replacement, c.rule); + self.prev_boundary = None; + return; + } let check_ms = t0.elapsed().as_millis() as u32; log::info!( "fix {:?} -> {:?} [{} {:?} {:.2}] checked in {} ms", @@ -775,8 +788,8 @@ impl App { } let text = self.shadow.text(); let before = text[..text.len() - boundary.len_utf8() - c.original.len()].to_string(); - if let Some(expected) = self.model_prefers_other(&before, &c.original, &c.replacement) { - log::info!("fix {:?} -> {:?} not applied: the model expects {:?}", c.original, c.replacement, expected); + if let Some(why) = self.model_objection(&before, &c.original, &c.replacement) { + log::info!("fix {:?} -> {:?} not applied: {why}", c.original, c.replacement); self.end_capture(); self.prev_boundary = Some((word, self.shadow.text().chars().count())); return; @@ -849,7 +862,8 @@ impl App { return; } let prev_is_unknown = self.checker.check(prev, &self.dict).iter().any(|c| c.rule == "spell"); - if !prev_is_unknown { + // Nothing is learned from a field it may not type in, such as a password. + if !prev_is_unknown || !self.field_allowed() { return; } self.auto.add(prev, word); @@ -1636,8 +1650,8 @@ fn start() -> anyhow::Result<()> { let (tx, rx) = unbounded::(); let engine = spawn_engine(CompletionClient::new(&sidecar_url()), tx.clone()); // Its own client: a pooled connection is driven by the runtime that opened it, and this - // one runs only while the orchestrator waits on a model check. - let client = CompletionClient::new(&sidecar_url()); + // one runs only while the orchestrator waits on a model check. So it keeps none. + let client = CompletionClient::unpooled(&sidecar_url()); let overlay = Overlay::new().context("overlay")?; let swallow = Arc::new(SwallowState::default()); diff --git a/crates/cc-engine/src/completion.rs b/crates/cc-engine/src/completion.rs index cff8363..6dceaa6 100644 --- a/crates/cc-engine/src/completion.rs +++ b/crates/cc-engine/src/completion.rs @@ -31,13 +31,26 @@ pub struct CompletionClient { impl CompletionClient { pub fn new(base_url: &str) -> CompletionClient { + Self::build(base_url, true) + } + + /// A client that opens a new connection for each request, for a caller whose runtime + /// runs only during its own requests. Nothing polls an idle pooled connection between + /// them, so one the server has closed would fail the next request (os error 10053). + pub fn unpooled(base_url: &str) -> CompletionClient { + Self::build(base_url, false) + } + + fn build(base_url: &str, pool: bool) -> CompletionClient { // llama-server is on loopback; never route it through the system proxy. - let http = reqwest::Client::builder() + let mut builder = reqwest::Client::builder() .no_proxy() .connect_timeout(Duration::from_secs(5)) - .tcp_nodelay(true) - .build() - .expect("building reqwest client"); + .tcp_nodelay(true); + if !pool { + builder = builder.pool_max_idle_per_host(0); + } + let http = builder.build().expect("building reqwest client"); CompletionClient { http, base_url: base_url.trim_end_matches('/').to_string() } } From 32db73cc2ff1af1bc9242e5a3ec29ddd4670d723 Mon Sep 17 00:00:00 2001 From: drknowhow Date: Tue, 20 Oct 2026 18:01:53 -0400 Subject: [PATCH 9/9] Add a Chrome contenteditable e2e typing test tools/e2e/contenteditable.ps1 types seven sentences with typos into a role=textbox contenteditable at 45 ms per key, 12 ms after a space, and checks each word that landed: it must be what was typed or that word's allowed fix. Two lines hold typos that go through the model check. With -Bare the field has no role, UIA reports a group, and nothing may change. The script refuses to type unless the test field has the focus. Claude-Session: https://claude.ai/code/session_01DiRshLaPboWShtRAdGazjy --- tools/e2e/contenteditable.ps1 | 114 ++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tools/e2e/contenteditable.ps1 diff --git a/tools/e2e/contenteditable.ps1 b/tools/e2e/contenteditable.ps1 new file mode 100644 index 0000000..c10728e --- /dev/null +++ b/tools/e2e/contenteditable.ps1 @@ -0,0 +1,114 @@ +# Types sentences with typos into a Chrome contenteditable (the kind of field chat apps +# and Claude desktop use) at a fast cadence, with a quick burst after each space, then +# checks every word that landed: it must be what was typed or that word's allowed fix. +# A joined, split, mangled or wrongly fixed word fails the run. +# -Bare drops the field's textbox role, so UIA reports it as a group, not an editable +# field: the app must leave every word as typed there. +# Run with the app started as: CC_ACCEPT_INJECTED=1 casecomplete.exe +param( + [string[]]$Lines = @( + "I wwant to see thhe report from yesterday and aree you sure about it", + "We should mmore carefully check the throughh traffic this morth and thhat is all", + "Please haave a look at teh new numbers before the meetting starts", + "Every one of them came and we waited a while for the quick start", + "I think it is ok to use Outllook for the calory count", + "I will go wiht them to recieve the package and definately call you", + # Typos outside the autocorrect list that are not doubled keys: the model is asked first. + "I sent the documnet yestreday with the pakage for you" + ), + # Typo=fix pairs the app may apply; any other word must land exactly as typed. + [string[]]$Fixes = @("wwant=want", "thhe=the", "aree=are", "mmore=more", "throughh=through", + "thhat=that", "haave=have", "teh=the", "meetting=meeting", "Outllook=Outlook", + "wiht=with", "recieve=receive", "definately=definitely", "documnet=document", + "yestreday=yesterday", "pakage=package"), + [int]$CadenceMs = 45, + [int]$AfterSpaceMs = 12, + [int]$Runs = 3, + [switch]$Bare +) +Add-Type -AssemblyName System.Windows.Forms, UIAutomationClient, UIAutomationTypes +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Threading; +public static class Typer { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l); + [StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } + [StructLayout(LayoutKind.Explicit)] public struct INPUTUNION { [FieldOffset(0)] public KEYBDINPUT ki; [FieldOffset(0)] public long pad1; [FieldOffset(8)] public long pad2; [FieldOffset(16)] public long pad3; [FieldOffset(24)] public long pad4; } + [StructLayout(LayoutKind.Sequential)] public struct INPUT { public uint type; public INPUTUNION u; } + [DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint n, INPUT[] inputs, int size); + static void Send(ushort vk, ushort scan, uint flags) { + var a = new INPUT[2]; + a[0].type = 1; a[0].u.ki.wVk = vk; a[0].u.ki.wScan = scan; a[0].u.ki.dwFlags = flags; + a[1].type = 1; a[1].u.ki.wVk = vk; a[1].u.ki.wScan = scan; a[1].u.ki.dwFlags = flags | 2; + SendInput(2, a, Marshal.SizeOf(typeof(INPUT))); + } + // Types `text` one char at a time; false if `target` lost the foreground. + public static bool Type(IntPtr target, string text, int cadenceMs, int afterSpaceMs) { + foreach (char c in text) { + if (GetForegroundWindow() != target) return false; + if (c == '\n') Send(0x0D, 0, 0); else Send(0, c, 4); + Thread.Sleep(c == ' ' ? afterSpaceMs : cadenceMs); + } + return true; + } + public static void Close(IntPtr h) { PostMessage(h, 0x0010, IntPtr.Zero, IntPtr.Zero); } +} +"@ +$allowed = @{} +if (-not $Bare) { + foreach ($pair in $Fixes) { $t, $f = $pair.Split("="); $allowed[$t.ToLower()] = $f.ToLower() } +} +function Bare([string]$w) { return ($w -replace "[^\p{L}\p{N}']", "").ToLower() } + +$role = if ($Bare) { "" } else { ' role="textbox" aria-multiline="true"' } +$page = "cc-e2e-ce
" +# Percent-encoded: Start-Process joins its arguments with spaces and does not quote them. +$url = "data:text/html," + [Uri]::EscapeDataString($page) +$expect = if ($Bare) { [System.Windows.Automation.ControlType]::Group } else { [System.Windows.Automation.ControlType]::Edit } +$saved = Get-Clipboard -Raw -ErrorAction SilentlyContinue +$failed = 0 +for ($run = 1; $run -le $Runs; $run++) { + Start-Process chrome.exe -ArgumentList @("--new-window", $url) + Start-Sleep -Seconds 3 + $w = Get-Process chrome | Where-Object { $_.MainWindowTitle -like "cc-e2e-ce*" } | Select-Object -First 1 + if (-not $w) { Write-Output "RESULT run=${run}: [no chrome window]"; exit 1 } + $hwnd = $w.MainWindowHandle + [Typer]::SetForegroundWindow($hwnd) | Out-Null + Start-Sleep -Milliseconds 800 + $focused = [System.Windows.Automation.AutomationElement]::FocusedElement + if ([Typer]::GetForegroundWindow() -ne $hwnd -or $focused.Current.ControlType -ne $expect) { + Write-Output "RESULT run=${run}: [aborted: the test field does not have the focus ($($focused.Current.ControlType.ProgrammaticName))]" + [Typer]::Close($hwnd); exit 2 + } + if (-not [Typer]::Type($hwnd, ($Lines -join "`n"), $CadenceMs, $AfterSpaceMs)) { + Write-Output "RESULT run=${run}: [aborted: the test window lost focus]" + [Typer]::Close($hwnd); exit 2 + } + Start-Sleep -Milliseconds 1500 + Set-Clipboard -Value " " + [System.Windows.Forms.SendKeys]::SendWait("{ESC}"); Start-Sleep -Milliseconds 200 + [System.Windows.Forms.SendKeys]::SendWait("^a"); Start-Sleep -Milliseconds 200 + [System.Windows.Forms.SendKeys]::SendWait("^c"); Start-Sleep -Milliseconds 300 + $got = @((Get-Clipboard -Raw) -split "\r?\n" | Where-Object { $_.Trim() -ne "" }) + [Typer]::Close($hwnd); Start-Sleep -Milliseconds 500 + for ($i = 0; $i -lt $Lines.Count; $i++) { + $typed = $Lines[$i] -split " +" + $line = if ($i -lt $got.Count) { $got[$i].Trim() } else { "" } + $landed = if ($line) { $line -split " +" } else { @() } + $problems = @() + if ($landed.Count -ne $typed.Count) { $problems += "typed $($typed.Count) words, $($landed.Count) landed" } + for ($j = 0; $j -lt [Math]::Min($typed.Count, $landed.Count); $j++) { + $t = Bare $typed[$j]; $l = Bare $landed[$j] + if ($l -ne $t -and $l -ne $allowed[$t]) { $problems += "'$($typed[$j])' landed as '$($landed[$j])'" } + } + $verdict = if ($problems.Count) { $failed++; "FAIL: " + ($problems -join "; ") } else { "ok" } + Write-Output "RESULT run=$run line=$($i + 1): $verdict | $line" + } +} +if ($null -ne $saved) { Set-Clipboard -Value $saved } +$field = if ($Bare) { "group (not editable)" } else { "textbox" } +Write-Output "SUMMARY: $failed line(s) failed in $Runs run(s) in a $field field at ${CadenceMs} ms per key, ${AfterSpaceMs} ms after a space" +exit [int]($failed -gt 0)