diff --git a/crates/terminal/src/terminal_hyperlinks.rs b/crates/terminal/src/terminal_hyperlinks.rs index 0ca6cb2edd916019a4a7822830faa1fdfaa238f3..649c3bdee350801de1f9799ecc08cdd565813b1f 100644 --- a/crates/terminal/src/terminal_hyperlinks.rs +++ b/crates/terminal/src/terminal_hyperlinks.rs @@ -11,7 +11,6 @@ use alacritty_terminal::{ use log::{info, warn}; use regex::Regex; use std::{ - iter::{once, once_with}, ops::{Index, Range}, time::{Duration, Instant}, }; @@ -206,6 +205,33 @@ fn sanitize_url_punctuation( } } +/// Returns the byte offset just past the first unbalanced `(` in `s`, or `None` +/// if all parentheses are balanced. Used to strip prefixes like `Update(` from +/// path matches while preserving balanced parens in filenames like `file(copy).txt`. +fn first_unbalanced_open_paren(s: &str) -> Option { + let mut balance: i32 = 0; + let mut first_unmatched = None; + for (i, c) in s.char_indices() { + match c { + '(' => { + if balance == 0 { + first_unmatched = Some(i + c.len_utf8()); + } + balance += 1; + } + ')' => { + balance -= 1; + if balance <= 0 { + balance = 0; + first_unmatched = None; + } + } + _ => {} + } + } + first_unmatched.filter(|_| balance > 0) +} + fn path_match( term: &Term, line_start: AlacPoint, @@ -237,16 +263,10 @@ fn path_match( let first_cell = &term.grid()[line_start]; let mut prev_len = 0; line.push(first_cell.c); - let mut prev_char_is_space = first_cell.c == ' '; let mut hovered_point_byte_offset = None; - let mut hovered_word_start_offset = None; - let mut hovered_word_end_offset = None; if line_start == hovered { hovered_point_byte_offset = Some(0); - if first_cell.c != ' ' { - hovered_word_start_offset = Some(0); - } } for cell in term.grid().iter_from(line_start) { @@ -257,22 +277,8 @@ fn path_match( if !cell.flags.intersects(WIDE_CHAR_SPACERS) { prev_len = line.len(); match cell.c { - ' ' | '\t' => { - if hovered_point_byte_offset.is_some() && !prev_char_is_space { - if hovered_word_end_offset.is_none() { - hovered_word_end_offset = Some(line.len()); - } - } - line.push(' '); - prev_char_is_space = true; - } - c @ _ => { - if hovered_point_byte_offset.is_none() && prev_char_is_space { - hovered_word_start_offset = Some(line.len()); - } - line.push(c); - prev_char_is_space = false; - } + ' ' | '\t' => line.push(' '), + c => line.push(c), } } @@ -283,11 +289,6 @@ fn path_match( } let line = line.trim_ascii_end(); let hovered_point_byte_offset = hovered_point_byte_offset?; - let hovered_word_range = { - let word_start_offset = hovered_word_start_offset.unwrap_or(0); - (word_start_offset != 0) - .then_some(word_start_offset..hovered_word_end_offset.unwrap_or(line.len())) - }; if line.len() <= hovered_point_byte_offset { return None; } @@ -336,23 +337,9 @@ fn path_match( for regex in path_hyperlink_regexes { let mut path_found = false; - for (line_start_offset, captures) in once( - regex - .captures_iter(&line) - .next() - .map(|captures| (0, captures)), - ) - .chain(once_with(|| { - if let Some(hovered_word_range) = &hovered_word_range { - regex - .captures_iter(&line[hovered_word_range.clone()]) - .next() - .map(|captures| (hovered_word_range.start, captures)) - } else { - None - } - })) - .flatten() + for (line_start_offset, captures) in regex + .captures_iter(&line) + .map(|captures| (0usize, captures)) { path_found = true; let match_range = captures.get(0).unwrap().range(); @@ -379,6 +366,16 @@ fn path_match( link_range.start += line_start_offset; link_range.end += line_start_offset; + // Strip prefix up to the first unbalanced `(` in the matched path. + // This handles delimiter parens like `Update(.claude/SKILL.md)` while + // preserving balanced parens in filenames like `file(copy).txt`. + // Analogous to `sanitize_url_punctuation` which strips unbalanced + // trailing `)` from URLs. + if let Some(trim) = first_unbalanced_open_paren(&line[path_range.clone()]) { + path_range.start += trim; + link_range.start = link_range.start.max(path_range.start); + } + if !link_range.contains(&hovered_point_byte_offset) { // No match, just skip. continue; @@ -650,6 +647,12 @@ mod tests { test_path!(" Compiling Cool (‹«/👉test/Cool»›)"); test_path!(" Compiling Cool (/test/Cool👉)"); + // Tool output with path inside parens (e.g. Claude Code) + test_path!("Update👉(src/cool.rs)"); + test_path!("Update(‹«src/👉cool.rs»›)"); + test_path!("Update(src/cool.rs👉)"); + test_path!("Write(‹«/👉test/Cool»›)"); + // Python test_path!("‹«awe👉some.py»›"); test_path!("‹«👉a»› "); @@ -1003,6 +1006,13 @@ mod tests { test_path!("‹«/te:st/👉co:ol.r:s:4:2::::::»›"); test_path!("/test/cool.rs:::👉:"); } + + #[test] + // Filenames with balanced parentheses are preserved as a single path. + // Unbalanced leading `(` (e.g. `Update(.claude/SKILL.md)`) is stripped. + fn parens_in_filename() { + test_path!("‹«docker-compose.prod(👉copy).yml»›"); + } } mod windows {