feat(history): Ctrl+R fuzzy search menu with run metadata (#45)

* 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
  <ts>\t<exit>\t<cwd>\t<command>, 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 <noreply@anthropic.com>

* style: cargo fmt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
l0ng-ai
2026-07-10 18:28:42 +08:00
committed by GitHub
co-authored by Claude Fable 5 l0ng-ai
parent 2c3b13fc99
commit 7401fd59df
10 changed files with 1464 additions and 278 deletions
+16
View File
@@ -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 `<ts>\t<exit>\t<cwd>\t<command>`, 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
+1 -1
View File
@@ -115,7 +115,7 @@ The essentials:
| <kbd>⌘ K</kbd> | clear the screen and scrollback |
| <kbd>⌘ P</kbd> | command palette |
| <kbd>⌘ F</kbd> | search the scrollback |
| <kbd>⌃ R</kbd> | reverse-search shell history |
| <kbd>⌃ R</kbd> | fuzzy-search shell history |
| <kbd>⌘ +</kbd> · <kbd>⌘ </kbd> · <kbd>⌘ 0</kbd> | font size up · down · reset |
The full list — and any overrides — lives in **Settings → Keybindings**.
+5 -5
View File
@@ -134,11 +134,11 @@ Features include:
History behavior:
- tty7 writes `<cwd>\t<command>` lines when cwd is known.
- Legacy bare lines parse without cwd association.
- Load normalizes blanks/duplicates and caps entries.
- tty7 writes `<ts>\t<exit>\t<cwd>\t<command>` 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 `<cwd>\t<command>` 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
+13 -2
View File
@@ -89,14 +89,25 @@ Files and sources:
Format:
- New tty7 entries are `<cwd>\t<command>` when cwd is an absolute path.
- Legacy bare command lines are accepted.
- New tty7 entries are `<ts>\t<exit>\t<cwd>\t<command>`: 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 `<cwd>\t<command>` 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:
+253
View File
@@ -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<usize>,
}
/// 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<FuzzyMatch> {
let terms: Vec<&str> = query.split_whitespace().collect();
if terms.is_empty() {
return None;
}
let hay: Vec<char> = line.chars().collect();
let hay_lc: Vec<char> = hay.iter().map(|&c| lc(c)).collect();
let bonus: Vec<i32> = (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<char> = 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<char>) -> 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<usize>)> {
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<usize> {
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]);
}
}
+378 -135
View File
@@ -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 `<cwd>\t<command>` — 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 `<ts>\t<exit>\t<cwd>\t<command>` — 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 `<cwd>\t<command>` 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<String>,
ts: Option<u64>,
exit: Option<i32>,
}
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<u64>,
pub exit: Option<i32>,
}
/// 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<String>,
pub counts: HashMap<String, u32>,
pub cwds: HashMap<String, HashSet<String>>,
pub meta: HashMap<String, EntryMeta>,
}
/// 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 `<cwd>\t<cmd>` lines do.
let mut raw: Vec<(String, Option<String>)> = 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<Raw> = 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
/// `<cwd>\t<command>` 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<String>) {
/// Parse one line of tty7's own history file. Current lines are
/// `<ts>\t<exit>\t<cwd>\t<command>` (ts all-digits; exit an integer or empty;
/// cwd absolute or empty; the command — the last field — may itself contain
/// tabs). Older `<cwd>\t<command>` 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::<i32>().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<String, u32>,
cwds: &HashMap<String, HashSet<String>>,
cwd: Option<&str>,
) -> Vec<String> {
) -> Vec<f64> {
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<String, u32>,
cwds: &HashMap<String, HashSet<String>>,
cwd: Option<&str>,
) -> Vec<String> {
let scores = frecency_scores(entries, counts, cwds, cwd);
let mut idx: Vec<usize> = (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<i32>) {
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);
}
// `<cwd>\t<cmd>` 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<String>)>) -> 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<Raw>) -> History {
let mut counts: HashMap<String, u32> = HashMap::new();
let mut cwds: HashMap<String, HashSet<String>> = HashMap::new();
let mut meta: HashMap<String, EntryMeta> = HashMap::new();
let mut seen = HashSet::new();
let mut out: Vec<String> = 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<String>)>) -> 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<String>)>) -> 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<String> {
fn load_shell_history() -> Vec<Raw> {
let mut files: Vec<PathBuf> = Vec::new();
let mut seen = HashSet::new();
let mut add = |p: PathBuf| {
@@ -256,9 +357,10 @@ fn load_shell_history() -> Vec<String> {
out
}
/// Parse one shell-history file into command lines, appending to `out`. Strips
/// zsh's extended-format prefix (`: <start>:<elapsed>;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
/// (`: <start>:<elapsed>;cmd`) and bash's `HISTTIMEFORMAT` comment (`#<ts>` 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<String> {
/// 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<String>) {
fn parse_shell_history(content: &str, out: &mut Vec<Raw>) {
// A bash timestamp comment stamps the *next* command line.
let mut pending_ts: Option<u64> = 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 `": <start>:<elapsed>;"` 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<u64> {
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 `": <start>:<elapsed>;"` prefix when present.
fn start_of_command(line: &str) -> Option<(&str, Option<u64>)> {
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<String> {
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<u64>)> {
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<String>) {
(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: `<cwd>\t<command>`.
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<i32>| 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<String>)> = (0..MAX_ENTRIES + 50)
.map(|i| (format!("cmd{i}"), None))
let big: Vec<Raw> = (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 `<cwd>\t<cmd>` 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()));
+1
View File
@@ -19,6 +19,7 @@ mod cmd_editor;
mod completion;
pub mod element;
pub mod fps;
mod fuzzy;
mod highlight;
mod history;
mod hold;
+19
View File
@@ -84,6 +84,11 @@ struct ShellState {
active: bool,
at_prompt: bool,
last_exit: Option<i32>,
/// 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<i32> {
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
+248 -118
View File
@@ -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<ReverseSearch>`,
//! 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<ReverseSearch>`, 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<usize>,
matches: Vec<Match>,
/// 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<usize>,
}
/// 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<String>),
/// 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<usize> {
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<f64> {
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<usize> = 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<String> = ["ls", "make", "ls"].into_iter().map(String::from).collect();
let rs = ReverseSearch::new(&h, &flat(&h));
let idx: Vec<usize> = 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
));
}
}
+530 -17
View File
@@ -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<String, std::collections::HashSet<String>>,
/// 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<String, super::history::EntryMeta>,
/// `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<String>,
/// 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<f64>,
/// The directory `history_ranked` was last computed for, so the polling loop
/// only re-ranks when the working directory actually changes.
ranked_cwd: Option<std::path::PathBuf>,
@@ -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<PendingHistory>,
/// 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<CompletionSession>,
/// 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<ReverseSearch>,
/// 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<std::path::PathBuf>,
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<Self>,
) -> Option<impl IntoElement + use<>> {
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<gpui::AnyElement> = 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<gpui::AnyElement> = (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<Self>) -> 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