From 1f4a3f65bf5e17298dde3aa6a4e8e4e5530a9e1d Mon Sep 17 00:00:00 2001 From: Oliver Sargison <103979764+ElSargo@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:30:25 -0400 Subject: [PATCH] Fix opening urls wrapped across lines in the terminal (#1913) --- .../src/components/ThreadTerminalDrawer.tsx | 28 +++-- apps/web/src/terminal-links.test.ts | 104 ++++++++++++++++ apps/web/src/terminal-links.ts | 111 ++++++++++++++++++ 3 files changed, 233 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index c753508a0603f026fabdc6eee7843cebfca98105..91f4dbc1b361980445379a8f2145956c92543381 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -21,9 +21,12 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { openInPreferredEditor } from "../editorPreferences"; import { + collectWrappedTerminalLinkLine, extractTerminalLinks, isTerminalLinkActivation, resolvePathLinkTarget, + resolveWrappedTerminalLinkRange, + wrappedTerminalLinkRangeIntersectsBufferLine, } from "../terminal-links"; import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; import { @@ -419,26 +422,31 @@ export function TerminalViewport({ return; } - const line = activeTerminal.buffer.active.getLine(bufferLineNumber - 1); - if (!line) { + const wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) => + activeTerminal.buffer.active.getLine(bufferLineIndex), + ); + if (!wrappedLine) { callback(undefined); return; } - const lineText = line.translateToString(true); - const matches = extractTerminalLinks(lineText); - if (matches.length === 0) { + const links = extractTerminalLinks(wrappedLine.text) + .map((match) => ({ + match, + range: resolveWrappedTerminalLinkRange(wrappedLine, match), + })) + .filter(({ range }) => + wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber), + ); + if (links.length === 0) { callback(undefined); return; } callback( - matches.map((match) => ({ + links.map(({ match, range }) => ({ text: match.text, - range: { - start: { x: match.start + 1, y: bufferLineNumber }, - end: { x: match.end, y: bufferLineNumber }, - }, + range, activate: (event: MouseEvent) => { if (!isTerminalLinkActivation(event)) return; diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index db0544fcefb7fe33e2e501b83716f8946473f03f..6442c0667b92b3854fcd85546effbbf04827c67c 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -1,11 +1,22 @@ import { describe, expect, it } from "vitest"; import { + collectWrappedTerminalLinkLine, extractTerminalLinks, isTerminalLinkActivation, resolvePathLinkTarget, + resolveWrappedTerminalLinkRange, + wrappedTerminalLinkRangeIntersectsBufferLine, + type TerminalBufferLineLike, } from "./terminal-links"; +function createBufferLine(text: string, isWrapped = false): TerminalBufferLineLike { + return { + isWrapped, + translateToString: (trimRight = false) => (trimRight ? text.replace(/\s+$/u, "") : text), + }; +} + describe("extractTerminalLinks", () => { it("finds http urls and path tokens", () => { const line = @@ -71,6 +82,99 @@ describe("extractTerminalLinks", () => { }); }); +describe("collectWrappedTerminalLinkLine", () => { + it("reconstructs a wrapped line from any physical row", () => { + const firstSegment = "see https://example.com/a"; + const secondSegment = "/bc?x=1"; + const lines = [ + createBufferLine("prompt> "), + createBufferLine(firstSegment), + createBufferLine(secondSegment, true), + createBufferLine("done"), + ]; + + const fromFirstRow = collectWrappedTerminalLinkLine(2, (index) => lines[index]); + const fromWrappedRow = collectWrappedTerminalLinkLine(3, (index) => lines[index]); + + expect(fromFirstRow).toEqual({ + text: `${firstSegment}${secondSegment}`, + segments: [ + { + bufferLineNumber: 2, + text: firstSegment, + startIndex: 0, + endIndex: firstSegment.length, + }, + { + bufferLineNumber: 3, + text: secondSegment, + startIndex: firstSegment.length, + endIndex: firstSegment.length + secondSegment.length, + }, + ], + }); + expect(fromWrappedRow).toEqual(fromFirstRow); + }); + + it("preserves trailing spaces on continued segments for downstream offsets", () => { + const firstSegment = "prefix "; + const secondSegment = "https://example.com/path"; + const lines = [createBufferLine(firstSegment), createBufferLine(secondSegment, true)]; + + const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]); + + expect(wrappedLine?.text).toBe(`${firstSegment}${secondSegment}`); + expect(extractTerminalLinks(wrappedLine?.text ?? "")).toEqual([ + { + kind: "url", + text: secondSegment, + start: firstSegment.length, + end: firstSegment.length + secondSegment.length, + }, + ]); + }); +}); + +describe("resolveWrappedTerminalLinkRange", () => { + it("maps wrapped URL matches back to the correct buffer rows", () => { + const prefix = "see "; + const firstSegment = `${prefix}https://example.com/a`; + const secondSegment = "/bc?x=1"; + const lines = [ + createBufferLine("prompt> "), + createBufferLine(firstSegment), + createBufferLine(secondSegment, true), + ]; + const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]); + + expect(wrappedLine).not.toBeNull(); + if (!wrappedLine) { + throw new Error("Expected wrapped terminal line to be present."); + } + + const [match] = extractTerminalLinks(wrappedLine.text); + expect(match).toEqual({ + kind: "url", + text: "https://example.com/a/bc?x=1", + start: prefix.length, + end: firstSegment.length + secondSegment.length, + }); + if (!match) { + throw new Error("Expected wrapped URL match to be present."); + } + + const range = resolveWrappedTerminalLinkRange(wrappedLine, match); + + expect(range).toEqual({ + start: { x: prefix.length + 1, y: 2 }, + end: { x: secondSegment.length, y: 3 }, + }); + expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 2)).toBe(true); + expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 3)).toBe(true); + expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 4)).toBe(false); + }); +}); + describe("resolvePathLinkTarget", () => { it("resolves relative paths against cwd", () => { expect( diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index e07904e180a17f684f66a84afdfa0dd89ee687a2..0d40a90e415413be1945cf819fd50b66edf4e0bc 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -9,6 +9,33 @@ export interface TerminalLinkMatch { end: number; } +export interface TerminalLinkBufferPosition { + x: number; + y: number; +} + +export interface TerminalLinkBufferRange { + start: TerminalLinkBufferPosition; + end: TerminalLinkBufferPosition; +} + +export interface TerminalBufferLineLike { + readonly isWrapped?: boolean; + translateToString(trimRight?: boolean): string; +} + +export interface WrappedTerminalLinkLineSegment { + bufferLineNumber: number; + text: string; + startIndex: number; + endIndex: number; +} + +export interface WrappedTerminalLinkLine { + text: string; + segments: ReadonlyArray; +} + const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; const FILE_PATH_PATTERN = /(?:~\/|\.{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[] { return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start); } +export function collectWrappedTerminalLinkLine( + bufferLineNumber: number, + getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined, +): WrappedTerminalLinkLine | null { + const anchorLine = getLine(bufferLineNumber - 1); + if (!anchorLine) return null; + + let startBufferLineNumber = bufferLineNumber; + let startLine = anchorLine; + + while (startBufferLineNumber > 1 && startLine.isWrapped) { + const previousLine = getLine(startBufferLineNumber - 2); + if (!previousLine) return null; + startBufferLineNumber -= 1; + startLine = previousLine; + } + + const segments: WrappedTerminalLinkLineSegment[] = []; + let nextStartIndex = 0; + let currentBufferLineNumber = startBufferLineNumber; + + while (true) { + const currentLine = getLine(currentBufferLineNumber - 1); + if (!currentLine) break; + + const nextLine = getLine(currentBufferLineNumber); + const hasWrappedContinuation = nextLine?.isWrapped === true; + const text = currentLine.translateToString(!hasWrappedContinuation); + + segments.push({ + bufferLineNumber: currentBufferLineNumber, + text, + startIndex: nextStartIndex, + endIndex: nextStartIndex + text.length, + }); + nextStartIndex += text.length; + + if (!hasWrappedContinuation) break; + currentBufferLineNumber += 1; + } + + return { + text: segments.map((segment) => segment.text).join(""), + segments, + }; +} + +function resolveCharacterPosition( + segments: ReadonlyArray, + characterIndex: number, +): TerminalLinkBufferPosition { + for (const segment of segments) { + if (characterIndex < segment.endIndex) { + return { + x: characterIndex - segment.startIndex + 1, + y: segment.bufferLineNumber, + }; + } + } + + const lastSegment = segments[segments.length - 1]; + return { + x: Math.max(lastSegment?.text.length ?? 0, 1), + y: lastSegment?.bufferLineNumber ?? 1, + }; +} + +export function resolveWrappedTerminalLinkRange( + wrappedLine: WrappedTerminalLinkLine, + match: Pick, +): TerminalLinkBufferRange { + return { + start: resolveCharacterPosition(wrappedLine.segments, match.start), + end: resolveCharacterPosition(wrappedLine.segments, match.end - 1), + }; +} + +export function wrappedTerminalLinkRangeIntersectsBufferLine( + range: TerminalLinkBufferRange, + bufferLineNumber: number, +): boolean { + return range.start.y <= bufferLineNumber && bufferLineNumber <= range.end.y; +} + export function isTerminalLinkActivation( event: Pick, platform = typeof navigator === "undefined" ? "" : navigator.platform, -- 2.54.0