From aae90599e5aa3a89e6b8ff0b5583a69e9b237237 Mon Sep 17 00:00:00 2001 From: Rounak Datta Date: Wed, 19 Aug 2026 19:41:43 +0000 Subject: [PATCH] fix(terminal): stitch a wrapped line through the indent its continuation carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent's numbered list wraps its URL, and the link opened a PREFIX of it: 1. https://github.com/users/someone/packages/container/p ackage/thing opened `…/container/p`. The provider already stitched hard wraps — Ink emits a real newline, so nothing is flagged `isWrapped` and a row that fills the last column is taken as continuing — but it joined the row texts VERBATIM, and the continuation carries the list's own three-space indent. That whitespace lands in the middle of the token, which is exactly where the URL pattern stops. Flush-left wrapped URLs (Claude Code's own `/login`) worked, which is why this survived. The touch-selection helpers had the shallower version of the same bug: they walked `isWrapped` only, so `Line` grabbed the single row on screen rather than the logical line, and a long-press on a wrapped token selected only its visible half. So the reconstruction now lives in ONE place, `terminalLogicalLine` in constants.js, and both consumers use it — the link provider matching patterns over its text and the selection helpers measuring words and lines with it. A link that spans a wrap and a `Line` that stops at the screen edge were the same bug twice. The helper drops the leading whitespace of a HARD continuation (the program's indent) and keeps that of a SOFT one (the emulator inserts nothing, so it is real content), records the dropped width per segment so the offset↔cell mapping stays exact in both directions, trims only the final row so earlier offsets stay aligned to cells, and keeps the 12-row bound that stops a screenful of full-width output from being re-scanned on every hover. ⚠️ Selection spans are computed in CELLS, not text offsets: an xterm selection is one contiguous run, so a token spanning a hard wrap also covers the indent cells between its halves. A run that skipped them cannot be expressed, and would not match what is highlighted. Tests: `test/terminal-logical-line.test.ts` (8 cases: the indent drop, resolving from either row, both mapping directions, soft continuations kept verbatim, no over-reach past a short row, the row bound, final-row trimming, a missing row) and 5 in `terminal-touch-tap.test.ts` (the whole URL from either row, a token selected across the wrap, `Line` spanning both rows, no reach into the next line). Removing either half of the fix reds 5 and 8 of them respectively. Co-Authored-By: Claude Opus 5 (1M context) --- src/web/public/constants.js | 105 ++++++++++++++++++++++ src/web/public/terminal-ui.js | 123 +++++++++++--------------- test/terminal-logical-line.test.ts | 137 +++++++++++++++++++++++++++++ test/terminal-touch-tap.test.ts | 88 ++++++++++++++++++ 4 files changed, 382 insertions(+), 71 deletions(-) create mode 100644 test/terminal-logical-line.test.ts diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 20df330a..e0b7ce2a 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1035,7 +1035,112 @@ function previewsInFileViewer(filePath) { return FILE_PREVIEW_EXTENSIONS.has(ext); } + +/** + * The LOGICAL line a terminal row belongs to — the rows it spans, its text as one + * string, and a two-way map between that string and terminal cells. + * + * One definition, two consumers: the link provider matches its patterns over this + * text (`registerFilePathLinkProvider`) and touch selection measures words and + * whole lines with it (`_touchSelectionLogicalLine`). They MUST agree — a link that + * spans a wrap and a "Line" that stops at the screen edge is the same bug twice. + * + * Two kinds of continuation, and handling only the first is not enough: + * + * 1. **Soft wrap** — the emulator ran out of columns and flags the next row + * `isWrapped`. It inserts nothing, so the row's text is joined verbatim. + * 2. **Hard wrap** — the program wrapped the text itself and emitted a real + * newline, so nothing is flagged. A row that fills the last column is taken + * as continuing into the next; that is the only trace a hard wrap leaves. + * + * ⚠️ A hard-wrapped continuation may carry the program's own INDENT, and joining + * that verbatim puts whitespace in the middle of the token being stitched. That is + * why an agent's numbered list — + * + * 1. https://github.com/users/someone/packages/container/p + * ackage/thing + * + * — opened only `…/container/p`: the URL pattern stops at the space the indent + * contributed. So the leading whitespace of a HARD continuation is dropped, and + * `colStart` on that segment records how much, keeping the cell mapping exact. A + * soft continuation keeps its leading whitespace, since the terminal never adds + * any and it is therefore real content. + * + * ⚠️ Only the final row is trimmed. Continuation rows are read UNTRIMMED so each + * contributes exactly `cols` cells; trimming one would shift every later offset. + * + * The row span is bounded by `maxRows` (12 by default): this runs on every hover, + * and a screenful of full-width output would otherwise re-scan the viewport each + * time. + * + * @param {{getLine: (row: number) => any, length: number}} buffer xterm buffer. + * @param {number} row 0-based ABSOLUTE buffer row to expand around. + * @param {number} cols Terminal width. + * @param {number} [maxRows] Row-span bound. + * @returns {{startRow: number, endRow: number, text: string, + * offsetToCell: (offset: number) => {row: number, col: number}, + * cellToOffset: (row: number, col: number) => number} | null} + * 0-based rows and columns throughout; null when the row does not exist. + */ +function terminalLogicalLine(buffer, row, cols, maxRows) { + if (!buffer || typeof buffer.getLine !== 'function') return null; + const width = Math.max(1, cols || 1); + const bound = Math.max(1, maxRows || 12); + const lineAt = (r) => (r >= 0 ? buffer.getLine(r) : undefined); + if (!lineAt(row)) return null; + + const continuesPrevious = (r) => { + if (r <= 0) return false; + if (lineAt(r)?.isWrapped) return true; + const prev = lineAt(r - 1); + return !!prev && (prev.translateToString(true) || '').length >= width; + }; + + let startRow = row; + while (startRow > 0 && row - startRow < bound && continuesPrevious(startRow)) startRow--; + let endRow = row; + const length = Number.isFinite(buffer.length) ? buffer.length : endRow + 1; + while (endRow + 1 < length && endRow - startRow < bound && continuesPrevious(endRow + 1)) endRow++; + + const segments = []; + let text = ''; + for (let r = startRow; r <= endRow; r++) { + const line = lineAt(r); + if (!line) break; + let rowText = line.translateToString(r === endRow) || ''; + let colStart = 0; + if (r > startRow && !line.isWrapped) { + const indent = rowText.length - rowText.replace(/^\s+/, '').length; + colStart = indent; + rowText = rowText.slice(indent); + } + segments.push({ row: r, textStart: text.length, colStart, length: rowText.length }); + text += rowText; + } + + const offsetToCell = (offset) => { + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]; + if (offset >= seg.textStart || i === 0) { + return { row: seg.row, col: seg.colStart + (offset - seg.textStart) }; + } + } + return { row: startRow, col: offset }; + }; + + const cellToOffset = (targetRow, targetCol) => { + for (const seg of segments) { + if (seg.row !== targetRow) continue; + return seg.textStart + Math.max(0, targetCol - seg.colStart); + } + return -1; + }; + + return { startRow, endRow, text, offsetToCell, cellToOffset }; +} + if (typeof window !== 'undefined') { window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine }; window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS }; + window.CodemanTerminalLines = { terminalLogicalLine }; } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index e7400a3d..2e6e0d53 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1419,63 +1419,32 @@ Object.assign(CodemanApp.prototype, { // Stitch the LOGICAL line back together. // - // xterm invokes this provider per visible ROW, and translateToString returns - // that row alone (the old comment here claimed otherwise). A URL or path - // longer than the terminal is wide therefore matched only as far as the row - // boundary, and the link opened a PREFIX of the real target. Walk out to both - // ends of the continuation, match against the joined text, and map offsets - // back to (x, y) so a link can span rows. - // - // Two different kinds of continuation, and handling only the first is not - // enough: - // 1. SOFT wrap: the emulator ran out of columns and flags the next row - // `isWrapped`. - // 2. HARD wrap: the program did its own wrapping and emitted a real - // newline, so nothing is flagged. Ink does this, which is why Claude - // Code's own `/login` URL was cut at the window edge, and why the - // clickable part grew when the window was widened. - // A row that fills the full width is treated as continuing into the next: - // that is the signal a hard wrap leaves behind, and a line that genuinely - // ended would stop short of the last column. - const cols = self.terminal.cols; - const rowAt = (r) => buffer.getLine(r - 1); - const continuesPrevious = (r) => { - if (r <= 1) return false; - if (rowAt(r)?.isWrapped) return true; - const prev = rowAt(r - 1); - return !!prev && prev.translateToString(true).length >= cols; - }; - + // xterm invokes this provider per visible ROW and translateToString returns + // that row alone, so a URL or path longer than the terminal is wide matched + // only as far as the row boundary and the link opened a PREFIX of the real + // target. `terminalLogicalLine` (constants.js) owns the reconstruction — + // both continuation kinds, the indent a hard wrap leaves on its + // continuation, and the offset↔cell mapping — because touch selection + // measures the SAME lines and the two must not disagree. // Bounded so a screenful of full-width output (wide tables, box drawing) // cannot make every hover stitch and re-scan the entire viewport. const MAX_STITCHED_ROWS = 12; - let startRow = bufferLineNumber; - while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) { - startRow--; + const logical = window.CodemanTerminalLines?.terminalLogicalLine( + buffer, + bufferLineNumber - 1, + self.terminal.cols, + MAX_STITCHED_ROWS + ); + if (!logical) { + callback(undefined); + return; } - let endRow = bufferLineNumber; - while (endRow < buffer.length && endRow - startRow < MAX_STITCHED_ROWS && continuesPrevious(endRow + 1)) { - endRow++; - } - - const rowTexts = []; - for (let r = startRow; r <= endRow; r++) { - const row = rowAt(r); - if (!row) break; - // Only the final row may be trimmed. Continuation rows fill the width by - // definition, and trimming one would shift every later offset. - rowTexts.push(row.translateToString(r === endRow)); - } - const lineText = rowTexts.join(''); + const lineText = logical.text; /** Map an offset in the stitched text back to a 1-based terminal cell. */ const coordAt = (index) => { - let rest = index; - for (let i = 0; i < rowTexts.length - 1; i++) { - if (rest < rowTexts[i].length) return { x: rest + 1, y: startRow + i }; - rest -= rowTexts[i].length; - } - return { x: rest + 1, y: startRow + rowTexts.length - 1 }; + const cell = logical.offsetToCell(index); + return { x: cell.col + 1, y: cell.row + 1 }; }; if (!lineText || !lineText.includes('/')) { @@ -1797,22 +1766,20 @@ Object.assign(CodemanApp.prototype, { }, /** - * The logical line a buffer row belongs to, as one string plus its start row. + * The logical line a buffer row belongs to — the SAME reconstruction the link + * provider matches against (`terminalLogicalLine`, constants.js). * - * ⚠️ Rows are read UNTRIMMED (`translateToString(false)`) so every row - * contributes exactly `cols` characters: the offset math below is linear over - * the joined text, and a trimmed row would silently shift every offset after it. + * ⚠️ Walking only `isWrapped` was not enough: Claude Code and every other Ink CLI + * wrap their own output and emit real newlines, so nothing is flagged and "Line" + * grabbed the one row on screen instead of the whole wrapped line. The shared + * helper treats a row that fills the last column as continuing, and drops the + * indent such a continuation carries. */ _touchSelectionLogicalLine(row) { const buffer = this.terminal?.buffer?.active; - if (!buffer?.getLine) return null; - let start = row; - while (start > 0 && buffer.getLine(start)?.isWrapped) start--; - let end = row; - while (end + 1 < buffer.length && buffer.getLine(end + 1)?.isWrapped) end++; - let text = ''; - for (let r = start; r <= end; r++) text += buffer.getLine(r)?.translateToString(false) ?? ''; - return { startRow: start, text }; + const cols = Math.max(1, this.terminal?.cols || 1); + if (!buffer || typeof window.CodemanTerminalLines?.terminalLogicalLine !== 'function') return null; + return window.CodemanTerminalLines.terminalLogicalLine(buffer, row, cols); }, /** @@ -1821,19 +1788,30 @@ Object.assign(CodemanApp.prototype, { * Whitespace is the only delimiter on purpose: in a terminal the thing worth * grabbing is a path, a URL, a container id or a hash, and every punctuation- * aware word rule cuts those in half. + * + * ⚠️ Bounds are found in the reconstructed TEXT (so a token is not cut at a wrap) + * and then converted to CELLS, because an xterm selection is one contiguous run of + * cells. A token spanning a hard wrap therefore also covers the indent cells + * between its halves — the alternative, a selection that skips them, cannot be + * expressed and would not match what is highlighted. */ _touchSelectionWordAt(cell) { const cols = Math.max(1, this.terminal?.cols || 1); const line = this._touchSelectionLogicalLine(cell.row); if (!line) return null; - const offset = (cell.row - line.startRow) * cols + cell.col; + const offset = line.cellToOffset(cell.row, cell.col); + if (offset < 0) return null; const ch = line.text[offset]; if (!ch || !ch.trim()) return null; // pressed on blank space: nothing to select let from = offset; while (from > 0 && line.text[from - 1] && line.text[from - 1].trim()) from--; let to = offset; while (to + 1 < line.text.length && line.text[to + 1] && line.text[to + 1].trim()) to++; - return { index: line.startRow * cols + from, length: to - from + 1 }; + const startCell = line.offsetToCell(from); + const endCell = line.offsetToCell(to); + const index = startCell.row * cols + startCell.col; + const length = endCell.row * cols + endCell.col - index + 1; + return length > 0 ? { index, length } : null; }, /** Apply a selection given absolute cell indices; `select()` wraps a length across rows. */ @@ -1923,13 +1901,16 @@ Object.assign(CodemanApp.prototype, { if (!anchor) return; const line = this._touchSelectionLogicalLine(Math.floor(anchor.index / cols)); if (!line) return; - // Trailing blanks are padding, not content: rows are read untrimmed so the - // offsets line up, and copying the pad would put a wall of spaces on the - // clipboard. - const length = line.text.replace(/\s+$/, '').length; - if (length === 0) return; - this._touchSelectionAnchor = { index: line.startRow * cols, length }; - this._applyTouchSelection(line.startRow * cols, length); + // Every row of the logical line, wraps included — that is the whole point of + // the button. The end is the last row's last non-blank cell: trailing cells are + // padding, and copying them would put a wall of spaces on the clipboard. + const buffer = this.terminal?.buffer?.active; + const lastRow = (buffer?.getLine(line.endRow)?.translateToString(true) || '').length; + const index = line.startRow * cols; + const length = line.endRow * cols + Math.max(0, lastRow - 1) - index + 1; + if (length <= 0) return; + this._touchSelectionAnchor = { index, length }; + this._applyTouchSelection(index, length); this._positionTouchSelectionBar(); }, diff --git a/test/terminal-logical-line.test.ts b/test/terminal-logical-line.test.ts new file mode 100644 index 00000000..6832216d --- /dev/null +++ b/test/terminal-logical-line.test.ts @@ -0,0 +1,137 @@ +// Port: none (a pure function in a vm context — no browser, no server). +// +// `terminalLogicalLine` (constants.js) reconstructs the logical line a terminal row +// belongs to. Two consumers depend on it and must not disagree: the link provider +// matches its patterns over this text, and touch selection measures words and whole +// lines with it. +// +// The bug it exists to fix, reported from a phone: an agent printing a numbered list +// +// 1. https://github.com/users/someone/packages/container/p +// ackage/thing +// +// opened only `…/container/p`. Ink wraps its own output and emits a real newline, so +// nothing is flagged `isWrapped`, and the continuation carries the list's indent — +// joining the rows verbatim put whitespace in the middle of the URL, where the +// pattern stops. "Line" was broken by the same shape: it walked `isWrapped` only, so +// it grabbed the single row on screen. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + +type Row = { text: string; wrapped?: boolean }; +type Logical = { + startRow: number; + endRow: number; + text: string; + offsetToCell: (offset: number) => { row: number; col: number }; + cellToOffset: (row: number, col: number) => number; +} | null; + +const COLS = 60; + +function load(): (buffer: unknown, row: number, cols: number, maxRows?: number) => Logical { + const context = vm.createContext({ console, window: undefined }); + vm.runInContext(`${SOURCE}\nglobalThis.__fn = terminalLogicalLine;`, context, { filename: 'constants.js' }); + return (context as { __fn: never })['__fn'] as never; +} + +/** An xterm-shaped buffer: untrimmed rows pad to the full width, as xterm's do. */ +function buffer(rows: Row[]) { + return { + length: rows.length, + getLine: (r: number) => + r >= 0 && r < rows.length + ? { + isWrapped: !!rows[r].wrapped, + translateToString: (trim?: boolean) => (trim === false ? rows[r].text.padEnd(COLS) : rows[r].text), + } + : undefined, + }; +} + +const terminalLogicalLine = load(); +const URL_RE = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/; + +describe('terminalLogicalLine', () => { + // Row 0 runs to the last column — the only trace a hard wrap leaves — and row 1 + // carries the three spaces the list indent put there. + const HEAD = '1. https://example.com/'; + const ROW0 = HEAD + 'a'.repeat(COLS - HEAD.length); + const ROW1 = ' ackage/thing'; + const HARD = [{ text: ROW0 }, { text: ROW1 }]; + + it('stitches a hard wrap and drops the indent the continuation carries', () => { + const line = terminalLogicalLine(buffer(HARD), 0, COLS)!; + + expect(line.startRow).toBe(0); + expect(line.endRow).toBe(1); + expect(line.text).toBe(ROW0 + 'ackage/thing'); + // The whole URL now matches, which is the entire point. + expect(line.text.match(URL_RE)![0]).toBe('https://example.com/' + 'a'.repeat(COLS - HEAD.length) + 'ackage/thing'); + }); + + it('finds the same line from the continuation row', () => { + // A tap or hover lands on either row; both must resolve the whole thing. + const line = terminalLogicalLine(buffer(HARD), 1, COLS)!; + + expect([line.startRow, line.endRow]).toEqual([0, 1]); + expect(line.text).toBe(ROW0 + 'ackage/thing'); + }); + + it('maps offsets back to the right cell across the dropped indent', () => { + const line = terminalLogicalLine(buffer(HARD), 0, COLS)!; + const at = line.text.indexOf('ackage/thing'); + + // 'a' of 'ackage' is the 4th cell of row 1 (0-based col 3), after the indent. + expect(line.offsetToCell(at)).toEqual({ row: 1, col: 3 }); + // …and the reverse direction agrees. + expect(line.cellToOffset(1, 3)).toBe(at); + // Row 0 is unshifted. + expect(line.offsetToCell(3)).toEqual({ row: 0, col: 3 }); + expect(line.cellToOffset(0, 3)).toBe(3); + }); + + it('keeps a SOFT continuation verbatim, indent and all', () => { + // The emulator inserts nothing when it wraps, so leading spaces there are real + // content and dropping them would corrupt the text. + const soft = [{ text: 'x'.repeat(COLS) }, { text: ' tail', wrapped: true }]; + const line = terminalLogicalLine(buffer(soft), 1, COLS)!; + + expect(line.text).toBe('x'.repeat(COLS) + ' tail'); + expect(line.offsetToCell(COLS)).toEqual({ row: 1, col: 0 }); + }); + + it('does not stitch a row that stops short of the last column', () => { + // A line that genuinely ended is not a wrap, and over-reaching would glue + // unrelated output into one link. + const rows = [{ text: 'short line' }, { text: 'next line' }]; + const line = terminalLogicalLine(buffer(rows), 0, COLS)!; + + expect([line.startRow, line.endRow]).toEqual([0, 0]); + expect(line.text).toBe('short line'); + }); + + it('bounds the span so a screenful of full-width output cannot be re-scanned per hover', () => { + const rows = Array.from({ length: 40 }, () => ({ text: 'y'.repeat(COLS) })); + const line = terminalLogicalLine(buffer(rows), 30, COLS, 4)!; + + expect(line.endRow - line.startRow).toBeLessThanOrEqual(4); + }); + + it('trims only the final row, so every earlier offset stays aligned to a cell', () => { + const rows = [{ text: 'z'.repeat(COLS) }, { text: 'tail' }]; + const line = terminalLogicalLine(buffer(rows), 0, COLS)!; + + // Row 0 contributes exactly COLS characters; the last row is trimmed. + expect(line.text).toBe('z'.repeat(COLS) + 'tail'); + expect(line.offsetToCell(COLS)).toEqual({ row: 1, col: 0 }); + }); + + it('answers null for a row that does not exist', () => { + expect(terminalLogicalLine(buffer([{ text: 'a' }]), 5, COLS)).toBeNull(); + }); +}); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 60734cde..f048c82a 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -1094,3 +1094,91 @@ describe('terminal touch selection', () => { expect(app._ensureTouchSelectionBar()).toBeNull(); }); }); + +describe('terminal wrapped-line handling', () => { + // Reported from a phone against the shipped fix: an agent's numbered list wraps its + // URL, and tapping it opened only the part on screen. Ink emits a real newline (so + // nothing is flagged isWrapped) and indents the continuation under the list marker, + // so joining the rows verbatim put whitespace inside the URL. "Line" broke the same + // way, grabbing the one visible row. + // + // Grid: 80 cols, 8×16 cells, screen rect at (0,0), viewportY 0. + const COLS = 80; + const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 }); + const press = (app: any, index: number, row = 0) => + app._beginTouchSelection(at(index, row).clientX, at(index, row).clientY); + const HEAD = '1. https://example.com/'; + // Row 0 runs to the last column, which is the only trace a hard wrap leaves. + const ROW0 = HEAD + 'a'.repeat(COLS - HEAD.length); + const ROW1 = ' ackage/thing'; + const FULL_URL = 'https://example.com/' + 'a'.repeat(COLS - HEAD.length) + 'ackage/thing'; + + function wrappedHarness() { + const { app, windowRef } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = vi.fn(); + app.terminal = createTerminalGrid([ROW0, ROW1, '❯ '], 2); + app.terminal.registerLinkProvider = vi.fn(); + app.openFilePreview = vi.fn(); + app.openLogViewerWindow = vi.fn(); + app._isExternalPreviewPath = () => false; + windowRef.open = vi.fn(); + app.registerFilePathLinkProvider(); + return { app, windowRef, select: app.terminal.select as ReturnType }; + } + + it('opens the WHOLE wrapped URL, not the part on screen', () => { + const { app, windowRef } = wrappedHarness(); + + expect(app._handleMobileTerminalTap(at(HEAD.length + 5), false, 'content')).toBe('link'); + expect(windowRef.open).toHaveBeenCalledWith(FULL_URL, '_blank', 'noopener,noreferrer'); + }); + + it('opens the whole URL from the continuation row too', () => { + // Tapping the second half is the natural gesture when that is what you can see. + const { app, windowRef } = wrappedHarness(); + + expect(app._handleMobileTerminalTap(at(5, 1), false, 'content')).toBe('link'); + expect(windowRef.open).toHaveBeenCalledWith(FULL_URL, '_blank', 'noopener,noreferrer'); + }); + + it('selects a token that spans the wrap, across both rows', () => { + const { app, select } = wrappedHarness(); + + press(app, 5, 1); // inside 'ackage/thing' on the continuation row + // From the URL's first cell (row 0, col 3) through the token's last cell + // (row 1, col 14). The run covers the indent cells between the halves, because + // an xterm selection is one contiguous run and a gap cannot be expressed. + const index = 3; + const end = COLS + ROW1.length - 1; + expect(select).toHaveBeenCalledWith(3, 0, end - index + 1); + }); + + it('Line takes every row of a HARD-wrapped line, not just the visible one', () => { + const { app, select } = wrappedHarness(); + press(app, HEAD.length + 5); + select.mockClear(); + + app._selectTouchSelectionLine(); + + // Row 0 col 0 through row 1's last non-blank cell. + expect(select).toHaveBeenCalledWith(0, 0, COLS + ROW1.length); + }); + + it('does not reach into the next line when a row stops short of the edge', () => { + // Over-reaching would glue unrelated output into one link or one "Line". + const { app, select } = loadTerminalUiHarness(); + void select; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app.terminal = createTerminalGrid(['short output', 'https://example.com/next', '❯ '], 2); + app.terminal.registerLinkProvider = vi.fn(); + app.registerFilePathLinkProvider(); + + press(app, 2); // inside 'short' + app._selectTouchSelectionLine(); + + expect(app.terminal.select).toHaveBeenCalledWith(0, 0, 'short output'.length); + }); +});