feat: tui#182
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a new Ratatui-based terminal UI (TUI) for lsf, along with a background runtime for indexing/searching and configurable keybindings, and wires it into the CLI via --tui.
Changes:
- Added a TUI implementation (rendering, input handling, state, sorting, actions) with debounced live search and popups.
- Introduced an
AppRuntimeworker thread to manage cache/index lifecycle, fsevent handling, and search/history APIs. - Added CLI flags for TUI launch, quit confirmation, cache path resolution, and optional TOML keymap loading; updated dependencies accordingly.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
lsf/src/tui/state.rs |
TUI state model (focus, history, sort, debounced search scheduling). |
lsf/src/tui/sort.rs |
Result sorting implementation and sort key extraction. |
lsf/src/tui/render.rs |
Ratatui UI rendering for query/results/status + popups/help. |
lsf/src/tui/input.rs |
Crossterm event loop and keybinding-driven interactions. |
lsf/src/tui/keymap.rs |
TOML-configurable keybinding system + defaults and parsing. |
lsf/src/tui/actions.rs |
OS actions (open/reveal/copy/quicklook/editor) invoked from UI. |
lsf/src/tui/app.rs |
Background runtime worker (cache load, event watcher, search, history). |
lsf/src/tui/mod.rs |
TUI entrypoint wiring terminal init/restore and options. |
lsf/src/main.rs |
Refactor main to use AppRuntime, add --tui path + keymap loading. |
lsf/src/lib.rs |
Exposes tui as a library module for the binary to consume. |
lsf/src/cli.rs |
Adds CLI flags/options for TUI, cache path, quit confirm, keymap. |
lsf/Cargo.toml |
Adds ratatui/crossterm/jiff/toml deps; removes rustyline usage. |
Cargo.lock |
Lockfile updates for new TUI/config dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| impl FromStr for KeySpec { | ||
| type Err = String; | ||
|
|
||
| fn from_str(s: &str) -> Result<Self, Self::Err> { | ||
| let s = s.trim(); | ||
| let parts: Vec<&str> = s.split('+').collect(); | ||
| if parts.is_empty() { | ||
| return Err("empty key spec".to_string()); | ||
| } | ||
|
|
||
| let key_str = parts.last().unwrap(); | ||
| let modifier_parts = &parts[..parts.len() - 1]; | ||
|
|
||
| let mut mods = KeyModifiers::NONE; | ||
| for m in modifier_parts { | ||
| match m.to_lowercase().as_str() { | ||
| "ctrl" | "control" => mods |= KeyModifiers::CONTROL, | ||
| "shift" => mods |= KeyModifiers::SHIFT, | ||
| "alt" | "meta" => mods |= KeyModifiers::ALT, | ||
| "" => {} | ||
| other => return Err(format!("unknown modifier: {other}")), | ||
| } | ||
| } | ||
|
|
||
| let code = match key_str.to_lowercase().as_str() { | ||
| "enter" | "return" => KeyCode::Enter, | ||
| "esc" | "escape" => KeyCode::Esc, | ||
| "backspace" | "bs" => KeyCode::Backspace, | ||
| "delete" | "del" => KeyCode::Delete, | ||
| "left" => KeyCode::Left, | ||
| "right" => KeyCode::Right, | ||
| "up" => KeyCode::Up, | ||
| "down" => KeyCode::Down, | ||
| "home" => KeyCode::Home, | ||
| "end" => KeyCode::End, | ||
| "pageup" | "pgup" | "page_up" => KeyCode::PageUp, | ||
| "pagedown" | "pgdn" | "page_down" => KeyCode::PageDown, | ||
| "tab" => KeyCode::Tab, | ||
| "space" => KeyCode::Char(' '), | ||
| f if f.starts_with('f') && f.len() > 1 => { | ||
| let n: u8 = f[1..] | ||
| .parse() | ||
| .map_err(|_| format!("unknown key: {key_str}"))?; | ||
| KeyCode::F(n) | ||
| } | ||
| c if c.chars().count() == 1 => KeyCode::Char(c.chars().next().unwrap()), | ||
| other => return Err(format!("unknown key: {other}")), | ||
| }; | ||
|
|
||
| Ok(KeySpec { code, mods }) | ||
| } | ||
| } | ||
|
|
||
| impl<'de> Deserialize<'de> for KeySpec { | ||
| fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { | ||
| let s = String::deserialize(deserializer)?; | ||
| s.parse::<KeySpec>().map_err(de::Error::custom) | ||
| } |
There was a problem hiding this comment.
Key parsing / TOML loading is fairly intricate here (modifier parsing, f-keys, string-or-array fields, default merging). Since this module is new and the crate already has unit tests (e.g. in tui/app.rs), it would be good to add focused tests for KeySpec::from_str, deser_key_list, and Keymap::load (including a minimal TOML and a malformed one) to prevent regressions.
| fn default_query_submit() -> Vec<KeySpec> { | ||
| ks(&["enter", "down"]) | ||
| } | ||
| fn default_history_older() -> Vec<KeySpec> { | ||
| ks(&["up"]) | ||
| } | ||
| fn default_history_newer() -> Vec<KeySpec> { | ||
| ks(&["down"]) | ||
| } |
There was a problem hiding this comment.
Default bindings set query.submit to ["enter", "down"] while query.history_newer is also bound to "down". In the input handler, submit is checked before history_newer, so Down will always submit and makes browsing history newer impossible (also contradicts the help text that says ↓ browses history). Remove the conflicting default (likely keep only enter for submit) or change the handler to disambiguate.
| #[serde(deserialize_with = "deser_key_list", default = "default_sort_created")] | ||
| pub sort_created: Vec<KeySpec>, | ||
| #[serde(deserialize_with = "deser_key_list", default = "default_focus_out")] | ||
| pub focus_out: Vec<KeySpec>, // TODO: add default? |
There was a problem hiding this comment.
The focus_out key has a default (default_focus_out / esc), but the inline comment says // TODO: add default?. This is now misleading—either remove the TODO or update it to reflect the current behavior.
| pub focus_out: Vec<KeySpec>, // TODO: add default? | |
| pub focus_out: Vec<KeySpec>, |
| let default_path = std::env::var_os("HOME") | ||
| .map(|h| PathBuf::from(h).join(".cardinal").join("lsf-keys.toml")) | ||
| .unwrap_or_default(); |
There was a problem hiding this comment.
load_keymap uses HOME and falls back to PathBuf::default() when it isn't set. An empty PathBuf typically resolves to the current directory ("."), so Keymap::load may try to read a directory and fail unexpectedly. Handle missing HOME by directly returning Keymap::default() (or use a platform-appropriate config dir) instead of passing an empty path to Keymap::load.
| let default_path = std::env::var_os("HOME") | |
| .map(|h| PathBuf::from(h).join(".cardinal").join("lsf-keys.toml")) | |
| .unwrap_or_default(); | |
| let Some(home) = std::env::var_os("HOME") else { | |
| return Ok(Keymap::default()); | |
| }; | |
| let default_path = PathBuf::from(home).join(".cardinal").join("lsf-keys.toml"); |
| pub(super) fn display_width(text: &str) -> usize { | ||
| text.chars().count() | ||
| } |
There was a problem hiding this comment.
display_width currently returns text.chars().count(), which is not the same as terminal cell width for wide Unicode (CJK, emojis) or combining characters. Since this is used to place the cursor in the query box, the cursor can become misaligned or even attempt to render outside the input area. Consider using unicode_width::UnicodeWidthStr (or ratatui's unicode width utilities) to compute display width.
| let ordering = match sort.key { | ||
| SortKey::Filename => compare_strings(filename_for_sort(left), filename_for_sort(right)), | ||
| SortKey::FullPath => compare_strings( | ||
| &left.path.display().to_string(), | ||
| &right.path.display().to_string(), | ||
| ), |
There was a problem hiding this comment.
Sorting by full path allocates multiple Strings per comparison (path.display().to_string() plus to_lowercase() inside compare_strings). For large result sets this can become a noticeable CPU/alloc cost. Prefer comparing using a cached key (sort_by_cached_key) or avoid repeated allocations (e.g., compare to_string_lossy() once per node, or store a precomputed lowercase key).
| fn size_for_sort(result: &SearchResultNode) -> Option<u32> { | ||
| result | ||
| .metadata | ||
| .as_ref() | ||
| .as_ref() | ||
| .and_then(|metadata| u32::try_from(metadata.size()).ok()) | ||
| } |
There was a problem hiding this comment.
size_for_sort converts metadata.size() (an i64) into u32. Files larger than 4 GiB will fail the conversion and sort as None, producing incorrect ordering for common large files. Use a wider integer type (e.g., u64/i64) for size sorting so large sizes are handled correctly.
| time::Duration, | ||
| }; | ||
|
|
||
| const HISTORY_PATH: &str = "target/search-history.txt"; |
There was a problem hiding this comment.
Search history is persisted under target/search-history.txt. target/ is often cleaned and may be unwritable in packaged/installed scenarios, so history can be lost or error at runtime. Consider storing history under a user config/data dir (e.g., $HOME/.cardinal/ or an XDG data directory) and/or making the path configurable via CLI.
| const HISTORY_PATH: &str = "target/search-history.txt"; | |
| const HISTORY_PATH: &str = ".cardinal/search-history.txt"; |
| let path = result.path.clone(); | ||
| ratatui::restore(); | ||
| let editor_result = launch_editor(&path); | ||
| *terminal = ratatui::init(); | ||
|
|
There was a problem hiding this comment.
open_selected_in_editor restores the terminal UI before spawning the editor, but mouse capture was enabled globally in tui::run_with_options and is not disabled here. This can interfere with terminal editors (mouse events still being captured). Disable mouse capture before launching the editor and re-enable it after re-initializing ratatui.
|
Overall this is great work, I think the old lsf could be deleted actually. :-) Here are a few observation and thoughts:
|
|
i had updated the helper panel and status bar, check it out. apart from that, current keymap binding is working, but is kinda messy and not general. I noticed that in hsqStephenZhang#1, changing one keybinding needs modification to many places. perhaps I could make a more general key event dispatch mechanism. |
Background
the tui could be useful for terminal users. and it's easier for permission configuration.
What does current TUI have?
How to run
cargo run -p lsf --release -- --refresh --path /usr/local --tui # --tui is requiredDemo
give it a shot!
TODOs
so the current TUI may serve as a draft, I'd like to hear some feedback and advice on the user experience before making further changes.