From df4149a93465f08ea7c64a5dca6c053c7826f1ba Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:46:10 -0400 Subject: [PATCH 01/13] feat: `@` operator for file/directory search suggestions --- crates/seal-cli/src/cli/config.rs | 34 + crates/seal-cli/src/main.rs | 1 + crates/seal-tui/src/app.rs | 135 +++- crates/seal-tui/src/chat/host.rs | 3 + crates/seal-tui/src/chat/runloop.rs | 47 ++ crates/seal-tui/src/composer.rs | 6 + crates/seal-tui/src/lib.rs | 1 + crates/seal-tui/src/path_mentions.rs | 674 ++++++++++++++++++ crates/seal-tui/src/renderer.rs | 87 ++- .../docs/reference/config-reference.mdx | 10 + schemas/config.toml.json | 6 + 11 files changed, 992 insertions(+), 12 deletions(-) create mode 100644 crates/seal-tui/src/path_mentions.rs diff --git a/crates/seal-cli/src/cli/config.rs b/crates/seal-cli/src/cli/config.rs index 72c77cc7f..6753718a3 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 50a9ce70a..54a2a8394 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-tui/src/app.rs b/crates/seal-tui/src/app.rs index 3501731ee..235c04264 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, @@ -432,16 +436,88 @@ impl ChatState { 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_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.dirty = true; + Some(Action::None) + } + KeyCode::Down => { + self.path_mention_popup.select_next(); + self.dirty = true; + Some(Action::None) + } + KeyCode::Enter + if !key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::SHIFT) => + { + 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 +1499,63 @@ 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 = ChatState::new(); + 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 = ChatState::new(); + 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 = ChatState::new(); + 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 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 3a8b4d543..0d48637ac 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 c09fa68cb..3994fa4e0 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,25 @@ fn short_session_id(id: &str) -> String { id.chars().take(8).collect::() } +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; + match result { + Ok(index) => { + let _ = tx.send(index); + } + Err(err) => warn!(error = %err, "path mention index build failed"), + } + }); +} + /// 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 +418,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_request = 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 +519,22 @@ where } } + while let Ok(index) = path_index_rx.try_recv() { + path_index_building = false; + app.set_path_mention_index(index); + } + if !path_index_building + && now.duration_since(last_path_index_request) >= PATH_MENTION_REFRESH_INTERVAL + { + path_index_building = true; + last_path_index_request = now; + 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 b6f050abe..4ff6b763e 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 5c989f1a4..67eea7dd1 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 000000000..10227d1c9 --- /dev/null +++ b/crates/seal-tui/src/path_mentions.rs @@ -0,0 +1,674 @@ +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 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_with_overflow(query, limit).matches + } + + pub fn query_with_overflow(&self, query: &str, limit: usize) -> PathMentionQueryResult { + if limit == 0 { + return PathMentionQueryResult::default(); + } + let range = self.prefix_range(query); + let level_prefix = query_level_prefix(query); + let mut same_level_dirs = Vec::new(); + let mut same_level_files = Vec::new(); + let mut descendant_dirs = Vec::new(); + let mut descendant_files = Vec::new(); + let mut total = 0usize; + + for entry in &self.entries[range] { + total += 1; + let bucket = if is_same_level(&entry.path, level_prefix) { + match entry.kind { + PathMentionKind::Directory => &mut same_level_dirs, + PathMentionKind::File => &mut same_level_files, + } + } else { + match entry.kind { + PathMentionKind::Directory => &mut descendant_dirs, + PathMentionKind::File => &mut descendant_files, + } + }; + if bucket.len() < limit { + bucket.push(entry.clone()); + } + } + + let mut matches = Vec::with_capacity(limit); + append_limited(&mut matches, same_level_dirs, limit); + append_limited(&mut matches, same_level_files, limit); + append_limited(&mut matches, descendant_dirs, limit); + append_limited(&mut matches, descendant_files, limit); + let overflow_count = total.saturating_sub(matches.len()); + PathMentionQueryResult { + matches, + 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 append_limited(target: &mut Vec, source: Vec, limit: usize) { + let remaining = limit.saturating_sub(target.len()); + target.extend(source.into_iter().take(remaining)); +} + +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 overflow_count: usize, + pub selected: 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.overflow_count = 0; + self.selected = 0; + return; + }; + if self + .dismissed + .as_ref() + .is_some_and(|dismissed| dismissed.matches(&active)) + { + self.active = Some(active); + self.matches.clear(); + self.overflow_count = 0; + self.selected = 0; + return; + } + self.dismissed = None; + let same_active = self.active.as_ref() == Some(&active); + let result = index.query_with_overflow(&active.query, PATH_MENTION_LIMIT); + self.matches = result.matches; + self.overflow_count = result.overflow_count; + if same_active { + self.selected = self.selected.min(self.matches.len().saturating_sub(1)); + } else { + self.selected = 0; + } + self.active = Some(active); + } + + pub fn select_next(&mut self) { + if !self.matches.is_empty() { + self.selected = (self.selected + 1).min(self.matches.len() - 1); + } + } + + pub fn select_previous(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + pub fn selected_entry(&self) -> Option<&PathMentionEntry> { + self.matches.get(self.selected) + } + + pub fn dismiss_current(&mut self) { + self.dismissed = self.active.as_ref().map(DismissedMention::from); + self.matches.clear(); + self.overflow_count = 0; + self.selected = 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( + dir: &Path, + rel_dir: &str, + respect_gitignore: bool, + inherited_rules: &[IgnoreRule], + entries: &mut Vec, +) { + let mut rules = inherited_rules.to_vec(); + if respect_gitignore { + rules.extend(load_gitignore_rules(dir, rel_dir)); + } + + let Ok(read_dir) = std::fs::read_dir(dir) else { + return; + }; + for entry in read_dir.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let rel = if rel_dir.is_empty() { + name + } else { + format!("{rel_dir}/{name}") + }; + let is_dir = file_type.is_dir(); + let kind = if is_dir { + PathMentionKind::Directory + } else if file_type.is_file() { + PathMentionKind::File + } else { + continue; + }; + if respect_gitignore && is_ignored(&rules, &rel, is_dir) { + continue; + } + entries.push(PathMentionEntry { + path: rel.clone(), + kind, + }); + if is_dir { + let child = entry.path(); + walk_dir(&child, &rel, respect_gitignore, &rules, entries); + } + } +} + +fn load_gitignore_rules(dir: &Path, rel_dir: &str) -> Vec { + let path = dir.join(".gitignore"); + let Ok(bytes) = seal_utils::io::read(&path) else { + return Vec::new(); + }; + let Ok(content) = String::from_utf8(bytes) else { + return Vec::new(); + }; + content + .lines() + .filter_map(|line| IgnoreRule::parse(rel_dir, line)) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IgnoreRule { + base: String, + pattern: String, + negated: bool, + dir_only: bool, + anchored: bool, + has_slash: bool, +} + +impl IgnoreRule { + fn parse(base: &str, line: &str) -> Option { + let mut pattern = line.trim_end(); + if pattern.is_empty() || pattern.starts_with('#') { + return None; + } + let negated = pattern.starts_with('!'); + if negated { + pattern = pattern[1..].trim_start(); + } + let anchored = pattern.starts_with('/'); + if anchored { + pattern = &pattern[1..]; + } + let dir_only = pattern.ends_with('/'); + if dir_only { + pattern = pattern.trim_end_matches('/'); + } + if pattern.is_empty() { + return None; + } + Some(Self { + base: base.to_string(), + pattern: pattern.to_string(), + negated, + dir_only, + anchored, + has_slash: pattern.contains('/'), + }) + } + + fn matches(&self, rel: &str, is_dir: bool) -> bool { + if self.dir_only && !is_dir { + return false; + } + let Some(target) = strip_base(rel, &self.base) else { + return false; + }; + if self.anchored || self.has_slash { + return path_pattern_matches(&self.pattern, target); + } + target + .split('/') + .any(|component| segment_pattern_matches(&self.pattern, component)) + } +} + +fn is_ignored(rules: &[IgnoreRule], rel: &str, is_dir: bool) -> bool { + let mut ignored = false; + for rule in rules { + if rule.matches(rel, is_dir) { + ignored = !rule.negated; + } + } + ignored +} + +fn strip_base<'a>(rel: &'a str, base: &str) -> Option<&'a str> { + if base.is_empty() { + return Some(rel); + } + if rel == base { + return Some(""); + } + rel.strip_prefix(base)?.strip_prefix('/') +} + +fn path_pattern_matches(pattern: &str, target: &str) -> bool { + let pattern_segments = pattern.split('/').collect::>(); + let target_segments = target.split('/').collect::>(); + match_segments(&pattern_segments, &target_segments) +} + +fn match_segments(pattern: &[&str], target: &[&str]) -> bool { + match (pattern.split_first(), target.split_first()) { + (None, None) => true, + (None, Some(_)) => false, + (Some((&"**", rest)), _) => { + match_segments(rest, target) + || (!target.is_empty() && match_segments(pattern, &target[1..])) + } + (Some((segment, rest)), Some((candidate, target_rest))) => { + segment_pattern_matches(segment, candidate) && match_segments(rest, target_rest) + } + (Some(_), None) => false, + } +} + +fn segment_pattern_matches(pattern: &str, candidate: &str) -> bool { + let p = pattern.chars().collect::>(); + let c = candidate.chars().collect::>(); + segment_match_inner(&p, &c) +} + +fn segment_match_inner(pattern: &[char], candidate: &[char]) -> bool { + match (pattern.split_first(), candidate.split_first()) { + (None, None) => true, + (None, Some(_)) => false, + (Some((&'*', rest)), _) => { + segment_match_inner(rest, candidate) + || (!candidate.is_empty() && segment_match_inner(pattern, &candidate[1..])) + } + (Some((&'?', rest)), Some((_, candidate_rest))) => { + segment_match_inner(rest, candidate_rest) + } + (Some((p, rest)), Some((c, candidate_rest))) => { + p == c && segment_match_inner(rest, candidate_rest) + } + (Some(_), None) => false, + } +} + +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 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 gitignore_rule_ignores_directory_by_name() { + let rules = vec![IgnoreRule::parse("", "target/").unwrap()]; + assert!(is_ignored(&rules, "target", true)); + assert!(!is_ignored(&rules, "target", false)); + assert!(is_ignored(&rules, "nested/target", true)); + } + + #[test] + fn gitignore_negation_reincludes_path() { + let rules = vec![ + IgnoreRule::parse("", "*.log").unwrap(), + IgnoreRule::parse("", "!keep.log").unwrap(), + ]; + assert!(is_ignored(&rules, "tmp.log", false)); + assert!(!is_ignored(&rules, "keep.log", false)); + } +} diff --git a/crates/seal-tui/src/renderer.rs b/crates/seal-tui/src/renderer.rs index 5e6a6222d..d2cddf5ad 100644 --- a/crates/seal-tui/src/renderer.rs +++ b/crates/seal-tui/src/renderer.rs @@ -144,6 +144,55 @@ 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 = popup.matches.iter().take(item_rows).collect::>(); + let name_width = visible_matches + .iter() + .map(|entry| entry.display_path().len()) + .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 padding = " ".repeat(name_width.saturating_sub(display.len()) + 2); + let style = if idx == popup.selected { + 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 +275,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 +307,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 +853,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 +1003,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 c8b61f849..a042c6e13 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 bae54516a..56c51df7e 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", From cc72284b4672872994a2e26b3ff93f058b82180c Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:05:19 -0400 Subject: [PATCH 02/13] feat: add scrolling of suggestion list past visible --- crates/seal-tui/src/app.rs | 6 +- crates/seal-tui/src/path_mentions.rs | 165 ++++++++++++++++++++------- 2 files changed, 128 insertions(+), 43 deletions(-) diff --git a/crates/seal-tui/src/app.rs b/crates/seal-tui/src/app.rs index 235c04264..45a12fbc4 100644 --- a/crates/seal-tui/src/app.rs +++ b/crates/seal-tui/src/app.rs @@ -482,12 +482,14 @@ impl ChatState { Some(Action::None) } KeyCode::Up => { - self.path_mention_popup.select_previous(); + 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_popup + .select_next(&self.path_mention_index); self.dirty = true; Some(Action::None) } diff --git a/crates/seal-tui/src/path_mentions.rs b/crates/seal-tui/src/path_mentions.rs index 10227d1c9..3269c7477 100644 --- a/crates/seal-tui/src/path_mentions.rs +++ b/crates/seal-tui/src/path_mentions.rs @@ -47,6 +47,7 @@ pub struct ActivePathMention { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PathMentionQueryResult { pub matches: Vec, + pub total_count: usize, pub overflow_count: usize, } @@ -78,47 +79,53 @@ impl PathMentionIndex { } pub fn query(&self, query: &str, limit: usize) -> Vec { - self.query_with_overflow(query, limit).matches + 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 same_level_dirs = Vec::new(); - let mut same_level_files = Vec::new(); - let mut descendant_dirs = Vec::new(); - let mut descendant_files = Vec::new(); - let mut total = 0usize; - - for entry in &self.entries[range] { - total += 1; - let bucket = if is_same_level(&entry.path, level_prefix) { - match entry.kind { - PathMentionKind::Directory => &mut same_level_dirs, - PathMentionKind::File => &mut same_level_files, - } - } else { - match entry.kind { - PathMentionKind::Directory => &mut descendant_dirs, - PathMentionKind::File => &mut descendant_files, - } + 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, }; - if bucket.len() < limit { - bucket.push(entry.clone()); - } } let mut matches = Vec::with_capacity(limit); - append_limited(&mut matches, same_level_dirs, limit); - append_limited(&mut matches, same_level_files, limit); - append_limited(&mut matches, descendant_dirs, limit); - append_limited(&mut matches, descendant_files, limit); - let overflow_count = total.saturating_sub(matches.len()); + 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, } } @@ -143,9 +150,13 @@ impl PathMentionIndex { } } -fn append_limited(target: &mut Vec, source: Vec, limit: usize) { - let remaining = limit.saturating_sub(target.len()); - target.extend(source.into_iter().take(remaining)); +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 { @@ -161,8 +172,10 @@ fn is_same_level(path: &str, level_prefix: &str) -> bool { 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, } @@ -175,8 +188,10 @@ impl PathMentionPopupState { 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 @@ -186,42 +201,80 @@ impl PathMentionPopupState { { 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); - let result = index.query_with_overflow(&active.query, PATH_MENTION_LIMIT); - self.matches = result.matches; - self.overflow_count = result.overflow_count; - if same_active { - self.selected = self.selected.min(self.matches.len().saturating_sub(1)); - } else { + if !same_active { self.selected = 0; + self.window_start = 0; } self.active = Some(active); + self.refresh_matches(index); } - pub fn select_next(&mut self) { - if !self.matches.is_empty() { - self.selected = (self.selected + 1).min(self.matches.len() - 1); + 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) { - self.selected = self.selected.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) } + fn refresh_matches(&mut self, index: &PathMentionIndex) { + let Some(active) = self.active.as_ref() else { + return; + }; + let 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) { @@ -600,6 +653,36 @@ mod tests { 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 query_prefix_uses_starts_with_only() { let index = PathMentionIndex::from_entries(vec![ From b20725f9108b61d1044d75da2dbc52704a14aa34 Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:01:34 -0400 Subject: [PATCH 03/13] fix: ignore `.git` `.hg` and `.svn` dirs in file search by default --- crates/seal-tui/src/path_mentions.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/seal-tui/src/path_mentions.rs b/crates/seal-tui/src/path_mentions.rs index 3269c7477..3f91226ac 100644 --- a/crates/seal-tui/src/path_mentions.rs +++ b/crates/seal-tui/src/path_mentions.rs @@ -359,6 +359,9 @@ fn walk_dir( format!("{rel_dir}/{name}") }; let is_dir = file_type.is_dir(); + if is_dir && is_vcs_metadata_dir(&name) { + continue; + } let kind = if is_dir { PathMentionKind::Directory } else if file_type.is_file() { @@ -380,6 +383,10 @@ fn walk_dir( } } +fn is_vcs_metadata_dir(name: &str) -> bool { + matches!(name, ".git" | ".hg" | ".svn") +} + fn load_gitignore_rules(dir: &Path, rel_dir: &str) -> Vec { let path = dir.join(".gitignore"); let Ok(bytes) = seal_utils::io::read(&path) else { @@ -737,6 +744,25 @@ mod tests { ); } + #[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 rules = vec![IgnoreRule::parse("", "target/").unwrap()]; From 8288f728ac933e5ad0bc72225d64ecfa6758e447 Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:13:03 -0400 Subject: [PATCH 04/13] fix: use unicodewidthstr for path label alignment --- crates/seal-tui/src/renderer.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/seal-tui/src/renderer.rs b/crates/seal-tui/src/renderer.rs index d2cddf5ad..db43023db 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)] @@ -153,7 +154,7 @@ fn render_path_mention_suggestions( let visible_matches = popup.matches.iter().take(item_rows).collect::>(); let name_width = visible_matches .iter() - .map(|entry| entry.display_path().len()) + .map(|entry| UnicodeWidthStr::width(entry.display_path().as_str())) .max() .unwrap_or(0); let mut lines = Vec::with_capacity(area.height as usize); @@ -163,7 +164,8 @@ fn render_path_mention_suggestions( )])); lines.extend(visible_matches.iter().enumerate().map(|(idx, entry)| { let display = entry.display_path(); - let padding = " ".repeat(name_width.saturating_sub(display.len()) + 2); + let display_width = UnicodeWidthStr::width(display.as_str()); + let padding = " ".repeat(name_width.saturating_sub(display_width) + 2); let style = if idx == popup.selected { Style::default() .fg(colors::GOLD) From 0b1e7f91ce0a4419cb8ee356d2b43bc609a213a2 Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:37:33 -0400 Subject: [PATCH 05/13] fix: preserve paste-mode Enter before accepting a popup completion --- crates/seal-tui/src/app.rs | 53 ++++++++++++++++++++++++---- crates/seal-tui/src/path_mentions.rs | 2 +- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/seal-tui/src/app.rs b/crates/seal-tui/src/app.rs index 45a12fbc4..b65f91740 100644 --- a/crates/seal-tui/src/app.rs +++ b/crates/seal-tui/src/app.rs @@ -436,6 +436,10 @@ 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; } @@ -471,6 +475,25 @@ impl ChatState { ); } + 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; @@ -493,10 +516,7 @@ impl ChatState { self.dirty = true; Some(Action::None) } - KeyCode::Enter - if !key.modifiers.contains(KeyModifiers::ALT) - && !key.modifiers.contains(KeyModifiers::SHIFT) => - { + KeyCode::Enter if Self::is_plain_enter(key) => { self.insert_selected_path_mention(); Some(Action::None) } @@ -1519,7 +1539,7 @@ mod tests { #[test] fn path_mention_popup_opens_for_matching_prefix() { - let mut app = ChatState::new(); + let mut app = ready_app(); app.set_path_mention_index(path_index(vec![path_entry( "foo/bar.rs", crate::path_mentions::PathMentionKind::File, @@ -1533,7 +1553,7 @@ mod tests { #[test] fn path_mention_popup_hides_without_matches() { - let mut app = ChatState::new(); + let mut app = ready_app(); app.set_path_mention_index(path_index(vec![path_entry( "foo/bar.rs", crate::path_mentions::PathMentionKind::File, @@ -1546,7 +1566,7 @@ mod tests { #[test] fn path_mention_enter_inserts_selected_path() { - let mut app = ChatState::new(); + 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), @@ -1558,6 +1578,25 @@ mod tests { assert_eq!(app.composer.text, "look foo/bar.rs "); } + #[test] + fn path_mention_enter_in_paste_mode_inserts_newline() { + let mut app = ready_app_with_paste_fsm(); + 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))); + } + assert!(app.composer.paste_mode); + 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 typing_characters() { let mut app = ChatState::new(); diff --git a/crates/seal-tui/src/path_mentions.rs b/crates/seal-tui/src/path_mentions.rs index 3f91226ac..3447e31b0 100644 --- a/crates/seal-tui/src/path_mentions.rs +++ b/crates/seal-tui/src/path_mentions.rs @@ -354,7 +354,7 @@ fn walk_dir( } let name = entry.file_name().to_string_lossy().into_owned(); let rel = if rel_dir.is_empty() { - name + name.clone() } else { format!("{rel_dir}/{name}") }; From 4572b3f7e007d12b4fa9543f537727a02ef4d80b Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:43:24 -0400 Subject: [PATCH 06/13] fix: resync popup after paste mutations --- crates/seal-tui/src/app.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/seal-tui/src/app.rs b/crates/seal-tui/src/app.rs index b65f91740..cdd5e0895 100644 --- a/crates/seal-tui/src/app.rs +++ b/crates/seal-tui/src/app.rs @@ -379,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. @@ -1597,6 +1598,24 @@ mod tests { 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(); From a8c5b9a3b2a7de94ba88d886b3653e956085567f Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:55:01 -0400 Subject: [PATCH 07/13] fix: clear path mention index build state on failure --- crates/seal-tui/src/chat/runloop.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/seal-tui/src/chat/runloop.rs b/crates/seal-tui/src/chat/runloop.rs index 3994fa4e0..bc792b650 100644 --- a/crates/seal-tui/src/chat/runloop.rs +++ b/crates/seal-tui/src/chat/runloop.rs @@ -158,22 +158,29 @@ 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, + 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; - match result { - Ok(index) => { - let _ = tx.send(index); + let message = match result { + Ok(index) => PathMentionIndexBuildResult::Built(index), + Err(err) => { + warn!(error = %err, "path mention index build failed"); + PathMentionIndexBuildResult::Failed } - Err(err) => warn!(error = %err, "path mention index build failed"), - } + }; + let _ = tx.send(message); }); } @@ -419,7 +426,7 @@ where let (slash_tx, mut slash_rx) = tokio::sync::mpsc::unbounded_channel::(); let (path_index_tx, mut path_index_rx) = - tokio::sync::mpsc::unbounded_channel::(); + tokio::sync::mpsc::unbounded_channel::(); let mut path_index_building = true; let mut last_path_index_request = std::time::Instant::now(); spawn_path_mention_index( @@ -519,9 +526,12 @@ where } } - while let Ok(index) = path_index_rx.try_recv() { + while let Ok(result) = path_index_rx.try_recv() { path_index_building = false; - app.set_path_mention_index(index); + match result { + PathMentionIndexBuildResult::Built(index) => app.set_path_mention_index(index), + PathMentionIndexBuildResult::Failed => {} + } } if !path_index_building && now.duration_since(last_path_index_request) >= PATH_MENTION_REFRESH_INTERVAL From eba48fd5512fdf709b8cf73564d706a1549c6d4e Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:13:23 -0400 Subject: [PATCH 08/13] fix: requery path mentions after index shrink --- crates/seal-tui/src/path_mentions.rs | 33 +++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/seal-tui/src/path_mentions.rs b/crates/seal-tui/src/path_mentions.rs index 3447e31b0..d850a8943 100644 --- a/crates/seal-tui/src/path_mentions.rs +++ b/crates/seal-tui/src/path_mentions.rs @@ -255,7 +255,11 @@ impl PathMentionPopupState { let Some(active) = self.active.as_ref() else { return; }; - let result = index.query_window(&active.query, self.window_start, PATH_MENTION_LIMIT); + 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() { @@ -690,6 +694,33 @@ mod tests { assert_eq!(popup.matches[0].path, "dir-00"); } + #[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![ From e46a6c1ff0068ad6152ff1f81fed9a11e45460ab Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:27:39 -0400 Subject: [PATCH 09/13] test: make path mention paste-mode Enter deterministic --- crates/seal-tui/src/app.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/seal-tui/src/app.rs b/crates/seal-tui/src/app.rs index cdd5e0895..79d9162db 100644 --- a/crates/seal-tui/src/app.rs +++ b/crates/seal-tui/src/app.rs @@ -1581,7 +1581,7 @@ mod tests { #[test] fn path_mention_enter_in_paste_mode_inserts_newline() { - let mut app = ready_app_with_paste_fsm(); + 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), @@ -1589,7 +1589,7 @@ mod tests { for c in "look @foo/ba".chars() { app.handle_key(key(KeyCode::Char(c))); } - assert!(app.composer.paste_mode); + app.composer.paste_mode = true; assert!(app.path_mention_popup.is_visible()); let action = app.handle_key(key(KeyCode::Enter)); From 4b23f43525e9d6db1ff3ca2d26452404b0d1df45 Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:53:24 -0400 Subject: [PATCH 10/13] fix: use ignore crate for path mention indexing --- Cargo.lock | 1 + Cargo.toml | 1 + crates/seal-runtime/Cargo.toml | 2 +- crates/seal-tui/Cargo.toml | 1 + crates/seal-tui/src/path_mentions.rs | 267 +++++++++------------------ 5 files changed, 89 insertions(+), 183 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 519a54f38..e3f7da04d 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 c5307b5d1..78ffcc130 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-runtime/Cargo.toml b/crates/seal-runtime/Cargo.toml index 3793b7d28..c50895437 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 6d89a8ced..0a022ea09 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/path_mentions.rs b/crates/seal-tui/src/path_mentions.rs index d850a8943..66cfca6c6 100644 --- a/crates/seal-tui/src/path_mentions.rs +++ b/crates/seal-tui/src/path_mentions.rs @@ -59,7 +59,7 @@ pub struct PathMentionIndex { impl PathMentionIndex { pub fn build(root: &Path, respect_gitignore: bool) -> Self { let mut entries = Vec::new(); - walk_dir(root, "", respect_gitignore, &[], &mut entries); + walk_dir(root, respect_gitignore, &mut entries); entries.sort_by(|a, b| { a.path .cmp(&b.path) @@ -334,196 +334,63 @@ pub fn active_path_mention(input: &str, cursor: usize) -> Option, -) { - let mut rules = inherited_rules.to_vec(); - if respect_gitignore { - rules.extend(load_gitignore_rules(dir, rel_dir)); - } - - let Ok(read_dir) = std::fs::read_dir(dir) else { - return; - }; - for entry in read_dir.flatten() { - let Ok(file_type) = entry.file_type() else { +fn walk_dir(root: &Path, respect_gitignore: bool, entries: &mut Vec) { + let mut builder = ignore::WalkBuilder::new(root); + builder + .follow_links(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 name = entry.file_name().to_string_lossy().into_owned(); - let rel = if rel_dir.is_empty() { - name.clone() - } else { - format!("{rel_dir}/{name}") - }; - let is_dir = file_type.is_dir(); - if is_dir && is_vcs_metadata_dir(&name) { + let Some(path) = relative_display_path(root, entry.path()) else { continue; - } - let kind = if is_dir { + }; + let kind = if file_type.is_dir() { PathMentionKind::Directory } else if file_type.is_file() { PathMentionKind::File } else { continue; }; - if respect_gitignore && is_ignored(&rules, &rel, is_dir) { - continue; - } - entries.push(PathMentionEntry { - path: rel.clone(), - kind, - }); - if is_dir { - let child = entry.path(); - walk_dir(&child, &rel, respect_gitignore, &rules, entries); - } - } -} - -fn is_vcs_metadata_dir(name: &str) -> bool { - matches!(name, ".git" | ".hg" | ".svn") -} - -fn load_gitignore_rules(dir: &Path, rel_dir: &str) -> Vec { - let path = dir.join(".gitignore"); - let Ok(bytes) = seal_utils::io::read(&path) else { - return Vec::new(); - }; - let Ok(content) = String::from_utf8(bytes) else { - return Vec::new(); - }; - content - .lines() - .filter_map(|line| IgnoreRule::parse(rel_dir, line)) - .collect() -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct IgnoreRule { - base: String, - pattern: String, - negated: bool, - dir_only: bool, - anchored: bool, - has_slash: bool, -} - -impl IgnoreRule { - fn parse(base: &str, line: &str) -> Option { - let mut pattern = line.trim_end(); - if pattern.is_empty() || pattern.starts_with('#') { - return None; - } - let negated = pattern.starts_with('!'); - if negated { - pattern = pattern[1..].trim_start(); - } - let anchored = pattern.starts_with('/'); - if anchored { - pattern = &pattern[1..]; - } - let dir_only = pattern.ends_with('/'); - if dir_only { - pattern = pattern.trim_end_matches('/'); - } - if pattern.is_empty() { - return None; - } - Some(Self { - base: base.to_string(), - pattern: pattern.to_string(), - negated, - dir_only, - anchored, - has_slash: pattern.contains('/'), - }) - } - - fn matches(&self, rel: &str, is_dir: bool) -> bool { - if self.dir_only && !is_dir { - return false; - } - let Some(target) = strip_base(rel, &self.base) else { - return false; - }; - if self.anchored || self.has_slash { - return path_pattern_matches(&self.pattern, target); - } - target - .split('/') - .any(|component| segment_pattern_matches(&self.pattern, component)) - } -} - -fn is_ignored(rules: &[IgnoreRule], rel: &str, is_dir: bool) -> bool { - let mut ignored = false; - for rule in rules { - if rule.matches(rel, is_dir) { - ignored = !rule.negated; - } - } - ignored -} - -fn strip_base<'a>(rel: &'a str, base: &str) -> Option<&'a str> { - if base.is_empty() { - return Some(rel); + entries.push(PathMentionEntry { path, kind }); } - if rel == base { - return Some(""); - } - rel.strip_prefix(base)?.strip_prefix('/') -} - -fn path_pattern_matches(pattern: &str, target: &str) -> bool { - let pattern_segments = pattern.split('/').collect::>(); - let target_segments = target.split('/').collect::>(); - match_segments(&pattern_segments, &target_segments) } -fn match_segments(pattern: &[&str], target: &[&str]) -> bool { - match (pattern.split_first(), target.split_first()) { - (None, None) => true, - (None, Some(_)) => false, - (Some((&"**", rest)), _) => { - match_segments(rest, target) - || (!target.is_empty() && match_segments(pattern, &target[1..])) - } - (Some((segment, rest)), Some((candidate, target_rest))) => { - segment_pattern_matches(segment, candidate) && match_segments(rest, target_rest) - } - (Some(_), None) => false, - } +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 segment_pattern_matches(pattern: &str, candidate: &str) -> bool { - let p = pattern.chars().collect::>(); - let c = candidate.chars().collect::>(); - segment_match_inner(&p, &c) +fn is_vcs_metadata_dir(name: &str) -> bool { + matches!(name, ".git" | ".hg" | ".svn") } -fn segment_match_inner(pattern: &[char], candidate: &[char]) -> bool { - match (pattern.split_first(), candidate.split_first()) { - (None, None) => true, - (None, Some(_)) => false, - (Some((&'*', rest)), _) => { - segment_match_inner(rest, candidate) - || (!candidate.is_empty() && segment_match_inner(pattern, &candidate[1..])) - } - (Some((&'?', rest)), Some((_, candidate_rest))) => { - segment_match_inner(rest, candidate_rest) - } - (Some((p, rest)), Some((c, candidate_rest))) => { - p == c && segment_match_inner(rest, candidate_rest) - } - (Some(_), None) => false, +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("/")) } } @@ -796,19 +663,55 @@ mod tests { #[test] fn gitignore_rule_ignores_directory_by_name() { - let rules = vec![IgnoreRule::parse("", "target/").unwrap()]; - assert!(is_ignored(&rules, "target", true)); - assert!(!is_ignored(&rules, "target", false)); - assert!(is_ignored(&rules, "nested/target", true)); + 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 rules = vec![ - IgnoreRule::parse("", "*.log").unwrap(), - IgnoreRule::parse("", "!keep.log").unwrap(), - ]; - assert!(is_ignored(&rules, "tmp.log", false)); - assert!(!is_ignored(&rules, "keep.log", false)); + 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), + ] + ); } } From c0e31b985f995e5fd1f44acaf190e67c98b256dc Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:37:40 -0400 Subject: [PATCH 11/13] fix: respect `.gitignore` for file suggestions in non-git repos --- crates/seal-tui/src/path_mentions.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/seal-tui/src/path_mentions.rs b/crates/seal-tui/src/path_mentions.rs index 66cfca6c6..5766f8675 100644 --- a/crates/seal-tui/src/path_mentions.rs +++ b/crates/seal-tui/src/path_mentions.rs @@ -338,6 +338,7 @@ fn walk_dir(root: &Path, respect_gitignore: bool, entries: &mut Vec Date: Wed, 24 Jun 2026 16:55:05 -0400 Subject: [PATCH 12/13] fix: fix refresh timing issue in runloop.rs --- crates/seal-tui/src/chat/runloop.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/seal-tui/src/chat/runloop.rs b/crates/seal-tui/src/chat/runloop.rs index bc792b650..9d083d37f 100644 --- a/crates/seal-tui/src/chat/runloop.rs +++ b/crates/seal-tui/src/chat/runloop.rs @@ -428,7 +428,7 @@ where let (path_index_tx, mut path_index_rx) = tokio::sync::mpsc::unbounded_channel::(); let mut path_index_building = true; - let mut last_path_index_request = std::time::Instant::now(); + let mut last_path_index_completion = std::time::Instant::now(); spawn_path_mention_index( project_root.to_path_buf(), host.path_mentions_respect_gitignore, @@ -528,16 +528,16 @@ 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_request) >= PATH_MENTION_REFRESH_INTERVAL + && now.duration_since(last_path_index_completion) >= PATH_MENTION_REFRESH_INTERVAL { path_index_building = true; - last_path_index_request = now; spawn_path_mention_index( project_root.to_path_buf(), host.path_mentions_respect_gitignore, From 413c93a4a8912606fd919b175faf204054789337 Mon Sep 17 00:00:00 2001 From: fluxdiv <156196590+fluxdiv@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:08:40 -0400 Subject: [PATCH 13/13] fix: keep selected path mention always visible --- crates/seal-tui/src/path_mentions.rs | 50 ++++++++++++++++++++++++++++ crates/seal-tui/src/renderer.rs | 4 +-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/seal-tui/src/path_mentions.rs b/crates/seal-tui/src/path_mentions.rs index 5766f8675..300241640 100644 --- a/crates/seal-tui/src/path_mentions.rs +++ b/crates/seal-tui/src/path_mentions.rs @@ -251,6 +251,20 @@ impl PathMentionPopupState { 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; @@ -562,6 +576,42 @@ mod tests { 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) diff --git a/crates/seal-tui/src/renderer.rs b/crates/seal-tui/src/renderer.rs index db43023db..2572aaa05 100644 --- a/crates/seal-tui/src/renderer.rs +++ b/crates/seal-tui/src/renderer.rs @@ -151,7 +151,7 @@ fn render_path_mention_suggestions( popup: &crate::path_mentions::PathMentionPopupState, ) { let item_rows = area.height.saturating_sub(2) as usize; - let visible_matches = popup.matches.iter().take(item_rows).collect::>(); + 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())) @@ -166,7 +166,7 @@ fn render_path_mention_suggestions( 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 == popup.selected { + let style = if idx == selected_row { Style::default() .fg(colors::GOLD) .add_modifier(Modifier::BOLD)