mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29: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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user