diff --git a/CHANGELOG.md b/CHANGELOG.md index ac666f7..1149bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ versioning: [SemVer](https://semver.org/spec/v2.0.0.html). ### Added +- **Prompt history + undo/redo** — `Up`/`Down` in the prompt recall previously + submitted messages, shell-style: recall triggers only at the editor's + vertical edges (`Up` on the first row, `Down` on the last) so multi-line + cursor movement is unaffected, and stepping back down past the newest entry + restores your in-progress draft. History is de-duplicated and capped. + `Ctrl+Z` / `Ctrl+Y` undo / redo prompt edits. + +- **Syntax-highlighted code blocks** — fenced code in assistant markdown is now + highlighted by language via `syntect` (pure-Rust `fancy-regex` engine, no C + toolchain). Common LLM fence tags (`rust`, `python`, `ts`, `bash`, …) are + normalised to the right syntax; unknown languages fall back to the previous + flat-green rendering. Highlighting is stateful across a block and paid once + per message version through the existing render cache. + +- **`@`-file mention autocomplete** — typing `@` in the prompt opens a + filesystem palette (relative to the focused session's workdir), fuzzy-ranked + against what you've typed. `Tab` completes to the top hit: directories keep a + trailing `/` so you can keep drilling, files get a trailing space. Descends + subdirectories (`@src/re…`) and reveals dotfiles only when you type a leading + dot. + +- **Fuzzy slash-command matching** — the `/` command palette and `Tab` + completion now rank by a fuzzy subsequence score (consecutive-run and + word-boundary bonuses, gap penalties) instead of a strict prefix, so `/expsn` + finds `/export` and `Tab` always commits the best match. + - **Multi-provider sessions** (pairs with the daemon's pi backend + mid-session switching): `/new [workdir] --provider ` creates a session on a specific backend, `/provider ` switches the focused diff --git a/Cargo.lock b/Cargo.lock index f42ac4e..9fde437 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -100,6 +106,30 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.1" @@ -263,6 +293,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "syntect", "thiserror 2.0.18", "tokio", "tracing", @@ -316,6 +347,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -444,12 +484,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -928,6 +995,12 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -985,6 +1058,16 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -1089,6 +1172,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1132,6 +1228,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1445,6 +1550,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1601,6 +1715,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1700,6 +1820,27 @@ dependencies = [ "syn", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "walkdir", + "yaml-rust", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1756,12 +1897,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -1770,6 +1913,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2098,6 +2251,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2265,6 +2428,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2521,6 +2693,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 6a0509b..219ae8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,10 @@ tui-textarea = "0.7" # Utility anyhow = "1" unicode-width = "0.2" +# Syntax highlighting for fenced code blocks. `default-fancy` swaps the +# oniguruma C dependency for the pure-Rust fancy-regex engine, so the build +# stays cross-compile-friendly with no C toolchain requirement. +syntect = { version = "5", default-features = false, features = ["default-fancy"] } thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/README.md b/README.md index 1aa899e..d394622 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,9 @@ cargo test -p codeoid-tui state:: # reducer tests (no Tokio, no Ratatui) | `Ctrl+X` / `.` | Interrupt — unconditional aliases | | `Enter` | Send prompt | | `Shift+Enter` / `Ctrl+J` | Newline in prompt | +| `Tab` (in prompt) | Autocomplete — slash-command after `/`, or a file after `@` (fuzzy-ranked; directories keep drilling) | +| `↑` / `↓` (in prompt) | Recall previous / next submitted prompt (at the editor's top / bottom edge) | +| `Ctrl+Z` / `Ctrl+Y` | Undo / redo prompt edits | | `←` / `→`, `p` / `n` | Prev / next session | | `y` / `d` | Approve / deny pending tool | | `m` | Cycle execution mode | diff --git a/crates/codeoid-tui/Cargo.toml b/crates/codeoid-tui/Cargo.toml index fa87f4b..c5360d5 100644 --- a/crates/codeoid-tui/Cargo.toml +++ b/crates/codeoid-tui/Cargo.toml @@ -32,3 +32,4 @@ tracing-subscriber = { workspace = true } clap = { workspace = true } chrono = { workspace = true } unicode-width = { workspace = true } +syntect = { workspace = true } diff --git a/crates/codeoid-tui/src/app.rs b/crates/codeoid-tui/src/app.rs index c9fe641..5e6661e 100644 --- a/crates/codeoid-tui/src/app.rs +++ b/crates/codeoid-tui/src/app.rs @@ -307,8 +307,42 @@ impl App { SessionStatus::Working | SessionStatus::WaitingApproval ) }); + // Tab completes an active `@`-file mention. Like the Esc + // interrupt above this is runtime-conditional (it depends on + // the cursor sitting inside a mention, plus live filesystem + // state), which the static keymap can't express — so it's + // resolved here, ahead of resolve() routing Tab to the editor. + let mention_tab = prompt_focused + && !command_mode + && !modal_open + && matches!(key.code, crossterm::event::KeyCode::Tab) + && state.active_mention().is_some(); + + // Up/Down recall submitted prompts, but only at the vertical + // edges of the editor so multi-line cursor movement still works + // (Up on the first row, Down on the last). Also runtime- + // conditional — it depends on the cursor row and history state. + let plain_key = key.modifiers.is_empty(); + let (cursor_row, _) = state.prompt.cursor(); + let last_row = state.prompt.lines().len().saturating_sub(1); + let recall_ok = prompt_focused && !command_mode && !modal_open && plain_key; + let history_prev = recall_ok + && matches!(key.code, crossterm::event::KeyCode::Up) + && cursor_row == 0 + && !state.prompt_history.is_empty(); + let history_next = recall_ok + && matches!(key.code, crossterm::event::KeyCode::Down) + && cursor_row == last_row + && state.history_index.is_some(); + let action = if esc_interrupts { Some(crate::keymap::Action::Interrupt) + } else if mention_tab { + Some(crate::keymap::Action::AutocompleteCommand) + } else if history_prev { + Some(crate::keymap::Action::HistoryPrev) + } else if history_next { + Some(crate::keymap::Action::HistoryNext) } else { resolve(key, prompt_focused, modal_kind, command_mode) }; @@ -401,8 +435,16 @@ impl App { state.prompt.insert_newline(); } Action::AutocompleteCommand => { - autocomplete_command(state); + autocomplete(state); + } + Action::UndoPrompt => { + state.prompt.undo(); + } + Action::RedoPrompt => { + state.prompt.redo(); } + Action::HistoryPrev => state.history_prev(), + Action::HistoryNext => state.history_next(), Action::NextSession => { state.sessions.focus_next(); state.scroll_to_bottom(); @@ -2199,29 +2241,55 @@ impl App { } } -/// Tab-autocomplete in command mode: if the user's partial command -/// uniquely matches a catalog entry, replace the prompt with -/// `/ ` (trailing space so they can start typing args). No-op -/// on ambiguous or zero matches — the palette hint line already tells the -/// user what their options are. +/// Tab handler in prompt mode: complete a slash-command when in command mode, +/// otherwise an active `@`-file mention. No-op when neither applies. +fn autocomplete(state: &mut AppState) { + if state.is_command_mode() { + autocomplete_command(state); + } else { + autocomplete_mention(state); + } +} + +/// Tab-autocomplete in command mode: complete the prompt to the top +/// fuzzy-ranked catalog entry as `/ ` (trailing space so the user +/// can start typing args). No-op when nothing matches — the palette hint line +/// already tells the user what their options are. fn autocomplete_command(state: &mut AppState) { use tui_textarea::TextArea; let Some(query) = state.command_query() else { return; }; - let Some(full) = commands::unique_completion(query) else { + let Some(full) = commands::top_completion(query) else { return; }; // Rebuild the editor with the completed command + trailing space. let mut fresh = TextArea::default(); - fresh.set_cursor_line_style(ratatui::style::Style::default()); - fresh.set_placeholder_text("Message… Enter sends · Shift+Enter newline · Esc blurs"); + crate::state::configure_prompt(&mut fresh); fresh.insert_str(format!("/{full} ")); state.prompt = fresh; } +/// Tab-autocomplete for an active `@`-file mention: replace the token with the +/// top fuzzy-ranked filesystem entry under the session workdir. Directories +/// gain a trailing `/` (keep drilling); files gain a trailing space. No-op +/// when there's no active mention or nothing matches. +fn autocomplete_mention(state: &mut AppState) { + let Some(mention) = state.active_mention() else { + return; + }; + let base = state.mention_base_dir(); + let Some(top) = crate::mention::suggest(&base, &mention.query, 1) + .into_iter() + .next() + else { + return; + }; + state.replace_mention(&mention, &top.display, !top.is_dir); +} + /// PageUp/PageDown step: a viewport-minus-one. Standard pager UX /// (more, less, vim) keeps one row of overlap on each page so the /// reader doesn't lose their place. Falls back to a sane default diff --git a/crates/codeoid-tui/src/commands/mod.rs b/crates/codeoid-tui/src/commands/mod.rs index 8eb6161..08fa6be 100644 --- a/crates/codeoid-tui/src/commands/mod.rs +++ b/crates/codeoid-tui/src/commands/mod.rs @@ -285,38 +285,38 @@ pub const CATALOG: &[(&str, &str)] = &[ ), ]; -/// Entries whose command name (before the first space) is a prefix match -/// for the user's partial query. An empty query returns every command, -/// preserving the catalog order. +/// The command name for a catalog `usage` string: strip the leading `/` and +/// take everything up to the first space, so `"/new "` → `"new"`. +fn command_name(usage: &str) -> &str { + usage + .trim_start_matches('/') + .split_whitespace() + .next() + .unwrap_or("") +} + +/// Catalog entries whose command name fuzzy-matches the user's partial query, +/// ranked best-first. An empty query returns every command in catalog order +/// (all entries tie at score 0 and the sort is stable). #[must_use] pub fn filter_catalog(query: &str) -> Vec<&'static (&'static str, &'static str)> { - let q = query.to_ascii_lowercase(); - CATALOG + let mut scored: Vec<(f64, &'static (&'static str, &'static str))> = CATALOG .iter() - .filter(|(usage, _)| { - // Strip the leading `/`, then take up to the first space so - // "/new xyz" filters on "new". - let name = usage - .trim_start_matches('/') - .split_whitespace() - .next() - .unwrap_or(""); - name.starts_with(&q) - }) - .collect() + .filter_map(|entry| crate::fuzzy::score(query, command_name(entry.0)).map(|s| (s, entry))) + .collect(); + // Lower score = better; stable sort keeps catalog order for ties. + scored.sort_by(|a, b| a.0.total_cmp(&b.0)); + scored.into_iter().map(|(_, entry)| entry).collect() } -/// Return the first command name whose prefix matches, or `None` if -/// multiple match (ambiguous) or zero match. Used by Tab-autocomplete. +/// The best fuzzy-matched command name for `query`, or `None` when nothing +/// matches. Used by Tab-autocomplete: Tab always completes to the top hit, +/// matching the palette's own ranking. #[must_use] -pub fn unique_completion(query: &str) -> Option<&'static str> { - let matches = filter_catalog(query); - if matches.len() != 1 { - return None; - } - let usage = matches[0].0; - // Extract just the command name for completion. - usage.trim_start_matches('/').split_whitespace().next() +pub fn top_completion(query: &str) -> Option<&'static str> { + filter_catalog(query) + .first() + .map(|entry| command_name(entry.0)) } #[cfg(test)] @@ -518,10 +518,12 @@ mod tests { } #[test] - fn filter_catalog_matches_prefix() { + fn filter_catalog_ranks_best_match_first() { + // Fuzzy matching may return several hits for "ne", but the prefix + // match `/new` must rank first. let out = filter_catalog("ne"); - assert_eq!(out.len(), 1); - assert!(out[0].0.starts_with("/new")); + assert!(!out.is_empty()); + assert!(out[0].0.starts_with("/new"), "top was {}", out[0].0); } #[test] @@ -532,24 +534,26 @@ mod tests { #[test] fn filter_catalog_case_insensitive() { let out = filter_catalog("HELP"); - assert_eq!(out.len(), 1); assert_eq!(out[0].0, "/help"); } #[test] - fn unique_completion_picks_unambiguous() { - assert_eq!(unique_completion("ne"), Some("new")); + fn top_completion_picks_best_match() { + assert_eq!(top_completion("ne"), Some("new")); + assert_eq!(top_completion("hel"), Some("help")); } #[test] - fn unique_completion_none_when_ambiguous() { - // Both /destroy and /deny start with `de` — ambiguous. - assert_eq!(unique_completion("de"), None); + fn top_completion_resolves_ambiguous_prefix_to_best() { + // Both `/destroy` and `/deny` fuzzy-match "de"; Tab commits to the + // top-ranked hit rather than refusing (old prefix behaviour). + let out = top_completion("de"); + assert!(matches!(out, Some("destroy" | "deny")), "got {out:?}"); } #[test] - fn unique_completion_none_when_no_match() { - assert_eq!(unique_completion("xzxzxz"), None); + fn top_completion_none_when_no_match() { + assert_eq!(top_completion("xzxzxz"), None); } #[test] diff --git a/crates/codeoid-tui/src/fuzzy.rs b/crates/codeoid-tui/src/fuzzy.rs new file mode 100644 index 0000000..da47531 --- /dev/null +++ b/crates/codeoid-tui/src/fuzzy.rs @@ -0,0 +1,143 @@ +//! Tiny fuzzy subsequence matcher, ported from pi-tui's `fuzzy.ts` scoring. +//! +//! [`score`] returns `None` when the query isn't a subsequence of the +//! candidate, and otherwise a number where **lower is better**. The weights +//! mirror the reference implementation: +//! +//! * exact match (case-insensitive) → large bonus +//! * consecutive matched characters → bonus +//! * gaps between matches → penalty +//! * match at a word boundary (start, after punctuation, or camelCase) → bonus +//! * later match positions → tiny penalty (prefer earlier hits) +//! +//! Matching is ASCII-case-insensitive; non-ASCII compares by exact codepoint +//! (fine for command names and file paths, which is all we feed it). + +/// Bonus applied when the whole candidate equals the query (ignoring case). +const EXACT_BONUS: f64 = -100.0; +/// Bonus per matched character that sits on a word boundary. +const BOUNDARY_BONUS: f64 = -10.0; +/// Bonus when a matched character immediately follows the previous match. +const CONSECUTIVE_BONUS: f64 = -5.0; +/// Penalty per skipped character between two matches. +const GAP_PENALTY: f64 = 2.0; +/// Penalty per index of a match's position (nudges earlier matches up). +const POSITION_PENALTY: f64 = 0.1; + +/// Score a fuzzy match of `needle` against `haystack`. `None` if `needle` +/// is not a subsequence of `haystack`; otherwise lower is a better match. +#[must_use] +pub fn score(needle: &str, haystack: &str) -> Option { + if needle.is_empty() { + return Some(0.0); + } + if haystack.eq_ignore_ascii_case(needle) { + return Some(EXACT_BONUS); + } + + let hay: Vec = haystack.chars().collect(); + let mut total = 0.0; + let mut h = 0usize; + let mut last_match: Option = None; + + for nc in needle.chars() { + let target = nc.to_ascii_lowercase(); + let idx = loop { + if h >= hay.len() { + return None; + } + if hay[h].to_ascii_lowercase() == target { + break h; + } + h += 1; + }; + + if is_boundary(&hay, idx) { + total += BOUNDARY_BONUS; + } + match last_match { + Some(prev) if prev + 1 == idx => total += CONSECUTIVE_BONUS, + Some(prev) => total += GAP_PENALTY * saturating_gap(prev, idx), + None => {} + } + total += POSITION_PENALTY * idx_as_f64(idx); + + last_match = Some(idx); + h = idx + 1; + } + + Some(total) +} + +fn is_boundary(hay: &[char], idx: usize) -> bool { + if idx == 0 { + return true; + } + let prev = hay[idx - 1]; + !prev.is_alphanumeric() || (prev.is_ascii_lowercase() && hay[idx].is_ascii_uppercase()) +} + +fn saturating_gap(prev: usize, idx: usize) -> f64 { + idx_as_f64(idx.saturating_sub(prev).saturating_sub(1)) +} + +// Indices in candidate strings are tiny (command names, path segments), so +// the lossy-cast lint doesn't apply in practice — isolate it here. +#[allow(clippy::cast_precision_loss)] +fn idx_as_f64(n: usize) -> f64 { + n as f64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_needle_matches_anything() { + assert_eq!(score("", "anything"), Some(0.0)); + } + + #[test] + fn non_subsequence_returns_none() { + assert!(score("xyz", "export").is_none()); + assert!(score("zzz", "").is_none()); + } + + #[test] + fn exact_match_wins_big() { + let exact = score("help", "help").unwrap(); + let fuzzy = score("hlp", "help").unwrap(); + assert!(exact < fuzzy); + assert!((exact - EXACT_BONUS).abs() < f64::EPSILON); + } + + #[test] + fn exact_is_case_insensitive() { + assert_eq!(score("HELP", "help"), Some(EXACT_BONUS)); + } + + #[test] + fn prefix_beats_scattered() { + // "mo" as a prefix of "model" should rank better (lower) than the + // same chars scattered through "autonomous". + let prefix = score("mo", "model").unwrap(); + let scattered = score("mo", "autonomous").unwrap(); + assert!(prefix < scattered, "prefix {prefix} scattered {scattered}"); + } + + #[test] + fn word_boundary_rewarded() { + // Matching the start of a segment after a separator beats a match + // buried mid-word. + let boundary = score("nb", "new-branch").unwrap(); // n(start) b(after '-') + let buried = score("ew", "new").unwrap(); // e,w mid-word + assert!(boundary < buried); + } + + #[test] + fn consecutive_beats_gapped() { + let consecutive = score("ex", "export").unwrap(); + let gapped = score("et", "export").unwrap(); // e...t with a gap + assert!(consecutive < gapped); + } +} diff --git a/crates/codeoid-tui/src/keymap.rs b/crates/codeoid-tui/src/keymap.rs index 003f435..df7cd48 100644 --- a/crates/codeoid-tui/src/keymap.rs +++ b/crates/codeoid-tui/src/keymap.rs @@ -13,6 +13,14 @@ pub enum Action { SubmitPrompt, NewlineInPrompt, AutocompleteCommand, + /// Undo the last prompt edit (Ctrl+Z). + UndoPrompt, + /// Redo the last undone prompt edit (Ctrl+Y). + RedoPrompt, + /// Recall the previous (older) submitted prompt into the editor. + HistoryPrev, + /// Recall the next (newer) submitted prompt, or restore the live draft. + HistoryNext, NextSession, PrevSession, Interrupt, @@ -257,6 +265,10 @@ pub fn resolve( (Enter, m) if m.contains(KeyModifiers::SHIFT) => Some(Action::NewlineInPrompt), (Char('j'), KeyModifiers::CONTROL) => Some(Action::NewlineInPrompt), + // Undo / redo of prompt edits. + (Char('z'), KeyModifiers::CONTROL) => Some(Action::UndoPrompt), + (Char('y'), KeyModifiers::CONTROL) => Some(Action::RedoPrompt), + // Global controls — always reachable while typing. (Char('c'), KeyModifiers::CONTROL) => Some(Action::Quit), (Char('x'), KeyModifiers::CONTROL) => Some(Action::Interrupt), @@ -609,6 +621,33 @@ mod tests { ); } + #[test] + fn prompt_ctrl_z_undoes_and_ctrl_y_redoes() { + assert_eq!( + resolve(ctrl(KeyCode::Char('z')), true, ModalKind::None, false), + Some(Action::UndoPrompt) + ); + assert_eq!( + resolve(ctrl(KeyCode::Char('y')), true, ModalKind::None, false), + Some(Action::RedoPrompt) + ); + } + + #[test] + fn prompt_plain_up_down_fall_through_for_runtime_history_gating() { + // History recall is decided in the app reducer (it needs the cursor + // row + history state), so the static keymap must leave plain Up/Down + // unbound in prompt mode — the editor/history handler takes them. + assert_eq!( + resolve(key(KeyCode::Up), true, ModalKind::None, false), + None + ); + assert_eq!( + resolve(key(KeyCode::Down), true, ModalKind::None, false), + None + ); + } + // ------------ nav mode (no prompt focus) ------------ #[test] diff --git a/crates/codeoid-tui/src/main.rs b/crates/codeoid-tui/src/main.rs index 13d9974..a427591 100644 --- a/crates/codeoid-tui/src/main.rs +++ b/crates/codeoid-tui/src/main.rs @@ -10,7 +10,9 @@ mod app; mod commands; mod event; +mod fuzzy; mod keymap; +mod mention; mod render; mod state; mod ui; diff --git a/crates/codeoid-tui/src/mention.rs b/crates/codeoid-tui/src/mention.rs new file mode 100644 index 0000000..0224a3d --- /dev/null +++ b/crates/codeoid-tui/src/mention.rs @@ -0,0 +1,154 @@ +//! `@`-file mention autocomplete: filesystem path completion for the prompt. +//! +//! When the user types `@src/re…` in the prompt, [`suggest`] lists entries of +//! the referenced directory (relative to the session's workdir), fuzzy-ranked +//! against the partial filename. The worker row shows the top hits and Tab +//! commits the best one — directories keep a trailing `/` so the user can +//! drill in, files get a trailing space. + +use std::path::Path; + +use crate::fuzzy; + +/// A single file/directory completion candidate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileSuggestion { + /// Path text to substitute after the `@`, relative to the base dir — + /// e.g. `"src/render/"` for a directory or `"Cargo.toml"` for a file. + /// Directories carry a trailing `/`. + pub display: String, + /// Whether this entry is a directory (drives Tab's trailing char). + pub is_dir: bool, +} + +/// Rank filesystem entries for the `@`-mention `query` under `base`. +/// +/// `query` is the text typed after `@` (may contain `/` to descend into +/// subdirectories, and may be empty right after typing `@`). Hidden entries +/// (dotfiles) are only offered when the partial name itself starts with `.`. +/// Returns at most `limit` results, best match first. Any I/O error (missing +/// or unreadable directory) yields an empty list rather than an error. +#[must_use] +pub fn suggest(base: &Path, query: &str, limit: usize) -> Vec { + // Split the query into a directory prefix (kept verbatim, trailing `/` + // included) and the partial filename we actually fuzzy-match. + let (dir_part, file_part) = match query.rfind('/') { + Some(i) => (&query[..=i], &query[i + 1..]), + None => ("", query), + }; + + let dir = base.join(dir_part); + let Ok(entries) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + + let mut scored: Vec<(f64, FileSuggestion)> = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + // Hide dotfiles unless the user is explicitly typing a leading dot. + if name.starts_with('.') && !file_part.starts_with('.') { + continue; + } + let Some(score) = fuzzy::score(file_part, name) else { + continue; + }; + let is_dir = entry.file_type().is_ok_and(|t| t.is_dir()); + let mut display = format!("{dir_part}{name}"); + if is_dir { + display.push('/'); + } + scored.push((score, FileSuggestion { display, is_dir })); + } + + scored.sort_by(|a, b| a.0.total_cmp(&b.0)); + scored.truncate(limit); + scored.into_iter().map(|(_, s)| s).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// Build a throwaway directory tree under the OS temp dir. Uses the test + /// name as a unique suffix (workflow scripts forbid `Date::now`, but tests + /// run under cargo so a fixed-per-test name is enough given cleanup). + fn scratch(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("codeoid-mention-{tag}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("src/render")).unwrap(); + fs::write(dir.join("Cargo.toml"), "").unwrap(); + fs::write(dir.join("README.md"), "").unwrap(); + fs::write(dir.join(".hidden"), "").unwrap(); + fs::write(dir.join("src/main.rs"), "").unwrap(); + fs::write(dir.join("src/render/mod.rs"), "").unwrap(); + dir + } + + #[test] + fn empty_query_lists_top_level_visible_entries() { + let dir = scratch("empty"); + let out = suggest(&dir, "", 50); + let names: Vec<&str> = out.iter().map(|s| s.display.as_str()).collect(); + assert!(names.contains(&"Cargo.toml")); + assert!(names.contains(&"README.md")); + assert!( + names.contains(&"src/"), + "dirs get a trailing slash: {names:?}" + ); + // Dotfiles stay hidden unless requested. + assert!(!names.iter().any(|n| n.starts_with('.'))); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn fuzzy_ranks_filename() { + let dir = scratch("rank"); + let out = suggest(&dir, "carg", 10); + assert_eq!(out.first().map(|s| s.display.as_str()), Some("Cargo.toml")); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn descends_into_subdirectory_prefix() { + let dir = scratch("descend"); + let out = suggest(&dir, "src/ma", 10); + // dir_part ("src/") is preserved on the completion. + assert_eq!(out.first().map(|s| s.display.as_str()), Some("src/main.rs")); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn nested_directory_keeps_trailing_slash() { + let dir = scratch("nested"); + let out = suggest(&dir, "src/rend", 10); + assert_eq!(out.first().map(|s| s.display.as_str()), Some("src/render/")); + assert!(out[0].is_dir); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn leading_dot_reveals_hidden() { + let dir = scratch("hidden"); + let out = suggest(&dir, ".hid", 10); + assert_eq!(out.first().map(|s| s.display.as_str()), Some(".hidden")); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn missing_directory_is_empty_not_error() { + let out = suggest(Path::new("/no/such/path/anywhere"), "x", 10); + assert!(out.is_empty()); + } + + #[test] + fn limit_is_respected() { + let dir = scratch("limit"); + let out = suggest(&dir, "", 1); + assert_eq!(out.len(), 1); + fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/codeoid-tui/src/render/highlight.rs b/crates/codeoid-tui/src/render/highlight.rs new file mode 100644 index 0000000..c97fcdd --- /dev/null +++ b/crates/codeoid-tui/src/render/highlight.rs @@ -0,0 +1,192 @@ +//! Syntax highlighting for fenced code blocks. +//! +//! Wraps [`syntect`] so `render/markdown.rs` can colour code by language. +//! The heavy defaults (syntax + theme dumps) are loaded exactly once behind +//! `OnceLock`; a highlighter is then cheap to spin up per fence. +//! +//! Highlighting is stateful across the lines of a single block (a `{` opened +//! on one line affects the next), so callers build one [`CodeHighlighter`] +//! per fence and feed it lines in order. +//! +//! Everything degrades gracefully: an unknown language yields `None` from +//! [`CodeHighlighter::for_language`] and the caller falls back to plain text. + +use std::sync::OnceLock; + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; +use syntect::easy::HighlightLines; +use syntect::highlighting::{FontStyle, Style as SynStyle, Theme, ThemeSet}; +use syntect::parsing::{SyntaxReference, SyntaxSet}; + +static SYNTAX_SET: OnceLock = OnceLock::new(); +static THEME: OnceLock = OnceLock::new(); + +/// Bundled syntax definitions. We split markdown on `\n` and feed lines +/// *without* the trailing newline, so the no-newlines variant is correct. +fn syntax_set() -> &'static SyntaxSet { + SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_nonewlines) +} + +/// A dark theme that reads well on the terminal's (assumed dark) background. +/// We only ever use the foreground colours, so the theme's own background is +/// intentionally ignored — code blocks blend with the surrounding transcript. +fn theme() -> &'static Theme { + THEME.get_or_init(|| { + let mut set = ThemeSet::load_defaults(); + set.themes + .remove("base16-ocean.dark") + .or_else(|| set.themes.values().next().cloned()) + .expect("syntect ships at least one default theme") + }) +} + +/// Map the language tag on a code fence to a token `syntect` recognises. +/// +/// LLM output writes fences like ```` ```rust ```` or ```` ```python ````; +/// `syntect` indexes syntaxes by file extension (`rs`, `py`) and exact name +/// (`Rust`), so we normalise the common aliases to an extension first. +fn normalize_lang(lang: &str) -> &str { + match lang.trim().to_ascii_lowercase().as_str() { + "rust" => "rs", + "python" => "py", + "javascript" | "node" | "mjs" | "cjs" => "js", + "typescript" => "ts", + "bash" | "shell" | "zsh" | "console" => "sh", + "yml" => "yaml", + "golang" => "go", + "c++" | "cxx" | "cc" => "cpp", + "c#" | "csharp" => "cs", + "rb" | "ruby" => "rb", + "markdown" => "md", + "plaintext" | "text" | "txt" => "txt", + // Anything else: pass the (trimmed) tag straight through — `syntect` + // resolves the majority of extension/name tokens on its own. + _ => lang.trim(), + } +} + +fn find_syntax(set: &'static SyntaxSet, lang: &str) -> Option<&'static SyntaxReference> { + let token = normalize_lang(lang); + if token.is_empty() { + return None; + } + set.find_syntax_by_token(token) + .or_else(|| set.find_syntax_by_extension(token)) + .or_else(|| set.find_syntax_by_name(token)) +} + +/// A per-fence highlighter. Cheap to build; holds the running parse state so +/// consecutive lines highlight in context. +pub struct CodeHighlighter { + inner: HighlightLines<'static>, +} + +impl CodeHighlighter { + /// Build a highlighter for `lang`, or `None` when the language isn't + /// recognised (caller should fall back to plain rendering). + #[must_use] + pub fn for_language(lang: &str) -> Option { + let set = syntax_set(); + let syntax = find_syntax(set, lang)?; + Some(Self { + inner: HighlightLines::new(syntax, theme()), + }) + } + + /// Highlight one line of code into styled spans. Returns `None` if the + /// underlying parser errors, so the caller can fall back to plain text. + #[must_use] + pub fn highlight(&mut self, line: &str) -> Option>> { + let ranges = self.inner.highlight_line(line, syntax_set()).ok()?; + Some( + ranges + .into_iter() + .map(|(style, text)| Span::styled(text.to_string(), convert_style(style))) + .collect(), + ) + } +} + +/// Convert a `syntect` style to a Ratatui style. Foreground colour and the +/// bold/italic/underline flags carry over; background is dropped so blocks +/// sit flush with the transcript background. +fn convert_style(style: SynStyle) -> Style { + let fg = style.foreground; + let mut out = Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b)); + if style.font_style.contains(FontStyle::BOLD) { + out = out.add_modifier(Modifier::BOLD); + } + if style.font_style.contains(FontStyle::ITALIC) { + out = out.add_modifier(Modifier::ITALIC); + } + if style.font_style.contains(FontStyle::UNDERLINE) { + out = out.add_modifier(Modifier::UNDERLINED); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_language_builds_highlighter() { + assert!(CodeHighlighter::for_language("rust").is_some()); + assert!(CodeHighlighter::for_language("rs").is_some()); + assert!(CodeHighlighter::for_language("python").is_some()); + assert!(CodeHighlighter::for_language("json").is_some()); + } + + #[test] + fn unknown_language_yields_none() { + assert!(CodeHighlighter::for_language("definitely-not-a-language").is_none()); + assert!(CodeHighlighter::for_language("").is_none()); + } + + #[test] + fn highlight_splits_into_multiple_styled_spans() { + let mut h = CodeHighlighter::for_language("rust").unwrap(); + let spans = h.highlight("fn main() {}").unwrap(); + // Keywords vs identifiers vs punctuation should yield >1 span, and + // the concatenated text must round-trip the input exactly. + assert!( + spans.len() > 1, + "expected multiple spans, got {}", + spans.len() + ); + let joined: String = spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(joined, "fn main() {}"); + } + + #[test] + fn highlight_preserves_content_across_lines() { + let mut h = CodeHighlighter::for_language("rust").unwrap(); + let l1: String = h + .highlight("let x = \"unterminated") + .unwrap() + .iter() + .map(|s| s.content.as_ref()) + .collect(); + let l2: String = h + .highlight("still string\";") + .unwrap() + .iter() + .map(|s| s.content.to_string()) + .collect(); + assert_eq!(l1, "let x = \"unterminated"); + assert_eq!(l2, "still string\";"); + } + + #[test] + fn empty_line_highlights_to_empty() { + let mut h = CodeHighlighter::for_language("rust").unwrap(); + let joined: String = h + .highlight("") + .unwrap() + .iter() + .map(|s| s.content.as_ref()) + .collect(); + assert_eq!(joined, ""); + } +} diff --git a/crates/codeoid-tui/src/render/markdown.rs b/crates/codeoid-tui/src/render/markdown.rs index e01b1f6..202b740 100644 --- a/crates/codeoid-tui/src/render/markdown.rs +++ b/crates/codeoid-tui/src/render/markdown.rs @@ -18,6 +18,12 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; +use crate::render::highlight::CodeHighlighter; + +/// Fallback colour for code-block content when the language is unknown (or +/// highlighting fails). Matches the pre-syntect look. +const CODE_PLAIN: Color = Color::Rgb(180, 230, 180); + /// Render a multi-line markdown string into styled `Line`s. /// /// `indent` is a prefix string (typically `" "`) prepended to every line. @@ -25,12 +31,17 @@ use ratatui::text::{Line, Span}; pub fn render_markdown_block(text: &str, indent: &str) -> Vec> { let mut out = Vec::with_capacity(text.len() / 40 + 1); let mut in_fence = false; + // `Some(Some(h))` = highlighting this block; `Some(None)` = in a block + // whose language we couldn't resolve (plain fallback); `None` = outside + // any fence. Built fresh per fence so parse state doesn't leak. + let mut highlighter: Option> = None; for raw in text.split('\n') { // Fenced code blocks. if let Some(rest) = raw.strip_prefix("```") { if in_fence { in_fence = false; + highlighter = None; out.push(Line::from(vec![ Span::raw(indent.to_owned()), Span::styled("└─", Style::default().fg(Color::DarkGray)), @@ -38,6 +49,7 @@ pub fn render_markdown_block(text: &str, indent: &str) -> Vec> { } else { in_fence = true; let lang = rest.trim(); + highlighter = Some(CodeHighlighter::for_language(lang)); let lang_label = if lang.is_empty() { "code" } else { lang }; out.push(Line::from(vec![ Span::raw(indent.to_owned()), @@ -54,14 +66,24 @@ pub fn render_markdown_block(text: &str, indent: &str) -> Vec> { } if in_fence { - out.push(Line::from(vec![ + let mut spans = vec![ Span::raw(indent.to_owned()), Span::styled("│ ", Style::default().fg(Color::DarkGray)), - Span::styled( + ]; + // Try the block's highlighter; fall back to flat green on either + // an unresolved language or a parser hiccup on this line. + let highlighted = highlighter + .as_mut() + .and_then(|h| h.as_mut()) + .and_then(|h| h.highlight(raw)); + match highlighted { + Some(code_spans) => spans.extend(code_spans), + None => spans.push(Span::styled( raw.to_string(), - Style::default().fg(Color::Rgb(180, 230, 180)), - ), - ])); + Style::default().fg(CODE_PLAIN), + )), + } + out.push(Line::from(spans)); continue; } @@ -249,15 +271,35 @@ mod tests { assert_eq!(lines.len(), 3); // opener assert!(lines[0].spans.iter().any(|s| s.content.contains("rs"))); - // body - assert!(lines[1] - .spans - .iter() - .any(|s| s.content.as_ref() == "fn main() {}")); + // body — highlighting splits the code into several styled spans, so + // assert the concatenated content round-trips the source line. + let body: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(body.contains("fn main() {}"), "body was {body:?}"); // closer assert!(lines[2].spans.iter().any(|s| s.content.as_ref() == "└─")); } + #[test] + fn fenced_code_body_is_highlighted() { + let lines = render_markdown_block("```rust\nfn main() {}\n```", ""); + // With a known language the body line carries more than the two + // frame spans (indent + gutter), i.e. real highlight spans landed. + let frame_spans = 2; // indent + "│ " + assert!( + lines[1].spans.len() > frame_spans + 1, + "expected highlighted spans, got {}", + lines[1].spans.len() + ); + } + + #[test] + fn fenced_code_unknown_language_falls_back() { + let lines = render_markdown_block("```zzznope\nsome text\n```", ""); + // Unknown language → single plain content span after the gutter. + let body: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(body.contains("some text")); + } + #[test] fn inline_code_and_bold() { let spans = inline_spans("call **foo** via `bar`", Style::default()); diff --git a/crates/codeoid-tui/src/render/mod.rs b/crates/codeoid-tui/src/render/mod.rs index 8895060..7402969 100644 --- a/crates/codeoid-tui/src/render/mod.rs +++ b/crates/codeoid-tui/src/render/mod.rs @@ -3,6 +3,7 @@ //! `ui/` so they stay trivially unit-testable. pub mod ansi; +pub mod highlight; pub mod markdown; pub mod parts; pub mod sanitize; diff --git a/crates/codeoid-tui/src/state/mod.rs b/crates/codeoid-tui/src/state/mod.rs index 49f1b0a..00c628f 100644 --- a/crates/codeoid-tui/src/state/mod.rs +++ b/crates/codeoid-tui/src/state/mod.rs @@ -13,7 +13,7 @@ pub mod render_cache; pub mod scrollback_build; pub mod sessions; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use codeoid_protocol::{ AuthOkMsg, ModelInfo, ProviderCommand, SessionInfo, SessionUiRequestMsg, UiRequestMethod, @@ -139,6 +139,18 @@ pub struct AppState { /// focused session. `None` means "no explicit selection yet" and /// `Enter` falls back to the most recent tool_call message. pub selected_tool_message_id: Option, + /// Previously submitted prompts, oldest first, for shell-style Up/Down + /// recall. Capped at [`PROMPT_HISTORY_MAX`]; consecutive duplicates are + /// collapsed so hammering the same message doesn't flood the ring. A + /// `VecDeque` so trimming the oldest entry is an O(1) `pop_front`. + pub prompt_history: VecDeque, + /// Position within [`AppState::prompt_history`] while recalling. `None` + /// means the user is editing a live draft (not browsing history); `Some(i)` + /// means the prompt currently mirrors `prompt_history[i]`. + pub history_index: Option, + /// The live draft stashed when the user first steps into history, restored + /// when they step back down past the newest entry. + pub history_draft: Option, } impl std::fmt::Debug for AppState { @@ -166,12 +178,59 @@ pub enum ConnectionState { Failed { reason: String }, } +/// Placeholder shown in the empty prompt editor. Single source of truth so +/// every place that (re)builds the `TextArea` stays in sync. +pub const PROMPT_PLACEHOLDER: &str = "Message… Enter sends · Shift+Enter newline · Esc blurs"; + +/// Cap on remembered prompts for Up/Down recall. Old entries drop off the +/// front once exceeded — plenty for a session without unbounded growth. +pub const PROMPT_HISTORY_MAX: usize = 200; + +/// An `@`-file mention the cursor is currently inside. Char indices are into +/// the referenced prompt line; `query` is the partial path after `@`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Mention { + /// Zero-based line (row) of the prompt the mention lives on. + pub line: usize, + /// Char index of the leading `@`. + pub start: usize, + /// Char index just past the cursor (exclusive end of the token). + pub end: usize, + /// Partial path typed after `@` (may be empty right after typing `@`). + pub query: String, +} + +/// Clamp a `usize` cursor coordinate into `tui-textarea`'s `u16` space. +/// Prompts never reach 65 535 rows/cols in practice; saturating keeps the +/// pedantic truncation lint happy without an unwrap. +fn clamp_u16(n: usize) -> u16 { + u16::try_from(n).unwrap_or(u16::MAX) +} + +/// Apply the prompt editor's standard styling. Used everywhere the editor is +/// (re)constructed — initial build, post-submit reset, and autocomplete. +pub(crate) fn configure_prompt(prompt: &mut TextArea<'static>) { + prompt.set_cursor_line_style(ratatui::style::Style::default()); + prompt.set_placeholder_text(PROMPT_PLACEHOLDER); +} + +/// Build a configured prompt editor holding `text`, cursor at the very end. +/// A free function (not a `&mut self` method) so callers can pass a borrow of +/// another `self` field — e.g. `&self.prompt_history[i]` — without cloning. +fn prompt_from_text(text: &str) -> TextArea<'static> { + let lines: Vec = text.split('\n').map(str::to_owned).collect(); + let mut prompt = TextArea::new(lines); + configure_prompt(&mut prompt); + prompt.move_cursor(tui_textarea::CursorMove::Bottom); + prompt.move_cursor(tui_textarea::CursorMove::End); + prompt +} + impl AppState { #[must_use] pub fn new(auth: AuthOkMsg) -> Self { let mut prompt = TextArea::default(); - prompt.set_cursor_line_style(ratatui::style::Style::default()); - prompt.set_placeholder_text("Message… Enter sends · Shift+Enter newline · Esc blurs"); + configure_prompt(&mut prompt); Self { auth, models: Vec::new(), @@ -202,6 +261,9 @@ impl AppState { verbose_tool_output: false, expanded_tool_message_ids: HashSet::new(), selected_tool_message_id: None, + prompt_history: VecDeque::new(), + history_index: None, + history_draft: None, } } @@ -296,20 +358,73 @@ impl AppState { } /// Drain the prompt into a `String` and reset the editor. Returns - /// `None` if the editor was empty (or whitespace-only). + /// `None` if the editor was empty (or whitespace-only). Records the + /// submitted text in the recall history and resets history navigation. pub fn take_prompt(&mut self) -> Option { let text = self.prompt.lines().join("\n"); if text.trim().is_empty() { return None; } + self.record_history(&text); // TextArea doesn't have a clear() method; re-initialize. let mut fresh = TextArea::default(); - fresh.set_cursor_line_style(ratatui::style::Style::default()); - fresh.set_placeholder_text("Message… Enter sends · Shift+Enter newline · Esc blurs"); + configure_prompt(&mut fresh); self.prompt = fresh; Some(text) } + /// Push a submitted prompt onto the recall history (skipping a repeat of + /// the newest entry) and reset any in-flight history navigation. + fn record_history(&mut self, text: &str) { + if self.prompt_history.back().map(String::as_str) != Some(text) { + self.prompt_history.push_back(text.to_owned()); + // We only ever add one at a time, so a single pop keeps the cap. + while self.prompt_history.len() > PROMPT_HISTORY_MAX { + self.prompt_history.pop_front(); + } + } + self.history_index = None; + self.history_draft = None; + } + + /// Recall the previous (older) prompt from history. On first step it stashes + /// the current live draft so [`AppState::history_next`] can restore it. + /// No-op when history is empty. + pub fn history_prev(&mut self) { + if self.prompt_history.is_empty() { + return; + } + let index = match self.history_index { + None => { + self.history_draft = Some(self.prompt.lines().join("\n")); + self.prompt_history.len() - 1 + } + Some(0) => 0, // already at the oldest entry + Some(i) => i - 1, + }; + self.history_index = Some(index); + // Borrow the entry directly (no clone): `prompt_from_text` doesn't + // touch `self`, so the immutable borrow of `prompt_history` ends + // before the assignment to `self.prompt`. + self.prompt = prompt_from_text(&self.prompt_history[index]); + } + + /// Recall the next (newer) prompt from history; stepping past the newest + /// entry restores the stashed live draft. No-op when not browsing history. + pub fn history_next(&mut self) { + let Some(i) = self.history_index else { + return; + }; + if i + 1 < self.prompt_history.len() { + self.history_index = Some(i + 1); + self.prompt = prompt_from_text(&self.prompt_history[i + 1]); + } else { + self.history_index = None; + let draft = self.history_draft.take().unwrap_or_default(); + self.prompt = prompt_from_text(&draft); + } + } + #[must_use] pub fn prompt_is_empty(&self) -> bool { self.prompt.lines().iter().all(|l| l.is_empty()) @@ -334,6 +449,88 @@ impl AppState { self.prompt.lines().first()?.strip_prefix('/') } + /// The `@`-file mention the cursor currently sits inside, if any. + /// + /// A mention is the run of non-whitespace characters ending at the cursor + /// that begins with `@` at a word boundary (line start or after + /// whitespace). The returned [`Mention`] carries the partial path typed + /// after `@` and the char range on the line to overwrite on completion. + /// Only active while the prompt is focused and not in command mode, so it + /// never fights the `/` palette or fires on an unfocused editor. + #[must_use] + pub fn active_mention(&self) -> Option { + if self.focus != Focus::Prompt || self.is_command_mode() { + return None; + } + let (row, col) = self.prompt.cursor(); + let line = self.prompt.lines().get(row)?; + let chars: Vec = line.chars().collect(); + let col = col.min(chars.len()); + + let mut start = col; + while start > 0 && !chars[start - 1].is_whitespace() { + start -= 1; + } + // Empty token (cursor sits right after whitespace) or a token that + // doesn't open with `@` isn't a mention. + if start >= col || chars[start] != '@' { + return None; + } + let query: String = chars[start + 1..col].iter().collect(); + // A second `@` inside the token means this isn't a path (e.g. an + // email fragment the walk-back happened to start on) — bail. + if query.contains('@') { + return None; + } + Some(Mention { + line: row, + start, + end: col, + query, + }) + } + + /// Base directory that `@`-mentions resolve against: the focused session's + /// workdir, falling back to the process cwd (then `.`). + #[must_use] + pub fn mention_base_dir(&self) -> std::path::PathBuf { + self.sessions + .focused() + .map(|s| std::path::PathBuf::from(&s.workdir)) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| ".".into())) + } + + /// Replace an active `@`-mention token with `@`, optionally + /// appending a trailing space (for files — directories keep the cursor + /// after the `/` so the user can keep drilling). Surrounding text on the + /// line and the other lines are preserved. + pub fn replace_mention(&mut self, mention: &Mention, replacement: &str, trailing_space: bool) { + let mut lines: Vec = self.prompt.lines().to_vec(); + let Some(line) = lines.get_mut(mention.line) else { + return; + }; + let chars: Vec = line.chars().collect(); + let end = mention.end.min(chars.len()); + let prefix: String = chars[..mention.start].iter().collect(); + let suffix: String = chars[end..].iter().collect(); + + let mut token = String::from("@"); + token.push_str(replacement); + if trailing_space { + token.push(' '); + } + let cursor_col = mention.start + token.chars().count(); + *line = format!("{prefix}{token}{suffix}"); + + let mut fresh = TextArea::new(lines); + configure_prompt(&mut fresh); + fresh.move_cursor(tui_textarea::CursorMove::Jump( + clamp_u16(mention.line), + clamp_u16(cursor_col), + )); + self.prompt = fresh; + } + /// Record that we've sent `session.attach` for this session. Returns /// `true` if this is a new attachment (i.e. the caller should actually /// send the message to the daemon). @@ -1550,4 +1747,168 @@ mod tests { ); assert_eq!(state.focused_provider_commands().len(), 1); } + + #[test] + fn active_mention_detects_token_at_cursor() { + let mut state = mk_state(); + state.prompt.insert_str("look at @src/mod"); + let m = state.active_mention().expect("mention active"); + assert_eq!(m.query, "src/mod"); + assert_eq!(m.start, "look at ".chars().count()); + } + + #[test] + fn active_mention_empty_query_right_after_at() { + let mut state = mk_state(); + state.prompt.insert_str("see @"); + let m = state.active_mention().expect("mention active"); + assert_eq!(m.query, ""); + } + + #[test] + fn active_mention_none_without_at_token() { + let mut state = mk_state(); + state.prompt.insert_str("just some text"); + assert!(state.active_mention().is_none()); + } + + #[test] + fn active_mention_ignores_email_like_token() { + let mut state = mk_state(); + state.prompt.insert_str("ping me@example"); + assert!(state.active_mention().is_none()); + } + + #[test] + fn active_mention_suppressed_in_command_mode() { + let mut state = mk_state(); + state.prompt.insert_str("/new @foo"); + assert!(state.active_mention().is_none()); + } + + #[test] + fn active_mention_suppressed_when_prompt_not_focused() { + let mut state = mk_state(); + state.prompt.insert_str("see @foo"); + state.focus = Focus::Scrollback; + assert!(state.active_mention().is_none()); + } + + #[test] + fn replace_mention_file_adds_space_and_preserves_surroundings() { + let mut state = mk_state(); + state.prompt.insert_str("look at @Ca rest"); + // Put the cursor just past "@Ca" (before the space). + state + .prompt + .move_cursor(tui_textarea::CursorMove::Jump(0, 11)); + let m = state.active_mention().expect("mention active"); + assert_eq!(m.query, "Ca"); + + state.replace_mention(&m, "Cargo.toml", true); + let line = &state.prompt.lines()[0]; + assert!( + line.starts_with("look at @Cargo.toml "), + "line was {line:?}" + ); + assert!(line.trim_end().ends_with("rest"), "suffix lost: {line:?}"); + } + + #[test] + fn replace_mention_directory_has_no_trailing_space() { + let mut state = mk_state(); + state.prompt.insert_str("open @sr"); + let m = state.active_mention().expect("mention active"); + state.replace_mention(&m, "src/", false); + assert_eq!(state.prompt.lines()[0], "open @src/"); + } + + fn prompt_text(state: &AppState) -> String { + state.prompt.lines().join("\n") + } + + #[test] + fn take_prompt_records_history_and_dedups() { + let mut state = mk_state(); + state.prompt.insert_str("first"); + assert_eq!(state.take_prompt().as_deref(), Some("first")); + state.prompt.insert_str("first"); // exact repeat — should not double up + state.take_prompt(); + state.prompt.insert_str("second"); + state.take_prompt(); + assert_eq!( + state.prompt_history.iter().collect::>(), + vec!["first", "second"] + ); + } + + #[test] + fn take_prompt_ignores_blank() { + let mut state = mk_state(); + state.prompt.insert_str(" "); + assert!(state.take_prompt().is_none()); + assert!(state.prompt_history.is_empty()); + } + + #[test] + fn history_prev_walks_backwards_then_pins_at_oldest() { + let mut state = mk_state(); + for msg in ["one", "two", "three"] { + state.prompt.insert_str(msg); + state.take_prompt(); + } + state.history_prev(); + assert_eq!(prompt_text(&state), "three"); + state.history_prev(); + assert_eq!(prompt_text(&state), "two"); + state.history_prev(); + assert_eq!(prompt_text(&state), "one"); + state.history_prev(); // already oldest — stays put + assert_eq!(prompt_text(&state), "one"); + } + + #[test] + fn history_next_restores_live_draft_past_newest() { + let mut state = mk_state(); + state.prompt.insert_str("sent"); + state.take_prompt(); + // Start a new draft, then browse into history and back out. + state.prompt.insert_str("draft in progress"); + state.history_prev(); + assert_eq!(prompt_text(&state), "sent"); + state.history_next(); + assert_eq!(prompt_text(&state), "draft in progress"); + assert!(state.history_index.is_none()); + } + + #[test] + fn history_next_is_noop_when_not_browsing() { + let mut state = mk_state(); + state.prompt.insert_str("sent"); + state.take_prompt(); + state.prompt.insert_str("live"); + state.history_next(); // not in history — must not touch the draft + assert_eq!(prompt_text(&state), "live"); + } + + #[test] + fn history_prev_noop_without_history() { + let mut state = mk_state(); + state.prompt.insert_str("typing"); + state.history_prev(); + assert_eq!(prompt_text(&state), "typing"); + } + + #[test] + fn history_is_capped() { + let mut state = mk_state(); + for i in 0..(PROMPT_HISTORY_MAX + 5) { + state.prompt.insert_str(format!("msg{i}")); + state.take_prompt(); + } + assert_eq!(state.prompt_history.len(), PROMPT_HISTORY_MAX); + // Oldest entries dropped off the front; newest retained. + assert_eq!(state.prompt_history.back().unwrap(), "msg204"); + assert_eq!(state.prompt_history.front().unwrap(), "msg5"); + } } diff --git a/crates/codeoid-tui/src/ui/worker.rs b/crates/codeoid-tui/src/ui/worker.rs index 7b7760b..b02754c 100644 --- a/crates/codeoid-tui/src/ui/worker.rs +++ b/crates/codeoid-tui/src/ui/worker.rs @@ -31,6 +31,14 @@ pub fn render(frame: &mut Frame<'_>, area: Rect, state: &AppState) { return; } + // An active `@`-file mention swaps the row for a filesystem palette, + // mirroring the command palette. + if let Some(mention) = state.active_mention() { + let palette = build_file_palette(state, &mention); + frame.render_widget(Paragraph::new(palette).alignment(Alignment::Left), area); + return; + } + let line = build_line(state).unwrap_or_else(|| idle_line(state)); // Right side: a subtle scroll position hint. @@ -148,6 +156,65 @@ fn build_palette(state: &AppState) -> Line<'static> { Line::from(spans) } +/// Filesystem palette for an active `@`-mention: the top fuzzy-ranked entries +/// under the session workdir, with directories flagged by their trailing `/`. +fn build_file_palette(state: &AppState, mention: &crate::state::Mention) -> Line<'static> { + const DISPLAY_LIMIT: usize = 5; + let base = state.mention_base_dir(); + let matches = crate::mention::suggest(&base, &mention.query, DISPLAY_LIMIT + 1); + + let mut spans: Vec> = vec![ + Span::raw(" "), + Span::styled( + "@ ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + ]; + + if matches.is_empty() { + spans.push(Span::styled( + format!("no files match “{}”", mention.query), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )); + return Line::from(spans); + } + + let overflow = matches.len() > DISPLAY_LIMIT; + for (i, suggestion) in matches.iter().take(DISPLAY_LIMIT).enumerate() { + if i > 0 { + spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray))); + } + let color = if suggestion.is_dir { + Color::Cyan + } else { + Color::Gray + }; + spans.push(Span::styled( + suggestion.display.clone(), + Style::default().fg(color), + )); + } + if overflow { + spans.push(Span::styled( + " (+more)", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )); + } + spans.push(Span::raw(" ")); + spans.push(Span::styled( + "[Tab] picks", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )); + + Line::from(spans) +} + fn build_line(state: &AppState) -> Option> { let session = state.sessions.focused()?; let msgs = state.messages.messages(&session.id);