diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 7174261e67f6..7aab0ede1e50 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core"; +import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core"; import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, @@ -429,9 +429,9 @@ describe("GhosttyTerminalSurface visibility", () => { ); }); -const cell = (text: string): GhosttyCell => ({ +const cell = (text: string, wide: GhosttyCell["wide"] = GHOSTTY_CELL_WIDE.narrow): GhosttyCell => ({ text, - wide: 0, + wide, foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 }, bold: false, @@ -443,6 +443,22 @@ const cell = (text: string): GhosttyCell => ({ selected: false, }); +const rowFromCells = ( + cells: GhosttyCell[], + isWrapContinuation = false, + wrapsToNext = false, +): GhosttyRow => ({ + cells, + text: cells + .map((cell) => cell.text || " ") + .join("") + .trimEnd(), + isWrapContinuation, + wrapsToNext, +}); + +const textCells = (text: string): GhosttyCell[] => Array.from(text, (character) => cell(character)); + describe("isTerminalAltGraphText", () => { it("defers printable AltGr output to the textarea input event", () => { expect( @@ -510,6 +526,103 @@ describe("shouldBlinkTerminalCursor", () => { }); describe("terminalLinkAtPositionWithRange", () => { + it("keeps links contiguous across Ghostty spacer tails and preserves real blanks", () => { + const urlPrefix = "https://example.com/"; + const url = `${urlPrefix}日本/report`; + const urlRow = rowFromCells([ + ...textCells(urlPrefix), + cell("日"), + cell("", GHOSTTY_CELL_WIDE.spacerTail), + cell("本"), + cell("", GHOSTTY_CELL_WIDE.spacerTail), + ...textCells("/report"), + ]); + + for (const column of [urlPrefix.length, urlPrefix.length + 1, urlPrefix.length + 2]) { + expect(terminalLinkAtPositionWithRange([urlRow], 0, column)?.text).toBe(url); + } + + const path = "~/日本/report.ts"; + const pathStart = "~/".length; + const pathRow = rowFromCells([ + ...textCells("~/"), + cell("日"), + cell("", GHOSTTY_CELL_WIDE.spacerTail), + cell("本"), + cell("", GHOSTTY_CELL_WIDE.spacerTail), + ...textCells("/report.ts"), + ]); + expect(terminalLinkAtPositionWithRange([pathRow], 0, pathStart + 1)?.text).toBe(path); + + const separatedRow = rowFromCells([ + ...textCells(urlPrefix), + cell("日"), + cell("", GHOSTTY_CELL_WIDE.spacerTail), + cell(""), + ...textCells("next"), + ]); + expect(terminalLinkAtPositionWithRange([separatedRow], 0, urlPrefix.length)?.text).toBe( + "https://example.com/日", + ); + }); + + it("maps both cells of a trailing emoji and includes its spacer tail in the range", () => { + const prefix = "https://example.com/"; + const url = `${prefix}🙂`; + const row = rowFromCells([ + ...textCells(prefix), + cell("🙂"), + cell("", GHOSTTY_CELL_WIDE.spacerTail), + ]); + + for (const column of [prefix.length, prefix.length + 1]) { + expect(terminalLinkAtPositionWithRange([row], 0, column)).toEqual({ + text: url, + range: { + start: { x: 0, y: 0 }, + end: { x: prefix.length + 1, y: 0 }, + }, + }); + } + }); + + it("keeps spacer tails out of links split across wrapped rows", () => { + const firstRow = rowFromCells( + [...textCells("https://example.com/"), cell("日"), cell("", GHOSTTY_CELL_WIDE.spacerTail)], + false, + ); + const secondRow = rowFromCells( + [cell("本"), cell("", GHOSTTY_CELL_WIDE.spacerTail), ...textCells("/report")], + true, + ); + const rows = [firstRow, secondRow]; + + expect(terminalLinkAtPositionWithRange(rows, 0, "https://example.com/".length)?.text).toBe( + "https://example.com/日本/report", + ); + expect(terminalLinkAtPositionWithRange(rows, 1, 0)?.text).toBe( + "https://example.com/日本/report", + ); + }); + + it("does not turn a soft-wrap spacer head into a link-breaking blank", () => { + const firstRow = rowFromCells( + [...textCells("https://example.com/"), cell("", GHOSTTY_CELL_WIDE.spacerHead)], + false, + true, + ); + const secondRow = rowFromCells( + [cell("日"), cell("", GHOSTTY_CELL_WIDE.spacerTail), ...textCells("/report")], + true, + ); + const rows = [firstRow, secondRow]; + + expect(terminalLinkAtPositionWithRange(rows, 0, "https://example.com/".length)?.text).toBe( + "https://example.com/日/report", + ); + expect(terminalLinkAtPositionWithRange(rows, 1, 0)?.text).toBe("https://example.com/日/report"); + }); + it("maps terminal cells to UTF-16 offsets after a wide emoji", () => { const cells = [ cell("🙂"), diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 6a069902918a..81e71cc6c542 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -2,7 +2,9 @@ import { isMacPlatform } from "../../lib/utils"; import { SELECTION_MULTI_CLICK_INTERVAL_MS } from "../../lib/selectionActions"; import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links"; import { + GHOSTTY_CELL_WIDE, GhosttyTerminalCore, + type GhosttyCell, type GhosttyScrollbar, type GhosttySnapshot, type GhosttyTheme, @@ -231,15 +233,29 @@ export function terminalGridCellAt(options: { }; } +function terminalCellText(cell: GhosttyCell): string { + const isSpacer = + cell.wide === GHOSTTY_CELL_WIDE.spacerTail || cell.wide === GHOSTTY_CELL_WIDE.spacerHead; + return cell.text || (isSpacer ? "" : " "); +} + +function terminalCellTextLength(cell: GhosttyCell | undefined): number { + return cell ? terminalCellText(cell).length : 1; +} + function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { - const text = row.cells.map((cell) => cell.text || " ").join(""); + const text = row.cells.map(terminalCellText).join(""); return trimRight ? text.trimEnd() : text; } function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { let offset = 0; for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { - offset += row.cells[cellIndex]?.text.length || 1; + offset += terminalCellTextLength(row.cells[cellIndex]); + } + if (row.cells[column]?.wide === GHOSTTY_CELL_WIDE.spacerTail) { + const previousCell = row.cells[column - 1]; + if (previousCell?.text.length) offset -= previousCell.text.length; } return offset; } @@ -262,10 +278,22 @@ function isSameTerminalLink( ); } -function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number { +function terminalColumnAtOffset( + row: GhosttySnapshot["rowData"][number], + offset: number, + includeSpacerTail: boolean, +): number { + let textOffset = 0; for (let column = 0; column < row.cells.length; column += 1) { - const nextOffset = terminalColumnOffset(row, column + 1); - if (offset < nextOffset) return column; + const cell = row.cells[column]; + const cellLength = terminalCellTextLength(cell); + if (cellLength > 0 && offset < textOffset + cellLength) { + if (includeSpacerTail && row.cells[column + 1]?.wide === GHOSTTY_CELL_WIDE.spacerTail) { + return column + 1; + } + return column; + } + textOffset += cellLength; } return Math.max(0, row.cells.length - 1); } @@ -316,11 +344,11 @@ export function terminalLinkAtPositionWithRange( text: match.text, range: { start: { - x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex), + x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex, false), y: startSegment.bufferLineNumber - 1, }, end: { - x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex), + x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex, true), y: endSegment.bufferLineNumber - 1, }, },