From 138cb87153e7822ab1f0899b2bacf8fcab7414f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 00:15:44 +0000 Subject: [PATCH 1/3] fix(go): paginate multi-match picker instead of dropping results qr go silently kept only the first 9 ranked matches, so common queries could hide later projects. Return the full ranked list and page the numbered picker 9 at a time. Co-authored-by: aanishbhirud --- CHANGELOG.md | 2 + src/commands/go.rs | 23 +++++--- src/picker.rs | 127 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 128 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c7ae36..93e5dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ adheres to [Semantic Versioning](https://semver.org/). project into `agent_commands` and may fill empty role fields. ### Fixed +- `qr go ` no longer silently drops matches after the first 9. The + numbered picker paginates (←/→ or n/p) and shows the current page and total. - `qr learn` no longer treats plain `main.py` as FastAPI, invents `next lint`, PM-qualifies Makefile/Justfile role commands on Node projects, or records `cargo run` / `go run .` when no runnable binary/root main package exists. diff --git a/src/commands/go.rs b/src/commands/go.rs index bb3341a..b078af3 100644 --- a/src/commands/go.rs +++ b/src/commands/go.rs @@ -136,11 +136,7 @@ pub fn rank_matches(entries: &[ProjectEntry], query: &str) -> Vec .collect::>(); scored.sort_by(|left, right| right.0.cmp(&left.0).then(left.1.name.cmp(&right.1.name))); - let results: Vec = scored - .into_iter() - .take(9) - .map(|(_, entry)| entry.clone()) - .collect(); + let results: Vec = scored.into_iter().map(|(_, entry)| entry.clone()).collect(); // If strict matching found nothing, try bigram similarity for typos/transpositions if results.is_empty() && lower_query.len() >= 3 { @@ -161,7 +157,6 @@ pub fn rank_matches(entries: &[ProjectEntry], query: &str) -> Vec fallback.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); return fallback .into_iter() - .take(9) .map(|(_, entry)| entry.clone()) .collect(); } @@ -233,6 +228,22 @@ mod tests { assert!(matches.iter().all(|entry| entry.name.starts_with("orion"))); } + #[test] + fn substring_matches_are_not_capped_at_one_picker_page() { + let projects = (0..15) + .map(|index| ProjectEntry { + name: format!("vibe-{index:02}"), + path: format!("/dev/vibe-{index:02}"), + source: "git".into(), + }) + .collect::>(); + + let matches = rank_matches(&projects, "vibe"); + + assert_eq!(matches.len(), 15); + assert!(matches.iter().all(|entry| entry.name.contains("vibe"))); + } + #[test] fn fuzzy_matching_is_case_insensitive() { // A mixed-case query that only matches via the fuzzy tier (not the diff --git a/src/picker.rs b/src/picker.rs index 8208d78..0f85990 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -13,6 +13,7 @@ const ANSI_RESET: &str = "\x1b[0m"; const ANSI_BOLD: &str = "\x1b[1m"; const ANSI_DIM: &str = "\x1b[2m"; const ANSI_CYAN: &str = "\x1b[36m"; +const NUMBERED_PER_PAGE: usize = 9; /// Restores the terminal (cursor + cooked mode) on every exit path — including /// early `?` errors and panics — so a crash mid-pick can never leave the user's @@ -159,7 +160,7 @@ pub fn pick_index(options: &[String]) -> Result> { let _guard = RawModeGuard::enter()?; let mut page = 0usize; - let per_page = 9usize; + let per_page = NUMBERED_PER_PAGE; loop { render_page(options, page, per_page, None)?; @@ -171,17 +172,26 @@ pub fn pick_index(options: &[String]) -> Result> { KeyCode::Char('q') | KeyCode::Esc => return Ok(None), KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => { let index = c.to_digit(10).unwrap() as usize - 1; - let absolute = page * per_page + index; - if absolute < options.len() { + if let Some(absolute) = + numbered_absolute_index(page, per_page, index, options.len()) + { render_page(options, page, per_page, Some(absolute))?; return Ok(Some(absolute)); } } - KeyCode::Right | KeyCode::Down if (page + 1) * per_page < options.len() => { - page += 1; + KeyCode::Right + | KeyCode::Down + | KeyCode::PageDown + | KeyCode::Char('n') + | KeyCode::Char('N') => { + page = next_numbered_page(page, options.len(), per_page); } - KeyCode::Left | KeyCode::Up if page > 0 => { - page -= 1; + KeyCode::Left + | KeyCode::Up + | KeyCode::PageUp + | KeyCode::Char('p') + | KeyCode::Char('P') => { + page = previous_numbered_page(page); } _ => {} } @@ -193,6 +203,66 @@ fn numbered_picker_key_code(key: crossterm::event::KeyEvent) -> Option should_handle_key_event(key.kind).then_some(key.code) } +fn numbered_page_count(total: usize, per_page: usize) -> usize { + if total == 0 || per_page == 0 { + 0 + } else { + total.div_ceil(per_page) + } +} + +fn numbered_page_range(total: usize, page: usize, per_page: usize) -> (usize, usize) { + let start = page.saturating_mul(per_page); + if start >= total { + (total, total) + } else { + (start, total.min(start + per_page)) + } +} + +fn numbered_absolute_index( + page: usize, + per_page: usize, + display_index: usize, + total: usize, +) -> Option { + let absolute = page.saturating_mul(per_page).saturating_add(display_index); + (absolute < total).then_some(absolute) +} + +fn next_numbered_page(page: usize, total: usize, per_page: usize) -> usize { + let next = page.saturating_add(1); + if numbered_page_range(total, next, per_page).0 < total { + next + } else { + page + } +} + +fn previous_numbered_page(page: usize) -> usize { + page.saturating_sub(1) +} + +fn numbered_picker_header(total: usize) -> String { + format!("Multiple matches found ({total}):") +} + +fn numbered_picker_hint(total: usize, page: usize, per_page: usize) -> String { + let pages = numbered_page_count(total, per_page); + if pages > 1 { + let (start, end) = numbered_page_range(total, page, per_page); + format!( + "Page {}/{} ({}-{} of {total}). ←/→ or n/p to change page. Press 1-9, ESC, or q.", + page + 1, + pages, + start + 1, + end + ) + } else { + "Press 1-9, ESC, or q.".to_string() + } +} + fn should_handle_key_event(kind: KeyEventKind) -> bool { kind != KeyEventKind::Release } @@ -257,15 +327,14 @@ fn render_page( selected_absolute: Option, ) -> Result<()> { let mut stderr = io::stderr(); - let start = page * per_page; - let end = options.len().min(start + per_page); + let (start, end) = numbered_page_range(options.len(), page, per_page); execute!( stderr, terminal::Clear(ClearType::All), cursor::MoveTo(0, 0) )?; - write!(stderr, "Multiple matches found:\r\n")?; + write!(stderr, "{}\r\n", numbered_picker_header(options.len()))?; for (display_index, value) in options[start..end].iter().enumerate() { let absolute = start + display_index; let marker = if selected_absolute == Some(absolute) { @@ -275,14 +344,11 @@ fn render_page( }; write!(stderr, "{marker} {}) {}\r\n", display_index + 1, value)?; } - if end < options.len() || page > 0 { - write!( - stderr, - "Use arrows to change page. Press 1-9, ESC, or q.\r\n" - )?; - } else { - write!(stderr, "Press 1-9, ESC, or q.\r\n")?; - } + write!( + stderr, + "{}\r\n", + numbered_picker_hint(options.len(), page, per_page) + )?; stderr.flush()?; Ok(()) } @@ -550,6 +616,31 @@ mod tests { assert!(!should_handle_key_event(KeyEventKind::Release)); } + #[test] + fn numbered_picker_pages_keep_single_digit_selection() { + assert_eq!(numbered_page_count(24, 9), 3); + assert_eq!(numbered_page_range(24, 0, 9), (0, 9)); + assert_eq!(numbered_page_range(24, 1, 9), (9, 18)); + assert_eq!(numbered_page_range(24, 2, 9), (18, 24)); + assert_eq!(numbered_absolute_index(1, 9, 0, 24), Some(9)); + assert_eq!(numbered_absolute_index(2, 9, 5, 24), Some(23)); + assert_eq!(numbered_absolute_index(2, 9, 6, 24), None); + assert_eq!(next_numbered_page(0, 24, 9), 1); + assert_eq!(next_numbered_page(2, 24, 9), 2); + assert_eq!(previous_numbered_page(0), 0); + assert_eq!(previous_numbered_page(2), 1); + } + + #[test] + fn numbered_picker_hint_includes_page_position_when_paginated() { + assert_eq!(numbered_picker_header(24), "Multiple matches found (24):"); + assert_eq!( + numbered_picker_hint(24, 1, 9), + "Page 2/3 (10-18 of 24). ←/→ or n/p to change page. Press 1-9, ESC, or q." + ); + assert_eq!(numbered_picker_hint(6, 0, 9), "Press 1-9, ESC, or q."); + } + #[test] fn numbered_picker_ignores_key_release_events() { let release = crossterm::event::KeyEvent::new_with_kind( From 6276decac4736172fe0ef0515b3406464c92039d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 00:17:25 +0000 Subject: [PATCH 2/3] test(go): assert non-interactive matches are not capped at 9 Lock the qr go listing so a 15-project substring query still names every match instead of silently dropping the second page. Co-authored-by: aanishbhirud --- tests/cli.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/cli.rs b/tests/cli.rs index 9c2d057..0ee4c30 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -808,6 +808,44 @@ fn go_print_path_writes_only_the_path_to_stdout() { } } +#[test] +fn go_noninteractive_lists_every_match_past_one_page() { + let _guard = env_lock().lock().unwrap(); + clear_test_env(); + let tmp = tempfile::tempdir().unwrap(); + let cfg_dir = tmp.path().join("cfg"); + fs::create_dir_all(&cfg_dir).unwrap(); + unsafe { + std::env::set_var("QR_CONFIG_DIR", &cfg_dir); + } + + let projects = (0..15) + .map(|index| { + format!(r#"{{"name":"vibe-{index:02}","path":"/tmp/vibe-{index:02}","source":"git"}}"#) + }) + .collect::>() + .join(","); + fs::write( + cfg_dir.join("projects-cache.json"), + format!(r#"{{"scanned_at_unix_ms":1,"projects":[{projects}]}}"#), + ) + .unwrap(); + + let mut assertion = Command::cargo_bin("qr") + .unwrap() + .args(["go", "vibe"]) + .assert() + .failure() + .stderr(contains("Multiple matches for 'vibe'")); + for index in 0..15 { + assertion = assertion.stderr(contains(format!("vibe-{index:02}"))); + } + + unsafe { + std::env::remove_var("QR_CONFIG_DIR"); + } +} + #[test] fn run_executes_positional_script_with_mode_flag() { let _guard = env_lock().lock().unwrap(); From 684a2c646031680eb368b79fb132c99d77b977b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 01:29:06 +0000 Subject: [PATCH 3/3] fix(go): list every numbered-picker page key in the hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker already accepted arrows, PageUp/PageDown, and n/p, but the footer and changelog only mentioned ←/→ and n/p. Co-authored-by: aanishbhirud --- CHANGELOG.md | 3 ++- src/picker.rs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e5dcc..c3674cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ adheres to [Semantic Versioning](https://semver.org/). ### Fixed - `qr go ` no longer silently drops matches after the first 9. The - numbered picker paginates (←/→ or n/p) and shows the current page and total. + numbered picker paginates (←/→, ↑/↓, PgUp/PgDn, or n/p) and shows the + current page and total. - `qr learn` no longer treats plain `main.py` as FastAPI, invents `next lint`, PM-qualifies Makefile/Justfile role commands on Node projects, or records `cargo run` / `go run .` when no runnable binary/root main package exists. diff --git a/src/picker.rs b/src/picker.rs index 0f85990..34254c0 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -252,7 +252,7 @@ fn numbered_picker_hint(total: usize, page: usize, per_page: usize) -> String { if pages > 1 { let (start, end) = numbered_page_range(total, page, per_page); format!( - "Page {}/{} ({}-{} of {total}). ←/→ or n/p to change page. Press 1-9, ESC, or q.", + "Page {}/{} ({}-{} of {total}). ←/→ ↑/↓ PgUp/PgDn or n/p to change page. Press 1-9, ESC, or q.", page + 1, pages, start + 1, @@ -636,7 +636,7 @@ mod tests { assert_eq!(numbered_picker_header(24), "Multiple matches found (24):"); assert_eq!( numbered_picker_hint(24, 1, 9), - "Page 2/3 (10-18 of 24). ←/→ or n/p to change page. Press 1-9, ESC, or q." + "Page 2/3 (10-18 of 24). ←/→ ↑/↓ PgUp/PgDn or n/p to change page. Press 1-9, ESC, or q." ); assert_eq!(numbered_picker_hint(6, 0, 9), "Press 1-9, ESC, or q."); }