diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs
--- a/crates/ui-egui/src/keytips.rs
+++ b/crates/ui-egui/src/keytips.rs
@@ -460,7 +460,10 @@ fn letters_for(app: &WordApp, tab: &str) -> Vec<String> {
             out[i] = Some(c.to_string());
         }
     }
-    // Pass 2: name-derived letters for everything still unassigned.
+    // Pass 2: name-derived letters for everything still unassigned. A control that finds no free
+    // letter is left for the two-letter pass below.
+    let mut named: Vec<usize> = Vec::new();
+    let mut overflow: Vec<usize> = Vec::new();
     for (i, ctl) in ctrls.iter().enumerate() {
         if out[i].is_some() {
             continue;
@@ -474,21 +477,30 @@ fn letters_for(app: &WordApp, tab: &str) -> Vec<String> {
             Some(c) => {
                 used.push(c);
                 out[i] = Some(c.to_string());
+                named.push(i);
             }
-            None => {
-                // Two-letter badge fallback: a tab has at most ~20 controls, so this succeeds;
-                // the "?n" form keeps assignment total rather than panicking.
-                let two = ('A'..='Z').flat_map(|a| ('A'..='Z').map(move |b| format!("{a}{b}"))).find(|s| {
-                    let mut ch = s.chars();
-                    let (a, b) = (ch.next().unwrap_or('?'), ch.next().unwrap_or('?'));
-                    !out.iter().flatten().any(|o| o == s) && !reserved.contains(&a) && !reserved.contains(&b)
-                });
-                let two = two.unwrap_or_else(|| format!("?{}", out.iter().flatten().count()));
-                used.extend(two.chars());
-                out[i] = Some(two);
-            }
+            None => overflow.push(i),
         }
     }
+    // Pass 3: two-letter badges. No badge may be the start of another (Word's rule), or the short
+    // one runs the moment its letter is typed and the long one can never be completed (#4498).
+    // So the first letter of every two-letter badge is a letter no single-letter badge uses: the
+    // letters of the last name-derived badges are given up for that until the overflow fits.
+    let seconds: Vec<char> = ('A'..='Z').filter(|c| !reserved.contains(c)).collect();
+    let mut firsts: Vec<char> = Vec::new();
+    let mut two: Vec<usize> = overflow;
+    while !two.is_empty() && firsts.len() * seconds.len() < two.len() {
+        let Some(i) = named.pop() else { break };
+        let letter = out[i].take().and_then(|l| l.chars().next());
+        firsts.extend(letter);
+        two.push(i);
+    }
+    two.sort_unstable();
+    let mut labels = firsts.iter().flat_map(|a| seconds.iter().map(move |b| format!("{a}{b}")));
+    for i in two {
+        // "?n" keeps the assignment total, with nothing to run it, rather than panicking.
+        out[i] = Some(labels.next().unwrap_or_else(|| format!("?{i}")));
+    }
     out.into_iter().map(Option::unwrap_or_default).collect()
 }
 
