From 75c61bd3cb27f6ecb261822d70b418ca03e79d43 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 30 Apr 2026 14:38:03 +0900 Subject: [PATCH] =?UTF-8?q?TUI=E8=A3=9C=E5=AE=8C=E3=81=AE=E7=B4=B0?= =?UTF-8?q?=E3=81=8B=E3=81=84=E6=8C=99=E5=8B=95=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/protocol/src/lib.rs | 3 +- crates/tui/src/app.rs | 346 +++++++++++++++++++++++++++++++++++-- crates/tui/src/input.rs | 28 ++- crates/tui/src/main.rs | 34 +++- 4 files changed, 385 insertions(+), 26 deletions(-) diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 3fa8d64b..48ab8e15 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -657,7 +657,8 @@ mod tests { assert_eq!(parsed["data"]["entries"][0]["value"], "clear"); // is_dir defaults to false on inbound payloads that omit it. - let inbound = r#"{"event":"completions","data":{"kind":"file","entries":[{"value":"main.rs"}]}}"#; + let inbound = + r#"{"event":"completions","data":{"kind":"file","entries":[{"value":"main.rs"}]}}"#; let decoded: Event = serde_json::from_str(inbound).unwrap(); match decoded { Event::Completions { kind, entries } => { diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 3ae2cfc7..fcd673c9 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -157,24 +157,119 @@ impl App { self.completion = None; } - /// Confirm the currently selected completion entry by replacing the - /// in-flight token with a chip atom. Returns `true` when something - /// was confirmed; `false` when there was no active candidate (so - /// the caller can fall through to the default key behaviour). - pub fn confirm_completion(&mut self) -> bool { + /// Tab path: insert the popup-selected entry's value (with a + /// trailing `/` when it's a directory) as raw text replacing the + /// in-flight `@` portion. The popup state is preserved so + /// the re-evaluated trigger can fetch fresh candidates for the new + /// prefix (drill-in for directories, narrow-to-one for files). + /// Returns the follow-up `Method::ListCompletions` to send when + /// the new prefix differs from the old one. + pub fn apply_completion_text(&mut self) -> Option { + let state = self.completion.as_ref()?; + if state.entries.is_empty() { + return None; + } + let entry = &state.entries[state.selected]; + let text = if entry.is_dir { + format!("{}/", entry.value) + } else { + entry.value.clone() + }; + // `prefix_start` indexes the sigil atom; the text we want to + // replace lives just after it (sigil itself stays). + let typed_start = state.prefix_start + 1; + self.input.replace_with_text_at(typed_start, &text); + self.refresh_completion() + } + + /// Space path: replace the `@` range with a chip atom and + /// clear the popup if `prefix` (= the text the user has typed + /// after the sigil) resolves to a confirmable target. Three + /// matching modes: + /// + /// 1. **Direct value match**: some entry's `value` equals `prefix` + /// (covers files and slash-less directory form). + /// 2. **Slashed directory match**: some directory entry's + /// `value + "/"` equals `prefix` (the form Tab inserts). + /// 3. **Drilled-into-directory match**: `prefix` ends with `/` + /// and at least one entry lives under it. + /// + /// Directory chips always carry a trailing `/` so the rendered + /// label reads `@crates/`. + /// + /// `selected` is intentionally ignored — terminating with a + /// space is a typed-based "I'm done with this token" signal, + /// so a race-y top entry shouldn't block confirmation when the + /// typed text matches another entry. + pub fn chipify_completion_if_exact_match(&mut self) -> bool { + let Some(state) = self.completion.as_ref() else { + return false; + }; + let direct = state + .entries + .iter() + .find(|e| { + state.prefix == e.value || (e.is_dir && state.prefix == format!("{}/", e.value)) + }) + .map(|e| { + if e.is_dir { + format!("{}/", e.value) + } else { + e.value.clone() + } + }); + let drilled = (direct.is_none() && state.prefix.ends_with('/')) + .then(|| { + state + .entries + .iter() + .any(|e| e.value.starts_with(&state.prefix)) + .then(|| state.prefix.clone()) + }) + .flatten(); + let Some(value) = direct.or(drilled) else { + return false; + }; + let kind = state.kind; + let start = state.prefix_start; + match kind { + CompletionKind::File => self.input.replace_with_file_ref(start, value), + CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value), + CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, value), + } + self.completion = None; + true + } + + /// Enter path: commit the currently *selected* popup entry, + /// regardless of how much of its value the user has typed. This + /// is the popup-UI sense of "Enter accepts the highlighted + /// suggestion" — partial typing like `@README.` followed by + /// Enter should chip when the popup is on `README.md`. + /// + /// Files (and Knowledge / Workflow entries, which have no dir + /// concept) chipify here. Directory file entries return `false` + /// so the caller can fall through to `apply_completion_text` + /// for drill-in — chip-ifying a directory on Enter would strand + /// the user with no way to inspect children. + pub fn chipify_selected_completion_if_committable(&mut self) -> bool { let Some(state) = self.completion.as_ref() else { return false; }; if state.entries.is_empty() { return false; } - let entry = state.entries[state.selected].clone(); + let entry = &state.entries[state.selected]; + if state.kind == CompletionKind::File && entry.is_dir { + return false; + } let kind = state.kind; let start = state.prefix_start; + let value = entry.value.clone(); match kind { - CompletionKind::File => self.input.replace_with_file_ref(start, entry.value), - CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, entry.value), - CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, entry.value), + CompletionKind::File => self.input.replace_with_file_ref(start, value), + CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value), + CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, value), } self.completion = None; true @@ -808,18 +903,58 @@ mod completion_flow_tests { } #[test] - fn confirm_replaces_token_with_chip_and_clears_popup() { + fn tab_inserts_entry_value_as_text_for_file() { let mut app = App::new("test".into()); for c in "@s".chars() { app.insert_char(c); } let _ = app.refresh_completion(); - // Pretend the Pod replied with a single candidate. app.completion.as_mut().unwrap().entries = vec![CompletionEntry { value: "src/main.rs".into(), is_dir: false, }]; - assert!(app.confirm_completion()); + // Tab path: text inserted, popup re-triggered with new prefix + // (still File kind since the typed range stays after `@`). + let _ = app.apply_completion_text(); + // The input now reads `@src/main.rs` as plain Char atoms; no + // chip yet. + let segs = app.input.submit_segments(); + assert_eq!(segs.len(), 1); + assert!(matches!(&segs[0], Segment::Text { content } if content == "@src/main.rs")); + assert!(app.completion.is_some()); + } + + #[test] + fn tab_appends_trailing_slash_for_directory() { + let mut app = App::new("test".into()); + for c in "@cr".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![CompletionEntry { + value: "crates".into(), + is_dir: true, + }]; + let _ = app.apply_completion_text(); + // Typed prefix advances to `crates/` so the next query can + // descend into the directory. + assert_eq!(app.completion.as_ref().unwrap().prefix, "crates/"); + let segs = app.input.submit_segments(); + assert!(matches!(&segs[0], Segment::Text { content } if content == "@crates/")); + } + + #[test] + fn space_chipifies_on_exact_match() { + let mut app = App::new("test".into()); + for c in "@src/main.rs".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![CompletionEntry { + value: "src/main.rs".into(), + is_dir: false, + }]; + assert!(app.chipify_completion_if_exact_match()); assert!(app.completion.is_none()); let segs = app.input.submit_segments(); assert_eq!(segs.len(), 1); @@ -827,14 +962,197 @@ mod completion_flow_tests { } #[test] - fn confirm_with_no_entries_is_a_noop() { + fn space_does_not_chipify_on_partial_match() { + let mut app = App::new("test".into()); + for c in "@s".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![CompletionEntry { + value: "src/main.rs".into(), + is_dir: false, + }]; + // typed = "s", expected = "src/main.rs" → no match, no chip. + assert!(!app.chipify_completion_if_exact_match()); + let segs = app.input.submit_segments(); + assert_eq!(segs.len(), 1); + assert!(matches!(&segs[0], Segment::Text { content } if content == "@s")); + } + + #[test] + fn space_chipifies_directory_with_or_without_trailing_slash() { + // Slash-less typed form chipifies the directory; the chip's + // path keeps a trailing slash so the rendered label is `@crates/`. + let mut app = App::new("test".into()); + for c in "@crates".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![CompletionEntry { + value: "crates".into(), + is_dir: true, + }]; + assert!(app.chipify_completion_if_exact_match()); + let segs = app.input.submit_segments(); + assert!(matches!(&segs[0], Segment::FileRef { path } if path == "crates/")); + + // Slashed typed form (the shape Tab inserts) — same chip. + let mut app = App::new("test".into()); + for c in "@crates/".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![CompletionEntry { + value: "crates".into(), + is_dir: true, + }]; + assert!(app.chipify_completion_if_exact_match()); + let segs = app.input.submit_segments(); + assert!(matches!(&segs[0], Segment::FileRef { path } if path == "crates/")); + } + + #[test] + fn space_chipifies_directory_when_popup_shows_its_children() { + // `@crates/` is the form Tab leaves you in after picking a + // directory; the popup is showing the children of `crates/`. + // Hitting space at this point should chipify `crates`, not + // require the user to back up and remove the trailing slash. + let mut app = App::new("test".into()); + for c in "@crates/".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![ + CompletionEntry { + value: "crates/daemon".into(), + is_dir: true, + }, + CompletionEntry { + value: "crates/llm-worker".into(), + is_dir: true, + }, + ]; + assert!(app.chipify_completion_if_exact_match()); + let segs = app.input.submit_segments(); + assert!(matches!(&segs[0], Segment::FileRef { path } if path == "crates/")); + } + + #[test] + fn enter_does_not_chipify_directory_so_drill_in_works() { + // Enter on a selected directory entry must NOT chipify — + // otherwise the user can never drill into the dir to see + // its children. + let mut app = App::new("test".into()); + for c in "@crates".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![CompletionEntry { + value: "crates".into(), + is_dir: true, + }]; + assert!(!app.chipify_selected_completion_if_committable()); + // Popup is still active so the caller can fall through to + // apply_completion_text. + assert!(app.completion.is_some()); + } + + #[test] + fn enter_path_appends_trailing_space_after_file_chip() { + // Mirrors the main.rs Enter handler sequence: chipify the + // selected entry, then insert a space so the cursor is ready + // for the next token without a manual separator. + let mut app = App::new("test".into()); + for c in "@README.".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![CompletionEntry { + value: "README.md".into(), + is_dir: false, + }]; + assert!(app.chipify_selected_completion_if_committable()); + app.insert_char(' '); + let segs = app.input.submit_segments(); + assert_eq!(segs.len(), 2); + assert!(matches!(&segs[0], Segment::FileRef { path } if path == "README.md")); + assert!(matches!(&segs[1], Segment::Text { content } if content == " ")); + } + + #[test] + fn enter_chipifies_selected_file_even_when_typed_is_partial() { + // Enter respects the selected entry: typed text may be a + // prefix of the entry's value, but the popup-highlighted + // file should still chipify on Enter. + let mut app = App::new("test".into()); + for c in "@README.".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![CompletionEntry { + value: "README.md".into(), + is_dir: false, + }]; + assert!(app.chipify_selected_completion_if_committable()); + assert!(app.completion.is_none()); + let segs = app.input.submit_segments(); + assert!(matches!(&segs[0], Segment::FileRef { path } if path == "README.md")); + } + + #[test] + fn space_does_not_chipify_drilled_state_with_unrelated_entries() { + // Stale entries that don't live under the typed prefix should + // not satisfy the drilled-into-directory rule. + let mut app = App::new("test".into()); + for c in "@xyz/".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![CompletionEntry { + value: "crates/daemon".into(), + is_dir: true, + }]; + assert!(!app.chipify_completion_if_exact_match()); + let segs = app.input.submit_segments(); + assert!(matches!(&segs[0], Segment::Text { content } if content == "@xyz/")); + } + + #[test] + fn chipify_finds_match_outside_selected_index() { + // Regression guard for the race where a stale reply leaves a + // non-matching entry at index 0 but an entry deeper in the + // list does match the current typed text. + let mut app = App::new("test".into()); + for c in "@src/main.rs".chars() { + app.insert_char(c); + } + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![ + CompletionEntry { + value: "src/main.rs.bak".into(), + is_dir: false, + }, + CompletionEntry { + value: "src/main.rs".into(), + is_dir: false, + }, + ]; + // selected stays at 0 (the non-matching one) but find() should + // still locate the match. + assert!(app.chipify_completion_if_exact_match()); + let segs = app.input.submit_segments(); + assert!(matches!(&segs[0], Segment::FileRef { path } if path == "src/main.rs")); + } + + #[test] + fn apply_completion_text_with_no_entries_is_a_noop() { let mut app = App::new("test".into()); for c in "@x".chars() { app.insert_char(c); } let _ = app.refresh_completion(); // No `Event::Completions` arrived yet — entries is still empty. - assert!(!app.confirm_completion()); + assert!(app.apply_completion_text().is_none()); assert!(app.completion.is_some()); } diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index 1671eb57..46fc4f1c 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -116,10 +116,9 @@ enum WordKind { fn atom_class(atom: &Atom) -> AtomClass { match atom { Atom::Char(c) => char_class(*c), - Atom::Paste(_) - | Atom::FileRef(_) - | Atom::KnowledgeRef(_) - | Atom::WorkflowInvoke(_) => AtomClass::Chip, + Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) | Atom::WorkflowInvoke(_) => { + AtomClass::Chip + } } } @@ -213,13 +212,26 @@ impl InputBuffer { pub fn replace_with_workflow_invoke(&mut self, start: usize, slug: String) { self.atoms.drain(start..self.cursor); - self.atoms.insert( - start, - Atom::WorkflowInvoke(WorkflowInvokeAtom { slug }), - ); + self.atoms + .insert(start, Atom::WorkflowInvoke(WorkflowInvokeAtom { slug })); self.cursor = start + 1; } + /// Replace `atoms[start..self.cursor]` with the chars of `text`, + /// leaving cursor at the end of the inserted run. Used by the Tab + /// completion path: the popup-selected entry is inserted as raw + /// text (not a chip) so the user can keep typing — e.g. drill into + /// a directory whose value ends with `/`. + pub fn replace_with_text_at(&mut self, start: usize, text: &str) { + self.atoms.drain(start..self.cursor); + let mut idx = start; + for c in text.chars() { + self.atoms.insert(idx, Atom::Char(c)); + idx += 1; + } + self.cursor = idx; + } + /// If the cursor is currently inside a `@` / `#` / /// `/` token that satisfies the trigger rules, return the /// kind, the index of the leading sigil atom, and the typed text diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 54c10703..7b33f7cb 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -392,14 +392,33 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { } // Completion popup overrides — only when there's something to - // confirm / navigate. An empty popup (request in flight) falls + // navigate / commit. An empty popup (request in flight) falls // through to the default behaviour. if app.completion.as_ref().is_some_and(|c| c.is_active()) { match key.code { - KeyCode::Tab | KeyCode::Enter if !alt => { - if app.confirm_completion() { + KeyCode::Tab if !alt => { + // Insert the selected entry as raw text and let the + // re-triggered popup fetch fresh candidates (drill-in + // for directories, narrow-to-exact for files). + return app.apply_completion_text(); + } + KeyCode::Enter if !alt => { + // While the popup has selectable entries, Enter + // commits the selection rather than submitting the + // message. The selected entry wins regardless of how + // much of its value the user has typed — Enter on a + // popup entry is "accept this suggestion". Directory + // entries are the exception: they fall through to + // text insertion so the popup re-fetches children + // for drill-in. After a successful chip we append a + // trailing space so the user can keep writing without + // a manual separator (the Space path already has the + // space the user typed, so it's not needed there). + if app.chipify_selected_completion_if_committable() { + app.insert_char(' '); return None; } + return app.apply_completion_text(); } KeyCode::Up => { app.move_completion_up(); @@ -484,6 +503,15 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { app.refresh_completion() } KeyCode::Char(c) => { + // Whitespace ends an in-flight completion token. Try the + // auto-confirm path first so an exact match (e.g. typed + // `@src/main.rs` matches the only popup entry) becomes a + // chip on the way out. Directories also commit here — + // ending with a space is an explicit "I want this dir" + // signal, not a drill-in. + if c.is_whitespace() { + app.chipify_completion_if_exact_match(); + } app.insert_char(c); app.refresh_completion() }