mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(terminal): stitch a wrapped line through the indent its continuation carries
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2e58da7479
commit
aae90599e5
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn> };
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user