@@ -626,6 +638,14 @@ pub fn handle_key(app: &mut WordApp, key: egui::Key) -> bool {
                         let tab = app.ui.tab.clone();
                         let ls = letters_for(app, &tab);
                         let ctrls = controls(&tab);
+                        // Badges are never the start of one another, so a complete one is the one.
+                        let name = format!("{}{name}", app.ui.keytip_prefix);
+                        // The start of a two-letter badge: wait for its next letter, with
+                        // KeyTips still on and only the badges it can complete showing (#4498).
+                        if !ls.contains(&name) && ls.iter().any(|l| l.starts_with(&name)) {
+                            app.ui.keytip_prefix = name;
+                            return true;
+                        }
                         let mut hit: Option<Target> = None;
                         for (ctl, l) in ctrls.into_iter().zip(ls) {
                             if l == name {
@@ -846,6 +866,9 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context, ui: &mut Ui) {
         return;
     }
     let cmds: Vec<(Rect, String)> = ui.data(|d| d.get_temp::<Vec<(Rect, String)>>(rects_id())).unwrap_or_default();
+    // After the start of a two-letter badge only the badges it can still complete show.
+    let prefix = app.ui.keytip_prefix.clone();
+    let cmds = cmds.into_iter().filter(|(_, l)| app.ui.keytips != Phase::Commands || l.starts_with(&prefix));
     for (r, l) in tabs.into_iter().chain(cmds) {
         badge(ui, r, &l);
     }
diff --git a/crates/ui-egui/src/keytips/tests.rs b/crates/ui-egui/src/keytips/tests.rs
--- a/crates/ui-egui/src/keytips/tests.rs
+++ b/crates/ui-egui/src/keytips/tests.rs
@@ -145,6 +145,60 @@ fn letters_are_unique_inside_every_tab() {
     }
 }
 
+/// #4498: no badge is the start of another, on any tab, or the short one runs as soon as its
+/// letter is typed and the long one can never be reached.
+#[test]
+fn no_badge_starts_another_badge() {
+    let mut a = app();
+    a.run("insert.table", serde_json::json!({"rows": 2, "cols": 2})).unwrap();
+    let (_, cmds) = letters(&a);
+    let mut two_letter = 0;
+    for (tab, ls) in &cmds {
+        for l in ls {
+            assert!(!l.is_empty() && !l.starts_with('?'), "{tab}: a control without a badge ({l:?})");
+            two_letter += usize::from(l.chars().count() > 1);
+            let others = ls.iter().filter(|o| *o != l && o.starts_with(l.as_str())).count();
+            assert_eq!(others, 0, "{tab}: badge {l} starts another badge in {ls:?}");
+        }
+    }
+    assert!(two_letter > 0, "Home has more controls than letters, so some badges take two");
+}
+
+/// #4498: the first letter of a two-letter badge keeps KeyTips on and waits; the second runs
+/// the command. A letter that completes nothing ends KeyTips and runs nothing.
+#[test]
+fn a_two_letter_badge_is_completed_by_its_second_letter() {
+    let key = |c: char| egui::Key::from_name(&c.to_string()).unwrap();
+    let mut a = app();
+    a.canvas.focused = false;
+    a.run("ui.tab", serde_json::json!({"tab": "Home"})).unwrap();
+    let ls = letters_for(&a, "Home");
+    let long: Vec<&String> = ls.iter().filter(|l| l.chars().count() == 2).collect();
+    let target = *long.first().expect("Home has two-letter badges");
+    let (first, second) = (target.chars().next().unwrap(), target.chars().nth(1).unwrap());
+    let sibling = long.iter().find(|l| l.starts_with(first) && *l != &target).map(|l| l.chars().nth(1).unwrap());
+    let before = a.session.doc.plain_text(wordcraft_doc::StoryRef::Body);
+    activate(&mut a);
+    assert!(handle_key(&mut a, egui::Key::H));
+    assert_eq!(a.ui.keytips, Phase::Commands);
+    assert!(handle_key(&mut a, key(first)));
+    assert_eq!((a.ui.keytips, a.ui.keytip_prefix.as_str()), (Phase::Commands, first.to_string().as_str()), "waits for the second letter");
+    assert!(handle_key(&mut a, key(second)));
+    assert_eq!(a.ui.keytips, Phase::Off, "the second letter completes it");
+    assert!(a.ui.keytip_prefix.is_empty());
+    assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), before, "no letter reached the document");
+    // The first letter then a second that completes nothing: off, and nothing runs.
+    let unused = ('A'..='Z').find(|c| !ls.contains(&format!("{first}{c}"))).unwrap();
+    a.run("ui.tab", serde_json::json!({"tab": "Home"})).unwrap();
+    activate(&mut a);
+    handle_key(&mut a, egui::Key::H);
+    handle_key(&mut a, key(first));
+    assert!(handle_key(&mut a, key(unused)));
+    assert_eq!((a.ui.keytips, a.ui.keytip_prefix.as_str()), (Phase::Off, ""));
+    assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), before);
+    assert!(sibling.is_none() || sibling != Some(second), "siblings of a prefix have distinct second letters");
+}
+
 #[test]
 fn keytips_never_reuse_another_commands_shortcut_letter() {
     let a = app();
