Fix terminal path detection inside parentheses (#52222)

Mikhail Butvin and Claude Opus 4.6 (1M context) created

## Summary

Paths inside parentheses without a preceding space (e.g.
`Update(.claude/skills/sandbox/SKILL.md)` or `Write(/test/cool.rs)` from
Claude Code output) were not clickable in the terminal. The `(`
character was allowed as a middle character in the default path
hyperlink regex, causing the entire `Update(.path/here` to be matched as
a single invalid path.

**Changes:**

- Remove `(` from the middle-chars alternation (`[:(]` → `:`) in the
default path hyperlink regex, so `(` always acts as a path boundary —
consistent with it already being excluded from first and last character
sets. Preserves upstream's space exclusion after `:`.
- Iterate all regex matches in the line instead of only the first, so
the correct path (which may be the second match after a prefix like
`Update`) is found. This also simplifies the code by removing the
separate hovered-word search logic.

**Known tradeoff:**

Filenames with parentheses in the middle (e.g.
`docker-compose.prod(copy).yml`) are no longer matched as a single path.
This is uncommon in terminal output contexts (compiler errors, stack
traces, tool output) and is documented with a `should_panic` test.

**What doesn't break:**

- `(/path/file.js:321:13)` — `(` at word start is excluded by the
first-char rule (unchanged)
- Node.js stack traces like `at fn (/path/file.js:10:5)` — space before
`(` makes it a separate word
- All 38 terminal hyperlink tests pass

Related: #18556, #23774

Release Notes:

- Fixed terminal path detection for paths inside parentheses without
preceding space (e.g. `Update(path)` or `Write(path)` patterns from CLI
tool output)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

crates/terminal/src/terminal_hyperlinks.rs | 100 +++++++++++++----------
1 file changed, 55 insertions(+), 45 deletions(-)

Detailed changes

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<T: EventListener>(
     }
 }
 
+/// 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<usize> {
+    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<T>(
     term: &Term<T>,
     line_start: AlacPoint,
@@ -237,16 +263,10 @@ fn path_match<T>(
     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<T>(
         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<T>(
     }
     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<T>(
     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<T>(
             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 {