From 7401fd59dfedfdc0eca421f4f03c4cda47b97ab9 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 10 Jul 2026 18:28:42 +0800 Subject: [PATCH] feat(history): Ctrl+R fuzzy search menu with run metadata (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(history): Ctrl+R fuzzy search menu with run metadata Ctrl+R grows from the single-line reverse-i-search into a browsable menu of ranked candidates floating beside the prompt: - Matching is fuzzy (src/terminal/fuzzy.rs, a dependency-free affine-gap aligner: word-boundary and consecutive-run bonuses, gap penalties; space-separated query terms must all match), blended with the existing frecency scores so a command you run constantly — or ran in this directory — outranks an equally-good textual match. - An empty query lists the whole history by frecency, so bare Ctrl+R is a "recent & relevant" browser. Matched characters highlight in the rows; Ctrl+R/Down and Ctrl+S/Up move the selection, Enter loads the line into the editor, Cmd+Enter runs it outright. The classic (reverse-i-search) prompt line stays. - History records now carry run metadata: new lines are \t\t\t, written when the command finishes (zsh INC_APPEND_HISTORY_TIME-style) so the exit code sniffed from OSC 133;D lands in the record; older formats still parse, and zsh/bash HISTFILE timestamps carry over when seeding. The menu shows "ran 3h ago" and a red x-badge on commands whose last run failed. - RemoteTerminal exposes prompt_seq/last_exit_code so the view can tell a fresh post-command prompt report from the stale pre-submit state even when 1 Hz polling misses a fast command's running window. Co-Authored-By: Claude Fable 5 * style: cargo fmt Co-Authored-By: Claude Fable 5 --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 16 + README.md | 2 +- openwiki/architecture/client-terminal-ui.md | 10 +- openwiki/domain/data-and-config.md | 15 +- src/terminal/fuzzy.rs | 253 +++++++++ src/terminal/history.rs | 513 +++++++++++++----- src/terminal/mod.rs | 1 + src/terminal/remote.rs | 19 + src/terminal/reverse_search.rs | 366 ++++++++----- src/terminal/view.rs | 547 +++++++++++++++++++- 10 files changed, 1464 insertions(+), 278 deletions(-) create mode 100644 src/terminal/fuzzy.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f334e0f8..7a438ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Ctrl+R history search is now a browsable menu: matching is fuzzy + (subsequence with word-boundary/consecutive bonuses; space-separated terms + must all match) blended with frecency, and the ranked candidates float + beside the prompt — matched characters highlighted, selection moved by + Ctrl+R/↓ and Ctrl+S/↑, Enter to edit, Cmd+Enter to run outright. An empty + query lists the whole history by frecency, so bare Ctrl+R is a "recent & + relevant" browser. The classic `(reverse-i-search)` line stays. (#45) +- History records now carry when the command ran and its exit code: new + entries are `\t\t\t`, written when the command + *finishes* (zsh `INC_APPEND_HISTORY_TIME`-style, exit code sniffed from + OSC 133;D daemon-side); older formats still load. The Ctrl+R menu shows + "ran 3h ago" and a `✗` badge on commands whose last run failed; timestamps + from zsh/bash history files are carried over when seeding. (#45) + ## [0.8.0] - 2026-07-10 ### Added diff --git a/README.md b/README.md index dd6299fc..10430501 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ The essentials: | ⌘ K | clear the screen and scrollback | | ⌘ P | command palette | | ⌘ F | search the scrollback | -| ⌃ R | reverse-search shell history | +| ⌃ R | fuzzy-search shell history | | ⌘ + · ⌘ − · ⌘ 0 | font size up · down · reset | The full list — and any overrides — lives in **Settings → Keybindings**. diff --git a/openwiki/architecture/client-terminal-ui.md b/openwiki/architecture/client-terminal-ui.md index d026767d..545671c9 100644 --- a/openwiki/architecture/client-terminal-ui.md +++ b/openwiki/architecture/client-terminal-ui.md @@ -134,11 +134,11 @@ Features include: History behavior: -- tty7 writes `\t` lines when cwd is known. -- Legacy bare lines parse without cwd association. -- Load normalizes blanks/duplicates and caps entries. +- tty7 writes `\t\t\t` lines, deferred until the command finishes so the record carries its exit code (see `data-and-config.md` for the format and flush rules). +- Older `\t` and legacy bare lines parse without the missing fields. +- Load normalizes blanks/duplicates, keeps per-command last-run metadata (timestamp + exit code), and caps entries. - Frecency ranking combines recency, frequency, and current-directory bonus. -- Up/Down uses chronological history; ghost suggestion uses frecency-ranked history. +- Up/Down uses chronological history; ghost suggestion uses frecency-ranked history; Ctrl+R blends fuzzy match scores with frecency. ### Completion @@ -157,7 +157,7 @@ Completion is intentionally shallow: whitespace-delimited word detection, simple URL handling prefers OSC 8 hyperlinks and falls back to bare URL detection. Cmd/Ctrl-click opens links when `Config::link_url` is enabled. -`src/terminal/reverse_search.rs` implements Ctrl+R history search with case-insensitive contains matching, repeated Ctrl+R to advance older, Enter to accept into editor, and Escape/Ctrl+G/Ctrl+C to cancel. +`src/terminal/reverse_search.rs` implements Ctrl+R history search as a ranked match list: `src/terminal/fuzzy.rs` scores case-insensitive fuzzy subsequences (whitespace-separated query terms must all match), blended with each entry's frecency; an empty query lists the whole history by frecency. The view renders the candidates in a completion-style menu (`render_reverse_search_menu`) with matched characters highlighted and last-run time / failure badges from history metadata, plus the classic `(reverse-i-search)` prompt on the input row. Ctrl+R/↓ and Ctrl+S/↑ move the selection, Enter accepts into the editor, Cmd+Enter runs the selection outright, and Escape/Ctrl+G/Ctrl+C cancels. ## Settings, keymap, and theme diff --git a/openwiki/domain/data-and-config.md b/openwiki/domain/data-and-config.md index e03d3550..4f54aabe 100644 --- a/openwiki/domain/data-and-config.md +++ b/openwiki/domain/data-and-config.md @@ -89,14 +89,25 @@ Files and sources: Format: -- New tty7 entries are `\t` when cwd is an absolute path. -- Legacy bare command lines are accepted. +- New tty7 entries are `\t\t\t`: unix seconds when the + command ran, that run's exit code (empty when the run never completed under + tty7's watch), the absolute cwd (empty when unknown), then the command — the + last field, so its own tabs survive. +- The record is written when the command *finishes*, not when it is submitted + (like zsh's `INC_APPEND_HISTORY_TIME`), so it can carry the exit code the + daemon sniffs from OSC 133;D. A pane that goes away mid-command flushes the + record without one. +- Older `\t` lines and legacy bare command lines are accepted. +- Timestamps from zsh extended history and bash `HISTTIMEFORMAT` comments are + carried over when seeding. Load behavior: - Blanks are dropped. - Duplicates are collapsed while preserving most recent occurrence semantics. - Counts and cwd sets are accumulated for ranking. +- Last-run metadata (timestamp + exit code) is kept per command — the Ctrl+R + menu's "ran 3h ago" and failure badges. - Entry count is capped. Ranking: diff --git a/src/terminal/fuzzy.rs b/src/terminal/fuzzy.rs new file mode 100644 index 00000000..528be65e --- /dev/null +++ b/src/terminal/fuzzy.rs @@ -0,0 +1,253 @@ +//! Fuzzy subsequence matching for the Ctrl+R history search. +//! +//! A small affine-gap aligner in the fzf/skim family: every query character +//! must appear in the haystack in order (a subsequence), and the returned score +//! rewards runs of consecutive matches and matches at word boundaries while +//! penalizing gaps — so `gst` prefers `git status` over `grep -rn "s" tests`. +//! The matched character positions come back too, so the menu can highlight +//! exactly which characters matched. +//! +//! Whitespace in the query splits it into terms that must *all* match +//! (anywhere, in any order) — `git push` finds `git push -f origin` but also +//! `push-all git-mirrors`. Matching is always case-insensitive, like the +//! substring search this replaces. +//! +//! Kept dependency-free on purpose: command lines are short, so the O(m×n) +//! dynamic program is comfortably cheap even against thousands of history +//! entries per keystroke. + +/// A successful match: the alignment score (higher is better; only comparable +/// between matches of the *same query*) and the matched char indices into the +/// haystack, ascending and deduplicated. +pub(super) struct FuzzyMatch { + pub score: i32, + pub positions: Vec, +} + +/// Every matched character is worth this much before bonuses. +const SCORE_MATCH: i32 = 16; +/// Bonus for a match at a word boundary (start of the line, or right after a +/// separator) — `st` should land on the `status` in `git status`. +const BONUS_BOUNDARY: i32 = 12; +/// Bonus for extending a run of consecutive matches — favours tight matches +/// over the same letters scattered across the line. Deliberately worth more +/// than a boundary bonus reached across a gap (`BONUS_BOUNDARY + +/// PENALTY_GAP_START = 9`), so `ab` still prefers the literal `ab` over the +/// two word heads of `a-b`. +const BONUS_CONSECUTIVE: i32 = 10; +/// Cost of opening a gap between two matched characters… +const PENALTY_GAP_START: i32 = -3; +/// …and of each further character that gap skips. +const PENALTY_GAP_EXTEND: i32 = -1; + +/// "Impossible" sentinel. Kept far from `i32::MIN` so adding penalties/bonuses +/// to a sentinel value can never wrap around into a plausible score. +const NEG: i32 = i32::MIN / 2; + +/// Match `query` against `line`. Whitespace splits the query into terms which +/// must all match; scores add up and positions merge. `None` when the query is +/// blank or any term fails to match. +pub(super) fn match_line(line: &str, query: &str) -> Option { + let terms: Vec<&str> = query.split_whitespace().collect(); + if terms.is_empty() { + return None; + } + let hay: Vec = line.chars().collect(); + let hay_lc: Vec = hay.iter().map(|&c| lc(c)).collect(); + let bonus: Vec = (0..hay.len()) + .map(|j| char_bonus(if j == 0 { None } else { Some(hay[j - 1]) })) + .collect(); + + let mut score = 0; + let mut positions = std::collections::BTreeSet::new(); + for term in terms { + let t: Vec = term.chars().map(lc).collect(); + let (s, pos) = match_term(&hay_lc, &bonus, &t)?; + score += s; + positions.extend(pos); + } + Some(FuzzyMatch { + score, + positions: positions.into_iter().collect(), + }) +} + +/// Lowercase a char for comparison (first mapping only — `ß`→`ss` expansions +/// don't matter for scoring command lines). +fn lc(c: char) -> char { + c.to_lowercase().next().unwrap_or(c) +} + +/// The word-boundary bonus a match at a position earns, given the preceding +/// character (`None` at the start of the line). +fn char_bonus(prev: Option) -> i32 { + match prev { + None => BONUS_BOUNDARY, + Some(c) + if c.is_whitespace() || matches!(c, '/' | '-' | '_' | '.' | ':' | '=' | ',' | '\\') => + { + BONUS_BOUNDARY + } + _ => 0, + } +} + +/// Align one lowercased `term` against the lowercased haystack, returning the +/// best score and the matched positions. Classic affine-gap DP: +/// `m[i][j]` is the best score with `term[i]` matched at `hay[j]`, reachable +/// either consecutively from `m[i-1][j-1]` or across a gap (tracked by a +/// running per-row maximum so each cell is O(1)); `parent[i][j]` remembers the +/// chosen predecessor for the backtrack that recovers the positions. +fn match_term(hay_lc: &[char], bonus: &[i32], term: &[char]) -> Option<(i32, Vec)> { + let (m, n) = (term.len(), hay_lc.len()); + if m == 0 || m > n { + return None; + } + let mut score = vec![NEG; m * n]; + let mut parent = vec![usize::MAX; m * n]; + + for j in 0..n { + if hay_lc[j] == term[0] { + score[j] = SCORE_MATCH + bonus[j]; + } + } + for i in 1..m { + // Best gapped predecessor for the current j: max over k ≤ j-2 of + // `score[i-1][k]` plus the affine penalty for the k→j gap. + let mut gap_best = NEG; + let mut gap_arg = usize::MAX; + for j in 0..n { + if j >= 2 { + let fresh = score[(i - 1) * n + (j - 2)]; + let fresh = if fresh > NEG { + fresh + PENALTY_GAP_START + } else { + NEG + }; + let extended = if gap_best > NEG { + gap_best + PENALTY_GAP_EXTEND + } else { + NEG + }; + if fresh >= extended { + gap_best = fresh; + gap_arg = j - 2; + } else { + gap_best = extended; + } + } + if hay_lc[j] != term[i] { + continue; + } + let cons = if j >= 1 && score[(i - 1) * n + (j - 1)] > NEG { + score[(i - 1) * n + (j - 1)] + BONUS_CONSECUTIVE + } else { + NEG + }; + let (prev, arg) = if cons >= gap_best { + (cons, j.wrapping_sub(1)) + } else { + (gap_best, gap_arg) + }; + if prev > NEG { + score[i * n + j] = prev + SCORE_MATCH + bonus[j]; + parent[i * n + j] = arg; + } + } + } + + // Best end position for the last term char; ties go to the earliest. + let (mut best_j, mut best) = (usize::MAX, NEG); + for j in 0..n { + if score[(m - 1) * n + j] > best { + best = score[(m - 1) * n + j]; + best_j = j; + } + } + if best <= NEG { + return None; + } + let mut positions = vec![0usize; m]; + let mut j = best_j; + for i in (0..m).rev() { + positions[i] = j; + j = parent[i * n + j]; + } + Some((best, positions)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn score(line: &str, query: &str) -> i32 { + match_line(line, query).expect("expected a match").score + } + + fn positions(line: &str, query: &str) -> Vec { + match_line(line, query).expect("expected a match").positions + } + + #[test] + fn non_subsequence_is_no_match() { + assert!(match_line("git status", "xyz").is_none()); + assert!(match_line("ls", "lss").is_none()); // longer than the line + assert!(match_line("git status", "tg").is_none()); // out of order + } + + #[test] + fn blank_query_is_no_match() { + assert!(match_line("git status", "").is_none()); + assert!(match_line("git status", " ").is_none()); + } + + #[test] + fn matching_is_case_insensitive() { + assert_eq!(score("Git Status", "git"), score("git status", "GIT")); + assert!(match_line("MAKE ALL", "make").is_some()); + } + + #[test] + fn consecutive_run_beats_scattered_letters() { + // Both contain g,i,t as a subsequence; only one has them adjacent. + assert!(score("git log", "git") > score("going to lunch", "git")); + } + + #[test] + fn word_boundary_beats_mid_word() { + // `st` at the start of "status" (after a space) vs inside "faster". + assert!(score("git status", "st") > score("faster", "st")); + } + + #[test] + fn positions_pick_the_best_alignment() { + // `gs` should land on the `g` of git and the boundary `s` of status, + // not some later `s`. + assert_eq!(positions("git status", "gs"), vec![0, 4]); + // A consecutive alignment is recovered exactly. + assert_eq!(positions("cargo build", "build"), vec![6, 7, 8, 9, 10]); + } + + #[test] + fn multi_term_queries_must_all_match_and_merge_positions() { + // Terms match independently (order-free) and positions merge sorted. + let m = match_line("git push --force origin", "push git").unwrap(); + assert_eq!(m.positions, vec![0, 1, 2, 4, 5, 6, 7]); + // One term failing fails the whole query. + assert!(match_line("git push", "git nope").is_none()); + } + + #[test] + fn gaps_are_penalized_by_length() { + // Same letters, tighter gap scores higher. + assert!(score("ab", "ab") > score("a-b", "ab")); + assert!(score("a-b", "ab") > score("a---------b", "ab")); + } + + #[test] + fn unicode_haystacks_match_by_char() { + // Positions are char indices, not bytes: the CJK prefix occupies + // char cells 0..2, so `ls` lands at 3..=4. + assert_eq!(positions("构建 ls", "ls"), vec![3, 4]); + } +} diff --git a/src/terminal/history.rs b/src/terminal/history.rs index 958f4238..fe4eaa19 100644 --- a/src/terminal/history.rs +++ b/src/terminal/history.rs @@ -5,15 +5,20 @@ //! ↑/↓ recall and Ctrl+R search without pulling in a database. Each terminal loads //! a snapshot on creation and appends as commands are submitted. //! -//! Each new line is `\t` — the working directory the command ran in, -//! a tab, then the command. That cwd feeds the frecency ranking: commands you've -//! run *in this directory* float to the top of completion and ghost text. Legacy -//! plain-command lines (no tab) still parse fine, just without a cwd association. +//! Each new line is `\t\t\t` — when the command ran +//! (unix seconds), the exit code of that run (empty while unknown: the record is +//! written once the command finishes, but a pane can die before that), the +//! working directory it ran in (empty when unusable), then the command itself +//! (which may contain further tabs — it's the last field). The cwd feeds the +//! frecency ranking; ts and exit feed the Ctrl+R menu's "ran 3h ago" / failure +//! badges. Older `\t` lines and legacy bare commands still parse +//! fine, just without the missing fields. //! //! On load we also seed from the user's real shell histories (`~/.zsh_history`, //! `~/.bash_history`, and `$HISTFILE`), so recall and completion work from the //! very first launch — before tty7 has accumulated a history of its own. Those -//! files are read-only inputs; tty7 only ever writes its own file. +//! files are read-only inputs; tty7 only ever writes its own file. zsh extended +//! and bash `HISTTIMEFORMAT` timestamps are carried over when present. use crate::core::config::config_path; use std::collections::{HashMap, HashSet}; @@ -36,32 +41,61 @@ const FREQ_WEIGHT: f64 = 0.6; /// very frequent global one (`git status`, `ls`, …). const CWD_BONUS: f64 = 1.2; +/// One history line as parsed from disk, before de-duplication: the command, +/// plus whatever metadata its source format carried. +struct Raw { + cmd: String, + cwd: Option, + ts: Option, + exit: Option, +} + +impl Raw { + fn bare(cmd: String) -> Self { + Self { + cmd, + cwd: None, + ts: None, + exit: None, + } + } +} + +/// Last-known run metadata for one history line: when it last ran (unix +/// seconds) and that run's exit code (`None` when the run never completed +/// under tty7's watch — or predates exit tracking). +#[derive(Clone, Copy, Default, PartialEq, Debug)] +pub struct EntryMeta { + pub ts: Option, + pub exit: Option, +} + /// Loaded history: the unique command lines (oldest-first, the source for ↑/↓ -/// recall and Ctrl+R search), plus the two extra dimensions the frecency ranking -/// needs — per-line run `counts` (frequency) and, for each line, the set of -/// directories it was run in (`cwds`), so we can favour commands used *here*. +/// recall and Ctrl+R search), plus the extra dimensions ranking and the Ctrl+R +/// menu need — per-line run `counts` (frequency), the set of directories each +/// line was run in (`cwds`, so we can favour commands used *here*), and the +/// last-run `meta` (timestamp + exit code) per line. pub struct History { pub entries: Vec, pub counts: HashMap, pub cwds: HashMap>, + pub meta: HashMap, } /// Load history (oldest first), seeding from the user's shell histories and then /// tty7's own file. Blanks are dropped and duplicates collapsed (keeping the most -/// recent occurrence), while occurrence counts and per-directory associations are -/// tallied for frecency ranking. Returns empty when nothing is readable. +/// recent occurrence), while occurrence counts, per-directory associations and +/// last-run metadata are tallied for ranking and the Ctrl+R menu. Returns empty +/// when nothing is readable. pub fn load() -> History { // Shell history first (older, so it sits at a lower completion priority than // commands actually run in tty7), then tty7's own file last (most recent). - // Shell-history lines carry no cwd; tty7's own `\t` lines do. - let mut raw: Vec<(String, Option)> = load_shell_history() - .into_iter() - .map(|cmd| (cmd, None)) - .collect(); - if let Some(path) = config_path("history") { - if let Ok(content) = std::fs::read_to_string(&path) { - raw.extend(content.lines().map(parse_own_line)); - } + // Shell-history lines carry no cwd; tty7's own lines do. + let mut raw: Vec = load_shell_history(); + if let Some(path) = config_path("history") + && let Ok(content) = std::fs::read_to_string(&path) + { + raw.extend(content.lines().map(parse_own_line)); } normalize(raw) } @@ -81,32 +115,52 @@ fn looks_absolute(p: &str) -> bool { } } -/// Parse one line of tty7's own history file into `(command, cwd)`. New lines are -/// `\t` with an absolute cwd; legacy plain lines (and anything whose -/// pre-tab part isn't an absolute path) carry just the command, no cwd. -fn parse_own_line(line: &str) -> (String, Option) { +/// Parse one line of tty7's own history file. Current lines are +/// `\t\t\t` (ts all-digits; exit an integer or empty; +/// cwd absolute or empty; the command — the last field — may itself contain +/// tabs). Older `\t` lines and legacy bare commands still parse, +/// carrying only the fields they have. +fn parse_own_line(line: &str) -> Raw { + let mut f = line.splitn(4, '\t'); + if let (Some(ts), Some(exit), Some(cwd), Some(cmd)) = (f.next(), f.next(), f.next(), f.next()) + && !ts.is_empty() + && ts.bytes().all(|b| b.is_ascii_digit()) + && (exit.is_empty() || exit.parse::().is_ok()) + && (cwd.is_empty() || looks_absolute(cwd)) + { + return Raw { + cmd: cmd.to_string(), + cwd: (!cwd.is_empty()).then(|| cwd.to_string()), + ts: ts.parse().ok(), + exit: exit.parse().ok(), + }; + } if let Some((cwd, cmd)) = line.split_once('\t') && looks_absolute(cwd) { - return (cmd.to_string(), Some(cwd.to_string())); + return Raw { + cmd: cmd.to_string(), + cwd: Some(cwd.to_string()), + ts: None, + exit: None, + }; } - (line.to_string(), None) + Raw::bare(line.to_string()) } -/// Order unique history entries by *frecency* (frequency × recency, plus a -/// current-directory bonus), most relevant first — the ranking that drives -/// ghost-text autosuggestion and the completion menu's history recalls, so neither -/// surfaces stale junk just because it was typed once, recently. `entries` is -/// oldest-first as from [`load`]; `counts` and `cwds` are its companions; `cwd` is -/// the directory to favour (none → no directory bonus). -pub fn rank_by_frecency( +/// The frecency score of every entry (frequency × recency, plus a +/// current-directory bonus), index-aligned with `entries`. Shared by +/// [`rank_by_frecency`] and the Ctrl+R search's relevance blend. `entries` is +/// oldest-first as from [`load`]; `counts` and `cwds` are its companions; `cwd` +/// is the directory to favour (none → no directory bonus). +pub fn frecency_scores( entries: &[String], counts: &HashMap, cwds: &HashMap>, cwd: Option<&str>, -) -> Vec { +) -> Vec { let n = entries.len(); - let mut scored: Vec<(f64, usize)> = entries + entries .iter() .enumerate() .map(|(i, e)| { @@ -125,25 +179,62 @@ pub fn rank_by_frecency( { score += CWD_BONUS; } - (score, i) + score }) - .collect(); - // Higher score first; ties broken toward the more recent entry. - scored.sort_by(|a, b| { - b.0.partial_cmp(&a.0) - .unwrap_or(std::cmp::Ordering::Equal) - .then(b.1.cmp(&a.1)) - }); - scored - .into_iter() - .map(|(_, i)| entries[i].clone()) .collect() } -/// Append one command to the history file (best effort), tagged with the `cwd` it -/// ran in when that's a usable absolute path. Commands containing a newline are -/// skipped, since the format is one-per-line. -pub fn append(cmd: &str, cwd: Option<&Path>) { +/// Order unique history entries by *frecency*, most relevant first — the +/// ranking that drives ghost-text autosuggestion and the completion menu's +/// history recalls, so neither surfaces stale junk just because it was typed +/// once, recently. See [`frecency_scores`] for the inputs. +pub fn rank_by_frecency( + entries: &[String], + counts: &HashMap, + cwds: &HashMap>, + cwd: Option<&str>, +) -> Vec { + let scores = frecency_scores(entries, counts, cwds, cwd); + let mut idx: Vec = (0..entries.len()).collect(); + // Higher score first; ties broken toward the more recent entry. + idx.sort_by(|&a, &b| { + scores[b] + .partial_cmp(&scores[a]) + .unwrap_or(std::cmp::Ordering::Equal) + .then(b.cmp(&a)) + }); + idx.into_iter().map(|i| entries[i].clone()).collect() +} + +/// Compact "how long ago" label for the Ctrl+R menu: `now` and `ts` are unix +/// seconds. Coarse on purpose — the menu row has room for `3h`, not a date. +pub fn format_ago(now: u64, ts: u64) -> String { + let s = now.saturating_sub(ts); + let (n, unit) = if s < 60 { + return "now".to_string(); + } else if s < 3600 { + (s / 60, "m") + } else if s < 86_400 { + (s / 3600, "h") + } else if s < 7 * 86_400 { + (s / 86_400, "d") + } else if s < 30 * 86_400 { + (s / (7 * 86_400), "w") + } else if s < 365 * 86_400 { + (s / (30 * 86_400), "mo") + } else { + (s / (365 * 86_400), "y") + }; + format!("{n}{unit}") +} + +/// Append one command to the history file (best effort): `ts` is when it ran +/// (unix seconds) and `exit` its exit code when the run completed under tty7's +/// watch. The cwd is recorded when it's a usable absolute path — one that can't +/// confuse the one-line format: no tab (the field separator) and no newline/CR +/// (which would split the record across lines). Commands containing a newline +/// are skipped, since the format is one-per-line. +pub fn append(cmd: &str, cwd: Option<&Path>, ts: u64, exit: Option) { if cmd.contains('\n') { return; } @@ -153,16 +244,12 @@ pub fn append(cmd: &str, cwd: Option<&Path>) { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - // `\t` when we have an absolute cwd — one that can't confuse the - // one-line format: no tab (the field separator) and no newline/CR (a cwd - // containing one would split the record across lines, and the stray tail - // would load back as a bogus command). Otherwise the legacy bare form. - let line = match cwd.and_then(Path::to_str) { - Some(c) if looks_absolute(c) && !c.contains(['\t', '\n', '\r']) => { - format!("{c}\t{cmd}") - } - _ => cmd.to_string(), + let cwd = match cwd.and_then(Path::to_str) { + Some(c) if looks_absolute(c) && !c.contains(['\t', '\n', '\r']) => c, + _ => "", }; + let exit = exit.map(|e| e.to_string()).unwrap_or_default(); + let line = format!("{ts}\t{exit}\t{cwd}\t{cmd}"); use std::io::Write; if let Ok(mut f) = std::fs::OpenOptions::new() .create(true) @@ -180,23 +267,35 @@ pub fn append(cmd: &str, cwd: Option<&Path>) { /// Drop blanks and de-duplicate (keeping the most recent occurrence, so recall /// and completion stay clean when shell history and tty7's own file overlap), -/// tallying how many times each line appears and which directories it ran in, then -/// cap to the most recent `MAX_ENTRIES`. Input is `(command, cwd)` pairs; output -/// entries are oldest-first. -fn normalize(raw: Vec<(String, Option)>) -> History { +/// tallying how many times each line appears, which directories it ran in, and +/// its most recent run's metadata, then cap to the most recent `MAX_ENTRIES`. +/// Output entries are oldest-first. +fn normalize(raw: Vec) -> History { let mut counts: HashMap = HashMap::new(); let mut cwds: HashMap> = HashMap::new(); + let mut meta: HashMap = HashMap::new(); let mut seen = HashSet::new(); let mut out: Vec = Vec::new(); - for (line, cwd) in raw.into_iter().rev() { - let line = line.trim_end_matches('\r'); + for r in raw.into_iter().rev() { + let line = r.cmd.trim_end_matches('\r'); if line.is_empty() { continue; } *counts.entry(line.to_string()).or_insert(0) += 1; - if let Some(cwd) = cwd { + if let Some(cwd) = r.cwd { cwds.entry(line.to_string()).or_default().insert(cwd); } + // Newest-first scan: the first occurrence carrying any run metadata is + // the last known run — its ts and exit stay a matched pair. + if (r.ts.is_some() || r.exit.is_some()) && !meta.contains_key(line) { + meta.insert( + line.to_string(), + EntryMeta { + ts: r.ts, + exit: r.exit, + }, + ); + } if seen.insert(line.to_string()) { out.push(line.to_string()); } @@ -208,12 +307,14 @@ fn normalize(raw: Vec<(String, Option)>) -> History { for r in out.drain(0..cut) { counts.remove(&r); cwds.remove(&r); + meta.remove(&r); } } History { entries: out, counts, cwds, + meta, } } @@ -222,7 +323,7 @@ fn normalize(raw: Vec<(String, Option)>) -> History { /// `$HISTFILE` if set, and orders the files by modification time so the /// most-recently-used shell's entries end up with the highest completion /// priority. -fn load_shell_history() -> Vec { +fn load_shell_history() -> Vec { let mut files: Vec = Vec::new(); let mut seen = HashSet::new(); let mut add = |p: PathBuf| { @@ -256,9 +357,10 @@ fn load_shell_history() -> Vec { out } -/// Parse one shell-history file into command lines, appending to `out`. Strips -/// zsh's extended-format prefix (`: :;cmd`) and skips bash -/// `HISTTIMEFORMAT` timestamp comments. +/// Parse one shell-history file into command lines, appending to `out`, +/// carrying over the timestamps the file records: zsh's extended-format prefix +/// (`: :;cmd`) and bash's `HISTTIMEFORMAT` comment (`#` on +/// the line *before* the command). /// /// Each physical line becomes its own entry — we deliberately do *not* stitch /// backslash-continued multi-line commands back together. bash stores multi-line @@ -266,45 +368,62 @@ fn load_shell_history() -> Vec { /// that wreck the single-line completion menu's layout and (b) on bash, wrongly /// swallow the following command. A few stray fragments from a zsh here-doc are a /// fair price for robustness. -fn parse_shell_history(content: &str, out: &mut Vec) { +fn parse_shell_history(content: &str, out: &mut Vec) { + // A bash timestamp comment stamps the *next* command line. + let mut pending_ts: Option = None; for raw in content.split('\n') { let line = raw.strip_suffix('\r').unwrap_or(raw); - if let Some(cmd) = start_of_command(line) { + if let Some(ts) = bash_timestamp(line) { + pending_ts = Some(ts); + continue; + } + if let Some((cmd, zsh_ts)) = start_of_command(line) { let cmd = cmd.trim(); if !cmd.is_empty() { - out.push(cmd.to_string()); + out.push(Raw { + cmd: cmd.to_string(), + cwd: None, + ts: zsh_ts.or(pending_ts), + exit: None, + }); } } + pending_ts = None; } } -/// The command text at the start of a history line, or `None` for lines that -/// carry no command (blank lines, bash timestamp comments). Strips the zsh -/// extended-history `": :;"` prefix when present. -fn start_of_command(line: &str) -> Option<&str> { +/// The bash `HISTTIMEFORMAT` timestamp comment (`#1700000000`), if that's what +/// this line is. It carries no command itself — it stamps the following line. +fn bash_timestamp(line: &str) -> Option { + let rest = line.strip_prefix('#')?; + if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + rest.parse().ok() +} + +/// The command text at the start of a history line plus the zsh +/// extended-history timestamp when the line carries one, or `None` for blank +/// lines. Strips the `": :;"` prefix when present. +fn start_of_command(line: &str) -> Option<(&str, Option)> { if line.is_empty() { return None; } // zsh extended history: ": 1700000000:0;the command". The timestamp field // must hold at least one digit — an empty/colon-only prefix would otherwise // match a *real* command like `: ;echo hi` and wrongly strip its head. - if let Some(rest) = line.strip_prefix(": ") { - if let Some(semi) = rest.find(';') { - let ts = &rest[..semi]; - if ts.bytes().any(|b| b.is_ascii_digit()) - && ts.bytes().all(|b| b.is_ascii_digit() || b == b':') - { - return Some(&rest[semi + 1..]); - } + if let Some(rest) = line.strip_prefix(": ") + && let Some(semi) = rest.find(';') + { + let ts = &rest[..semi]; + if ts.bytes().any(|b| b.is_ascii_digit()) + && ts.bytes().all(|b| b.is_ascii_digit() || b == b':') + { + let start = ts.split(':').next().and_then(|t| t.parse().ok()); + return Some((&rest[semi + 1..], start)); } } - // bash HISTTIMEFORMAT timestamp comment: "#1700000000". - if let Some(rest) = line.strip_prefix('#') { - if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) { - return None; - } - } - Some(line) + Some((line, None)) } #[cfg(test)] @@ -314,7 +433,13 @@ mod tests { fn parse(content: &str) -> Vec { let mut out = Vec::new(); parse_shell_history(content, &mut out); - out + out.into_iter().map(|r| r.cmd).collect() + } + + fn parse_ts(content: &str) -> Vec<(String, Option)> { + let mut out = Vec::new(); + parse_shell_history(content, &mut out); + out.into_iter().map(|r| (r.cmd, r.ts)).collect() } #[test] @@ -326,15 +451,29 @@ mod tests { } #[test] - fn zsh_extended_prefix_is_stripped() { + fn zsh_extended_prefix_is_stripped_and_timestamp_kept() { let content = ": 1700000000:0;git status\n: 1700000005:2;cargo build\n"; - assert_eq!(parse(content), ["git status", "cargo build"]); + assert_eq!( + parse_ts(content), + [ + ("git status".to_string(), Some(1_700_000_000)), + ("cargo build".to_string(), Some(1_700_000_005)), + ] + ); } #[test] - fn bash_timestamp_comments_are_skipped() { - let content = "#1700000000\nls -la\n#1700000005\ncd ..\n"; - assert_eq!(parse(content), ["ls -la", "cd .."]); + fn bash_timestamp_comments_stamp_the_next_command() { + let content = "#1700000000\nls -la\n#1700000005\ncd ..\nuntimed\n"; + assert_eq!( + parse_ts(content), + [ + ("ls -la".to_string(), Some(1_700_000_000)), + ("cd ..".to_string(), Some(1_700_000_005)), + // No comment directly above → no timestamp bleeds over. + ("untimed".to_string(), None), + ] + ); } #[test] @@ -348,26 +487,50 @@ mod tests { assert!(got.iter().all(|e| !e.contains('\n'))); } - fn pair(cmd: &str, cwd: Option<&str>) -> (String, Option) { - (cmd.to_string(), cwd.map(str::to_string)) + fn pair(cmd: &str, cwd: Option<&str>) -> Raw { + Raw { + cmd: cmd.to_string(), + cwd: cwd.map(str::to_string), + ts: None, + exit: None, + } } #[test] - fn parse_own_line_reads_cwd_prefix_and_legacy_lines() { + fn parse_own_line_reads_all_generations() { + // Current format: ts, exit, cwd, command. + let r = parse_own_line("1700000000\t0\t/home/me\tgit status"); + assert_eq!(r.cmd, "git status"); + assert_eq!(r.cwd.as_deref(), Some("/home/me")); + assert_eq!(r.ts, Some(1_700_000_000)); + assert_eq!(r.exit, Some(0)); + // Exit unknown (pane died mid-command) and cwd unknown stay empty fields. + let r = parse_own_line("1700000000\t\t\tmake"); assert_eq!( - parse_own_line("/home/me\tgit status"), - ("git status".to_string(), Some("/home/me".to_string())) + (r.cmd.as_str(), r.cwd, r.ts, r.exit), + ("make", None, Some(1_700_000_000), None) + ); + // The command is the last field, so its own tabs survive. + let r = parse_own_line("1700000000\t1\t/a\techo\tfoo"); + assert_eq!(r.cmd, "echo\tfoo"); + assert_eq!(r.exit, Some(1)); + // Previous generation: `\t`. + let r = parse_own_line("/home/me\tgit status"); + assert_eq!( + (r.cmd.as_str(), r.cwd.as_deref(), r.ts), + ("git status", Some("/home/me"), None) ); // Windows absolute cwd is recognized too (cross-platform, host-independent). - // `\\` are literal backslashes; `\t` is the real tab separator. + let r = parse_own_line("C:\\Users\\me\tgit status"); + assert_eq!(r.cwd.as_deref(), Some("C:\\Users\\me")); + // Legacy bare command — no tab, no metadata. + let r = parse_own_line("ls -la"); assert_eq!( - parse_own_line("C:\\Users\\me\tgit status"), - ("git status".to_string(), Some("C:\\Users\\me".to_string())) + (r.cmd.as_str(), r.cwd, r.ts, r.exit), + ("ls -la", None, None, None) ); - // Legacy bare command — no tab, no cwd. - assert_eq!(parse_own_line("ls -la"), ("ls -la".to_string(), None)); // A tab whose pre-part isn't an absolute path is not treated as a cwd. - assert_eq!(parse_own_line("echo\tfoo"), ("echo\tfoo".to_string(), None)); + assert_eq!(parse_own_line("echo\tfoo").cmd, "echo\tfoo"); } #[test] @@ -398,6 +561,34 @@ mod tests { assert_eq!(dirs.len(), 2); } + #[test] + fn normalize_keeps_the_most_recent_runs_metadata() { + let with_meta = |cmd: &str, ts: u64, exit: Option| Raw { + cmd: cmd.to_string(), + cwd: None, + ts: Some(ts), + exit, + }; + let raw = vec![ + with_meta("make", 100, Some(2)), + pair("ls", None), + with_meta("make", 200, Some(0)), + // The newest occurrence has no metadata (a shell-history duplicate): + // the newest occurrence *with* metadata still wins. + pair("make", None), + ]; + let h = normalize(raw); + assert_eq!( + h.meta.get("make"), + Some(&EntryMeta { + ts: Some(200), + exit: Some(0) + }) + ); + // No metadata anywhere → no entry. + assert_eq!(h.meta.get("ls"), None); + } + #[test] fn frecency_ranks_frequent_over_merely_recent() { // `git status` is old but run many times; `oops typo` is the newest line @@ -437,6 +628,29 @@ mod tests { assert_eq!(neutral[1], "npm test"); } + #[test] + fn frecency_scores_align_with_the_ranking() { + let entries = vec!["a".to_string(), "b".to_string()]; + let scores = frecency_scores(&entries, &HashMap::new(), &HashMap::new(), None); + assert_eq!(scores.len(), 2); + // Same count, so the newer entry scores strictly higher (recency). + assert!(scores[1] > scores[0]); + } + + #[test] + fn format_ago_picks_readable_units() { + let now = 1_700_000_000; + assert_eq!(format_ago(now, now - 5), "now"); + assert_eq!(format_ago(now, now - 300), "5m"); + assert_eq!(format_ago(now, now - 2 * 3600), "2h"); + assert_eq!(format_ago(now, now - 3 * 86_400), "3d"); + assert_eq!(format_ago(now, now - 20 * 86_400), "2w"); + assert_eq!(format_ago(now, now - 90 * 86_400), "3mo"); + assert_eq!(format_ago(now, now - 800 * 86_400), "2y"); + // A clock that went backwards degrades to "now", never underflows. + assert_eq!(format_ago(now, now + 100), "now"); + } + #[test] fn looks_absolute_recognizes_unix_and_windows_roots() { assert!(looks_absolute("/home/me")); @@ -451,37 +665,45 @@ mod tests { } #[test] - fn start_of_command_strips_prefixes_and_skips_timestamps() { - // zsh extended-history prefix is stripped. + fn start_of_command_strips_prefixes_and_keeps_timestamps() { + // zsh extended-history prefix is stripped, its start timestamp kept. assert_eq!( start_of_command(": 1700000000:0;git status"), - Some("git status") + Some(("git status", Some(1_700_000_000))) ); // A colon-prefixed line whose middle isn't numeric is taken verbatim. - assert_eq!(start_of_command(": not-a-ts;cmd"), Some(": not-a-ts;cmd")); + assert_eq!( + start_of_command(": not-a-ts;cmd"), + Some((": not-a-ts;cmd", None)) + ); // Regression: an empty or colon-only "timestamp" is not the zsh format — // the line is a real command (`: ;echo hi` runs the colon builtin, then // echo) and must NOT have its head stripped. - assert_eq!(start_of_command(": ;echo hi"), Some(": ;echo hi")); - assert_eq!(start_of_command(": :::;cmd"), Some(": :::;cmd")); - // A bash timestamp comment carries no command. - assert_eq!(start_of_command("#1700000000"), None); - // A real comment-looking line with non-digits is a command. - assert_eq!(start_of_command("#notdigits"), Some("#notdigits")); + assert_eq!(start_of_command(": ;echo hi"), Some((": ;echo hi", None))); + assert_eq!(start_of_command(": :::;cmd"), Some((": :::;cmd", None))); // Blank → None. assert_eq!(start_of_command(""), None); // Plain command passes through. - assert_eq!(start_of_command("ls -la"), Some("ls -la")); + assert_eq!(start_of_command("ls -la"), Some(("ls -la", None))); + } + + #[test] + fn bash_timestamp_recognizes_only_all_digit_comments() { + assert_eq!(bash_timestamp("#1700000000"), Some(1_700_000_000)); + // A real comment-looking line with non-digits is a command, not a stamp. + assert_eq!(bash_timestamp("#notdigits"), None); + assert_eq!(bash_timestamp("#"), None); + assert_eq!(bash_timestamp("ls"), None); } #[test] fn normalize_dedups_counts_and_caps_entries() { // Duplicates collapse to the most recent position, with a run count tallied. let raw = vec![ - ("ls".to_string(), Some("/a".to_string())), - ("git".to_string(), None), - ("".to_string(), None), // blank dropped - ("ls".to_string(), Some("/b".to_string())), + pair("ls", Some("/a")), + pair("git", None), + pair("", None), // blank dropped + pair("ls", Some("/b")), ]; let h = normalize(raw); // "ls" moved to the end (most recent) and "git" stayed; blank gone. @@ -492,8 +714,8 @@ mod tests { assert!(dirs.contains("/a") && dirs.contains("/b")); // The cap keeps only the most recent MAX_ENTRIES unique lines. - let big: Vec<(String, Option)> = (0..MAX_ENTRIES + 50) - .map(|i| (format!("cmd{i}"), None)) + let big: Vec = (0..MAX_ENTRIES + 50) + .map(|i| pair(&format!("cmd{i}"), None)) .collect(); let capped = normalize(big); assert_eq!(capped.entries.len(), MAX_ENTRIES); @@ -505,23 +727,34 @@ mod tests { } #[test] - fn append_then_load_recovers_the_command() { + fn append_then_load_recovers_the_command_and_metadata() { // Pin the config dir so history writes to a temp file, not the real one. let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); crate::core::config::set_config_dir(dir); // A command with an embedded newline is rejected (one-per-line format). - append("bad\ncmd", None); + append("bad\ncmd", None, 1_700_000_000, None); - // A unique command tagged with an absolute cwd round-trips through load(). + // A unique command tagged with cwd/ts/exit round-trips through load(). let unique = format!("tty7_cov_marker_{}", std::process::id()); - append(&unique, Some(Path::new("/tmp"))); + append(&unique, Some(Path::new("/tmp")), 1_700_000_123, Some(1)); let loaded = load(); assert!( loaded.entries.iter().any(|e| e == &unique), "appended command should be recalled by load()" ); + assert_eq!( + loaded.meta.get(&unique), + Some(&EntryMeta { + ts: Some(1_700_000_123), + exit: Some(1) + }) + ); + assert!( + loaded.cwds.get(&unique).is_some_and(|d| d.contains("/tmp")), + "cwd association should round-trip" + ); assert!( !loaded.entries.iter().any(|e| e.contains('\n')), "newline command was never written" @@ -544,7 +777,12 @@ mod tests { let tag = tag.clone(); std::thread::spawn(move || { for i in 0..25 { - append(&format!("{tag}_{t}_{i}"), Some(Path::new("/tmp"))); + append( + &format!("{tag}_{t}_{i}"), + Some(Path::new("/tmp")), + 1_700_000_000, + Some(0), + ); } }) }) @@ -568,17 +806,22 @@ mod tests { #[test] fn append_rejects_a_cwd_that_would_break_the_line_format() { // Regression: a cwd containing a newline used to be written verbatim into - // the `\t` line, splitting the record — the pre-newline half - // loaded back as a bogus command and the real command gained a wrong cwd. - // Such a cwd is dropped (legacy bare form) so the record stays one line. + // the record, splitting it — the pre-newline half loaded back as a bogus + // command and the real command gained a wrong cwd. Such a cwd is dropped + // (empty field) so the record stays one line. let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); crate::core::config::set_config_dir(dir); let unique = format!("tty7_nlcwd_marker_{}", std::process::id()); - append(&unique, Some(Path::new("/tmp/evil\n/tmp/tail"))); + append( + &unique, + Some(Path::new("/tmp/evil\n/tmp/tail")), + 1_700_000_000, + None, + ); let loaded = load(); - // The command itself survives, as a bare entry… + // The command itself survives… assert!(loaded.entries.iter().any(|e| e == &unique)); // …with no cwd association (the unusable path was dropped, not split)… assert!(loaded.cwds.get(&unique).is_none_or(|d| d.is_empty())); diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 58ce34cf..be85c6c7 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -19,6 +19,7 @@ mod cmd_editor; mod completion; pub mod element; pub mod fps; +mod fuzzy; mod highlight; mod history; mod hold; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 44627cd9..dce32dc9 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -84,6 +84,11 @@ struct ShellState { active: bool, at_prompt: bool, last_exit: Option, + /// Monotonic count of `Prompt` reports applied. Lets the view tell a + /// *fresh* prompt (the shell cycled through the submitted command and came + /// back) from the stale pre-submit state — even when 1 Hz polling misses + /// the intermediate not-at-prompt window of a fast command. + seq: u64, } /// The shared handles the reader thread writes into as daemon frames arrive; @@ -513,6 +518,7 @@ impl RemoteTerminal { active, at_prompt, last_exit, + seq: guard.seq + 1, }; } // The shell just reported a fresh prompt, so at @@ -724,6 +730,19 @@ impl RemoteTerminal { .unwrap_or(false) } + /// Monotonic count of `Prompt` reports applied so far — see + /// [`ShellState::seq`]. Comparing values from before and after a submit + /// tells whether the shell has reported back since. + pub fn prompt_seq(&self) -> u64 { + self.shell_state.lock().map(|s| s.seq).unwrap_or(0) + } + + /// Exit code of the most recently completed foreground command, as sniffed + /// from OSC 133;D daemon-side. `None` before any command has finished. + pub fn last_exit_code(&self) -> Option { + self.shell_state.lock().ok().and_then(|s| s.last_exit) + } + /// Whether shell integration has engaged at all (the daemon has seen any /// OSC 133 from this pane). False for the whole rc-sourcing window after /// spawn, and forever for shells without integration. Gates the gap-input diff --git a/src/terminal/reverse_search.rs b/src/terminal/reverse_search.rs index 80b245dc..1b361bb5 100644 --- a/src/terminal/reverse_search.rs +++ b/src/terminal/reverse_search.rs @@ -1,96 +1,177 @@ -//! Ctrl+R reverse-history search, extracted from the terminal view so the search -//! *logic* (query editing + scanning `history` for a match) lives apart from the -//! GPUI plumbing (focus, repaint). The view owns an `Option`, -//! forwards keys and typed text to it, and acts on the returned [`Action`] — -//! it never reaches into the query or match index directly. +//! Ctrl+R history search, extracted from the terminal view so the search +//! *logic* (query editing + ranking `history` into a match list) lives apart +//! from the GPUI plumbing (focus, repaint). The view owns an +//! `Option`, forwards keys and typed text to it, and acts on the +//! returned [`Action`] — it never reaches into the query or match list beyond +//! the read-only accessors the menu renderer uses. +//! +//! Matching is fuzzy (see the [`fuzzy`](super::fuzzy) module), blended with the +//! entry's frecency so a command you run constantly — or ran *in this +//! directory* — outranks an equally-good textual match you typed once. An +//! empty query ranks the whole history by frecency alone, so bare Ctrl+R is a +//! browsable "recent & relevant" list rather than a blank prompt. -/// In-progress reverse search: the typed query and the history index of the -/// current match (the most recent match at or older than the last step). +use super::fuzzy; +use std::collections::HashSet; + +/// How much an entry's frecency score (roughly `0..7`: recency `0..1` + +/// dampened frequency + current-directory bonus) adds to its fuzzy match +/// score (16+ per matched char). At 2× it decides ties and near-ties between +/// textually similar matches without ever drowning a clearly better match. +const FRECENCY_WEIGHT: f64 = 2.0; + +/// In-progress search: the typed query and the ranked matches, best first. pub(super) struct ReverseSearch { query: String, - match_index: Option, + matches: Vec, + /// Cursor into `matches`: the entry Enter accepts, highlighted in the menu. + selected: usize, +} + +/// One ranked match: where it lives in the view's chronological `history`, and +/// which of its chars the query matched (for menu highlighting; empty for the +/// empty-query frecency listing). +pub(super) struct Match { + pub index: usize, + pub positions: Vec, } /// What the view should do after handing a key to an active search. pub(super) enum Action { - /// Stay open; just repaint (query or match changed, or the key was ignored). + /// Stay open; just repaint (query, matches or selection changed). Redraw, /// Close the search and leave the edited line untouched (Esc / Ctrl+G / Ctrl+C). Cancel, - /// Close the search; if `Some`, load that history line into the editor (Enter). + /// Close the search; if `Some`, load that history line into the editor + /// (Enter — the user still presses Enter again to run it). Accept(Option), + /// Close the search and run that history line outright (Cmd+Enter). + Run(String), } impl ReverseSearch { - pub(super) fn new() -> Self { - Self { + /// Open a search: the empty query immediately lists the history by + /// frecency, so the menu is useful before a single key is typed. + pub(super) fn new(history: &[String], frecency: &[f64]) -> Self { + let mut rs = Self { query: String::new(), - match_index: None, - } + matches: Vec::new(), + selected: 0, + }; + rs.update(history, frecency); + rs } - /// The `(reverse-i-search)` query text, for the prompt the view renders. + /// The typed query, for the prompt the view renders. pub(super) fn query(&self) -> &str { &self.query } - /// History index of the current match, if any — the view highlights it. - pub(super) fn match_index(&self) -> Option { - self.match_index + /// The ranked matches, best first — the menu renders a window of these. + pub(super) fn matches(&self) -> &[Match] { + &self.matches } - /// Recompute the current match. `advance` continues to the next *older* match - /// (Ctrl+R again); otherwise the scan starts from the newest entry. - pub(super) fn update(&mut self, history: &[String], advance: bool) { - if self.query.is_empty() { - self.match_index = None; - return; - } - let q = self.query.to_lowercase(); - // Upper bound (exclusive): everything when refining the query, or the - // current match when stepping to an older one. - let upper = if advance { - self.match_index.unwrap_or(history.len()) - } else { - history.len() - }; - let found = (0..upper) - .rev() - .find(|&i| history[i].to_lowercase().contains(&q)); - // Stepping to an older match (Ctrl+R again) that finds nothing means we're - // already on the oldest hit — keep it rather than blanking the match. When - // refining the query, an empty result genuinely means "no match". - if !(advance && found.is_none()) { - self.match_index = found; - } + /// Index of the selected match within [`matches`](Self::matches). + pub(super) fn selected(&self) -> usize { + self.selected } - /// Append typed text to the query and re-search. Text arrives either via the - /// IME path (`replace_text_in_range` → the view's `input_text`) or, for a plain - /// ASCII input source, as a direct `key_char` the view forwards from + /// The history line the selection sits on, if any. + pub(super) fn selected_line<'a>(&self, history: &'a [String]) -> Option<&'a str> { + self.matches + .get(self.selected) + .map(|m| history[m.index].as_str()) + } + + /// Recompute the match list. Entries are deduplicated by content (the most + /// recent occurrence wins) and ranked by fuzzy score blended with frecency; + /// an empty query ranks everything by frecency alone. `frecency` is + /// index-aligned with `history`. Resets the selection to the best match. + fn update(&mut self, history: &[String], frecency: &[f64]) { + self.selected = 0; + let list_all = self.query.trim().is_empty(); + let mut seen: HashSet<&str> = HashSet::new(); + let mut scored: Vec<(f64, Match)> = Vec::new(); + // Newest → oldest, so the stable sort below keeps recent entries first + // among equal scores. + for i in (0..history.len()).rev() { + let line = history[i].as_str(); + if !seen.insert(line) { + continue; + } + let f = frecency.get(i).copied().unwrap_or(0.0); + if list_all { + scored.push(( + f, + Match { + index: i, + positions: Vec::new(), + }, + )); + } else if let Some(m) = fuzzy::match_line(line, &self.query) { + scored.push(( + f64::from(m.score) + FRECENCY_WEIGHT * f, + Match { + index: i, + positions: m.positions, + }, + )); + } + } + scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + self.matches = scored.into_iter().map(|(_, m)| m).collect(); + } + + /// Move the selection `delta` steps down the ranked list (positive → worse + /// matches, the classic "older hit" direction of a repeated Ctrl+R), + /// sticking at the ends. + fn step(&mut self, delta: isize) { + let last = self.matches.len().saturating_sub(1); + self.selected = self.selected.saturating_add_signed(delta).min(last); + } + + /// Append typed text to the query and re-rank. Text arrives either via the + /// IME path (`replace_text_in_range` → the view's `input_text`) or, for a + /// plain ASCII input source, as a direct `key_char` the view forwards from /// `handle_reverse_search_key`. - pub(super) fn push_query(&mut self, text: &str, history: &[String]) { + pub(super) fn push_query(&mut self, text: &str, history: &[String], frecency: &[f64]) { self.query.push_str(text); - self.update(history, false); + self.update(history, frecency); } /// Handle a key while the search is active. Query text itself arrives via /// [`push_query`](Self::push_query); this covers the control keys only. - pub(super) fn handle_key(&mut self, ks: &gpui::Keystroke, history: &[String]) -> Action { + pub(super) fn handle_key( + &mut self, + ks: &gpui::Keystroke, + history: &[String], + frecency: &[f64], + ) -> Action { let m = &ks.modifiers; let key = ks.key.as_str(); - if m.control && key == "r" { - self.update(history, true); + if (m.control && key == "r") || key == "down" { + // Next (worse-ranked) match — the classic repeated-Ctrl+R step. + self.step(1); + Action::Redraw + } else if (m.control && key == "s") || key == "up" { + // Back toward the best match (readline's forward-search direction). + self.step(-1); Action::Redraw } else if (m.control && (key == "g" || key == "c")) || key == "escape" { Action::Cancel } else if key == "enter" { - // Accept: hand back the match (the user still presses Enter to run it). - // A bare Enter with no match just exits the search. - Action::Accept(self.match_index.map(|i| history[i].clone())) + let line = self.selected_line(history).map(str::to_string); + match (m.platform, line) { + // Cmd+Enter: run the selected line outright. + (true, Some(line)) => Action::Run(line), + // Enter: hand back the match (the user still presses Enter to + // run it). A bare Enter with no match just exits the search. + (_, line) => Action::Accept(line), + } } else if key == "backspace" { self.query.pop(); - self.update(history, false); + self.update(history, frecency); Action::Redraw } else { // Other keys are ignored while searching. @@ -111,42 +192,9 @@ mod tests { .collect() } - #[test] - fn update_finds_the_most_recent_match() { - let h = history(); - let mut rs = ReverseSearch::new(); - rs.push_query("git", &h); - assert_eq!(rs.match_index(), Some(2)); // "git commit -m x" - } - - #[test] - fn advance_steps_to_older_matches_then_sticks_on_the_oldest() { - let h = history(); - let mut rs = ReverseSearch::new(); - rs.push_query("git", &h); - assert_eq!(rs.match_index(), Some(2)); - rs.update(&h, true); - assert_eq!(rs.match_index(), Some(0)); // "git status" - rs.update(&h, true); // no older match — keep the oldest hit - assert_eq!(rs.match_index(), Some(0)); - } - - #[test] - fn refining_the_query_can_drop_the_match() { - let h = history(); - let mut rs = ReverseSearch::new(); - rs.push_query("cargo", &h); - assert_eq!(rs.match_index(), Some(3)); // "cargo test" - rs.push_query("_nope", &h); - assert_eq!(rs.match_index(), None); - } - - #[test] - fn empty_query_has_no_match() { - let h = history(); - let mut rs = ReverseSearch::new(); - rs.update(&h, false); - assert_eq!(rs.match_index(), None); + /// Uniform frecency: ranking falls back to fuzzy score + recency order. + fn flat(h: &[String]) -> Vec { + vec![0.0; h.len()] } fn key(spec: &str) -> gpui::Keystroke { @@ -154,62 +202,144 @@ mod tests { } #[test] - fn handle_key_ctrl_r_steps_to_older_match_and_redraws() { + fn empty_query_lists_everything_newest_first_under_flat_frecency() { let h = history(); - let mut rs = ReverseSearch::new(); - rs.push_query("git", &h); - assert_eq!(rs.match_index(), Some(2)); - // Ctrl+R again advances to the older match and asks for a redraw. - assert!(matches!(rs.handle_key(&key("ctrl-r"), &h), Action::Redraw)); - assert_eq!(rs.match_index(), Some(0)); + let rs = ReverseSearch::new(&h, &flat(&h)); + let order: Vec = rs.matches().iter().map(|m| m.index).collect(); + assert_eq!(order, [3, 2, 1, 0]); + assert_eq!(rs.selected_line(&h), Some("cargo test")); + } + + #[test] + fn query_ranks_the_most_recent_equal_match_first() { + let h = history(); + let mut rs = ReverseSearch::new(&h, &flat(&h)); + rs.push_query("git", &h, &flat(&h)); + // Both git commands match equally well; the newer one wins the tie. + assert_eq!(rs.selected_line(&h), Some("git commit -m x")); + assert_eq!(rs.matches().len(), 2); + } + + #[test] + fn fuzzy_matching_spans_words() { + // `gst` is a subsequence of `git status` — the substring search this + // replaces could never find it. + let h = history(); + let mut rs = ReverseSearch::new(&h, &flat(&h)); + rs.push_query("gst", &h, &flat(&h)); + assert_eq!(rs.selected_line(&h), Some("git status")); + // The matched positions point at g, s, t for the menu highlight. + assert_eq!(rs.matches()[0].positions, vec![0, 4, 5]); + } + + #[test] + fn frecency_outranks_recency_between_equal_text_matches() { + let h = history(); + // "git status" (oldest) is heavily used; the newer "git commit -m x" + // is a one-off. The blend should float the frequent one on top. + let frecency = vec![5.0, 0.0, 0.0, 0.0]; + let mut rs = ReverseSearch::new(&h, &frecency); + rs.push_query("git", &h, &frecency); + assert_eq!(rs.selected_line(&h), Some("git status")); + } + + #[test] + fn duplicates_collapse_to_their_most_recent_occurrence() { + let h: Vec = ["ls", "make", "ls"].into_iter().map(String::from).collect(); + let rs = ReverseSearch::new(&h, &flat(&h)); + let idx: Vec = rs.matches().iter().map(|m| m.index).collect(); + assert_eq!(idx, [2, 1]); // one "ls", at its newest position + } + + #[test] + fn ctrl_r_and_arrows_step_through_matches_and_stick_at_the_ends() { + let h = history(); + let mut rs = ReverseSearch::new(&h, &flat(&h)); + rs.push_query("git", &h, &flat(&h)); + assert_eq!(rs.selected(), 0); + assert!(matches!( + rs.handle_key(&key("ctrl-r"), &h, &flat(&h)), + Action::Redraw + )); + assert_eq!(rs.selected_line(&h), Some("git status")); + // Already on the last match — a further step sticks. + rs.handle_key(&key("ctrl-r"), &h, &flat(&h)); + assert_eq!(rs.selected(), 1); + // Ctrl+S / Up steps back toward the best match, sticking at the top. + rs.handle_key(&key("ctrl-s"), &h, &flat(&h)); + assert_eq!(rs.selected(), 0); + rs.handle_key(&key("up"), &h, &flat(&h)); + assert_eq!(rs.selected(), 0); + rs.handle_key(&key("down"), &h, &flat(&h)); + assert_eq!(rs.selected(), 1); } #[test] fn handle_key_cancel_keys() { let h = history(); - let mut rs = ReverseSearch::new(); - assert!(matches!(rs.handle_key(&key("ctrl-g"), &h), Action::Cancel)); - assert!(matches!(rs.handle_key(&key("ctrl-c"), &h), Action::Cancel)); - assert!(matches!(rs.handle_key(&key("escape"), &h), Action::Cancel)); + let mut rs = ReverseSearch::new(&h, &flat(&h)); + assert!(matches!( + rs.handle_key(&key("ctrl-g"), &h, &flat(&h)), + Action::Cancel + )); + assert!(matches!( + rs.handle_key(&key("ctrl-c"), &h, &flat(&h)), + Action::Cancel + )); + assert!(matches!( + rs.handle_key(&key("escape"), &h, &flat(&h)), + Action::Cancel + )); } #[test] - fn handle_key_enter_accepts_current_match_or_none() { + fn enter_accepts_and_cmd_enter_runs_the_selection() { let h = history(); - let mut rs = ReverseSearch::new(); - rs.push_query("cargo", &h); - match rs.handle_key(&key("enter"), &h) { + let mut rs = ReverseSearch::new(&h, &flat(&h)); + rs.push_query("cargo", &h, &flat(&h)); + match rs.handle_key(&key("enter"), &h, &flat(&h)) { Action::Accept(Some(line)) => assert_eq!(line, "cargo test"), - _ => panic!("expected Accept(Some) with the matched line"), + _ => panic!("expected Accept(Some) with the selected line"), } - // A bare Enter with no active match accepts nothing (just exits). - let mut empty = ReverseSearch::new(); - match empty.handle_key(&key("enter"), &h) { + let mut rs = ReverseSearch::new(&h, &flat(&h)); + rs.push_query("cargo", &h, &flat(&h)); + match rs.handle_key(&key("cmd-enter"), &h, &flat(&h)) { + Action::Run(line) => assert_eq!(line, "cargo test"), + _ => panic!("expected Run with the selected line"), + } + // A bare Enter with no match accepts nothing (just exits). + let mut rs = ReverseSearch::new(&h, &flat(&h)); + rs.push_query("zzz_nope", &h, &flat(&h)); + assert!(rs.matches().is_empty()); + match rs.handle_key(&key("enter"), &h, &flat(&h)) { Action::Accept(None) => {} _ => panic!("expected Accept(None) with no match"), } } #[test] - fn handle_key_backspace_pops_query_and_re_searches() { + fn handle_key_backspace_pops_query_and_re_ranks() { let h = history(); - let mut rs = ReverseSearch::new(); - rs.push_query("gitx", &h); // no match (no "gitx" in history) - assert_eq!(rs.match_index(), None); - // Backspace drops the trailing 'x', restoring the "git" match. + let mut rs = ReverseSearch::new(&h, &flat(&h)); + rs.push_query("gitq", &h, &flat(&h)); // no match (no q anywhere) + assert!(rs.matches().is_empty()); + // Backspace drops the trailing 'q', restoring the git matches. assert!(matches!( - rs.handle_key(&key("backspace"), &h), + rs.handle_key(&key("backspace"), &h, &flat(&h)), Action::Redraw )); assert_eq!(rs.query(), "git"); - assert_eq!(rs.match_index(), Some(2)); + assert_eq!(rs.selected_line(&h), Some("git commit -m x")); } #[test] fn handle_key_other_keys_are_ignored_with_redraw() { let h = history(); - let mut rs = ReverseSearch::new(); + let mut rs = ReverseSearch::new(&h, &flat(&h)); // A plain letter is handled via push_query, not handle_key; here it's a no-op redraw. - assert!(matches!(rs.handle_key(&key("a"), &h), Action::Redraw)); + assert!(matches!( + rs.handle_key(&key("a"), &h, &flat(&h)), + Action::Redraw + )); } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index ab5a632a..e9324969 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -186,12 +186,20 @@ pub struct TerminalView { /// current-directory half of the frecency ranking, so commands used *here* /// float up. Kept in step with `history` on submit. history_cwds: std::collections::HashMap>, + /// Last-run metadata (timestamp + exit code) per history line, feeding the + /// Ctrl+R menu's "ran 3h ago" and failure badges. Kept in step with + /// `history` on submit; the exit code lands when the shell reports back. + history_meta: std::collections::HashMap, /// `history` re-ordered by frecency (frequency × recency + a current-directory /// bonus), most relevant first. Drives the ghost-text autosuggestion — the /// sole whole-line recall surface besides Ctrl+R (the Tab menu stays /// history-free). Recomputed when a command is run or the working directory /// changes. history_ranked: Vec, + /// The frecency score of each `history` entry, index-aligned with it — the + /// relevance half of the Ctrl+R search's fuzzy+frecency blend. Recomputed + /// alongside `history_ranked`. + history_frecency: Vec, /// The directory `history_ranked` was last computed for, so the polling loop /// only re-ranks when the working directory actually changes. ranked_cwd: Option, @@ -201,14 +209,20 @@ pub struct TerminalView { /// The in-progress line saved when history navigation starts, so pressing ↓ /// past the newest entry restores what the user was typing. history_stash: String, + /// A submitted command whose history-file record is deferred until the + /// shell reports back at its prompt, so the record can carry the command's + /// exit code (see [`PendingHistory`]). + pending_history: Option, /// Open Tab-completion menu, if any — a picker over the candidates gathered /// when it opened. Typing/Backspace re-filter it in place; it closes on /// accept, on Escape, or once the edited word no longer matches anything. completion: Option, - /// Active Ctrl+R reverse-history search, if any. While set, the editor shows a - /// `(reverse-i-search)` prompt instead of the line: typing edits the query, - /// Ctrl+R steps to older matches, Enter accepts the match into the line, and - /// Escape/Ctrl+G cancels. + /// Active Ctrl+R history search, if any. While set, the editor shows a + /// `(reverse-i-search)` prompt instead of the line and a menu of the ranked + /// matches floats beside it: typing edits the query (fuzzy, blended with + /// frecency), Ctrl+R/↓ and Ctrl+S/↑ move the selection, Enter accepts the + /// selection into the line, Cmd+Enter runs it outright, and Escape/Ctrl+G + /// cancels. reverse_search: Option, /// True while a left-drag that began on the command-editor line is in progress, /// so mouse-move extends the editor selection rather than the terminal's. @@ -240,6 +254,26 @@ pub(super) struct HoveredLink { pub end: usize, } +/// A submitted command whose history-file record is deferred so it can carry +/// the command's exit code (like zsh's `INC_APPEND_HISTORY_TIME`). `seq` is +/// [`RemoteTerminal::prompt_seq`] at submit time: a later report that puts the +/// shell back at its prompt means the run completed and `last_exit_code()` is +/// this command's. Flushed without an exit code if the view goes away first. +struct PendingHistory { + line: String, + cwd: Option, + ts: u64, + seq: u64, +} + +/// Seconds since the unix epoch — the timestamp history records carry. +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + /// Outcome of a ⌘ shortcut at the terminal surface — the three control-flow /// paths the key dispatcher needs. Splitting the ⌘ block into its own method /// keeps `on_key_down` readable; the caller maps each variant back to the @@ -622,6 +656,8 @@ impl TerminalView { &history.cwds, None, ); + let history_frecency = + super::history::frecency_scores(&history.entries, &history.counts, &history.cwds, None); Self { terminal, @@ -658,10 +694,13 @@ impl TerminalView { history: history.entries, history_counts: history.counts, history_cwds: history.cwds, + history_meta: history.meta, history_ranked, + history_frecency, ranked_cwd: None, history_nav: None, history_stash: String::new(), + pending_history: None, completion: None, reverse_search: None, editor_selecting: false, @@ -1755,6 +1794,19 @@ impl TerminalView { } let at_prompt = self.terminal.at_prompt(); + // A deferred history record is finalized once the shell has reported + // back at its prompt: the daemon's `last_exit` is now this command's. + // Sequence-based, so a fast command whose not-at-prompt window fell + // between polls still gets its exit code. + if self + .pending_history + .as_ref() + .is_some_and(|p| at_prompt && self.terminal.prompt_seq() > p.seq) + { + self.flush_pending_history(); + cx.notify(); + } + // Re-rank history when the working directory changes (a `cd`), so ghost text // and completion start favouring commands run in the new directory. Only on // a real, known change — an unknown cwd keeps the previous ranking. @@ -2038,10 +2090,14 @@ impl TerminalView { } let line = self.cmd.text(); // Record in history (skip blanks and immediate duplicates for ↑/↓ recall), - // but always tally the run — count and the directory it ran in — for - // frecency, then refresh the ranked view for the current directory. + // but always tally the run — count, the directory it ran in, and when — + // for ranking and the Ctrl+R menu, then refresh the ranked view for the + // current directory. The file record is deferred until the shell reports + // back at its prompt, so it can carry this run's exit code; a previous + // record still deferred goes out first. if !line.trim().is_empty() { let cwd = self.cwd(); + let now = unix_now(); *self.history_counts.entry(line.clone()).or_insert(0) += 1; if let Some(dir) = cwd.as_ref().and_then(|p| p.to_str()) { self.history_cwds @@ -2049,10 +2105,23 @@ impl TerminalView { .or_default() .insert(dir.to_string()); } + self.history_meta.insert( + line.clone(), + super::history::EntryMeta { + ts: Some(now), + exit: None, + }, + ); if self.history.last().map(String::as_str) != Some(line.as_str()) { self.history.push(line.clone()); - super::history::append(&line, cwd.as_deref()); } + self.flush_pending_history(); + self.pending_history = Some(PendingHistory { + line: line.clone(), + cwd: cwd.clone(), + ts: now, + seq: self.terminal.prompt_seq(), + }); self.rerank_history(cwd.as_deref()); } self.history_nav = None; @@ -2118,15 +2187,42 @@ impl TerminalView { /// in that directory float to the top of ghost text and completion. Records the /// directory used, so `poll_foreground` can skip re-ranking until it changes. fn rerank_history(&mut self, cwd: Option<&std::path::Path>) { + let cwd_str = cwd.and_then(|p| p.to_str()); self.history_ranked = super::history::rank_by_frecency( &self.history, &self.history_counts, &self.history_cwds, - cwd.and_then(|p| p.to_str()), + cwd_str, + ); + self.history_frecency = super::history::frecency_scores( + &self.history, + &self.history_counts, + &self.history_cwds, + cwd_str, ); self.ranked_cwd = cwd.map(std::path::Path::to_path_buf); } + /// Write the deferred history record (see [`PendingHistory`]), if any. The + /// exit code is attached only when the shell has reported back *and* sits + /// at its prompt again — then `last_exit_code()` is this command's; + /// otherwise (pane going away mid-command, a new submit racing in) the + /// record goes out without one, like a plain shell history line. + fn flush_pending_history(&mut self) { + let Some(p) = self.pending_history.take() else { + return; + }; + let exit = (self.terminal.prompt_seq() > p.seq && self.terminal.at_prompt()) + .then(|| self.terminal.last_exit_code()) + .flatten(); + if exit.is_some() + && let Some(m) = self.history_meta.get_mut(&p.line) + { + m.exit = exit; + } + super::history::append(&p.line, p.cwd.as_deref(), p.ts, exit); + } + /// The autosuggestion (ghost text): the most *frecent* history entry that /// starts with the current line, when the caret is at the end. Returns the /// *full* suggested line; the renderer shows the remainder in muted text and @@ -2144,10 +2240,12 @@ impl TerminalView { .cloned() } - /// Begin a Ctrl+R reverse-history search (no-op if one is already active). + /// Begin a Ctrl+R history search (no-op if one is already active). Opens + /// with the empty query's frecency listing, so the menu is browsable + /// before a single key is typed. fn start_reverse_search(&mut self) { if self.reverse_search.is_none() { - self.reverse_search = Some(ReverseSearch::new()); + self.reverse_search = Some(ReverseSearch::new(&self.history, &self.history_frecency)); } } @@ -2168,7 +2266,7 @@ impl TerminalView { if let Some(ch) = ks.key_char.as_deref() { if !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}') { if let Some(rs) = self.reverse_search.as_mut() { - rs.push_query(ch, &self.history); + rs.push_query(ch, &self.history, &self.history_frecency); } cx.notify(); return; @@ -2178,7 +2276,7 @@ impl TerminalView { let Some(rs) = self.reverse_search.as_mut() else { return; }; - match rs.handle_key(ks, &self.history) { + match rs.handle_key(ks, &self.history, &self.history_frecency) { reverse_search::Action::Redraw => {} reverse_search::Action::Cancel => self.reverse_search = None, reverse_search::Action::Accept(line) => { @@ -2187,6 +2285,12 @@ impl TerminalView { self.cmd.set(&line); } } + reverse_search::Action::Run(line) => { + // Cmd+Enter: accept the selection and run it in one stroke. + self.reverse_search = None; + self.cmd.set(&line); + self.submit_command(cx); + } } cx.notify(); } @@ -2369,7 +2473,7 @@ impl TerminalView { } // While reverse-searching, typed text edits the query, not the line. if let Some(rs) = self.reverse_search.as_mut() { - rs.push_query(text, &self.history); + rs.push_query(text, &self.history, &self.history_frecency); self.cursor_visible = true; cx.notify(); return; @@ -2747,13 +2851,14 @@ impl TerminalView { let cy_top = px(8.) + self.line_height * (crow as f32); // Reverse-search mode replaces the line with a `(reverse-i-search)` prompt - // showing the query and the current match. + // showing the query and the selected match; the ranked candidates float + // in their own menu (`render_reverse_search_menu`). if let Some(rs) = &self.reverse_search { let label = format!("(reverse-i-search)`{}': ", rs.query()); let matched = rs - .match_index() - .map(|i| self.history[i].clone()) - .unwrap_or_default(); + .selected_line(&self.history) + .unwrap_or_default() + .to_string(); return div() .absolute() .left(cx_left) @@ -3080,6 +3185,168 @@ impl TerminalView { ) } + /// The floating Ctrl+R history menu: the ranked matches (best first) in a + /// completion-style popup anchored to the input row — matched characters + /// highlighted, the last-run time and a failure badge on the right. The + /// classic `(reverse-i-search)` prompt stays on the input row itself + /// (`render_input_bar`); this menu is the browsable view of the candidates, + /// windowed around the selection like the completion menu. + fn render_reverse_search_menu( + &self, + cx: &mut Context, + ) -> Option> { + let rs = self.reverse_search.as_ref()?; + let matches = rs.matches(); + if matches.is_empty() { + return None; + } + let (srow, _) = self.cursor_cell()?; + + const MAX_ROWS: usize = 10; + let (total_rows, total_cols) = { + let term = self.terminal.term.lock(); + (term.screen_lines(), term.columns()) + }; + let (place_above, visible, first) = + menu_layout(total_rows, srow, matches.len(), rs.selected(), MAX_ROWS); + let hidden_above = first; + let hidden_below = matches.len() - first - visible; + + let theme = cx.theme(); + let lh = self.line_height; + let now = unix_now(); + let row = |i: usize| { + let m = &matches[i]; + let line = self.history[m.index].as_str(); + let selected = rs.selected() == i; + let base = if selected { + theme.foreground + } else { + theme.popover_foreground + }; + + // The command in runs of matched/unmatched characters, so the + // query's hits read highlighted inside the (possibly clipped) text. + let mut spans: Vec = Vec::new(); + let mut flush = |run: &mut String, hit: bool| { + if run.is_empty() { + return; + } + spans.push( + div() + .flex_none() + .whitespace_nowrap() + .text_color(if hit { theme.blue } else { base }) + .child(std::mem::take(run)) + .into_any_element(), + ); + }; + let mut pos = m.positions.iter().copied().peekable(); + let mut run = String::new(); + let mut run_hit = false; + for (ci, ch) in line.chars().enumerate() { + let hit = pos.next_if_eq(&ci).is_some(); + if hit != run_hit { + flush(&mut run, run_hit); + run_hit = hit; + } + run.push(ch); + } + flush(&mut run, run_hit); + + // Right column: a failure badge when the last run exited non-zero, + // and how long ago that run was. + let meta = self.history_meta.get(line); + let failed = meta.and_then(|em| em.exit).filter(|&e| e != 0); + let ago = meta + .and_then(|em| em.ts) + .map(|ts| super::history::format_ago(now, ts)); + + div() + .h(lh) + .flex() + .items_center() + .gap_1p5() + .px_2() + .whitespace_nowrap() + // Same selection fill as the completion menu (see the note + // there on `list_active` vs the stock `accent`). + .when(selected, |d| d.bg(theme.list_active)) + .child(div().flex_1().flex().overflow_hidden().children(spans)) + .when_some(failed, |d, code| { + d.child( + div() + .flex_none() + .text_color(theme.red) + .child(format!("✗ {code}")), + ) + }) + .when_some(ago, |d, ago| { + d.child( + div() + .flex_none() + .text_color(theme.muted_foreground) + .child(ago), + ) + }) + .into_any_element() + }; + let rows: Vec = (first..first + visible).map(row).collect(); + + // Menu height (for upward placement) = rows + any overflow footers. + let footer = |n: usize, label: String| { + (n > 0).then(|| { + div() + .h(lh) + .flex() + .items_center() + .px_2() + .text_color(theme.muted_foreground) + .child(label) + .into_any_element() + }) + }; + let footer_lines = (hidden_above > 0) as usize + (hidden_below > 0) as usize; + let line_count = visible + footer_lines; + let menu_h = lh * (line_count as f32) + px(10.); + + // Anchored at the line's left edge (unlike the completion menu, which + // anchors at the current word): history rows are whole commands, so + // the menu spans the input area at a fixed width — that keeps the + // right-hand metadata column vertically aligned across rows. A small + // gap keeps it clear of the input line and its caret. + let gap = px(6.); + let grid_w = self.cell_width * (total_cols as f32); + let menu_w = if grid_w < px(720.) { grid_w } else { px(720.) }; + let y = if place_above { + px(8.) + lh * (srow as f32) - menu_h - gap + } else { + px(8.) + lh * ((srow + 1) as f32) + gap + }; + + Some( + div() + .absolute() + .left(px(16.)) + .top(y) + .flex() + .flex_col() + .py_1() + .w(menu_w) + .overflow_hidden() + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .rounded(px(6.)) + .font_family(self.font.family.clone()) + .text_size(self.font_size) + .text_color(theme.popover_foreground) + .children(footer(hidden_above, format!("↑ {hidden_above} more"))) + .children(rows) + .children(footer(hidden_below, format!("↓ {hidden_below} more"))), + ) + } + /// Map a highlighter token kind to a theme color. fn kind_color(&self, kind: TokenKind, cx: &App) -> gpui::Hsla { let theme = cx.theme(); @@ -3101,6 +3368,15 @@ impl Focusable for TerminalView { } } +impl Drop for TerminalView { + fn drop(&mut self) { + // A history record still deferred when the pane goes away (tab closed, + // window closed — possibly mid-command) is flushed rather than lost; + // it carries an exit code only if the shell had already reported back. + self.flush_pending_history(); + } +} + impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // Editor live: adopt anything typed while it was disengaged. Held gap @@ -3133,6 +3409,10 @@ impl Render for TerminalView { .input_active() .then(|| self.render_completion_menu(cx)) .flatten(); + let reverse_search_menu = self + .input_active() + .then(|| self.render_reverse_search_menu(cx)) + .flatten(); // Captured for the right-click menu: the focus handle routes dispatched // actions to this terminal (and lets tab/split ones bubble to the root), @@ -3213,6 +3493,7 @@ impl Render for TerminalView { .children(search_bar) .children(input_bar) .children(completion_menu) + .children(reverse_search_menu) // Right-click context menu (gpui-component PopupMenu). .context_menu(move |menu, _window, _cx| { // Small size = tighter 20px rows; the default 26px felt too airy. @@ -4321,6 +4602,238 @@ mod gpui_tests { assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x0c])); } + fn key(spec: &str) -> gpui::Keystroke { + gpui::Keystroke::parse(spec).expect("valid keystroke spec") + } + + /// The Ctrl+R flow end-to-end at the editor dispatcher: Ctrl+R opens the + /// search, typed text (the IME/commit path) edits the query with fuzzy + /// matching, Enter loads the selection into the editor without running it. + #[gpui::test] + fn ctrl_r_fuzzy_search_accepts_into_the_editor(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + view.history = ["git status", "cargo build", "git commit -m x"] + .into_iter() + .map(String::from) + .collect(); + view.history_frecency = vec![0.0; view.history.len()]; + + view.handle_editor_key(&key("ctrl-r"), cx); + assert!(view.reverse_search.is_some(), "Ctrl+R opens the search"); + // `gst` is a subsequence of `git status` — fuzzy, not substring. + view.commit_text("gst", cx); + assert_eq!( + view.reverse_search + .as_ref() + .and_then(|rs| rs.selected_line(&view.history)), + Some("git status") + ); + view.handle_editor_key(&key("enter"), cx); + assert!(view.reverse_search.is_none(), "Enter closes the search"); + assert_eq!(view.cmd.text(), "git status"); + }) + .unwrap(); + } + + /// Repeated Ctrl+R steps down the ranked matches, and Cmd+Enter runs the + /// selection outright: the line must come out of the client socket as + /// `Input` bytes ending in `\r`. + #[gpui::test] + fn ctrl_r_steps_matches_and_cmd_enter_runs(cx: &mut TestAppContext) { + // `submit_command` defers a history-file record; pin the config dir to + // the shared test scratch so nothing touches the real user history. + let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok(); + crate::core::config::set_config_dir(dir); + + let (window, mut daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + view.history = ["git status", "cargo build", "git commit -m x"] + .into_iter() + .map(String::from) + .collect(); + view.history_frecency = vec![0.0; view.history.len()]; + + view.handle_editor_key(&key("ctrl-r"), cx); + view.commit_text("git", cx); + // Equal fuzzy scores: the newer entry ranks first; a second + // Ctrl+R steps to the older match. + assert_eq!( + view.reverse_search + .as_ref() + .and_then(|rs| rs.selected_line(&view.history)), + Some("git commit -m x") + ); + view.handle_editor_key(&key("ctrl-r"), cx); + assert_eq!( + view.reverse_search + .as_ref() + .and_then(|rs| rs.selected_line(&view.history)), + Some("git status") + ); + view.handle_editor_key(&key("cmd-enter"), cx); + assert!(view.reverse_search.is_none()); + assert!(view.cmd.is_empty(), "submit clears the editor"); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"git status\r".to_vec()), + "Cmd+Enter ships the selected line to the PTY" + ); + } + + /// The Ctrl+R menu actually renders while the shell sits at its prompt: + /// with `input_active` true and a search open over entries carrying run + /// metadata, a real (headless) frame draws `render_reverse_search_menu` — + /// guarding the row/highlight/badge layout code against panics that unit + /// tests of the search logic can't reach. + #[gpui::test] + fn reverse_search_menu_survives_a_real_render_pass(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + // Put the shell at its prompt so `input_active()` is true and the + // menu branch of `render` runs. + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + if window + .update(cx, |view, _, _| view.terminal.at_prompt()) + .unwrap() + { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |view, _, cx| { + assert!(view.input_active(), "prompt report engages the editor"); + view.history = ["git status", "cargo build --release", "echo hello"] + .into_iter() + .map(String::from) + .collect(); + view.history_frecency = vec![0.0; view.history.len()]; + // Metadata for the badge/ago column: one failed run, one aged. + view.history_meta.insert( + "cargo build --release".into(), + super::super::history::EntryMeta { + ts: Some(unix_now().saturating_sub(7200)), + exit: Some(1), + }, + ); + view.handle_editor_key(&key("ctrl-r"), cx); + view.commit_text("c", cx); + assert!( + view.reverse_search + .as_ref() + .is_some_and(|rs| !rs.matches().is_empty()), + "the query has matches for the menu to draw" + ); + cx.notify(); + }) + .unwrap(); + // Let the notified frame actually draw — a panic in the menu layout + // or row rendering fails the test here. + cx.run_until_parked(); + window + .update(cx, |view, _, _| { + assert!(view.reverse_search.is_some(), "search survives the frame"); + }) + .unwrap(); + } + + /// The deferred history record picks up the command's exit code once the + /// shell reports back at its prompt (OSC 133;D → daemon `Prompt` frame → + /// `prompt_seq`/`last_exit_code`), and the file line carries it. + #[gpui::test] + fn submitted_command_backfills_its_exit_code(cx: &mut TestAppContext) { + let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok(); + crate::core::config::set_config_dir(dir.clone()); + + let (window, mut daemon) = harness(cx); + let wait = |cx: &mut TestAppContext, pred: &dyn Fn(&TerminalView) -> bool, what: &str| { + for _ in 0..200 { + if window.update(cx, |view, _, _| pred(view)).unwrap() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("timed out waiting for {what}"); + }; + + // The shell reaches its prompt (integration active). + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait(cx, &|v| v.terminal.at_prompt(), "the initial prompt report"); + + let marker = format!("tty7_gpui_exit_marker_{}", std::process::id()); + window + .update(cx, |view, _, cx| { + view.cmd.set(&marker); + view.submit_command(cx); + assert!(view.pending_history.is_some(), "record defers for the exit"); + }) + .unwrap(); + + // The command runs (leaves the prompt) and finishes with exit 3. + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: Some(3), + } + .encode(&mut daemon) + .unwrap(); + wait( + cx, + &|v| v.terminal.at_prompt() && v.terminal.last_exit_code() == Some(3), + "the post-command prompt report", + ); + + window + .update(cx, |view, window, cx| { + view.poll_foreground(window, cx); + assert!(view.pending_history.is_none(), "poll flushed the record"); + assert_eq!( + view.history_meta.get(&marker).and_then(|m| m.exit), + Some(3), + "in-memory metadata learned the exit code" + ); + }) + .unwrap(); + + // The file record is the current format with the exit code attached. + let content = std::fs::read_to_string(dir.join("history")).expect("history file written"); + let line = content + .lines() + .find(|l| l.contains(&marker)) + .expect("the submitted command was recorded"); + let mut fields = line.splitn(4, '\t'); + let ts = fields.next().unwrap(); + assert!(!ts.is_empty() && ts.bytes().all(|b| b.is_ascii_digit())); + assert_eq!(fields.next(), Some("3"), "exit code field"); + } + /// Readline's Meta word chords act on the local prompt editor: M-b / M-f /// move by word, M-d deletes the word right of the caret. (On macOS these /// chords reach the editor only with `macos_option_as_alt` on — the