From 0c8271db4998a9ac5a164714e8b5054b444b3f31 Mon Sep 17 00:00:00 2001 From: jerch Date: Sat, 6 Jun 2026 18:01:49 +0200 Subject: [PATCH 1/6] fix atlas callstack exception (#6001) * fix atlas callstack exception * remove spread operator, integrate stress test into demo --- addons/addon-webgl/src/TextureAtlas.ts | 28 +++++++--- demo/client/components/window/webglWindow.ts | 54 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 9d42ca9e1..1c3e08a1c 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -1080,17 +1080,33 @@ class AtlasPage { size: number, sourcePages?: AtlasPage[] ) { - if (sourcePages) { - for (const p of sourcePages) { - this._glyphs.push(...p.glyphs); - this._usedPixels += p._usedPixels; - } - } this.canvas = createCanvas(document, size, size); // The canvas needs alpha because we use clearColor to convert the background color to alpha. // It might also contain some characters with transparent backgrounds if allowTransparency is // set. this.ctx = throwIfFalsy(this.canvas.getContext('2d', { alpha: true })); + if (sourcePages) { + if (sourcePages.length === 4) { + // optimized for quadmerge + this._glyphs = this._glyphs.concat( + sourcePages[0].glyphs, + sourcePages[1].glyphs, + sourcePages[2].glyphs, + sourcePages[3].glyphs + ); + this._usedPixels = sourcePages[0]._usedPixels + + sourcePages[1]._usedPixels + + sourcePages[2]._usedPixels + + sourcePages[3]._usedPixels; + } else { + // fallback for non quadmerges (should never be used) + for (let i = 0; i < sourcePages.length; ++i) { + this._glyphs = this._glyphs.concat(sourcePages[i].glyphs); + this._usedPixels += sourcePages[i]._usedPixels; + } + + } + } } public clear(): void { diff --git a/demo/client/components/window/webglWindow.ts b/demo/client/components/window/webglWindow.ts index 2886c3b57..d7c175c45 100644 --- a/demo/client/components/window/webglWindow.ts +++ b/demo/client/components/window/webglWindow.ts @@ -11,8 +11,33 @@ export class WebglWindow extends BaseWindow implements IControlWindow { public readonly label = 'webgl'; private _textureAtlasContainer!: HTMLElement; + private _stressRunning = false; + private _stressScrolling: HTMLInputElement | undefined; public build(container: HTMLElement): void { + const stressDiv = document.createElement('div'); + const stressStart = document.createElement('button'); + stressStart.id = 'stress-start'; + stressStart.textContent = 'atlas stress start'; + stressStart.addEventListener('click', () => this._stress()); + stressDiv.appendChild(stressStart); + + const stressStop = document.createElement('button'); + stressStop.id = 'stress-stop'; + stressStop.textContent = 'atlas stress stop'; + stressStop.addEventListener('click', () => this._stressRunning = false); + stressDiv.appendChild(stressStop); + + this._stressScrolling = document.createElement('input'); + this._stressScrolling.type = 'checkbox'; + this._stressScrolling.id = 'stress-scrolling'; + stressDiv.appendChild(this._stressScrolling); + const stressScrollingLabel = document.createElement('label'); + stressScrollingLabel.htmlFor = 'stress-scrolling'; + stressScrollingLabel.textContent = 'stress scrolling'; + stressDiv.appendChild(stressScrollingLabel); + container.appendChild(stressDiv); + const zoomCheckbox = document.createElement('input'); zoomCheckbox.type = 'checkbox'; zoomCheckbox.id = 'texture-atlas-zoom'; @@ -48,4 +73,33 @@ export class WebglWindow extends BaseWindow implements IControlWindow { canvas.style.width = `${canvas.width / dpr}px`; canvas.style.height = `${canvas.height / dpr}px`; } + + private async _stress(): Promise { + const TEXT = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; + if (this._stressRunning) { + return; + } + this._stressRunning = true; + const scrolling = !!(this._stressScrolling?.checked); + for (let r = 0; r < 256; r += 2) { + for (let g = 0; g < 256; g += 2) { + let s: string[] = []; + for (let b = 0; b < 256; b += 2) { + if (!this._stressRunning) { + return; + } + const rbg = `RGB: ${[r, g, b]}`; + s.push(`\r\x1b[38;2;${r};${g};${b}m${rbg.padEnd(18, ' ')}${TEXT}`); + if (s.length >= 16) { + if (scrolling) { + await new Promise(r => this._terminal.write(s.join('\r\n') + '\r\n', r)); + } else { + await new Promise(r => this._terminal.write('\x1b[H' + s.join('\r\n'), r)); + } + s = []; + } + } + } + } + } } From 596634ac4189852a15d0178e49201fc7d52c968d Mon Sep 17 00:00:00 2001 From: jerch Date: Sat, 6 Jun 2026 18:02:43 +0200 Subject: [PATCH 2/6] fix #6003 (#6004) --- src/common/input/TextDecoder.test.ts | 64 +++++++++++++++++++--------- src/common/input/TextDecoder.ts | 4 +- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/common/input/TextDecoder.test.ts b/src/common/input/TextDecoder.test.ts index b32d8a721..0276fc8c8 100644 --- a/src/common/input/TextDecoder.test.ts +++ b/src/common/input/TextDecoder.test.ts @@ -79,7 +79,7 @@ function assertDecodedRange( } const target = new Uint32Array(count); const length = decode(input, target); - assert.equal(length, count); + assert.strictEqual(length, count); let mismatchIndex = -1; let index = 0; for (let i = min; i < max; ++i) { @@ -92,8 +92,8 @@ function assertDecodedRange( } index++; } - assert.equal(mismatchIndex, -1); - assert.equal(outputToString(target, length), input); + assert.strictEqual(mismatchIndex, -1); + assert.strictEqual(outputToString(target, length), input); } const BATCH_SIZE = 8192; @@ -116,9 +116,9 @@ describe('text encodings', () => { const data = new Uint32Array(s.length); for (let i = 0; i < s.length; ++i) { data[i] = s.charCodeAt(i); - assert.equal(stringFromCodePoint(data[i]), s[i]); + assert.strictEqual(stringFromCodePoint(data[i]), s[i]); } - assert.equal(utf32ToString(data), s); + assert.strictEqual(utf32ToString(data), s); }); describe('StringToUtf32 decoder', () => { @@ -159,7 +159,7 @@ describe('text encodings', () => { const decoder = new StringToUtf32(); const target = new Uint32Array(5); const length = decoder.decode(String.fromCharCode(0xFEFF), target); - assert.equal(length, 0); + assert.strictEqual(length, 0); decoder.clear(); }); }); @@ -169,7 +169,7 @@ describe('text encodings', () => { const target = new Uint32Array(500); for (let i = 0; i < TEST_STRINGS.length; ++i) { const length = decoder.decode(TEST_STRINGS[i], target); - assert.equal(toString(target, length), TEST_STRINGS[i]); + assert.strictEqual(toString(target, length), TEST_STRINGS[i]); decoder.clear(); } }); @@ -184,7 +184,7 @@ describe('text encodings', () => { const written = decoder.decode(input[i], target); decoded += toString(target, written); } - assert(decoded, 'Γ„β‚¬π„žΓ–π„žβ‚¬Γœπ„žβ‚¬'); + assert.strictEqual(decoded, 'Γ„β‚¬π„žΓ–π„žβ‚¬Γœπ„žβ‚¬'); }); }); }); @@ -226,7 +226,7 @@ describe('text encodings', () => { const target = new Uint32Array(5); const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF)); const length = decoder.decode(utf8Data, target); - assert.equal(length, 0); + assert.strictEqual(length, 0); decoder.clear(); }); }); @@ -237,7 +237,7 @@ describe('text encodings', () => { for (let i = 0; i < TEST_STRINGS.length; ++i) { const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]); const length = decoder.decode(utf8Data, target); - assert.equal(toString(target, length), TEST_STRINGS[i]); + assert.strictEqual(toString(target, length), TEST_STRINGS[i]); decoder.clear(); } }); @@ -252,7 +252,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 1), target); decoded += toString(target, written); } - assert(decoded, 'Γ„Γ–ΓœΓŸΓΆΓ€ΓΌ'); + assert.strictEqual(decoded, 'Γ„Γ–ΓœΓŸΓΆΓ€ΓΌ'); }); it('2/3 byte sequences - advance by 1', () => { @@ -264,7 +264,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 1), target); decoded += toString(target, written); } - assert(decoded, 'Γ„β‚¬Γ–β‚¬Γœβ‚¬ΓŸβ‚¬ΓΆβ‚¬Γ€β‚¬ΓΌ'); + assert.strictEqual(decoded, 'Γ„β‚¬Γ–β‚¬Γœβ‚¬ΓŸβ‚¬ΓΆβ‚¬Γ€β‚¬ΓΌ'); }); it('2/3/4 byte sequences - advance by 1', () => { @@ -276,7 +276,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 1), target); decoded += toString(target, written); } - assert(decoded, 'Γ„β‚¬π„žΓ–π„žβ‚¬Γœπ„žβ‚¬'); + assert.strictEqual(decoded, 'Γ„β‚¬π„žΓ–π„žβ‚¬Γœπ„žβ‚¬'); }); it('2/3/4 byte sequences - advance by 2', () => { @@ -288,7 +288,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 2), target); decoded += toString(target, written); } - assert(decoded, 'Γ„β‚¬π„žΓ–π„žβ‚¬Γœπ„žβ‚¬'); + assert.strictEqual(decoded, 'Γ„β‚¬π„žΓ–π„žβ‚¬Γœπ„žβ‚¬'); }); it('2/3/4 byte sequences - advance by 3', () => { @@ -300,7 +300,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 3), target); decoded += toString(target, written); } - assert(decoded, 'Γ„β‚¬π„žΓ–π„žβ‚¬Γœπ„žβ‚¬'); + assert.strictEqual(decoded, 'Γ„β‚¬π„žΓ–π„žβ‚¬Γœπ„žβ‚¬'); }); it('BOMs (3 byte sequences) - advance by 2', () => { @@ -312,7 +312,7 @@ describe('text encodings', () => { const written = decoder.decode(utf8Data.slice(i, i + 2), target); decoded += toString(target, written); } - assert.equal(decoded, ''); + assert.strictEqual(decoded, ''); }); it('test break after 3 bytes - issue #2495', () => { @@ -320,11 +320,37 @@ describe('text encodings', () => { const target = new Uint32Array(5); const utf8Data = fromByteString('\xf0\xa0\x9c\x8e'); let written = decoder.decode(utf8Data.slice(0, 3), target); - assert.equal(written, 0); + assert.strictEqual(written, 0); written = decoder.decode(utf8Data.slice(3), target); - assert.equal(written, 1); - assert(toString(target, written), '𠜎'); + assert.strictEqual(written, 1); + assert.strictEqual(toString(target, written), '𠜎'); }); + + describe('0x80 not swallowed in continuation', () => { + it('Aβ€”B', () => { + const decoder = new Utf8ToUtf32(); + const target = new Uint32Array(5); + const utf8Data = new TextEncoder().encode('Aβ€”BAβ€”BAβ€”BAβ€”BAβ€”B'); + let decoded = ''; + for (let i = 0; i < utf8Data.length; i += 2) { + const written = decoder.decode(utf8Data.slice(i, i + 2), target); + decoded += toString(target, written); + } + assert.strictEqual(decoded, 'Aβ€”BAβ€”BAβ€”BAβ€”BAβ€”B'); + }); + it('A𐀀B', () => { + const decoder = new Utf8ToUtf32(); + const target = new Uint32Array(5); + const utf8Data = new TextEncoder().encode('A𐀀BA𐀀BA𐀀BA𐀀BA𐀀B'); + let decoded = ''; + for (let i = 0; i < utf8Data.length; i += 2) { + const written = decoder.decode(utf8Data.slice(i, i + 2), target); + decoded += toString(target, written); + } + assert.strictEqual(decoded, 'A𐀀BA𐀀BA𐀀BA𐀀BA𐀀B'); + }); + }); + }); }); }); diff --git a/src/common/input/TextDecoder.ts b/src/common/input/TextDecoder.ts index ac7f8d155..7e0495630 100644 --- a/src/common/input/TextDecoder.ts +++ b/src/common/input/TextDecoder.ts @@ -158,9 +158,9 @@ export class Utf8ToUtf32 { cp &= ((((cp & 0xE0) === 0xC0)) ? 0x1F : (((cp & 0xF0) === 0xE0)) ? 0x0F : 0x07); let pos = 0; let tmp: number; - while ((tmp = this.interim[++pos] & 0x3F) && pos < 4) { + while ((tmp = this.interim[++pos]) && pos < 4) { cp <<= 6; - cp |= tmp; + cp |= tmp & 0x3F; } // missing bytes - read ahead from input const type = (((this.interim[0] & 0xE0) === 0xC0)) ? 2 : (((this.interim[0] & 0xF0) === 0xE0)) ? 3 : 4; From 7c24c580072daea61f607c102d515f37cf2dbb17 Mon Sep 17 00:00:00 2001 From: jerch Date: Sat, 6 Jun 2026 18:07:03 +0200 Subject: [PATCH 3/6] fix non-ascii clipboard handling (#6002) * fix non-ascii clipboard handling * rename type helpers * make linter happy --- addons/addon-clipboard/src/ClipboardAddon.ts | 34 +++++++++++++++++-- .../test/ClipboardAddon.test.ts | 20 ++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/addons/addon-clipboard/src/ClipboardAddon.ts b/addons/addon-clipboard/src/ClipboardAddon.ts index 5e194880c..535c450be 100644 --- a/addons/addon-clipboard/src/ClipboardAddon.ts +++ b/addons/addon-clipboard/src/ClipboardAddon.ts @@ -78,13 +78,43 @@ export class BrowserClipboardProvider implements IClipboardProvider { } } +/** + * TODO: Once the base64 handling on Uint8Arrays is more common, + * remove the btoa/atob fallbacks below. + */ +interface IUint8ArrayB64 extends Uint8Array { + toBase64(): string; +} +interface IUint8ArrayB64Ctor extends Uint8ArrayConstructor { + fromBase64(s: string): IUint8ArrayB64; +} + export class Base64 implements IBase64 { public encodeText(data: string): string { - return btoa(data); + const bytes = new TextEncoder().encode(data) as IUint8ArrayB64; + if (bytes.toBase64 !== undefined) { + return bytes.toBase64(); + } + let bin = ''; + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]); + } + return btoa(bin); } public decodeText(data: string): string { + if ((Uint8Array as IUint8ArrayB64Ctor).fromBase64 !== undefined) { + try { + return new TextDecoder().decode((Uint8Array as IUint8ArrayB64Ctor).fromBase64(data)); + } catch {} + return ''; + } try { - return atob(data); + const bin = atob(data); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; ++i) { + bytes[i] = bin.charCodeAt(i); + } + return new TextDecoder().decode(bytes); } catch {} return ''; } diff --git a/addons/addon-clipboard/test/ClipboardAddon.test.ts b/addons/addon-clipboard/test/ClipboardAddon.test.ts index 2c04e619b..6995850d4 100644 --- a/addons/addon-clipboard/test/ClipboardAddon.test.ts +++ b/addons/addon-clipboard/test/ClipboardAddon.test.ts @@ -29,7 +29,7 @@ test.describe('ClipboardAddon', () => { await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']); } await ctx.page.evaluate(` - window.term.reset() + window.term.reset(); window.clipboard?.dispose(); window.clipboard = new ClipboardAddon(); window.term.loadAddon(window.clipboard); @@ -92,4 +92,22 @@ test.describe('ClipboardAddon', () => { deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), ''); }); }); + + test.describe('non-ASCII', () => { + const testDataEncoded = '4oKsbWzDpMO8dMOf'; + const testDataDecoded = '€mlÀütß'; + test('write simple string', async () => { + await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`); + deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded); + }); + test('read simple string', async () => { + await ctx.page.evaluate(` + window.data2 = []; + window.term.onData(e => data2.push(e)); + `); + await ctx.page.evaluate((d) => window.navigator.clipboard.writeText(d), testDataDecoded); + await ctx.proxy.write(`\x1b]52;c;?\x07`); + deepEqual(await ctx.page.evaluate('window.data2'), [`\x1b]52;c;${testDataEncoded}\x07`]); + }); + }); }); From f33f502267455f40e64a89a6a554b8d6beae6284 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:09:32 -0700 Subject: [PATCH 4/6] fix(DomRenderer): resilience when buffer lines are missing (#5998) * fix(DomRenderer): skip missing buffer lines during row render renderRows and link underline refresh stopped updating when any viewport row lacked buffer data, leaving stale DOM on rows below. Match ImageStorage.render behavior: clear the row element when present and continue with remaining rows. Co-authored-by: Cursor * fix(DomRenderer): clear blink state when skipping missing lines When a viewport row has no buffer line, renderRows clears the row DOM and continues but did not reset _rowHasBlinkingCells for that row. That could leave the text blink manager thinking blinking cells remain after the row was emptied. Clear blink state for the row when skipping a missing buffer line, including during link underline refresh. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- src/browser/renderer/dom/DomRenderer.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 383cf3025..657217f71 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -536,9 +536,14 @@ export class DomRenderer extends Disposable implements IRenderer { for (let y = start; y <= end; y++) { const row = y + buffer.ydisp; const rowElement = this._rowElements[y]; + if (!rowElement) { + continue; + } const lineData = buffer.lines.get(row); - if (!rowElement || !lineData) { - break; + if (!lineData) { + rowElement.replaceChildren(); + this._setRowBlinkState(y, false); + continue; } rowElement.replaceChildren( ...this._rowFactory.createRow( @@ -610,9 +615,14 @@ export class DomRenderer extends Disposable implements IRenderer { for (let i = y; i <= y2; ++i) { const row = i + buffer.ydisp; const rowElement = this._rowElements[i]; + if (!rowElement) { + continue; + } const bufferline = buffer.lines.get(row); - if (!rowElement || !bufferline) { - break; + if (!bufferline) { + rowElement.replaceChildren(); + this._setRowBlinkState(i, false); + continue; } rowElement.replaceChildren( ...this._rowFactory.createRow( From 2fe3fd13a164f956d0c6a2365fa89feaf4366074 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:15:09 -0700 Subject: [PATCH 5/6] fix(addon-webgl): skip missing buffer lines in _updateModel (#5991) * fix(addon-webgl): skip missing buffer lines in _updateModel _updateModel force-unwrapped buffer.lines.get(row), which can be undefined during trim or resize. That threw and left stale glyphs on rows below the gap. Clear the WebGL model row and glyph cells, reset blink state, and continue with remaining viewport rows, matching DomRenderer and ImageStorage handling. Co-authored-by: Cursor * refactor(addon-webgl): extract _nullModelCell helper Co-authored-by: Daniel Imms --------- Co-authored-by: Cursor Co-authored-by: Daniel Imms --- addons/addon-webgl/src/WebglRenderer.ts | 27 +++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 2f38e03cb..a951efba5 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -456,7 +456,18 @@ export class WebglRenderer extends Disposable implements IRenderer { for (y = start; y <= end; y++) { row = y + terminal.buffer.ydisp; - line = terminal.buffer.lines.get(row)!; + const bufferLine = terminal.buffer.lines.get(row); + if (!bufferLine) { + this._model.lineLengths[y] = 0; + for (x = 0; x < terminal.cols; x++) { + j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL; + modelUpdated = true; + this._nullModelCell(x, y, j, 0, 0, 0); + } + this._setRowBlinkState(y, false); + continue; + } + line = bufferLine; let rowHasBlinkingCells = false; this._model.lineLengths[y] = 0; isCursorRow = cursorY === row; @@ -587,13 +598,9 @@ export class WebglRenderer extends Disposable implements IRenderer { // Null out non-first cells for (x++; x <= lastCharX; x++) { j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL; - this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0); - this._model.cells[j] = NULL_CELL_CODE; // Don't re-resolve the cell color since multi-colored ligature backgrounds are not // supported - this._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg; - this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg; - this._model.cells[j + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext; + this._nullModelCell(x, y, j, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext); } x--; // Go back to the previous update cell for next iteration } @@ -607,6 +614,14 @@ export class WebglRenderer extends Disposable implements IRenderer { this._updateTextBlinkState(); } + private _nullModelCell(x: number, y: number, cellIndex: number, bg: number, fg: number, ext: number): void { + this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, bg, fg, ext, NULL_CELL_CHAR, 0, 0); + this._model.cells[cellIndex] = NULL_CELL_CODE; + this._model.cells[cellIndex + RenderModelConstants.BG_OFFSET] = bg; + this._model.cells[cellIndex + RenderModelConstants.FG_OFFSET] = fg; + this._model.cells[cellIndex + RenderModelConstants.EXT_OFFSET] = ext; + } + private _resetBlinkingRowState(): void { this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false); this._rowHasBlinkingCellsCount = 0; From 3d929a1db0e01f94b3bd1c4f02d538ac5ea454ae Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:18:47 -0700 Subject: [PATCH 6/6] fix(selection): drag-scroll end row and trim start clamp (#5993) * fix(selection): drag-scroll end row matches last visible line Use ydisp + rows - 1 for selectionEnd when scrolling down during drag, consistent with finalSelectionEnd for select-all. Co-authored-by: Daniel Imms * fix(selection): reset start to origin when start row is trimmed When handleTrim removes the selection start row, clamp to [0,0] and refresh; do not keep a stale column on row 0. Co-authored-by: Daniel Imms --------- Co-authored-by: Cursor Agent Co-authored-by: Daniel Imms --- src/browser/selection/SelectionModel.test.ts | 7 +++++++ src/browser/selection/SelectionModel.ts | 5 +++-- src/browser/services/SelectionService.ts | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/browser/selection/SelectionModel.test.ts b/src/browser/selection/SelectionModel.test.ts index 5f813d7ee..10ca79a34 100644 --- a/src/browser/selection/SelectionModel.test.ts +++ b/src/browser/selection/SelectionModel.test.ts @@ -64,6 +64,13 @@ describe('SelectionModel', () => { assert.deepEqual(model.finalSelectionStart, undefined); assert.deepEqual(model.finalSelectionEnd, undefined); }); + it('should reset selection start to origin when start row is trimmed', () => { + model.selectionStart = [50, 0]; + model.selectionEnd = [10, 2]; + assert.equal(model.handleTrim(1), true); + assert.deepEqual(model.finalSelectionStart, [0, 0]); + assert.deepEqual(model.finalSelectionEnd, [10, 1]); + }); }); describe('finalSelectionStart', () => { diff --git a/src/browser/selection/SelectionModel.ts b/src/browser/selection/SelectionModel.ts index 7b62b230b..8f87f17e2 100644 --- a/src/browser/selection/SelectionModel.ts +++ b/src/browser/selection/SelectionModel.ts @@ -135,9 +135,10 @@ export class SelectionModel { return true; } - // If the selection start is trimmed, ensure the start column is 0. + // If the selection start row is trimmed away, reset to the buffer origin. if (this.selectionStart && this.selectionStart[1] < 0) { - this.selectionStart[1] = 0; + this.selectionStart = [0, 0]; + return true; } return false; } diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 576bdc525..39d8dd76b 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -704,7 +704,7 @@ export class SelectionService extends Disposable implements ISelectionService { if (this._activeSelectionMode !== SelectionMode.COLUMN) { this._model.selectionEnd[0] = this._bufferService.cols; } - this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows, buffer.lines.length - 1); + this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows - 1, buffer.lines.length - 1); } else { if (this._activeSelectionMode !== SelectionMode.COLUMN) { this._model.selectionEnd[0] = 0;