Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 34 additions & 0 deletions crates/seal-cli/src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -168,6 +170,7 @@ impl Default for TuiConfig {
copy_on_select: CopyOnSelect::default(),
terminal_title: true,
notifications: NotificationMethod::default(),
path_mentions_respect_gitignore: true,
}
}
}
Expand Down Expand Up @@ -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;
}
}
}
}
Expand Down Expand Up @@ -379,6 +385,7 @@ struct TuiConfigFile {
copy_on_select: Option<CopyOnSelect>,
terminal_title: Option<bool>,
notifications: Option<NotificationMethod>,
path_mentions_respect_gitignore: Option<bool>,
}

#[cfg(test)]
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/seal-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/seal-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/seal-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
195 changes: 194 additions & 1 deletion crates/seal-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn handle_path_mention_paste_enter(&mut self, key: KeyEvent) -> Option<Action> {
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<Action> {
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
Expand Down Expand Up @@ -1423,6 +1522,100 @@ mod tests {
KeyEvent::new(code, KeyModifiers::CONTROL)
}

fn path_index(
entries: Vec<crate::path_mentions::PathMentionEntry>,
) -> 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");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[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();
Expand Down
3 changes: 3 additions & 0 deletions crates/seal-tui/src/chat/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
}
Expand Down
Loading
Loading