From a51c7f8c73bbbab4153573e8f2cdda4b5136b77b Mon Sep 17 00:00:00 2001 From: Jai Date: Wed, 18 Feb 2026 07:54:37 -0800 Subject: [PATCH 01/10] editor: add 'foldedLine' unit to cursorMove command Add a new 'foldedLine' movement unit to the `cursorMove` command that moves by model lines while treating each folded region as a single step. When moving down/up by 'wrappedLine' the cursor skips folds naturally because it operates in view space. When moving by 'line' it uses model coordinates and can land inside a fold, causing VS Code to auto-unfold it. The new 'foldedLine' unit moves in model space but queries `viewModel.getHiddenAreas()` to detect folds and jump to the first visible line past each one, so folds are skipped without being opened. This is the semantics needed by vim's j/k motions (VSCodeVim/Vim#1004): each fold counts as exactly one step, matching how real vim treats folds. Fixes: https://github.com/VSCodeVim/Vim/issues/1004 --- .../common/cursor/cursorMoveCommands.ts | 94 ++++++++++++++++++- .../controller/cursorMoveCommand.test.ts | 94 +++++++++++++++++++ 2 files changed, 185 insertions(+), 3 deletions(-) diff --git a/src/vs/editor/common/cursor/cursorMoveCommands.ts b/src/vs/editor/common/cursor/cursorMoveCommands.ts index a447e3a8f7598..cbad4c981493c 100644 --- a/src/vs/editor/common/cursor/cursorMoveCommands.ts +++ b/src/vs/editor/common/cursor/cursorMoveCommands.ts @@ -294,6 +294,9 @@ export class CursorMoveCommands { if (unit === CursorMove.Unit.WrappedLine) { // Move up by view lines return this._moveUpByViewLines(viewModel, cursors, inSelectionMode, value); + } else if (unit === CursorMove.Unit.FoldedLine) { + // Move up by model lines, skipping over folded regions + return this._moveUpByFoldedLines(viewModel, cursors, inSelectionMode, value); } else { // Move up by model lines return this._moveUpByModelLines(viewModel, cursors, inSelectionMode, value); @@ -303,6 +306,9 @@ export class CursorMoveCommands { if (unit === CursorMove.Unit.WrappedLine) { // Move down by view lines return this._moveDownByViewLines(viewModel, cursors, inSelectionMode, value); + } else if (unit === CursorMove.Unit.FoldedLine) { + // Move down by model lines, skipping over folded regions + return this._moveDownByFoldedLines(viewModel, cursors, inSelectionMode, value); } else { // Move down by model lines return this._moveDownByModelLines(viewModel, cursors, inSelectionMode, value); @@ -515,6 +521,81 @@ export class CursorMoveCommands { return result; } + // Move down by `count` model lines, treating each folded region as a single step. + // This is the correct behavior for vim's `j` motion: logical lines are moved, folds are skipped. + private static _moveDownByFoldedLines(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean, count: number): PartialCursorState[] { + const model = viewModel.model; + const lineCount = model.getLineCount(); + const hiddenAreas = viewModel.getHiddenAreas(); + + return cursors.map(cursor => { + const startLine = cursor.modelState.hasSelection() && !inSelectionMode + ? cursor.modelState.selection.endLineNumber + : cursor.modelState.position.lineNumber; + + let line = startLine; + for (let steps = 0; steps < count && line < lineCount; steps++) { + // Advance one model line, then jump over any fold that begins there. + // The whole fold counts as a single step. + const candidate = line + 1; + let target = candidate; + for (const area of hiddenAreas) { + if (candidate >= area.startLineNumber && candidate <= area.endLineNumber) { + target = area.endLineNumber + 1; + break; + } + } + if (target > lineCount) { + // Fold reaches end of document; no visible line to land on. + break; + } + line = target; + } + + const modelLineDelta = line - startLine; + if (modelLineDelta === 0) { + return CursorState.fromModelState(cursor.modelState); + } + return CursorState.fromModelState(MoveOperations.moveDown(viewModel.cursorConfig, model, cursor.modelState, inSelectionMode, modelLineDelta)); + }); + } + + private static _moveUpByFoldedLines(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean, count: number): PartialCursorState[] { + const model = viewModel.model; + const hiddenAreas = viewModel.getHiddenAreas(); + + return cursors.map(cursor => { + const startLine = cursor.modelState.hasSelection() && !inSelectionMode + ? cursor.modelState.selection.startLineNumber + : cursor.modelState.position.lineNumber; + + let line = startLine; + for (let steps = 0; steps < count && line > 1; steps++) { + // Retreat one model line, then jump over any fold that ends there. + // The whole fold counts as a single step. + const candidate = line - 1; + let target = candidate; + for (const area of hiddenAreas) { + if (candidate >= area.startLineNumber && candidate <= area.endLineNumber) { + target = area.startLineNumber - 1; + break; + } + } + if (target < 1) { + // Fold reaches start of document; no visible line to land on. + break; + } + line = target; + } + + const modelLineDelta = startLine - line; + if (modelLineDelta === 0) { + return CursorState.fromModelState(cursor.modelState); + } + return CursorState.fromModelState(MoveOperations.moveUp(viewModel.cursorConfig, model, cursor.modelState, inSelectionMode, modelLineDelta)); + }); + } + private static _moveToViewPosition(viewModel: IViewModel, cursor: CursorState, inSelectionMode: boolean, toViewLineNumber: number, toViewColumn: number): PartialCursorState { return CursorState.fromViewState(cursor.viewState.move(inSelectionMode, toViewLineNumber, toViewColumn, 0)); } @@ -626,8 +707,10 @@ export namespace CursorMove { \`\`\` * 'by': Unit to move. Default is computed based on 'to' value. \`\`\` - 'line', 'wrappedLine', 'character', 'halfLine' + 'line', 'wrappedLine', 'character', 'halfLine', 'foldedLine' \`\`\` + Use 'foldedLine' with 'up'/'down' to move by logical lines while treating each + folded region as a single step. * 'value': Number of units to move. Default is '1'. * 'select': If 'true' makes the selection. Default is 'false'. * 'noHistory': If 'true' does not add the movement to navigation history. Default is 'false'. @@ -643,7 +726,7 @@ export namespace CursorMove { }, 'by': { 'type': 'string', - 'enum': ['line', 'wrappedLine', 'character', 'halfLine'] + 'enum': ['line', 'wrappedLine', 'character', 'halfLine', 'foldedLine'] }, 'value': { 'type': 'number', @@ -695,7 +778,8 @@ export namespace CursorMove { Line: 'line', WrappedLine: 'wrappedLine', Character: 'character', - HalfLine: 'halfLine' + HalfLine: 'halfLine', + FoldedLine: 'foldedLine' }; /** @@ -781,6 +865,9 @@ export namespace CursorMove { case RawUnit.HalfLine: unit = Unit.HalfLine; break; + case RawUnit.FoldedLine: + unit = Unit.FoldedLine; + break; } return { @@ -855,6 +942,7 @@ export namespace CursorMove { WrappedLine, Character, HalfLine, + FoldedLine, } } diff --git a/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts b/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts index 511842715693f..1af598af4e872 100644 --- a/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts +++ b/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts @@ -506,6 +506,92 @@ suite('Cursor move by blankline test', () => { }); }); +// Tests for 'foldedLine' unit: moves by model lines but treats each fold as a single step. +// This is the semantics required by vim's j/k: move through visible lines, skip hidden ones. + +suite('Cursor move command - foldedLine unit', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function executeFoldTest(callback: (editor: ITestCodeEditor, viewModel: ViewModel) => void): void { + withTestCodeEditor([ + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + ].join('\n'), {}, (editor, viewModel) => { + callback(editor, viewModel); + }); + } + + test('move down by foldedLine skips a fold below the cursor', () => { + executeFoldTest((editor, viewModel) => { + // Line 4 is hidden (folded under line 3 as header) + viewModel.setHiddenAreas([new Range(4, 1, 4, 1)]); + moveTo(viewModel, 2, 1); + // j from line 2 → line 3 (visible fold header) + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 3, 1); + // j from line 3 (fold header) → line 4 is hidden, lands on line 5 + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine skips a fold above the cursor', () => { + executeFoldTest((editor, viewModel) => { + // Line 3 is hidden (folded under line 2 as header) + viewModel.setHiddenAreas([new Range(3, 1, 3, 1)]); + moveTo(viewModel, 4, 1); + // k from line 4: line 3 is hidden, lands on line 2 (fold header) + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 2, 1); + // k from line 2 → line 1 + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 1, 1); + }); + }); + + test('move down by foldedLine with count treats each fold as one step', () => { + executeFoldTest((editor, viewModel) => { + // Line 3 is hidden + viewModel.setHiddenAreas([new Range(3, 1, 3, 1)]); + moveTo(viewModel, 1, 1); + // 3j from line 1: step1→2, step2→3(hidden)→4, step3→5 + moveDownByFoldedLine(viewModel, 3); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move down by foldedLine skips a multi-line fold as one step', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden (folded under line 1 as header) + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 1, 1); + // j from line 1: lines 2-4 are all hidden, lands directly on line 5 + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move down by foldedLine at last line stays at last line', () => { + executeFoldTest((editor, viewModel) => { + moveTo(viewModel, 5, 1); + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine at first line stays at first line', () => { + executeFoldTest((editor, viewModel) => { + moveTo(viewModel, 1, 1); + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 1, 1); + }); + }); +}); + // Move command function move(viewModel: ViewModel, args: any) { @@ -564,6 +650,14 @@ function moveDownByModelLine(viewModel: ViewModel, noOfLines: number = 1, select move(viewModel, { to: CursorMove.RawDirection.Down, value: noOfLines, select: select }); } +function moveDownByFoldedLine(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { + move(viewModel, { to: CursorMove.RawDirection.Down, by: CursorMove.RawUnit.FoldedLine, value: noOfLines, select: select }); +} + +function moveUpByFoldedLine(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { + move(viewModel, { to: CursorMove.RawDirection.Up, by: CursorMove.RawUnit.FoldedLine, value: noOfLines, select: select }); +} + function moveToTop(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { move(viewModel, { to: CursorMove.RawDirection.ViewPortTop, value: noOfLines, select: select }); } From fb9b07653f07f788086d9c3f01f3e1cb0e19aa03 Mon Sep 17 00:00:00 2001 From: Jai Date: Wed, 18 Feb 2026 13:05:15 -0800 Subject: [PATCH 02/10] editor: simplify foldedLine movement using fold-walk algorithm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the step-by-step simulation (O(count × folds)) with a single pass over sorted hidden areas (O(folds in path)). Compute a naive target, then extend it for each fold encountered, stopping before any fold that reaches the document boundary. Also extracts _targetFoldedDown/_targetFoldedUp helpers to eliminate the duplicated loop structure between the two directions. Co-Authored-By: Claude Sonnet 4.6 --- .../common/cursor/cursorMoveCommands.ts | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/src/vs/editor/common/cursor/cursorMoveCommands.ts b/src/vs/editor/common/cursor/cursorMoveCommands.ts index cbad4c981493c..0da291070a2cf 100644 --- a/src/vs/editor/common/cursor/cursorMoveCommands.ts +++ b/src/vs/editor/common/cursor/cursorMoveCommands.ts @@ -521,8 +521,6 @@ export class CursorMoveCommands { return result; } - // Move down by `count` model lines, treating each folded region as a single step. - // This is the correct behavior for vim's `j` motion: logical lines are moved, folds are skipped. private static _moveDownByFoldedLines(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean, count: number): PartialCursorState[] { const model = viewModel.model; const lineCount = model.getLineCount(); @@ -533,30 +531,12 @@ export class CursorMoveCommands { ? cursor.modelState.selection.endLineNumber : cursor.modelState.position.lineNumber; - let line = startLine; - for (let steps = 0; steps < count && line < lineCount; steps++) { - // Advance one model line, then jump over any fold that begins there. - // The whole fold counts as a single step. - const candidate = line + 1; - let target = candidate; - for (const area of hiddenAreas) { - if (candidate >= area.startLineNumber && candidate <= area.endLineNumber) { - target = area.endLineNumber + 1; - break; - } - } - if (target > lineCount) { - // Fold reaches end of document; no visible line to land on. - break; - } - line = target; - } - - const modelLineDelta = line - startLine; - if (modelLineDelta === 0) { + const targetLine = CursorMoveCommands._targetFoldedDown(startLine, count, hiddenAreas, lineCount); + const delta = targetLine - startLine; + if (delta === 0) { return CursorState.fromModelState(cursor.modelState); } - return CursorState.fromModelState(MoveOperations.moveDown(viewModel.cursorConfig, model, cursor.modelState, inSelectionMode, modelLineDelta)); + return CursorState.fromModelState(MoveOperations.moveDown(viewModel.cursorConfig, model, cursor.modelState, inSelectionMode, delta)); }); } @@ -569,33 +549,53 @@ export class CursorMoveCommands { ? cursor.modelState.selection.startLineNumber : cursor.modelState.position.lineNumber; - let line = startLine; - for (let steps = 0; steps < count && line > 1; steps++) { - // Retreat one model line, then jump over any fold that ends there. - // The whole fold counts as a single step. - const candidate = line - 1; - let target = candidate; - for (const area of hiddenAreas) { - if (candidate >= area.startLineNumber && candidate <= area.endLineNumber) { - target = area.startLineNumber - 1; - break; - } - } - if (target < 1) { - // Fold reaches start of document; no visible line to land on. - break; - } - line = target; - } - - const modelLineDelta = startLine - line; - if (modelLineDelta === 0) { + const targetLine = CursorMoveCommands._targetFoldedUp(startLine, count, hiddenAreas); + const delta = startLine - targetLine; + if (delta === 0) { return CursorState.fromModelState(cursor.modelState); } - return CursorState.fromModelState(MoveOperations.moveUp(viewModel.cursorConfig, model, cursor.modelState, inSelectionMode, modelLineDelta)); + return CursorState.fromModelState(MoveOperations.moveUp(viewModel.cursorConfig, model, cursor.modelState, inSelectionMode, delta)); }); } + // Compute the target line after moving `count` steps downward from `startLine`, + // treating each folded region as a single step. + private static _targetFoldedDown(startLine: number, count: number, hiddenAreas: Range[], lineCount: number): number { + let target = startLine + count; + let i = 0; + while (i < hiddenAreas.length && hiddenAreas[i].endLineNumber <= startLine) { i++; } + while (i < hiddenAreas.length && hiddenAreas[i].startLineNumber <= target) { + const area = hiddenAreas[i]; + const extended = target + (area.endLineNumber - area.startLineNumber + 1); + if (extended > lineCount) { + // Fold reaches end of document; land on the line before it. + return area.startLineNumber - 1; + } + target = extended; + i++; + } + return Math.min(target, lineCount); + } + + // Compute the target line after moving `count` steps upward from `startLine`, + // treating each folded region as a single step. + private static _targetFoldedUp(startLine: number, count: number, hiddenAreas: Range[]): number { + let target = startLine - count; + let i = hiddenAreas.length - 1; + while (i >= 0 && hiddenAreas[i].startLineNumber >= startLine) { i--; } + while (i >= 0 && hiddenAreas[i].endLineNumber >= target) { + const area = hiddenAreas[i]; + const extended = target - (area.endLineNumber - area.startLineNumber + 1); + if (extended < 1) { + // Fold reaches start of document; land on the line after it. + return area.endLineNumber + 1; + } + target = extended; + i--; + } + return Math.max(target, 1); + } + private static _moveToViewPosition(viewModel: IViewModel, cursor: CursorState, inSelectionMode: boolean, toViewLineNumber: number, toViewColumn: number): PartialCursorState { return CursorState.fromViewState(cursor.viewState.move(inSelectionMode, toViewLineNumber, toViewColumn, 0)); } From a97e4c6735f72b7b30c56798c15e2d999426a56e Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 28 Feb 2026 23:24:06 +0100 Subject: [PATCH 03/10] editor: fix foldedLine count movement at fold boundaries --- .../common/cursor/cursorMoveCommands.ts | 76 +++++++++++++------ .../controller/cursorMoveCommand.test.ts | 22 ++++++ 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/vs/editor/common/cursor/cursorMoveCommands.ts b/src/vs/editor/common/cursor/cursorMoveCommands.ts index 0da291070a2cf..d3183d48af57c 100644 --- a/src/vs/editor/common/cursor/cursorMoveCommands.ts +++ b/src/vs/editor/common/cursor/cursorMoveCommands.ts @@ -561,39 +561,71 @@ export class CursorMoveCommands { // Compute the target line after moving `count` steps downward from `startLine`, // treating each folded region as a single step. private static _targetFoldedDown(startLine: number, count: number, hiddenAreas: Range[], lineCount: number): number { - let target = startLine + count; + let line = startLine; let i = 0; - while (i < hiddenAreas.length && hiddenAreas[i].endLineNumber <= startLine) { i++; } - while (i < hiddenAreas.length && hiddenAreas[i].startLineNumber <= target) { - const area = hiddenAreas[i]; - const extended = target + (area.endLineNumber - area.startLineNumber + 1); - if (extended > lineCount) { - // Fold reaches end of document; land on the line before it. - return area.startLineNumber - 1; - } - target = extended; + + while (i < hiddenAreas.length && hiddenAreas[i].endLineNumber < line + 1) { i++; } - return Math.min(target, lineCount); + + for (let step = 0; step < count; step++) { + if (line >= lineCount) { + return lineCount; + } + + let candidate = line + 1; + while (i < hiddenAreas.length && hiddenAreas[i].endLineNumber < candidate) { + i++; + } + + if (i < hiddenAreas.length && hiddenAreas[i].startLineNumber <= candidate) { + candidate = hiddenAreas[i].endLineNumber + 1; + } + + if (candidate > lineCount) { + // The next visible line does not exist (e.g. a fold reaches EOF). + return line; + } + + line = candidate; + } + + return line; } // Compute the target line after moving `count` steps upward from `startLine`, // treating each folded region as a single step. private static _targetFoldedUp(startLine: number, count: number, hiddenAreas: Range[]): number { - let target = startLine - count; + let line = startLine; let i = hiddenAreas.length - 1; - while (i >= 0 && hiddenAreas[i].startLineNumber >= startLine) { i--; } - while (i >= 0 && hiddenAreas[i].endLineNumber >= target) { - const area = hiddenAreas[i]; - const extended = target - (area.endLineNumber - area.startLineNumber + 1); - if (extended < 1) { - // Fold reaches start of document; land on the line after it. - return area.endLineNumber + 1; - } - target = extended; + + while (i >= 0 && hiddenAreas[i].startLineNumber > line - 1) { i--; } - return Math.max(target, 1); + + for (let step = 0; step < count; step++) { + if (line <= 1) { + return 1; + } + + let candidate = line - 1; + while (i >= 0 && hiddenAreas[i].startLineNumber > candidate) { + i--; + } + + if (i >= 0 && hiddenAreas[i].endLineNumber >= candidate) { + candidate = hiddenAreas[i].startLineNumber - 1; + } + + if (candidate < 1) { + // The previous visible line does not exist (e.g. a fold reaches BOF). + return line; + } + + line = candidate; + } + + return line; } private static _moveToViewPosition(viewModel: IViewModel, cursor: CursorState, inSelectionMode: boolean, toViewLineNumber: number, toViewColumn: number): PartialCursorState { diff --git a/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts b/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts index 1af598af4e872..f0f79d731d105 100644 --- a/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts +++ b/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts @@ -590,6 +590,28 @@ suite('Cursor move command - foldedLine unit', () => { cursorEqual(viewModel, 1, 1); }); }); + + test('move down by foldedLine with count clamps to last visible line after fold', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden. Visible lines are 1 and 5. + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 1, 1); + // 2j should land on line 5 and clamp there. + moveDownByFoldedLine(viewModel, 2); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine with count clamps to first visible line before fold', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden. Visible lines are 1 and 5. + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 5, 1); + // 2k should land on line 1 and clamp there. + moveUpByFoldedLine(viewModel, 2); + cursorEqual(viewModel, 1, 1); + }); + }); }); // Move command From 2ea417c9abba89bfd1ddfd3ceee550cf8e2293a1 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 28 Feb 2026 23:45:25 +0100 Subject: [PATCH 04/10] fix repository for isolation mode (#298498) --- .../contrib/sessions/browser/sessionsManagementService.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 8bd18f8f337ce..85f4a6cbd63d7 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -197,6 +197,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return [URI.parse(`${GITHUB_REMOTE_FILE_SCHEME}://github/${metadata.owner}/${metadata.name}`), undefined]; } + const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined; + if (workingDirectoryPath) { + return [URI.file(workingDirectoryPath), undefined]; + } + const repositoryPath = metadata?.repositoryPath as string | undefined; const repositoryPathUri = typeof repositoryPath === 'string' ? URI.file(repositoryPath) : undefined; From b094e2f7dd0f12247df4a33b94d7930640d9f2bc Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 28 Feb 2026 15:04:28 -0800 Subject: [PATCH 05/10] Fix inline reference at block start rendering on its own line (#298497) Copilot-authored message: Fix inline reference at block start rendering on its own line When an inlineReference is the first item in a chat response (no preceding markdown), it creates a standalone markdownContent with default MarkdownString properties. The subsequent markdown from the model has different properties (e.g. isTrusted: true), causing canMergeMarkdownStrings to return false. This leaves them as separate content parts, rendering the inline reference link on its own line. Fix by detecting when the previous item consists solely of synthesized content-ref links (via isContentRefOnly check) and merging them by prepending the reference text while adopting the incoming markdown's properties. Fixes #278191 --- .../contrib/chat/common/widget/annotations.ts | 34 +++++++++++++++++-- .../test/common/widget/annotations.test.ts | 34 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/widget/annotations.ts b/src/vs/workbench/contrib/chat/common/widget/annotations.ts index b43079b5add3a..e1b417e5f9bf3 100644 --- a/src/vs/workbench/contrib/chat/common/widget/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/widget/annotations.ts @@ -57,9 +57,25 @@ export function annotateSpecialMarkdownContent(response: Iterable${item.content.value}`; @@ -88,6 +104,18 @@ export function annotateSpecialMarkdownContent(response: Iterable { + const result = annotateSpecialMarkdownContent([ + { kind: 'inlineReference', inlineReference: URI.parse('file:///index.ts'), name: 'index.ts' }, + { kind: 'markdownContent', content: new MarkdownString(' is the entry point', { isTrusted: true, supportThemeIcons: true }) }, + ]); + + assert.strictEqual(result.length, 1); + const md = result[0] as IChatMarkdownContent; + assert.ok(md.content.value.includes('[index.ts]')); + assert.ok(md.content.value.includes('_vscodecontentref_')); + assert.ok(md.content.value.endsWith(' is the entry point')); + assert.ok(md.inlineReferences); + assert.strictEqual(md.content.isTrusted, true); + assert.strictEqual(md.content.supportThemeIcons, true); + }); + + test('inline reference after regular text does not force-merge incompatible markdown', () => { + const result = annotateSpecialMarkdownContent([ + content('See '), + { kind: 'inlineReference', inlineReference: URI.parse('file:///index.ts'), name: 'index.ts' }, + { kind: 'markdownContent', content: new MarkdownString(' more info', { isTrusted: true, supportThemeIcons: true }) }, + ]); + + // The first item has "See [index.ts](...)" with default markdown properties, + // the second item has different properties - they must stay separate. + assert.strictEqual(result.length, 2); + const first = result[0] as IChatMarkdownContent; + assert.ok(first.content.value.startsWith('See ')); + assert.ok(first.inlineReferences); + const second = result[1] as IChatMarkdownContent; + assert.strictEqual(second.content.value, ' more info'); + assert.strictEqual(second.content.isTrusted, true); + }); }); }); From e88a72063724e09fc0a32450c1ff9250fc789080 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 28 Feb 2026 15:10:34 -0800 Subject: [PATCH 06/10] Avoid flicker when checkpoint toolbar appears on request (#298501) --- src/vs/platform/actions/browser/toolbar.ts | 53 +++++++++++++------ .../chat/browser/widget/chatListRenderer.ts | 7 ++- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/src/vs/platform/actions/browser/toolbar.ts b/src/vs/platform/actions/browser/toolbar.ts index a167319371ca0..9304d12db48a8 100644 --- a/src/vs/platform/actions/browser/toolbar.ts +++ b/src/vs/platform/actions/browser/toolbar.ts @@ -15,7 +15,7 @@ import { Iterable } from '../../../base/common/iterator.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { createActionViewItem, getActionBarActions } from './menuEntryActionViewItem.js'; -import { IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../common/actions.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../common/actions.js'; import { createConfigureKeybindingAction } from '../common/menuService.js'; import { ICommandService } from '../../commands/common/commands.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; @@ -332,6 +332,11 @@ export class MenuWorkbenchToolBar extends WorkbenchToolBar { private readonly _onDidChangeMenuItems = this._store.add(new Emitter()); get onDidChangeMenuItems() { return this._onDidChangeMenuItems.event; } + private readonly _menu: IMenu; + private readonly _menuOptions: IMenuActionOptions | undefined; + private readonly _toolbarOptions: IToolBarRenderOptions | undefined; + private readonly _container: HTMLElement; + constructor( container: HTMLElement, menuId: MenuId, @@ -361,30 +366,44 @@ export class MenuWorkbenchToolBar extends WorkbenchToolBar { } }, menuService, contextKeyService, contextMenuService, keybindingService, commandService, telemetryService); + this._container = container; + this._menuOptions = options?.menuOptions; + this._toolbarOptions = options?.toolbarOptions; + // update logic - const menu = this._store.add(menuService.createMenu(menuId, contextKeyService, { emitEventsForSubmenuChanges: true, eventDebounceDelay: options?.eventDebounceDelay })); - const updateToolbar = () => { - const { primary, secondary } = getActionBarActions( - menu.getActions(options?.menuOptions), - options?.toolbarOptions?.primaryGroup, - options?.toolbarOptions?.shouldInlineSubmenu, - options?.toolbarOptions?.useSeparatorsInPrimaryActions - ); - container.classList.toggle('has-no-actions', primary.length === 0 && secondary.length === 0); - super.setActions(primary, secondary); - }; - - this._store.add(menu.onDidChange(() => { - updateToolbar(); + this._menu = this._store.add(menuService.createMenu(menuId, contextKeyService, { emitEventsForSubmenuChanges: true, eventDebounceDelay: options?.eventDebounceDelay })); + + this._store.add(this._menu.onDidChange(() => { + this._updateToolbar(); this._onDidChangeMenuItems.fire(this); })); this._store.add(actionViewService.onDidChange(e => { if (e === menuId) { - updateToolbar(); + this._updateToolbar(); } })); - updateToolbar(); + this._updateToolbar(); + } + + private _updateToolbar(): void { + const { primary, secondary } = getActionBarActions( + this._menu.getActions(this._menuOptions), + this._toolbarOptions?.primaryGroup, + this._toolbarOptions?.shouldInlineSubmenu, + this._toolbarOptions?.useSeparatorsInPrimaryActions + ); + this._container.classList.toggle('has-no-actions', primary.length === 0 && secondary.length === 0); + super.setActions(primary, secondary); + } + + /** + * Force the toolbar to immediately re-evaluate its menu actions. + * Use this after synchronously updating context keys to avoid + * layout shifts caused by the debounced menu change event. + */ + refresh(): void { + this._updateToolbar(); } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 5130b606b9832..87b4bff576e06 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -530,7 +530,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer submenu.actions.length <= 1 }, actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => { @@ -756,6 +755,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer Date: Sun, 1 Mar 2026 01:41:59 +0100 Subject: [PATCH 07/10] Better handle event subscriptions (#298503) Better handle event subscriptions (#293200) --- src/vs/server/node/remoteExtensionManagement.ts | 7 +++++-- src/vs/workbench/api/node/extensionHostProcess.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/vs/server/node/remoteExtensionManagement.ts b/src/vs/server/node/remoteExtensionManagement.ts index df587cf746828..4a38e83ea25a0 100644 --- a/src/vs/server/node/remoteExtensionManagement.ts +++ b/src/vs/server/node/remoteExtensionManagement.ts @@ -8,6 +8,7 @@ import { ILogService } from '../../platform/log/common/log.js'; import { Emitter, Event } from '../../base/common/event.js'; import { VSBuffer } from '../../base/common/buffer.js'; import { ProcessTimeRunOnceScheduler } from '../../base/common/async.js'; +import { IDisposable } from '../../base/common/lifecycle.js'; function printTime(ms: number): string { let h = 0; @@ -45,6 +46,7 @@ export class ManagementConnection { private _disposed: boolean; private _disconnectRunner1: ProcessTimeRunOnceScheduler; private _disconnectRunner2: ProcessTimeRunOnceScheduler; + private readonly _socketCloseListener: IDisposable; constructor( private readonly _logService: ILogService, @@ -69,11 +71,11 @@ export class ManagementConnection { this._cleanResources(); }, this._reconnectionShortGraceTime); - this.protocol.onDidDispose(() => { + Event.once(this.protocol.onDidDispose)(() => { this._log(`The client has disconnected gracefully, so the connection will be disposed.`); this._cleanResources(); }); - this.protocol.onSocketClose(() => { + this._socketCloseListener = this.protocol.onSocketClose(() => { this._log(`The client has disconnected, will wait for reconnection ${printTime(this._reconnectionGraceTime)} before disposing...`); // The socket has closed, let's give the renderer a certain amount of time to reconnect this._disconnectRunner1.schedule(); @@ -106,6 +108,7 @@ export class ManagementConnection { this._disposed = true; this._disconnectRunner1.dispose(); this._disconnectRunner2.dispose(); + this._socketCloseListener.dispose(); const socket = this.protocol.getSocket(); this.protocol.sendDisconnect(); this.protocol.dispose(); diff --git a/src/vs/workbench/api/node/extensionHostProcess.ts b/src/vs/workbench/api/node/extensionHostProcess.ts index bb0fb8eec64c0..50ef42e0a2f0e 100644 --- a/src/vs/workbench/api/node/extensionHostProcess.ts +++ b/src/vs/workbench/api/node/extensionHostProcess.ts @@ -242,7 +242,7 @@ function _createExtHostProtocol(): Promise { clearTimeout(timer); protocol = new PersistentProtocol({ socket, initialChunk: initialDataChunk }); protocol.sendResume(); - protocol.onDidDispose(() => onTerminate('renderer disconnected')); + Event.once(protocol.onDidDispose)(() => onTerminate('renderer disconnected')); resolve(protocol); // Wait for rich client to reconnect From b5d9e29098e5832858a02455fe7af0a12d6b8e82 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Sun, 1 Mar 2026 01:42:41 +0100 Subject: [PATCH 08/10] Fix wrong current index in find widget if matches > 1000 (#298508) * fix #288515: wrong current index in find widget if matches > 1000 When there are more than 1000 matches, decorations use _FIND_MATCH_NO_OVERVIEW_DECORATION instead of _FIND_MATCH_DECORATION. getCurrentMatchesPosition did not check for this decoration type, causing it to return 0 and fall through to a fallback with an off-by-one error. * Address review feedback * Fix unused error --- .../contrib/find/browser/findDecorations.ts | 2 +- .../find/test/browser/findModel.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/vs/editor/contrib/find/browser/findDecorations.ts b/src/vs/editor/contrib/find/browser/findDecorations.ts index 1940e2a315262..6ce266c699eae 100644 --- a/src/vs/editor/contrib/find/browser/findDecorations.ts +++ b/src/vs/editor/contrib/find/browser/findDecorations.ts @@ -103,7 +103,7 @@ export class FindDecorations implements IDisposable { const candidates = this._editor.getModel().getDecorationsInRange(desiredRange); for (const candidate of candidates) { const candidateOpts = candidate.options; - if (candidateOpts === FindDecorations._FIND_MATCH_DECORATION || candidateOpts === FindDecorations._CURRENT_FIND_MATCH_DECORATION) { + if (candidateOpts === FindDecorations._FIND_MATCH_DECORATION || candidateOpts === FindDecorations._FIND_MATCH_NO_OVERVIEW_DECORATION || candidateOpts === FindDecorations._CURRENT_FIND_MATCH_DECORATION) { return this._getDecorationIndex(candidate.id); } } diff --git a/src/vs/editor/contrib/find/test/browser/findModel.test.ts b/src/vs/editor/contrib/find/test/browser/findModel.test.ts index 09a0de5a7ae5f..8e2bbc3ccd362 100644 --- a/src/vs/editor/contrib/find/test/browser/findModel.test.ts +++ b/src/vs/editor/contrib/find/test/browser/findModel.test.ts @@ -2383,4 +2383,23 @@ suite('FindModel', () => { }); + test('issue #288515: Wrong current index in find widget if matches > 1000', () => { + // Create 1001 lines of 'hello' + const textArr = Array(1001).fill('hello'); + withTestCodeEditor(textArr, {}, (_editor) => { + const editor = _editor as IActiveCodeEditor; + + // Place cursor at line 900, selecting 'hello' + editor.setSelection(new Selection(900, 1, 900, 6)); + + const findState = disposables.add(new FindReplaceState()); + findState.change({ searchString: 'hello' }, false); + disposables.add(new FindModelBoundToEditorModel(editor, findState)); + + assert.strictEqual(findState.matchesCount, 1001); + // With cursor selecting 'hello' at line 900, matchesPosition should be 900 + assert.strictEqual(findState.matchesPosition, 900); + }); + }); + }); From a94e3bb9cffaddea58c25101035f064a5b98f9bc Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Sun, 1 Mar 2026 01:43:11 +0100 Subject: [PATCH 09/10] Fix potential listener leak in document semantic tokens (#298512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix potential listener leak in document semantic tokens Hoist provider onDidChange and registry onDidChange subscriptions from each ModelSemanticColoring instance into the singleton DocumentSemanticTokensFeature. Previously, every ModelSemanticColoring subscribed individually to both the global LanguageFeatureRegistry.onDidChange and each provider's onDidChange event, resulting in O(N*M) listeners (N models × M providers). In scenarios like chat editing where many models are created rapidly, these listeners accumulated and triggered leak detection. Now the singleton subscribes once to the registry change and once per provider (via allNoModel()), then fans out notifications to watchers. Each watcher checks provider relevance via _provider.all(model).includes() before acting on the event. Also replaces manual IDisposable[] management with a DisposableStore for proper lifecycle tracking. * Review feedback --- .../browser/documentSemanticTokens.ts | 76 ++++++++++++------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts b/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts index 2bc7cab868a76..95348d44230af 100644 --- a/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts +++ b/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts @@ -6,7 +6,7 @@ import { RunOnceScheduler } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import * as errors from '../../../../base/common/errors.js'; -import { Disposable, IDisposable, dispose } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, dispose } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -27,6 +27,7 @@ import { SEMANTIC_HIGHLIGHTING_SETTING_ID, isSemanticColoringEnabled } from '../ export class DocumentSemanticTokensFeature extends Disposable { private readonly _watchers = new ResourceMap(); + private readonly _providerChangeListeners = this._register(new DisposableStore()); constructor( @ISemanticTokensStylingService semanticTokensStylingService: ISemanticTokensStylingService, @@ -38,6 +39,8 @@ export class DocumentSemanticTokensFeature extends Disposable { ) { super(); + const provider = languageFeaturesService.documentSemanticTokensProvider; + const register = (model: ITextModel) => { this._watchers.get(model.uri)?.dispose(); this._watchers.set(model.uri, new ModelSemanticColoring(model, semanticTokensStylingService, themeService, languageFeatureDebounceService, languageFeaturesService)); @@ -60,6 +63,20 @@ export class DocumentSemanticTokensFeature extends Disposable { } } }; + + const bindProviderChangeListeners = () => { + this._providerChangeListeners.clear(); + for (const p of provider.allNoModel()) { + if (typeof p.onDidChange === 'function') { + this._providerChangeListeners.add(p.onDidChange(() => { + for (const watcher of this._watchers.values()) { + watcher.handleProviderDidChange(p); + } + })); + } + } + }; + modelService.getModels().forEach(model => { if (isSemanticColoringEnabled(model, themeService, configurationService)) { register(model); @@ -82,6 +99,13 @@ export class DocumentSemanticTokensFeature extends Disposable { } })); this._register(themeService.onDidColorThemeChange(handleSettingOrThemeChange)); + bindProviderChangeListeners(); + this._register(provider.onDidChange(() => { + bindProviderChangeListeners(); + for (const watcher of this._watchers.values()) { + watcher.handleRegistryChange(); + } + })); } override dispose(): void { @@ -104,7 +128,7 @@ class ModelSemanticColoring extends Disposable { private readonly _fetchDocumentSemanticTokens: RunOnceScheduler; private _currentDocumentResponse: SemanticTokensResponse | null; private _currentDocumentRequestCancellationTokenSource: CancellationTokenSource | null; - private _documentProvidersChangeListeners: IDisposable[]; + private _relevantProviders = new Set(); private _providersChangedDuringRequest: boolean; constructor( @@ -123,8 +147,8 @@ class ModelSemanticColoring extends Disposable { this._fetchDocumentSemanticTokens = this._register(new RunOnceScheduler(() => this._fetchDocumentSemanticTokensNow(), ModelSemanticColoring.REQUEST_MIN_DELAY)); this._currentDocumentResponse = null; this._currentDocumentRequestCancellationTokenSource = null; - this._documentProvidersChangeListeners = []; this._providersChangedDuringRequest = false; + this._updateRelevantProviders(); this._register(this._model.onDidChangeContent(() => { if (!this._fetchDocumentSemanticTokens.isScheduled()) { @@ -147,31 +171,10 @@ class ModelSemanticColoring extends Disposable { this._currentDocumentRequestCancellationTokenSource = null; } this._setDocumentSemanticTokens(null, null, null, []); + this._updateRelevantProviders(); this._fetchDocumentSemanticTokens.schedule(0); })); - const bindDocumentChangeListeners = () => { - dispose(this._documentProvidersChangeListeners); - this._documentProvidersChangeListeners = []; - for (const provider of this._provider.all(model)) { - if (typeof provider.onDidChange === 'function') { - this._documentProvidersChangeListeners.push(provider.onDidChange(() => { - if (this._currentDocumentRequestCancellationTokenSource) { - // there is already a request running, - this._providersChangedDuringRequest = true; - return; - } - this._fetchDocumentSemanticTokens.schedule(0); - })); - } - } - }; - bindDocumentChangeListeners(); - this._register(this._provider.onDidChange(() => { - bindDocumentChangeListeners(); - this._fetchDocumentSemanticTokens.schedule(this._debounceInformation.get(this._model)); - })); - this._register(themeService.onDidColorThemeChange(_ => { // clear out existing tokens this._setDocumentSemanticTokens(null, null, null, []); @@ -181,6 +184,27 @@ class ModelSemanticColoring extends Disposable { this._fetchDocumentSemanticTokens.schedule(0); } + public handleRegistryChange(): void { + this._updateRelevantProviders(); + this._fetchDocumentSemanticTokens.schedule(this._debounceInformation.get(this._model)); + } + + public handleProviderDidChange(provider: DocumentSemanticTokensProvider): void { + if (!this._relevantProviders.has(provider)) { + return; + } + if (this._currentDocumentRequestCancellationTokenSource) { + // there is already a request running, + this._providersChangedDuringRequest = true; + return; + } + this._fetchDocumentSemanticTokens.schedule(0); + } + + private _updateRelevantProviders(): void { + this._relevantProviders = new Set(this._provider.all(this._model)); + } + public override dispose(): void { if (this._currentDocumentResponse) { this._currentDocumentResponse.dispose(); @@ -190,8 +214,6 @@ class ModelSemanticColoring extends Disposable { this._currentDocumentRequestCancellationTokenSource.cancel(); this._currentDocumentRequestCancellationTokenSource = null; } - dispose(this._documentProvidersChangeListeners); - this._documentProvidersChangeListeners = []; this._setDocumentSemanticTokens(null, null, null, []); this._isDisposed = true; From 4ede078c267d6b437fb31f08915e56286e444088 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Sat, 28 Feb 2026 19:20:07 -0800 Subject: [PATCH 10/10] fix non-edit codeblocks in thinking (#298519) * fix non-edit codeblocks in thinking * Address some comments --- .../chat/browser/widget/chatListRenderer.ts | 12 +++--- .../contrib/chat/common/widget/annotations.ts | 4 ++ .../test/common/widget/annotations.test.ts | 43 ++++++++++++++++++- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 87b4bff576e06..c3ae8477d422a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -50,7 +50,7 @@ import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../.. import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { IWorkbenchIssueService } from '../../../issue/common/issue.js'; import { CodiconActionViewItem } from '../../../notebook/browser/view/cellParts/cellActionView.js'; -import { annotateSpecialMarkdownContent, extractSubAgentInvocationIdFromText, hasCodeblockUriTag } from '../../common/widget/annotations.js'; +import { annotateSpecialMarkdownContent, extractSubAgentInvocationIdFromText, hasEditCodeblockUriTag } from '../../common/widget/annotations.js'; import { checkModeOption } from '../../common/chat.js'; import { IChatAgentMetadata } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -1035,7 +1035,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part instanceof ChatThinkingContentPart); - const hasEditPillMarkdown = partsToRender.some(part => part.kind === 'markdownContent' && this.hasCodeblockUri(part)); + const hasEditPillMarkdown = partsToRender.some(part => part.kind === 'markdownContent' && this.hasEditCodeblockUri(part)); if (hasRenderedThinkingPart && hasEditPillMarkdown) { return false; } @@ -1485,11 +1485,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer c.kind === 'thinking' || c.kind === 'toolInvocation' || c.kind === 'toolInvocationSerialized') : -1; const isFinalAnswerPart = isFinalRenderPass && context.contentIndex > lastPinnedPartIndex; - if (!this.hasCodeblockUri(markdown) || isFinalAnswerPart) { + if (!this.hasEditCodeblockUri(markdown) || isFinalAnswerPart) { this.finalizeCurrentThinkingPart(context, templateData); } const fillInIncompleteTokens = isResponseVM(element) && (!element.isComplete || element.isCanceled || element.errorDetails?.responseIsFiltered || element.errorDetails?.responseIsIncomplete || !!element.renderData); diff --git a/src/vs/workbench/contrib/chat/common/widget/annotations.ts b/src/vs/workbench/contrib/chat/common/widget/annotations.ts index e1b417e5f9bf3..c2fe2859e216a 100644 --- a/src/vs/workbench/contrib/chat/common/widget/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/widget/annotations.ts @@ -250,6 +250,10 @@ export function hasCodeblockUriTag(text: string): boolean { return text.includes(' { + test('returns true for edit codeblock URI tags', () => { + const editTag = 'file:///test.ts'; + assert.strictEqual(hasEditCodeblockUriTag(editTag), true); + }); + + test('returns false for non-edit codeblock URI tags', () => { + const nonEditTag = 'file:///test.ts'; + assert.strictEqual(hasEditCodeblockUriTag(nonEditTag), false); + }); + + test('returns true for edit codeblock URI tags with subAgentInvocationId', () => { + const editTagWithSubAgent = 'file:///test.ts'; + assert.strictEqual(hasEditCodeblockUriTag(editTagWithSubAgent), true); + }); + + test('returns false for non-edit codeblock URI tags with subAgentInvocationId', () => { + const nonEditTagWithSubAgent = 'file:///test.ts'; + assert.strictEqual(hasEditCodeblockUriTag(nonEditTagWithSubAgent), false); + }); + + test('returns false for text without codeblock URI tags', () => { + assert.strictEqual(hasEditCodeblockUriTag('some plain text'), false); + }); + + test('returns false for text with only partial tag prefix', () => { + assert.strictEqual(hasEditCodeblockUriTag(' { + const multipleEditTags = 'some text file:///test.ts more file:///other.ts'; + assert.strictEqual(hasEditCodeblockUriTag(multipleEditTags), true); + }); + + test('returns false for text containing only non-edit codeblock URI tags', () => { + const multipleNonEditTags = 'some text file:///test.ts more file:///other.ts'; + assert.strictEqual(hasEditCodeblockUriTag(multipleNonEditTags), false); + }); + + }); });