authorgravatar for 103979764+ElSargo@users.noreply.github.comOliver Sargison <103979764+ElSargo@users.noreply.github.com> 2026-04-10 22:30:25-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 19:30:25-07:00
log1f4a3f65bf5e17298dde3aa6a4e8e4e5530a9e1d
tree63df8c31df5a6eba53f2b3f4050fd7d75fbbeae1
parent26cc1fffb33ca4aad36341cacb57424570c69bec
signature Signed by PGP key B5690EEEBB952194

Fix opening urls wrapped across lines in the terminal (#1913)


3 files changed, 233 insertions(+), 10 deletions(-)

apps/web/src/components/ThreadTerminalDrawer.tsx+18-10
...@@ -21,9 +21,12 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";...@@ -21,9 +21,12 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
21import { type TerminalContextSelection } from "~/lib/terminalContext";21import { type TerminalContextSelection } from "~/lib/terminalContext";
22import { openInPreferredEditor } from "../editorPreferences";22import { openInPreferredEditor } from "../editorPreferences";
23import {23import {
24 collectWrappedTerminalLinkLine,
24 extractTerminalLinks,25 extractTerminalLinks,
25 isTerminalLinkActivation,26 isTerminalLinkActivation,
26 resolvePathLinkTarget,27 resolvePathLinkTarget,
28 resolveWrappedTerminalLinkRange,
29 wrappedTerminalLinkRangeIntersectsBufferLine,
27} from "../terminal-links";30} from "../terminal-links";
28import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings";31import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings";
29import {32import {
...@@ -419,26 +422,31 @@ export function TerminalViewport({...@@ -419,26 +422,31 @@ export function TerminalViewport({
419 return;422 return;
420 }423 }
421424
422 const line = activeTerminal.buffer.active.getLine(bufferLineNumber - 1);425 const wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) =>
423 if (!line) {426 activeTerminal.buffer.active.getLine(bufferLineIndex),
427 );
428 if (!wrappedLine) {
424 callback(undefined);429 callback(undefined);
425 return;430 return;
426 }431 }
427432
428 const lineText = line.translateToString(true);433 const links = extractTerminalLinks(wrappedLine.text)
429 const matches = extractTerminalLinks(lineText);434 .map((match) => ({
430 if (matches.length === 0) {435 match,
436 range: resolveWrappedTerminalLinkRange(wrappedLine, match),
437 }))
438 .filter(({ range }) =>
439 wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber),
440 );
441 if (links.length === 0) {
431 callback(undefined);442 callback(undefined);
432 return;443 return;
433 }444 }
434445
435 callback(446 callback(
436 matches.map((match) => ({447 links.map(({ match, range }) => ({
437 text: match.text,448 text: match.text,
438 range: {449 range,
439 start: { x: match.start + 1, y: bufferLineNumber },
440 end: { x: match.end, y: bufferLineNumber },
441 },
442 activate: (event: MouseEvent) => {450 activate: (event: MouseEvent) => {
443 if (!isTerminalLinkActivation(event)) return;451 if (!isTerminalLinkActivation(event)) return;
444452
apps/web/src/terminal-links.test.ts+104
...@@ -1,11 +1,22 @@...@@ -1,11 +1,22 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 collectWrappedTerminalLinkLine,
4 extractTerminalLinks,5 extractTerminalLinks,
5 isTerminalLinkActivation,6 isTerminalLinkActivation,
6 resolvePathLinkTarget,7 resolvePathLinkTarget,
8 resolveWrappedTerminalLinkRange,
9 wrappedTerminalLinkRangeIntersectsBufferLine,
10 type TerminalBufferLineLike,
7} from "./terminal-links";11} from "./terminal-links";
812
13function createBufferLine(text: string, isWrapped = false): TerminalBufferLineLike {
14 return {
15 isWrapped,
16 translateToString: (trimRight = false) => (trimRight ? text.replace(/\s+$/u, "") : text),
17 };
18}
19
9describe("extractTerminalLinks", () => {20describe("extractTerminalLinks", () => {
10 it("finds http urls and path tokens", () => {21 it("finds http urls and path tokens", () => {
11 const line =22 const line =
...@@ -71,6 +82,99 @@ describe("extractTerminalLinks", () => {...@@ -71,6 +82,99 @@ describe("extractTerminalLinks", () => {
71 });82 });
72});83});
7384
85describe("collectWrappedTerminalLinkLine", () => {
86 it("reconstructs a wrapped line from any physical row", () => {
87 const firstSegment = "see https://example.com/a";
88 const secondSegment = "/bc?x=1";
89 const lines = [
90 createBufferLine("prompt> "),
91 createBufferLine(firstSegment),
92 createBufferLine(secondSegment, true),
93 createBufferLine("done"),
94 ];
95
96 const fromFirstRow = collectWrappedTerminalLinkLine(2, (index) => lines[index]);
97 const fromWrappedRow = collectWrappedTerminalLinkLine(3, (index) => lines[index]);
98
99 expect(fromFirstRow).toEqual({
100 text: `${firstSegment}${secondSegment}`,
101 segments: [
102 {
103 bufferLineNumber: 2,
104 text: firstSegment,
105 startIndex: 0,
106 endIndex: firstSegment.length,
107 },
108 {
109 bufferLineNumber: 3,
110 text: secondSegment,
111 startIndex: firstSegment.length,
112 endIndex: firstSegment.length + secondSegment.length,
113 },
114 ],
115 });
116 expect(fromWrappedRow).toEqual(fromFirstRow);
117 });
118
119 it("preserves trailing spaces on continued segments for downstream offsets", () => {
120 const firstSegment = "prefix ";
121 const secondSegment = "https://example.com/path";
122 const lines = [createBufferLine(firstSegment), createBufferLine(secondSegment, true)];
123
124 const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]);
125
126 expect(wrappedLine?.text).toBe(`${firstSegment}${secondSegment}`);
127 expect(extractTerminalLinks(wrappedLine?.text ?? "")).toEqual([
128 {
129 kind: "url",
130 text: secondSegment,
131 start: firstSegment.length,
132 end: firstSegment.length + secondSegment.length,
133 },
134 ]);
135 });
136});
137
138describe("resolveWrappedTerminalLinkRange", () => {
139 it("maps wrapped URL matches back to the correct buffer rows", () => {
140 const prefix = "see ";
141 const firstSegment = `${prefix}https://example.com/a`;
142 const secondSegment = "/bc?x=1";
143 const lines = [
144 createBufferLine("prompt> "),
145 createBufferLine(firstSegment),
146 createBufferLine(secondSegment, true),
147 ];
148 const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]);
149
150 expect(wrappedLine).not.toBeNull();
151 if (!wrappedLine) {
152 throw new Error("Expected wrapped terminal line to be present.");
153 }
154
155 const [match] = extractTerminalLinks(wrappedLine.text);
156 expect(match).toEqual({
157 kind: "url",
158 text: "https://example.com/a/bc?x=1",
159 start: prefix.length,
160 end: firstSegment.length + secondSegment.length,
161 });
162 if (!match) {
163 throw new Error("Expected wrapped URL match to be present.");
164 }
165
166 const range = resolveWrappedTerminalLinkRange(wrappedLine, match);
167
168 expect(range).toEqual({
169 start: { x: prefix.length + 1, y: 2 },
170 end: { x: secondSegment.length, y: 3 },
171 });
172 expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 2)).toBe(true);
173 expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 3)).toBe(true);
174 expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 4)).toBe(false);
175 });
176});
177
74describe("resolvePathLinkTarget", () => {178describe("resolvePathLinkTarget", () => {
75 it("resolves relative paths against cwd", () => {179 it("resolves relative paths against cwd", () => {
76 expect(180 expect(
apps/web/src/terminal-links.ts+111
...@@ -9,6 +9,33 @@ export interface TerminalLinkMatch {...@@ -9,6 +9,33 @@ export interface TerminalLinkMatch {
9 end: number;9 end: number;
10}10}
1111
12export interface TerminalLinkBufferPosition {
13 x: number;
14 y: number;
15}
16
17export interface TerminalLinkBufferRange {
18 start: TerminalLinkBufferPosition;
19 end: TerminalLinkBufferPosition;
20}
21
22export interface TerminalBufferLineLike {
23 readonly isWrapped?: boolean;
24 translateToString(trimRight?: boolean): string;
25}
26
27export interface WrappedTerminalLinkLineSegment {
28 bufferLineNumber: number;
29 text: string;
30 startIndex: number;
31 endIndex: number;
32}
33
34export interface WrappedTerminalLinkLine {
35 text: string;
36 segments: ReadonlyArray<WrappedTerminalLinkLineSegment>;
37}
38
12const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g;39const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g;
13const FILE_PATH_PATTERN =40const FILE_PATH_PATTERN =
14 /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g;41 /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g;
...@@ -145,6 +172,90 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] {...@@ -145,6 +172,90 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
145 return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start);172 return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start);
146}173}
147174
175export function collectWrappedTerminalLinkLine(
176 bufferLineNumber: number,
177 getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined,
178): WrappedTerminalLinkLine | null {
179 const anchorLine = getLine(bufferLineNumber - 1);
180 if (!anchorLine) return null;
181
182 let startBufferLineNumber = bufferLineNumber;
183 let startLine = anchorLine;
184
185 while (startBufferLineNumber > 1 && startLine.isWrapped) {
186 const previousLine = getLine(startBufferLineNumber - 2);
187 if (!previousLine) return null;
188 startBufferLineNumber -= 1;
189 startLine = previousLine;
190 }
191
192 const segments: WrappedTerminalLinkLineSegment[] = [];
193 let nextStartIndex = 0;
194 let currentBufferLineNumber = startBufferLineNumber;
195
196 while (true) {
197 const currentLine = getLine(currentBufferLineNumber - 1);
198 if (!currentLine) break;
199
200 const nextLine = getLine(currentBufferLineNumber);
201 const hasWrappedContinuation = nextLine?.isWrapped === true;
202 const text = currentLine.translateToString(!hasWrappedContinuation);
203
204 segments.push({
205 bufferLineNumber: currentBufferLineNumber,
206 text,
207 startIndex: nextStartIndex,
208 endIndex: nextStartIndex + text.length,
209 });
210 nextStartIndex += text.length;
211
212 if (!hasWrappedContinuation) break;
213 currentBufferLineNumber += 1;
214 }
215
216 return {
217 text: segments.map((segment) => segment.text).join(""),
218 segments,
219 };
220}
221
222function resolveCharacterPosition(
223 segments: ReadonlyArray<WrappedTerminalLinkLineSegment>,
224 characterIndex: number,
225): TerminalLinkBufferPosition {
226 for (const segment of segments) {
227 if (characterIndex < segment.endIndex) {
228 return {
229 x: characterIndex - segment.startIndex + 1,
230 y: segment.bufferLineNumber,
231 };
232 }
233 }
234
235 const lastSegment = segments[segments.length - 1];
236 return {
237 x: Math.max(lastSegment?.text.length ?? 0, 1),
238 y: lastSegment?.bufferLineNumber ?? 1,
239 };
240}
241
242export function resolveWrappedTerminalLinkRange(
243 wrappedLine: WrappedTerminalLinkLine,
244 match: Pick<TerminalLinkMatch, "start" | "end">,
245): TerminalLinkBufferRange {
246 return {
247 start: resolveCharacterPosition(wrappedLine.segments, match.start),
248 end: resolveCharacterPosition(wrappedLine.segments, match.end - 1),
249 };
250}
251
252export function wrappedTerminalLinkRangeIntersectsBufferLine(
253 range: TerminalLinkBufferRange,
254 bufferLineNumber: number,
255): boolean {
256 return range.start.y <= bufferLineNumber && bufferLineNumber <= range.end.y;
257}
258
148export function isTerminalLinkActivation(259export function isTerminalLinkActivation(
149 event: Pick<MouseEvent, "metaKey" | "ctrlKey">,260 event: Pick<MouseEvent, "metaKey" | "ctrlKey">,
150 platform = typeof navigator === "undefined" ? "" : navigator.platform,261 platform = typeof navigator === "undefined" ? "" : navigator.platform,