From 64718444e2c27a4e005afbaab7993c52975c14e7 Mon Sep 17 00:00:00 2001 From: Will Hampson Date: Wed, 3 Jun 2026 06:00:44 -0700 Subject: [PATCH 01/11] fix(webgl): avoid glyph atlas mipmaps (#5987) Co-authored-by: Will Hampson Co-authored-by: jerch --- addons/addon-webgl/src/GlyphRenderer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index 4117525c4..be1990c2a 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -389,8 +389,9 @@ export class GlyphRenderer extends Disposable { gl.bindTexture(gl.TEXTURE_2D, this._atlasTextures[i].texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.pages[i].canvas); - gl.generateMipmap(gl.TEXTURE_2D); this._atlasTextures[i].version = atlas.pages[i].version; } From f3f4dcaf9fa295a4b3807391bf9336d741c8557e Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:05:19 -0700 Subject: [PATCH 02/11] fix(CharacterJoiner): bound join ranges to trimmed line length (#5990) Character joiner scanning already iterated cells with getTrimmedLength(), but trailing attribute runs and _stringRangesToCellRanges used buffer cols. On short lines that could run joiners on a single trailing cell and extend open ranges through empty tail columns, affecting ligature/join rendering. Use trimmed length consistently for trailing-run detection, the cell-range walk, and the final range end column. Co-authored-by: Cursor --- src/browser/services/CharacterJoinerService.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/browser/services/CharacterJoinerService.ts b/src/browser/services/CharacterJoinerService.ts index e1da0bd28..2cf3bd506 100644 --- a/src/browser/services/CharacterJoinerService.ts +++ b/src/browser/services/CharacterJoinerService.ts @@ -100,6 +100,7 @@ export class CharacterJoinerService implements ICharacterJoinerService { const ranges: [number, number][] = []; const lineStr = line.translateToString(true); + const trimmedLength = line.getTrimmedLength(); // Because some cells can be represented by multiple javascript characters, // we track the cell and the string indexes separately. This allows us to @@ -111,7 +112,7 @@ export class CharacterJoinerService implements ICharacterJoinerService { let rangeAttrFG = line.getFg(0); let rangeAttrBG = line.getBg(0); - for (let x = 0; x < line.getTrimmedLength(); x++) { + for (let x = 0; x < trimmedLength; x++) { line.loadCell(x, this._workCell); if (this._workCell.getWidth() === 0) { @@ -147,7 +148,7 @@ export class CharacterJoinerService implements ICharacterJoinerService { } // Process any trailing ranges. - if (this._bufferService.cols - rangeStartColumn > 1) { + if (trimmedLength - rangeStartColumn > 1) { const joinedRanges = this._getJoinedRanges( lineStr, rangeStartStringIndex, @@ -216,7 +217,8 @@ export class CharacterJoinerService implements ICharacterJoinerService { return; } - for (let x = startCol; x < this._bufferService.cols; x++) { + const trimmedLength = line.getTrimmedLength(); + for (let x = startCol; x < trimmedLength; x++) { const width = line.getWidth(x); const length = line.getString(x).length || WHITESPACE_CELL_CHAR.length; @@ -264,7 +266,7 @@ export class CharacterJoinerService implements ICharacterJoinerService { // If there is still a range left at the end, it must extend all the way to // the end of the line. if (currentRange) { - currentRange[1] = this._bufferService.cols; + currentRange[1] = trimmedLength; } } From 0c8271db4998a9ac5a164714e8b5054b444b3f31 Mon Sep 17 00:00:00 2001 From: jerch Date: Sat, 6 Jun 2026 18:01:49 +0200 Subject: [PATCH 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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; From c8f11d63b5f8b01f4bccbbf4d9bd25ef19e0dfad Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:43:22 -0700 Subject: [PATCH 09/11] fix(terminal): always run clear() at cursor home (#5992) Remove early return when ybase===0 && y===0 so clear() always clears markers, resets buffer lines, and refills the viewport. Co-authored-by: Cursor Agent Co-authored-by: Daniel Imms --- src/browser/CoreBrowserTerminal.ts | 4 ---- src/headless/Terminal.ts | 4 ---- 2 files changed, 8 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 2b024e72e..4557e1652 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -1072,10 +1072,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { * Clear the entire buffer, making the prompt line the new first line. */ public clear(): void { - if (this.buffer.ybase === 0 && this.buffer.y === 0) { - // Don't clear if it's already clear - return; - } this.buffer.clearAllMarkers(); this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!); this.buffer.lines.length = 1; diff --git a/src/headless/Terminal.ts b/src/headless/Terminal.ts index 4d90cc03e..16dc932be 100644 --- a/src/headless/Terminal.ts +++ b/src/headless/Terminal.ts @@ -99,10 +99,6 @@ export class Terminal extends CoreTerminal { * Clear the entire buffer, making the prompt line the new first line. */ public clear(): void { - if (this.buffer.ybase === 0 && this.buffer.y === 0) { - // Don't clear if it's already clear - return; - } this.buffer.clearAllMarkers(); this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!); this.buffer.lines.length = 1; From f57b8e1534ac7ab013002a09df140c470fddf9bf Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:58:42 -0700 Subject: [PATCH 10/11] Update GitHub Actions to Node 24 compatible versions (#6007) Upgrade actions/checkout, setup-node, upload-artifact, download-artifact, and codeql-action to their latest major versions that run on Node 24. Replace NathanFirmo/wait-for-other-job with native needs: build for test-unit and test-integration jobs. Fixes #6006 Co-authored-by: Cursor Agent Co-authored-by: Daniel Imms --- .github/workflows/ci.yml | 46 ++++++++++------------- .github/workflows/codeql-analysis.yml | 8 ++-- .github/workflows/copilot-setup-steps.yml | 4 +- .github/workflows/release.yml | 4 +- 4 files changed, 27 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 204faf12a..0f8385894 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Use Node.js 22.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: 22.x cache: 'npm' @@ -69,7 +69,7 @@ jobs: ./addons/addon-webgl/out/* \ ./addons/addon-webgl/out-*st/* - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: build-artifacts path: compressed-build.zip @@ -79,9 +79,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Use Node.js 22.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: 22.x cache: 'npm' @@ -100,16 +100,16 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Use Node.js 22.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: 22.x cache: 'npm' - name: Install dependencies run: | npm ci - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: build-artifacts - name: Unzip artifacts @@ -129,6 +129,7 @@ jobs: exit $EXIT_CODE test-unit: + needs: build timeout-minutes: 20 strategy: matrix: @@ -136,21 +137,16 @@ jobs: runs-on: [ubuntu, macos, windows] runs-on: ${{ matrix.runs-on }}-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Use Node.js ${{ matrix.node-version }}.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }}.x cache: 'npm' - name: Install dependencies run: | npm ci - - name: Wait for build job - uses: NathanFirmo/wait-for-other-job@v1.1.1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - job: build - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: build-artifacts - name: Unzip artifacts @@ -166,6 +162,7 @@ jobs: run: npm run test-unit --forbid-only test-integration: + needs: build timeout-minutes: 20 strategy: matrix: @@ -174,9 +171,9 @@ jobs: browser: [chromium, firefox, webkit] runs-on: ${{ matrix.runs-on }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Use Node.js ${{ matrix.node-version }}.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }}.x cache: 'npm' @@ -185,12 +182,7 @@ jobs: npm ci - name: Install playwright run: npx playwright install --with-deps ${{ matrix.browser }} - - name: Wait for build job - uses: NathanFirmo/wait-for-other-job@v1.1.1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - job: build - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: build-artifacts - name: Unzip artifacts @@ -240,9 +232,9 @@ jobs: matrix: node-version: [22] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Use Node.js ${{ matrix.node-version }}.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }}.x cache: 'npm' @@ -251,7 +243,7 @@ jobs: npm ci - name: Install playwright run: npx playwright install - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: build-artifacts - name: Unzip artifacts diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3ba91f7e9..f0d27975c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -24,12 +24,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index f4854bd28..79e0fc306 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -18,9 +18,9 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Use Node.js 22.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: 22.x cache: 'npm' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1e659ecf..3dacee1d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,9 +10,9 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Use Node.js 22.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: 22.x cache: 'npm' From 3cdbc77386af12e6812171cc3c525b569d076835 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:12:04 -0700 Subject: [PATCH 11/11] fix(addon-search): advance highlight scan by buffer match size (#5994) * fix(addon-search): advance highlight scan by buffer match size _highlightAllMatches() stepped to the next find() using term.length and col + 1 when staying on the same row. Search results expose size in buffer columns (wide glyphs, wrapped matches), which select() and decorations already use. Advance with col + size and wrap row/col when crossing the terminal width so highlight-all does not skip, duplicate, or stop early on non-ASCII matches. Co-authored-by: Cursor * test(addon-search): regression for highlight-all wide char advance by size Co-authored-by: Daniel Imms * test(addon-search): move highlight-all regression to integration tests Co-authored-by: Daniel Imms --------- Co-authored-by: Cursor Co-authored-by: Daniel Imms --- addons/addon-search/src/SearchAddon.ts | 14 ++++++++------ addons/addon-search/test/SearchAddon.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/addons/addon-search/src/SearchAddon.ts b/addons/addon-search/src/SearchAddon.ts index cb4094743..e5fd31cfb 100644 --- a/addons/addon-search/src/SearchAddon.ts +++ b/addons/addon-search/src/SearchAddon.ts @@ -142,12 +142,14 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp } prevResult = result; results.push(prevResult); - result = this._engine.find( - term, - prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row, - prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1, - searchOptions - ); + const cols = this._terminal.cols; + let nextCol = prevResult.col + prevResult.size; + let nextRow = prevResult.row; + if (nextCol >= cols) { + nextRow += Math.floor(nextCol / cols); + nextCol = nextCol % cols; + } + result = this._engine.find(term, nextRow, nextCol, searchOptions); } this._resultTracker.updateResults(results, this._highlightLimit); diff --git a/addons/addon-search/test/SearchAddon.test.ts b/addons/addon-search/test/SearchAddon.test.ts index 3438772a8..4a62aca7f 100644 --- a/addons/addon-search/test/SearchAddon.test.ts +++ b/addons/addon-search/test/SearchAddon.test.ts @@ -414,6 +414,20 @@ test.describe('Search Tests', () => { }); test.describe('Regression tests', () => { + test('should advance highlight-all scan by buffer match size for wide characters', async () => { + await ctx.page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + await ctx.proxy.resize(3, 5); + await ctx.proxy.write('π„žπ„žπ„ž'); + strictEqual(await ctx.page.evaluate(`window.search.findNext('π„ž', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true); + deepStrictEqual(await ctx.page.evaluate('window.calls'), [ + { resultCount: 3, resultIndex: 0 } + ]); + await ctx.proxy.resize(80, 24); + }); + test.describe('#2444 wrapped line content not being found', () => { let fixture: string; test.beforeAll(async () => {