diff --git a/crates/forge_infra/src/fs_read_dir.rs b/crates/forge_infra/src/fs_read_dir.rs index 02e370af48..761103fe71 100644 --- a/crates/forge_infra/src/fs_read_dir.rs +++ b/crates/forge_infra/src/fs_read_dir.rs @@ -37,6 +37,7 @@ impl ForgeDirectoryReaderService { .cwd(directory.to_path_buf()) .max_depth(1) .skip_binary(true) + .hidden(true) .get() .await?; diff --git a/crates/forge_infra/src/walker.rs b/crates/forge_infra/src/walker.rs index ba7f6d1dac..344e0264bc 100644 --- a/crates/forge_infra/src/walker.rs +++ b/crates/forge_infra/src/walker.rs @@ -16,7 +16,8 @@ impl ForgeWalkerService { && config.max_files.is_none() && config.max_total_size.is_none() { - forge_walker::Walker::max_all() + // Agent-facing walker: keep hidden files excluded by default. + forge_walker::Walker::max_all().hidden(true) } else { forge_walker::Walker::min_all() }; diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index b18cee0319..ad29d5bd66 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -38,7 +38,6 @@ lazy_static.workspace = true reedline.workspace = true crossterm = "0.29.0" nu-ansi-term.workspace = true -nucleo.workspace = true tracing.workspace = true chrono.workspace = true serde_json.workspace = true diff --git a/crates/forge_main/src/banner.rs b/crates/forge_main/src/banner.rs index db852c94c0..1827ddaa52 100644 --- a/crates/forge_main/src/banner.rs +++ b/crates/forge_main/src/banner.rs @@ -46,8 +46,8 @@ impl fmt::Display for DisplayBox { /// /// # Arguments /// -/// * `cli_mode` - If true, shows CLI-relevant commands with `:` prefix. If -/// false, shows all interactive commands with `/` prefix. +/// * `cli_mode` - If true, shows CLI-relevant commands. Both interactive and +/// CLI modes use `:` as the canonical command prefix. /// /// # Environment Variables /// @@ -76,12 +76,12 @@ pub fn display(cli_mode: bool) -> io::Result<()> { } else { // Interactive mode: show all commands vec![ - ("New conversation:", "/new"), - ("Get started:", "/info, /usage, /help, /conversation"), - ("Switch model:", "/model"), - ("Switch agent:", "/forge or /muse or /agent"), - ("Update:", "/update"), - ("Quit:", "/exit or "), + ("New conversation:", ":new"), + ("Get started:", ":info, :usage, :help, :conversation"), + ("Switch model:", ":model"), + ("Switch agent:", ":forge or :muse or :agent"), + ("Update:", ":update"), + ("Quit:", ":exit or "), ] }; diff --git a/crates/forge_main/src/built_in_commands.json b/crates/forge_main/src/built_in_commands.json deleted file mode 100644 index 889dc7f449..0000000000 --- a/crates/forge_main/src/built_in_commands.json +++ /dev/null @@ -1,142 +0,0 @@ -[ - { - "command": "info", - "description": "Print session information [alias: i]" - }, - { - "command": "config", - "description": "Display effective resolved configuration in TOML format" - }, - { - "command": "config-model", - "description": "Switch the models [alias: cm]" - }, - { - "command": "model", - "description": "Switch the model for the current session only, without modifying global config [alias: m]" - }, - { - "command": "config-reload", - "description": "Reset all session overrides (model, provider, reasoning effort) to use global config [alias: cr]" - }, - { - "command": "reasoning-effort", - "description": "Set reasoning effort for the current session only [alias: re]" - }, - { - "command": "config-reasoning-effort", - "description": "Set the reasoning effort level in global config [alias: cre]" - }, - { - "command": "config-commit-model", - "description": "Set the model used for commit message generation [alias: ccm]" - }, - { - "command": "config-suggest-model", - "description": "Set the model used for command suggestion generation [alias: csm]" - }, - { - "command": "config-edit", - "description": "Open the global forge config file (~/forge/.forge.toml) in an editor [alias: ce]" - }, - { - "command": "new", - "description": "Start new conversation [alias: n]" - }, - { - "command": "dump", - "description": "Save conversation as JSON or HTML (use /dump html for HTML format) [alias: d]" - }, - { - "command": "conversation", - "description": "List all conversations for the active workspace [alias: c]" - }, - { - "command": "retry", - "description": "Retry the last command [alias: r]" - }, - { - "command": "compact", - "description": "Compact the conversation context" - }, - { - "command": "edit", - "description": "Use an external editor to write a prompt" - }, - { - "command": "tools", - "description": "List all available tools with their descriptions and schema [alias: t]" - }, - { - "command": "skill", - "description": "List all available skills" - }, - { - "command": "commit", - "description": "Directly commits AI generated commit message" - }, - { - "command": "commit-preview", - "description": "Previews AI generated commit message" - }, - { - "command": "suggest", - "description": "Generate shell commands without executing them [alias: s]" - }, - { - "command": "provider-login", - "description": "Login to a provider [aliases: login, provider]" - }, - { - "command": "logout", - "description": "Logout from a provider" - }, - { - "command": "agent", - "description": "Select and switch between agents [alias: a]" - }, - { - "command": "workspace-sync", - "description": "Sync the current workspace for codebase search [alias: sync]" - }, - { - "command": "workspace-status", - "description": "Show sync status of all workspace files" - }, - { - "command": "workspace-info", - "description": "Show workspace information with sync details" - }, - { - "command": "workspace-init", - "description": "Initialize a new workspace without syncing files" - }, - { - "command": "clone", - "description": "Clone and manage conversation context" - }, - { - "command": "rename", - "description": "Rename the current conversation [alias: rn]" - }, - { - "command": "conversation-rename", - "description": "Rename a conversation by ID or interactively" - }, - { - "command": "copy", - "description": "Copy last assistant message to clipboard as raw markdown" - }, - { - "command": "doctor", - "description": "Run environment diagnostics for the shell plugin" - }, - { - "command": "keyboard-shortcuts", - "description": "Display ZSH keyboard shortcuts [alias: kb]" - }, - { - "command": "setup", - "description": "Setup zsh integration by updating .zshrc" - } -] diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index d0c1e9eef3..1af889ab80 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -366,6 +366,13 @@ pub enum ListCommand { #[arg(long)] custom: bool, }, + + /// List files and directories in the current workspace. + /// + /// Includes hidden files and directories (dotfiles), respects .gitignore, + /// and outputs one path per line. Directories are suffixed with `/`. + #[command(alias = "files")] + File, } /// Shell extension commands. diff --git a/crates/forge_main/src/completer/command.rs b/crates/forge_main/src/completer/command.rs index 327830f853..6401b7df16 100644 --- a/crates/forge_main/src/completer/command.rs +++ b/crates/forge_main/src/completer/command.rs @@ -1,8 +1,20 @@ use std::sync::Arc; +use forge_select::ForgeWidget; use reedline::{Completer, Span, Suggestion}; -use crate::model::ForgeCommandManager; +use crate::model::{ForgeCommand, ForgeCommandManager}; + +/// A display wrapper for `ForgeCommand` that renders the name and description +/// side-by-side for fzf. +#[derive(Clone)] +struct CommandRow(ForgeCommand); + +impl std::fmt::Display for CommandRow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:<30} {}", self.0.name, self.0.description) + } +} #[derive(Clone)] pub struct CommandCompleter(Arc); @@ -15,35 +27,65 @@ impl CommandCompleter { impl Completer for CommandCompleter { fn complete(&mut self, line: &str, _: usize) -> Vec { - self.0 + // Determine which sentinel the user typed (`:` or `/`), defaulting to `/`. + let sentinel = if line.starts_with(':') { ':' } else { '/' }; + + // Build the list of display names using the same sentinel the user typed. + let commands: Vec = self + .0 .list() .into_iter() .filter_map(|cmd| { - // For command completion, we want to show commands with `/` prefix let display_name = if cmd.name.starts_with('!') { - // Shell commands already have the `!` prefix cmd.name.clone() } else { - // Add `/` prefix for slash commands - format!("/{}", cmd.name) + format!("{}{}", sentinel, cmd.name) }; - // Check if the display name starts with what the user typed + // Only include commands that match what the user has typed so far. if display_name.starts_with(line) { - Some(Suggestion { - value: display_name, - description: Some(cmd.description), - style: None, - extra: None, - span: Span::new(0, line.len()), - append_whitespace: false, - match_indices: None, - display_override: None, - }) + Some(CommandRow(ForgeCommand { + name: display_name, + description: cmd.description, + value: cmd.value, + })) } else { None } }) - .collect() + .collect(); + + if commands.is_empty() { + return vec![]; + } + + // Extract the initial query text (everything after the leading sentinel or + // `!`). + let initial_query = line + .strip_prefix('/') + .or_else(|| line.strip_prefix(':')) + .or_else(|| line.strip_prefix('!')) + .unwrap_or(line); + + let mut builder = ForgeWidget::select("Command", commands); + if !initial_query.is_empty() { + builder = builder.with_initial_text(initial_query); + } + + match builder.prompt() { + Ok(Some(row)) => { + vec![Suggestion { + value: row.0.name, + description: None, + style: None, + extra: None, + span: Span::new(0, line.len()), + append_whitespace: true, + match_indices: None, + display_override: None, + }] + } + _ => vec![], + } } } diff --git a/crates/forge_main/src/completer/input_completer.rs b/crates/forge_main/src/completer/input_completer.rs index 27c8eded03..9f81a661dc 100644 --- a/crates/forge_main/src/completer/input_completer.rs +++ b/crates/forge_main/src/completer/input_completer.rs @@ -1,37 +1,30 @@ use std::path::PathBuf; use std::sync::Arc; +use forge_select::ForgeWidget; use forge_walker::Walker; -use nucleo::pattern::{CaseMatching, Normalization, Pattern}; -use nucleo::{Config, Matcher, Utf32Str}; -use reedline::{Completer, Suggestion}; +use reedline::{Completer, Span, Suggestion}; use crate::completer::CommandCompleter; use crate::completer::search_term::SearchTerm; use crate::model::ForgeCommandManager; pub struct InputCompleter { - walker: Walker, + cwd: PathBuf, command: CommandCompleter, - fuzzy_matcher: Matcher, } impl InputCompleter { pub fn new(cwd: PathBuf, command_manager: Arc) -> Self { - let walker = Walker::max_all().cwd(cwd).skip_binary(true); - Self { - walker, - command: CommandCompleter::new(command_manager), - fuzzy_matcher: Matcher::new(Config::DEFAULT.match_paths()), - } + Self { cwd, command: CommandCompleter::new(command_manager) } } } impl Completer for InputCompleter { fn complete(&mut self, line: &str, pos: usize) -> Vec { - if line.starts_with("/") { - // if the line starts with '/' it's probably a command, so we delegate to the - // command completer. + if line.starts_with('/') || line.starts_with(':') { + // if the line starts with '/' or ':' it's probably a command, so we delegate to + // the command completer. let result = self.command.complete(line, pos); if !result.is_empty() { return result; @@ -39,329 +32,58 @@ impl Completer for InputCompleter { } if let Some(query) = SearchTerm::new(line, pos).process() { - let files = self.walker.get_blocking().unwrap_or_default(); - let pattern = Pattern::parse( - escape_for_pattern_parse(query.term).as_str(), - CaseMatching::Smart, - Normalization::Smart, - ); - let mut scored_matches: Vec<(u32, Suggestion)> = files + let walker = Walker::max_all().cwd(self.cwd.clone()).skip_binary(true); + let files: Vec = walker + .get_blocking() + .unwrap_or_default() .into_iter() - .filter(|file| !file.is_dir()) - .filter_map(|file| { - let mut haystack_buf = Vec::new(); - let haystack = Utf32Str::new(&file.path, &mut haystack_buf); - if let Some(score) = pattern.score(haystack, &mut self.fuzzy_matcher) { - let path_md_fmt = format!("[{}]", file.path); - Some(( - score, - Suggestion { - description: None, - value: path_md_fmt, - style: None, - extra: None, - span: query.span, - append_whitespace: true, - match_indices: None, - display_override: None, - }, - )) - } else { - None - } - }) + .map(|file| file.path) .collect(); - // Sort by fuzzy match score (higher is better) - scored_matches.sort_by_key(|b| std::cmp::Reverse(b.0)); + // Preview command: show directory listing for dirs, file contents for files. + // {2} references the path column (items are formatted as "{idx}\t{path}"). + // Use bat for syntax-highlighted file previews when available, falling back + // to cat. Mirrors the shell plugin's _FORGE_CAT_CMD and completion.zsh preview. + let cat_cmd = if which_bat() { + "bat --color=always --style=numbers,changes --line-range=:500" + } else { + "cat" + }; + let preview_cmd = format!( + "if [ -d {{2}} ]; then ls -la --color=always {{2}} 2>/dev/null || ls -la {{2}}; else {cat_cmd} {{2}}; fi" + ); - // Extract suggestions from scored matches - scored_matches - .into_iter() - .map(|(_, suggestion)| suggestion) - .collect() - } else { - vec![] + let mut builder = ForgeWidget::select("File", files) + .with_preview(preview_cmd) + .with_preview_window("bottom:75%:wrap:border-sharp"); + if !query.term.is_empty() { + builder = builder.with_initial_text(query.term); + } + + if let Ok(Some(selected)) = builder.prompt() { + let value = format!("[{}]", selected); + return vec![Suggestion { + description: None, + value, + style: None, + extra: None, + span: Span::new(query.span.start, query.span.end), + append_whitespace: true, + match_indices: None, + display_override: None, + }]; + } } - } -} -fn escape_for_pattern_parse(term: &str) -> String { - let mut term_string = term.to_string(); - if term_string.ends_with('$') { - term_string.insert(term_string.len() - 1, '\\'); - } - if term_string.starts_with('\'') || term_string.starts_with('^') || term_string.starts_with('!') - { - term_string = format!("\\{term_string}"); + vec![] } - term_string } -#[cfg(test)] -mod tests { - use std::fs; - use std::sync::Arc; - - use tempfile::TempDir; - - use super::*; - use crate::model::ForgeCommandManager; - - fn create_test_fixture() -> (TempDir, InputCompleter) { - let temp_dir = TempDir::new().unwrap(); - let temp_path = temp_dir.path().to_path_buf(); - - // Create test files - fs::write(temp_path.join("config.rs"), "").unwrap(); - fs::write(temp_path.join("main.rs"), "").unwrap(); - fs::write(temp_path.join("lib.rs"), "").unwrap(); - fs::write(temp_path.join("test_file.txt"), "").unwrap(); - fs::write(temp_path.join("another_config.toml"), "").unwrap(); - fs::write(temp_path.join("main$"), "").unwrap(); - fs::write(temp_path.join("$main"), "").unwrap(); - fs::write(temp_path.join("ma$in"), "").unwrap(); - fs::write(temp_path.join("^main"), "").unwrap(); - fs::write(temp_path.join("main^"), "").unwrap(); - fs::write(temp_path.join("ma^in"), "").unwrap(); - fs::write(temp_path.join("!test"), "").unwrap(); - fs::write(temp_path.join("test!"), "").unwrap(); - fs::write(temp_path.join("te!st"), "").unwrap(); - fs::write(temp_path.join("'lib"), "").unwrap(); - fs::write(temp_path.join("lib'"), "").unwrap(); - fs::write(temp_path.join("li'b"), "").unwrap(); - - let command_manager = Arc::new(ForgeCommandManager::default()); - let completer = InputCompleter::new(temp_path, command_manager); - - (temp_dir, completer) - } - - #[test] - fn test_fuzzy_matching_works() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test fuzzy matching - "cfg" should match "config.rs" - let actual = completer.complete("@cfg", 4); - - // Should find config.rs and another_config.toml - assert!(!actual.is_empty()); - let config_match = actual.iter().find(|s| s.value.contains("config.rs")); - assert!( - config_match.is_some(), - "Should find config.rs with fuzzy matching" - ); - } - - #[test] - fn test_fuzzy_matching_ordering() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test that better matches come first - let actual = completer.complete("@config", 7); - - // config.rs should rank higher than another_config.toml for "config" query - assert!(actual.len() >= 2); - let first_match = &actual[0]; - assert!( - first_match.value.contains("config.rs"), - "config.rs should be the top match for 'config' query, got: {}", - first_match.value - ); - } - - #[test] - fn test_literal_fallback() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test that literal matching still works for exact substrings - let actual = completer.complete("@main", 5); - - assert!(!actual.is_empty()); - let main_match = actual.iter().find(|s| s.value.contains("main.rs")); - assert!( - main_match.is_some(), - "Should find main.rs with literal matching" - ); - } - - #[test] - fn test_special_character_dollar_end() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test dollar '$' at the end - let actual = completer.complete("@main$", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("main$")); - assert!( - match_found.is_some(), - "Should find main$ with literal matching in the end" - ); - } - - #[test] - fn test_special_character_dollar_start() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test dollar '$' at the start - let actual = completer.complete("@$main", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("$main")); - assert!( - match_found.is_some(), - "Should find $main with literal matching at the start" - ); - } - - #[test] - fn test_special_character_dollar_middle() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test dollar '$' in the middle - let actual = completer.complete("@ma$in", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("ma$in")); - assert!( - match_found.is_some(), - "Should find ma$in with literal matching in the middle" - ); - } - - #[test] - fn test_special_character_caret_start() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test caret '^' at the start - let actual = completer.complete("@^main", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("^main")); - assert!( - match_found.is_some(), - "Should find ^main with literal matching" - ); - } - - #[test] - fn test_special_character_caret_end() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test caret '^' at the end - let actual = completer.complete("@main^", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("main^")); - assert!( - match_found.is_some(), - "Should find main^ with literal matching" - ); - } - - #[test] - fn test_special_character_caret_middle() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test caret '^' in the middle - let actual = completer.complete("@ma^in", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("ma^in")); - assert!( - match_found.is_some(), - "Should find ma^in with literal matching" - ); - } - - #[test] - fn test_special_character_exclamation_start() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test exclamation '!' at the start - let actual = completer.complete("@!test", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("!test")); - assert!( - match_found.is_some(), - "Should find !test with literal matching" - ); - } - - #[test] - fn test_special_character_exclamation_end() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test exclamation '!' at the end - let actual = completer.complete("@test!", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("test!")); - assert!( - match_found.is_some(), - "Should find test! with literal matching" - ); - } - - #[test] - fn test_special_character_exclamation_middle() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test exclamation '!' in the middle - let actual = completer.complete("@te!st", 6); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("te!st")); - assert!( - match_found.is_some(), - "Should find te!st with literal matching" - ); - } - - #[test] - fn test_special_character_single_quote_start() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test single quote '\'' at the start - let actual = completer.complete("@'lib", 5); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("'lib")); - assert!( - match_found.is_some(), - "Should find 'lib with literal matching" - ); - } - - #[test] - fn test_special_character_single_quote_end() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test single quote '\'' at the end - let actual = completer.complete("@lib'", 5); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("lib'")); - assert!( - match_found.is_some(), - "Should find lib' with literal matching" - ); - } - - #[test] - fn test_special_character_single_quote_middle() { - let (_temp_dir, mut completer) = create_test_fixture(); - - // Test single quote '\'' in the middle - let actual = completer.complete("@li'b", 5); - - assert!(!actual.is_empty()); - let match_found = actual.iter().find(|s| s.value.contains("li'b")); - assert!( - match_found.is_some(), - "Should find li'b with literal matching" - ); - } +/// Returns `true` if the `bat` binary is available on `PATH`. +fn which_bat() -> bool { + std::process::Command::new("which") + .arg("bat") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) } diff --git a/crates/forge_main/src/editor.rs b/crates/forge_main/src/editor.rs index c787850bca..65be838ee6 100644 --- a/crates/forge_main/src/editor.rs +++ b/crates/forge_main/src/editor.rs @@ -12,6 +12,7 @@ use reedline::{ use super::completer::InputCompleter; use super::zsh::paste::wrap_pasted_text; +use crate::highlighter::ForgeHighlighter; use crate::model::ForgeCommandManager; use crate::prompt::ForgePrompt; @@ -92,6 +93,7 @@ impl ForgeEditor { let editor = Reedline::create() .with_completer(Box::new(InputCompleter::new(env.cwd, manager))) .with_history(history) + .with_highlighter(Box::new(ForgeHighlighter)) .with_hinter(Box::new( DefaultHinter::default().with_style(Style::new().fg(Color::DarkGray)), )) diff --git a/crates/forge_main/src/highlighter.rs b/crates/forge_main/src/highlighter.rs new file mode 100644 index 0000000000..48f7228563 --- /dev/null +++ b/crates/forge_main/src/highlighter.rs @@ -0,0 +1,292 @@ +use nu_ansi_term::{Color, Style}; +use reedline::{Highlighter, StyledText}; + +/// Syntax highlighter for the forge readline prompt. +/// +/// Applies visual styles to recognised input patterns as the user types: +/// - Commands (`:foo` or `/foo` for backward compatibility) are rendered in +/// yellow bold. +/// - File mentions (`@[path]`) are rendered in cyan bold. +/// - Shell pass-through commands (`!cmd`) are rendered in magenta. +/// - All other text is rendered in the default terminal style. +pub struct ForgeHighlighter; + +impl Highlighter for ForgeHighlighter { + fn highlight(&self, line: &str, _cursor: usize) -> StyledText { + let mut styled = StyledText::new(); + + if line.is_empty() { + return styled; + } + + // Command: highlight the command token (e.g. `:compact` or `/compact` for + // compat) in yellow bold, then the remainder (arguments) without + // special styling. + if line.starts_with('/') || line.starts_with(':') { + let end = line.find(|c: char| c.is_whitespace()).unwrap_or(line.len()); + styled.push(( + Style::new().bold().fg(Color::Yellow), + line[..end].to_string(), + )); + if end < line.len() { + highlight_mentions(&line[end..], &mut styled); + } + return styled; + } + + // Shell pass-through: `!` rendered in magenta. + if line.starts_with('!') { + styled.push((Style::new().fg(Color::Magenta), line.to_string())); + return styled; + } + + // General message text — scan for `@[...]` file mentions and colour them cyan + // bold. + highlight_mentions(line, &mut styled); + + styled + } +} + +/// Walk through `line` and emit styled segments, colouring every `@[...]` +/// mention (matching the ZSH pattern `@\[[^]]*\]`) in cyan bold and leaving +/// surrounding text unstyled. +/// +/// Mirrors `ZSH_HIGHLIGHT_PATTERNS+=('@\[[^]]#\]' 'fg=cyan,bold')` exactly: +/// - Requires `@[` opener. +/// - Matches zero or more non-`]` characters inside the brackets. +/// - Requires closing `]` — unterminated tags are left unstyled. +fn highlight_mentions(line: &str, styled: &mut StyledText) { + let mut remaining = line; + + while !remaining.is_empty() { + // Find the next `@[` opener. + match remaining.find("@[") { + None => { + // No more mentions — emit the rest as plain text. + styled.push((Style::new(), remaining.to_string())); + break; + } + Some(start) => { + // Emit any plain text before the `@[`. + if start > 0 { + styled.push((Style::new(), remaining[..start].to_string())); + } + + // `after_open` starts at `@[`. + let after_open = &remaining[start..]; + + // Look for a `]` that is not immediately after `@[`. + // The ZSH pattern `[^]]#` matches zero-or-more non-`]` chars, + // so `@[]` (empty brackets) also qualifies. + // We search for `]` starting from position 2 (after `@[`). + match after_open[2..].find(']') { + None => { + // No closing `]` — emit `@[` and the rest as plain text + // to match ZSH behaviour (unterminated tag = no highlight). + styled.push((Style::new(), after_open.to_string())); + break; + } + Some(rel_close) => { + // Absolute position of `]` within `after_open`. + let close = 2 + rel_close; + // Emit `@[...]` in cyan bold (inclusive of both brackets). + let mention = &after_open[..=close]; + styled.push((Style::new().bold().fg(Color::Cyan), mention.to_string())); + remaining = &after_open[close + 1..]; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn render(styled: &StyledText) -> String { + styled.buffer.iter().map(|(_, s)| s.as_str()).collect() + } + + fn styles(styled: &StyledText) -> Vec