diff --git a/crates/cc-app/src/fixgate.rs b/crates/cc-app/src/fixgate.rs index 7972bd9..315e418 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,106 @@ 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() +} + +/// 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 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 { + 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 { + 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 +397,64 @@ 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 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 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."); + 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 9f77264..eb37267 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,13 +44,23 @@ 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); +/// 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"; /// Terminals: UIA reports the whole screen buffer, not an edit field, so the shadow buffer /// is the only context and editability is assumed. 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 }, } @@ -104,8 +114,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)] @@ -182,6 +195,10 @@ 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. pending_fix_event: Option<(i64, Instant, usize)>, pending_completion_event: Option, @@ -214,8 +231,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 +251,13 @@ struct App { style: GhostStyle, last_exe: String, 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, } @@ -392,8 +422,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 +435,18 @@ 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(); + self.text_hwnd = self.last_hwnd; + 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 +459,31 @@ 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 }; + self.text_hwnd = self.last_hwnd; + 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 +496,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 +520,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,17 +559,59 @@ 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. 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) + } + } + + /// 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() { + return None; + } + 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 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) { let Some(rect) = cc_context::caret_rect() else { log::debug!("no caret rect; ghost hidden"); @@ -517,14 +630,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 +663,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 +686,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 @@ -571,9 +695,10 @@ 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 (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(); @@ -621,6 +746,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(); @@ -632,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", @@ -639,18 +779,31 @@ 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(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; + } + } let caret = cc_context::caret_rect(); let mut typed = c.replacement.clone(); typed.push(boundary); - let del = c.original.chars().count() + 1; - if !self.replace_tail(del, &typed) { + let mut delete = c.original.clone(); + delete.push(boundary); + let captured_before = self.swallow.captured(); + 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 { @@ -672,15 +825,24 @@ 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); } - 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 @@ -700,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); @@ -710,13 +873,23 @@ 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, 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"); + // 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; + // 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 @@ -731,6 +904,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)), @@ -758,6 +936,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) { @@ -782,7 +972,7 @@ impl App { self.replay_at = Some(now); } } - self.swallow.set_capture(false); + self.swallow.end_capture(); self.capture_deadline = None; } @@ -796,22 +986,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) { @@ -835,7 +1074,7 @@ impl App { HookEvent::Char(c) } }; - self.on_key(ev); + self.on_key(self.applied, ev); } } @@ -845,7 +1084,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); @@ -860,6 +1101,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) { @@ -868,12 +1110,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 +1134,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 +1155,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 +1184,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 +1254,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 +1276,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) { @@ -1183,6 +1460,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:#}"), @@ -1251,9 +1530,13 @@ 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); + self.verify_field(check); } } if self.resync_at.is_some_and(|t| Instant::now() >= t) { @@ -1271,7 +1554,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) => {} @@ -1365,21 +1648,23 @@ fn start() -> anyhow::Result<()> { let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?; let (tx, rx) = unbounded::(); - let client = CompletionClient::new(&sidecar_url()); - let engine = spawn_engine(client, tx.clone()); + 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. So it keeps none. + let client = CompletionClient::unpooled(&sidecar_url()); 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; } } @@ -1418,6 +1703,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) { @@ -1442,7 +1730,9 @@ 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, completion_latency_ms: 0, @@ -1476,8 +1766,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 +1782,9 @@ fn start() -> anyhow::Result<()> { style: GhostStyle::default(), 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-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-correct/src/lib.rs b/crates/cc-correct/src/lib.rs index 8415fb6..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,13 +478,21 @@ 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) && common_words().contains(best.text.as_str())); if !dialect_variant && !name_risk + && !looks_coined(&lower, &best.text) && long_enough + && one_slip && margin >= S0_MIN_MARGIN && confidence >= S0_MIN_CONFIDENCE { @@ -502,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`. @@ -725,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); @@ -733,6 +745,29 @@ 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)) +} + +/// `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() { @@ -1195,6 +1230,42 @@ 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 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"] { diff --git a/crates/cc-engine/src/completion.rs b/crates/cc-engine/src/completion.rs index a85082e..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() } } @@ -124,6 +137,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 +478,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); 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] diff --git a/crates/cc-store/src/lib.rs b/crates/cc-store/src/lib.rs index af52ae7..3add2e6 100644 --- a/crates/cc-store/src/lib.rs +++ b/crates/cc-store/src/lib.rs @@ -33,6 +33,26 @@ 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. +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( @@ -169,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, @@ -234,6 +255,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 { @@ -393,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)?), @@ -439,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) \ @@ -447,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)?), @@ -473,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(), @@ -730,6 +768,42 @@ 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 + .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 +831,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 +1046,82 @@ 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(); + 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(); 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)