fix(search): a command named in the query leads over a session that says it

'worktree' then Return resumed a past agent session instead of opening
New Worktree Tab. Two causes:

- The fuzzy scorer matched greedily from the left, so the query's first
  letter was spent on any earlier occurrence ('New') and the rest
  scattered: a title containing the whole word scored as a poor match.
  It now tries every position the first letter occurs and keeps the
  best; the leftmost alignment is one of those, so no score drops.
- Session titles are whatever was first typed to an agent, and those
  often open with a command's name, taking the prefix bonus. On the All
  tab the Sessions section now stands back by a little more than that
  bonus when sections are ordered; a session still leads when it is
  plainly the better answer, and the Sessions tab ranks untouched.
This commit is contained in:
l0ng-ai
2026-09-27 16:50:19 +02:00
parent 73eb7e55c5
commit fb7f3ca4d8
2 changed files with 74 additions and 13 deletions
+34 -12
View File
@@ -17,11 +17,34 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> {
return None;
}
// Every place the first letter occurs is a place the match could begin,
// and the best of them is the score. Matching greedily from the left
// alone let an early stray letter decide the alignment: `worktree`
// against `New Worktree Tab…` spent its `w` on "New" and scattered the
// rest, scoring the title as a poor match of the very word it contains.
let best = (0..hay.len())
.filter(|&i| hay[i] == needle[0])
.filter_map(|start| align(&needle, &hay, start))
.max()?;
let mut score = best;
if hay == needle {
score += 120;
} else if hay.starts_with(&needle) {
score += 50;
}
score -= (hay.len() as i32) / 6;
Some(score)
}
/// `needle` as a subsequence of `hay` beginning at `start`, taken greedily
/// from there; `None` when the rest of it does not fit.
fn align(needle: &[char], hay: &[char], start: usize) -> Option<i32> {
let mut qi = 0usize;
let mut score = 0i32;
let mut run = 0i32;
let mut prev_hit = false;
for (i, ch) in hay.iter().enumerate() {
for (i, ch) in hay.iter().enumerate().skip(start) {
if qi >= needle.len() {
break;
}
@@ -47,17 +70,7 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> {
prev_hit = true;
qi += 1;
}
if qi < needle.len() {
return None;
}
if hay == needle {
score += 120;
} else if hay.starts_with(&needle) {
score += 50;
}
score -= (hay.len() as i32) / 6;
Some(score)
(qi == needle.len()).then_some(score)
}
/// How far behind the visible label an alias hit lands. An alias *is* the
@@ -117,6 +130,15 @@ mod tests {
assert_eq!(frecency_bonus(1_000.0), frecency_bonus(10_000.0));
}
#[test]
fn a_whole_word_is_found_past_an_earlier_stray_letter() {
// The `w` of "New" used to claim the match and scatter the rest.
let whole = fuzzy_score("worktree", "New Worktree Tab…").expect("matches");
let scattered = fuzzy_score("worktree", "New work on tree").expect("matches");
assert!(whole > scattered, "{whole} should beat {scattered}");
assert_eq!(whole, 88);
}
#[test]
fn empty_query_matches_everything() {
assert_eq!(fuzzy_score("", "anything"), Some(0));
+40 -1
View File
@@ -154,7 +154,7 @@ impl Catalog {
let mut found: Vec<(i32, Section)> = tabs
.filter_map(|s| {
let hits = s.search(query, cx);
let best = hits.first()?.0;
let best = hits.first()?.0 - section_bias(s.tab());
let hidden = hits.len().saturating_sub(ALL_TAB_ROWS);
let mut rows: Vec<Row> = hits
.into_iter()
@@ -181,6 +181,25 @@ impl Catalog {
}
}
/// How far a past session's best row stands back when the All tab orders its
/// sections — a little more than a prefix match is worth.
///
/// A session is titled with the first thing someone typed to an agent, and
/// people start those with the words the commands are named after:
/// `Worktree cleanup` took the prefix bonus from `New Worktree Tab…`, so
/// `worktree` then Return resumed an agent instead of opening the dialog —
/// the heaviest thing on the page picked by a two-word guess. A session
/// still leads when it is plainly the better answer; the Sessions tab itself
/// ranks untouched.
const SESSION_SECTION_BIAS: i32 = 64;
fn section_bias(tab: SearchTab) -> i32 {
match tab {
SearchTab::Sessions => SESSION_SECTION_BIAS,
_ => 0,
}
}
/// A tab's rows on the All tab before anything is typed: what it has been
/// used for lately first, topped up from the rest of the tab to
/// [`ALL_TAB_ROWS`], then a row into the tab for whatever did not fit. An
@@ -693,6 +712,26 @@ mod tests {
.in_section(section.to_string())
}
#[gpui::test]
fn a_session_named_like_a_command_does_not_take_return_from_it(cx: &mut TestAppContext) {
with_config(cx);
let mut catalog = Catalog::new(
vec![Item::new("New Worktree Tab…", CommandKind::NewWorktreeTab)],
Vec::new(),
Vec::new(),
);
catalog.sessions = vec![session("Worktree cleanup", "Recent")];
cx.update(|cx| {
let sections = catalog.sections(SearchTab::All, "worktree", cx);
let headers: Vec<_> = sections.iter().filter_map(|s| s.title.clone()).collect();
assert_eq!(headers, vec!["Actions", "Sessions"]);
// Typed out, the session is the answer, and it leads.
let sections = catalog.sections(SearchTab::All, "worktree cleanup", cx);
assert_eq!(sections[0].title.as_deref(), Some("Sessions"));
});
}
/// Before anything is typed, the All tab offers to pick up where you left
/// off here first; past its rows, the rest of the tab is one row away.
#[gpui::test]