diff --git a/Cargo.lock b/Cargo.lock index 519a54f3..e3f7da04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4553,6 +4553,7 @@ dependencies = [ "base64", "crossterm", "futures", + "ignore", "libc", "pulldown-cmark", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index c5307b5d..78ffcc13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ anyhow = "1" thiserror = "2" dirs = "6" glob = "0.3" +ignore = "0.4" regex = "1" uuid = { version = "1", features = ["v4"] } rand = "0.10" diff --git a/crates/seal-cli/src/cli/config.rs b/crates/seal-cli/src/cli/config.rs index 72c77cc7..6753718a 100644 --- a/crates/seal-cli/src/cli/config.rs +++ b/crates/seal-cli/src/cli/config.rs @@ -160,6 +160,8 @@ pub struct TuiConfig { /// Desktop-notification backend selection. See /// `NotificationMethod` for value semantics. pub notifications: NotificationMethod, + /// Respect .gitignore files when building @ path mention suggestions. + pub path_mentions_respect_gitignore: bool, } impl Default for TuiConfig { @@ -168,6 +170,7 @@ impl Default for TuiConfig { copy_on_select: CopyOnSelect::default(), terminal_title: true, notifications: NotificationMethod::default(), + path_mentions_respect_gitignore: true, } } } @@ -306,6 +309,9 @@ impl Config { if let Some(v) = tui.notifications { self.tui.notifications = v; } + if let Some(v) = tui.path_mentions_respect_gitignore { + self.tui.path_mentions_respect_gitignore = v; + } } } } @@ -379,6 +385,7 @@ struct TuiConfigFile { copy_on_select: Option, terminal_title: Option, notifications: Option, + path_mentions_respect_gitignore: Option, } #[cfg(test)] @@ -405,6 +412,33 @@ mod tests { assert!(!config.cli.always_approve); } + #[test] + fn path_mentions_respect_gitignore_defaults_true() { + let dir = tempfile::tempdir().unwrap(); + let config = Config::load_from( + &dir.path().join("nonexistent_global.toml"), + &dir.path().join("nonexistent_local.toml"), + ) + .unwrap(); + assert!(config.tui.path_mentions_respect_gitignore); + } + + #[test] + fn tui_path_mentions_respect_gitignore_parses_and_overrides() { + let gdir = tempfile::tempdir().unwrap(); + let ldir = tempfile::tempdir().unwrap(); + let global = write_config( + gdir.path(), + "[tui]\npath_mentions_respect_gitignore = true\n", + ); + let local = write_config( + ldir.path(), + "[tui]\npath_mentions_respect_gitignore = false\n", + ); + let config = Config::load_from(&global, &local).unwrap(); + assert!(!config.tui.path_mentions_respect_gitignore); + } + #[test] fn global_sets_always_approve() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/seal-cli/src/main.rs b/crates/seal-cli/src/main.rs index 50a9ce70..54a2a839 100644 --- a/crates/seal-cli/src/main.rs +++ b/crates/seal-cli/src/main.rs @@ -893,6 +893,7 @@ fn build_host_context(config: &cli::config::Config) -> seal_tui::chat::HostConte daemon_log_level, copy_on_select_config, terminal_title_enabled: config.tui.terminal_title, + path_mentions_respect_gitignore: config.tui.path_mentions_respect_gitignore, notifications, } } diff --git a/crates/seal-runtime/Cargo.toml b/crates/seal-runtime/Cargo.toml index 3793b7d2..c5089543 100644 --- a/crates/seal-runtime/Cargo.toml +++ b/crates/seal-runtime/Cargo.toml @@ -48,7 +48,7 @@ lru = "0.18" grep-searcher = "0.1" grep-regex = "0.1" grep-printer = "0.3" -ignore = "0.4" +ignore.workspace = true termcolor = "1" dirs.workspace = true thiserror.workspace = true diff --git a/crates/seal-tui/Cargo.toml b/crates/seal-tui/Cargo.toml index 6d89a8ce..0a022ea0 100644 --- a/crates/seal-tui/Cargo.toml +++ b/crates/seal-tui/Cargo.toml @@ -14,6 +14,7 @@ async-trait.workspace = true base64.workspace = true crossterm.workspace = true futures.workspace = true +ignore.workspace = true libc.workspace = true pulldown-cmark.workspace = true ratatui.workspace = true diff --git a/crates/seal-tui/src/app.rs b/crates/seal-tui/src/app.rs index 3501731e..79d9162d 100644 --- a/crates/seal-tui/src/app.rs +++ b/crates/seal-tui/src/app.rs @@ -208,6 +208,8 @@ pub struct ChatState { /// wrapper because dismissing the connected toast on paste /// touches `connection`, not just composer state. pub composer: Composer, + pub path_mention_index: crate::path_mentions::PathMentionIndex, + pub path_mention_popup: crate::path_mentions::PathMentionPopupState, /// Per-turn timing + token state. See [`crate::turn::TurnTracker`] /// for the field-level breakdown. `busy`, `tokens_used`, /// `discard_next_response`, and the start-instant timers all @@ -343,6 +345,8 @@ impl ChatState { Self { transcript: Transcript::new(), composer: Composer::new(), + path_mention_index: crate::path_mentions::PathMentionIndex::default(), + path_mention_popup: crate::path_mentions::PathMentionPopupState::default(), turn: TurnTracker::new(), queued_messages: Vec::new(), session_id: None, @@ -375,6 +379,7 @@ impl ChatState { self.dirty = true; self.connection.dismiss_connected_toast(); self.composer.handle_paste(text); + self.sync_path_mention_popup(); } /// Top-level keystroke entry point. @@ -432,16 +437,110 @@ impl ChatState { return action; } + if let Some(action) = self.handle_path_mention_paste_enter(key) { + return action; + } + + if let Some(action) = self.handle_path_mention_popup_keys(key) { + return action; + } + if let Some(action) = self.handle_cancel_keys(key) { + self.sync_path_mention_popup(); return action; } if let Some(action) = self.handle_reconnect(key) { + self.sync_path_mention_popup(); return action; } if let Some(action) = self.handle_tool_toggle(key) { + self.sync_path_mention_popup(); return action; } - self.handle_composer_keys(key) + let action = self.handle_composer_keys(key); + self.sync_path_mention_popup(); + action + } + + pub fn set_path_mention_index(&mut self, index: crate::path_mentions::PathMentionIndex) { + self.path_mention_index = index; + self.sync_path_mention_popup(); + self.dirty = true; + } + + fn sync_path_mention_popup(&mut self) { + self.path_mention_popup.sync( + &self.composer.text, + self.composer.cursor, + &self.path_mention_index, + ); + } + + fn handle_path_mention_paste_enter(&mut self, key: KeyEvent) -> Option { + if !self.path_mention_popup.is_visible() || !Self::is_plain_enter(key) { + return None; + } + self.composer.tick_paste_fsm(); + if !self.composer.paste_mode { + return None; + } + self.composer.insert_char('\n'); + self.sync_path_mention_popup(); + Some(Action::None) + } + + fn is_plain_enter(key: KeyEvent) -> bool { + key.code == KeyCode::Enter + && !key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::SHIFT) + } + + fn handle_path_mention_popup_keys(&mut self, key: KeyEvent) -> Option { + if !self.path_mention_popup.is_visible() { + return None; + } + match key.code { + KeyCode::Esc => { + self.path_mention_popup.dismiss_current(); + self.dirty = true; + Some(Action::None) + } + KeyCode::Up => { + self.path_mention_popup + .select_previous(&self.path_mention_index); + self.dirty = true; + Some(Action::None) + } + KeyCode::Down => { + self.path_mention_popup + .select_next(&self.path_mention_index); + self.dirty = true; + Some(Action::None) + } + KeyCode::Enter if Self::is_plain_enter(key) => { + self.insert_selected_path_mention(); + Some(Action::None) + } + KeyCode::Tab => { + self.insert_selected_path_mention(); + Some(Action::None) + } + _ => None, + } + } + + fn insert_selected_path_mention(&mut self) { + let Some(active) = self.path_mention_popup.active.clone() else { + return; + }; + let Some(entry) = self.path_mention_popup.selected_entry().cloned() else { + return; + }; + let replacement = format!("{} ", entry.insertion_text()); + self.composer.replace_range(active.range, &replacement); + self.path_mention_popup.clear_dismissed(); + self.sync_path_mention_popup(); + self.dirty = true; } /// SEA-612 / SEA-728: cmd+c / ctrl+shift+c / ctrl+y commit an @@ -1423,6 +1522,100 @@ mod tests { KeyEvent::new(code, KeyModifiers::CONTROL) } + fn path_index( + entries: Vec, + ) -> crate::path_mentions::PathMentionIndex { + crate::path_mentions::PathMentionIndex::from_entries(entries) + } + + fn path_entry( + path: &str, + kind: crate::path_mentions::PathMentionKind, + ) -> crate::path_mentions::PathMentionEntry { + crate::path_mentions::PathMentionEntry { + path: path.to_string(), + kind, + } + } + + #[test] + fn path_mention_popup_opens_for_matching_prefix() { + let mut app = ready_app(); + app.set_path_mention_index(path_index(vec![path_entry( + "foo/bar.rs", + crate::path_mentions::PathMentionKind::File, + )])); + for c in "look @foo/ba".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + assert!(app.path_mention_popup.is_visible()); + assert_eq!(app.path_mention_popup.matches[0].path, "foo/bar.rs"); + } + + #[test] + fn path_mention_popup_hides_without_matches() { + let mut app = ready_app(); + app.set_path_mention_index(path_index(vec![path_entry( + "foo/bar.rs", + crate::path_mentions::PathMentionKind::File, + )])); + for c in "look @nope".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + assert!(!app.path_mention_popup.is_visible()); + } + + #[test] + fn path_mention_enter_inserts_selected_path() { + let mut app = ready_app(); + app.set_path_mention_index(path_index(vec![ + path_entry("foo", crate::path_mentions::PathMentionKind::Directory), + path_entry("foo/bar.rs", crate::path_mentions::PathMentionKind::File), + ])); + for c in "look @foo/ba".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.composer.text, "look foo/bar.rs "); + } + + #[test] + fn path_mention_enter_in_paste_mode_inserts_newline() { + let mut app = ready_app(); + app.set_path_mention_index(path_index(vec![ + path_entry("foo", crate::path_mentions::PathMentionKind::Directory), + path_entry("foo/bar.rs", crate::path_mentions::PathMentionKind::File), + ])); + for c in "look @foo/ba".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.composer.paste_mode = true; + assert!(app.path_mention_popup.is_visible()); + + let action = app.handle_key(key(KeyCode::Enter)); + + assert_eq!(action, Action::None); + assert_eq!(app.composer.text, "look @foo/ba\n"); + } + + #[test] + fn path_mention_popup_resyncs_after_paste() { + let mut app = ready_app(); + app.set_path_mention_index(path_index(vec![path_entry( + "foo/bar.rs", + crate::path_mentions::PathMentionKind::File, + )])); + for c in "look @foo/ba".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + assert!(app.path_mention_popup.is_visible()); + + app.handle_paste("z"); + + assert_eq!(app.composer.text, "look @foo/baz"); + assert!(!app.path_mention_popup.is_visible()); + } + #[test] fn typing_characters() { let mut app = ChatState::new(); diff --git a/crates/seal-tui/src/chat/host.rs b/crates/seal-tui/src/chat/host.rs index 3a8b4d54..0d48637a 100644 --- a/crates/seal-tui/src/chat/host.rs +++ b/crates/seal-tui/src/chat/host.rs @@ -72,6 +72,8 @@ pub struct HostContext { /// SEA-727: `[tui] terminal_title` — write OSC 0 title /// updates while the chat runs. Default true. pub terminal_title_enabled: bool, + /// SEA-83: whether @ path mention indexing respects .gitignore. + pub path_mentions_respect_gitignore: bool, /// SEA-727: `[tui] notifications` — desktop-notification /// backend selection. Mirrors /// `seal-cli::cli::config::NotificationMethod` shape. @@ -146,6 +148,7 @@ impl HostContext { daemon_log_level: None, copy_on_select_config: CopyOnSelectConfig::Off, terminal_title_enabled: true, + path_mentions_respect_gitignore: true, notifications: NotificationMethodConfig::Auto, } } diff --git a/crates/seal-tui/src/chat/runloop.rs b/crates/seal-tui/src/chat/runloop.rs index c09fa68c..9d083d37 100644 --- a/crates/seal-tui/src/chat/runloop.rs +++ b/crates/seal-tui/src/chat/runloop.rs @@ -22,6 +22,8 @@ use super::startup::draw_splash; use super::tick_timing::{RenderIoMeter, StageTimer, record_render_stall}; use crate::chat::event_source::EventSource; +const PATH_MENTION_REFRESH_INTERVAL: Duration = Duration::from_secs(30); + /// Outcome from the main event loop. pub enum RunLoopOutcome { Quit, @@ -156,6 +158,32 @@ fn short_session_id(id: &str) -> String { id.chars().take(8).collect::() } +enum PathMentionIndexBuildResult { + Built(crate::path_mentions::PathMentionIndex), + Failed, +} + +fn spawn_path_mention_index( + project_root: std::path::PathBuf, + respect_gitignore: bool, + tx: tokio::sync::mpsc::UnboundedSender, +) { + seal_utils::guarded::spawn_guarded("tui_path_mentions_index", async move { + let result = tokio::task::spawn_blocking(move || { + crate::path_mentions::PathMentionIndex::build(&project_root, respect_gitignore) + }) + .await; + let message = match result { + Ok(index) => PathMentionIndexBuildResult::Built(index), + Err(err) => { + warn!(error = %err, "path mention index build failed"); + PathMentionIndexBuildResult::Failed + } + }; + let _ = tx.send(message); + }); +} + /// Main TUI event loop. Multiplexes terminal input, RPC streaming events, /// turn completions, and the async connection handshake on a multi-thread /// tokio runtime (single worker, since the TUI's own work is dwarfed by @@ -397,6 +425,16 @@ where // status line, surface RPC errors). SEA-172. let (slash_tx, mut slash_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (path_index_tx, mut path_index_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let mut path_index_building = true; + let mut last_path_index_completion = std::time::Instant::now(); + spawn_path_mention_index( + project_root.to_path_buf(), + host.path_mentions_respect_gitignore, + path_index_tx.clone(), + ); + // Clone of the last drawn buffer, refreshed after each // `terminal.draw`. Mouse handlers (word/line bounds, drag-to- // scroll, drag extension) need read access to the rendered @@ -488,6 +526,25 @@ where } } + while let Ok(result) = path_index_rx.try_recv() { + path_index_building = false; + last_path_index_completion = now; + match result { + PathMentionIndexBuildResult::Built(index) => app.set_path_mention_index(index), + PathMentionIndexBuildResult::Failed => {} + } + } + if !path_index_building + && now.duration_since(last_path_index_completion) >= PATH_MENTION_REFRESH_INTERVAL + { + path_index_building = true; + spawn_path_mention_index( + project_root.to_path_buf(), + host.path_mentions_respect_gitignore, + path_index_tx.clone(), + ); + } + if app.is_dirty() { // SEA-873: time the render stage. `terminal.draw` // builds the frame (CPU) AND writes the diff to stdout diff --git a/crates/seal-tui/src/composer.rs b/crates/seal-tui/src/composer.rs index b6f050ab..4ff6b763 100644 --- a/crates/seal-tui/src/composer.rs +++ b/crates/seal-tui/src/composer.rs @@ -196,6 +196,12 @@ impl Composer { self.dirty = true; } + pub fn replace_range(&mut self, range: std::ops::Range, replacement: &str) { + self.text.replace_range(range.clone(), replacement); + self.cursor = range.start + replacement.len(); + self.dirty = true; + } + /// Delete the char before the cursor (`Backspace`). No-op at /// the start of the buffer. /// diff --git a/crates/seal-tui/src/lib.rs b/crates/seal-tui/src/lib.rs index 5c989f1a..67eea7dd 100644 --- a/crates/seal-tui/src/lib.rs +++ b/crates/seal-tui/src/lib.rs @@ -9,6 +9,7 @@ pub mod connection; pub mod history; pub mod markdown; pub mod nav_intent; +pub mod path_mentions; pub mod permission_prompt; pub mod permission_queue; pub mod raw_mode; diff --git a/crates/seal-tui/src/path_mentions.rs b/crates/seal-tui/src/path_mentions.rs new file mode 100644 index 00000000..30024164 --- /dev/null +++ b/crates/seal-tui/src/path_mentions.rs @@ -0,0 +1,768 @@ +use std::ops::Range; +use std::path::Path; + +pub const PATH_MENTION_LIMIT: usize = 20; +pub const PATH_MENTION_MENU_HEIGHT: u16 = PATH_MENTION_LIMIT as u16 + 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathMentionKind { + File, + Directory, +} + +impl PathMentionKind { + pub fn label(self) -> &'static str { + match self { + Self::File => "File", + Self::Directory => "Dir", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathMentionEntry { + pub path: String, + pub kind: PathMentionKind, +} + +impl PathMentionEntry { + pub fn display_path(&self) -> String { + match self.kind { + PathMentionKind::File => self.path.clone(), + PathMentionKind::Directory => format!("{}/", self.path), + } + } + + pub fn insertion_text(&self) -> String { + self.display_path() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActivePathMention { + pub range: Range, + pub query: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PathMentionQueryResult { + pub matches: Vec, + pub total_count: usize, + pub overflow_count: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PathMentionIndex { + entries: Vec, +} + +impl PathMentionIndex { + pub fn build(root: &Path, respect_gitignore: bool) -> Self { + let mut entries = Vec::new(); + walk_dir(root, respect_gitignore, &mut entries); + entries.sort_by(|a, b| { + a.path + .cmp(&b.path) + .then_with(|| kind_order(a.kind).cmp(&kind_order(b.kind))) + }); + entries.dedup_by(|a, b| a.path == b.path && a.kind == b.kind); + Self { entries } + } + + pub fn from_entries(mut entries: Vec) -> Self { + entries.sort_by(|a, b| { + a.path + .cmp(&b.path) + .then_with(|| kind_order(a.kind).cmp(&kind_order(b.kind))) + }); + Self { entries } + } + + pub fn query(&self, query: &str, limit: usize) -> Vec { + self.query_window(query, 0, limit).matches + } + + pub fn query_with_overflow(&self, query: &str, limit: usize) -> PathMentionQueryResult { + self.query_window(query, 0, limit) + } + + pub fn query_window(&self, query: &str, offset: usize, limit: usize) -> PathMentionQueryResult { + if limit == 0 { + return PathMentionQueryResult::default(); + } + let range = self.prefix_range(query); + let level_prefix = query_level_prefix(query); + let mut bucket_totals = [0usize; 4]; + for entry in &self.entries[range.clone()] { + bucket_totals[query_bucket(entry, level_prefix)] += 1; + } + let total_count = bucket_totals.iter().sum::(); + if offset >= total_count { + return PathMentionQueryResult { + matches: Vec::new(), + total_count, + overflow_count: 0, + }; + } + + let mut matches = Vec::with_capacity(limit); + let mut skipped = 0usize; + 'buckets: for bucket in 0..4 { + for entry in &self.entries[range.clone()] { + if query_bucket(entry, level_prefix) != bucket { + continue; + } + if skipped < offset { + skipped += 1; + continue; + } + matches.push(entry.clone()); + if matches.len() == limit { + break 'buckets; + } + } + } + let overflow_count = total_count.saturating_sub(offset + matches.len()); + PathMentionQueryResult { + matches, + total_count, + overflow_count, + } + } + + fn prefix_range(&self, query: &str) -> std::ops::Range { + if query.is_empty() { + return 0..self.entries.len(); + } + let start = self + .entries + .partition_point(|entry| entry.path.as_str() < query); + let end = start + + self.entries[start..] + .iter() + .take_while(|entry| entry.path.starts_with(query)) + .count(); + start..end + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +fn query_bucket(entry: &PathMentionEntry, level_prefix: &str) -> usize { + match (is_same_level(&entry.path, level_prefix), entry.kind) { + (true, PathMentionKind::Directory) => 0, + (true, PathMentionKind::File) => 1, + (false, PathMentionKind::Directory) => 2, + (false, PathMentionKind::File) => 3, + } +} + +fn query_level_prefix(query: &str) -> &str { + query.rfind('/').map(|idx| &query[..=idx]).unwrap_or("") +} + +fn is_same_level(path: &str, level_prefix: &str) -> bool { + path.strip_prefix(level_prefix) + .is_some_and(|rest| !rest.contains('/')) +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PathMentionPopupState { + pub active: Option, + pub matches: Vec, + pub total_count: usize, + pub overflow_count: usize, + pub selected: usize, + window_start: usize, + dismissed: Option, +} + +impl PathMentionPopupState { + pub fn is_visible(&self) -> bool { + self.active.is_some() && !self.matches.is_empty() + } + + pub fn sync(&mut self, input: &str, cursor: usize, index: &PathMentionIndex) { + let Some(active) = active_path_mention(input, cursor) else { + self.active = None; + self.matches.clear(); + self.total_count = 0; + self.overflow_count = 0; + self.selected = 0; + self.window_start = 0; + return; + }; + if self + .dismissed + .as_ref() + .is_some_and(|dismissed| dismissed.matches(&active)) + { + self.active = Some(active); + self.matches.clear(); + self.total_count = 0; + self.overflow_count = 0; + self.selected = 0; + self.window_start = 0; + return; + } + self.dismissed = None; + let same_active = self.active.as_ref() == Some(&active); + if !same_active { + self.selected = 0; + self.window_start = 0; + } + self.active = Some(active); + self.refresh_matches(index); + } + + pub fn select_next(&mut self, index: &PathMentionIndex) { + if self.matches.is_empty() { + return; + } + if self.selected + 1 < self.matches.len() { + self.selected += 1; + return; + } + if self.window_start + self.matches.len() < self.total_count { + self.window_start += 1; + self.refresh_matches(index); + self.selected = self.matches.len().saturating_sub(1); + } + } + + pub fn select_previous(&mut self, index: &PathMentionIndex) { + if self.matches.is_empty() { + return; + } + if self.selected > 0 { + self.selected -= 1; + return; + } + if self.window_start > 0 { + self.window_start -= 1; + self.refresh_matches(index); + self.selected = 0; + } + } + + pub fn selected_entry(&self) -> Option<&PathMentionEntry> { + self.matches.get(self.selected) + } + + pub fn render_window(&self, item_rows: usize) -> (&[PathMentionEntry], usize) { + if item_rows == 0 || self.matches.is_empty() { + return (&[], 0); + } + let selected = self.selected.min(self.matches.len() - 1); + let start = if selected < item_rows { + 0 + } else { + selected + 1 - item_rows + }; + let end = (start + item_rows).min(self.matches.len()); + (&self.matches[start..end], selected - start) + } + + fn refresh_matches(&mut self, index: &PathMentionIndex) { + let Some(active) = self.active.as_ref() else { + return; + }; + let mut result = index.query_window(&active.query, self.window_start, PATH_MENTION_LIMIT); + if result.matches.is_empty() && result.total_count > 0 && self.window_start > 0 { + self.window_start = 0; + result = index.query_window(&active.query, self.window_start, PATH_MENTION_LIMIT); + } + self.total_count = result.total_count; + self.matches = result.matches; + if self.matches.is_empty() { + self.selected = 0; + self.window_start = 0; + self.overflow_count = 0; + return; + } + self.selected = self.selected.min(self.matches.len() - 1); + self.overflow_count = result.overflow_count; + } + + pub fn dismiss_current(&mut self) { + self.dismissed = self.active.as_ref().map(DismissedMention::from); + self.matches.clear(); + self.total_count = 0; + self.overflow_count = 0; + self.selected = 0; + self.window_start = 0; + } + + pub fn clear_dismissed(&mut self) { + self.dismissed = None; + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DismissedMention { + range: Range, + query: String, +} + +impl DismissedMention { + fn matches(&self, active: &ActivePathMention) -> bool { + self.range == active.range && self.query == active.query + } +} + +impl From<&ActivePathMention> for DismissedMention { + fn from(value: &ActivePathMention) -> Self { + Self { + range: value.range.clone(), + query: value.query.clone(), + } + } +} + +pub fn active_path_mention(input: &str, cursor: usize) -> Option { + if cursor > input.len() || !input.is_char_boundary(cursor) { + return None; + } + let token_start = input[..cursor] + .char_indices() + .rev() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + let token_end = input[cursor..] + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| cursor + idx) + .unwrap_or(input.len()); + if token_start == token_end { + return None; + } + let left = &input[token_start..cursor]; + let at_rel = left.rfind('@')?; + let start = token_start + at_rel; + Some(ActivePathMention { + range: start..token_end, + query: input[start + 1..token_end].to_string(), + }) +} + +fn walk_dir(root: &Path, respect_gitignore: bool, entries: &mut Vec) { + let mut builder = ignore::WalkBuilder::new(root); + builder + .follow_links(false) + .require_git(false) + .hidden(false) + .ignore(false) + .git_ignore(respect_gitignore) + .git_global(false) + .git_exclude(false) + .parents(false) + .filter_entry(|entry| entry.depth() == 0 || !is_vcs_metadata_entry(entry)); + + for entry in builder.build().flatten() { + if entry.depth() == 0 { + continue; + } + let Some(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() { + continue; + } + let Some(path) = relative_display_path(root, entry.path()) else { + continue; + }; + let kind = if file_type.is_dir() { + PathMentionKind::Directory + } else if file_type.is_file() { + PathMentionKind::File + } else { + continue; + }; + entries.push(PathMentionEntry { path, kind }); + } +} + +fn is_vcs_metadata_entry(entry: &ignore::DirEntry) -> bool { + entry + .file_type() + .is_some_and(|file_type| file_type.is_dir()) + && entry.file_name().to_str().is_some_and(is_vcs_metadata_dir) +} + +fn is_vcs_metadata_dir(name: &str) -> bool { + matches!(name, ".git" | ".hg" | ".svn") +} + +fn relative_display_path(root: &Path, path: &Path) -> Option { + let rel = path.strip_prefix(root).ok()?; + let parts = rel + .iter() + .map(|part| part.to_string_lossy().into_owned()) + .collect::>(); + if parts.is_empty() { + None + } else { + Some(parts.join("/")) + } +} + +fn kind_order(kind: PathMentionKind) -> u8 { + match kind { + PathMentionKind::Directory => 0, + PathMentionKind::File => 1, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(path: &str, kind: PathMentionKind) -> PathMentionEntry { + PathMentionEntry { + path: path.to_string(), + kind, + } + } + + #[test] + fn active_mention_detects_bare_at() { + assert_eq!( + active_path_mention("look @", "look @".len()), + Some(ActivePathMention { + range: 5..6, + query: String::new(), + }) + ); + } + + #[test] + fn active_mention_detects_path_prefix() { + assert_eq!( + active_path_mention("look up @foo/ba", "look up @foo/ba".len()), + Some(ActivePathMention { + range: 8..15, + query: "foo/ba".to_string(), + }) + ); + } + + #[test] + fn active_mention_uses_last_at_in_token() { + assert_eq!( + active_path_mention("see @one @two", "see @one @two".len()), + Some(ActivePathMention { + range: 9..13, + query: "two".to_string(), + }) + ); + } + + #[test] + fn active_mention_includes_token_end_when_cursor_is_inside_token() { + let input = "look @foo/bar"; + assert_eq!( + active_path_mention(input, "look @foo".len()), + Some(ActivePathMention { + range: 5..13, + query: "foo/bar".to_string(), + }) + ); + } + + #[test] + fn no_active_mention_without_at_token() { + assert_eq!(active_path_mention("look foo", "look foo".len()), None); + assert_eq!(active_path_mention("look @foo ", "look @foo ".len()), None); + } + + #[test] + fn query_empty_returns_same_level_directories_then_files() { + let index = PathMentionIndex::from_entries(vec![ + entry("zeta.rs", PathMentionKind::File), + entry("alpha", PathMentionKind::Directory), + entry("alpha/main.rs", PathMentionKind::File), + ]); + assert_eq!( + index.query("", 2), + vec![ + entry("alpha", PathMentionKind::Directory), + entry("zeta.rs", PathMentionKind::File), + ] + ); + } + + #[test] + fn query_prioritizes_same_level_directories_before_descendants() { + let index = PathMentionIndex::from_entries(vec![ + entry(".buildkite", PathMentionKind::Directory), + entry(".buildkite/README.md", PathMentionKind::File), + entry("crates", PathMentionKind::Directory), + entry("docs", PathMentionKind::Directory), + entry("README.md", PathMentionKind::File), + ]); + assert_eq!( + index.query("", 5), + vec![ + entry(".buildkite", PathMentionKind::Directory), + entry("crates", PathMentionKind::Directory), + entry("docs", PathMentionKind::Directory), + entry("README.md", PathMentionKind::File), + entry(".buildkite/README.md", PathMentionKind::File), + ] + ); + } + + #[test] + fn query_steps_into_directory_level_after_slash() { + let index = PathMentionIndex::from_entries(vec![ + entry("foo", PathMentionKind::Directory), + entry("foo/bar", PathMentionKind::Directory), + entry("foo/bar/baz.rs", PathMentionKind::File), + entry("foo/beta.rs", PathMentionKind::File), + entry("foo/zeta", PathMentionKind::Directory), + ]); + assert_eq!( + index.query("foo/", 10), + vec![ + entry("foo/bar", PathMentionKind::Directory), + entry("foo/zeta", PathMentionKind::Directory), + entry("foo/beta.rs", PathMentionKind::File), + entry("foo/bar/baz.rs", PathMentionKind::File), + ] + ); + } + + #[test] + fn query_with_overflow_reports_remaining_matches() { + let entries = (0..25) + .map(|idx| entry(&format!("dir-{idx:02}"), PathMentionKind::Directory)) + .collect::>(); + let index = PathMentionIndex::from_entries(entries); + let result = index.query_with_overflow("", PATH_MENTION_LIMIT); + assert_eq!(result.matches.len(), PATH_MENTION_LIMIT); + assert_eq!(result.overflow_count, 5); + } + + #[test] + fn popup_down_scrolls_beyond_first_window() { + let entries = (0..25) + .map(|idx| entry(&format!("dir-{idx:02}"), PathMentionKind::Directory)) + .collect::>(); + let index = PathMentionIndex::from_entries(entries); + let mut popup = PathMentionPopupState::default(); + popup.sync("@", 1, &index); + assert_eq!(popup.matches.len(), PATH_MENTION_LIMIT); + assert_eq!(popup.matches[PATH_MENTION_LIMIT - 1].path, "dir-19"); + assert_eq!(popup.overflow_count, 5); + + for _ in 0..PATH_MENTION_LIMIT { + popup.select_next(&index); + } + assert_eq!(popup.selected_entry().unwrap().path, "dir-20"); + assert_eq!(popup.matches[PATH_MENTION_LIMIT - 1].path, "dir-20"); + assert_eq!(popup.overflow_count, 4); + + popup.select_previous(&index); + assert_eq!(popup.selected_entry().unwrap().path, "dir-19"); + assert_eq!(popup.matches[0].path, "dir-01"); + + for _ in 0..PATH_MENTION_LIMIT { + popup.select_previous(&index); + } + assert_eq!(popup.selected_entry().unwrap().path, "dir-00"); + assert_eq!(popup.matches[0].path, "dir-00"); + } + + #[test] + fn popup_render_window_keeps_selected_visible_when_area_is_short() { + let entries = (0..10) + .map(|idx| entry(&format!("dir-{idx:02}"), PathMentionKind::Directory)) + .collect::>(); + let index = PathMentionIndex::from_entries(entries); + let mut popup = PathMentionPopupState::default(); + popup.sync("@", 1, &index); + + for _ in 0..8 { + popup.select_next(&index); + } + + let (visible, selected_row) = popup.render_window(5); + assert_eq!(selected_row, 4); + assert_eq!(visible[0].path, "dir-04"); + assert_eq!(visible[4].path, "dir-08"); + assert_eq!( + visible[selected_row].path, + popup.selected_entry().unwrap().path + ); + } + + #[test] + fn popup_render_window_handles_empty_area() { + let index = + PathMentionIndex::from_entries(vec![entry("dir-00", PathMentionKind::Directory)]); + let mut popup = PathMentionPopupState::default(); + popup.sync("@", 1, &index); + + let (visible, selected_row) = popup.render_window(0); + + assert!(visible.is_empty()); + assert_eq!(selected_row, 0); + } + + #[test] + fn popup_requeries_when_scrolled_window_exceeds_rebuilt_index() { + let large_entries = (0..25) + .map(|idx| entry(&format!("dir-{idx:02}"), PathMentionKind::Directory)) + .collect::>(); + let large_index = PathMentionIndex::from_entries(large_entries); + let mut popup = PathMentionPopupState::default(); + popup.sync("@", 1, &large_index); + for _ in 0..24 { + popup.select_next(&large_index); + } + assert_eq!(popup.selected_entry().unwrap().path, "dir-24"); + + let small_entries = (0..5) + .map(|idx| entry(&format!("dir-{idx:02}"), PathMentionKind::Directory)) + .collect::>(); + let small_index = PathMentionIndex::from_entries(small_entries); + popup.sync("@", 1, &small_index); + + assert!(popup.is_visible()); + assert_eq!(popup.window_start, 0); + assert_eq!(popup.matches.len(), 5); + assert_eq!(popup.matches[0].path, "dir-00"); + assert_eq!(popup.selected_entry().unwrap().path, "dir-04"); + assert_eq!(popup.overflow_count, 0); + } + + #[test] + fn query_prefix_uses_starts_with_only() { + let index = PathMentionIndex::from_entries(vec![ + entry("foo/bar.rs", PathMentionKind::File), + entry("foo/baz.rs", PathMentionKind::File), + entry("food.rs", PathMentionKind::File), + ]); + assert_eq!( + index.query("foo/ba", 10), + vec![ + entry("foo/bar.rs", PathMentionKind::File), + entry("foo/baz.rs", PathMentionKind::File), + ] + ); + } + + #[test] + fn query_no_matches_is_empty() { + let index = PathMentionIndex::from_entries(vec![entry("foo.rs", PathMentionKind::File)]); + assert!(index.query("bar", 10).is_empty()); + } + + #[test] + fn build_respects_gitignore_by_default() { + let dir = tempfile::tempdir().unwrap(); + seal_utils::io::write(&dir.path().join(".gitignore"), b"target/\n").unwrap(); + seal_utils::io::ensure_dir(&dir.path().join("src")).unwrap(); + seal_utils::io::write(&dir.path().join("src/main.rs"), b"").unwrap(); + seal_utils::io::ensure_dir(&dir.path().join("target")).unwrap(); + seal_utils::io::write(&dir.path().join("target/debug.log"), b"").unwrap(); + + let respected = PathMentionIndex::build(dir.path(), true); + assert_eq!( + respected.query("target", 10), + Vec::::new() + ); + assert_eq!( + respected.query("src", 10), + vec![ + entry("src", PathMentionKind::Directory), + entry("src/main.rs", PathMentionKind::File), + ] + ); + + let ignored = PathMentionIndex::build(dir.path(), false); + assert_eq!( + ignored.query("target", 10), + vec![ + entry("target", PathMentionKind::Directory), + entry("target/debug.log", PathMentionKind::File), + ] + ); + } + + #[test] + fn build_skips_vcs_metadata_directories() { + let dir = tempfile::tempdir().unwrap(); + for vcs in [".git", ".hg", ".svn"] { + seal_utils::io::ensure_dir(&dir.path().join(vcs).join("objects")).unwrap(); + seal_utils::io::write(&dir.path().join(vcs).join("objects/file"), b"").unwrap(); + } + seal_utils::io::write(&dir.path().join("main.rs"), b"").unwrap(); + + let index = PathMentionIndex::build(dir.path(), false); + assert_eq!( + index.query("", 10), + vec![entry("main.rs", PathMentionKind::File)] + ); + assert_eq!(index.query(".git", 10), Vec::::new()); + assert_eq!(index.query(".hg", 10), Vec::::new()); + assert_eq!(index.query(".svn", 10), Vec::::new()); + } + + #[test] + fn gitignore_rule_ignores_directory_by_name() { + let dir = tempfile::tempdir().unwrap(); + seal_utils::io::write(&dir.path().join(".gitignore"), b"target/\n").unwrap(); + seal_utils::io::ensure_dir(&dir.path().join("nested/target")).unwrap(); + seal_utils::io::write(&dir.path().join("nested/target/debug.log"), b"").unwrap(); + seal_utils::io::write(&dir.path().join("main.rs"), b"").unwrap(); + + let index = PathMentionIndex::build(dir.path(), true); + + assert_eq!(index.query("target", 10), Vec::::new()); + assert_eq!( + index.query("main", 10), + vec![entry("main.rs", PathMentionKind::File)] + ); + } + + #[test] + fn gitignore_negation_reincludes_path() { + let dir = tempfile::tempdir().unwrap(); + seal_utils::io::write(&dir.path().join(".gitignore"), b"*.log\n!keep.log\n").unwrap(); + seal_utils::io::write(&dir.path().join("tmp.log"), b"").unwrap(); + seal_utils::io::write(&dir.path().join("keep.log"), b"").unwrap(); + + let index = PathMentionIndex::build(dir.path(), true); + + assert_eq!(index.query("tmp", 10), Vec::::new()); + assert_eq!( + index.query("keep", 10), + vec![entry("keep.log", PathMentionKind::File)] + ); + } + + #[test] + fn gitignore_character_classes_exclude_paths() { + let dir = tempfile::tempdir().unwrap(); + seal_utils::io::write(&dir.path().join(".gitignore"), b"[Bb]uild/\n").unwrap(); + seal_utils::io::ensure_dir(&dir.path().join("Build")).unwrap(); + seal_utils::io::write(&dir.path().join("Build/secret.txt"), b"").unwrap(); + seal_utils::io::ensure_dir(&dir.path().join("src")).unwrap(); + seal_utils::io::write(&dir.path().join("src/main.rs"), b"").unwrap(); + + let index = PathMentionIndex::build(dir.path(), true); + + assert_eq!(index.query("Build", 10), Vec::::new()); + assert_eq!( + index.query("src", 10), + vec![ + entry("src", PathMentionKind::Directory), + entry("src/main.rs", PathMentionKind::File), + ] + ); + } +} diff --git a/crates/seal-tui/src/renderer.rs b/crates/seal-tui/src/renderer.rs index 5e6a6222..2572aaa0 100644 --- a/crates/seal-tui/src/renderer.rs +++ b/crates/seal-tui/src/renderer.rs @@ -14,6 +14,7 @@ use ratatui::{ text::{Line, Span, Text}, widgets::{Block, Borders, Paragraph}, }; +use unicode_width::UnicodeWidthStr; /// Where the chat renderer is being used — controls status bar hints. #[derive(Clone, Copy, PartialEq, Default)] @@ -144,6 +145,56 @@ fn render_slash_command_suggestions( frame.render_widget(Paragraph::new(Text::from(lines)), area); } +fn render_path_mention_suggestions( + frame: &mut Frame, + area: Rect, + popup: &crate::path_mentions::PathMentionPopupState, +) { + let item_rows = area.height.saturating_sub(2) as usize; + let (visible_matches, selected_row) = popup.render_window(item_rows); + let name_width = visible_matches + .iter() + .map(|entry| UnicodeWidthStr::width(entry.display_path().as_str())) + .max() + .unwrap_or(0); + let mut lines = Vec::with_capacity(area.height as usize); + lines.push(Line::from(vec![Span::styled( + "─".repeat(area.width as usize), + Style::default().fg(colors::DIM), + )])); + lines.extend(visible_matches.iter().enumerate().map(|(idx, entry)| { + let display = entry.display_path(); + let display_width = UnicodeWidthStr::width(display.as_str()); + let padding = " ".repeat(name_width.saturating_sub(display_width) + 2); + let style = if idx == selected_row { + Style::default() + .fg(colors::GOLD) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(colors::GOLD) + }; + Line::from(vec![ + Span::raw(" "), + Span::styled(display, style), + Span::raw(padding), + Span::styled(entry.kind.label(), Style::default().fg(colors::EXTRA_DIM)), + ]) + })); + while lines.len() + 1 < area.height as usize { + lines.push(Line::default()); + } + if popup.overflow_count > 0 && lines.len() < area.height as usize { + lines.push(Line::from(vec![Span::styled( + format!(" and {} other matches...", popup.overflow_count), + Style::default().fg(colors::DIM), + )])); + } + while lines.len() < area.height as usize { + lines.push(Line::default()); + } + frame.render_widget(Paragraph::new(Text::from(lines)), area); +} + /// Render the full TUI layout: conversation pane, input box, and status bar. /// /// The conversation pane auto-scrolls to the bottom unless the user has scrolled up. @@ -226,12 +277,22 @@ pub fn draw_with_context(frame: &mut Frame, app: &mut ChatState, context: ChatCo // the renderer publishes; layout reserves a single row only when // there's something to surface. let show_unread_banner = render::unread_banner::is_visible(app.view.unread_rows_below); - let slash_suggestions = - crate::slash_commands::slash_command_suggestions(&app.composer.text, app.composer.cursor); - let show_slash_suggestions = crate::slash_commands::slash_command_suggestions_open( - &app.composer.text, - app.composer.cursor, - ); + let show_path_mention_suggestions = app.path_mention_popup.is_visible(); + let slash_suggestions = if show_path_mention_suggestions { + Vec::new() + } else { + crate::slash_commands::slash_command_suggestions(&app.composer.text, app.composer.cursor) + }; + let show_slash_suggestions = !show_path_mention_suggestions + && crate::slash_commands::slash_command_suggestions_open( + &app.composer.text, + app.composer.cursor, + ); + let path_mention_suggestions_height = if show_path_mention_suggestions { + crate::path_mentions::PATH_MENTION_MENU_HEIGHT + } else { + 0 + }; let slash_suggestions_height = if show_slash_suggestions { crate::slash_commands::SlashCommand::registry().len() as u16 + 1 } else { @@ -248,7 +309,9 @@ pub fn draw_with_context(frame: &mut Frame, app: &mut ChatState, context: ChatCo if show_sticky_tasks { constraints.push(Constraint::Length(2)); } - if show_slash_suggestions { + if show_path_mention_suggestions { + constraints.push(Constraint::Length(path_mention_suggestions_height)); + } else if show_slash_suggestions { constraints.push(Constraint::Length(slash_suggestions_height)); } constraints.push(Constraint::Length(input_height)); // separator + input + separator @@ -792,12 +855,12 @@ pub fn draw_with_context(frame: &mut Frame, app: &mut ChatState, context: ChatCo let unread_banner_idx: Option = show_unread_banner.then_some(1); let sticky_tasks_idx: Option = show_sticky_tasks.then_some(1 + usize::from(show_unread_banner)); - let slash_suggestions_idx: Option = show_slash_suggestions + let suggestion_idx: Option = (show_path_mention_suggestions || show_slash_suggestions) .then_some(1 + usize::from(show_unread_banner) + usize::from(show_sticky_tasks)); let input_idx = 1 + usize::from(show_unread_banner) + usize::from(show_sticky_tasks) - + usize::from(show_slash_suggestions); + + usize::from(show_path_mention_suggestions || show_slash_suggestions); let build_row_idx: Option = show_build_row.then_some(input_idx + 1); let agent_row_idx: Option = show_agent_row.then_some(input_idx + 2); let status_bar_idx = input_idx + 1 + usize::from(show_build_row) + usize::from(show_agent_row); @@ -942,8 +1005,12 @@ pub fn draw_with_context(frame: &mut Frame, app: &mut ChatState, context: ChatCo ); } - if let Some(idx) = slash_suggestions_idx { - render_slash_command_suggestions(frame, chunks[idx], &slash_suggestions); + if let Some(idx) = suggestion_idx { + if show_path_mention_suggestions { + render_path_mention_suggestions(frame, chunks[idx], &app.path_mention_popup); + } else { + render_slash_command_suggestions(frame, chunks[idx], &slash_suggestions); + } } // ─── Input ─── diff --git a/docs/site/src/content/docs/reference/config-reference.mdx b/docs/site/src/content/docs/reference/config-reference.mdx index c8b61f84..a042c6e1 100644 --- a/docs/site/src/content/docs/reference/config-reference.mdx +++ b/docs/site/src/content/docs/reference/config-reference.mdx @@ -162,6 +162,16 @@ Allowed values: `"auto"`, `"osc9"`, `"osc99"`, `"bel"`, `"off"`. +### `path_mentions_respect_gitignore` + +
TypebooleanDefaulttrue
+ +Respect .gitignore files when building @ path mention suggestions. + +
+ + + ### `terminal_title`
TypebooleanDefaulttrue
diff --git a/schemas/config.toml.json b/schemas/config.toml.json index bae54516..56c51df7 100644 --- a/schemas/config.toml.json +++ b/schemas/config.toml.json @@ -25,6 +25,7 @@ "default": { "copy_on_select": "auto", "notifications": "auto", + "path_mentions_respect_gitignore": true, "terminal_title": true } } @@ -106,6 +107,11 @@ "$ref": "#/$defs/NotificationMethod", "default": "auto" }, + "path_mentions_respect_gitignore": { + "description": "Respect .gitignore files when building @ path mention suggestions.", + "type": "boolean", + "default": true + }, "terminal_title": { "description": "Write OSC 0 terminal-window title updates while the chat runs\n(`seal — Working ⠋ — my-project` etc.). Default `true`; set\n`false` to leave the terminal title alone.", "type": "boolean",