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
52 changes: 52 additions & 0 deletions app/src/terminal/model/grid/grid_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,58 @@ impl GridHandler {
total_characters_scanned += 1;
}

// Try to extend the URL across a hard-wrap row boundary.
//
// Some programs (e.g. kiro-cli) emit explicit \r\n, creating rows whose
// WRAPLINE flag is absent (hard wraps). The grapheme cursor only follows soft
// wraps, so it stops at hard-wrap boundaries and truncates URLs that overflow
// the terminal width.
//
// When the detected URL ends near the right edge of a hard-wrapped row, scan
// the leading token of the following row. If it looks like a URL fragment
// (no separators, does not start with an uppercase letter) extend the range
// to include it.
//
// See: https://github.com/warpdotdev/warp/issues/11609
if !url.is_empty {
let url_end = *url.range.end();
let last_col = self.columns().saturating_sub(1);
// Require the URL to be within a few columns of the right edge to avoid
// false positives when a short URL ends in the middle of a wide terminal.
let near_right_edge = url_end.col + 4 >= last_col;
let row_hard_wrapped = !self.row_wraps(url_end.row);
let next_row_exists = self.row(url_end.row + 1).is_some();
if near_right_edge && row_hard_wrapped && next_row_exists {
let next_row_start = Point {
row: url_end.row + 1,
col: 0,
};
let mut continuation_cursor =
self.grapheme_cursor_from(next_row_start, grapheme_cursor::Wrap::None);
let mut is_first_char = true;
while let Some(item) = continuation_cursor.current_item() {
// Stay on the continuation row only.
if item.point().row != url_end.row + 1 {
break;
}
let cell = item.cell();
// Stop at any URL separator or whitespace.
if is_at_boundary(cell) || cell.c.is_whitespace() {
break;
}
// Reject continuations starting with an uppercase letter; these
// almost certainly begin a new sentence rather than a URL fragment.
// Example: "https://example.com\nFor more info" must not join.
if is_first_char && cell.c.is_uppercase() {
break;
}
is_first_char = false;
url.extend_link(item.point());
continuation_cursor.move_forward();
}
}
}

if url.is_empty || !url.range.contains(&original_point) {
None
} else {
Expand Down
66 changes: 66 additions & 0 deletions app/src/terminal/model/grid/grid_handler_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,72 @@ fn test_find_url_line_breaks() {
);
}

// Tests for hard-wrap URL continuation (issue #11609).
//
// kiro-cli and other TUI programs emit \r\n (hard wraps), which creates rows
// without the WRAPLINE flag. When a URL fills the terminal width and spills
// onto the next row, url_at_point should return the full cross-row URL.

#[test]
fn test_url_extends_across_hard_wrap_boundary() {
// "https://example.com/" is exactly 20 chars and fills the grid width.
// The \r\n between the rows means row 0 is hard-wrapped (no WRAPLINE flag).
// Hovering anywhere on the row-0 fragment should yield the full URL.
let blockgrid = mock_blockgrid("https://example.com/\r\npath");
let full_url = Link {
range: Point { row: 0, col: 0 }..=Point { row: 1, col: 3 },
is_empty: false,
};
assert_eq!(
blockgrid.grid_handler.url_at_point(Point { row: 0, col: 0 }),
Some(full_url.clone())
);
assert_eq!(
blockgrid.grid_handler.url_at_point(Point { row: 0, col: 10 }),
Some(full_url.clone())
);
assert_eq!(
blockgrid.grid_handler.url_at_point(Point { row: 0, col: 18 }),
Some(full_url)
);
// Hovering on the continuation row still returns None (backward scan cannot
// cross a hard-wrap boundary; only forward extension is implemented).
assert_eq!(
blockgrid.grid_handler.url_at_point(Point { row: 1, col: 0 }),
None
);
}

#[test]
fn test_url_hard_wrap_no_extend_for_uppercase_continuation() {
// When the following row starts with an uppercase letter the continuation
// looks like a new sentence and must NOT be joined with the URL.
let blockgrid = mock_blockgrid("https://example.com/\r\nFor more info");
let row0_only = Link {
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 19 },
is_empty: false,
};
assert_eq!(
blockgrid.grid_handler.url_at_point(Point { row: 0, col: 0 }),
Some(row0_only)
);
}

#[test]
fn test_url_hard_wrap_no_extend_for_space_continuation() {
// When the following row starts with whitespace (e.g. an indented block)
// the URL must NOT be extended.
let blockgrid = mock_blockgrid("https://example.com/\r\n more text");
let row0_only = Link {
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 19 },
is_empty: false,
};
assert_eq!(
blockgrid.grid_handler.url_at_point(Point { row: 0, col: 0 }),
Some(row0_only)
);
}

#[test]
fn test_find_url_wide_characters() {
let blockgrid = mock_blockgrid("https://google.com/啊啊啊啊");
Expand Down
8 changes: 6 additions & 2 deletions app/src/terminal/model/terminal_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1830,8 +1830,12 @@ impl TerminalModel {
respect_obfuscated_secrets: RespectObfuscatedSecrets,
) -> String {
let text = self.string_at_range(item, respect_obfuscated_secrets);
text.trim_matches(['\u{200B}', ' ', '\n', '\r', '\t'])
.to_owned()
// Trim leading/trailing whitespace and zero-width spaces.
// Also remove embedded \r/\n characters, which appear when the URL range
// spans a hard-wrapped row boundary (see issue #11609). RFC 3986 URLs
// cannot contain raw newlines, so this stripping is always safe.
let trimmed = text.trim_matches(['\u{200B}', ' ', '\n', '\r', '\t']);
trimmed.replace(['\n', '\r'], "")
}

/// Return all possible file paths containing the grid point ordered from longest to shortest.
Expand Down