Skip to content

feat: tui#182

Draft
hsqStephenZhang wants to merge 2 commits intocardisoft:masterfrom
hsqStephenZhang:feat/ratatui
Draft

feat: tui#182
hsqStephenZhang wants to merge 2 commits intocardisoft:masterfrom
hsqStephenZhang:feat/ratatui

Conversation

@hsqStephenZhang
Copy link
Copy Markdown
Collaborator

@hsqStephenZhang hsqStephenZhang commented Apr 24, 2026

Background

the tui could be useful for terminal users. and it's easier for permission configuration.

What does current TUI have?

  1. search interface
  2. keyboard configuration
  3. background fsevent handling

How to run

cargo run -p lsf --release -- --refresh --path /usr/local --tui # --tui is required

Demo

image

give it a shot!

TODOs

  • fsevent search.
  • rescan.
  • specification for keybinding files and index file's path.

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.

@hsqStephenZhang hsqStephenZhang marked this pull request as draft April 24, 2026 12:24
@ldm0 ldm0 requested review from Copilot and ldm0 April 24, 2026 19:10
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AppRuntime worker 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.

Comment thread lsf/src/tui/keymap.rs
Comment on lines +82 to +139
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)
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread lsf/src/tui/keymap.rs
Comment on lines +206 to +214
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"])
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread lsf/src/tui/keymap.rs
#[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?
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
pub focus_out: Vec<KeySpec>, // TODO: add default?
pub focus_out: Vec<KeySpec>,

Copilot uses AI. Check for mistakes.
Comment thread lsf/src/main.rs
Comment on lines +82 to +84
let default_path = std::env::var_os("HOME")
.map(|h| PathBuf::from(h).join(".cardinal").join("lsf-keys.toml"))
.unwrap_or_default();
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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");

Copilot uses AI. Check for mistakes.
Comment thread lsf/src/tui/state.rs
Comment on lines +412 to +414
pub(super) fn display_width(text: &str) -> usize {
text.chars().count()
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread lsf/src/tui/sort.rs
Comment on lines +10 to +15
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(),
),
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread lsf/src/tui/sort.rs
Comment on lines +53 to +59
fn size_for_sort(result: &SearchResultNode) -> Option<u32> {
result
.metadata
.as_ref()
.as_ref()
.and_then(|metadata| u32::try_from(metadata.size()).ok())
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread lsf/src/tui/app.rs
time::Duration,
};

const HISTORY_PATH: &str = "target/search-history.txt";
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const HISTORY_PATH: &str = "target/search-history.txt";
const HISTORY_PATH: &str = ".cardinal/search-history.txt";

Copilot uses AI. Check for mistakes.
Comment thread lsf/src/tui/actions.rs
Comment on lines +19 to +23
let path = result.path.clone();
ratatui::restore();
let editor_result = launch_editor(&path);
*terminal = ratatui::init();

Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@ldm0
Copy link
Copy Markdown
Member

ldm0 commented Apr 25, 2026

Overall this is great work, I think the old lsf could be deleted actually. :-)

Here are a few observation and thoughts:

  1. If there are a lot of results, screen will stuck: Table is slow/laggy when there are a lot of items ratatui/ratatui#1004. You can easily reproduce that by typing 'a' in query box. I think we need a virtual list, ratatui doesn't have one which is sad. It's not a merge blocker though.
  2. Switch between query and results is clunky. Press ⬇️ to results panel and ESC to query panel, it's okay but kind of confusing for first user. I tweaked a little bit, checkout Misc tweak hsqStephenZhang/cardinal#1 (actually you could allow me to edit or push your branch to cardisoft/cardinal directly).

@hsqStephenZhang
Copy link
Copy Markdown
Collaborator Author

hsqStephenZhang commented Apr 26, 2026

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.
so i wonder if we need to make it more elegant before making further progress.
ratatui/ratatui#627 here is a relevant discussion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants