From a7937e4b3764b1ac48c2a97c487d7c6a578d9599 Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 15:44:32 -0400 Subject: [PATCH 01/14] docs(superpowers): native picker spec and plan --- .../plans/2026-08-03-native-picker.md | 79 +++++++++++++++++++ .../specs/2026-08-03-native-picker-design.md | 79 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-native-picker.md create mode 100644 docs/superpowers/specs/2026-08-03-native-picker-design.md diff --git a/docs/superpowers/plans/2026-08-03-native-picker.md b/docs/superpowers/plans/2026-08-03-native-picker.md new file mode 100644 index 00000000..3983e1b8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-native-picker.md @@ -0,0 +1,79 @@ +# Native ratatui picker — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `crates/path-cli/src/tui/` — a first-party ratatui picker behind the existing `embedded-picker` feature honoring the full `PickOptions` contract, then remove skim. External fzf stays as escape hatch. + +**Spec:** `docs/superpowers/specs/2026-08-03-native-picker-design.md` + +--- + +## File map + +- **Create** `crates/path-cli/src/tui/{mod,state,render,matcher,preview}.rs` +- **Modify** `crates/path-cli/Cargo.toml` — deps `ratatui` 0.30, `crossterm` 0.29, `nucleo-matcher` 0.3, `ansi-to-tui` 8 (optional, cfg'd like skim); dev-dep `portable-pty` 0.9; drop `skim`/`regex` at the end +- **Modify** `crates/path-cli/src/lib.rs` — register `mod tui`, update `--picker` flag docs, drop `mod skim_picker` +- **Modify** `crates/path-cli/src/fuzzy.rs` — `Picker::Skim` -> `Picker::Native` (+ hidden `skim` alias + one-time note), delegate `pick_embedded` to `crate::tui::pick`, refresh module docs and error text +- **Delete** `crates/path-cli/src/skim_picker.rs` +- **Create** `crates/path-cli/tests/picker_pty.rs` (opt-in `#[ignore]` PTY smoke) +- **Modify** `CLAUDE.md`, `README.md`, version-bump files (0.17.0), `CHANGELOG.md` + +## Task 1: Spec + plan docs + +- [ ] Commit the design spec and this plan (first commit on the branch). + +## Task 2: matcher module + +- [ ] Add `ratatui`/`crossterm`/`nucleo-matcher`/`ansi-to-tui` optional deps in the `cfg(not(emscripten))` table; extend `embedded-picker` feature (skim stays for now). +- [ ] `tui/matcher.rs`: `Row`, `MatchEntry`, `FieldRange` + real `parse_field_spec`, `project_fields`, `NucleoMatcher` wrapper (Smart case/normalization, empty query = input order, score-desc/row-asc sort, char indices). +- [ ] Register `mod tui` in `lib.rs` behind the skim cfg. +- [ ] Tests: `parse_field_spec_single_index`, `_open_range_from`, `_open_range_to`, `_bounded_range`, `_comma_list`, `_rejects_negative_and_garbage`, `parse_field_spec_covers_every_in_repo_spec`, `project_fields_skips_out_of_range`. +- [ ] Green: `RUSTFLAGS="-D warnings" cargo test -p path-cli`. + +## Task 3: preview module + +- [ ] `tui/preview.rs`: `parse_preview_window` (colon-split, order-tolerant, never errors; defaults Right/60%/wrap); `PreviewScheduler` (pending/generation/cache; `on_selection_change`, `poll`, `on_msg`, `cached`); `substitute_placeholders` (shell-quoted `{1}`..`{n}`, `{}`); `spawn_preview_job` (std::thread, sh -c, kill slot, env sizes, ansi-to-tui with de-ANSI fallback, Failed with first stderr line). +- [ ] Tests: `parse_preview_window_right_percent_wrap_word`, `_up_stacked`, `_tolerates_unknown_tokens_with_defaults`, `debounce_coalesces_rapid_selection_changes`, `stale_generation_message_is_dropped`, `cache_hit_skips_spawn`, `substitute_placeholders_shell_quotes_fields`, `ansi_conversion_of_markdown_to_ansi_sample`, `failed_command_yields_failed_state_with_stderr_line`. +- [ ] Green. + +## Task 4: state module + +- [ ] `tui/state.rs`: `AppState` + pure `handle_event` covering the whole key contract (Enter/Esc/Ctrl-C/Ctrl-D, editing keys, selection moves, Tab/BackTab marks, Ctrl-O, Shift-scroll, dormant Ctrl-R `FilterHook` stub). +- [ ] Tests (event-vector driven): `enter_with_no_query_returns_first_row_original`, `typing_filters_and_enter_returns_top_match_original_line`, `enter_with_zero_matches_returns_no_match`, `esc_returns_cancelled`, `ctrl_c_returns_cancelled`, `ctrl_d_on_empty_query_cancels`, `tab_toggles_mark_and_advances_in_multi_mode`, `tab_is_noop_without_multi`, `enter_returns_marked_rows_in_input_order`, `marks_survive_query_change`, `query_change_resets_selection_to_top`, `up_down_clamp_at_bounds`, `page_down_moves_by_page`, `ctrl_u_clears_query_and_rematches`, `hidden_columns_are_not_searchable`, `resize_below_width_threshold_switches_side_to_stacked`. +- [ ] Green. + +## Task 5: render module + +- [ ] `tui/render.rs`: `LayoutPref` + `DEFAULT_LAYOUT`, `choose_layout` ladder, `compute_areas`, pane renderers (dim header, marker-column list with bold match spans, right-aligned status, prompt+input, preview Block titled "preview"/"preview (loading…)", placeholder + dim-red error states). +- [ ] Snapshot tests (TestBackend + insta): `snapshot_inline_empty_query`, `snapshot_inline_filtered_highlights`, `snapshot_multi_marked_rows`, `snapshot_fullscreen_side_preview_ready`, `snapshot_fullscreen_stacked_narrow`, `snapshot_no_match_status_line`, `snapshot_preview_loading_placeholder`, `snapshot_preview_error_pane`. +- [ ] Green. + +## Task 6: event loop + terminal lifecycle + +- [ ] `tui/mod.rs`: `InputEvent` + `From` (Release filtered), `TermGuard` (stderr backend, idempotent restore, inline-region clear, panic hook), resize/mode-change re-setup, 50 ms poll loop, preview mpsc drain + scheduler-driven spawns, `pub(crate) fn pick`. +- [ ] Green. + +## Task 7: fuzzy.rs switch + +- [ ] `Picker::Skim` -> `Picker::Native` with `#[value(alias = "skim")]`; one-time stderr note when the alias is used; Auto prefers native -> external fzf fallback; `pick_embedded` delegates to `crate::tui::pick`; refresh fuzzy.rs module docs, lib.rs flag docs, no-backend error text; lift `shell_quote` to `pub(crate)`. +- [ ] Green. + +## Task 8: skim removal + +- [ ] Delete `skim_picker.rs` + its `mod` line; drop `skim`/`regex` deps; `embedded-picker = ["dep:ratatui", "dep:crossterm", "dep:nucleo-matcher", "dep:ansi-to-tui"]`. +- [ ] Verify `cargo tree -p path-cli | grep -E "skim|tui-term|portable-pty|frizbee"` is empty (dev-deps aside) and `cargo build -p path-cli --no-default-features` builds. +- [ ] Green. + +## Task 9: PTY smoke tests + +- [ ] Dev-dep `portable-pty` 0.9; `tests/picker_pty.rs` with `#[ignore]` tests `pty_smoke_import_picker_accept_first_row`, `pty_smoke_esc_exits_130`. +- [ ] Run the ignored tests once locally; record the result. + +## Task 10: docs + version bump + +- [ ] CLAUDE.md "Interactive session selection" bullet; README picker section; 4-file bump to 0.17.0; CHANGELOG H2. +- [ ] Final gates: `scripts/quality_gates.sh -site` (or the shellcheck-less subset, noted in the report). + +## Self-Review Notes + +(Recorded during implementation; deviations from the spec land here.) diff --git a/docs/superpowers/specs/2026-08-03-native-picker-design.md b/docs/superpowers/specs/2026-08-03-native-picker-design.md new file mode 100644 index 00000000..b7030b55 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-native-picker-design.md @@ -0,0 +1,79 @@ +# Native ratatui picker — design + +**Status:** approved design, implementing +**Date:** 2026-08-03 + +## Intent + +Replace the embedded skim backend of the interactive fuzzy picker with a +first-party ratatui implementation (Atuin-inspired) that we fully control: +adaptive inline/fullscreen layouts, debounced async previews, fzf-style +query operators, and a testable pure-state core. The external `fzf` +backend stays untouched as the escape hatch. All ~26 existing call sites +(`cmd_import`, `cmd_share`, `cmd_resume`) keep working unchanged because +the new picker honors the full `PickOptions` contract. + +## Decisions Locked In + +| Decision | Choice | +| --- | --- | +| Default layout | `const DEFAULT_LAYOUT: LayoutPref = LayoutPref::Adaptive` — inline viewport without a preview, fullscreen alt-screen with one | +| Matcher | `nucleo-matcher` 0.3, `Pattern::parse(query, CaseMatching::Smart, Normalization::Smart)` — space=AND, `'exact`, `^prefix`, `!negate` are a deliberate upgrade over skim | +| Search scope | `Row.display` (the `with_nth` projection) ONLY — hidden columns never match | +| Tiebreak | score-desc then row-asc (`tiebreak=index` — the only value used in-repo) | +| Marks | `BTreeSet` keyed by ROW index; survive query changes; returned in input order | +| Preview runtime | NO tokio — `std::thread` + `mpsc`, pure `PreviewScheduler` debounce state machine (100 ms), generation counter drops stale results, kill-slot supersede | +| Terminal | ratatui 0.30 `CrosstermBackend` on **stderr** (stdout stays clean for piped results); no `ratatui::init()` | +| Side-by-side threshold | `right:`/`left:` previews go side-by-side at term width >= 100, stacked below | +| Inline height | header(0/1) + min(rows,12) + status(1) + input(1), clamped to min(…, 15, term_h-1); promote to fullscreen when fewer than ~5 usable list rows fit | +| `--picker` flag | `skim` variant renamed `native` with hidden alias `skim` + one-time stderr note; `auto` prefers native, falls back to external fzf | +| Skim | removed entirely at the end (deps `skim`, `regex` dropped); `embedded-picker` feature repoints to `ratatui`/`crossterm`/`nucleo-matcher`/`ansi-to-tui` | +| Ctrl-R | reserved: dormant `FilterHook` stub field in `AppState`, no behavior yet (future bare-resume session picker) | +| Mouse | none in v1 | + +## Surface + +`crates/path-cli/src/tui/` behind the existing +`cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))`: + +- `mod.rs` — event loop, terminal lifecycle (`TermGuard`, panic hook, + resize/mode-change handling), `pub(crate) fn pick(lines, opts) -> + Result`, `InputEvent` + `From`. +- `state.rs` — `AppState` + pure `handle_event(&mut AppState, InputEvent) + -> Option` (no IO, event-vector testable). +- `render.rs` — layout ladder (`LayoutPref`, `choose_layout`), panes + (header/list/status/input/preview), bold match-span highlighting. +- `matcher.rs` — nucleo wrapper, REAL `parse_field_spec` for fzf + `--with-nth` notation (`3`, `1..`, `2..4`, `..2`, `1,3`; rejects + garbage), `project_fields`. +- `preview.rs` — `PreviewScheduler` (pure debounce), `spawn_preview_job` + (`sh -c`, `{1}`..`{n}`/`{}` shell-quoted substitution, COLUMNS / + FZF_PREVIEW_COLUMNS / FZF_PREVIEW_LINES env, ansi-to-tui conversion), + `parse_preview_window` (order-tolerant, never errors). + +## Semantics (contract) + +- `Row { original, fields, display }`; `Selected` returns `original` + (full TSV line). `MatchEntry { row, score, indices }`. +- Enter: marked rows in input order if any, else highlighted row, else + `NoMatch`. Esc / Ctrl-C -> `Cancelled`. Ctrl-D on empty query -> + `Cancelled` (fzf parity), else delete-forward. +- Editing: Backspace, Ctrl-U (clear), Ctrl-W (word), + Left/Right/Home/End/Ctrl-A/Ctrl-E (char-boundary safe). Query change + rematches and resets selection to top. +- Selection: Up/Down/Ctrl-P/Ctrl-N clamped; PgUp/PgDn by page. Tab + toggles mark + advances, BackTab toggles + retreats (multi only). +- Ctrl-O toggles the preview pane (fullscreen only). Shift-Up/Down + scroll the preview. +- Preview UX: cache hit renders instantly; miss with prior text keeps + the text with title "preview (loading…)"; no prior text shows a dim + "deriving preview…" placeholder; failures render the first stderr + line dim-red in the pane and never crash the picker. +- `KeyEventKind::Release` events are filtered (Windows double-fire). + +## Out of scope + +- `cmd_resume.rs` / `cmd_share.rs` changes beyond doc references (a + sibling PR owns those). +- The external fzf backend (`pick_external`) — untouched. +- Ctrl-R behavior (stub only). From 7612c0575bce3173518fcc17009dea87d359329f Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 15:48:01 -0400 Subject: [PATCH 02/14] feat(path-cli): tui matcher module with real with-nth field parsing --- Cargo.lock | 14 ++ crates/path-cli/Cargo.toml | 15 +- crates/path-cli/src/lib.rs | 2 + crates/path-cli/src/tui/matcher.rs | 339 +++++++++++++++++++++++++++++ crates/path-cli/src/tui/mod.rs | 21 ++ 5 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 crates/path-cli/src/tui/matcher.rs create mode 100644 crates/path-cli/src/tui/mod.rs diff --git a/Cargo.lock b/Cargo.lock index d9b355b7..f7dc4475 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2224,6 +2224,16 @@ dependencies = [ "instant", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num" version = "0.4.3" @@ -2443,10 +2453,12 @@ dependencies = [ name = "path-cli" version = "0.16.1" dependencies = [ + "ansi-to-tui", "anyhow", "assert_cmd", "chrono", "clap", + "crossterm", "git2", "hex", "insta", @@ -2454,9 +2466,11 @@ dependencies = [ "jaq-json", "jaq-std", "jsonschema", + "nucleo-matcher", "pathbase-client", "predicates", "rand 0.9.4", + "ratatui", "regex", "reqwest", "rusqlite", diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 1ce394b6..bb11afed 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -67,6 +67,12 @@ skim = { version = "4.7", default-features = false, features = ["frizbee"], opti # crate directly to build a tab regex. Skim already pulls `regex` in # transitively, so this just makes it explicit. regex = { version = "1", optional = true } +# Native picker (crates/path-cli/src/tui/) — same `embedded-picker` +# feature gate. Versions pinned to what the lockfile already carries. +ratatui = { version = "0.30", optional = true } +crossterm = { version = "0.29", optional = true } +nucleo-matcher = { version = "0.3", optional = true } +ansi-to-tui = { version = "8", optional = true } [target.'cfg(target_os = "emscripten")'.dependencies] toolpath-claude = { workspace = true } @@ -82,7 +88,14 @@ vendored-openssl = ["git2/vendored-openssl"] # `path p import` work in interactive flows even when external `fzf` # isn't on PATH. Adds ~2 MB to the release binary; turn off # (`--no-default-features`) for the minimal build. -embedded-picker = ["dep:skim", "dep:regex"] +embedded-picker = [ + "dep:skim", + "dep:regex", + "dep:ratatui", + "dep:crossterm", + "dep:nucleo-matcher", + "dep:ansi-to-tui", +] [dev-dependencies] assert_cmd = "2" diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 14ed9bba..39296254 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -40,6 +40,8 @@ mod schema; mod skim_picker; mod sync; mod term; +#[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] +mod tui; use anyhow::Result; use clap::{Parser, Subcommand}; diff --git a/crates/path-cli/src/tui/matcher.rs b/crates/path-cli/src/tui/matcher.rs new file mode 100644 index 00000000..70fa5789 --- /dev/null +++ b/crates/path-cli/src/tui/matcher.rs @@ -0,0 +1,339 @@ +//! Fuzzy matching for the native picker: fzf `--with-nth` field +//! projection plus a thin wrapper over [`nucleo_matcher`]. +//! +//! Query syntax is nucleo's, parsed with +//! `Pattern::parse(query, CaseMatching::Smart, Normalization::Smart)`. +//! This is a deliberate upgrade over the skim backend: +//! +//! - space-separated words AND together (`share codex` matches rows +//! containing both), +//! - `'text` requires an exact (non-fuzzy) substring match, +//! - `^text` anchors at the start, +//! - `!text` negates. +//! +//! Matching runs over [`Row::display`] ONLY — the `with_nth` projection +//! — so hidden lookup columns (project paths, session ids) never match +//! a query, exactly like fzf with `--with-nth`. + +use anyhow::{Result, bail}; +use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; +use nucleo_matcher::{Config, Matcher, Utf32String}; + +/// One picker line. `original` is the full TSV line as supplied by the +/// caller — it is what [`Selected`](crate::fuzzy::PickResult::Selected) +/// returns, hidden columns included. `fields` back the `{1}`..`{n}` +/// preview placeholders. `display` is the `with_nth` projection, +/// space-joined — the ONLY text that is visible and searchable. +#[derive(Debug, Clone)] +pub(super) struct Row { + pub original: String, + pub fields: Vec, + pub display: String, +} + +impl Row { + /// Split a TSV `line` into fields and project the visible columns + /// per `spec` (an already-parsed `--with-nth` field spec). + pub fn new(line: &str, spec: &[FieldRange]) -> Self { + let fields: Vec = line.split('\t').map(str::to_string).collect(); + let display = project_fields(&fields, spec); + Self { + original: line.to_string(), + fields, + display, + } + } +} + +/// One row's match result. `indices` are *char* positions into +/// [`Row::display`] (sorted, deduped) for highlight rendering. +#[derive(Debug, Clone)] +pub(super) struct MatchEntry { + pub row: usize, + pub score: u32, + pub indices: Vec, +} + +/// One component of an fzf `--with-nth` field spec. 1-based, as fzf +/// counts fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FieldRange { + /// `3` — a single field. + Single(usize), + /// `2..` — from a field to the end. + From(usize), + /// `..2` — from the start through a field. + To(usize), + /// `2..4` — a bounded inclusive range. + Between(usize, usize), +} + +impl FieldRange { + /// Does this range include 1-based field index `idx`? + fn contains(&self, idx: usize) -> bool { + match *self { + FieldRange::Single(n) => idx == n, + FieldRange::From(n) => idx >= n, + FieldRange::To(n) => idx <= n, + FieldRange::Between(a, b) => idx >= a && idx <= b, + } + } +} + +/// Parse fzf `--with-nth` notation: comma-separated components, each +/// `3`, `1..`, `2..4`, or `..2`. Rejects negatives and garbage with a +/// clear error — the in-repo call sites only ever pass well-formed +/// specs, so an error here is a programming bug worth surfacing. +pub(super) fn parse_field_spec(s: &str) -> Result> { + let mut out = Vec::new(); + for part in s.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + out.push(parse_component(part)?); + } + if out.is_empty() { + bail!("empty --with-nth field spec {s:?}"); + } + Ok(out) +} + +fn parse_component(part: &str) -> Result { + let parse_index = |txt: &str| -> Result { + if txt.starts_with('-') { + bail!("negative field index {txt:?} in --with-nth spec (not supported)"); + } + let n: usize = txt + .parse() + .map_err(|_| anyhow::anyhow!("invalid field index {txt:?} in --with-nth spec"))?; + if n == 0 { + bail!("field indices are 1-based; got 0 in --with-nth spec"); + } + Ok(n) + }; + if let Some((lo, hi)) = part.split_once("..") { + match (lo.is_empty(), hi.is_empty()) { + (true, true) => bail!("bare `..` in --with-nth spec (use `1..`)"), + (false, true) => Ok(FieldRange::From(parse_index(lo)?)), + (true, false) => Ok(FieldRange::To(parse_index(hi)?)), + (false, false) => { + let (a, b) = (parse_index(lo)?, parse_index(hi)?); + if a > b { + bail!("inverted range `{part}` in --with-nth spec"); + } + Ok(FieldRange::Between(a, b)) + } + } + } else { + Ok(FieldRange::Single(parse_index(part)?)) + } +} + +/// Project `fields` through a parsed field spec, space-joining the +/// selected columns in spec order. Out-of-range indices are skipped — +/// a row with fewer columns than the spec asks for just shows less. +pub(super) fn project_fields(fields: &[String], spec: &[FieldRange]) -> String { + let mut picked: Vec<&str> = Vec::new(); + for range in spec { + for (i, field) in fields.iter().enumerate() { + if range.contains(i + 1) { + picked.push(field.as_str()); + } + } + } + picked.join(" ") +} + +/// Reused nucleo matcher. `Matcher` carries sizable internal scratch +/// buffers, so it is constructed once and reused across keystrokes. +pub(super) struct NucleoMatcher { + matcher: Matcher, + /// Rows converted to UTF-32 once up front — nucleo matches over + /// `Utf32Str`, and re-converting every row on every keystroke would + /// dominate the match cost. + haystacks: Vec, +} + +impl NucleoMatcher { + pub fn new(rows: &[Row]) -> Self { + Self { + matcher: Matcher::new(Config::DEFAULT), + haystacks: rows + .iter() + .map(|r| Utf32String::from(r.display.as_str())) + .collect(), + } + } + + /// Re-match every row against `query`. Empty query returns every + /// row in input order with no highlight spans. Non-empty queries + /// return matches sorted score-descending, then row-ascending — + /// the `tiebreak=index` contract every in-repo call site relies on. + pub fn rematch(&mut self, query: &str) -> Vec { + if query.is_empty() { + return (0..self.haystacks.len()) + .map(|row| MatchEntry { + row, + score: 0, + indices: Vec::new(), + }) + .collect(); + } + let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart); + let mut out = Vec::new(); + let mut indices: Vec = Vec::new(); + for (row, hay) in self.haystacks.iter().enumerate() { + indices.clear(); + if let Some(score) = pattern.indices(hay.slice(..), &mut self.matcher, &mut indices) { + let mut idx = indices.clone(); + idx.sort_unstable(); + idx.dedup(); + out.push(MatchEntry { + row, + score, + indices: idx, + }); + } + } + out.sort_by(|a, b| b.score.cmp(&a.score).then(a.row.cmp(&b.row))); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fields(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn parse_field_spec_single_index() { + assert_eq!(parse_field_spec("3").unwrap(), vec![FieldRange::Single(3)]); + } + + #[test] + fn parse_field_spec_open_range_from() { + assert_eq!(parse_field_spec("2..").unwrap(), vec![FieldRange::From(2)]); + } + + #[test] + fn parse_field_spec_open_range_to() { + assert_eq!(parse_field_spec("..2").unwrap(), vec![FieldRange::To(2)]); + } + + #[test] + fn parse_field_spec_bounded_range() { + assert_eq!( + parse_field_spec("2..4").unwrap(), + vec![FieldRange::Between(2, 4)] + ); + } + + #[test] + fn parse_field_spec_comma_list() { + assert_eq!( + parse_field_spec("1,3").unwrap(), + vec![FieldRange::Single(1), FieldRange::Single(3)] + ); + } + + #[test] + fn parse_field_spec_rejects_negative_and_garbage() { + assert!(parse_field_spec("-1").is_err()); + assert!(parse_field_spec("2..-4").is_err()); + assert!(parse_field_spec("abc").is_err()); + assert!(parse_field_spec("0").is_err()); + assert!(parse_field_spec("..").is_err()); + assert!(parse_field_spec("4..2").is_err()); + assert!(parse_field_spec("").is_err()); + } + + /// Every `with_nth` value in live use in this repo must parse: + /// "2", "3", "4", "1..", "2..". A regression here bricks a picker + /// call site. + #[test] + fn parse_field_spec_covers_every_in_repo_spec() { + for spec in ["2", "3", "4", "1..", "2.."] { + assert!( + parse_field_spec(spec).is_ok(), + "in-repo with_nth spec {spec:?} failed to parse" + ); + } + } + + #[test] + fn project_fields_skips_out_of_range() { + let f = fields(&["a", "b"]); + // Field 5 doesn't exist; only what's present renders. + assert_eq!( + project_fields(&f, &parse_field_spec("2,5").unwrap()), + "b".to_string() + ); + assert_eq!( + project_fields(&f, &parse_field_spec("3..").unwrap()), + String::new() + ); + } + + #[test] + fn project_fields_open_range_takes_tail() { + let f = fields(&["proj", "sess", "row text"]); + assert_eq!( + project_fields(&f, &parse_field_spec("2..").unwrap()), + "sess row text" + ); + } + + #[test] + fn row_display_is_projection_of_tsv_line() { + let spec = parse_field_spec("3").unwrap(); + let row = Row::new("proj\tsess\tvisible title", &spec); + assert_eq!(row.original, "proj\tsess\tvisible title"); + assert_eq!(row.fields.len(), 3); + assert_eq!(row.display, "visible title"); + } + + #[test] + fn rematch_empty_query_preserves_input_order_without_spans() { + let spec = parse_field_spec("1..").unwrap(); + let rows: Vec = ["bbb", "aaa"].iter().map(|l| Row::new(l, &spec)).collect(); + let mut m = NucleoMatcher::new(&rows); + let out = m.rematch(""); + assert_eq!(out.len(), 2); + assert_eq!(out[0].row, 0); + assert_eq!(out[1].row, 1); + assert!(out.iter().all(|e| e.indices.is_empty())); + } + + #[test] + fn rematch_scores_and_sorts_best_first() { + let spec = parse_field_spec("1..").unwrap(); + let rows: Vec = ["completely unrelated", "share codex session", "share"] + .iter() + .map(|l| Row::new(l, &spec)) + .collect(); + let mut m = NucleoMatcher::new(&rows); + let out = m.rematch("share"); + // "completely unrelated" has no fuzzy `share` match in order; the + // two real matches are present with highlight indices. + assert!(out.iter().all(|e| e.row != 0)); + assert_eq!(out.len(), 2); + assert!(out.iter().all(|e| !e.indices.is_empty())); + // Equal-quality matches tie-break by row (input) order. + let rows_in_order: Vec = out.iter().map(|e| e.row).collect(); + assert!(rows_in_order == vec![1, 2] || rows_in_order == vec![2, 1]); + } + + #[test] + fn rematch_matches_display_only_not_hidden_columns() { + // Hidden column 1 contains "secret"; display is column 2. + let spec = parse_field_spec("2").unwrap(); + let rows = vec![Row::new("secret\tvisible", &spec)]; + let mut m = NucleoMatcher::new(&rows); + assert!(m.rematch("secret").is_empty()); + assert_eq!(m.rematch("visible").len(), 1); + } +} diff --git a/crates/path-cli/src/tui/mod.rs b/crates/path-cli/src/tui/mod.rs new file mode 100644 index 00000000..6207955f --- /dev/null +++ b/crates/path-cli/src/tui/mod.rs @@ -0,0 +1,21 @@ +//! Native interactive picker — the embedded backend behind +//! [`crate::fuzzy::pick`] (Atuin-inspired, built on ratatui). +//! +//! Module layout: +//! +//! - [`matcher`] — nucleo wrapper + fzf `--with-nth` field projection. +//! - [`preview`] — debounced async preview pipeline (no tokio). +//! - [`state`] — pure, event-vector-testable picker state machine. +//! - [`render`] — layout ladder + pane rendering. +//! - this file — terminal lifecycle and the event loop. +//! +//! The picker renders on **stderr** so stdout stays clean for piped +//! results, honors the full [`crate::fuzzy::PickOptions`] contract, and +//! returns [`crate::fuzzy::PickResult`] exactly like the external fzf +//! backend. + +// Removed when fuzzy.rs switches its embedded backend to this module +// (the module is dark until then, and `-D warnings` would reject it). +#![allow(dead_code)] + +mod matcher; From b61ae54ffc7face746f2e97ffaed6976d23ade47 Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 15:50:14 -0400 Subject: [PATCH 03/14] feat(path-cli): tui preview pipeline with pure debounce scheduler --- crates/path-cli/src/fuzzy.rs | 8 +- crates/path-cli/src/tui/mod.rs | 1 + crates/path-cli/src/tui/preview.rs | 600 ++++++++++++++++++ ...conversion_of_markdown_to_ansi_sample.snap | 8 + 4 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 crates/path-cli/src/tui/preview.rs create mode 100644 crates/path-cli/src/tui/snapshots/path_cli__tui__preview__tests__ansi_conversion_of_markdown_to_ansi_sample.snap diff --git a/crates/path-cli/src/fuzzy.rs b/crates/path-cli/src/fuzzy.rs index d37f7848..788953a5 100644 --- a/crates/path-cli/src/fuzzy.rs +++ b/crates/path-cli/src/fuzzy.rs @@ -342,9 +342,11 @@ pub(crate) fn substitute_exe_placeholder(preview: &str) -> String { preview.replace("{exe}", &exe) } -/// Single-quote a path for embedding in a `/bin/sh -c` command line. -/// Any embedded single-quote becomes `'\''` so the path survives intact. -fn shell_quote(s: &str) -> String { +/// Single-quote a value for embedding in a `/bin/sh -c` command line. +/// Any embedded single-quote becomes `'\''` so the value survives +/// intact. Used for the `{exe}` substitution here and the `{1}`..`{n}` +/// field substitutions in the native picker's preview pipeline. +pub(crate) fn shell_quote(s: &str) -> String { let escaped = s.replace('\'', "'\\''"); format!("'{escaped}'") } diff --git a/crates/path-cli/src/tui/mod.rs b/crates/path-cli/src/tui/mod.rs index 6207955f..4aeb8ef8 100644 --- a/crates/path-cli/src/tui/mod.rs +++ b/crates/path-cli/src/tui/mod.rs @@ -19,3 +19,4 @@ #![allow(dead_code)] mod matcher; +mod preview; diff --git a/crates/path-cli/src/tui/preview.rs b/crates/path-cli/src/tui/preview.rs new file mode 100644 index 00000000..5219a9a2 --- /dev/null +++ b/crates/path-cli/src/tui/preview.rs @@ -0,0 +1,600 @@ +//! Debounced async preview pipeline for the native picker. NO tokio — +//! plain `std::thread` workers reporting over an `mpsc` channel, driven +//! by a *pure* scheduler state machine that the event loop polls. +//! +//! Flow: selection changes arm a 100 ms debounce; when it fires (and +//! the row isn't cached) the scheduler emits a [`SpawnRequest`] with a +//! bumped generation. The event loop spawns the preview command via +//! [`spawn_preview_job`]; any previously running command is killed +//! best-effort through the shared kill slot. Results come back as +//! [`PreviewMsg`]s; stale generations are dropped on receipt. + +use std::collections::HashMap; +use std::io::Read; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use ansi_to_tui::IntoText; +use ratatui::text::Text; + +/// Debounce window between a selection change and the preview spawn. +/// Long enough to coalesce held-down arrow keys, short enough to feel +/// instant on a deliberate stop. +pub(super) const DEBOUNCE: Duration = Duration::from_millis(100); + +/// Where the preview pane sits relative to the list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Side { + Up, + Down, + Left, + Right, +} + +/// Preview pane size: a percentage of the split axis or absolute lines. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PaneSize { + Percent(u16), + Lines(u16), +} + +/// Wrapping mode for preview text. ratatui's `Paragraph` wraps at word +/// boundaries, so `Wrap` and `WrapWord` render identically — both are +/// kept so the parse stays faithful to the fzf notation callers pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum WrapMode { + WrapWord, + Wrap, + NoWrap, +} + +/// Parsed `--preview-window` spec. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct PreviewWindow { + pub side: Side, + pub size: PaneSize, + pub wrap: WrapMode, +} + +impl Default for PreviewWindow { + fn default() -> Self { + Self { + side: Side::Right, + size: PaneSize::Percent(60), + wrap: WrapMode::WrapWord, + } + } +} + +/// Parse fzf `--preview-window` notation: colon-separated tokens in any +/// order — a side (`up`/`down`/`left`/`right`), a size (`60%` or an +/// absolute line count), and a wrap mode (`wrap`/`wrap-word`/`nowrap`). +/// NEVER errors: unknown tokens are ignored and missing ones fall back +/// to the defaults (right / 60% / wrap-word). +pub(super) fn parse_preview_window(s: &str) -> PreviewWindow { + let mut out = PreviewWindow::default(); + for token in s.split(':') { + let token = token.trim(); + match token { + "up" => out.side = Side::Up, + "down" => out.side = Side::Down, + "left" => out.side = Side::Left, + "right" => out.side = Side::Right, + "wrap" => out.wrap = WrapMode::Wrap, + "wrap-word" => out.wrap = WrapMode::WrapWord, + "nowrap" => out.wrap = WrapMode::NoWrap, + _ => { + if let Some(pct) = token.strip_suffix('%') { + if let Ok(n) = pct.parse::() { + out.size = PaneSize::Percent(n.min(100)); + } + } else if let Ok(n) = token.parse::() { + out.size = PaneSize::Lines(n); + } + // Anything else: unknown token, deliberately ignored. + } + } + } + out +} + +/// A finished preview: renderable text, or a failure with the first +/// stderr line of the command. +#[derive(Debug, Clone)] +pub(super) enum PreviewContent { + Ready(Text<'static>), + Failed(String), +} + +/// Message from a preview worker thread back to the event loop. +#[derive(Debug)] +pub(super) struct PreviewMsg { + pub generation: u64, + pub row: usize, + pub content: PreviewContent, +} + +/// A spawn the scheduler decided on: run the preview for `row`, tagged +/// with the generation whose results are still welcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct SpawnRequest { + pub row: usize, + pub generation: u64, +} + +/// Pure debounce state machine. Owns no threads, does no IO — the +/// event loop feeds it selection changes, polls it with `now`, and +/// routes worker messages back in. Fully deterministic under test. +pub(super) struct PreviewScheduler { + /// The debounced (row, due-time) waiting to fire. + pending: Option<(usize, Instant)>, + /// Bumped on every spawn; messages from older generations are + /// dropped so a slow superseded command can't overwrite a newer + /// preview. + generation: u64, + /// Finished previews by row index. Failures are cached too — a + /// broken preview command shouldn't be re-run on every selection + /// bounce. + cache: HashMap, +} + +impl PreviewScheduler { + pub fn new() -> Self { + Self { + pending: None, + generation: 0, + cache: HashMap::new(), + } + } + + /// The highlighted row changed: (re-)arm the debounce. + pub fn on_selection_change(&mut self, row: usize, now: Instant) { + self.pending = Some((row, now + DEBOUNCE)); + } + + /// Fire the debounce if due. Cache hits spawn nothing. A returned + /// request has already bumped the generation — the caller must + /// actually spawn it. + pub fn poll(&mut self, now: Instant) -> Option { + let (row, due) = self.pending?; + if now < due { + return None; + } + self.pending = None; + if self.cache.contains_key(&row) { + return None; + } + self.generation += 1; + Some(SpawnRequest { + row, + generation: self.generation, + }) + } + + /// Accept a worker message. Stale generations are dropped. Returns + /// whether the message affects `current_row` (i.e. the pane should + /// redraw with new content). + pub fn on_msg(&mut self, msg: PreviewMsg, current_row: Option) -> bool { + if msg.generation != self.generation { + return false; + } + let affects = current_row == Some(msg.row); + self.cache.insert(msg.row, msg.content); + affects + } + + /// Cached preview for `row`, if any. + pub fn cached(&self, row: usize) -> Option<&PreviewContent> { + self.cache.get(&row) + } +} + +/// Substitute fzf-style field placeholders into a preview template: +/// `{1}`..`{n}` become the row's shell-quoted fields, `{}` the quoted +/// whole original line. Unknown `{...}` runs (e.g. an unsubstituted +/// `{exe}`) pass through untouched. Out-of-range indices substitute an +/// empty quoted string so the command still parses. +pub(super) fn substitute_placeholders(template: &str, fields: &[String], original: &str) -> String { + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while let Some(open) = rest.find('{') { + out.push_str(&rest[..open]); + let after = &rest[open..]; + match after.find('}') { + None => { + out.push_str(after); + rest = ""; + break; + } + Some(close) => { + let inner = &after[1..close]; + if inner.is_empty() { + out.push_str(&crate::fuzzy::shell_quote(original)); + } else if inner.chars().all(|c| c.is_ascii_digit()) { + let idx: usize = inner.parse().unwrap_or(0); + let value = idx + .checked_sub(1) + .and_then(|i| fields.get(i)) + .map(String::as_str) + .unwrap_or(""); + out.push_str(&crate::fuzzy::shell_quote(value)); + } else { + // Not a field placeholder — emit verbatim. + out.push_str(&after[..=close]); + } + rest = &after[close + 1..]; + } + } + } + out.push_str(rest); + out +} + +/// Shared handle to the currently running preview child, if any. +/// Superseding spawns kill it best-effort so at most one preview +/// command runs at a time. +pub(super) type KillSlot = Arc>>; + +pub(super) fn new_kill_slot() -> KillSlot { + Arc::new(Mutex::new(None)) +} + +/// Kill and reap whatever child currently occupies the slot. +fn supersede(slot: &KillSlot) { + let prev = slot.lock().expect("kill slot poisoned").take(); + if let Some(mut child) = prev { + let _ = child.kill(); + let _ = child.wait(); + } +} + +/// Spawn the preview command for `req` on a worker thread. `command` +/// is the fully substituted shell command line; `pane` is the preview +/// pane's inner `(columns, lines)`, exported as `COLUMNS` / +/// `FZF_PREVIEW_COLUMNS` / `FZF_PREVIEW_LINES` for fzf-compatible +/// preview scripts. The previous preview child (if any) is killed +/// before the new one starts. +pub(super) fn spawn_preview_job( + req: SpawnRequest, + command: String, + pane: (u16, u16), + tx: Sender, + slot: KillSlot, +) { + supersede(&slot); + std::thread::spawn(move || { + if let Some(content) = run_preview_command(&command, pane, &slot) { + // Receiver gone means the picker already exited — fine. + let _ = tx.send(PreviewMsg { + generation: req.generation, + row: req.row, + content, + }); + } + }); +} + +/// Run one preview command to completion. Returns `None` when the +/// child was superseded mid-run (a newer spawn killed and reaped it) — +/// its output is stale by definition and no message should be sent. +fn run_preview_command(command: &str, pane: (u16, u16), slot: &KillSlot) -> Option { + let mut cmd = if cfg!(windows) { + let mut c = Command::new("cmd"); + c.arg("/C").arg(command); + c + } else { + let mut c = Command::new("sh"); + c.arg("-c").arg(command); + c + }; + let spawned = cmd + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("COLUMNS", pane.0.to_string()) + .env("FZF_PREVIEW_COLUMNS", pane.0.to_string()) + .env("FZF_PREVIEW_LINES", pane.1.to_string()) + .spawn(); + let mut child = match spawned { + Ok(c) => c, + Err(e) => return Some(PreviewContent::Failed(format!("failed to spawn preview: {e}"))), + }; + let child_id = child.id(); + let mut stdout_pipe = child.stdout.take(); + let mut stderr_pipe = child.stderr.take(); + + // Park the child in the kill slot so a superseding spawn can kill + // it while we block on its output. + *slot.lock().expect("kill slot poisoned") = Some(child); + + // Drain stderr on a helper thread to avoid a pipe-buffer deadlock + // when a preview command is chatty on both streams. + let stderr_handle = std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(pipe) = stderr_pipe.as_mut() { + let _ = pipe.read_to_end(&mut buf); + } + buf + }); + let mut stdout = Vec::new(); + if let Some(pipe) = stdout_pipe.as_mut() { + let _ = pipe.read_to_end(&mut stdout); + } + let stderr = stderr_handle.join().unwrap_or_default(); + + // Reap: only if the slot still holds *our* child. If a newer spawn + // superseded us it already killed and reaped it, and our output is + // stale. + let ours = { + let mut guard = slot.lock().expect("kill slot poisoned"); + match guard.as_ref() { + Some(c) if c.id() == child_id => guard.take(), + _ => None, + } + }; + let mut child = ours?; + let status = match child.wait() { + Ok(s) => s, + Err(e) => return Some(PreviewContent::Failed(format!("preview wait failed: {e}"))), + }; + + if !status.success() { + let first_line = String::from_utf8_lossy(&stderr) + .lines() + .find(|l| !l.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("preview command exited with {status}")); + return Some(PreviewContent::Failed(first_line)); + } + + Some(PreviewContent::Ready(ansi_bytes_to_text(&stdout))) +} + +/// Convert ANSI-styled bytes into a ratatui `Text`. On conversion +/// failure, fall back to a plain de-ANSI'd rendering — an ugly preview +/// beats a crashed picker. +pub(super) fn ansi_bytes_to_text(bytes: &[u8]) -> Text<'static> { + match bytes.into_text() { + Ok(text) => text, + Err(_) => Text::raw(strip_ansi(&String::from_utf8_lossy(bytes))), + } +} + +/// Best-effort ANSI escape removal: drops CSI (`ESC [ ... `) +/// and OSC (`ESC ] ... BEL`/`ESC \`) sequences, keeps everything else. +fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + match chars.peek() { + Some('[') => { + chars.next(); + // CSI: parameter/intermediate bytes then a final byte + // in `@`..`~`. + for c in chars.by_ref() { + if ('\u{40}'..='\u{7e}').contains(&c) { + break; + } + } + } + Some(']') => { + chars.next(); + // OSC: terminated by BEL or ESC \. + while let Some(c) = chars.next() { + if c == '\u{7}' { + break; + } + if c == '\u{1b}' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + // Bare escape (or two-char sequence): drop the escape and + // let the next char through on the following iteration. + _ => {} + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use std::time::Duration; + + #[test] + fn parse_preview_window_right_percent_wrap_word() { + let w = parse_preview_window("right:60%:wrap-word"); + assert_eq!(w.side, Side::Right); + assert_eq!(w.size, PaneSize::Percent(60)); + assert_eq!(w.wrap, WrapMode::WrapWord); + } + + #[test] + fn parse_preview_window_up_stacked() { + let w = parse_preview_window("up:60%:wrap-word"); + assert_eq!(w.side, Side::Up); + assert_eq!(w.size, PaneSize::Percent(60)); + // Order tolerance: same tokens shuffled parse identically. + assert_eq!(parse_preview_window("wrap-word:up:60%"), w); + // Absolute line counts parse as lines, not percent. + assert_eq!( + parse_preview_window("down:15").size, + PaneSize::Lines(15), + ); + } + + #[test] + fn parse_preview_window_tolerates_unknown_tokens_with_defaults() { + let w = parse_preview_window("frobnicate:~3:!!"); + assert_eq!(w, PreviewWindow::default()); + assert_eq!(parse_preview_window(""), PreviewWindow::default()); + // A known token among garbage still lands. + assert_eq!(parse_preview_window("border-rounded:left").side, Side::Left); + } + + #[test] + fn debounce_coalesces_rapid_selection_changes() { + let mut s = PreviewScheduler::new(); + let t0 = Instant::now(); + s.on_selection_change(1, t0); + s.on_selection_change(2, t0 + Duration::from_millis(10)); + // Not due yet relative to the *latest* change. + assert!(s.poll(t0 + Duration::from_millis(50)).is_none()); + // Due: one spawn, for the latest row only. + let req = s.poll(t0 + Duration::from_millis(120)).unwrap(); + assert_eq!(req.row, 2); + // Nothing further pending. + assert!(s.poll(t0 + Duration::from_millis(500)).is_none()); + } + + #[test] + fn stale_generation_message_is_dropped() { + let mut s = PreviewScheduler::new(); + let t0 = Instant::now(); + s.on_selection_change(1, t0); + let old = s.poll(t0 + DEBOUNCE).unwrap(); + // A newer selection supersedes the first spawn. + s.on_selection_change(2, t0 + DEBOUNCE); + let new = s.poll(t0 + DEBOUNCE + DEBOUNCE).unwrap(); + assert!(new.generation > old.generation); + // The old worker reports late: dropped, nothing cached. + let accepted = s.on_msg( + PreviewMsg { + generation: old.generation, + row: old.row, + content: PreviewContent::Ready(Text::raw("stale")), + }, + Some(old.row), + ); + assert!(!accepted); + assert!(s.cached(old.row).is_none()); + // The new worker's message lands. + assert!(s.on_msg( + PreviewMsg { + generation: new.generation, + row: new.row, + content: PreviewContent::Ready(Text::raw("fresh")), + }, + Some(new.row), + )); + assert!(s.cached(new.row).is_some()); + } + + #[test] + fn cache_hit_skips_spawn() { + let mut s = PreviewScheduler::new(); + let t0 = Instant::now(); + s.on_selection_change(3, t0); + let req = s.poll(t0 + DEBOUNCE).unwrap(); + s.on_msg( + PreviewMsg { + generation: req.generation, + row: 3, + content: PreviewContent::Ready(Text::raw("cached")), + }, + Some(3), + ); + // Selecting the row again: debounce arms, but poll spawns + // nothing because the cache already has it. + s.on_selection_change(3, t0 + Duration::from_millis(500)); + assert!(s.poll(t0 + Duration::from_secs(1)).is_none()); + } + + #[test] + fn substitute_placeholders_shell_quotes_fields() { + let fields = vec!["/tmp/o'reilly".to_string(), "sess-1".to_string()]; + let out = substitute_placeholders( + "path show --project {1} --session {2}", + &fields, + "/tmp/o'reilly\tsess-1", + ); + // The single quote survives via sh quoting. + assert_eq!( + out, + r#"path show --project '/tmp/o'\''reilly' --session 'sess-1'"# + ); + // `{}` substitutes the quoted whole line. + let whole = substitute_placeholders("echo {}", &fields, "a\tb"); + assert_eq!(whole, "echo 'a\tb'"); + // Out-of-range index quotes an empty string; `{exe}`-style + // non-numeric placeholders pass through. + assert_eq!( + substitute_placeholders("{exe} p {9}", &fields, "x"), + "{exe} p ''" + ); + } + + #[test] + fn ansi_conversion_of_markdown_to_ansi_sample() { + let ansi = crate::term::markdown_to_ansi("# Title\n**bold**"); + let text = ansi_bytes_to_text(ansi.as_bytes()); + insta::assert_debug_snapshot!(text); + } + + #[test] + #[cfg(unix)] + fn failed_command_yields_failed_state_with_stderr_line() { + let (tx, rx) = mpsc::channel(); + let slot = new_kill_slot(); + spawn_preview_job( + SpawnRequest { + row: 0, + generation: 1, + }, + "echo boom >&2; echo more-noise >&2; exit 3".to_string(), + (80, 20), + tx, + slot, + ); + let msg = rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert_eq!(msg.generation, 1); + match msg.content { + PreviewContent::Failed(line) => assert_eq!(line, "boom"), + other => panic!("expected Failed, got {other:?}"), + } + } + + #[test] + #[cfg(unix)] + fn successful_command_yields_ready_text() { + let (tx, rx) = mpsc::channel(); + spawn_preview_job( + SpawnRequest { + row: 2, + generation: 7, + }, + "printf 'hello preview'".to_string(), + (80, 20), + tx, + new_kill_slot(), + ); + let msg = rx.recv_timeout(Duration::from_secs(10)).unwrap(); + match msg.content { + PreviewContent::Ready(text) => { + let flat: String = text + .lines + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) + .collect(); + assert_eq!(flat, "hello preview"); + } + other => panic!("expected Ready, got {other:?}"), + } + } + + #[test] + fn strip_ansi_removes_csi_and_osc() { + assert_eq!(strip_ansi("\u{1b}[1mbold\u{1b}[0m"), "bold"); + assert_eq!(strip_ansi("\u{1b}]0;title\u{7}text"), "text"); + assert_eq!(strip_ansi("plain"), "plain"); + } +} diff --git a/crates/path-cli/src/tui/snapshots/path_cli__tui__preview__tests__ansi_conversion_of_markdown_to_ansi_sample.snap b/crates/path-cli/src/tui/snapshots/path_cli__tui__preview__tests__ansi_conversion_of_markdown_to_ansi_sample.snap new file mode 100644 index 00000000..0c8f9b01 --- /dev/null +++ b/crates/path-cli/src/tui/snapshots/path_cli__tui__preview__tests__ansi_conversion_of_markdown_to_ansi_sample.snap @@ -0,0 +1,8 @@ +--- +source: crates/path-cli/src/tui/preview.rs +expression: text +--- +Text::from_iter([ + Line::from(Span::from("Title").bold()), + Line::from(Span::from("bold").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold().not_dim().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out()), +]) From f7d450c0573eadd777196cf70157608a7a398973 Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 15:54:11 -0400 Subject: [PATCH 04/14] feat(path-cli): tui state machine and adaptive layout ladder --- crates/path-cli/src/fuzzy.rs | 1 + crates/path-cli/src/tui/mod.rs | 90 +++++ crates/path-cli/src/tui/preview.rs | 11 +- crates/path-cli/src/tui/render.rs | 347 +++++++++++++++++++ crates/path-cli/src/tui/state.rs | 536 +++++++++++++++++++++++++++++ 5 files changed, 980 insertions(+), 5 deletions(-) create mode 100644 crates/path-cli/src/tui/render.rs create mode 100644 crates/path-cli/src/tui/state.rs diff --git a/crates/path-cli/src/fuzzy.rs b/crates/path-cli/src/fuzzy.rs index 788953a5..cd970b3f 100644 --- a/crates/path-cli/src/fuzzy.rs +++ b/crates/path-cli/src/fuzzy.rs @@ -358,6 +358,7 @@ pub(crate) fn shell_quote(s: &str) -> String { /// non-zero exit on cancel can match on `Cancelled`; callers that just /// want the picked lines treat both `NoMatch` and `Cancelled` as "empty /// selection". +#[derive(Debug, Clone, PartialEq, Eq)] pub enum PickResult { /// Picker exited cleanly with at least one selected line. Selected(Vec), diff --git a/crates/path-cli/src/tui/mod.rs b/crates/path-cli/src/tui/mod.rs index 4aeb8ef8..63698065 100644 --- a/crates/path-cli/src/tui/mod.rs +++ b/crates/path-cli/src/tui/mod.rs @@ -20,3 +20,93 @@ mod matcher; mod preview; +mod render; +mod state; + +/// Picker input, decoupled from crossterm so [`state::handle_event`] +/// stays pure and event-vector testable. Chorded editing keys with +/// obvious single equivalents are normalized in the `From` impl +/// (Ctrl-A/Ctrl-E -> Home/End, Ctrl-P/Ctrl-N -> Up/Down). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum InputEvent { + /// A printable character typed into the query. + Char(char), + Backspace, + /// Forward delete (Delete key, or Ctrl-D with a non-empty query). + DeleteForward, + Enter, + Esc, + CtrlC, + /// Ctrl-D: cancel on an empty query (fzf parity), else forward + /// delete — the split happens in the state machine, which knows + /// the query. + CtrlD, + /// Clear the whole query. + CtrlU, + /// Delete the word before the cursor. + CtrlW, + /// Toggle the preview pane (fullscreen layouts only). + CtrlO, + /// Reserved: cycle the dormant [`state::FilterHook`]. No behavior + /// yet — a future PR populates it for the bare-resume session + /// picker. + CtrlR, + Left, + Right, + Home, + End, + Up, + Down, + PageUp, + PageDown, + /// Toggle mark + advance (multi mode only). + Tab, + /// Toggle mark + retreat (multi mode only). + BackTab, + /// Scroll the preview pane up. + ShiftUp, + /// Scroll the preview pane down. + ShiftDown, + /// Terminal resized to (columns, rows). + Resize(u16, u16), + /// Anything we don't handle. + Noop, +} + +impl From for InputEvent { + fn from(key: crossterm::event::KeyEvent) -> Self { + use crossterm::event::{KeyCode, KeyModifiers}; + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + match key.code { + KeyCode::Char('c') if ctrl => InputEvent::CtrlC, + KeyCode::Char('d') if ctrl => InputEvent::CtrlD, + KeyCode::Char('u') if ctrl => InputEvent::CtrlU, + KeyCode::Char('w') if ctrl => InputEvent::CtrlW, + KeyCode::Char('o') if ctrl => InputEvent::CtrlO, + KeyCode::Char('r') if ctrl => InputEvent::CtrlR, + KeyCode::Char('a') if ctrl => InputEvent::Home, + KeyCode::Char('e') if ctrl => InputEvent::End, + KeyCode::Char('p') if ctrl => InputEvent::Up, + KeyCode::Char('n') if ctrl => InputEvent::Down, + KeyCode::Char(c) if !ctrl => InputEvent::Char(c), + KeyCode::Backspace => InputEvent::Backspace, + KeyCode::Delete => InputEvent::DeleteForward, + KeyCode::Enter => InputEvent::Enter, + KeyCode::Esc => InputEvent::Esc, + KeyCode::Left => InputEvent::Left, + KeyCode::Right => InputEvent::Right, + KeyCode::Home => InputEvent::Home, + KeyCode::End => InputEvent::End, + KeyCode::Up if shift => InputEvent::ShiftUp, + KeyCode::Down if shift => InputEvent::ShiftDown, + KeyCode::Up => InputEvent::Up, + KeyCode::Down => InputEvent::Down, + KeyCode::PageUp => InputEvent::PageUp, + KeyCode::PageDown => InputEvent::PageDown, + KeyCode::Tab => InputEvent::Tab, + KeyCode::BackTab => InputEvent::BackTab, + _ => InputEvent::Noop, + } + } +} diff --git a/crates/path-cli/src/tui/preview.rs b/crates/path-cli/src/tui/preview.rs index 5219a9a2..d8bc2691 100644 --- a/crates/path-cli/src/tui/preview.rs +++ b/crates/path-cli/src/tui/preview.rs @@ -299,7 +299,11 @@ fn run_preview_command(command: &str, pane: (u16, u16), slot: &KillSlot) -> Opti .spawn(); let mut child = match spawned { Ok(c) => c, - Err(e) => return Some(PreviewContent::Failed(format!("failed to spawn preview: {e}"))), + Err(e) => { + return Some(PreviewContent::Failed(format!( + "failed to spawn preview: {e}" + ))); + } }; let child_id = child.id(); let mut stdout_pipe = child.stdout.take(); @@ -426,10 +430,7 @@ mod tests { // Order tolerance: same tokens shuffled parse identically. assert_eq!(parse_preview_window("wrap-word:up:60%"), w); // Absolute line counts parse as lines, not percent. - assert_eq!( - parse_preview_window("down:15").size, - PaneSize::Lines(15), - ); + assert_eq!(parse_preview_window("down:15").size, PaneSize::Lines(15),); } #[test] diff --git a/crates/path-cli/src/tui/render.rs b/crates/path-cli/src/tui/render.rs new file mode 100644 index 00000000..f72c3b3d --- /dev/null +++ b/crates/path-cli/src/tui/render.rs @@ -0,0 +1,347 @@ +//! Layout ladder and pane rendering for the native picker. +//! +//! The picker adapts to its job: a plain list gets a small *inline* +//! viewport under the shell prompt (Atuin-style); a preview-bearing +//! picker takes over the alternate screen so the preview has room. +//! [`choose_layout`] is the single decision point. + +use ratatui::layout::Rect; + +use super::preview::{PaneSize, PreviewWindow, Side}; +use super::state::AppState; + +/// Overall layout preference. `Adaptive` (the default) picks inline +/// for preview-less pickers and fullscreen otherwise; `Inline` and +/// `Fullscreen` force one mode (no in-repo caller forces yet — the +/// knob exists so a future flag can). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum LayoutPref { + Adaptive, + Inline, + Fullscreen, +} + +/// LOCKED DECISION: the default layout is adaptive. +pub(super) const DEFAULT_LAYOUT: LayoutPref = LayoutPref::Adaptive; + +/// The concrete layout chosen for the current terminal size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum LayoutMode { + /// Inline viewport of `height` rows at the shell cursor. + Inline { height: u16 }, + /// Alternate screen, no preview pane (none configured, or toggled + /// off with Ctrl-O). + Fullscreen, + /// Alternate screen with a preview pane on `side` sized by `size`. + /// A `right:`/`left:` spec degrades to a stacked `Up` pane below + /// the side-by-side width threshold. + FullscreenSplit { side: Side, size: PaneSize }, +} + +impl LayoutMode { + /// True when this mode runs on the alternate screen. + pub fn is_fullscreen(&self) -> bool { + !matches!(self, LayoutMode::Inline { .. }) + } +} + +/// Minimum terminal width for a side-by-side (left/right) preview +/// split; anything narrower stacks the preview above the list. +const SIDE_BY_SIDE_MIN_WIDTH: u16 = 100; + +/// Most list rows an inline viewport will show. +const INLINE_MAX_LIST_ROWS: usize = 12; + +/// Hard cap on the inline viewport height. +const INLINE_MAX_HEIGHT: u16 = 15; + +/// Pick the layout for the current state + terminal size. +pub(super) fn choose_layout(state: &AppState) -> LayoutMode { + layout_for( + DEFAULT_LAYOUT, + state.has_preview, + state.preview_visible, + state.preview_window, + state.header.is_some(), + state.rows.len(), + state.term_w, + state.term_h, + ) +} + +/// The pure ladder, parameterized for tests. +#[allow(clippy::too_many_arguments)] +pub(super) fn layout_for( + pref: LayoutPref, + has_preview: bool, + preview_visible: bool, + window: PreviewWindow, + has_header: bool, + nrows: usize, + term_w: u16, + term_h: u16, +) -> LayoutMode { + let fullscreen = || { + if has_preview && preview_visible { + let side = match window.side { + Side::Left | Side::Right if term_w < SIDE_BY_SIDE_MIN_WIDTH => Side::Up, + side => side, + }; + LayoutMode::FullscreenSplit { + side, + size: window.size, + } + } else { + LayoutMode::Fullscreen + } + }; + match pref { + LayoutPref::Fullscreen => fullscreen(), + LayoutPref::Inline => { + inline_or_promote(has_header, nrows, term_h).unwrap_or_else(fullscreen) + } + LayoutPref::Adaptive => { + if has_preview { + fullscreen() + } else { + inline_or_promote(has_header, nrows, term_h).unwrap_or_else(fullscreen) + } + } + } +} + +/// Inline viewport height: header(0|1) + min(rows, 12) + status(1) + +/// input(1), clamped to min(…, 15, term_h - 1). Returns `None` (promote +/// to fullscreen) when fewer than ~5 usable list rows would fit. +fn inline_or_promote(has_header: bool, nrows: usize, term_h: u16) -> Option { + let chrome: u16 = u16::from(has_header) + 1 /* status */ + 1 /* input */; + let desired_list = nrows.clamp(1, INLINE_MAX_LIST_ROWS) as u16; + let height = (chrome + desired_list) + .min(INLINE_MAX_HEIGHT) + .min(term_h.saturating_sub(1)); + let usable = height.saturating_sub(chrome); + if usable < desired_list.min(5) { + return None; + } + Some(LayoutMode::Inline { height }) +} + +/// The panes of one rendered frame. `preview` is present only in +/// [`LayoutMode::FullscreenSplit`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct Areas { + pub header: Option, + pub list: Rect, + pub status: Rect, + pub input: Rect, + pub preview: Option, +} + +/// Carve `area` into panes for `mode`. Frame anatomy: optional dim +/// header line, list, status line, prompt+input at the bottom; the +/// preview pane (fullscreen split only) takes its side first. +pub(super) fn compute_areas(state: &AppState, mode: LayoutMode, area: Rect) -> Areas { + let (list_zone, preview) = match mode { + LayoutMode::FullscreenSplit { side, size } => { + let (list_zone, preview) = split_preview(area, side, size); + (list_zone, Some(preview)) + } + _ => (area, None), + }; + let has_header = state.header.is_some(); + let chrome: u16 = u16::from(has_header) + 2; + let list_h = list_zone.height.saturating_sub(chrome); + let mut y = list_zone.y; + let header = has_header.then(|| { + let r = Rect::new(list_zone.x, y, list_zone.width, 1.min(list_zone.height)); + y += 1; + r + }); + let list = Rect::new(list_zone.x, y, list_zone.width, list_h); + y += list_h; + let status = Rect::new(list_zone.x, y, list_zone.width, 1.min(list_zone.height)); + y += 1; + let input = Rect::new( + list_zone.x, + y.min(list_zone.bottom().saturating_sub(1)), + list_zone.width, + 1.min(list_zone.height), + ); + Areas { + header, + list, + status, + input, + preview, + } +} + +/// Split `area` into (list zone, preview pane) along `side`. +fn split_preview(area: Rect, side: Side, size: PaneSize) -> (Rect, Rect) { + match side { + Side::Left | Side::Right => { + let w = match size { + PaneSize::Percent(p) => { + (u32::from(area.width) * u32::from(p.min(100)) / 100) as u16 + } + PaneSize::Lines(n) => n.min(area.width), + }; + let w = w.min(area.width); + if side == Side::Right { + let list = Rect::new(area.x, area.y, area.width - w, area.height); + let preview = Rect::new(area.x + area.width - w, area.y, w, area.height); + (list, preview) + } else { + let preview = Rect::new(area.x, area.y, w, area.height); + let list = Rect::new(area.x + w, area.y, area.width - w, area.height); + (list, preview) + } + } + Side::Up | Side::Down => { + let h = match size { + PaneSize::Percent(p) => { + (u32::from(area.height) * u32::from(p.min(100)) / 100) as u16 + } + PaneSize::Lines(n) => n.min(area.height), + }; + let h = h.min(area.height); + if side == Side::Up { + let preview = Rect::new(area.x, area.y, area.width, h); + let list = Rect::new(area.x, area.y + h, area.width, area.height - h); + (list, preview) + } else { + let list = Rect::new(area.x, area.y, area.width, area.height - h); + let preview = Rect::new(area.x, area.y + area.height - h, area.width, h); + (list, preview) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn window(side: Side) -> PreviewWindow { + PreviewWindow { + side, + size: PaneSize::Percent(60), + wrap: super::super::preview::WrapMode::WrapWord, + } + } + + #[test] + fn adaptive_without_preview_is_inline() { + let mode = layout_for( + LayoutPref::Adaptive, + false, + false, + PreviewWindow::default(), + false, + 3, + 80, + 24, + ); + assert_eq!(mode, LayoutMode::Inline { height: 5 }); + } + + #[test] + fn inline_height_clamps_to_cap_and_terminal() { + // 50 rows want 12 list rows + 2 chrome = 14. + let mode = layout_for( + LayoutPref::Adaptive, + false, + false, + PreviewWindow::default(), + false, + 50, + 80, + 24, + ); + assert_eq!(mode, LayoutMode::Inline { height: 14 }); + // A header adds a row but the cap is 15. + let mode = layout_for( + LayoutPref::Adaptive, + false, + false, + PreviewWindow::default(), + true, + 50, + 80, + 24, + ); + assert_eq!(mode, LayoutMode::Inline { height: 15 }); + } + + #[test] + fn tiny_terminal_promotes_inline_to_fullscreen() { + let mode = layout_for( + LayoutPref::Adaptive, + false, + false, + PreviewWindow::default(), + false, + 50, + 80, + 6, + ); + assert_eq!(mode, LayoutMode::Fullscreen); + } + + #[test] + fn preview_forces_fullscreen_split() { + let mode = layout_for( + LayoutPref::Adaptive, + true, + true, + window(Side::Right), + false, + 3, + 120, + 30, + ); + assert_eq!( + mode, + LayoutMode::FullscreenSplit { + side: Side::Right, + size: PaneSize::Percent(60) + } + ); + } + + #[test] + fn narrow_terminal_stacks_side_preview() { + let mode = layout_for( + LayoutPref::Adaptive, + true, + true, + window(Side::Right), + false, + 3, + 80, + 30, + ); + assert_eq!( + mode, + LayoutMode::FullscreenSplit { + side: Side::Up, + size: PaneSize::Percent(60) + } + ); + } + + #[test] + fn ctrl_o_hidden_preview_is_plain_fullscreen() { + let mode = layout_for( + LayoutPref::Adaptive, + true, + false, + window(Side::Up), + false, + 3, + 120, + 30, + ); + assert_eq!(mode, LayoutMode::Fullscreen); + } +} diff --git a/crates/path-cli/src/tui/state.rs b/crates/path-cli/src/tui/state.rs new file mode 100644 index 00000000..3c60e6f0 --- /dev/null +++ b/crates/path-cli/src/tui/state.rs @@ -0,0 +1,536 @@ +//! The picker's state machine. [`handle_event`] is PURE — no IO, no +//! terminal, no clocks — so the whole key contract is testable by +//! feeding event vectors and asserting on the returned [`PickResult`] +//! and state. + +use std::collections::BTreeSet; + +use crate::fuzzy::PickResult; + +use super::InputEvent; +use super::matcher::{MatchEntry, NucleoMatcher, Row}; +use super::preview::PreviewWindow; + +/// Reserved extension point: a future PR populates this for the +/// bare-resume session picker (Ctrl-R cycles source filters). Shipping +/// the field now keeps the state shape stable; there is deliberately +/// NO behavior behind it yet. +#[derive(Debug, Clone, Copy)] +pub(super) struct FilterHook; + +/// Everything the picker knows. Render reads it; `handle_event` +/// mutates it. +pub(super) struct AppState { + /// All input rows, in input order. Row indices are stable — marks + /// and match entries key on them. + pub rows: Vec, + /// Current query text. + pub query: String, + /// Byte offset of the cursor within `query` (always on a char + /// boundary). + pub cursor: usize, + /// Current matches, sorted score-desc then row-asc. + pub matches: Vec, + /// Index into `matches` of the highlighted row. + pub selected: usize, + /// Marked rows by ROW index — a `BTreeSet` so iteration yields + /// input order and marks survive query changes untouched. + pub marked: BTreeSet, + /// Multi-select enabled (Tab/BackTab active). + pub multi: bool, + pub prompt: String, + pub header: Option, + /// A preview command is configured. + pub has_preview: bool, + pub preview_window: PreviewWindow, + /// Preview pane currently shown (Ctrl-O toggles; fullscreen only). + pub preview_visible: bool, + /// Preview scroll offset (Shift-Up/Down). + pub preview_scroll: u16, + /// Rows per PgUp/PgDn jump; render keeps it in sync with the list + /// pane height. + pub page_rows: usize, + pub term_w: u16, + pub term_h: u16, + matcher: NucleoMatcher, + /// Dormant Ctrl-R hook — see [`FilterHook`]. + #[allow(dead_code)] + pub filter_hook: Option, +} + +impl AppState { + pub fn new( + rows: Vec, + multi: bool, + prompt: &str, + header: Option<&str>, + preview_window: Option, + ) -> Self { + let mut matcher = NucleoMatcher::new(&rows); + let matches = matcher.rematch(""); + Self { + rows, + query: String::new(), + cursor: 0, + matches, + selected: 0, + marked: BTreeSet::new(), + multi, + prompt: prompt.to_string(), + header: header.map(str::to_string), + has_preview: preview_window.is_some(), + preview_window: preview_window.unwrap_or_default(), + preview_visible: preview_window.is_some(), + preview_scroll: 0, + page_rows: 10, + term_w: 80, + term_h: 24, + matcher, + filter_hook: None, + } + } + + /// ROW index of the highlighted match, if any. + pub fn current_row(&self) -> Option { + self.matches.get(self.selected).map(|m| m.row) + } + + fn on_query_changed(&mut self) { + self.matches = self.matcher.rematch(&self.query); + self.selected = 0; + self.preview_scroll = 0; + } + + fn move_selection(&mut self, delta: isize) { + if self.matches.is_empty() { + return; + } + let max = (self.matches.len() - 1) as isize; + let next = (self.selected as isize + delta).clamp(0, max) as usize; + if next != self.selected { + self.selected = next; + self.preview_scroll = 0; + } + } + + fn prev_char_boundary(&self) -> usize { + self.query[..self.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0) + } + + fn next_char_boundary(&self) -> usize { + self.query[self.cursor..] + .chars() + .next() + .map(|c| self.cursor + c.len_utf8()) + .unwrap_or(self.cursor) + } + + fn delete_forward(&mut self) { + if self.cursor < self.query.len() { + let end = self.next_char_boundary(); + self.query.replace_range(self.cursor..end, ""); + self.on_query_changed(); + } + } + + /// Delete the word before the cursor: trailing whitespace, then + /// the run of non-whitespace. + fn delete_word_back(&mut self) { + if self.cursor == 0 { + return; + } + let head = &self.query[..self.cursor]; + let trimmed = head.trim_end(); + let start = trimmed + .rfind(char::is_whitespace) + .map(|i| i + trimmed[i..].chars().next().map_or(1, char::len_utf8)) + .unwrap_or(0); + self.query.replace_range(start..self.cursor, ""); + self.cursor = start; + self.on_query_changed(); + } + + /// The rows Enter would return right now. + fn accepted_rows(&self) -> Vec { + if !self.marked.is_empty() { + // BTreeSet iterates ascending row index = input order. + return self + .marked + .iter() + .map(|&i| self.rows[i].original.clone()) + .collect(); + } + self.current_row() + .map(|r| vec![self.rows[r].original.clone()]) + .unwrap_or_default() + } +} + +/// Advance the state by one input event. Returns `Some` when the +/// picker session is over. +pub(super) fn handle_event(state: &mut AppState, ev: InputEvent) -> Option { + match ev { + InputEvent::Char(c) => { + state.query.insert(state.cursor, c); + state.cursor += c.len_utf8(); + state.on_query_changed(); + } + InputEvent::Backspace => { + if state.cursor > 0 { + let start = state.prev_char_boundary(); + state.query.replace_range(start..state.cursor, ""); + state.cursor = start; + state.on_query_changed(); + } + } + InputEvent::DeleteForward => state.delete_forward(), + InputEvent::CtrlD => { + // fzf parity: Ctrl-D on an empty query cancels; otherwise + // it's forward delete. + if state.query.is_empty() { + return Some(PickResult::Cancelled); + } + state.delete_forward(); + } + InputEvent::CtrlU => { + if !state.query.is_empty() { + state.query.clear(); + state.cursor = 0; + state.on_query_changed(); + } + } + InputEvent::CtrlW => state.delete_word_back(), + InputEvent::Left => state.cursor = state.prev_char_boundary(), + InputEvent::Right => state.cursor = state.next_char_boundary(), + InputEvent::Home => state.cursor = 0, + InputEvent::End => state.cursor = state.query.len(), + InputEvent::Up => state.move_selection(-1), + InputEvent::Down => state.move_selection(1), + InputEvent::PageUp => state.move_selection(-(state.page_rows as isize)), + InputEvent::PageDown => state.move_selection(state.page_rows as isize), + InputEvent::Tab => { + if state.multi { + if let Some(row) = state.current_row() { + if !state.marked.remove(&row) { + state.marked.insert(row); + } + state.move_selection(1); + } + } + } + InputEvent::BackTab => { + if state.multi { + if let Some(row) = state.current_row() { + if !state.marked.remove(&row) { + state.marked.insert(row); + } + state.move_selection(-1); + } + } + } + InputEvent::Enter => { + let rows = state.accepted_rows(); + return Some(if rows.is_empty() { + PickResult::NoMatch + } else { + PickResult::Selected(rows) + }); + } + InputEvent::Esc | InputEvent::CtrlC => return Some(PickResult::Cancelled), + InputEvent::CtrlO => { + // Only meaningful in fullscreen layouts — which is exactly + // when a preview is configured. + if state.has_preview { + state.preview_visible = !state.preview_visible; + } + } + InputEvent::CtrlR => { + // Reserved: FilterHook cycling lands in a future PR. + } + InputEvent::ShiftUp => state.preview_scroll = state.preview_scroll.saturating_sub(1), + InputEvent::ShiftDown => state.preview_scroll = state.preview_scroll.saturating_add(1), + InputEvent::Resize(w, h) => { + state.term_w = w; + state.term_h = h; + } + InputEvent::Noop => {} + } + None +} + +#[cfg(test)] +mod tests { + use super::super::matcher::parse_field_spec; + use super::super::preview::{PaneSize, Side}; + use super::super::render::{LayoutMode, choose_layout}; + use super::*; + + fn state_of(lines: &[&str], with_nth: &str, multi: bool) -> AppState { + let spec = parse_field_spec(with_nth).unwrap(); + let rows: Vec = lines.iter().map(|l| Row::new(l, &spec)).collect(); + AppState::new(rows, multi, "> ", None, None) + } + + fn type_str(state: &mut AppState, s: &str) { + for c in s.chars() { + assert!(handle_event(state, InputEvent::Char(c)).is_none()); + } + } + + #[test] + fn enter_with_no_query_returns_first_row_original() { + let mut s = state_of(&["p1\ts1\tfirst row", "p2\ts2\tsecond row"], "3", false); + let out = handle_event(&mut s, InputEvent::Enter); + assert_eq!( + out, + Some(PickResult::Selected(vec!["p1\ts1\tfirst row".to_string()])) + ); + } + + #[test] + fn typing_filters_and_enter_returns_top_match_original_line() { + let mut s = state_of(&["p1\ts1\talpha work", "p2\ts2\tbeta work"], "3", false); + type_str(&mut s, "beta"); + let out = handle_event(&mut s, InputEvent::Enter); + assert_eq!( + out, + Some(PickResult::Selected(vec!["p2\ts2\tbeta work".to_string()])) + ); + } + + #[test] + fn enter_with_zero_matches_returns_no_match() { + let mut s = state_of(&["p1\ts1\talpha", "p2\ts2\tbeta"], "3", false); + type_str(&mut s, "zzzzqqqq"); + assert!(s.matches.is_empty()); + assert_eq!( + handle_event(&mut s, InputEvent::Enter), + Some(PickResult::NoMatch) + ); + } + + #[test] + fn esc_returns_cancelled() { + let mut s = state_of(&["a"], "1..", false); + assert_eq!( + handle_event(&mut s, InputEvent::Esc), + Some(PickResult::Cancelled) + ); + } + + #[test] + fn ctrl_c_returns_cancelled() { + let mut s = state_of(&["a"], "1..", false); + assert_eq!( + handle_event(&mut s, InputEvent::CtrlC), + Some(PickResult::Cancelled) + ); + } + + #[test] + fn ctrl_d_on_empty_query_cancels() { + let mut s = state_of(&["a"], "1..", false); + assert_eq!( + handle_event(&mut s, InputEvent::CtrlD), + Some(PickResult::Cancelled) + ); + // With a query, Ctrl-D is forward delete, not cancel. + let mut s = state_of(&["abc"], "1..", false); + type_str(&mut s, "ab"); + assert!(handle_event(&mut s, InputEvent::Home).is_none()); + assert!(handle_event(&mut s, InputEvent::CtrlD).is_none()); + assert_eq!(s.query, "b"); + } + + #[test] + fn tab_toggles_mark_and_advances_in_multi_mode() { + let mut s = state_of(&["one", "two", "three"], "1..", true); + assert!(handle_event(&mut s, InputEvent::Tab).is_none()); + assert!(s.marked.contains(&0)); + assert_eq!(s.selected, 1); + // BackTab from here toggles row 1 and retreats. + assert!(handle_event(&mut s, InputEvent::BackTab).is_none()); + assert!(s.marked.contains(&1)); + assert_eq!(s.selected, 0); + // Tab on an already-marked row unmarks it. + assert!(handle_event(&mut s, InputEvent::Tab).is_none()); + assert!(!s.marked.contains(&0)); + } + + #[test] + fn tab_is_noop_without_multi() { + let mut s = state_of(&["one", "two"], "1..", false); + assert!(handle_event(&mut s, InputEvent::Tab).is_none()); + assert!(s.marked.is_empty()); + assert_eq!(s.selected, 0); + } + + #[test] + fn enter_returns_marked_rows_in_input_order() { + let mut s = state_of(&["row zero", "row one", "row two"], "1..", true); + // Mark row 2 first, then row 0 — result must still be input + // order (0 before 2). + handle_event(&mut s, InputEvent::Down); + handle_event(&mut s, InputEvent::Down); + handle_event(&mut s, InputEvent::Tab); // marks row 2, advance clamps + handle_event(&mut s, InputEvent::Up); + handle_event(&mut s, InputEvent::Up); + handle_event(&mut s, InputEvent::Tab); // marks row 0 + let out = handle_event(&mut s, InputEvent::Enter); + assert_eq!( + out, + Some(PickResult::Selected(vec![ + "row zero".to_string(), + "row two".to_string() + ])) + ); + } + + #[test] + fn marks_survive_query_change() { + let mut s = state_of(&["alpha", "beta", "gamma"], "1..", true); + handle_event(&mut s, InputEvent::Tab); // mark "alpha" + type_str(&mut s, "gam"); + // The mark on row 0 survived even though row 0 no longer + // matches; Enter returns the marked set. + assert!(s.marked.contains(&0)); + let out = handle_event(&mut s, InputEvent::Enter); + assert_eq!(out, Some(PickResult::Selected(vec!["alpha".to_string()]))); + } + + #[test] + fn query_change_resets_selection_to_top() { + let mut s = state_of(&["aa", "ab", "ac"], "1..", false); + handle_event(&mut s, InputEvent::Down); + handle_event(&mut s, InputEvent::Down); + assert_eq!(s.selected, 2); + type_str(&mut s, "a"); + assert_eq!(s.selected, 0); + } + + #[test] + fn up_down_clamp_at_bounds() { + let mut s = state_of(&["one", "two"], "1..", false); + handle_event(&mut s, InputEvent::Up); + assert_eq!(s.selected, 0); + handle_event(&mut s, InputEvent::Down); + handle_event(&mut s, InputEvent::Down); + handle_event(&mut s, InputEvent::Down); + assert_eq!(s.selected, 1); + } + + #[test] + fn page_down_moves_by_page() { + let lines: Vec = (0..30).map(|i| format!("row {i}")).collect(); + let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); + let mut s = state_of(&refs, "1..", false); + s.page_rows = 10; + handle_event(&mut s, InputEvent::PageDown); + assert_eq!(s.selected, 10); + handle_event(&mut s, InputEvent::PageUp); + assert_eq!(s.selected, 0); + // Clamps at the end. + handle_event(&mut s, InputEvent::PageDown); + handle_event(&mut s, InputEvent::PageDown); + handle_event(&mut s, InputEvent::PageDown); + assert_eq!(s.selected, 29); + } + + #[test] + fn ctrl_u_clears_query_and_rematches() { + let mut s = state_of(&["alpha", "beta"], "1..", false); + type_str(&mut s, "beta"); + assert_eq!(s.matches.len(), 1); + assert!(handle_event(&mut s, InputEvent::CtrlU).is_none()); + assert_eq!(s.query, ""); + assert_eq!(s.cursor, 0); + assert_eq!(s.matches.len(), 2); + } + + #[test] + fn ctrl_w_deletes_word_before_cursor() { + let mut s = state_of(&["alpha beta"], "1..", false); + type_str(&mut s, "alpha beta"); + assert!(handle_event(&mut s, InputEvent::CtrlW).is_none()); + assert_eq!(s.query, "alpha "); + assert!(handle_event(&mut s, InputEvent::CtrlW).is_none()); + assert_eq!(s.query, ""); + } + + #[test] + fn cursor_moves_are_char_boundary_safe() { + let mut s = state_of(&["héllo"], "1..", false); + type_str(&mut s, "hé"); + // Cursor sits after the multibyte é; Left crosses it cleanly. + handle_event(&mut s, InputEvent::Left); + assert_eq!(s.cursor, 1); + handle_event(&mut s, InputEvent::Right); + assert_eq!(s.cursor, 1 + 'é'.len_utf8()); + handle_event(&mut s, InputEvent::Backspace); + assert_eq!(s.query, "h"); + } + + #[test] + fn hidden_columns_are_not_searchable() { + // The query text exists only in hidden column 1 of a + // with_nth "3" row: zero matches, Enter -> NoMatch. + let mut s = state_of(&["needle-project\tsess\tvisible title"], "3", false); + type_str(&mut s, "needle-project"); + assert!(s.matches.is_empty()); + assert_eq!( + handle_event(&mut s, InputEvent::Enter), + Some(PickResult::NoMatch) + ); + } + + #[test] + fn resize_below_width_threshold_switches_side_to_stacked() { + let spec = parse_field_spec("1..").unwrap(); + let rows = vec![Row::new("one", &spec)]; + let mut s = AppState::new( + rows, + false, + "> ", + None, + Some(PreviewWindow { + side: Side::Right, + size: PaneSize::Percent(60), + wrap: super::super::preview::WrapMode::WrapWord, + }), + ); + handle_event(&mut s, InputEvent::Resize(120, 30)); + assert_eq!( + choose_layout(&s), + LayoutMode::FullscreenSplit { + side: Side::Right, + size: PaneSize::Percent(60) + } + ); + handle_event(&mut s, InputEvent::Resize(80, 30)); + assert_eq!( + choose_layout(&s), + LayoutMode::FullscreenSplit { + side: Side::Up, + size: PaneSize::Percent(60) + } + ); + } + + #[test] + fn shift_arrows_scroll_preview() { + let mut s = state_of(&["one"], "1..", false); + handle_event(&mut s, InputEvent::ShiftDown); + handle_event(&mut s, InputEvent::ShiftDown); + assert_eq!(s.preview_scroll, 2); + handle_event(&mut s, InputEvent::ShiftUp); + assert_eq!(s.preview_scroll, 1); + // Saturates at zero. + handle_event(&mut s, InputEvent::ShiftUp); + handle_event(&mut s, InputEvent::ShiftUp); + assert_eq!(s.preview_scroll, 0); + } +} From 364e133ddf91803a0b3352551bfb294e595dbb7c Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 15:56:12 -0400 Subject: [PATCH 05/14] feat(path-cli): tui pane rendering with frame snapshots --- crates/path-cli/src/tui/render.rs | 307 +++++++++++++++++- ...napshot_fullscreen_side_preview_ready.snap | 20 ++ ...s__snapshot_fullscreen_stacked_narrow.snap | 22 ++ ...r__tests__snapshot_inline_empty_query.snap | 9 + ...__snapshot_inline_filtered_highlights.snap | 9 + ...er__tests__snapshot_multi_marked_rows.snap | 9 + ..._tests__snapshot_no_match_status_line.snap | 8 + ...r__tests__snapshot_preview_error_pane.snap | 18 + ..._snapshot_preview_loading_placeholder.snap | 18 + 9 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_fullscreen_side_preview_ready.snap create mode 100644 crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_fullscreen_stacked_narrow.snap create mode 100644 crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_inline_empty_query.snap create mode 100644 crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_inline_filtered_highlights.snap create mode 100644 crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_multi_marked_rows.snap create mode 100644 crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_no_match_status_line.snap create mode 100644 crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_preview_error_pane.snap create mode 100644 crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_preview_loading_placeholder.snap diff --git a/crates/path-cli/src/tui/render.rs b/crates/path-cli/src/tui/render.rs index f72c3b3d..69dc8954 100644 --- a/crates/path-cli/src/tui/render.rs +++ b/crates/path-cli/src/tui/render.rs @@ -5,9 +5,13 @@ //! picker takes over the alternate screen so the preview has room. //! [`choose_layout`] is the single decision point. +use ratatui::Frame; use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Paragraph, Wrap}; -use super::preview::{PaneSize, PreviewWindow, Side}; +use super::preview::{PaneSize, PreviewWindow, Side, WrapMode}; use super::state::AppState; /// Overall layout preference. `Adaptive` (the default) picks inline @@ -218,9 +222,159 @@ fn split_preview(area: Rect, side: Side, size: PaneSize) -> (Rect, Rect) { } } +/// What the preview pane should show this frame. The event loop +/// derives it from the scheduler cache; render just paints it. +pub(super) struct PreviewView<'a> { + /// Pane title: `"preview"` or `"preview (loading…)"`. + pub title: &'a str, + pub body: PreviewBody<'a>, +} + +pub(super) enum PreviewBody<'a> { + /// A finished preview (possibly kept from the previous selection + /// while a newer one derives). + Text(&'a Text<'static>), + /// Nothing to show yet. + Placeholder, + /// The preview command failed; the first stderr line. + Error(&'a str), +} + +/// Paint one frame. `preview` is ignored unless `mode` has a preview +/// pane. +pub(super) fn draw( + frame: &mut Frame<'_>, + state: &AppState, + mode: LayoutMode, + preview: Option<&PreviewView<'_>>, +) { + let areas = compute_areas(state, mode, frame.area()); + if let (Some(area), Some(text)) = (areas.header, state.header.as_deref()) { + frame.render_widget(Paragraph::new(text).style(Style::new().dim()), area); + } + render_list(frame, areas.list, state); + render_status(frame, areas.status, state); + render_input(frame, areas.input, state); + if let (Some(area), Some(view)) = (areas.preview, preview) { + render_preview(frame, area, state, view); + } +} + +/// The match list with its marker gutter: `>` on the highlighted row, +/// `*` on marked rows, matched chars in bold. +fn render_list(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let height = area.height as usize; + if height == 0 { + return; + } + // Stateless scroll: keep the highlighted row visible. + let offset = (state.selected + 1).saturating_sub(height); + let mut lines: Vec> = Vec::with_capacity(height); + for (i, entry) in state.matches.iter().enumerate().skip(offset).take(height) { + let row = &state.rows[entry.row]; + let is_selected = i == state.selected; + let is_marked = state.marked.contains(&entry.row); + let mut spans: Vec> = Vec::new(); + spans.push(if is_selected { + Span::styled("> ", Style::new().bold()) + } else { + Span::raw(" ") + }); + spans.push(if is_marked { + Span::styled("* ", Style::new().bold()) + } else { + Span::raw(" ") + }); + spans.extend(highlight_spans(&row.display, &entry.indices, is_selected)); + lines.push(Line::from(spans)); + } + frame.render_widget(Paragraph::new(lines), area); +} + +/// Split `display` into styled spans: matched char positions render +/// bold (on top of the selected-row style). +fn highlight_spans<'a>(display: &'a str, indices: &[u32], selected: bool) -> Vec> { + let base = if selected { + Style::new().bold() + } else { + Style::new() + }; + let hilite = base.bold().underlined(); + let mut spans = Vec::new(); + let mut run = String::new(); + let mut run_hilited = false; + for (ci, ch) in display.chars().enumerate() { + let hit = indices.binary_search(&(ci as u32)).is_ok(); + if hit != run_hilited && !run.is_empty() { + spans.push(Span::styled( + std::mem::take(&mut run), + if run_hilited { hilite } else { base }, + )); + } + run_hilited = hit; + run.push(ch); + } + if !run.is_empty() { + spans.push(Span::styled(run, if run_hilited { hilite } else { base })); + } + spans +} + +/// Right-aligned dim status: `N/M` match count plus the mark count +/// when any rows are marked. +fn render_status(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let mut status = format!("{}/{}", state.matches.len(), state.rows.len()); + if !state.marked.is_empty() { + status.push_str(&format!(" · {} marked", state.marked.len())); + } + frame.render_widget( + Paragraph::new(status) + .style(Style::new().dim()) + .right_aligned(), + area, + ); +} + +/// The prompt + query input line, with the terminal cursor parked at +/// the edit position. +fn render_input(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let line = Line::from(vec![ + Span::styled(state.prompt.clone(), Style::new().bold()), + Span::raw(state.query.clone()), + ]); + frame.render_widget(Paragraph::new(line), area); + let prompt_cols = state.prompt.chars().count() as u16; + let cursor_cols = state.query[..state.cursor].chars().count() as u16; + let x = (area.x + prompt_cols + cursor_cols).min(area.right().saturating_sub(1)); + frame.set_cursor_position((x, area.y)); +} + +/// The preview pane: a titled block around the preview text, a dim +/// placeholder, or a dim-red error line. +fn render_preview(frame: &mut Frame<'_>, area: Rect, state: &AppState, view: &PreviewView<'_>) { + let block = Block::bordered().title(view.title); + let inner = block.inner(area); + frame.render_widget(block, area); + let paragraph = match view.body { + PreviewBody::Text(text) => Paragraph::new(text.clone()), + PreviewBody::Placeholder => Paragraph::new("deriving preview…").style(Style::new().dim()), + PreviewBody::Error(line) => Paragraph::new(line).style(Style::new().dim().fg(Color::Red)), + }; + let paragraph = match state.preview_window.wrap { + WrapMode::NoWrap => paragraph, + WrapMode::Wrap | WrapMode::WrapWord => paragraph.wrap(Wrap { trim: false }), + }; + frame.render_widget(paragraph.scroll((state.preview_scroll, 0)), inner); +} + #[cfg(test)] mod tests { + use super::super::InputEvent; + use super::super::matcher::{Row, parse_field_spec}; + use super::super::state::handle_event; use super::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; fn window(side: Side) -> PreviewWindow { PreviewWindow { @@ -344,4 +498,155 @@ mod tests { ); assert_eq!(mode, LayoutMode::Fullscreen); } + + // ── Frame snapshots ────────────────────────────────────────────── + + fn plain_state(lines: &[&str], with_nth: &str, multi: bool) -> AppState { + let spec = parse_field_spec(with_nth).unwrap(); + let rows: Vec = lines.iter().map(|l| Row::new(l, &spec)).collect(); + AppState::new(rows, multi, "> ", None, None) + } + + fn preview_state(lines: &[&str], side: Side) -> AppState { + let spec = parse_field_spec("2..").unwrap(); + let rows: Vec = lines.iter().map(|l| Row::new(l, &spec)).collect(); + AppState::new(rows, false, "> ", None, Some(window(side))) + } + + /// Render one frame at (w, h) and snapshot the buffer. + fn render_frame( + state: &AppState, + preview: Option<&PreviewView<'_>>, + w: u16, + h: u16, + ) -> Terminal { + let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); + let mode = choose_layout(state); + terminal.draw(|f| draw(f, state, mode, preview)).unwrap(); + terminal + } + + #[test] + fn snapshot_inline_empty_query() { + let mut state = plain_state(&["first row", "second row", "third row"], "1..", false); + state.term_w = 40; + state.term_h = 24; + // Inline viewport: the backend is exactly the viewport's size. + let LayoutMode::Inline { height } = choose_layout(&state) else { + panic!("expected inline layout"); + }; + let terminal = render_frame(&state, None, 40, height); + insta::assert_snapshot!(terminal.backend()); + } + + #[test] + fn snapshot_inline_filtered_highlights() { + let mut state = plain_state(&["alpha work", "beta work", "gamma play"], "1..", false); + state.term_w = 40; + state.term_h = 24; + for c in "work".chars() { + handle_event(&mut state, InputEvent::Char(c)); + } + let LayoutMode::Inline { height } = choose_layout(&state) else { + panic!("expected inline layout"); + }; + let terminal = render_frame(&state, None, 40, height); + insta::assert_snapshot!(terminal.backend()); + } + + #[test] + fn snapshot_multi_marked_rows() { + let mut state = plain_state(&["one", "two", "three"], "1..", true); + state.term_w = 40; + state.term_h = 24; + handle_event(&mut state, InputEvent::Tab); // mark "one", advance + handle_event(&mut state, InputEvent::Tab); // mark "two", advance + let LayoutMode::Inline { height } = choose_layout(&state) else { + panic!("expected inline layout"); + }; + let terminal = render_frame(&state, None, 40, height); + insta::assert_snapshot!(terminal.backend()); + } + + #[test] + fn snapshot_no_match_status_line() { + let mut state = plain_state(&["alpha", "beta"], "1..", false); + state.term_w = 40; + state.term_h = 24; + for c in "zzz".chars() { + handle_event(&mut state, InputEvent::Char(c)); + } + let LayoutMode::Inline { height } = choose_layout(&state) else { + panic!("expected inline layout"); + }; + let terminal = render_frame(&state, None, 40, height); + insta::assert_snapshot!(terminal.backend()); + } + + #[test] + fn snapshot_fullscreen_side_preview_ready() { + let mut state = preview_state( + &[ + "s1\t2026-08-01 10:00 first session", + "s2\t2026-08-02 11:00 second session", + ], + Side::Right, + ); + state.term_w = 120; + state.term_h = 16; + let text = Text::raw("# Session\n\nrendered preview body"); + let view = PreviewView { + title: "preview", + body: PreviewBody::Text(&text), + }; + let terminal = render_frame(&state, Some(&view), 120, 16); + insta::assert_snapshot!(terminal.backend()); + } + + #[test] + fn snapshot_fullscreen_stacked_narrow() { + let mut state = preview_state( + &[ + "s1\t2026-08-01 10:00 first session", + "s2\t2026-08-02 11:00 second session", + ], + Side::Right, + ); + // Below the side-by-side threshold: the right: spec stacks. + state.term_w = 60; + state.term_h = 18; + let text = Text::raw("stacked preview body"); + let view = PreviewView { + title: "preview", + body: PreviewBody::Text(&text), + }; + let terminal = render_frame(&state, Some(&view), 60, 18); + insta::assert_snapshot!(terminal.backend()); + } + + #[test] + fn snapshot_preview_loading_placeholder() { + let mut state = preview_state(&["s1\tonly session"], Side::Up); + state.term_w = 60; + state.term_h = 14; + let view = PreviewView { + title: "preview (loading…)", + body: PreviewBody::Placeholder, + }; + let terminal = render_frame(&state, Some(&view), 60, 14); + insta::assert_snapshot!(terminal.backend()); + } + + #[test] + fn snapshot_preview_error_pane() { + let mut state = preview_state(&["s1\tonly session"], Side::Up); + state.term_w = 60; + state.term_h = 14; + let view = PreviewView { + title: "preview", + body: PreviewBody::Error("error: session file unreadable"), + }; + let terminal = render_frame(&state, Some(&view), 60, 14); + insta::assert_snapshot!(terminal.backend()); + } } diff --git a/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_fullscreen_side_preview_ready.snap b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_fullscreen_side_preview_ready.snap new file mode 100644 index 00000000..4c5c39bb --- /dev/null +++ b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_fullscreen_side_preview_ready.snap @@ -0,0 +1,20 @@ +--- +source: crates/path-cli/src/tui/render.rs +expression: terminal.backend() +--- +"> 2026-08-01 10:00 first session ┌preview───────────────────────────────────────────────────────────────┐" +" 2026-08-02 11:00 second session │# Session │" +" │ │" +" │rendered preview body │" +" │ │" +" │ │" +" │ │" +" │ │" +" │ │" +" │ │" +" │ │" +" │ │" +" │ │" +" │ │" +" 2/2│ │" +"> └──────────────────────────────────────────────────────────────────────┘" diff --git a/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_fullscreen_stacked_narrow.snap b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_fullscreen_stacked_narrow.snap new file mode 100644 index 00000000..0d1eccf0 --- /dev/null +++ b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_fullscreen_stacked_narrow.snap @@ -0,0 +1,22 @@ +--- +source: crates/path-cli/src/tui/render.rs +expression: terminal.backend() +--- +"┌preview───────────────────────────────────────────────────┐" +"│stacked preview body │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└──────────────────────────────────────────────────────────┘" +"> 2026-08-01 10:00 first session " +" 2026-08-02 11:00 second session " +" " +" " +" " +" " +" 2/2" +"> " diff --git a/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_inline_empty_query.snap b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_inline_empty_query.snap new file mode 100644 index 00000000..b948e030 --- /dev/null +++ b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_inline_empty_query.snap @@ -0,0 +1,9 @@ +--- +source: crates/path-cli/src/tui/render.rs +expression: terminal.backend() +--- +"> first row " +" second row " +" third row " +" 3/3" +"> " diff --git a/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_inline_filtered_highlights.snap b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_inline_filtered_highlights.snap new file mode 100644 index 00000000..81cb42b5 --- /dev/null +++ b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_inline_filtered_highlights.snap @@ -0,0 +1,9 @@ +--- +source: crates/path-cli/src/tui/render.rs +expression: terminal.backend() +--- +"> alpha work " +" beta work " +" " +" 2/3" +"> work " diff --git a/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_multi_marked_rows.snap b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_multi_marked_rows.snap new file mode 100644 index 00000000..85031945 --- /dev/null +++ b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_multi_marked_rows.snap @@ -0,0 +1,9 @@ +--- +source: crates/path-cli/src/tui/render.rs +expression: terminal.backend() +--- +" * one " +" * two " +"> three " +" 3/3 · 2 marked" +"> " diff --git a/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_no_match_status_line.snap b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_no_match_status_line.snap new file mode 100644 index 00000000..dcb3c68c --- /dev/null +++ b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_no_match_status_line.snap @@ -0,0 +1,8 @@ +--- +source: crates/path-cli/src/tui/render.rs +expression: terminal.backend() +--- +" " +" " +" 0/2" +"> zzz " diff --git a/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_preview_error_pane.snap b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_preview_error_pane.snap new file mode 100644 index 00000000..c472df17 --- /dev/null +++ b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_preview_error_pane.snap @@ -0,0 +1,18 @@ +--- +source: crates/path-cli/src/tui/render.rs +expression: terminal.backend() +--- +"┌preview───────────────────────────────────────────────────┐" +"│error: session file unreadable │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└──────────────────────────────────────────────────────────┘" +"> only session " +" " +" " +" " +" 1/1" +"> " diff --git a/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_preview_loading_placeholder.snap b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_preview_loading_placeholder.snap new file mode 100644 index 00000000..e5e3d883 --- /dev/null +++ b/crates/path-cli/src/tui/snapshots/path_cli__tui__render__tests__snapshot_preview_loading_placeholder.snap @@ -0,0 +1,18 @@ +--- +source: crates/path-cli/src/tui/render.rs +expression: terminal.backend() +--- +"┌preview (loading…)────────────────────────────────────────┐" +"│deriving preview… │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└──────────────────────────────────────────────────────────┘" +"> only session " +" " +" " +" " +" 1/1" +"> " From 3472ab607fe64e9c4fa4fa74bad250e942d3a420 Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 15:58:30 -0400 Subject: [PATCH 06/14] feat(path-cli): tui event loop and terminal lifecycle --- crates/path-cli/src/tui/mod.rs | 292 +++++++++++++++++++++++++++++ crates/path-cli/src/tui/preview.rs | 7 +- 2 files changed, 296 insertions(+), 3 deletions(-) diff --git a/crates/path-cli/src/tui/mod.rs b/crates/path-cli/src/tui/mod.rs index 63698065..bd744e3a 100644 --- a/crates/path-cli/src/tui/mod.rs +++ b/crates/path-cli/src/tui/mod.rs @@ -23,6 +23,298 @@ mod preview; mod render; mod state; +use std::io::Stderr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use crossterm::event::{self, Event, KeyEventKind}; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Rect; +use ratatui::{Terminal, TerminalOptions, Viewport}; + +use crate::fuzzy::{PickOptions, PickResult}; + +use matcher::Row; +use preview::{PreviewContent, PreviewScheduler}; +use render::{LayoutMode, PreviewBody, PreviewView}; +use state::AppState; + +/// How long each event-loop tick waits for input before servicing the +/// preview scheduler and redrawing. +const POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// True while the terminal is in picker mode (raw + maybe alt screen) +/// and needs restoring. Consulted by both the panic hook and the +/// normal drop path so restore runs exactly once whichever fires. +static NEEDS_RESTORE: AtomicBool = AtomicBool::new(false); +/// Whether the emergency restore must also leave the alternate screen. +static RESTORE_FULLSCREEN: AtomicBool = AtomicBool::new(false); + +/// Restore-first path for the panic hook: idempotent, best-effort, +/// touches only global terminal state (the `Terminal` value itself is +/// unreachable from a panic hook). +fn emergency_restore() { + if !NEEDS_RESTORE.swap(false, Ordering::SeqCst) { + return; + } + let _ = disable_raw_mode(); + if RESTORE_FULLSCREEN.load(Ordering::SeqCst) { + let _ = crossterm::execute!(std::io::stderr(), LeaveAlternateScreen); + } +} + +/// Scoped panic hook: `take_hook` -> install a restore-first wrapper, +/// then put the previous hook back on clean drop. +struct PanicHookGuard { + prev: Option) + Send + Sync>>, +} + +impl PanicHookGuard { + fn install() -> Self { + let prev: Arc) + Send + Sync> = + Arc::from(std::panic::take_hook()); + let in_hook = prev.clone(); + std::panic::set_hook(Box::new(move |info| { + emergency_restore(); + in_hook(info); + })); + Self { prev: Some(prev) } + } +} + +impl Drop for PanicHookGuard { + fn drop(&mut self) { + if let Some(prev) = self.prev.take() { + std::panic::set_hook(Box::new(move |info| prev(info))); + } + } +} + +/// The live terminal for one layout mode. Renders on **stderr** so +/// stdout stays clean for piped results. Restore is idempotent (the +/// [`NEEDS_RESTORE`] flag arbitrates with the panic hook) and clears +/// the inline viewport region so the shell prompt continues cleanly. +struct TermGuard { + terminal: Terminal>, + fullscreen: bool, +} + +impl TermGuard { + fn new(mode: LayoutMode) -> Result { + let fullscreen = mode.is_fullscreen(); + enable_raw_mode().context("enable raw mode")?; + NEEDS_RESTORE.store(true, Ordering::SeqCst); + RESTORE_FULLSCREEN.store(fullscreen, Ordering::SeqCst); + let backend = CrosstermBackend::new(std::io::stderr()); + let terminal = match mode { + LayoutMode::Inline { height } => Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(height), + }, + ) + .context("create inline terminal")?, + _ => { + crossterm::execute!(std::io::stderr(), EnterAlternateScreen) + .context("enter alternate screen")?; + Terminal::new(backend).context("create fullscreen terminal")? + } + }; + Ok(Self { + terminal, + fullscreen, + }) + } + + fn restore(&mut self) { + if !NEEDS_RESTORE.swap(false, Ordering::SeqCst) { + return; + } + if !self.fullscreen { + // Clear the inline viewport region so the shell prompt + // continues where the picker sat, without stale rows. + let _ = self.terminal.clear(); + } + let _ = disable_raw_mode(); + if self.fullscreen { + let _ = crossterm::execute!(std::io::stderr(), LeaveAlternateScreen); + } + } +} + +impl Drop for TermGuard { + fn drop(&mut self) { + self.restore(); + } +} + +/// Run the native picker over `lines` with the fzf-shaped `opts`. +/// Same contract as the external backend: `Selected` carries full +/// original lines (hidden columns included), Esc/Ctrl-C yield +/// `Cancelled`, an accepted empty match set yields `NoMatch`. +pub(crate) fn pick(lines: &[String], opts: &PickOptions<'_>) -> Result { + let spec = matcher::parse_field_spec(opts.with_nth)?; + let rows: Vec = lines.iter().map(|l| Row::new(l, &spec)).collect(); + let preview_window = opts + .preview + .map(|_| preview::parse_preview_window(opts.preview_window)); + let mut state = AppState::new(rows, opts.multi, opts.prompt, opts.header, preview_window); + if let Ok((w, h)) = crossterm::terminal::size() { + state.term_w = w; + state.term_h = h; + } + let template = opts.preview.map(crate::fuzzy::substitute_exe_placeholder); + + let _hook = PanicHookGuard::install(); + let mut mode = render::choose_layout(&state); + let mut guard = TermGuard::new(mode)?; + + let (tx, rx) = mpsc::channel::(); + let mut scheduler = PreviewScheduler::new(); + let kill_slot = preview::new_kill_slot(); + let mut last_row: Option = None; + // Row whose Ready text the pane last showed — kept on cache misses + // so the pane doesn't blank while the next preview derives. + let mut shown_row: Option = None; + + if template.is_some() + && let Some(row) = state.current_row() + { + scheduler.on_selection_change(row, Instant::now()); + last_row = Some(row); + } + + let result = loop { + // Pane geometry for this tick: page size + preview inner size. + let size = guard.terminal.size().context("query terminal size")?; + let frame_area = match mode { + LayoutMode::Inline { height } => Rect::new(0, 0, size.width, height.min(size.height)), + _ => Rect::new(0, 0, size.width, size.height), + }; + let areas = render::compute_areas(&state, mode, frame_area); + state.page_rows = (areas.list.height as usize).max(1); + + let view = build_preview_view(&state, &scheduler, &mut shown_row); + guard + .terminal + .draw(|f| render::draw(f, &state, mode, view.as_ref())) + .context("draw picker frame")?; + + if event::poll(POLL_INTERVAL).context("poll terminal events")? { + match event::read().context("read terminal event")? { + // Key-release events double-fire on Windows terminals; + // only act on press/repeat. + Event::Key(key) if key.kind != KeyEventKind::Release => { + if let Some(result) = state::handle_event(&mut state, InputEvent::from(key)) { + break result; + } + } + Event::Resize(w, h) => { + state::handle_event(&mut state, InputEvent::Resize(w, h)); + } + _ => {} + } + } + + // Layout may have changed (resize, Ctrl-O). Crossing the + // inline/fullscreen boundary — or changing the inline height — + // needs a fresh terminal; staying fullscreen just re-splits on + // the next draw. + let new_mode = render::choose_layout(&state); + if new_mode != mode { + let recreate = + mode.is_fullscreen() != new_mode.is_fullscreen() || !new_mode.is_fullscreen(); + mode = new_mode; + if recreate { + guard.restore(); + guard = TermGuard::new(mode)?; + } else { + guard.terminal.autoresize().context("autoresize terminal")?; + } + } else if matches!(mode, LayoutMode::Inline { .. }) { + guard.terminal.autoresize().context("autoresize terminal")?; + } + + if let Some(template) = template.as_deref() { + let now = Instant::now(); + let current = state.current_row(); + if current != last_row { + if let Some(row) = current { + scheduler.on_selection_change(row, now); + } + last_row = current; + } + while let Ok(msg) = rx.try_recv() { + scheduler.on_msg(msg, current); + } + if let Some(req) = scheduler.poll(now) { + let pane = areas + .preview + .map(|r| (r.width.saturating_sub(2), r.height.saturating_sub(2))) + .unwrap_or((size.width, size.height)); + let row = &state.rows[req.row]; + let command = + preview::substitute_placeholders(template, &row.fields, &row.original); + preview::spawn_preview_job(req, command, pane, tx.clone(), kill_slot.clone()); + } + } + }; + + guard.restore(); + // Don't leave a preview command running after the picker exits. + preview::kill_current(&kill_slot); + Ok(result) +} + +/// Decide what the preview pane shows this frame. Pure derivation +/// from scheduler cache + selection: cache hits render instantly, a +/// miss keeps the previously shown text under a "(loading…)" title, +/// and a miss with nothing to keep shows the dim placeholder. +fn build_preview_view<'a>( + state: &AppState, + scheduler: &'a PreviewScheduler, + shown_row: &mut Option, +) -> Option> { + if !state.has_preview || !state.preview_visible { + return None; + } + let Some(row) = state.current_row() else { + return Some(PreviewView { + title: "preview", + body: PreviewBody::Placeholder, + }); + }; + match scheduler.cached(row) { + Some(PreviewContent::Ready(text)) => { + *shown_row = Some(row); + Some(PreviewView { + title: "preview", + body: PreviewBody::Text(text), + }) + } + Some(PreviewContent::Failed(line)) => Some(PreviewView { + title: "preview", + body: PreviewBody::Error(line), + }), + None => match shown_row.and_then(|r| scheduler.cached(r)) { + Some(PreviewContent::Ready(text)) => Some(PreviewView { + title: "preview (loading…)", + body: PreviewBody::Text(text), + }), + _ => Some(PreviewView { + title: "preview (loading…)", + body: PreviewBody::Placeholder, + }), + }, + } +} + /// Picker input, decoupled from crossterm so [`state::handle_event`] /// stays pure and event-vector testable. Chorded editing keys with /// obvious single equivalents are normalized in the `From` impl diff --git a/crates/path-cli/src/tui/preview.rs b/crates/path-cli/src/tui/preview.rs index d8bc2691..f6de8a7c 100644 --- a/crates/path-cli/src/tui/preview.rs +++ b/crates/path-cli/src/tui/preview.rs @@ -241,8 +241,9 @@ pub(super) fn new_kill_slot() -> KillSlot { Arc::new(Mutex::new(None)) } -/// Kill and reap whatever child currently occupies the slot. -fn supersede(slot: &KillSlot) { +/// Kill and reap whatever child currently occupies the slot. Called +/// when a newer preview supersedes a running one, and on picker exit. +pub(super) fn kill_current(slot: &KillSlot) { let prev = slot.lock().expect("kill slot poisoned").take(); if let Some(mut child) = prev { let _ = child.kill(); @@ -263,7 +264,7 @@ pub(super) fn spawn_preview_job( tx: Sender, slot: KillSlot, ) { - supersede(&slot); + kill_current(&slot); std::thread::spawn(move || { if let Some(content) = run_preview_command(&command, pane, &slot) { // Receiver gone means the picker already exited — fine. From 039ea42117307571dcbc33c73aabc9e4daf2ccd0 Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 16:00:41 -0400 Subject: [PATCH 07/14] feat(path-cli): switch embedded picker backend to the native tui picker --- crates/path-cli/src/fuzzy.rs | 106 +++++++++++++++++++++-------- crates/path-cli/src/lib.rs | 9 +-- crates/path-cli/src/skim_picker.rs | 3 + crates/path-cli/src/tui/mod.rs | 4 -- crates/path-cli/src/tui/render.rs | 6 ++ 5 files changed, 92 insertions(+), 36 deletions(-) diff --git a/crates/path-cli/src/fuzzy.rs b/crates/path-cli/src/fuzzy.rs index cd970b3f..9b39abb2 100644 --- a/crates/path-cli/src/fuzzy.rs +++ b/crates/path-cli/src/fuzzy.rs @@ -14,15 +14,17 @@ //! //! Two backends, selected at runtime: //! -//! - **External `fzf`** (preferred when on `PATH`). Users keep their -//! own fzf config and keybindings. -//! - **Embedded skim** (Rust fzf-clone, compiled in unless the -//! `embedded-picker` feature is disabled). Same `{1}`/`{2}` preview -//! placeholder grammar and the same `--with-nth`/`--tiebreak` -//! notation, so existing call sites work unchanged. +//! - **Native picker** (`crates/path-cli/src/tui/`, compiled in unless +//! the `embedded-picker` feature is disabled). First-party ratatui +//! picker: adaptive inline/fullscreen layouts, debounced async +//! previews, fzf-style query operators. Honors the same `{1}`/`{2}` +//! preview placeholder grammar and `--with-nth`/`--tiebreak` +//! notation, so all call sites work with either backend. +//! - **External `fzf`** (escape hatch, forced via `--picker fzf`). +//! Users keep their own fzf config and keybindings. //! //! When neither backend can run (no TTY, or no external fzf with the -//! embedded picker disabled at build time), callers fall through to the +//! native picker disabled at build time), callers fall through to the //! documented manual recipe printed by [`print_recipe`]. #![cfg(not(target_os = "emscripten"))] @@ -35,24 +37,26 @@ use std::sync::OnceLock; /// Backend override for the interactive fuzzy picker, set once at CLI /// startup via the global `--picker` flag and read by [`pick`]. /// -/// `Auto` (the default) preserves the historical behavior: external -/// `fzf` if on `PATH`, embedded skim otherwise. `Fzf` and `Skim` are +/// `Auto` (the default) prefers the native picker and falls back to +/// external `fzf` when it isn't compiled in. `Fzf` and `Native` are /// strict — they error out rather than silently using the other backend. #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)] #[value(rename_all = "lower")] pub enum Picker { - /// Pick whichever backend is available; prefer the embedded skim - /// picker and fall back to external `fzf` when skim isn't compiled - /// in. When external `fzf` is also on `PATH`, a one-time hint is + /// Pick whichever backend is available; prefer the native picker + /// and fall back to external `fzf` when it isn't compiled in. + /// When external `fzf` is also on `PATH`, a one-time hint is /// printed to stderr advising `--picker fzf` for users who want /// their fzf config / keybindings. #[default] Auto, /// Force the external `fzf` binary. Errors if it isn't on `PATH`. Fzf, - /// Force the embedded skim picker. Errors if path-cli was built - /// with `--no-default-features`. - Skim, + /// Force the built-in native picker. Errors if path-cli was built + /// with `--no-default-features`. `skim` is accepted as a hidden + /// alias for the backend this one replaced. + #[value(alias = "skim")] + Native, } static PICKER_OVERRIDE: OnceLock = OnceLock::new(); @@ -62,9 +66,31 @@ static PICKER_OVERRIDE: OnceLock = OnceLock::new(); /// value wins, so library callers can't accidentally override the /// user's explicit choice mid-run. pub fn set_picker_override(picker: Picker) { + if picker == Picker::Native && args_used_skim_alias(std::env::args()) { + eprintln!( + "note: `--picker skim` is now the native picker; the skim backend was removed in 0.17" + ); + } let _ = PICKER_OVERRIDE.set(picker); } +/// Detect whether the user literally typed the `skim` alias — clap +/// resolves aliases before we see the parsed value, so the raw args +/// are the only place the spelling survives. +fn args_used_skim_alias>(args: I) -> bool { + let mut prev_was_picker_flag = false; + for arg in args { + if prev_was_picker_flag && arg == "skim" { + return true; + } + prev_was_picker_flag = arg == "--picker"; + if arg == "--picker=skim" { + return true; + } + } + false +} + fn current_picker() -> Picker { PICKER_OVERRIDE.get().copied().unwrap_or_default() } @@ -82,14 +108,14 @@ pub fn available() -> bool { } /// True when the external `fzf` binary is on `PATH`. Used by [`pick`] -/// to decide between the external picker and the embedded skim -/// fallback, and to tailor the manual-recipe message. +/// to decide between the native picker and the external fallback, and +/// to tailor the manual-recipe message. pub fn external_fzf_available() -> bool { which("fzf").is_some() } -/// True when the embedded skim picker was compiled in. Always `true` -/// in the default build; `false` under `--no-default-features`. +/// True when the native picker was compiled in. Always `true` in the +/// default build; `false` under `--no-default-features`. #[cfg(feature = "embedded-picker")] pub const fn embedded_picker_available() -> bool { true @@ -379,17 +405,17 @@ pub fn pick(lines: &[String], opts: &PickOptions<'_>) -> Result { Picker::Fzf => { if !external_fzf_available() { anyhow::bail!( - "`--picker fzf` requested but `fzf` isn't on PATH; install it or pass `--picker auto`/`skim`" + "`--picker fzf` requested but `fzf` isn't on PATH; install it or pass `--picker auto`/`native`" ); } pick_external(lines, opts) } - Picker::Skim => pick_embedded(lines, opts), + Picker::Native => pick_embedded(lines, opts), Picker::Auto => { - // Skim is the default: it's compiled in, behaves - // consistently across versions, and we control its option - // surface. Fall back to external fzf only when skim is - // unavailable (e.g. `--no-default-features` builds). + // The native picker is the default: it's compiled in, + // behaves consistently across versions, and we control its + // whole surface. Fall back to external fzf only when it + // isn't compiled in (`--no-default-features` builds). if embedded_picker_available() { if external_fzf_available() { hint_external_fzf_available(); @@ -417,18 +443,18 @@ fn hint_external_fzf_available() { } } -/// Invoke the embedded skim picker. Compiled-out under +/// Invoke the native picker. Compiled-out under /// `--no-default-features`, in which case calling this returns a clear /// error rather than the cryptic "no backend" surface. #[cfg(feature = "embedded-picker")] fn pick_embedded(lines: &[String], opts: &PickOptions<'_>) -> Result { - crate::skim_picker::pick(lines, opts) + crate::tui::pick(lines, opts) } #[cfg(not(feature = "embedded-picker"))] fn pick_embedded(_lines: &[String], _opts: &PickOptions<'_>) -> Result { anyhow::bail!( - "embedded skim picker isn't compiled in (built with `--no-default-features`); install `fzf` and pass `--picker fzf` or rebuild with the default `embedded-picker` feature" + "the native picker isn't compiled in (built with `--no-default-features`); install `fzf` and pass `--picker fzf` or rebuild with the default `embedded-picker` feature" ) } @@ -770,6 +796,30 @@ mod tests { assert!(!out.contains(" Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn skim_alias_detected_in_both_flag_spellings() { + assert!(args_used_skim_alias(args(&[ + "path", "--picker", "skim", "share" + ]))); + assert!(args_used_skim_alias(args(&[ + "path", + "--picker=skim", + "share" + ]))); + } + + #[test] + fn skim_alias_not_detected_for_native_or_unrelated_args() { + assert!(!args_used_skim_alias(args(&["path", "--picker", "native"]))); + assert!(!args_used_skim_alias(args(&["path", "--picker", "fzf"]))); + // "skim" as a positional (not following --picker) doesn't count. + assert!(!args_used_skim_alias(args(&["path", "query", "skim"]))); + } + #[test] fn parse_fzf_version_handles_common_shapes() { assert_eq!(parse_fzf_version("0.46.0\n"), Some((0, 46))); diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 39296254..53dcf941 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -59,10 +59,11 @@ struct Cli { /// Backend for the interactive fuzzy picker used by `share`, /// `resume`, and `p import `. `auto` (default) uses the - /// embedded skim picker and falls back to external `fzf` only when - /// skim isn't compiled in; a hint is printed if `fzf` is also on - /// PATH. `fzf`/`skim` force one backend and error if it isn't - /// available. + /// built-in native picker and falls back to external `fzf` only + /// when the native picker isn't compiled in; a hint is printed if + /// `fzf` is also on PATH. `fzf`/`native` force one backend and + /// error if it isn't available (`skim` is a legacy alias for + /// `native`). #[cfg(not(target_os = "emscripten"))] #[arg(long, global = true, value_enum, default_value_t = fuzzy::Picker::Auto)] picker: fuzzy::Picker, diff --git a/crates/path-cli/src/skim_picker.rs b/crates/path-cli/src/skim_picker.rs index cc3d605c..3cb0bcea 100644 --- a/crates/path-cli/src/skim_picker.rs +++ b/crates/path-cli/src/skim_picker.rs @@ -6,6 +6,9 @@ //! about, and its `{1}`, `{2}` preview-placeholder syntax matches fzf's, //! so the existing preview commands (`path show ...`) work unchanged. #![cfg(not(target_os = "emscripten"))] +// The native picker (crate::tui) replaced this backend; the module is +// deleted in the next commit — the allow keeps this one green. +#![allow(dead_code)] use anyhow::{Context, Result}; use std::io::Cursor; diff --git a/crates/path-cli/src/tui/mod.rs b/crates/path-cli/src/tui/mod.rs index bd744e3a..7300f1de 100644 --- a/crates/path-cli/src/tui/mod.rs +++ b/crates/path-cli/src/tui/mod.rs @@ -14,10 +14,6 @@ //! returns [`crate::fuzzy::PickResult`] exactly like the external fzf //! backend. -// Removed when fuzzy.rs switches its embedded backend to this module -// (the module is dark until then, and `-D warnings` would reject it). -#![allow(dead_code)] - mod matcher; mod preview; mod render; diff --git a/crates/path-cli/src/tui/render.rs b/crates/path-cli/src/tui/render.rs index 69dc8954..0bed8d92 100644 --- a/crates/path-cli/src/tui/render.rs +++ b/crates/path-cli/src/tui/render.rs @@ -21,7 +21,13 @@ use super::state::AppState; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum LayoutPref { Adaptive, + /// Force the inline viewport. No production caller constructs the + /// forced variants yet — they exist for a future layout flag (and + /// the ladder tests exercise them). + #[allow(dead_code)] Inline, + /// Force the fullscreen alternate screen. + #[allow(dead_code)] Fullscreen, } From df2bca812505309362b62f428b045c739decab8c Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 16:04:28 -0400 Subject: [PATCH 08/14] feat(path-cli)!: remove the skim picker backend --- Cargo.lock | 588 +---------------------------- crates/path-cli/Cargo.toml | 24 +- crates/path-cli/src/fuzzy.rs | 8 +- crates/path-cli/src/lib.rs | 2 - crates/path-cli/src/skim_picker.rs | 149 -------- 5 files changed, 20 insertions(+), 751 deletions(-) delete mode 100644 crates/path-cli/src/skim_picker.rs diff --git a/Cargo.lock b/Cargo.lock index f7dc4475..6d386ab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "ahash" version = "0.8.12" @@ -124,12 +109,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - [[package]] name = "assert_cmd" version = "2.2.1" @@ -145,12 +124,6 @@ dependencies = [ "wait-timeout", ] -[[package]] -name = "assert_enum_variants" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1685feee7d06d8813fe963f814c5c398d90392b9c3c41e656ac3100d5c334536" - [[package]] name = "atomic" version = "0.6.1" @@ -194,21 +167,6 @@ dependencies = [ "fs_extra", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - [[package]] name = "base64" version = "0.22.1" @@ -256,9 +214,6 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -dependencies = [ - "serde_core", -] [[package]] name = "block-buffer" @@ -337,12 +292,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "cfg_aliases" version = "0.2.1" @@ -385,25 +334,6 @@ dependencies = [ "strsim", ] -[[package]] -name = "clap_complete" -version = "4.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" -dependencies = [ - "clap", -] - -[[package]] -name = "clap_complete_nushell" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbb9e9715d29a754b468591be588f6b926f5b0a1eb6a8b62acabeb66ff84d897" -dependencies = [ - "clap", - "clap_complete", -] - [[package]] name = "clap_derive" version = "4.6.1" @@ -431,33 +361,6 @@ dependencies = [ "cc", ] -[[package]] -name = "color-eyre" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" -dependencies = [ - "backtrace", - "color-spantrace", - "eyre", - "indenter", - "once_cell", - "owo-colors", - "tracing-error", -] - -[[package]] -name = "color-spantrace" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" -dependencies = [ - "once_cell", - "owo-colors", - "tracing-core", - "tracing-error", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -553,9 +456,6 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", - "filedescriptor", - "futures-core", - "libc", "mio", "parking_lot", "rustix", @@ -593,38 +493,14 @@ dependencies = [ "phf", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", + "darling_core", + "darling_macro", ] [[package]] @@ -640,24 +516,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.117", ] @@ -715,37 +580,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -766,7 +600,6 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", - "unicode-xid", ] [[package]] @@ -796,12 +629,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "doctest-file" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" - [[package]] name = "document-features" version = "0.2.12" @@ -811,12 +638,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "dunce" version = "1.0.5" @@ -884,16 +705,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "eyre" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" -dependencies = [ - "indenter", - "once_cell", -] - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1030,16 +841,6 @@ dependencies = [ "num", ] -[[package]] -name = "frizbee" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca1f7b0dce1c25254457f2edcfcf611bf207113564b96f90659a3f54506016b2" -dependencies = [ - "itertools", - "raw-cpuid", -] - [[package]] name = "fs_extra" version = "1.3.0" @@ -1193,12 +994,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "git2" version = "0.19.0" @@ -1534,12 +1329,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indenter" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" - [[package]] name = "indexmap" version = "2.14.0" @@ -1599,7 +1388,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling 0.23.0", + "darling", "indoc", "proc-macro2", "quote", @@ -1615,21 +1404,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "interprocess" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069323743400cb7ab06a8fe5c1ed911d36b6919ec531661d034c89083629595b" -dependencies = [ - "doctest-file", - "futures-core", - "libc", - "recvmsg", - "tokio", - "widestring", - "windows-sys 0.61.2", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -1854,16 +1628,6 @@ dependencies = [ "uuid-simd", ] -[[package]] -name = "kanal" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e3953adf0cd667798b396c2fa13552d6d9b3269d7dd1154c4c416442d1ff574" -dependencies = [ - "futures-core", - "lock_api", -] - [[package]] name = "kasuari" version = "0.4.12" @@ -1939,16 +1703,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libmimalloc-sys" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "libredox" version = "0.1.16" @@ -2061,7 +1815,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix 0.29.0", + "nix", "winapi", ] @@ -2092,15 +1846,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" -[[package]] -name = "mimalloc" -version = "0.1.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8" -dependencies = [ - "libmimalloc-sys", -] - [[package]] name = "mime" version = "0.3.17" @@ -2113,15 +1858,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - [[package]] name = "mio" version = "1.2.0" @@ -2134,18 +1870,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases 0.1.1", - "libc", -] - [[package]] name = "nix" version = "0.29.0" @@ -2154,23 +1878,11 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "memoffset", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases 0.2.1", - "libc", -] - [[package]] name = "nom" version = "7.1.3" @@ -2339,15 +2051,6 @@ dependencies = [ "libc", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2420,12 +2123,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - [[package]] name = "parking_lot" version = "0.12.5" @@ -2471,14 +2168,12 @@ dependencies = [ "predicates", "rand 0.9.4", "ratatui", - "regex", "reqwest", "rusqlite", "serde", "serde_json", "sha2", "similar", - "skim", "tempfile", "tokio", "toolpath", @@ -2649,27 +2344,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix 0.28.0", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -2838,7 +2512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -2879,7 +2553,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "once_cell", "socket2", @@ -3037,21 +2711,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "recvmsg" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3211,26 +2870,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "roff" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" - -[[package]] -name = "ron" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" -dependencies = [ - "bitflags 2.11.1", - "once_cell", - "serde", - "serde_derive", - "typeid", - "unicode-ident", -] - [[package]] name = "rusqlite" version = "0.32.1" @@ -3245,12 +2884,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - [[package]] name = "rustc-hash" version = "2.1.2" @@ -3546,17 +3179,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "serial2" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" -dependencies = [ - "cfg-if", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "sha1" version = "0.10.6" @@ -3579,40 +3201,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-quote" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb502615975ae2365825521fa1529ca7648fd03ce0b0746604e0683856ecd7e4" -dependencies = [ - "bstr", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" @@ -3678,47 +3266,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" -[[package]] -name = "skim" -version = "4.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311d1058ae9abf4fd0589b74624a3658cb1ae9ad9f43a2ce0fb47adbcf0d022e" -dependencies = [ - "ansi-to-tui", - "assert_enum_variants", - "cfg_aliases 0.2.1", - "clap_complete_nushell", - "color-eyre", - "crossterm", - "derive_builder", - "derive_more", - "frizbee", - "futures", - "indexmap", - "interprocess", - "kanal", - "log", - "memchr", - "mimalloc", - "nix 0.31.3", - "portable-pty", - "ratatui", - "regex", - "roff", - "ron", - "serde", - "shell-quote", - "tempfile", - "thiserror 2.0.18", - "thread_local", - "tokio", - "tokio-util", - "tui-term", - "unicode-display-width", - "unicode-normalization", - "which", -] - [[package]] name = "slab" version = "0.4.12" @@ -3907,7 +3454,7 @@ dependencies = [ "libc", "log", "memmem", - "nix 0.29.0", + "nix", "num-derive", "num-traits", "ordered-float", @@ -3971,15 +3518,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - [[package]] name = "time" version = "0.3.47" @@ -4307,28 +3845,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" -dependencies = [ - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "sharded-slab", - "thread_local", - "tracing-core", ] [[package]] @@ -4337,29 +3853,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tui-term" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338ded85dbe7f9ea2298321d126244f54e531e2b2006b97abdab8e47d6f3c88" -dependencies = [ - "ratatui-core", - "ratatui-widgets", - "vt100", -] - [[package]] name = "typed-arena" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - [[package]] name = "typenum" version = "1.20.0" @@ -4419,15 +3918,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" -[[package]] -name = "unicode-display-width" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a43273b656140aa2bb8e65351fe87c255f0eca706b2538a9bd4a590a3490bf3" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "unicode-general-category" version = "1.1.0" @@ -4440,15 +3930,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -4543,12 +4024,6 @@ dependencies = [ "vsimd", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "vcpkg" version = "0.2.15" @@ -4567,27 +4042,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" -[[package]] -name = "vt100" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" -dependencies = [ - "itoa", - "unicode-width", - "vte", -] - -[[package]] -name = "vte" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" -dependencies = [ - "arrayvec", - "memchr", -] - [[package]] name = "vtparse" version = "0.6.2" @@ -4852,21 +4306,6 @@ dependencies = [ "wezterm-dynamic", ] -[[package]] -name = "which" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" -dependencies = [ - "libc", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" @@ -5124,15 +4563,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index bb11afed..cd1b205f 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -57,18 +57,10 @@ reqwest = { workspace = true } tokio = { workspace = true } rusqlite = { workspace = true } uuid = { workspace = true, features = ["v4"] } -# Embedded fuzzy picker — used when external `fzf` isn't on PATH. -# Disable default features to avoid pulling in skim's CLI deps -# (clap, clap_mangen, env_logger); keep `frizbee` for the matcher. -# Gated by the `embedded-picker` feature (enabled by default) so -# downstream packagers who want a smaller binary can opt out. -skim = { version = "4.7", default-features = false, features = ["frizbee"], optional = true } -# Skim's `delimiter: Regex` field has no string setter — we need the -# crate directly to build a tab regex. Skim already pulls `regex` in -# transitively, so this just makes it explicit. -regex = { version = "1", optional = true } -# Native picker (crates/path-cli/src/tui/) — same `embedded-picker` -# feature gate. Versions pinned to what the lockfile already carries. +# Native fuzzy picker (crates/path-cli/src/tui/) — used when external +# `fzf` isn't on PATH (and by default even when it is). Gated by the +# `embedded-picker` feature (enabled by default) so downstream +# packagers who want a smaller binary can opt out. ratatui = { version = "0.30", optional = true } crossterm = { version = "0.29", optional = true } nucleo-matcher = { version = "0.3", optional = true } @@ -84,13 +76,11 @@ toolpath-pi = { workspace = true } [features] default = ["embedded-picker"] vendored-openssl = ["git2/vendored-openssl"] -# Ship a built-in fuzzy picker (skim) so `path share` / `path resume` / +# Ship the built-in native picker so `path share` / `path resume` / # `path p import` work in interactive flows even when external `fzf` -# isn't on PATH. Adds ~2 MB to the release binary; turn off -# (`--no-default-features`) for the minimal build. +# isn't on PATH. Turn off (`--no-default-features`) for the minimal +# build (external `fzf` then becomes the only interactive backend). embedded-picker = [ - "dep:skim", - "dep:regex", "dep:ratatui", "dep:crossterm", "dep:nucleo-matcher", diff --git a/crates/path-cli/src/fuzzy.rs b/crates/path-cli/src/fuzzy.rs index 9b39abb2..7466cc2a 100644 --- a/crates/path-cli/src/fuzzy.rs +++ b/crates/path-cli/src/fuzzy.rs @@ -96,10 +96,10 @@ fn current_picker() -> Picker { } /// Returns true when an interactive picker can actually run: stdin and -/// stderr are TTYs *and* at least one backend is available (external -/// `fzf` on `PATH`, or the embedded skim picker compiled in). Callers -/// use this as a guard before invoking [`pick`]; on `false` they print -/// the manual recipe via [`print_recipe`]. +/// stderr are TTYs *and* at least one backend is available (the native +/// picker compiled in, or external `fzf` on `PATH`). Callers use this +/// as a guard before invoking [`pick`]; on `false` they print the +/// manual recipe via [`print_recipe`]. pub fn available() -> bool { if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { return false; diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 53dcf941..2bd49641 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -36,8 +36,6 @@ mod io; mod kinds; mod query; mod schema; -#[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] -mod skim_picker; mod sync; mod term; #[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] diff --git a/crates/path-cli/src/skim_picker.rs b/crates/path-cli/src/skim_picker.rs deleted file mode 100644 index 3cb0bcea..00000000 --- a/crates/path-cli/src/skim_picker.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Embedded fuzzy picker — used when external `fzf` isn't on `PATH`. -//! -//! Mirrors the `fzf::PickOptions` surface so callers don't have to know -//! which backend is in play. Skim is a Rust fzf-clone that supports the -//! same `--with-nth`/`--preview`/`--multi`/`--tiebreak` knobs we care -//! about, and its `{1}`, `{2}` preview-placeholder syntax matches fzf's, -//! so the existing preview commands (`path show ...`) work unchanged. -#![cfg(not(target_os = "emscripten"))] -// The native picker (crate::tui) replaced this backend; the module is -// deleted in the next commit — the allow keeps this one green. -#![allow(dead_code)] - -use anyhow::{Context, Result}; -use std::io::Cursor; - -use crate::fuzzy::{PickOptions, PickResult}; - -use regex::Regex; -use skim::Skim; -use skim::prelude::{RankCriteria, SkimItemReader, SkimItemReaderOption, SkimOptionsBuilder}; - -/// Run the embedded fuzzy picker. Same semantics as `fzf::pick`: returns -/// `Selected` for accepted picks, `Cancelled` for Esc/Ctrl-C, `NoMatch` -/// for "input was non-empty but nothing matched the query". -pub fn pick(lines: &[String], opts: &PickOptions<'_>) -> Result { - let tiebreak = parse_tiebreak(opts.tiebreak)?; - let with_nth = parse_field_spec(opts.with_nth); - - let mut builder = SkimOptionsBuilder::default(); - builder - .prompt(opts.prompt.to_string()) - .multi(opts.multi) - // Match the fzf side, which always passes `--delimiter=\t`. The - // skim default (`[\t\n ]+`) would split on spaces inside display - // columns, which we don't want. - .delimiter(Regex::new(r"\t").expect("static tab regex")) - .with_nth(with_nth) - .tiebreak(vec![tiebreak]) - // fzf shows the prompt at the top; match that so the layout - // matches what users see with external fzf. - .reverse(true) - // Take over the whole terminal so the picker has room. - .height("100%".to_string()); - - if let Some(preview) = opts.preview { - // `setter(strip_option, into)` on the builder unwraps `Option` - // and converts via `Into`, so we just pass the bare value. - builder.preview(crate::fuzzy::substitute_exe_placeholder(preview)); - builder.preview_window(opts.preview_window); - } - if let Some(header) = opts.header { - builder.header(header.to_string()); - } - - let options = builder.build().context("build skim options")?; - - // Feed lines through the standard item reader so skim handles - // matching/scoring just like its CLI does on stdin. The reader is - // built *from* the configured options — `SkimItemReaderOption::default()` - // would silently ignore `delimiter` and `with_nth` (those settings - // live on the reader, not on `SkimOptions`), which is why an - // earlier version of this code displayed every column including - // the hidden lookup keys. - let input = lines.join("\n"); - let reader = SkimItemReader::new(SkimItemReaderOption::from_options(&options)); - let items = reader.of_bufread(Cursor::new(input)); - - // Skim returns eyre::Result rather than anyhow, so the chain isn't - // 1:1; render the message and re-wrap. - let output = Skim::run_with(options, Some(items)) - .map_err(|e| anyhow::anyhow!("skim picker failed: {e}"))?; - - if output.is_abort { - return Ok(PickResult::Cancelled); - } - if output.selected_items.is_empty() { - // Accepted without anything selected → treat as NoMatch so - // callers that distinguish "user cancelled" from "query had no - // matches" see the same shape they got from fzf. - return Ok(PickResult::NoMatch); - } - let picked: Vec = output - .selected_items - .iter() - .map(|item| item.output().to_string()) - .collect(); - Ok(PickResult::Selected(picked)) -} - -/// Parse fzf's `tiebreak` flag value into skim's enum. We only use -/// `index` (preserve input order), but tolerate the other fzf names so -/// future call-sites don't surprise us. -fn parse_tiebreak(s: &str) -> Result { - match s { - "index" => Ok(RankCriteria::Index), - "score" => Ok(RankCriteria::Score), - "begin" => Ok(RankCriteria::Begin), - "end" => Ok(RankCriteria::End), - "length" => Ok(RankCriteria::Length), - other => anyhow::bail!("unsupported tiebreak `{other}` for embedded picker"), - } -} - -/// Convert fzf's `--with-nth` spec (e.g. `"2.."` or `"1,3"`) into the -/// `Vec` skim expects. fzf and skim share the same grammar, so -/// the conversion is just a comma-split with no remapping. -fn parse_field_spec(s: &str) -> Vec { - s.split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_tiebreak_known_values_map_through() { - assert!(matches!( - parse_tiebreak("index").unwrap(), - RankCriteria::Index - )); - assert!(matches!( - parse_tiebreak("score").unwrap(), - RankCriteria::Score - )); - } - - #[test] - fn parse_tiebreak_rejects_unknown_value() { - assert!(parse_tiebreak("nope").is_err()); - } - - #[test] - fn parse_field_spec_splits_on_comma_and_trims() { - assert_eq!(parse_field_spec("2.."), vec!["2..".to_string()]); - assert_eq!( - parse_field_spec("1,3, 5"), - vec!["1".to_string(), "3".to_string(), "5".to_string()] - ); - } - - #[test] - fn parse_field_spec_drops_empty_entries() { - assert_eq!(parse_field_spec(",,2"), vec!["2".to_string()]); - assert_eq!(parse_field_spec(""), Vec::::new()); - } -} From bf3de22f56b9e7df55b8e008856e021c7fbc9dc1 Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 16:07:09 -0400 Subject: [PATCH 09/14] test(path-cli): opt-in pty smoke tests for the native picker --- Cargo.lock | 92 +++++++++++++- crates/path-cli/Cargo.toml | 4 + crates/path-cli/tests/picker_pty.rs | 184 ++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 crates/path-cli/tests/picker_pty.rs diff --git a/Cargo.lock b/Cargo.lock index 6d386ab0..3ea172f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -638,6 +644,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -1815,7 +1827,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "winapi", ] @@ -1870,6 +1882,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -1878,7 +1902,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "memoffset", ] @@ -2165,6 +2189,7 @@ dependencies = [ "jsonschema", "nucleo-matcher", "pathbase-client", + "portable-pty", "predicates", "rand 0.9.4", "ratatui", @@ -2344,6 +2369,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2512,7 +2558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -2553,7 +2599,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -3179,6 +3225,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3201,6 +3258,22 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -3454,7 +3527,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float", @@ -4563,6 +4636,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index cd1b205f..30b96d00 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -92,3 +92,7 @@ assert_cmd = "2" predicates = "3" insta = { workspace = true } toolpath-convo = { workspace = true } +# PTY harness for the opt-in (#[ignore]) native-picker smoke tests in +# tests/picker_pty.rs — the only way to exercise the real TTY event +# loop end to end. +portable-pty = "0.9" diff --git a/crates/path-cli/tests/picker_pty.rs b/crates/path-cli/tests/picker_pty.rs new file mode 100644 index 00000000..91cc128e --- /dev/null +++ b/crates/path-cli/tests/picker_pty.rs @@ -0,0 +1,184 @@ +//! Opt-in PTY smoke tests for the native picker. +//! +//! These drive the real `path` binary inside a pseudo-terminal so the +//! whole stack runs: TTY detection, raw mode, the ratatui event loop, +//! and the exit-code contract. They're `#[ignore]` because they spawn +//! real processes with real timing — run them explicitly: +//! +//! ```sh +//! cargo test -p path-cli --test picker_pty -- --ignored +//! ``` + +#![cfg(unix)] + +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; + +/// A `$HOME` with one Claude session the picker can list and import. +/// Mirrors the `claude_home_fixture` in integration.rs. +fn claude_home_fixture() -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + let project_slug = project + .to_string_lossy() + .replace([std::path::MAIN_SEPARATOR, '_', '.'], "-"); + let project_dir = temp.path().join(".claude/projects").join(&project_slug); + std::fs::create_dir_all(&project_dir).unwrap(); + let session_file = project_dir.join("session-pty.jsonl"); + std::fs::write( + &session_file, + format!( + r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"pty smoke prompt"}}}} +{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}} +"#, + cwd = project.display() + ), + ) + .unwrap(); + (temp, session_file) +} + +struct PtyRun { + exit_code: u32, + output: String, +} + +/// Spawn the `path` binary in a fresh PTY, wait `settle` for the +/// picker to come up, send `keys`, and collect exit code + everything +/// the PTY produced. +fn run_in_pty(args: &[&str], home: &std::path::Path, cfg: &std::path::Path, keys: &[u8]) -> PtyRun { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 40, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }) + .expect("open pty"); + + let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_path")); + cmd.args(args); + cmd.env("HOME", home); + cmd.env("TOOLPATH_CONFIG_DIR", cfg); + cmd.env("TERM", "xterm-256color"); + cmd.cwd(home); + let mut child = pair.slave.spawn_command(cmd).expect("spawn in pty"); + drop(pair.slave); + + // Drain the PTY continuously so the child never blocks on a full + // output buffer. + let mut reader = pair.master.try_clone_reader().expect("clone pty reader"); + let (tx, rx) = mpsc::channel::>(); + let reader_thread = std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + } + } + }); + + // Give the binary time to start, list sessions, and enter the + // picker loop; raw-mode input is buffered by the PTY anyway, so + // early keystrokes would still land — the settle just makes the + // test deterministic-ish about *what* consumes them. + std::thread::sleep(Duration::from_millis(1500)); + let mut writer = pair.master.take_writer().expect("pty writer"); + writer.write_all(keys).expect("send keys"); + writer.flush().expect("flush keys"); + + // Wait for exit with a hard deadline so a wedged picker fails the + // test instead of hanging the suite. + let deadline = Instant::now() + Duration::from_secs(30); + let status = loop { + if let Some(status) = child.try_wait().expect("try_wait") { + break status; + } + if Instant::now() > deadline { + let _ = child.kill(); + panic!("picker did not exit within 30s"); + } + std::thread::sleep(Duration::from_millis(50)); + }; + + drop(pair.master); + let _ = reader_thread.join(); + let mut bytes = Vec::new(); + while let Ok(chunk) = rx.try_recv() { + bytes.extend_from_slice(&chunk); + } + PtyRun { + exit_code: status.exit_code(), + output: String::from_utf8_lossy(&bytes).into_owned(), + } +} + +/// Enter on the import picker accepts the highlighted first row: the +/// session derives into the cache and the CLI exits 0. +#[test] +#[ignore = "spawns a real PTY; run with --ignored"] +fn pty_smoke_import_picker_accept_first_row() { + let (home, _session) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let run = run_in_pty(&["p", "import", "claude"], home.path(), cfg.path(), b"\r"); + assert_eq!( + run.exit_code, 0, + "import should exit 0; pty output:\n{}", + run.output + ); + // The derive shapes the cache id (`claude-path-claude-code-`), so assert on the stable parts: a claude-* doc landed + // in the cache and the import summary named its cache id. + let docs_dir = cfg.path().join("documents"); + let claude_docs: Vec<_> = std::fs::read_dir(&docs_dir) + .unwrap_or_else(|e| { + panic!( + "read {}: {e}; pty output:\n{}", + docs_dir.display(), + run.output + ) + }) + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("claude-") && n.ends_with(".json")) + .collect(); + assert!( + !claude_docs.is_empty(), + "expected a claude-* cache doc in {}; pty output:\n{}", + docs_dir.display(), + run.output + ); + assert!( + run.output.contains("Imported") && run.output.contains("claude-"), + "import summary with cache id missing from output:\n{}", + run.output + ); +} + +/// Esc is a deliberate cancel: `path share` propagates it as exit 130 +/// (the same contract the external fzf backend has). +#[test] +#[ignore = "spawns a real PTY; run with --ignored"] +fn pty_smoke_esc_exits_130() { + let (home, _session) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + // --anon skips the auth preflight, so no network is touched before + // the picker comes up; Esc exits before any upload could happen. + let run = run_in_pty(&["share", "--anon"], home.path(), cfg.path(), b"\x1b"); + assert_eq!( + run.exit_code, 130, + "esc should exit 130; pty output:\n{}", + run.output + ); +} From 7aa02941fa5f7ff85e498b88a60881541deff180 Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 16:08:50 -0400 Subject: [PATCH 10/14] docs: native picker docs; bump path-cli to 0.17.0 --- CHANGELOG.md | 31 ++++++++++++++ CLAUDE.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 27 ++++++++----- crates/path-cli/Cargo.toml | 2 +- .../plans/2026-08-03-native-picker.md | 40 ++++++++++++++++++- site/_data/crates.json | 2 +- 8 files changed, 91 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f31478..16ae22c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to the Toolpath workspace are documented here. +## The built-in picker is now native — Atuin-style session selection — 2026-08-03 + +The embedded picker behind `path share` / `path resume` / +`path p import ` is no longer skim: it's a first-party +ratatui implementation at `crates/path-cli/src/tui/`, built for exactly +the picking Toolpath does. All picker call sites keep working +unchanged; the external `fzf` backend is untouched as the escape hatch. + +- **`path-cli`** (0.17.0): native picker backend on ratatui 0.30 + + nucleo-matcher, rendering on stderr so piped stdout stays clean. + Adaptive layouts: preview-less pickers open a small Atuin-style + inline viewport under the prompt; preview-bearing pickers take the + alternate screen, side-by-side when the terminal is wide enough and + stacked when it isn't. +- Previews run async off the event loop (no tokio): a 100 ms debounce + coalesces held-down arrows, results are cached per row, a stale + preview never overwrites a newer one, and superseded preview + commands are killed. Failures render in-pane instead of crashing + the picker. +- Queries get fzf-style operators via nucleo: space-separated words + AND together, plus `'exact`, `^prefix`, and `!negate`. Matching runs + over the visible columns only, exactly like `--with-nth`. +- `--picker native` replaces `--picker skim` (`skim` survives as a + hidden alias with a one-time stderr note); `auto` still prefers the + built-in backend and falls back to external `fzf` when the + `embedded-picker` feature is compiled out. +- The skim dependency is gone (the `embedded-picker` feature now pulls + `ratatui`/`crossterm`/`nucleo-matcher`/`ansi-to-tui`), and opt-in + PTY smoke tests (`cargo test -p path-cli --test picker_pty -- + --ignored`) drive the real binary end to end. + ## Projected Claude sessions are resumable again — 2026-07-30 Two fixes found by live-resuming a projected session against the real diff --git a/CLAUDE.md b/CLAUDE.md index 2bc061c7..47282d6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,7 +280,7 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - opencode provider: `toolpath-opencode` reads a SQLite database at `~/.local/share/opencode/opencode.db` (opened read-only). Each session's messages and 12 typed part variants (text, reasoning, tool, step-start/-finish, snapshot, patch, file, agent, subtask, retry, compaction) land as one step per message with tool invocations attached. File diffs come from a sibling bare git repo at `snapshot//[]/` via `git2` tree↔tree diffs — opencode respects the user's `.gitignore`, so changes under gitignored paths fall back to tool-input-derived structural changes with no `raw` perspective. Project id is the SHA of the repo's first root commit. See `docs/agents/formats/opencode.md` for the full format reference. - Cursor (IDE) provider: `toolpath-cursor` reads Cursor.app's global `state.vscdb` SQLite (opened read-only) at `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` (macOS; `~/.config/Cursor/...` on Linux). Composers, bubbles, and content-addressed file blobs are stored as key-prefixed rows in the `cursorDiskKV` table (`composerData:`, `bubbleId::`, `composer.content.`) plus a `composer.composerHeaders` index blob in `ItemTable`. The full tool-dispatch enum (53 entries, ids 0–63) is extracted from the workbench bundle into `TOOL_TABLE` for round-trip-correct numeric ids — projector-written composers load back into Cursor.app's UI with the right tool rendering. The cursor-agent CLI uses a different per-chat protobuf store at `~/.cursor/chats///store.db` that this crate does not yet parse — that's deferred to a future `toolpath-cursor-cli` companion. See `docs/agents/formats/cursor.md` for the full format reference. - Format references for the agent on-disk formats we derive from live at `docs/agents/formats/`. The Claude Code format (`~/.claude/projects/…` JSONL) gets the deepest treatment — twelve focused docs at `docs/agents/formats/claude-code/` covering envelope, entry types, tools, session chains, compaction, writing-compatible JSONL, a linear walkthrough, and a version-keyed changelog. Sibling single-file references: `codex.md`, `gemini.md`, `opencode.md`. Keep them in sync with their derive crates when fields or behaviors change. -- Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. +- Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: the first-party **native picker** at `crates/path-cli/src/tui/` (default-feature `embedded-picker`; ratatui + nucleo-matcher, renders on **stderr** so piped stdout stays clean), with external `fzf` as the escape hatch — the global flag is `--picker auto|fzf|native` (`skim` is a hidden legacy alias for `native`; `auto` prefers native and falls back to `fzf` only when the feature is compiled out). Layouts are adaptive: no preview → small inline viewport under the prompt; preview configured → fullscreen alt-screen (side-by-side at ≥100 cols, stacked below); previews run debounced (100 ms) on worker threads with per-row caching and kill-on-supersede. Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. Opt-in PTY smoke tests: `cargo test -p path-cli --test picker_pty -- --ignored`. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. diff --git a/Cargo.lock b/Cargo.lock index 3ea172f4..ebc09aae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2172,7 +2172,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.16.1" +version = "0.17.0" dependencies = [ "ansi-to-tui", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index ec3e2606..a7eef89f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.16.1", path = "crates/path-cli" } +path-cli = { version = "0.17.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/README.md b/README.md index d3a82eba..909c9c23 100644 --- a/README.md +++ b/README.md @@ -229,19 +229,24 @@ topic. TAB selects multiple — the result is a `Graph`. `path share` and Two backends, selected at runtime: -- **External `fzf`** is preferred when it's on `$PATH` (so your fzf - config and keybindings keep working). -- **Embedded `skim`** (Rust fzf-clone) is shipped in the default build - and used when `fzf` isn't installed. Same `{1}`/`{2}` preview - placeholders, same column-selection grammar — visually similar UX. - Build with `--no-default-features` to drop it for a ~2 MB smaller - binary; without either backend the CLI prints a manual recipe. - -Use the global `--picker auto|fzf|skim` flag to force a backend -(default `auto`): +- **Native picker** (built-in, the default). A first-party ratatui + picker: preview-less pickers open a small Atuin-style inline + viewport under your prompt; preview-bearing pickers go fullscreen + with an async, debounced preview pane. Queries support fzf-style + operators (space = AND, `'exact`, `^prefix`, `!negate`). It renders + on stderr, so piped stdout stays clean. Build with + `--no-default-features` to drop it for a smaller binary. +- **External `fzf`** as the escape hatch (so your fzf config and + keybindings keep working): force it with `--picker fzf`, and it's + the automatic fallback when the native picker is compiled out. + +Without either backend the CLI prints a manual recipe. + +Use the global `--picker auto|fzf|native` flag to force a backend +(default `auto`; `skim` is accepted as a legacy alias for `native`): ```bash -path --picker skim share # use embedded skim even with fzf on PATH +path --picker native share # use the built-in picker even with fzf on PATH path --picker fzf p import claude # error out if fzf isn't installed ``` diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 30b96d00..d92267e6 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.16.1" +version = "0.17.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/docs/superpowers/plans/2026-08-03-native-picker.md b/docs/superpowers/plans/2026-08-03-native-picker.md index 3983e1b8..87ad64a0 100644 --- a/docs/superpowers/plans/2026-08-03-native-picker.md +++ b/docs/superpowers/plans/2026-08-03-native-picker.md @@ -76,4 +76,42 @@ ## Self-Review Notes -(Recorded during implementation; deviations from the spec land here.) +Deviations from the spec, found against the real code: + +- **`pty_smoke_esc_exits_130` drives `path share --anon`, not `p import + claude`.** `cmd_import` deliberately treats a picker cancel as an + empty selection and exits 0 — that is its pre-existing contract with + the external fzf backend, and the call sites must work unchanged. + `share` is the surface whose cancel contract is `exit(130)`, so the + PTY test pins it there. (`--anon` keeps the auth preflight off the + network; Esc fires before any upload.) +- **`spawn_preview_job` reads pipes instead of literal + `wait_with_output`.** The `Child` must sit in the kill slot *while* + its output is being read so a superseding spawn can kill it; + `wait_with_output` consumes the child and would defeat the slot. The + worker reads stdout on itself and stderr on a helper thread (pipe- + deadlock safe), then reaps through the slot — taking the child back + only if it is still its own (a superseder kills + reaps otherwise, + and the stale worker sends nothing). +- **Terminal recreation on mode change is narrower than the spec's + wording.** Crossing inline<->fullscreen (or an inline height change) + drops + recreates the terminal; a side<->stacked flip stays on the + same alternate screen and just recomputes pane areas on the next + draw — recreating there would flash the alt screen for no benefit. +- **The one-time `--picker skim` note scans raw process args.** clap + resolves value aliases before the parsed value is visible, so the + literal spelling only survives in `std::env::args()`. +- **The PTY accept test asserts on stable output shapes** (a + `claude-*` cache doc exists + the `Imported …` summary) rather than + a specific cache id: the derive shortens the session id (first 8 + chars) when shaping `claude-path-claude-code-`. +- `PickResult` gained `Debug/Clone/PartialEq/Eq` derives so state + tests can assert on it directly. +- The ansi-to-tui sample test snapshots via `assert_debug_snapshot` + (ratatui `Text` has no `Display`). +- Failed previews are cached like successes so a broken preview + command isn't re-run on every selection bounce (UX choice within the + spec's "errors never crash the picker"). +- Version-slot note: in-flight PRs #138/#145 also claim `path-cli` + 0.17.0 — proceeded with 0.17.0 as instructed; whichever lands last + rebases its CHANGELOG H2 + version slots. diff --git a/site/_data/crates.json b/site/_data/crates.json index 4d282a97..73587052 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.16.1", + "version": "0.17.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", From 57609268ae50cfc5f2bc2ba5ce0bb3e39b53fde6 Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 16:10:13 -0400 Subject: [PATCH 11/14] fix(path-cli): per-guard restore latch so terminal recreate survives old guard drop --- crates/path-cli/src/tui/mod.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/path-cli/src/tui/mod.rs b/crates/path-cli/src/tui/mod.rs index 7300f1de..8526783e 100644 --- a/crates/path-cli/src/tui/mod.rs +++ b/crates/path-cli/src/tui/mod.rs @@ -99,6 +99,13 @@ impl Drop for PanicHookGuard { struct TermGuard { terminal: Terminal>, fullscreen: bool, + /// Per-guard restore latch. The global [`NEEDS_RESTORE`] flag + /// arbitrates with the panic hook; this one keeps an + /// already-restored guard's `Drop` from tearing down a *newer* + /// terminal after a layout-driven recreate (`guard = TermGuard:: + /// new(..)` drops the old guard after the new one armed the + /// global flag). + restored: bool, } impl TermGuard { @@ -125,10 +132,15 @@ impl TermGuard { Ok(Self { terminal, fullscreen, + restored: false, }) } fn restore(&mut self) { + if self.restored { + return; + } + self.restored = true; if !NEEDS_RESTORE.swap(false, Ordering::SeqCst) { return; } From 3ca210e20ddb613899cd13d74f40ab22f3a87d69 Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 16:11:02 -0400 Subject: [PATCH 12/14] docs(path-cli): scrub stale skim mentions from picker doc comments --- crates/path-cli/src/cmd_share.rs | 2 +- crates/path-cli/src/fuzzy.rs | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index d85b780f..43e7a82c 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -874,7 +874,7 @@ fn share_explicit( /// /// The display column is space-padded rather than tab-separated so the /// columns line up consistently across pickers — terminal tab stops -/// produce ugly variable gaps in both fzf and skim. +/// produce ugly variable gaps in both backends. fn format_picker_row(row: &ArtifactRow) -> String { let key = row .path diff --git a/crates/path-cli/src/fuzzy.rs b/crates/path-cli/src/fuzzy.rs index 7466cc2a..66996785 100644 --- a/crates/path-cli/src/fuzzy.rs +++ b/crates/path-cli/src/fuzzy.rs @@ -396,10 +396,9 @@ pub enum PickResult { /// Run the fuzzy picker with the supplied lines and options. Honors /// the global `--picker` override; defaults to `Auto`, which prefers -/// the external `fzf` binary when it's on `PATH` (so users keep their -/// own fzf config / keybindings) and falls back to the embedded skim -/// picker otherwise. Errors out only when the requested backend isn't -/// available — callers should check [`available`] first. +/// the native picker and falls back to external `fzf` only when the +/// native picker isn't compiled in. Errors out only when the requested +/// backend isn't available — callers should check [`available`] first. pub fn pick(lines: &[String], opts: &PickOptions<'_>) -> Result { match current_picker() { Picker::Fzf => { @@ -582,7 +581,7 @@ pub fn pick_external(lines: &[String], opts: &PickOptions<'_>) -> Result { /// Visible columns in fzf's notation, e.g. `2..` to hide col 1. pub with_nth: &'a str, From c7c77f50e8859a642e4c23e59393a0c2858a47be Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 16:14:19 -0400 Subject: [PATCH 13/14] chore: clippy cleanups; bump toolpath-cli shim to 0.17.0 in lockstep --- crates/path-cli/src/tui/mod.rs | 8 +++++--- crates/path-cli/src/tui/state.rs | 24 ++++++++++++------------ crates/toolpath-cli/Cargo.toml | 4 ++-- site/_data/crates.json | 2 +- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/crates/path-cli/src/tui/mod.rs b/crates/path-cli/src/tui/mod.rs index 8526783e..a9ab3071 100644 --- a/crates/path-cli/src/tui/mod.rs +++ b/crates/path-cli/src/tui/mod.rs @@ -65,16 +65,18 @@ fn emergency_restore() { } } +/// A shareable panic hook, as [`std::panic::take_hook`] returns it. +type PanicHook = Arc) + Send + Sync>; + /// Scoped panic hook: `take_hook` -> install a restore-first wrapper, /// then put the previous hook back on clean drop. struct PanicHookGuard { - prev: Option) + Send + Sync>>, + prev: Option, } impl PanicHookGuard { fn install() -> Self { - let prev: Arc) + Send + Sync> = - Arc::from(std::panic::take_hook()); + let prev: PanicHook = Arc::from(std::panic::take_hook()); let in_hook = prev.clone(); std::panic::set_hook(Box::new(move |info| { emergency_restore(); diff --git a/crates/path-cli/src/tui/state.rs b/crates/path-cli/src/tui/state.rs index 3c60e6f0..07d8ebdc 100644 --- a/crates/path-cli/src/tui/state.rs +++ b/crates/path-cli/src/tui/state.rs @@ -213,23 +213,23 @@ pub(super) fn handle_event(state: &mut AppState, ev: InputEvent) -> Option state.move_selection(-(state.page_rows as isize)), InputEvent::PageDown => state.move_selection(state.page_rows as isize), InputEvent::Tab => { - if state.multi { - if let Some(row) = state.current_row() { - if !state.marked.remove(&row) { - state.marked.insert(row); - } - state.move_selection(1); + if state.multi + && let Some(row) = state.current_row() + { + if !state.marked.remove(&row) { + state.marked.insert(row); } + state.move_selection(1); } } InputEvent::BackTab => { - if state.multi { - if let Some(row) = state.current_row() { - if !state.marked.remove(&row) { - state.marked.insert(row); - } - state.move_selection(-1); + if state.multi + && let Some(row) = state.current_row() + { + if !state.marked.remove(&row) { + state.marked.insert(row); } + state.move_selection(-1); } } InputEvent::Enter => { diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index 9b38ca37..5fd5d044 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.16.1" +version = "0.17.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.16.1" } +path-cli = { path = "../path-cli", version = "0.17.0" } anyhow = "1.0" [workspace] diff --git a/site/_data/crates.json b/site/_data/crates.json index 73587052..fefe5d46 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -121,7 +121,7 @@ }, { "name": "toolpath-cli", - "version": "0.16.1", + "version": "0.17.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli", From 559f735f4dcac028ace26f10b69eff8b9cb6cb6c Mon Sep 17 00:00:00 2001 From: Bryan Russett Date: Mon, 3 Aug 2026 22:22:18 -0400 Subject: [PATCH 14/14] =?UTF-8?q?fix(tui):=20review=20fixes=20=E2=80=94=20?= =?UTF-8?q?grapheme-correct=20highlights,=20terminal=20and=20preview=20hyg?= =?UTF-8?q?iene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial-review findings: nucleo reports grapheme positions, so highlight spans now segment identically (unicode-segmentation); TermGuard::new undoes raw mode on partial failure; preview kill slot parks replace-and-kill keyed by spawn generation (no zombie or ABA reap); honest equal-score tiebreak test; style-level assertions on highlight spans; inline restore homes the cursor to the viewport origin; preview jobs pause while the pane is hidden. --- Cargo.lock | 1 + crates/path-cli/Cargo.toml | 6 + crates/path-cli/src/tui/matcher.rs | 29 ++++- crates/path-cli/src/tui/mod.rs | 59 ++++++++-- crates/path-cli/src/tui/preview.rs | 47 +++++--- crates/path-cli/src/tui/render.rs | 106 +++++++++++++++++- .../plans/2026-08-03-native-picker.md | 33 ++++++ .../specs/2026-08-03-native-picker-design.md | 2 +- 8 files changed, 249 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebc09aae..f09e3e82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2214,6 +2214,7 @@ dependencies = [ "toolpath-md", "toolpath-opencode", "toolpath-pi", + "unicode-segmentation", "uuid", ] diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index d92267e6..5c9ba063 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -65,6 +65,11 @@ ratatui = { version = "0.30", optional = true } crossterm = { version = "0.29", optional = true } nucleo-matcher = { version = "0.3", optional = true } ansi-to-tui = { version = "8", optional = true } +# Grapheme iteration for match-highlight rendering: nucleo builds its +# haystacks by grapheme segmentation, so highlight indices are grapheme +# positions and the renderer must segment identically. Already in the +# tree transitively (nucleo-matcher, ratatui). +unicode-segmentation = { version = "1.13.2", optional = true } [target.'cfg(target_os = "emscripten")'.dependencies] toolpath-claude = { workspace = true } @@ -85,6 +90,7 @@ embedded-picker = [ "dep:crossterm", "dep:nucleo-matcher", "dep:ansi-to-tui", + "dep:unicode-segmentation", ] [dev-dependencies] diff --git a/crates/path-cli/src/tui/matcher.rs b/crates/path-cli/src/tui/matcher.rs index 70fa5789..b3821629 100644 --- a/crates/path-cli/src/tui/matcher.rs +++ b/crates/path-cli/src/tui/matcher.rs @@ -45,8 +45,12 @@ impl Row { } } -/// One row's match result. `indices` are *char* positions into -/// [`Row::display`] (sorted, deduped) for highlight rendering. +/// One row's match result. `indices` are *grapheme* positions into +/// [`Row::display`] (sorted, deduped) as reported by nucleo — its +/// haystacks are built by grapheme segmentation, one index per +/// grapheme — so highlight rendering must segment identically +/// (codepoint positions drift after multi-codepoint clusters like +/// emoji ZWJ sequences). #[derive(Debug, Clone)] pub(super) struct MatchEntry { pub row: usize, @@ -322,9 +326,26 @@ mod tests { assert!(out.iter().all(|e| e.row != 0)); assert_eq!(out.len(), 2); assert!(out.iter().all(|e| !e.indices.is_empty())); - // Equal-quality matches tie-break by row (input) order. + // Sorted best-first. + assert!(out.windows(2).all(|w| w[0].score >= w[1].score)); + } + + #[test] + fn rematch_equal_scores_tiebreak_by_input_order() { + // Two rows with IDENTICAL display text: their scores are + // guaranteed equal, so the ordering below can only come from + // the row-ascending tiebreak (`tiebreak=index` contract). + let spec = parse_field_spec("1..").unwrap(); + let rows: Vec = ["share codex session", "share codex session"] + .iter() + .map(|l| Row::new(l, &spec)) + .collect(); + let mut m = NucleoMatcher::new(&rows); + let out = m.rematch("share"); + assert_eq!(out.len(), 2); + assert_eq!(out[0].score, out[1].score); let rows_in_order: Vec = out.iter().map(|e| e.row).collect(); - assert!(rows_in_order == vec![1, 2] || rows_in_order == vec![2, 1]); + assert_eq!(rows_in_order, vec![0, 1]); } #[test] diff --git a/crates/path-cli/src/tui/mod.rs b/crates/path-cli/src/tui/mod.rs index a9ab3071..e2315406 100644 --- a/crates/path-cli/src/tui/mod.rs +++ b/crates/path-cli/src/tui/mod.rs @@ -114,28 +114,45 @@ impl TermGuard { fn new(mode: LayoutMode) -> Result { let fullscreen = mode.is_fullscreen(); enable_raw_mode().context("enable raw mode")?; + // Arm the restore flags BEFORE building the terminal: any + // failure past this point (terminal creation, entering the + // alternate screen) must undo raw mode — and leave the alt + // screen best-effort — before the error propagates, or the + // shell is left stuck in raw mode. This covers both the + // initial entry and the mid-loop recreate path. NEEDS_RESTORE.store(true, Ordering::SeqCst); RESTORE_FULLSCREEN.store(fullscreen, Ordering::SeqCst); + match Self::build(mode) { + Ok(terminal) => Ok(Self { + terminal, + fullscreen, + restored: false, + }), + Err(err) => { + emergency_restore(); + Err(err) + } + } + } + + /// Build the mode's terminal. Failures after [`enable_raw_mode`] + /// succeeded are cleaned up by [`TermGuard::new`]. + fn build(mode: LayoutMode) -> Result>> { let backend = CrosstermBackend::new(std::io::stderr()); - let terminal = match mode { + match mode { LayoutMode::Inline { height } => Terminal::with_options( backend, TerminalOptions { viewport: Viewport::Inline(height), }, ) - .context("create inline terminal")?, + .context("create inline terminal"), _ => { crossterm::execute!(std::io::stderr(), EnterAlternateScreen) .context("enter alternate screen")?; - Terminal::new(backend).context("create fullscreen terminal")? + Terminal::new(backend).context("create fullscreen terminal") } - }; - Ok(Self { - terminal, - fullscreen, - restored: false, - }) + } } fn restore(&mut self) { @@ -149,7 +166,15 @@ impl TermGuard { if !self.fullscreen { // Clear the inline viewport region so the shell prompt // continues where the picker sat, without stale rows. + let origin_y = self.terminal.get_frame().area().y; let _ = self.terminal.clear(); + // `Terminal::clear` preserves the pre-clear cursor position + // — wherever the last draw parked it (the query input line, + // mid-viewport). Park the cursor at the viewport's origin, + // column 0, so post-picker shell output resumes at the + // top-left of where the picker sat — no viewport-height + // gap, no indent. + let _ = crossterm::execute!(std::io::stderr(), crossterm::cursor::MoveTo(0, origin_y)); } let _ = disable_raw_mode(); if self.fullscreen { @@ -192,8 +217,12 @@ pub(crate) fn pick(lines: &[String], opts: &PickOptions<'_>) -> Result = None; + // Pane visibility last tick — a hidden->shown flip (Ctrl-O) + // re-arms the scheduler for the current row. + let mut preview_was_visible = state.preview_visible; if template.is_some() + && state.preview_visible && let Some(row) = state.current_row() { scheduler.on_selection_change(row, Instant::now()); @@ -251,10 +280,17 @@ pub(crate) fn pick(lines: &[String], opts: &PickOptions<'_>) -> Result