From 7a61b07e0237c2fb2bffdfd1982923d81388b99c Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 25 Sep 2026 19:54:51 +0100 Subject: [PATCH 1/4] Send the real Shift state to the prediction worker The worker saw only a yes/no shift flag, so Shift once and Shift locked both uppercased the whole suggestion: Shift once before "water" inserted "WATER". The keyboard now reports off, once or locked. Caps or Shift locked uppercase the whole completion and cancel each other as on typed letters. Before any letter of the word is typed, Shift once changes only the first letter, as it would the next typed letter, and a sentence start capitalises it without a modifier. After typed letters a pending Shift once is left for the next letter. Closes #907 Co-Authored-By: Claude Fable 5.1 --- docs/word-prediction.md | 2 +- src-tauri/src/prediction/mod.rs | 2 +- src-tauri/src/prediction/worker.rs | 162 ++++++++++++++++++++++------- src-tauri/src/scan_keyboard.rs | 22 +++- 4 files changed, 142 insertions(+), 46 deletions(-) diff --git a/docs/word-prediction.md b/docs/word-prediction.md index a80683ec..a5295629 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -14,7 +14,7 @@ One Word prediction setting controls an offline SmolLM2-135M int8 ONNX model. Th For a first typed prefix, a fixed local context keeps the model from favoring website names at the start of a document; it adds no user text. The model loads in the worker while keyboard input remains available. Suggestions are blank until loading finishes. A passive badge beside the scan prompt distinguishes loading, a ready keyboard awaiting typed context, no matching suggestions, available suggestions, paused activity tracking, and prediction failure. It never shows typed text and is not a scan target. If loading or inference fails, or a call exceeds 1.5 seconds, the keyboard continues accepting input and offers **Retry predictions** in its toolbar. Retry restarts only the prediction worker, clears its private text context, and leaves the keyboard open; type a new prefix afterward. A 400 ms search budget bounds candidate exploration. A clipped buffer with no complete earlier word yields no suggestions. -Candidates contain ASCII letters and apostrophes and must have sufficient model probability. There is no vocabulary filter: any word the model finds likely can be suggested, including swearing, because the person typing chose it. Single-letter candidates are limited to “a” and “I”. Up to five suggestions are shown: the first, third and fifth are the most likely single words, and the second and fourth are the two most likely two-word phrases that begin with one of the top three words, ranked by the probability of the pair. When fewer phrases are found within an extra 200 ms, single words fill the remaining slots, and the other way round. Accepting a phrase inserts the rest of its first word, a space, the second word and a trailing space. Ordinary words use lowercase; mixed-case names retain model casing. Shift, Caps and sentence capitalization are applied when the keyboard displays a candidate. +Candidates contain ASCII letters and apostrophes and must have sufficient model probability. There is no vocabulary filter: any word the model finds likely can be suggested, including swearing, because the person typing chose it. Single-letter candidates are limited to “a” and “I”. Up to five suggestions are shown: the first, third and fifth are the most likely single words, and the second and fourth are the two most likely two-word phrases that begin with one of the top three words, ranked by the probability of the pair. When fewer phrases are found within an extra 200 ms, single words fill the remaining slots, and the other way round. Accepting a phrase inserts the rest of its first word, a space, the second word and a trailing space. Ordinary words use lowercase; mixed-case names retain model casing. Casing is applied when the keyboard displays a candidate, and the label shows exactly what will be inserted. Caps, or Shift locked, uppercases the whole completion, and together they cancel as they do on typed letters. Before any letter of the word is typed, Shift once changes only the first letter, the way it would change the next typed letter, and a sentence start capitalises it without any modifier. After typed letters, a pending Shift once is left for the next letter and does not touch the completion. The model files are too large to commit. `npm run prediction-model` downloads them from a pinned upstream revision and verifies their SHA-256. The Tauri dev and build commands and CI run it automatically. Run it once before `cargo test` or `cargo clippy` in a fresh checkout. diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index aa83b06b..4e906891 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -148,7 +148,7 @@ struct Service { reset: bool, accept: Option<(u64, usize)>, accepting: bool, - case: Option<(bool, bool, bool)>, + case: Option<(worker::Shift, bool, bool)>, tracking: bool, } #[derive(Clone, Copy)] diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index e09cb4f9..0f4b497a 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -11,6 +11,15 @@ use unicode_segmentation::UnicodeSegmentation; pub const ARG: &str = "--switchify-prediction-worker"; pub const LIMIT: usize = 16384; +/// The keyboard's Shift as it applies to a suggestion. Once acts like it does +/// on a typed letter: it changes the first character only. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Shift { + Off, + Once, + Locked, +} + #[derive(Clone, Serialize, Deserialize)] pub enum Edit { Append(String), @@ -34,7 +43,7 @@ pub enum Request { generation: u64, edits: Vec, revision: u64, - shift: bool, + shift: Shift, caps: bool, }, Accept { @@ -102,7 +111,7 @@ pub struct Engine { batch: Option, suffixes: Vec, token: u64, - case: (bool, bool), + case: (Shift, bool), } impl Engine { pub fn new(database: Database, tracked: bool) -> Self { @@ -122,7 +131,7 @@ impl Engine { batch: None, suffixes: Vec::new(), token: 0, - case: (false, false), + case: (Shift::Off, false), } } fn clear(&mut self) { @@ -163,7 +172,7 @@ impl Engine { &mut self, edits: Vec, revision: u64, - shift: bool, + shift: Shift, caps: bool, ) -> Option { if revision < self.revision || (revision == self.revision && !edits.is_empty()) { @@ -228,15 +237,13 @@ impl Engine { if word[..offset].to_lowercase() != ctx.prefix.to_lowercase() { continue; } - let mut suffix = word[offset..].to_owned(); - if caps ^ shift { - suffix = suffix.to_uppercase(); - } else if ctx.prefix.is_empty() && ctx.sentence_start { - let mut chars = suffix.chars(); - if let Some(c) = chars.next() { - suffix = c.to_uppercase().collect::() + chars.as_str(); - } - } + let suffix = cased( + &word[offset..], + ctx.prefix.is_empty(), + ctx.sentence_start, + shift, + caps, + ); labels.push(ctx.prefix.clone() + &suffix); self.suffixes.push(suffix + " "); } @@ -304,6 +311,35 @@ impl Engine { } } } +/// The case a suggestion's suffix is inserted in. Caps, or Shift locked, +/// uppercases it all, and together they cancel like they do on typed letters. +/// With nothing typed yet, Shift once flips the first character's case the +/// way it would flip the next typed letter, and a sentence start capitalises +/// it without any modifier. After typed letters, a pending Shift once is left +/// for the next letter and does not touch the suggestion. +fn cased(suffix: &str, whole_word: bool, sentence_start: bool, shift: Shift, caps: bool) -> String { + let upper = caps ^ (shift == Shift::Locked); + let suffix = if upper { + suffix.to_uppercase() + } else { + suffix.to_owned() + }; + if !whole_word { + return suffix; + } + let first_upper = if shift == Shift::Once { + !upper + } else { + upper || sentence_start + }; + let mut chars = suffix.chars(); + match chars.next() { + Some(c) if first_upper => c.to_uppercase().collect::() + chars.as_str(), + Some(c) => c.to_lowercase().collect::() + chars.as_str(), + None => suffix, + } +} + /// The five suggestion slots: words in the first, third and fifth, phrases /// in the second and fourth. When one kind runs short the other fills in. pub fn interleave(words: Vec, phrases: Vec) -> Vec { @@ -398,25 +434,25 @@ mod tests { ); assert_eq!(interleave(w(&[]), w(&["a b"])), w(&["a b"])); let mut e = engine(); - let b = e.query(vec![append("wa")], 1, false, false).unwrap(); + let b = e.query(vec![append("wa")], 1, Shift::Off, false).unwrap(); assert_eq!(b.words, w(&["water", "water is", "waffle", "walk"])); assert_eq!(e.accept(b.token, 1).unwrap(), "ter is "); - let upper = e.query(vec![], 1, false, true).unwrap(); + let upper = e.query(vec![], 1, Shift::Off, true).unwrap(); assert_eq!(upper.words[1], "waTER IS"); } #[test] fn first_letter_and_completion_chain_use_only_buffer() { let mut e = engine(); - let first = e.query(vec![append("w")], 1, false, false).unwrap(); + let first = e.query(vec![append("w")], 1, Shift::Off, false).unwrap(); assert!(first.words.iter().any(|w| w == "water")); - let b = e.query(vec![append("a")], 2, false, false).unwrap(); + let b = e.query(vec![append("a")], 2, Shift::Off, false).unwrap(); assert_eq!(b.words[0], "water"); let suffix = e.accept(b.token, 0).unwrap(); assert_eq!(suffix, "ter "); assert!(e.accept(b.token, 0).is_none()); - e.query(vec![append(&suffix)], 3, false, false); + e.query(vec![append(&suffix)], 3, Shift::Off, false); assert_eq!(e.buffer, "water "); - e.query(vec![edit(Edit::Reset), append("wa")], 4, false, false) + e.query(vec![edit(Edit::Reset), append("wa")], 4, Shift::Off, false) .unwrap(); assert_eq!(e.buffer, "wa"); } @@ -426,26 +462,26 @@ mod tests { // A Switchify edit at 10 and external activity at 11 both precede // observer startup at 12. Only typing after startup is trustworthy. e.last_activity = || 12; - e.query(vec![append("wa")], 1, false, false); + e.query(vec![append("wa")], 1, Shift::Off, false); assert!(e.buffer.is_empty()); let mut fresh = append("w"); fresh.time = 13; - assert!(e.query(vec![fresh], 2, false, false).is_some()); + assert!(e.query(vec![fresh], 2, Shift::Off, false).is_some()); assert_eq!(e.buffer, "w"); } #[test] fn queued_edits_are_scoped_to_window_and_external_activity() { let mut e = engine(); - let b = e.query(vec![append("wa")], 1, false, false).unwrap(); + let b = e.query(vec![append("wa")], 1, Shift::Off, false).unwrap(); e.observe = || (1, true); e.last_activity = || 11; assert!(e.accept(b.token, 0).is_none()); let mut fresh = append("he"); fresh.time = 12; - e.query(vec![append("ter"), fresh], 2, false, false); + e.query(vec![append("ter"), fresh], 2, Shift::Off, false); assert_eq!(e.buffer, "he"); e.foreground = || Ok(2); - e.query(vec![append("wa")], 3, false, false); + e.query(vec![append("wa")], 3, Shift::Off, false); assert!(e.buffer.is_empty()); } #[test] @@ -458,24 +494,24 @@ mod tests { edit(Edit::Backspace), ], 1, - false, + Shift::Off, false, ); assert_eq!(e.buffer, "wa"); for rev in 2..10 { - e.query(vec![append(&" word".repeat(90))], rev, false, false); + e.query(vec![append(&" word".repeat(90))], rev, Shift::Off, false); } assert!(e.buffer.chars().count() <= 512); assert!(e.clipped); - e.query(vec![edit(Edit::Reset)], 10, false, false); + e.query(vec![edit(Edit::Reset)], 10, Shift::Off, false); assert!(e.buffer.is_empty()); - e.query(vec![append("wa"); 513], 11, false, false); + e.query(vec![append("wa"); 513], 11, Shift::Off, false); assert!(e.buffer.is_empty()); } #[test] fn stale_revisions_health_and_foreground_races_reject_acceptance() { let mut e = engine(); - let b = e.query(vec![append("wa")], 1, false, false).unwrap(); + let b = e.query(vec![append("wa")], 1, Shift::Off, false).unwrap(); assert!(matches!( e.respond(Request::Accept { generation: 0, @@ -486,30 +522,76 @@ mod tests { Response::Insert { text: None, .. } )); assert!(e.buffer.is_empty()); - e.query(vec![append("wa")], 2, false, false); - assert!(e.query(vec![append("wa")], 2, false, false).is_none()); - let b = e.query(vec![append("wa")], 3, false, false).unwrap(); + e.query(vec![append("wa")], 2, Shift::Off, false); + assert!(e.query(vec![append("wa")], 2, Shift::Off, false).is_none()); + let b = e.query(vec![append("wa")], 3, Shift::Off, false).unwrap(); e.observe = || (0, false); assert!(e.accept(b.token, 0).is_none()); - assert!(e.query(vec![append("wa")], 4, false, false).is_none()); + assert!(e.query(vec![append("wa")], 4, Shift::Off, false).is_none()); } #[test] fn casing_and_unchanged_context_keep_choice_identity() { let mut e = engine(); - let b = e.query(vec![append("Wa")], 1, false, false).unwrap(); + let b = e.query(vec![append("Wa")], 1, Shift::Off, false).unwrap(); assert_eq!(b.words[0], "Water"); - assert_eq!(e.query(vec![], 1, false, false).unwrap().token, b.token); - let upper = e.query(vec![], 1, false, true).unwrap(); + assert_eq!( + e.query(vec![], 1, Shift::Off, false).unwrap().token, + b.token + ); + let upper = e.query(vec![], 1, Shift::Off, true).unwrap(); assert_eq!(upper.words[0], "WaTER"); assert_ne!(upper.token, b.token); } #[test] fn sentence_start_prediction_uses_title_case_without_manual_shift() { let mut e = engine(); - let suggestions = e.query(vec![append("Done! ")], 1, false, false).unwrap(); + let suggestions = e + .query(vec![append("Done! ")], 1, Shift::Off, false) + .unwrap(); assert_eq!(suggestions.words[0], "Water"); - let manual_shift = e.query(vec![], 1, true, false).unwrap(); - assert_eq!(manual_shift.words[0], "WATER"); + let locked = e.query(vec![], 1, Shift::Locked, false).unwrap(); + assert_eq!(locked.words[0], "WATER"); + let once = e.query(vec![], 1, Shift::Once, false).unwrap(); + assert_eq!(once.words[0], "Water"); + } + #[test] + fn shift_once_changes_only_the_first_letter_and_only_before_typing() { + let mut e = engine(); + e.query(vec![append("Send ")], 1, Shift::Off, false) + .unwrap(); + assert_eq!( + e.query(vec![], 1, Shift::Off, false).unwrap().words[0], + "water" + ); + assert_eq!( + e.query(vec![], 1, Shift::Once, false).unwrap().words[0], + "Water" + ); + assert_eq!( + e.query(vec![], 1, Shift::Locked, false).unwrap().words[0], + "WATER" + ); + assert_eq!( + e.query(vec![], 1, Shift::Off, true).unwrap().words[0], + "WATER" + ); + assert_eq!( + e.query(vec![], 1, Shift::Locked, true).unwrap().words[0], + "water" + ); + assert_eq!( + e.query(vec![], 1, Shift::Once, true).unwrap().words[0], + "wATER" + ); + e.query(vec![append("wa")], 2, Shift::Off, false).unwrap(); + assert_eq!( + e.query(vec![], 2, Shift::Once, false).unwrap().words[0], + "water" + ); + assert_eq!( + e.query(vec![], 2, Shift::Locked, false).unwrap().words[0], + "waTER" + ); } #[test] fn private_frames_are_bounded() { @@ -518,7 +600,7 @@ mod tests { generation: 0, revision: 1, edits: vec![append(&"x".repeat(LIMIT))], - shift: false, + shift: Shift::Off, caps: false, }; assert!(send(&mut Vec::new(), &request).is_err()); diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index d1846317..65f1dc72 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -461,8 +461,16 @@ impl Keyboard { caps: self.caps, } } - pub fn prediction_shift(&self) -> bool { - self.modifiers[0] != Modifier::Off && !self.auto_shift + /// Shift as a suggestion should see it. An automatic Shift after a + /// sentence end is not reported: the worker capitalises there itself. + pub fn prediction_shift(&self) -> crate::prediction::worker::Shift { + use crate::prediction::worker::Shift; + match self.modifiers[0] { + Modifier::Off => Shift::Off, + Modifier::Once if self.auto_shift => Shift::Off, + Modifier::Once => Shift::Once, + Modifier::Locked => Shift::Locked, + } } fn clear_auto_capital(&mut self) { if self.auto_shift { @@ -1042,7 +1050,10 @@ mod tests { keyboard.choose_with_context(Key::Character('.', '>'), context(1)); keyboard.succeeded_with_context(context(1)); assert_eq!(keyboard.modifiers[0], Modifier::Once); - assert!(!keyboard.prediction_shift()); + assert_eq!( + keyboard.prediction_shift(), + crate::prediction::worker::Shift::Off + ); assert_eq!(keyboard.label(Key::Modifier(0)), "Shift\nNext key"); let screen = Rect { x: 0.0, @@ -1081,7 +1092,10 @@ mod tests { assert!(!keyboard.capitalize_next); keyboard.choose_with_context(Key::Modifier(0), context(1)); assert_eq!(keyboard.modifiers[0], Modifier::Once); - assert!(keyboard.prediction_shift()); + assert_eq!( + keyboard.prediction_shift(), + crate::prediction::worker::Shift::Once + ); } #[test] From fad10291ac1ef4e659fc938ab2b1565d18fa4e1c Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 25 Sep 2026 20:02:03 +0100 Subject: [PATCH 2/4] Keep a name's capital unless Shift once flips it Lowering the first letter whenever it was not to be capitalised also lowered names like WhatsApp in an unmodified mid-sentence suggestion. Only an explicit Shift once flip lowers a first letter now. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/prediction/database.rs | 2 +- src-tauri/src/prediction/worker.rs | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/prediction/database.rs b/src-tauri/src/prediction/database.rs index 119bea6d..d3365b22 100644 --- a/src-tauri/src/prediction/database.rs +++ b/src-tauri/src/prediction/database.rs @@ -104,7 +104,7 @@ impl Predict for FakeModel { _deadline: Instant, _phrase_deadline: Instant, ) -> Result { - let words: Vec = ["water", "waffle", "walk"] + let words: Vec = ["water", "waffle", "walk", "WhatsApp"] .into_iter() .filter(|w| w.starts_with(&prefix.to_lowercase())) .map(str::to_owned) diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 0f4b497a..ef8a5e0f 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -332,11 +332,13 @@ fn cased(suffix: &str, whole_word: bool, sentence_start: bool, shift: Shift, cap } else { upper || sentence_start }; + // Only an explicit flip lowers a first letter; otherwise the model's own + // casing, such as a name's capital, stays. let mut chars = suffix.chars(); match chars.next() { Some(c) if first_upper => c.to_uppercase().collect::() + chars.as_str(), - Some(c) => c.to_lowercase().collect::() + chars.as_str(), - None => suffix, + Some(c) if shift == Shift::Once => c.to_lowercase().collect::() + chars.as_str(), + _ => suffix, } } @@ -583,6 +585,10 @@ mod tests { e.query(vec![], 1, Shift::Once, true).unwrap().words[0], "wATER" ); + assert_eq!( + e.query(vec![], 1, Shift::Off, false).unwrap().words[4], + "WhatsApp" + ); e.query(vec![append("wa")], 2, Shift::Off, false).unwrap(); assert_eq!( e.query(vec![], 2, Shift::Once, false).unwrap().words[0], From 1865b39c2f05fac0eb38815ae20a1af6f885808b Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 25 Sep 2026 20:04:54 +0100 Subject: [PATCH 3/4] Test modifiers at a sentence start and say they mirror typing Co-Authored-By: Claude Fable 5.1 --- docs/word-prediction.md | 2 +- src-tauri/src/prediction/worker.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/word-prediction.md b/docs/word-prediction.md index a5295629..8120bdc4 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -14,7 +14,7 @@ One Word prediction setting controls an offline SmolLM2-135M int8 ONNX model. Th For a first typed prefix, a fixed local context keeps the model from favoring website names at the start of a document; it adds no user text. The model loads in the worker while keyboard input remains available. Suggestions are blank until loading finishes. A passive badge beside the scan prompt distinguishes loading, a ready keyboard awaiting typed context, no matching suggestions, available suggestions, paused activity tracking, and prediction failure. It never shows typed text and is not a scan target. If loading or inference fails, or a call exceeds 1.5 seconds, the keyboard continues accepting input and offers **Retry predictions** in its toolbar. Retry restarts only the prediction worker, clears its private text context, and leaves the keyboard open; type a new prefix afterward. A 400 ms search budget bounds candidate exploration. A clipped buffer with no complete earlier word yields no suggestions. -Candidates contain ASCII letters and apostrophes and must have sufficient model probability. There is no vocabulary filter: any word the model finds likely can be suggested, including swearing, because the person typing chose it. Single-letter candidates are limited to “a” and “I”. Up to five suggestions are shown: the first, third and fifth are the most likely single words, and the second and fourth are the two most likely two-word phrases that begin with one of the top three words, ranked by the probability of the pair. When fewer phrases are found within an extra 200 ms, single words fill the remaining slots, and the other way round. Accepting a phrase inserts the rest of its first word, a space, the second word and a trailing space. Ordinary words use lowercase; mixed-case names retain model casing. Casing is applied when the keyboard displays a candidate, and the label shows exactly what will be inserted. Caps, or Shift locked, uppercases the whole completion, and together they cancel as they do on typed letters. Before any letter of the word is typed, Shift once changes only the first letter, the way it would change the next typed letter, and a sentence start capitalises it without any modifier. After typed letters, a pending Shift once is left for the next letter and does not touch the completion. +Candidates contain ASCII letters and apostrophes and must have sufficient model probability. There is no vocabulary filter: any word the model finds likely can be suggested, including swearing, because the person typing chose it. Single-letter candidates are limited to “a” and “I”. Up to five suggestions are shown: the first, third and fifth are the most likely single words, and the second and fourth are the two most likely two-word phrases that begin with one of the top three words, ranked by the probability of the pair. When fewer phrases are found within an extra 200 ms, single words fill the remaining slots, and the other way round. Accepting a phrase inserts the rest of its first word, a space, the second word and a trailing space. Ordinary words use lowercase; mixed-case names retain model casing. Casing is applied when the keyboard displays a candidate, and the label shows exactly what will be inserted. Caps, or Shift locked, uppercases the whole completion, and together they cancel as they do on typed letters. Before any letter of the word is typed, Shift once changes only the first letter, the way it would change the next typed letter, and a sentence start capitalises it without any modifier. Modifiers still mirror typing at a sentence start, so Shift once under Caps gives a lowercase first letter. After typed letters, a pending Shift once is left for the next letter and does not touch the completion. The model files are too large to commit. `npm run prediction-model` downloads them from a pinned upstream revision and verifies their SHA-256. The Tauri dev and build commands and CI run it automatically. Run it once before `cargo test` or `cargo clippy` in a fresh checkout. diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index ef8a5e0f..85672da9 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -555,6 +555,21 @@ mod tests { assert_eq!(locked.words[0], "WATER"); let once = e.query(vec![], 1, Shift::Once, false).unwrap(); assert_eq!(once.words[0], "Water"); + // Modifiers still mirror typing at a sentence start: Caps with Shift + // locked cancel to lowercase letters, and Shift once under Caps + // lowers only the first one, exactly as the keys would type them. + assert_eq!( + e.query(vec![], 1, Shift::Off, true).unwrap().words[0], + "WATER" + ); + assert_eq!( + e.query(vec![], 1, Shift::Locked, true).unwrap().words[0], + "Water" + ); + assert_eq!( + e.query(vec![], 1, Shift::Once, true).unwrap().words[0], + "wATER" + ); } #[test] fn shift_once_changes_only_the_first_letter_and_only_before_typing() { From 77b02ee7fc9fad881a4cb832223c01f43a43297c Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 25 Sep 2026 20:13:25 +0100 Subject: [PATCH 4/4] Say exactly what Shift once under Caps does at a sentence start Co-Authored-By: Claude Fable 5.1 --- docs/word-prediction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/word-prediction.md b/docs/word-prediction.md index 8120bdc4..fe957c2b 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -14,7 +14,7 @@ One Word prediction setting controls an offline SmolLM2-135M int8 ONNX model. Th For a first typed prefix, a fixed local context keeps the model from favoring website names at the start of a document; it adds no user text. The model loads in the worker while keyboard input remains available. Suggestions are blank until loading finishes. A passive badge beside the scan prompt distinguishes loading, a ready keyboard awaiting typed context, no matching suggestions, available suggestions, paused activity tracking, and prediction failure. It never shows typed text and is not a scan target. If loading or inference fails, or a call exceeds 1.5 seconds, the keyboard continues accepting input and offers **Retry predictions** in its toolbar. Retry restarts only the prediction worker, clears its private text context, and leaves the keyboard open; type a new prefix afterward. A 400 ms search budget bounds candidate exploration. A clipped buffer with no complete earlier word yields no suggestions. -Candidates contain ASCII letters and apostrophes and must have sufficient model probability. There is no vocabulary filter: any word the model finds likely can be suggested, including swearing, because the person typing chose it. Single-letter candidates are limited to “a” and “I”. Up to five suggestions are shown: the first, third and fifth are the most likely single words, and the second and fourth are the two most likely two-word phrases that begin with one of the top three words, ranked by the probability of the pair. When fewer phrases are found within an extra 200 ms, single words fill the remaining slots, and the other way round. Accepting a phrase inserts the rest of its first word, a space, the second word and a trailing space. Ordinary words use lowercase; mixed-case names retain model casing. Casing is applied when the keyboard displays a candidate, and the label shows exactly what will be inserted. Caps, or Shift locked, uppercases the whole completion, and together they cancel as they do on typed letters. Before any letter of the word is typed, Shift once changes only the first letter, the way it would change the next typed letter, and a sentence start capitalises it without any modifier. Modifiers still mirror typing at a sentence start, so Shift once under Caps gives a lowercase first letter. After typed letters, a pending Shift once is left for the next letter and does not touch the completion. +Candidates contain ASCII letters and apostrophes and must have sufficient model probability. There is no vocabulary filter: any word the model finds likely can be suggested, including swearing, because the person typing chose it. Single-letter candidates are limited to “a” and “I”. Up to five suggestions are shown: the first, third and fifth are the most likely single words, and the second and fourth are the two most likely two-word phrases that begin with one of the top three words, ranked by the probability of the pair. When fewer phrases are found within an extra 200 ms, single words fill the remaining slots, and the other way round. Accepting a phrase inserts the rest of its first word, a space, the second word and a trailing space. Ordinary words use lowercase; mixed-case names retain model casing. Casing is applied when the keyboard displays a candidate, and the label shows exactly what will be inserted. Caps, or Shift locked, uppercases the whole completion, and together they cancel as they do on typed letters. Before any letter of the word is typed, Shift once changes only the first letter, the way it would change the next typed letter, and a sentence start capitalises it without any modifier. Shift once under Caps gives a lowercase first letter there, as it would on a typed letter. After typed letters, a pending Shift once is left for the next letter and does not touch the completion. The model files are too large to commit. `npm run prediction-model` downloads them from a pinned upstream revision and verifies their SHA-256. The Tauri dev and build commands and CI run it automatically. Run it once before `cargo test` or `cargo clippy` in a fresh checkout.