From b74352371ff2ee0158b01cdbf63d269c14387d8c Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Fri, 13 Feb 2026 15:00:31 +0000 Subject: [PATCH 001/541] feat: update theme setting defaults based on product quality --- .../themes/common/workbenchThemeService.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/services/themes/common/workbenchThemeService.ts b/src/vs/workbench/services/themes/common/workbenchThemeService.ts index a214818b29ce2..8fefa7297ca33 100644 --- a/src/vs/workbench/services/themes/common/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/common/workbenchThemeService.ts @@ -11,6 +11,7 @@ import { ConfigurationTarget } from '../../../../platform/configuration/common/c import { isBoolean, isString } from '../../../../base/common/types.js'; import { IconContribution, IconDefinition } from '../../../../platform/theme/common/iconRegistry.js'; import { ColorScheme, ThemeTypeSelector } from '../../../../platform/theme/common/theme.js'; +import product from '../../../../platform/product/common/product.js'; export const IWorkbenchThemeService = refineServiceDecorator(IThemeService); @@ -38,17 +39,19 @@ export enum ThemeSettings { SYSTEM_COLOR_THEME = 'window.systemColorTheme' } -export enum ThemeSettingDefaults { - COLOR_THEME_DARK = 'Default Dark Modern', - COLOR_THEME_LIGHT = 'Default Light Modern', - COLOR_THEME_HC_DARK = 'Default High Contrast', - COLOR_THEME_HC_LIGHT = 'Default High Contrast Light', +const isOSS = !product.quality; - COLOR_THEME_DARK_OLD = 'Default Dark+', - COLOR_THEME_LIGHT_OLD = 'Default Light+', +export namespace ThemeSettingDefaults { + export const COLOR_THEME_DARK = isOSS ? 'Experimental Dark' : 'Default Dark Modern'; + export const COLOR_THEME_LIGHT = isOSS ? 'Experimental Light' : 'Default Light Modern'; + export const COLOR_THEME_HC_DARK = 'Default High Contrast'; + export const COLOR_THEME_HC_LIGHT = 'Default High Contrast Light'; - FILE_ICON_THEME = 'vs-seti', - PRODUCT_ICON_THEME = 'Default', + export const COLOR_THEME_DARK_OLD = 'Default Dark+'; + export const COLOR_THEME_LIGHT_OLD = 'Default Light+'; + + export const FILE_ICON_THEME = 'vs-seti'; + export const PRODUCT_ICON_THEME = 'Default'; } export const COLOR_THEME_DARK_INITIAL_COLORS = { From a51c7f8c73bbbab4153573e8f2cdda4b5136b77b Mon Sep 17 00:00:00 2001 From: Jai Date: Wed, 18 Feb 2026 07:54:37 -0800 Subject: [PATCH 002/541] 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 003/541] 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 c3e3abcd9220099d4d9744c8c726e3545e4d79aa Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:18:13 -0800 Subject: [PATCH 004/541] Add `newSessionOptions` so providers can set the initial options for a session --- .../api/browser/mainThreadChatSessions.ts | 3 +++ .../workbench/api/common/extHost.protocol.ts | 3 ++- .../api/common/extHostChatSessions.ts | 12 +++++++---- .../chatSessions/chatSessions.contribution.ts | 20 +++++++++++++++++++ .../chat/common/chatSessionsService.ts | 3 +++ .../test/common/mockChatSessionsService.ts | 8 ++++++++ .../vscode.proposed.chatSessionsProvider.d.ts | 12 ++++++++--- 7 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 496430de9647f..42cb2f2ddaa67 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -753,6 +753,9 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat })); this._chatSessionsService.setOptionGroupsForSessionType(chatSessionScheme, handle, groupsWithCallbacks); } + if (options?.newSessionOptions) { + this._chatSessionsService.setNewSessionOptionsForSessionType(chatSessionScheme, options.newSessionOptions); + } }).catch(err => this._logService.error('Error fetching chat session options', err)); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 6770d6b49e26d..ea3e324290027 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3420,7 +3420,8 @@ export interface ChatSessionDto { } export interface IChatSessionProviderOptions { - optionGroups?: IChatSessionProviderOptionGroup[]; + optionGroups?: readonly IChatSessionProviderOptionGroup[]; + newSessionOptions?: Record; } export interface IChatSessionItemsChange { diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index f884d985ddacf..d33a86b6f5b4e 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -332,7 +332,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio /** * Store option groups with onSearch callbacks per provider handle */ - private readonly _providerOptionGroups = new Map(); + private readonly _providerOptionGroups = new Map(); constructor( private readonly commands: ExtHostCommands, @@ -598,13 +598,17 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } try { - const { optionGroups } = await provider.provideChatSessionProviderOptions(token); - if (!optionGroups) { + const result = await provider.provideChatSessionProviderOptions(token); + if (!result) { return; } - this._providerOptionGroups.set(handle, optionGroups); + const { optionGroups, newSessionOptions } = result; + if (optionGroups) { + this._providerOptionGroups.set(handle, optionGroups); + } return { optionGroups, + newSessionOptions, }; } catch (error) { this._logService.error(`Error calling provideChatSessionProviderOptions for handle ${handle}:`, error); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index deff82e869367..b42e78ffe5e72 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -292,6 +292,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly inProgressMap: Map = new Map(); private readonly _sessionTypeOptions: Map = new Map(); + private readonly _sessionTypeNewSessionOptions: Map> = new Map(); private readonly _sessionTypeIcons: Map = new Map(); private readonly _sessionTypeWelcomeTitles: Map = new Map(); private readonly _sessionTypeWelcomeMessages: Map = new Map(); @@ -1036,6 +1037,17 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ throw Error(`Can not find provider for ${sessionResource}`); } + if (sessionResource.path.startsWith('/untitled')) { + const newSessionOptions = this.getNewSessionOptionsForSessionType(resolvedType); + return { + sessionResource: sessionResource, + onWillDispose: Event.None, + history: [], + options: newSessionOptions ?? {}, + dispose: () => { } + }; + } + const session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); // Make sure another session wasn't created while we were awaiting the provider @@ -1094,6 +1106,14 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._sessionTypeOptions.get(chatSessionType); } + public getNewSessionOptionsForSessionType(chatSessionType: string): Record | undefined { + return this._sessionTypeNewSessionOptions.get(chatSessionType); + } + + public setNewSessionOptionsForSessionType(chatSessionType: string, options: Record): void { + this._sessionTypeNewSessionOptions.set(chatSessionType, options); + } + /** * Notify extension about option changes for a session */ diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index ec178df2ab9c1..858254564bead 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -287,6 +287,9 @@ export interface IChatSessionsService { getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined; setOptionGroupsForSessionType(chatSessionType: string, handle: number, optionGroups?: IChatSessionProviderOptionGroup[]): void; + + getNewSessionOptionsForSessionType(chatSessionType: string): Record | undefined; + setNewSessionOptionsForSessionType(chatSessionType: string, options: Record): void; /** * Event fired when session options change and need to be sent to the extension. * MainThreadChatSessions subscribes to this to forward changes to the extension host. diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index bbeaa0b4c058a..46cdb2f5e0968 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -177,6 +177,14 @@ export class MockChatSessionsService implements IChatSessionsService { } } + getNewSessionOptionsForSessionType(_chatSessionType: string): Record | undefined { + return undefined; + } + + setNewSessionOptionsForSessionType(_chatSessionType: string, _options: Record): void { + // noop + } + async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise { await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); } diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index f0f1e7fb27916..1306bb7f480cd 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -456,9 +456,8 @@ declare module 'vscode' { /** * Called as soon as you register (call me once) - * @param token */ - provideChatSessionProviderOptions?(token: CancellationToken): Thenable; + provideChatSessionProviderOptions?(token: CancellationToken): Thenable; } export interface ChatSessionOptionUpdate { @@ -615,6 +614,13 @@ declare module 'vscode' { * Provider-defined option groups (0-2 groups supported). * Examples: models picker, sub-agents picker, etc. */ - optionGroups?: ChatSessionProviderOptionGroup[]; + readonly optionGroups?: readonly ChatSessionProviderOptionGroup[]; + + /** + * The set of default options used for new chat sessions, provided as key-value pairs. + * + * Keys correspond to option group IDs (e.g., 'models', 'subagents'). + */ + readonly newSessionOptions?: Record; } } From f6b913e4ae24c1ea9076bea585d0dfd269928f36 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:32:08 -0800 Subject: [PATCH 005/541] Fix setting options in UI --- .../browser/chatSessions/chatSessions.contribution.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index b42e78ffe5e72..538cf28732f06 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -1037,18 +1037,23 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ throw Error(`Can not find provider for ${sessionResource}`); } + let session: IChatSession; if (sessionResource.path.startsWith('/untitled')) { const newSessionOptions = this.getNewSessionOptionsForSessionType(resolvedType); - return { + session = { sessionResource: sessionResource, onWillDispose: Event.None, history: [], options: newSessionOptions ?? {}, dispose: () => { } }; - } - const session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); + for (const [optionId, value] of Object.entries(newSessionOptions ?? {})) { + this.setSessionOption(sessionResource, optionId, value); + } + } else { + session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); + } // Make sure another session wasn't created while we were awaiting the provider { From db7be7cd1b3db04ad84d138d43cfc356d9ff4332 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 24 Feb 2026 15:47:06 +0000 Subject: [PATCH 006/541] Enhance notification icons and functionality for expand/collapse actions Co-authored-by: Copilot --- .../notifications/notificationsActions.ts | 13 ++++++++- .../notifications/notificationsViewer.ts | 22 ++++++++++++-- .../browser/notificationsPosition.test.ts | 29 ++++++++++++++++++- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts index 36ccd6f2af856..e0912c3f276d2 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/notificationsActions.css'; -import { INotificationViewItem } from '../../../common/notifications.js'; +import { INotificationViewItem, NotificationsPosition } from '../../../common/notifications.js'; import { localize } from '../../../../nls.js'; import { Action } from '../../../../base/common/actions.js'; import { CLEAR_NOTIFICATION, EXPAND_NOTIFICATION, COLLAPSE_NOTIFICATION, CLEAR_ALL_NOTIFICATIONS, HIDE_NOTIFICATIONS_CENTER, TOGGLE_DO_NOT_DISTURB_MODE, TOGGLE_DO_NOT_DISTURB_MODE_BY_SOURCE } from './notificationsCommands.js'; @@ -19,7 +19,18 @@ const clearAllIcon = registerIcon('notifications-clear-all', Codicon.clearAll, l export const hideIcon = registerIcon('notifications-hide', Codicon.chevronDown, localize('hideIcon', 'Icon for the hide action in notifications.')); export const hideUpIcon = registerIcon('notifications-hide-up', Codicon.chevronUp, localize('hideUpIcon', 'Icon for the hide action in notifications when positioned at the top.')); const expandIcon = registerIcon('notifications-expand', Codicon.chevronUp, localize('expandIcon', 'Icon for the expand action in notifications.')); +const expandDownIcon = registerIcon('notifications-expand-down', Codicon.chevronDown, localize('expandDownIcon', 'Icon for the expand action in notifications when the notification center is at the top.')); const collapseIcon = registerIcon('notifications-collapse', Codicon.chevronDown, localize('collapseIcon', 'Icon for the collapse action in notifications.')); +const collapseUpIcon = registerIcon('notifications-collapse-up', Codicon.chevronUp, localize('collapseUpIcon', 'Icon for the collapse action in notifications when the notification center is at the top.')); + +export function getNotificationExpandIcon(position: NotificationsPosition): ThemeIcon { + return position === NotificationsPosition.TOP_RIGHT ? expandDownIcon : expandIcon; +} + +export function getNotificationCollapseIcon(position: NotificationsPosition): ThemeIcon { + return position === NotificationsPosition.TOP_RIGHT ? collapseUpIcon : collapseIcon; +} + const configureIcon = registerIcon('notifications-configure', Codicon.gear, localize('configureIcon', 'Icon for the configure action in notifications.')); const doNotDisturbIcon = registerIcon('notifications-do-not-disturb', Codicon.bellSlash, localize('doNotDisturbIcon', 'Icon for the mute all action in notifications.')); export const positionIcon = registerIcon('notifications-position', Codicon.arrowSwap, localize('positionIcon', 'Icon for the position action in notifications.')); diff --git a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts index 6512492cae4f4..79d82d23be627 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts @@ -14,8 +14,8 @@ import { ActionRunner, IAction, IActionRunner, Separator, toAction } from '../.. import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { dispose, DisposableStore, Disposable } from '../../../../base/common/lifecycle.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { INotificationViewItem, NotificationViewItem, NotificationViewItemContentChangeKind, INotificationMessage, ChoiceAction } from '../../../common/notifications.js'; -import { ClearNotificationAction, ExpandNotificationAction, CollapseNotificationAction, ConfigureNotificationAction } from './notificationsActions.js'; +import { INotificationViewItem, NotificationViewItem, NotificationViewItemContentChangeKind, INotificationMessage, ChoiceAction, NotificationsSettings, getNotificationsPosition } from '../../../common/notifications.js'; +import { ClearNotificationAction, ExpandNotificationAction, CollapseNotificationAction, ConfigureNotificationAction, getNotificationExpandIcon, getNotificationCollapseIcon } from './notificationsActions.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js'; import { INotificationService, NotificationsFilter, Severity, isNotificationSource } from '../../../../platform/notification/common/notification.js'; @@ -32,6 +32,7 @@ import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import type { IManagedHover } from '../../../../base/browser/ui/hover/hover.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; export class NotificationsListDelegate implements IListVirtualDelegate { @@ -316,6 +317,15 @@ export class NotificationTemplateRenderer extends Disposable { private static expandNotificationAction: ExpandNotificationAction; private static collapseNotificationAction: CollapseNotificationAction; + private static updateExpandCollapseIcons(configurationService: IConfigurationService): void { + if (!NotificationTemplateRenderer.expandNotificationAction) { + return; + } + const position = getNotificationsPosition(configurationService); + NotificationTemplateRenderer.expandNotificationAction.class = ThemeIcon.asClassName(getNotificationExpandIcon(position)); + NotificationTemplateRenderer.collapseNotificationAction.class = ThemeIcon.asClassName(getNotificationCollapseIcon(position)); + } + private static readonly SEVERITIES = [Severity.Info, Severity.Warning, Severity.Error]; private readonly inputDisposables = this._register(new DisposableStore()); @@ -328,6 +338,7 @@ export class NotificationTemplateRenderer extends Disposable { @IKeybindingService private readonly keybindingService: IKeybindingService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @IHoverService private readonly hoverService: IHoverService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); @@ -335,7 +346,14 @@ export class NotificationTemplateRenderer extends Disposable { NotificationTemplateRenderer.closeNotificationAction = instantiationService.createInstance(ClearNotificationAction, ClearNotificationAction.ID, ClearNotificationAction.LABEL); NotificationTemplateRenderer.expandNotificationAction = instantiationService.createInstance(ExpandNotificationAction, ExpandNotificationAction.ID, ExpandNotificationAction.LABEL); NotificationTemplateRenderer.collapseNotificationAction = instantiationService.createInstance(CollapseNotificationAction, CollapseNotificationAction.ID, CollapseNotificationAction.LABEL); + NotificationTemplateRenderer.updateExpandCollapseIcons(configurationService); } + + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION)) { + NotificationTemplateRenderer.updateExpandCollapseIcons(configurationService); + } + })); } setInput(notification: INotificationViewItem): void { diff --git a/src/vs/workbench/test/browser/notificationsPosition.test.ts b/src/vs/workbench/test/browser/notificationsPosition.test.ts index 7b39370d37c73..fac02b907daf5 100644 --- a/src/vs/workbench/test/browser/notificationsPosition.test.ts +++ b/src/vs/workbench/test/browser/notificationsPosition.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/comm import { DEFAULT_CUSTOM_TITLEBAR_HEIGHT } from '../../../platform/window/common/window.js'; import { NotificationsPosition, NotificationsSettings } from '../../common/notifications.js'; import { Codicon } from '../../../base/common/codicons.js'; -import { hideIcon, hideUpIcon } from '../../browser/parts/notifications/notificationsActions.js'; +import { hideIcon, hideUpIcon, getNotificationExpandIcon, getNotificationCollapseIcon } from '../../browser/parts/notifications/notificationsActions.js'; suite('Notifications Position', () => { @@ -142,4 +142,31 @@ suite('Notifications Position', () => { assert.strictEqual(Codicon.chevronUp.id, 'chevron-up'); }); }); + + suite('Expand/Collapse Notification Icons', () => { + + test('bottom-right expand uses notifications-expand icon', () => { + assert.strictEqual(getNotificationExpandIcon(NotificationsPosition.BOTTOM_RIGHT).id, 'notifications-expand'); + }); + + test('bottom-left expand uses notifications-expand icon', () => { + assert.strictEqual(getNotificationExpandIcon(NotificationsPosition.BOTTOM_LEFT).id, 'notifications-expand'); + }); + + test('top-right expand uses notifications-expand-down icon', () => { + assert.strictEqual(getNotificationExpandIcon(NotificationsPosition.TOP_RIGHT).id, 'notifications-expand-down'); + }); + + test('bottom-right collapse uses notifications-collapse icon', () => { + assert.strictEqual(getNotificationCollapseIcon(NotificationsPosition.BOTTOM_RIGHT).id, 'notifications-collapse'); + }); + + test('bottom-left collapse uses notifications-collapse icon', () => { + assert.strictEqual(getNotificationCollapseIcon(NotificationsPosition.BOTTOM_LEFT).id, 'notifications-collapse'); + }); + + test('top-right collapse uses notifications-collapse-up icon', () => { + assert.strictEqual(getNotificationCollapseIcon(NotificationsPosition.TOP_RIGHT).id, 'notifications-collapse-up'); + }); + }); }); From b834690534d42f603a5b297f9cbdc6f82bdd5bb2 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 24 Feb 2026 17:04:04 +0000 Subject: [PATCH 007/541] feat(selectBox): add separator option styling and update focus outlines Co-authored-by: Copilot --- src/vs/base/browser/ui/selectBox/selectBox.ts | 2 + .../browser/ui/selectBox/selectBoxCustom.css | 40 ++++++++++++++++++- .../browser/ui/selectBox/selectBoxCustom.ts | 17 +++++--- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/vs/base/browser/ui/selectBox/selectBox.ts b/src/vs/base/browser/ui/selectBox/selectBox.ts index 335c2c9c09bdc..e70edcbac5f57 100644 --- a/src/vs/base/browser/ui/selectBox/selectBox.ts +++ b/src/vs/base/browser/ui/selectBox/selectBox.ts @@ -51,11 +51,13 @@ export interface ISelectOptionItem { descriptionIsMarkdown?: boolean; readonly descriptionMarkdownActionHandler?: MarkdownActionHandler; isDisabled?: boolean; + isSeparator?: boolean; } export const SeparatorSelectOption: Readonly = Object.freeze({ text: '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500', isDisabled: true, + isSeparator: true, }); export interface ISelectBoxStyles extends IListStyles { diff --git a/src/vs/base/browser/ui/selectBox/selectBoxCustom.css b/src/vs/base/browser/ui/selectBox/selectBoxCustom.css index b2665393270ab..117c5a6cb2a72 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxCustom.css +++ b/src/vs/base/browser/ui/selectBox/selectBoxCustom.css @@ -6,7 +6,7 @@ .monaco-select-box-dropdown-container { display: none; box-sizing: border-box; - border-radius: var(--vscode-cornerRadius-small); + border-radius: var(--vscode-cornerRadius-large); box-shadow: 0 2px 8px var(--vscode-widget-shadow); } @@ -45,6 +45,11 @@ padding: 5px 6px; } +/* Remove focus ring around the list */ +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list:focus::before { + outline: 0 !important; +} + .monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row { cursor: pointer; padding-left: 2px; @@ -76,6 +81,39 @@ } +/* Separator styling */ +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-separator { + cursor: default; + border-radius: 0; + padding: 0; +} + +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-separator > .option-text { + visibility: hidden; + width: 0; + float: none; +} + +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-separator > .option-detail { + display: none; +} + +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-separator > .option-decorator-right { + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-separator::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 1px; + background-color: var(--vscode-menu-separatorBackground); +} + + /* Accepted CSS hiding technique for accessibility reader text */ /* https://webaim.org/techniques/css/invisiblecontent/ */ diff --git a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts index f6c2ff1cb4fec..21136145c406f 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts +++ b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts @@ -71,6 +71,13 @@ class SelectListRenderer implements IListRenderer .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { background-color: ${this.styles.listHoverBackground} !important; }`); } - // Match quick input outline styles - ignore for disabled options + // Match action widget outline styles - ignore for disabled options if (this.styles.listFocusOutline) { - content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { outline: 1.6px dotted ${this.styles.listFocusOutline} !important; outline-offset: -1.6px !important; }`); + content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { outline: 1px solid ${this.styles.listFocusOutline} !important; outline-offset: -1px !important; }`); } if (this.styles.listHoverOutline) { - content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { outline: 1.6px dashed ${this.styles.listHoverOutline} !important; outline-offset: -1.6px !important; }`); + content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { outline: 1px solid ${this.styles.listHoverOutline} !important; outline-offset: -1px !important; }`); } // Clear list styles on focus and on hover for disabled options @@ -425,11 +432,9 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi const background = this.styles.selectBackground ?? ''; const listBackground = cssJs.asCssValueWithDefault(this.styles.selectListBackground, background); + this.selectDropDownContainer.style.backgroundColor = listBackground; this.selectDropDownListContainer.style.backgroundColor = listBackground; this.selectionDetailsPane.style.backgroundColor = listBackground; - const optionsBorder = this.styles.focusBorder ?? ''; - this.selectDropDownContainer.style.outlineColor = optionsBorder; - this.selectDropDownContainer.style.outlineOffset = '-1px'; this.selectList.style(this.styles); } From 0f53f0e0ebfdd2b5879ba5108978a285f10630b0 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 24 Feb 2026 17:06:40 +0000 Subject: [PATCH 008/541] feat(debug): add separator option to select box in StartDebugActionViewItem --- .../workbench/contrib/debug/browser/debugActionViewItems.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/debug/browser/debugActionViewItems.ts b/src/vs/workbench/contrib/debug/browser/debugActionViewItems.ts index 43c4dba2f34b6..4b74c19bf585f 100644 --- a/src/vs/workbench/contrib/debug/browser/debugActionViewItems.ts +++ b/src/vs/workbench/contrib/debug/browser/debugActionViewItems.ts @@ -265,7 +265,11 @@ export class StartDebugActionViewItem extends BaseActionViewItem { }); }); - this.selectBox.setOptions(this.debugOptions.map((data, index): ISelectOptionItem => ({ text: data.label, isDisabled: disabledIdxs.indexOf(index) !== -1 })), this.selected); + this.selectBox.setOptions(this.debugOptions.map((data, index): ISelectOptionItem => ({ + text: data.label, + isDisabled: disabledIdxs.indexOf(index) !== -1, + isSeparator: data.label === SeparatorSelectOption.text, + })), this.selected); } private _setAriaLabel(title: string): void { From 21be8883be2bf8d072ff717d53e4ab4c3ab79451 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 10:18:07 -0800 Subject: [PATCH 009/541] Implement slash command support in NewChatWidget --- .../contrib/chat/browser/newChatViewPane.ts | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 10dc60a3800a1..8d823c27b3203 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -21,6 +21,15 @@ import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/b import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; import { IModelService } from '../../../../editor/common/services/model.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { CompletionContext, CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { getWordAtText } from '../../../../editor/common/core/wordHelper.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; +import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -58,6 +67,23 @@ import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { FolderPicker } from './folderPicker.js'; +// #region --- Slash Command Data --- + +/** + * Minimal slash command descriptor for the sessions new-chat widget. + * Self-contained copy of the essential fields from core's `IChatSlashData` + * to avoid a direct dependency on the workbench chat slash command service. + */ +interface ISessionsSlashCommandData { + readonly command: string; + readonly detail: string; + readonly sortText?: string; + /** Whether the command should execute as soon as it is entered. */ + readonly executeImmediately?: boolean; +} + +// #endregion + // #region --- Target Config --- /** @@ -219,6 +245,9 @@ class NewChatWidget extends Disposable { // Attached context private readonly _contextAttachments: NewChatContextAttachments; + // Slash commands + private readonly _slashCommands: ISessionsSlashCommandData[] = []; + constructor( options: INewChatWidgetOptions, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -232,6 +261,7 @@ class NewChatWidget extends Disposable { @IHoverService _hoverService: IHoverService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, ) { super(); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); @@ -281,6 +311,9 @@ class NewChatWidget extends Disposable { this._renderExtensionPickers(true); } })); + + // Register slash commands + this._registerSlashCommands(); } // --- Rendering --- @@ -397,6 +430,8 @@ class NewChatWidget extends Disposable { isSimpleWidget: true, contributions: EditorExtensionsRegistry.getSomeEditorContributions([ ContextMenuController.ID, + SuggestController.ID, + SnippetController2.ID, ]), }; @@ -416,6 +451,9 @@ class NewChatWidget extends Disposable { this._register(this._editor.onDidContentSizeChange(() => { this._editor.layout(); })); + + // Register slash command completions for this editor + this._registerSlashCommandCompletions(); } private _createAttachButton(container: HTMLElement): void { @@ -988,6 +1026,12 @@ class NewChatWidget extends Disposable { return; } + // Check for slash commands first + if (this._tryExecuteSlashCommand(query)) { + this._editor.getModel()?.setValue(''); + return; + } + const target = this._getEffectiveTarget(); if (!target) { this.logService.warn('ChatWelcomeWidget: No target selected, cannot create session'); @@ -1031,6 +1075,120 @@ class NewChatWidget extends Disposable { this._contextAttachments.clear(); } + // --- Slash commands --- + + private _registerSlashCommands(): void { + this._slashCommands.push({ + command: 'clear', + detail: localize('slashCommand.clear', "Start a new chat"), + sortText: 'z2_clear', + executeImmediately: true, + }); + this._slashCommands.push({ + command: 'help', + detail: localize('slashCommand.help', "Show available slash commands"), + sortText: 'z1_help', + executeImmediately: true, + }); + } + + /** + * Attempts to parse and execute a slash command from the input. + * Returns `true` if a command was handled. + */ + private _tryExecuteSlashCommand(query: string): boolean { + const match = query.match(/^\/(\w+)\s*/); + if (!match) { + return false; + } + + const commandName = match[1]; + const slashCommand = this._slashCommands.find(c => c.command === commandName); + if (!slashCommand) { + return false; + } + + switch (slashCommand.command) { + case 'clear': + this.sessionsManagementService.openNewSession(); + return true; + case 'help': { + const helpLines = this._slashCommands.map(c => ` /${c.command} — ${c.detail}`); + this.logService.info(`Available slash commands:\n${helpLines.join('\n')}`); + return true; + } + default: + return false; + } + } + + private _registerSlashCommandCompletions(): void { + const uri = this._editor.getModel()?.uri; + if (!uri) { + return; + } + + this._register(this.languageFeaturesService.completionProvider.register({ pattern: `**/${uri.path}`, scheme: uri.scheme }, { + _debugDisplayName: 'sessionsSlashCommands', + triggerCharacters: ['/'], + provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + const range = this._computeCompletionRanges(model, position, /\/\w*/g); + if (!range) { + return null; + } + + // Only allow slash commands at the start of input + const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); + if (textBefore.trim() !== '') { + return null; + } + + return { + suggestions: this._slashCommands.map((c, i): CompletionItem => { + const withSlash = `/${c.command}`; + return { + label: withSlash, + insertText: c.executeImmediately ? `${withSlash} ` : `${withSlash} `, + detail: c.detail, + range, + sortText: c.sortText ?? 'a'.repeat(i + 1), + kind: CompletionItemKind.Text, + }; + }) + }; + } + })); + } + + /** + * Compute insert and replace ranges for completion at the given position. + * Minimal copy of the helper from chatInputCompletions. + */ + private _computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp): { insert: Range; replace: Range } | undefined { + const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0); + if (!varWord && model.getWordUntilPosition(position).word) { + return; + } + + if (!varWord && position.column > 1) { + const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column)); + if (textBefore !== ' ') { + return; + } + } + + let insert: Range; + let replace: Range; + if (!varWord) { + insert = replace = Range.fromPositions(position); + } else { + insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column); + replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn); + } + + return { insert, replace }; + } + // --- Layout --- layout(_height: number, _width: number): void { From 70bcb89fc4657ee319ec18d67375b4f4a7f9180c Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 10:41:00 -0800 Subject: [PATCH 010/541] Refactor completion provider registration in NewChatWidget to simplify URI pattern handling --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 8d823c27b3203..e0be1fe1699b3 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -1128,7 +1128,7 @@ class NewChatWidget extends Disposable { return; } - this._register(this.languageFeaturesService.completionProvider.register({ pattern: `**/${uri.path}`, scheme: uri.scheme }, { + this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme }, { _debugDisplayName: 'sessionsSlashCommands', triggerCharacters: ['/'], provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { From c878a1b06ea3f633ce2c8755635d426cc6bc246b Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 10:43:23 -0800 Subject: [PATCH 011/541] Enhance NewChatWidget suggest options and ensure proper rendering above input --- .../sessions/contrib/chat/browser/media/chatWidget.css | 2 +- .../sessions/contrib/chat/browser/newChatViewPane.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 8710e63bf3dc3..579e2f76d756f 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -28,7 +28,7 @@ border: 1px solid var(--vscode-input-border, var(--vscode-contrastBorder, transparent)); border-radius: 8px; background-color: var(--vscode-input-background); - overflow: hidden; + overflow: visible; display: flex; flex-direction: column; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index e0be1fe1699b3..e9c7c0f9ed56a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -424,6 +424,13 @@ class NewChatWidget extends Disposable { wrappingStrategy: 'advanced', stickyScroll: { enabled: false }, renderWhitespace: 'none', + suggest: { + showIcons: true, + showSnippets: false, + showWords: true, + showStatusBar: false, + insertMode: 'insert', + }, }; const widgetOptions: ICodeEditorWidgetOptions = { @@ -440,6 +447,9 @@ class NewChatWidget extends Disposable { )); this._editor.setModel(textModel); + // Ensure suggest widget renders above the input (not clipped by container) + SuggestController.get(this._editor)?.forceRenderingAbove(); + this._register(this._editor.onKeyDown(e => { if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); From e7b5088e58f5826088f2cbc6751e0bbc1516345f Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 10:47:36 -0800 Subject: [PATCH 012/541] Enhance completion provider registration in NewChatWidget to allow access to all models --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index e9c7c0f9ed56a..583d361cd854a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -1138,7 +1138,7 @@ class NewChatWidget extends Disposable { return; } - this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme }, { + this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { _debugDisplayName: 'sessionsSlashCommands', triggerCharacters: ['/'], provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { From ea04f40b6f32e9ca06bb9f51cbcca3650c441445 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 10:49:49 -0800 Subject: [PATCH 013/541] Add logging for slash command completion registration and suggestions --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 583d361cd854a..f82895fd0961b 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -1135,24 +1135,32 @@ class NewChatWidget extends Disposable { private _registerSlashCommandCompletions(): void { const uri = this._editor.getModel()?.uri; if (!uri) { + this.logService.warn('[SlashCommands] No editor model URI, skipping completion registration'); return; } + this.logService.info(`[SlashCommands] Registering completion provider for scheme="${uri.scheme}", uri="${uri.toString()}", commands=${this._slashCommands.length}`); + this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { _debugDisplayName: 'sessionsSlashCommands', triggerCharacters: ['/'], provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + this.logService.info(`[SlashCommands] provideCompletionItems called, model.uri="${model.uri.toString()}", position=${position.lineNumber}:${position.column}`); + const range = this._computeCompletionRanges(model, position, /\/\w*/g); if (!range) { + this.logService.info('[SlashCommands] No completion range found'); return null; } // Only allow slash commands at the start of input const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); if (textBefore.trim() !== '') { + this.logService.info(`[SlashCommands] Text before slash command: "${textBefore}", skipping`); return null; } + this.logService.info(`[SlashCommands] Returning ${this._slashCommands.length} suggestions`); return { suggestions: this._slashCommands.map((c, i): CompletionItem => { const withSlash = `/${c.command}`; From e06b08cadd7a2c6ee6f2e2bf4bab42d629c4ea9a Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 10:56:45 -0800 Subject: [PATCH 014/541] Enhance NewChatWidget to prevent clipping of suggest widget by adding overflow handling --- .../sessions/contrib/chat/browser/newChatViewPane.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index f82895fd0961b..3a20664bc4b2e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -320,6 +320,12 @@ class NewChatWidget extends Disposable { render(container: HTMLElement): void { const wrapper = dom.append(container, dom.$('.sessions-chat-widget')); + + // Overflow widget DOM node at the top level so the suggest widget + // is not clipped by any overflow:hidden ancestor. + const editorOverflowWidgetsDomNode = dom.append(container, dom.$('.sessions-chat-editor-overflow.monaco-editor')); + this._register({ dispose: () => editorOverflowWidgetsDomNode.remove() }); + const welcomeElement = dom.append(wrapper, dom.$('.chat-full-welcome')); // Watermark letterpress @@ -343,7 +349,7 @@ class NewChatWidget extends Disposable { const attachedContextContainer = dom.append(attachRow, dom.$('.sessions-chat-attached-context')); this._contextAttachments.renderAttachedContext(attachedContextContainer); - this._createEditor(inputArea); + this._createEditor(inputArea, editorOverflowWidgetsDomNode); this._createBottomToolbar(inputArea); this._inputSlot.appendChild(inputArea); @@ -406,7 +412,7 @@ class NewChatWidget extends Disposable { // --- Editor --- - private _createEditor(container: HTMLElement): void { + private _createEditor(container: HTMLElement, overflowWidgetsDomNode: HTMLElement): void { const editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); const uri = URI.from({ scheme: 'sessions-chat', path: `input-${Date.now()}` }); @@ -424,6 +430,7 @@ class NewChatWidget extends Disposable { wrappingStrategy: 'advanced', stickyScroll: { enabled: false }, renderWhitespace: 'none', + overflowWidgetsDomNode, suggest: { showIcons: true, showSnippets: false, From d31d6918248e3d725f30e26370c7d36a50706768 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 10:57:53 -0800 Subject: [PATCH 015/541] Fix overflow handling in chat input area to prevent content overflow --- src/vs/sessions/contrib/chat/browser/media/chatWidget.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 579e2f76d756f..8710e63bf3dc3 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -28,7 +28,7 @@ border: 1px solid var(--vscode-input-border, var(--vscode-contrastBorder, transparent)); border-radius: 8px; background-color: var(--vscode-input-background); - overflow: visible; + overflow: hidden; display: flex; flex-direction: column; } From ac774b9629f40756ae8b17481e604b8c3da5fc38 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:03:49 -0800 Subject: [PATCH 016/541] Add slash command decorations in NewChatWidget for enhanced user experience --- .../contrib/chat/browser/newChatViewPane.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 3a20664bc4b2e..a0390371a2b01 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -22,12 +22,15 @@ import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExten import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; import { IModelService } from '../../../../editor/common/services/model.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { CompletionContext, CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js'; import { ITextModel } from '../../../../editor/common/model.js'; +import { IDecorationOptions } from '../../../../editor/common/editorCommon.js'; import { Position } from '../../../../editor/common/core/position.js'; import { Range } from '../../../../editor/common/core/range.js'; import { getWordAtText } from '../../../../editor/common/core/wordHelper.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { themeColorFromId } from '../../../../base/common/themables.js'; import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -38,12 +41,14 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { ILogService } from '../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { inputPlaceholderForeground } from '../../../../platform/theme/common/colorRegistry.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; @@ -262,6 +267,8 @@ class NewChatWidget extends Disposable { @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + @IThemeService private readonly themeService: IThemeService, ) { super(); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); @@ -471,6 +478,9 @@ class NewChatWidget extends Disposable { // Register slash command completions for this editor this._registerSlashCommandCompletions(); + + // Register slash command decorations (blue highlight + placeholder) + this._registerSlashCommandDecorations(); } private _createAttachButton(container: HTMLElement): void { @@ -1109,6 +1119,74 @@ class NewChatWidget extends Disposable { }); } + private static readonly _slashDecoType = 'sessions-slash-command'; + private static readonly _slashPlaceholderDecoType = 'sessions-slash-placeholder'; + private _slashDecosRegistered = false; + + private _registerSlashCommandDecorations(): void { + if (!this._slashDecosRegistered) { + this._slashDecosRegistered = true; + this._register(this.codeEditorService.registerDecorationType('sessions-chat', NewChatWidget._slashDecoType, { + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), + borderRadius: '3px', + })); + this._register(this.codeEditorService.registerDecorationType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, {})); + } + + this._register(this._editor.onDidChangeModelContent(() => this._updateSlashCommandDecorations())); + this._updateSlashCommandDecorations(); + } + + private _updateSlashCommandDecorations(): void { + const value = this._editor.getValue(); + const match = value.match(/^\/(\w+)\s?/); + + if (!match) { + this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashDecoType, []); + this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, []); + return; + } + + const commandName = match[1]; + const slashCommand = this._slashCommands.find(c => c.command === commandName); + if (!slashCommand) { + this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashDecoType, []); + this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, []); + return; + } + + // Highlight the slash command text in blue + const commandEnd = match[0].trimEnd().length; + const commandDeco: IDecorationOptions[] = [{ + range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: commandEnd + 1 }, + }]; + this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashDecoType, commandDeco); + + // Show the command description as a placeholder after the command + const restOfInput = value.slice(match[0].length).trim(); + if (!restOfInput && slashCommand.detail) { + const placeholderCol = match[0].length + 1; + const placeholderDeco: IDecorationOptions[] = [{ + range: { startLineNumber: 1, startColumn: placeholderCol, endLineNumber: 1, endColumn: 1000 }, + renderOptions: { + after: { + contentText: slashCommand.detail, + color: this._getPlaceholderColor(), + } + } + }]; + this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, placeholderDeco); + } else { + this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, []); + } + } + + private _getPlaceholderColor(): string | undefined { + const theme = this.themeService.getColorTheme(); + return theme.getColor(inputPlaceholderForeground)?.toString(); + } + /** * Attempts to parse and execute a slash command from the input. * Returns `true` if a command was handled. From 306e923a21d593d2cd9dcc8eccca6951e8374be0 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:06:47 -0800 Subject: [PATCH 017/541] Prevent sending message when suggest widget is visible in NewChatWidget --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index a0390371a2b01..aa20a4d3ce1b6 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -466,6 +466,10 @@ class NewChatWidget extends Disposable { this._register(this._editor.onKeyDown(e => { if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && !e.altKey) { + // Don't send if the suggest widget is visible (let it accept the completion) + if (this.contextKeyService.getContextKeyValue('suggestWidgetVisible')) { + return; + } e.preventDefault(); e.stopPropagation(); this._send(); From bf8f505cd6eebdcd7193315c0cccccc28dce0b5d Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:08:36 -0800 Subject: [PATCH 018/541] Fix context key reference for suggest widget visibility in NewChatWidget --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index aa20a4d3ce1b6..aa4076adf877c 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -467,7 +467,7 @@ class NewChatWidget extends Disposable { this._register(this._editor.onKeyDown(e => { if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && !e.altKey) { // Don't send if the suggest widget is visible (let it accept the completion) - if (this.contextKeyService.getContextKeyValue('suggestWidgetVisible')) { + if (this._editor.contextKeyService.getContextKeyValue('suggestWidgetVisible')) { return; } e.preventDefault(); From ba931887fa240758b2bc3a30274ae25449b7f98d Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:17:20 -0800 Subject: [PATCH 019/541] Add slash command execution support in NewChatWidget for enhanced functionality --- .../contrib/chat/browser/newChatViewPane.ts | 91 ++++++++++++++----- 1 file changed, 69 insertions(+), 22 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index aa4076adf877c..b8352020be101 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -36,6 +36,7 @@ import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/s import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -85,6 +86,8 @@ interface ISessionsSlashCommandData { readonly sortText?: string; /** Whether the command should execute as soon as it is entered. */ readonly executeImmediately?: boolean; + /** Callback to execute when the command is invoked. */ + readonly execute: (args: string) => void; } // #endregion @@ -269,6 +272,7 @@ class NewChatWidget extends Disposable { @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IThemeService private readonly themeService: IThemeService, + @ICommandService private readonly commandService: ICommandService, ) { super(); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); @@ -1114,12 +1118,73 @@ class NewChatWidget extends Disposable { detail: localize('slashCommand.clear', "Start a new chat"), sortText: 'z2_clear', executeImmediately: true, + execute: () => this.sessionsManagementService.openNewSession(), }); this._slashCommands.push({ command: 'help', detail: localize('slashCommand.help', "Show available slash commands"), sortText: 'z1_help', executeImmediately: true, + execute: () => { + const helpLines = this._slashCommands.map(c => ` /${c.command} — ${c.detail}`); + this.logService.info(`Available slash commands:\n${helpLines.join('\n')}`); + }, + }); + this._slashCommands.push({ + command: 'models', + detail: localize('slashCommand.models', "Open the model picker"), + sortText: 'z3_models', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.openModelPicker'), + }); + this._slashCommands.push({ + command: 'agents', + detail: localize('slashCommand.agents', "Configure custom agents"), + sortText: 'z3_agents', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.openModePicker'), + }); + this._slashCommands.push({ + command: 'tools', + detail: localize('slashCommand.tools', "Configure tools"), + sortText: 'z3_tools', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.configureTools'), + }); + this._slashCommands.push({ + command: 'skills', + detail: localize('slashCommand.skills', "Configure skills"), + sortText: 'z3_skills', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.configure.skills'), + }); + this._slashCommands.push({ + command: 'instructions', + detail: localize('slashCommand.instructions', "Configure instructions"), + sortText: 'z3_instructions', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.configure.instructions'), + }); + this._slashCommands.push({ + command: 'prompts', + detail: localize('slashCommand.prompts', "Configure prompt files"), + sortText: 'z3_prompts', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.configure.prompts'), + }); + this._slashCommands.push({ + command: 'hooks', + detail: localize('slashCommand.hooks', "Configure hooks"), + sortText: 'z3_hooks', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.configure.hooks'), + }); + this._slashCommands.push({ + command: 'debug', + detail: localize('slashCommand.debug', "Show Chat Debug View"), + sortText: 'z3_debug', + executeImmediately: true, + execute: () => this.commandService.executeCommand('github.copilot.debug.showChatLogView'), }); } @@ -1196,7 +1261,7 @@ class NewChatWidget extends Disposable { * Returns `true` if a command was handled. */ private _tryExecuteSlashCommand(query: string): boolean { - const match = query.match(/^\/(\w+)\s*/); + const match = query.match(/^\/(\w+)\s*(.*)/s); if (!match) { return false; } @@ -1207,55 +1272,37 @@ class NewChatWidget extends Disposable { return false; } - switch (slashCommand.command) { - case 'clear': - this.sessionsManagementService.openNewSession(); - return true; - case 'help': { - const helpLines = this._slashCommands.map(c => ` /${c.command} — ${c.detail}`); - this.logService.info(`Available slash commands:\n${helpLines.join('\n')}`); - return true; - } - default: - return false; - } + slashCommand.execute(match[2]?.trim() ?? ''); + return true; } private _registerSlashCommandCompletions(): void { const uri = this._editor.getModel()?.uri; if (!uri) { - this.logService.warn('[SlashCommands] No editor model URI, skipping completion registration'); return; } - this.logService.info(`[SlashCommands] Registering completion provider for scheme="${uri.scheme}", uri="${uri.toString()}", commands=${this._slashCommands.length}`); - this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { _debugDisplayName: 'sessionsSlashCommands', triggerCharacters: ['/'], provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { - this.logService.info(`[SlashCommands] provideCompletionItems called, model.uri="${model.uri.toString()}", position=${position.lineNumber}:${position.column}`); - const range = this._computeCompletionRanges(model, position, /\/\w*/g); if (!range) { - this.logService.info('[SlashCommands] No completion range found'); return null; } // Only allow slash commands at the start of input const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); if (textBefore.trim() !== '') { - this.logService.info(`[SlashCommands] Text before slash command: "${textBefore}", skipping`); return null; } - this.logService.info(`[SlashCommands] Returning ${this._slashCommands.length} suggestions`); return { suggestions: this._slashCommands.map((c, i): CompletionItem => { const withSlash = `/${c.command}`; return { label: withSlash, - insertText: c.executeImmediately ? `${withSlash} ` : `${withSlash} `, + insertText: `${withSlash} `, detail: c.detail, range, sortText: c.sortText ?? 'a'.repeat(i + 1), From d31ff50c3ca35fb15a12bf00df951ac0b080fdd6 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:26:33 -0800 Subject: [PATCH 020/541] Enhance slash command functionality in NewChatWidget to support target-specific commands --- .../contrib/chat/browser/newChatViewPane.ts | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index b8352020be101..92ee7ee2a4b52 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -88,6 +88,8 @@ interface ISessionsSlashCommandData { readonly executeImmediately?: boolean; /** Callback to execute when the command is invoked. */ readonly execute: (args: string) => void; + /** Which session targets this command applies to. If omitted, shown for all targets. */ + readonly targets?: readonly AgentSessionProviders[]; } // #endregion @@ -1113,6 +1115,10 @@ class NewChatWidget extends Disposable { // --- Slash commands --- private _registerSlashCommands(): void { + const Local = AgentSessionProviders.Local; + const Background = AgentSessionProviders.Background; + const Cloud = AgentSessionProviders.Cloud; + this._slashCommands.push({ command: 'clear', detail: localize('slashCommand.clear', "Start a new chat"), @@ -1126,7 +1132,8 @@ class NewChatWidget extends Disposable { sortText: 'z1_help', executeImmediately: true, execute: () => { - const helpLines = this._slashCommands.map(c => ` /${c.command} — ${c.detail}`); + const commands = this._getSlashCommandsForCurrentTarget(); + const helpLines = commands.map(c => ` /${c.command} — ${c.detail}`); this.logService.info(`Available slash commands:\n${helpLines.join('\n')}`); }, }); @@ -1150,6 +1157,7 @@ class NewChatWidget extends Disposable { sortText: 'z3_tools', executeImmediately: true, execute: () => this.commandService.executeCommand('workbench.action.chat.configureTools'), + targets: [Local, Background], }); this._slashCommands.push({ command: 'skills', @@ -1157,6 +1165,7 @@ class NewChatWidget extends Disposable { sortText: 'z3_skills', executeImmediately: true, execute: () => this.commandService.executeCommand('workbench.action.chat.configure.skills'), + targets: [Local, Background], }); this._slashCommands.push({ command: 'instructions', @@ -1164,6 +1173,7 @@ class NewChatWidget extends Disposable { sortText: 'z3_instructions', executeImmediately: true, execute: () => this.commandService.executeCommand('workbench.action.chat.configure.instructions'), + targets: [Local, Background], }); this._slashCommands.push({ command: 'prompts', @@ -1171,6 +1181,7 @@ class NewChatWidget extends Disposable { sortText: 'z3_prompts', executeImmediately: true, execute: () => this.commandService.executeCommand('workbench.action.chat.configure.prompts'), + targets: [Local, Background], }); this._slashCommands.push({ command: 'hooks', @@ -1178,6 +1189,7 @@ class NewChatWidget extends Disposable { sortText: 'z3_hooks', executeImmediately: true, execute: () => this.commandService.executeCommand('workbench.action.chat.configure.hooks'), + targets: [Local, Background], }); this._slashCommands.push({ command: 'debug', @@ -1188,6 +1200,14 @@ class NewChatWidget extends Disposable { }); } + private _getSlashCommandsForCurrentTarget(): ISessionsSlashCommandData[] { + const target = this._getEffectiveTarget(); + if (!target) { + return this._slashCommands; + } + return this._slashCommands.filter(c => !c.targets || c.targets.includes(target)); + } + private static readonly _slashDecoType = 'sessions-slash-command'; private static readonly _slashPlaceholderDecoType = 'sessions-slash-placeholder'; private _slashDecosRegistered = false; @@ -1218,7 +1238,7 @@ class NewChatWidget extends Disposable { } const commandName = match[1]; - const slashCommand = this._slashCommands.find(c => c.command === commandName); + const slashCommand = this._getSlashCommandsForCurrentTarget().find(c => c.command === commandName); if (!slashCommand) { this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashDecoType, []); this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, []); @@ -1267,7 +1287,7 @@ class NewChatWidget extends Disposable { } const commandName = match[1]; - const slashCommand = this._slashCommands.find(c => c.command === commandName); + const slashCommand = this._getSlashCommandsForCurrentTarget().find(c => c.command === commandName); if (!slashCommand) { return false; } @@ -1297,8 +1317,9 @@ class NewChatWidget extends Disposable { return null; } + const commands = this._getSlashCommandsForCurrentTarget(); return { - suggestions: this._slashCommands.map((c, i): CompletionItem => { + suggestions: commands.map((c, i): CompletionItem => { const withSlash = `/${c.command}`; return { label: withSlash, From 4463c8fd065a20a58d0725bf97e99cfbdd7184a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:31:36 +0000 Subject: [PATCH 021/541] Initial plan From 679854a7c5c8d1a1e91d2b09744af234c0634dd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:35:24 +0000 Subject: [PATCH 022/541] Update package.json to reference new unified setting name config.js/ts.experimental.useTsgo Co-authored-by: mjbvz <12821956+mjbvz@users.noreply.github.com> --- extensions/typescript-language-features/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 51446da2325b6..be3ece86babb9 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -2856,13 +2856,13 @@ "command": "typescript.experimental.enableTsgo", "title": "Use TypeScript Go (Experimental)", "category": "TypeScript", - "enablement": "!config.typescript.experimental.useTsgo && config.typescript-go.executablePath" + "enablement": "!config.js/ts.experimental.useTsgo && config.typescript-go.executablePath" }, { "command": "typescript.experimental.disableTsgo", "title": "Stop using TypeScript Go (Experimental)", "category": "TypeScript", - "enablement": "config.typescript.experimental.useTsgo" + "enablement": "config.js/ts.experimental.useTsgo" } ], "menus": { @@ -2939,7 +2939,7 @@ "editor/context": [ { "command": "typescript.goToSourceDefinition", - "when": "!config.typescript.experimental.useTsgo && tsSupportsSourceDefinition && (resourceLangId == typescript || resourceLangId == typescriptreact || resourceLangId == javascript || resourceLangId == javascriptreact)", + "when": "!config.js/ts.experimental.useTsgo && tsSupportsSourceDefinition && (resourceLangId == typescript || resourceLangId == typescriptreact || resourceLangId == javascript || resourceLangId == javascriptreact)", "group": "navigation@1.41" } ], From b88b39db897aa75b633d8a52cda0c9879ea73070 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:36:50 -0800 Subject: [PATCH 023/541] Refactor slash command handling in NewChatWidget to utilize core services and remove deprecated code --- .../contrib/chat/browser/newChatViewPane.ts | 188 ++++++------------ 1 file changed, 61 insertions(+), 127 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 92ee7ee2a4b52..7e76af0b83298 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -36,7 +36,6 @@ import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/s import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -50,6 +49,8 @@ import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; +import { IChatSlashCommandService, IChatSlashData } from '../../../../workbench/contrib/chat/common/participants/chatSlashCommands.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; @@ -73,27 +74,6 @@ import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { FolderPicker } from './folderPicker.js'; -// #region --- Slash Command Data --- - -/** - * Minimal slash command descriptor for the sessions new-chat widget. - * Self-contained copy of the essential fields from core's `IChatSlashData` - * to avoid a direct dependency on the workbench chat slash command service. - */ -interface ISessionsSlashCommandData { - readonly command: string; - readonly detail: string; - readonly sortText?: string; - /** Whether the command should execute as soon as it is entered. */ - readonly executeImmediately?: boolean; - /** Callback to execute when the command is invoked. */ - readonly execute: (args: string) => void; - /** Which session targets this command applies to. If omitted, shown for all targets. */ - readonly targets?: readonly AgentSessionProviders[]; -} - -// #endregion - // #region --- Target Config --- /** @@ -255,9 +235,6 @@ class NewChatWidget extends Disposable { // Attached context private readonly _contextAttachments: NewChatContextAttachments; - // Slash commands - private readonly _slashCommands: ISessionsSlashCommandData[] = []; - constructor( options: INewChatWidgetOptions, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -274,7 +251,8 @@ class NewChatWidget extends Disposable { @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IThemeService private readonly themeService: IThemeService, - @ICommandService private readonly commandService: ICommandService, + @IChatSlashCommandService private readonly slashCommandService: IChatSlashCommandService, + @IPromptsService private readonly promptsService: IPromptsService, ) { super(); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); @@ -324,9 +302,6 @@ class NewChatWidget extends Disposable { this._renderExtensionPickers(true); } })); - - // Register slash commands - this._registerSlashCommands(); } // --- Rendering --- @@ -1114,98 +1089,12 @@ class NewChatWidget extends Disposable { // --- Slash commands --- - private _registerSlashCommands(): void { - const Local = AgentSessionProviders.Local; - const Background = AgentSessionProviders.Background; - const Cloud = AgentSessionProviders.Cloud; - - this._slashCommands.push({ - command: 'clear', - detail: localize('slashCommand.clear', "Start a new chat"), - sortText: 'z2_clear', - executeImmediately: true, - execute: () => this.sessionsManagementService.openNewSession(), - }); - this._slashCommands.push({ - command: 'help', - detail: localize('slashCommand.help', "Show available slash commands"), - sortText: 'z1_help', - executeImmediately: true, - execute: () => { - const commands = this._getSlashCommandsForCurrentTarget(); - const helpLines = commands.map(c => ` /${c.command} — ${c.detail}`); - this.logService.info(`Available slash commands:\n${helpLines.join('\n')}`); - }, - }); - this._slashCommands.push({ - command: 'models', - detail: localize('slashCommand.models', "Open the model picker"), - sortText: 'z3_models', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.openModelPicker'), - }); - this._slashCommands.push({ - command: 'agents', - detail: localize('slashCommand.agents', "Configure custom agents"), - sortText: 'z3_agents', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.openModePicker'), - }); - this._slashCommands.push({ - command: 'tools', - detail: localize('slashCommand.tools', "Configure tools"), - sortText: 'z3_tools', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.configureTools'), - targets: [Local, Background], - }); - this._slashCommands.push({ - command: 'skills', - detail: localize('slashCommand.skills', "Configure skills"), - sortText: 'z3_skills', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.configure.skills'), - targets: [Local, Background], - }); - this._slashCommands.push({ - command: 'instructions', - detail: localize('slashCommand.instructions', "Configure instructions"), - sortText: 'z3_instructions', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.configure.instructions'), - targets: [Local, Background], - }); - this._slashCommands.push({ - command: 'prompts', - detail: localize('slashCommand.prompts', "Configure prompt files"), - sortText: 'z3_prompts', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.configure.prompts'), - targets: [Local, Background], - }); - this._slashCommands.push({ - command: 'hooks', - detail: localize('slashCommand.hooks', "Configure hooks"), - sortText: 'z3_hooks', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.configure.hooks'), - targets: [Local, Background], - }); - this._slashCommands.push({ - command: 'debug', - detail: localize('slashCommand.debug', "Show Chat Debug View"), - sortText: 'z3_debug', - executeImmediately: true, - execute: () => this.commandService.executeCommand('github.copilot.debug.showChatLogView'), - }); - } - - private _getSlashCommandsForCurrentTarget(): ISessionsSlashCommandData[] { - const target = this._getEffectiveTarget(); - if (!target) { - return this._slashCommands; - } - return this._slashCommands.filter(c => !c.targets || c.targets.includes(target)); + /** + * Get the slash commands available for the current target from the core + * `IChatSlashCommandService`, which holds all registered commands. + */ + private _getSlashCommands(): IChatSlashData[] { + return this.slashCommandService.getCommands(ChatAgentLocation.Chat, ChatModeKind.Agent); } private static readonly _slashDecoType = 'sessions-slash-command'; @@ -1238,7 +1127,7 @@ class NewChatWidget extends Disposable { } const commandName = match[1]; - const slashCommand = this._getSlashCommandsForCurrentTarget().find(c => c.command === commandName); + const slashCommand = this._getSlashCommands().find(c => c.command === commandName); if (!slashCommand) { this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashDecoType, []); this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, []); @@ -1287,12 +1176,20 @@ class NewChatWidget extends Disposable { } const commandName = match[1]; - const slashCommand = this._getSlashCommandsForCurrentTarget().find(c => c.command === commandName); - if (!slashCommand) { + if (!this.slashCommandService.hasCommand(commandName)) { return false; } - slashCommand.execute(match[2]?.trim() ?? ''); + // Execute via the core slash command service + this.slashCommandService.executeCommand( + commandName, + match[2]?.trim() ?? '', + { report: () => { } }, + [], + ChatAgentLocation.Chat, + this._pendingSessionResource ?? URI.parse('sessions-chat:empty'), + CancellationToken.None, + ); return true; } @@ -1302,6 +1199,7 @@ class NewChatWidget extends Disposable { return; } + // Core slash commands (registered via IChatSlashCommandService) this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { _debugDisplayName: 'sessionsSlashCommands', triggerCharacters: ['/'], @@ -1317,13 +1215,13 @@ class NewChatWidget extends Disposable { return null; } - const commands = this._getSlashCommandsForCurrentTarget(); + const commands = this._getSlashCommands(); return { suggestions: commands.map((c, i): CompletionItem => { const withSlash = `/${c.command}`; return { label: withSlash, - insertText: `${withSlash} `, + insertText: c.executeImmediately ? '' : `${withSlash} `, detail: c.detail, range, sortText: c.sortText ?? 'a'.repeat(i + 1), @@ -1333,6 +1231,42 @@ class NewChatWidget extends Disposable { }; } })); + + // Prompt slash commands (registered via IPromptsService) + this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { + _debugDisplayName: 'sessionsPromptSlashCommands', + triggerCharacters: ['/'], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => { + const range = this._computeCompletionRanges(model, position, /\/\w*/g); + if (!range) { + return null; + } + + const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); + if (textBefore.trim() !== '') { + return null; + } + + const promptCommands = await this.promptsService.getPromptSlashCommands(token); + if (!promptCommands || token.isCancellationRequested) { + return null; + } + + return { + suggestions: promptCommands.map((c, i): CompletionItem => { + const withSlash = `/${c.name}`; + return { + label: withSlash, + insertText: `${withSlash} `, + detail: c.description, + range, + sortText: c.name, + kind: CompletionItemKind.Text, + }; + }) + }; + } + })); } /** From 96b5c00ba299e2dda7a97fdac6fa5eda98966650 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:39:36 -0800 Subject: [PATCH 024/541] Implement slash command registration and execution in NewChatWidget --- .../contrib/chat/browser/newChatViewPane.ts | 141 +++++++++++++++--- 1 file changed, 119 insertions(+), 22 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 7e76af0b83298..d349a78aa3633 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -36,6 +36,7 @@ import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/s import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -49,7 +50,6 @@ import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; -import { IChatSlashCommandService, IChatSlashData } from '../../../../workbench/contrib/chat/common/participants/chatSlashCommands.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; @@ -74,6 +74,19 @@ import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { FolderPicker } from './folderPicker.js'; +/** + * Minimal slash command descriptor for the sessions new-chat widget. + * Self-contained copy of the essential fields from core's `IChatSlashData` + * to avoid a direct dependency on the workbench chat slash command service. + */ +interface ISessionsSlashCommandData { + readonly command: string; + readonly detail: string; + readonly sortText?: string; + readonly executeImmediately?: boolean; + readonly execute: (args: string) => void; +} + // #region --- Target Config --- /** @@ -235,6 +248,9 @@ class NewChatWidget extends Disposable { // Attached context private readonly _contextAttachments: NewChatContextAttachments; + // Slash commands + private readonly _slashCommands: ISessionsSlashCommandData[] = []; + constructor( options: INewChatWidgetOptions, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -251,7 +267,7 @@ class NewChatWidget extends Disposable { @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IThemeService private readonly themeService: IThemeService, - @IChatSlashCommandService private readonly slashCommandService: IChatSlashCommandService, + @ICommandService private readonly commandService: ICommandService, @IPromptsService private readonly promptsService: IPromptsService, ) { super(); @@ -302,6 +318,9 @@ class NewChatWidget extends Disposable { this._renderExtensionPickers(true); } })); + + // Register slash commands + this._registerSlashCommands(); } // --- Rendering --- @@ -1089,12 +1108,99 @@ class NewChatWidget extends Disposable { // --- Slash commands --- - /** - * Get the slash commands available for the current target from the core - * `IChatSlashCommandService`, which holds all registered commands. - */ - private _getSlashCommands(): IChatSlashData[] { - return this.slashCommandService.getCommands(ChatAgentLocation.Chat, ChatModeKind.Agent); + private _registerSlashCommands(): void { + this._slashCommands.push({ + command: 'clear', + detail: localize('slashCommand.clear', "Start a new chat and archive the current one"), + sortText: 'z2_clear', + executeImmediately: true, + execute: () => this.sessionsManagementService.openNewSession(), + }); + this._slashCommands.push({ + command: 'help', + detail: localize('slashCommand.help', "Show available slash commands"), + sortText: 'z1_help', + executeImmediately: true, + execute: () => { + const helpLines = this._slashCommands.map(c => ` /${c.command} — ${c.detail}`); + this.logService.info(`Available slash commands:\n${helpLines.join('\n')}`); + }, + }); + this._slashCommands.push({ + command: 'fork', + detail: localize('slashCommand.fork', "Fork conversation into a new chat session"), + sortText: 'z2_fork', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.forkConversation', this._pendingSessionResource), + }); + this._slashCommands.push({ + command: 'rename', + detail: localize('slashCommand.rename', "Rename this chat"), + sortText: 'z2_rename', + executeImmediately: false, + execute: (args) => { + const title = args.trim(); + if (title && this._pendingSessionResource) { + this.commandService.executeCommand('workbench.action.chat.renameSession', this._pendingSessionResource, title); + } + }, + }); + this._slashCommands.push({ + command: 'agents', + detail: localize('slashCommand.agents', "Configure custom agents"), + sortText: 'z3_agents', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.openModePicker'), + }); + this._slashCommands.push({ + command: 'models', + detail: localize('slashCommand.models', "Open the model picker"), + sortText: 'z3_models', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.openModelPicker'), + }); + this._slashCommands.push({ + command: 'tools', + detail: localize('slashCommand.tools', "Configure tools"), + sortText: 'z3_tools', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.configureTools'), + }); + this._slashCommands.push({ + command: 'hooks', + detail: localize('slashCommand.hooks', "Configure hooks"), + sortText: 'z3_hooks', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.configure.hooks'), + }); + this._slashCommands.push({ + command: 'instructions', + detail: localize('slashCommand.instructions', "Configure instructions"), + sortText: 'z3_instructions', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.configure.instructions'), + }); + this._slashCommands.push({ + command: 'skills', + detail: localize('slashCommand.skills', "Configure skills"), + sortText: 'z3_skills', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.configure.skills'), + }); + this._slashCommands.push({ + command: 'prompts', + detail: localize('slashCommand.prompts', "Configure prompt files"), + sortText: 'z3_prompts', + executeImmediately: true, + execute: () => this.commandService.executeCommand('workbench.action.chat.configure.prompts'), + }); + this._slashCommands.push({ + command: 'debug', + detail: localize('slashCommand.debug', "Show Chat Debug View"), + sortText: 'z3_debug', + executeImmediately: true, + execute: () => this.commandService.executeCommand('github.copilot.debug.showChatLogView'), + }); } private static readonly _slashDecoType = 'sessions-slash-command'; @@ -1127,7 +1233,7 @@ class NewChatWidget extends Disposable { } const commandName = match[1]; - const slashCommand = this._getSlashCommands().find(c => c.command === commandName); + const slashCommand = this._slashCommands.find(c => c.command === commandName); if (!slashCommand) { this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashDecoType, []); this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, []); @@ -1176,20 +1282,12 @@ class NewChatWidget extends Disposable { } const commandName = match[1]; - if (!this.slashCommandService.hasCommand(commandName)) { + const slashCommand = this._slashCommands.find(c => c.command === commandName); + if (!slashCommand) { return false; } - // Execute via the core slash command service - this.slashCommandService.executeCommand( - commandName, - match[2]?.trim() ?? '', - { report: () => { } }, - [], - ChatAgentLocation.Chat, - this._pendingSessionResource ?? URI.parse('sessions-chat:empty'), - CancellationToken.None, - ); + slashCommand.execute(match[2]?.trim() ?? ''); return true; } @@ -1215,9 +1313,8 @@ class NewChatWidget extends Disposable { return null; } - const commands = this._getSlashCommands(); return { - suggestions: commands.map((c, i): CompletionItem => { + suggestions: this._slashCommands.map((c, i): CompletionItem => { const withSlash = `/${c.command}`; return { label: withSlash, From ef52d290204e4a76340d857e74c8354ccfbf24bb Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:45:26 -0800 Subject: [PATCH 025/541] Remove unused prompts service from NewChatWidget and simplify slash command registration --- .../contrib/chat/browser/newChatViewPane.ts | 40 +------------------ 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index d349a78aa3633..e3ef4930c3821 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -50,7 +50,6 @@ import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; -import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; @@ -268,7 +267,6 @@ class NewChatWidget extends Disposable { @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IThemeService private readonly themeService: IThemeService, @ICommandService private readonly commandService: ICommandService, - @IPromptsService private readonly promptsService: IPromptsService, ) { super(); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); @@ -1297,7 +1295,7 @@ class NewChatWidget extends Disposable { return; } - // Core slash commands (registered via IChatSlashCommandService) + // Built-in slash commands this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { _debugDisplayName: 'sessionsSlashCommands', triggerCharacters: ['/'], @@ -1328,42 +1326,6 @@ class NewChatWidget extends Disposable { }; } })); - - // Prompt slash commands (registered via IPromptsService) - this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { - _debugDisplayName: 'sessionsPromptSlashCommands', - triggerCharacters: ['/'], - provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => { - const range = this._computeCompletionRanges(model, position, /\/\w*/g); - if (!range) { - return null; - } - - const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); - if (textBefore.trim() !== '') { - return null; - } - - const promptCommands = await this.promptsService.getPromptSlashCommands(token); - if (!promptCommands || token.isCancellationRequested) { - return null; - } - - return { - suggestions: promptCommands.map((c, i): CompletionItem => { - const withSlash = `/${c.name}`; - return { - label: withSlash, - insertText: `${withSlash} `, - detail: c.description, - range, - sortText: c.name, - kind: CompletionItemKind.Text, - }; - }) - }; - } - })); } /** From d099f0663045d275e794e7504155c664f601bbb0 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:47:44 -0800 Subject: [PATCH 026/541] Fix slash command insert text formatting in NewChatWidget --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index e3ef4930c3821..a1ad6eff2d6c2 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -1316,7 +1316,7 @@ class NewChatWidget extends Disposable { const withSlash = `/${c.command}`; return { label: withSlash, - insertText: c.executeImmediately ? '' : `${withSlash} `, + insertText: `${withSlash} `, detail: c.detail, range, sortText: c.sortText ?? 'a'.repeat(i + 1), From a86ee74a852727a0ebaa948ce1d1261cd49afff2 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:54:58 -0800 Subject: [PATCH 027/541] Remove deprecated slash commands from NewChatWidget to streamline functionality --- .../contrib/chat/browser/newChatViewPane.ts | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index a1ad6eff2d6c2..f3cd087562ba5 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -1124,46 +1124,6 @@ class NewChatWidget extends Disposable { this.logService.info(`Available slash commands:\n${helpLines.join('\n')}`); }, }); - this._slashCommands.push({ - command: 'fork', - detail: localize('slashCommand.fork', "Fork conversation into a new chat session"), - sortText: 'z2_fork', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.forkConversation', this._pendingSessionResource), - }); - this._slashCommands.push({ - command: 'rename', - detail: localize('slashCommand.rename', "Rename this chat"), - sortText: 'z2_rename', - executeImmediately: false, - execute: (args) => { - const title = args.trim(); - if (title && this._pendingSessionResource) { - this.commandService.executeCommand('workbench.action.chat.renameSession', this._pendingSessionResource, title); - } - }, - }); - this._slashCommands.push({ - command: 'agents', - detail: localize('slashCommand.agents', "Configure custom agents"), - sortText: 'z3_agents', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.openModePicker'), - }); - this._slashCommands.push({ - command: 'models', - detail: localize('slashCommand.models', "Open the model picker"), - sortText: 'z3_models', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.openModelPicker'), - }); - this._slashCommands.push({ - command: 'tools', - detail: localize('slashCommand.tools', "Configure tools"), - sortText: 'z3_tools', - executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.configureTools'), - }); this._slashCommands.push({ command: 'hooks', detail: localize('slashCommand.hooks', "Configure hooks"), From 110228a0bf954b91b0eedfa3f858b1ee16c909ec Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 11:59:42 -0800 Subject: [PATCH 028/541] Disable icon display in suggestions for NewChatWidget to enhance user experience --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index f3cd087562ba5..d1b432916db2b 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -437,7 +437,7 @@ class NewChatWidget extends Disposable { renderWhitespace: 'none', overflowWidgetsDomNode, suggest: { - showIcons: true, + showIcons: false, showSnippets: false, showWords: true, showStatusBar: false, From badf88656ff2721181e1e96f938ad6da1c63f977 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 12:00:39 -0800 Subject: [PATCH 029/541] Remove debug slash command from NewChatWidget to streamline functionality --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index d1b432916db2b..248d675647c5a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -1152,13 +1152,6 @@ class NewChatWidget extends Disposable { executeImmediately: true, execute: () => this.commandService.executeCommand('workbench.action.chat.configure.prompts'), }); - this._slashCommands.push({ - command: 'debug', - detail: localize('slashCommand.debug', "Show Chat Debug View"), - sortText: 'z3_debug', - executeImmediately: true, - execute: () => this.commandService.executeCommand('github.copilot.debug.showChatLogView'), - }); } private static readonly _slashDecoType = 'sessions-slash-command'; From 7c3dc064e7285eb5b03fa4113a0c67586e76067e Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 12:04:05 -0800 Subject: [PATCH 030/541] Refactor slash command handling in NewChatWidget to utilize viewsService and update command details for better functionality --- .../contrib/chat/browser/newChatViewPane.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 248d675647c5a..d550cc8f2da98 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -36,7 +36,6 @@ import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/s import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -63,6 +62,7 @@ import { EnhancedModelPickerActionItem } from '../../../../workbench/contrib/cha import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; @@ -72,6 +72,7 @@ import { isString } from '../../../../base/common/types.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { FolderPicker } from './folderPicker.js'; +import { AI_CUSTOMIZATION_VIEW_ID } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeView.js'; /** * Minimal slash command descriptor for the sessions new-chat widget. @@ -266,7 +267,7 @@ class NewChatWidget extends Disposable { @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IThemeService private readonly themeService: IThemeService, - @ICommandService private readonly commandService: ICommandService, + @IViewsService private readonly viewsService: IViewsService, ) { super(); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); @@ -1107,6 +1108,8 @@ class NewChatWidget extends Disposable { // --- Slash commands --- private _registerSlashCommands(): void { + const openCustomizationView = () => this.viewsService.openView(AI_CUSTOMIZATION_VIEW_ID, true); + this._slashCommands.push({ command: 'clear', detail: localize('slashCommand.clear', "Start a new chat and archive the current one"), @@ -1125,32 +1128,39 @@ class NewChatWidget extends Disposable { }, }); this._slashCommands.push({ - command: 'hooks', - detail: localize('slashCommand.hooks', "Configure hooks"), - sortText: 'z3_hooks', + command: 'agents', + detail: localize('slashCommand.agents', "View and manage custom agents"), + sortText: 'z3_agents', executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.configure.hooks'), + execute: openCustomizationView, }); this._slashCommands.push({ - command: 'instructions', - detail: localize('slashCommand.instructions', "Configure instructions"), - sortText: 'z3_instructions', + command: 'skills', + detail: localize('slashCommand.skills', "View and manage skills"), + sortText: 'z3_skills', executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.configure.instructions'), + execute: openCustomizationView, }); this._slashCommands.push({ - command: 'skills', - detail: localize('slashCommand.skills', "Configure skills"), - sortText: 'z3_skills', + command: 'instructions', + detail: localize('slashCommand.instructions', "View and manage instructions"), + sortText: 'z3_instructions', executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.configure.skills'), + execute: openCustomizationView, }); this._slashCommands.push({ command: 'prompts', - detail: localize('slashCommand.prompts', "Configure prompt files"), + detail: localize('slashCommand.prompts', "View and manage prompt files"), sortText: 'z3_prompts', executeImmediately: true, - execute: () => this.commandService.executeCommand('workbench.action.chat.configure.prompts'), + execute: openCustomizationView, + }); + this._slashCommands.push({ + command: 'hooks', + detail: localize('slashCommand.hooks', "View and manage hooks"), + sortText: 'z3_hooks', + executeImmediately: true, + execute: openCustomizationView, }); } From ea5c4e72c2fb8cfb5312372612752b7f81d8e315 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 12:07:48 -0800 Subject: [PATCH 031/541] Refactor slash command execution in NewChatWidget to use ICommandService for improved command handling --- .../contrib/chat/browser/newChatViewPane.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index d550cc8f2da98..9ffdfecae75e6 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -36,6 +36,7 @@ import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/s import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -62,7 +63,6 @@ import { EnhancedModelPickerActionItem } from '../../../../workbench/contrib/cha import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; @@ -72,7 +72,7 @@ import { isString } from '../../../../base/common/types.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { FolderPicker } from './folderPicker.js'; -import { AI_CUSTOMIZATION_VIEW_ID } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeView.js'; +import { AICustomizationManagementCommands } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; /** * Minimal slash command descriptor for the sessions new-chat widget. @@ -267,7 +267,7 @@ class NewChatWidget extends Disposable { @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IThemeService private readonly themeService: IThemeService, - @IViewsService private readonly viewsService: IViewsService, + @ICommandService private readonly commandService: ICommandService, ) { super(); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); @@ -1108,7 +1108,7 @@ class NewChatWidget extends Disposable { // --- Slash commands --- private _registerSlashCommands(): void { - const openCustomizationView = () => this.viewsService.openView(AI_CUSTOMIZATION_VIEW_ID, true); + const openCustomizations = () => this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor); this._slashCommands.push({ command: 'clear', @@ -1132,35 +1132,49 @@ class NewChatWidget extends Disposable { detail: localize('slashCommand.agents', "View and manage custom agents"), sortText: 'z3_agents', executeImmediately: true, - execute: openCustomizationView, + execute: openCustomizations, }); this._slashCommands.push({ command: 'skills', detail: localize('slashCommand.skills', "View and manage skills"), sortText: 'z3_skills', executeImmediately: true, - execute: openCustomizationView, + execute: openCustomizations, }); this._slashCommands.push({ command: 'instructions', detail: localize('slashCommand.instructions', "View and manage instructions"), sortText: 'z3_instructions', executeImmediately: true, - execute: openCustomizationView, + execute: openCustomizations, }); this._slashCommands.push({ command: 'prompts', detail: localize('slashCommand.prompts', "View and manage prompt files"), sortText: 'z3_prompts', executeImmediately: true, - execute: openCustomizationView, + execute: openCustomizations, }); this._slashCommands.push({ command: 'hooks', detail: localize('slashCommand.hooks', "View and manage hooks"), sortText: 'z3_hooks', executeImmediately: true, - execute: openCustomizationView, + execute: openCustomizations, + }); + this._slashCommands.push({ + command: 'mcp', + detail: localize('slashCommand.mcp', "View and manage MCP servers"), + sortText: 'z3_mcp', + executeImmediately: true, + execute: openCustomizations, + }); + this._slashCommands.push({ + command: 'models', + detail: localize('slashCommand.models', "View and manage models"), + sortText: 'z3_models', + executeImmediately: true, + execute: openCustomizations, }); } From 6c446ea94e0f5a4c72ca3f28c5a24e022b86cca7 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:55:16 -0800 Subject: [PATCH 032/541] Support old settings too for now --- extensions/typescript-language-features/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index be3ece86babb9..952dbfe0ea537 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -2856,13 +2856,13 @@ "command": "typescript.experimental.enableTsgo", "title": "Use TypeScript Go (Experimental)", "category": "TypeScript", - "enablement": "!config.js/ts.experimental.useTsgo && config.typescript-go.executablePath" + "enablement": "!config.js/ts.experimental.useTsgo && !config.typescript.experimental.useTsgo && config.typescript-go.executablePath" }, { "command": "typescript.experimental.disableTsgo", "title": "Stop using TypeScript Go (Experimental)", "category": "TypeScript", - "enablement": "config.js/ts.experimental.useTsgo" + "enablement": "config.js/ts.experimental.useTsgo || config.typescript.experimental.useTsgo" } ], "menus": { @@ -2939,7 +2939,7 @@ "editor/context": [ { "command": "typescript.goToSourceDefinition", - "when": "!config.js/ts.experimental.useTsgo && tsSupportsSourceDefinition && (resourceLangId == typescript || resourceLangId == typescriptreact || resourceLangId == javascript || resourceLangId == javascriptreact)", + "when": "!config.js/ts.experimental.useTsgo && !config.typescript.experimental.useTsgo && tsSupportsSourceDefinition && (resourceLangId == typescript || resourceLangId == typescriptreact || resourceLangId == javascript || resourceLangId == javascriptreact)", "group": "navigation@1.41" } ], From f3dba005dbea2a01e591547b8f2d7389fe504b07 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:05:10 -0800 Subject: [PATCH 033/541] sessions welcome flow: fix stale session after sign in (#297481) sessions welcome: restart extension host after setup to fix model registration After the welcome overlay completes (extension installed + user signed in), restart the extension host so the copilot-chat extension picks up the new auth session cleanly. Without this, the extension activates before the auth provider is ready, gets stuck in GitHubLoginFailed, and models never appear. The overlay stays visible during the restart so the user doesn't see a broken intermediate state. Dismiss is controlled by the contribution via an autorun watching isComplete, not by the overlay itself. --- .../welcome/browser/sessionsWelcomeOverlay.ts | 9 +---- .../welcome/browser/welcome.contribution.ts | 37 +++++++++++++++++-- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/vs/sessions/contrib/welcome/browser/sessionsWelcomeOverlay.ts b/src/vs/sessions/contrib/welcome/browser/sessionsWelcomeOverlay.ts index 1eca99dd18359..b0249d755a0f7 100644 --- a/src/vs/sessions/contrib/welcome/browser/sessionsWelcomeOverlay.ts +++ b/src/vs/sessions/contrib/welcome/browser/sessionsWelcomeOverlay.ts @@ -57,12 +57,7 @@ export class SessionsWelcomeOverlay extends Disposable { this._register(autorun(reader => { const steps = this.welcomeService.steps.read(reader); const current = this.welcomeService.currentStep.read(reader); - const isComplete = this.welcomeService.isComplete.read(reader); - - if (isComplete) { - this.dismiss(); - return; - } + this.welcomeService.isComplete.read(reader); // Render step indicators this.renderStepList(stepList, steps, current); @@ -135,7 +130,7 @@ export class SessionsWelcomeOverlay extends Disposable { } } - private dismiss(): void { + dismiss(): void { this.overlay.classList.add('sessions-welcome-overlay-dismissed'); this._onDidDismiss.fire(); // Allow CSS transition to finish before disposing diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index fbc9149ceeb86..7c934df0e2ac3 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -13,9 +13,11 @@ import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/b import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { autorun } from '../../../../base/common/observable.js'; +import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; -import { localize2 } from '../../../../nls.js'; import { ISessionsWelcomeService } from '../common/sessionsWelcomeService.js'; import { SessionsWelcomeService } from './sessionsWelcomeService.js'; import { SessionsWelcomeOverlay } from './sessionsWelcomeOverlay.js'; @@ -42,6 +44,8 @@ class SessionsWelcomeContribution extends Disposable implements IWorkbenchContri @IProductService private readonly productService: IProductService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, + @IExtensionService private readonly extensionService: IExtensionService, + @ILogService private readonly logService: ILogService, ) { super(); @@ -127,12 +131,39 @@ class SessionsWelcomeContribution extends Disposable implements IWorkbenchContri this.layoutService.mainContainer, )); - // Mark welcome as complete once the overlay is dismissed (all steps satisfied) + // When all steps are satisfied, restart the extension host (so the + // chat extension picks up the auth session cleanly) then dismiss. this.overlayRef.value.add(overlay.onDidDismiss(() => { this.overlayRef.clear(); - this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); this.watchForSignOutOrTokenExpiry(); })); + + this.overlayRef.value.add(autorun(reader => { + const isComplete = this.welcomeService.isComplete.read(reader); + if (!isComplete) { + return; + } + + this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + this.restartExtensionHostThenDismiss(overlay); + })); + } + + /** + * After the welcome flow completes (extension installed + user signed in), + * restart the extension host so the chat extension picks up the new auth + * session cleanly, then dismiss the overlay. The overlay stays visible + * during the restart so the user doesn't see a broken intermediate state. + */ + private async restartExtensionHostThenDismiss(overlay: SessionsWelcomeOverlay): Promise { + this.logService.info('[sessions welcome] Restarting extension host after welcome completion'); + const stopped = await this.extensionService.stopExtensionHosts( + localize('sessionsWelcome.restart', "Completing sessions setup") + ); + if (stopped) { + await this.extensionService.startExtensionHosts(); + } + overlay.dismiss(); } } From ef748e3095e3808c6f00a798da376a878e6d74cb Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:29:21 -0800 Subject: [PATCH 034/541] Add definition support for nls strings in package.json Fixes #297496 --- .../src/extensionEditingBrowserMain.ts | 4 +- .../src/extensionEditingMain.ts | 4 + .../src/packageDocumentL10nSupport.ts | 77 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 extensions/extension-editing/src/packageDocumentL10nSupport.ts diff --git a/extensions/extension-editing/src/extensionEditingBrowserMain.ts b/extensions/extension-editing/src/extensionEditingBrowserMain.ts index f9d6885c6223c..57c969d017020 100644 --- a/extensions/extension-editing/src/extensionEditingBrowserMain.ts +++ b/extensions/extension-editing/src/extensionEditingBrowserMain.ts @@ -5,11 +5,14 @@ import * as vscode from 'vscode'; import { PackageDocument } from './packageDocumentHelper'; +import { PackageDocumentL10nSupport } from './packageDocumentL10nSupport'; export function activate(context: vscode.ExtensionContext) { //package.json suggestions context.subscriptions.push(registerPackageDocumentCompletions()); + //package.json go to definition for NLS strings + context.subscriptions.push(new PackageDocumentL10nSupport()); } function registerPackageDocumentCompletions(): vscode.Disposable { @@ -18,5 +21,4 @@ function registerPackageDocumentCompletions(): vscode.Disposable { return new PackageDocument(document).provideCompletionItems(position, token); } }); - } diff --git a/extensions/extension-editing/src/extensionEditingMain.ts b/extensions/extension-editing/src/extensionEditingMain.ts index c056fbfa975ae..c620b3039541f 100644 --- a/extensions/extension-editing/src/extensionEditingMain.ts +++ b/extensions/extension-editing/src/extensionEditingMain.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { PackageDocument } from './packageDocumentHelper'; +import { PackageDocumentL10nSupport } from './packageDocumentL10nSupport'; import { ExtensionLinter } from './extensionLinter'; export function activate(context: vscode.ExtensionContext) { @@ -15,6 +16,9 @@ export function activate(context: vscode.ExtensionContext) { //package.json code actions for lint warnings context.subscriptions.push(registerCodeActionsProvider()); + // package.json l10n support + context.subscriptions.push(new PackageDocumentL10nSupport()); + context.subscriptions.push(new ExtensionLinter()); } diff --git a/extensions/extension-editing/src/packageDocumentL10nSupport.ts b/extensions/extension-editing/src/packageDocumentL10nSupport.ts new file mode 100644 index 0000000000000..2ec2b876a07fb --- /dev/null +++ b/extensions/extension-editing/src/packageDocumentL10nSupport.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getLocation, getNodeValue, parseTree, findNodeAtLocation } from 'jsonc-parser'; + + +export class PackageDocumentL10nSupport implements vscode.DefinitionProvider, vscode.Disposable { + + private readonly _registration: vscode.Disposable; + + constructor() { + this._registration = vscode.languages.registerDefinitionProvider( + { language: 'json', pattern: '**/package.json' }, + this, + ); + } + + dispose(): void { + this._registration.dispose(); + } + + public async provideDefinition(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken): Promise { + const nlsRef = this.getNlsReferenceAtPosition(document, position); + if (!nlsRef) { + return undefined; + } + + const nlsUri = vscode.Uri.joinPath(document.uri, '..', 'package.nls.json'); + + try { + const nlsDoc = await vscode.workspace.openTextDocument(nlsUri); + const nlsTree = parseTree(nlsDoc.getText()); + if (!nlsTree) { + return undefined; + } + + const node = findNodeAtLocation(nlsTree, [nlsRef.key]); + if (!node) { + return undefined; + } + + const targetStart = nlsDoc.positionAt(node.offset); + const targetEnd = nlsDoc.positionAt(node.offset + node.length); + return [{ + originSelectionRange: nlsRef.range, + targetUri: nlsUri, + targetRange: new vscode.Range(targetStart, targetEnd), + }]; + } catch { + return undefined; + } + } + + private getNlsReferenceAtPosition(document: vscode.TextDocument, position: vscode.Position): { key: string; range: vscode.Range } | undefined { + const location = getLocation(document.getText(), document.offsetAt(position)); + if (!location.previousNode || location.previousNode.type !== 'string') { + return undefined; + } + + const value = getNodeValue(location.previousNode); + if (typeof value !== 'string') { + return undefined; + } + + const match = value.match(/^%(.+)%$/); + if (!match) { + return undefined; + } + + const nodeStart = document.positionAt(location.previousNode.offset); + const nodeEnd = document.positionAt(location.previousNode.offset + location.previousNode.length); + return { key: match[1], range: new vscode.Range(nodeStart, nodeEnd) }; + } +} From ad8360665ecf5437f2eb26dce3ca808612769eed Mon Sep 17 00:00:00 2001 From: Harald Kirschner Date: Tue, 24 Feb 2026 13:41:29 -0800 Subject: [PATCH 035/541] Enable subagent custom agent setting by default (#297499) --- src/vs/workbench/contrib/chat/browser/chat.contribution.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 1f0c66b25c4be..c9f4f1d02f5c5 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1227,8 +1227,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.SubagentToolCustomAgents]: { type: 'boolean', description: nls.localize('chat.subagentTool.customAgents', "Whether the runSubagent tool is able to use custom agents. When enabled, the tool can take the name of a custom agent, but it must be given the exact name of the agent."), - default: false, - tags: ['experimental'], + default: true, experiment: { mode: 'auto' } From 889e2e7df66a5b9166d5092a1f6f34993009b7cc Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 14:10:01 -0800 Subject: [PATCH 036/541] Refactor slash command execution in NewChatWidget to open specific sections in AICustomizationManagementEditor --- .../aiCustomizationManagement.contribution.ts | 8 ++++++-- .../contrib/chat/browser/newChatViewPane.ts | 19 ++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts index 9450cd347d37b..f1430ed2633c4 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts @@ -23,6 +23,7 @@ import { AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, AICustomizationManagementCommands, AICustomizationManagementItemMenuId, + AICustomizationManagementSection, } from './aiCustomizationManagement.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -276,10 +277,13 @@ class AICustomizationManagementActionsContribution extends Disposable implements }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, section?: AICustomizationManagementSection): Promise { const editorGroupsService = accessor.get(IEditorGroupsService); const input = AICustomizationManagementEditorInput.getOrCreate(); - await editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + const pane = await editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + if (section && pane && 'selectSectionById' in pane) { + (pane as { selectSectionById(s: AICustomizationManagementSection): void }).selectSectionById(section); + } } })); } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 9ffdfecae75e6..17081e5835b4b 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -72,7 +72,7 @@ import { isString } from '../../../../base/common/types.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { FolderPicker } from './folderPicker.js'; -import { AICustomizationManagementCommands } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; /** * Minimal slash command descriptor for the sessions new-chat widget. @@ -1108,7 +1108,8 @@ class NewChatWidget extends Disposable { // --- Slash commands --- private _registerSlashCommands(): void { - const openCustomizations = () => this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor); + const openSection = (section: AICustomizationManagementSection) => + () => this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, section); this._slashCommands.push({ command: 'clear', @@ -1132,49 +1133,49 @@ class NewChatWidget extends Disposable { detail: localize('slashCommand.agents', "View and manage custom agents"), sortText: 'z3_agents', executeImmediately: true, - execute: openCustomizations, + execute: openSection(AICustomizationManagementSection.Agents), }); this._slashCommands.push({ command: 'skills', detail: localize('slashCommand.skills', "View and manage skills"), sortText: 'z3_skills', executeImmediately: true, - execute: openCustomizations, + execute: openSection(AICustomizationManagementSection.Skills), }); this._slashCommands.push({ command: 'instructions', detail: localize('slashCommand.instructions', "View and manage instructions"), sortText: 'z3_instructions', executeImmediately: true, - execute: openCustomizations, + execute: openSection(AICustomizationManagementSection.Instructions), }); this._slashCommands.push({ command: 'prompts', detail: localize('slashCommand.prompts', "View and manage prompt files"), sortText: 'z3_prompts', executeImmediately: true, - execute: openCustomizations, + execute: openSection(AICustomizationManagementSection.Prompts), }); this._slashCommands.push({ command: 'hooks', detail: localize('slashCommand.hooks', "View and manage hooks"), sortText: 'z3_hooks', executeImmediately: true, - execute: openCustomizations, + execute: openSection(AICustomizationManagementSection.Hooks), }); this._slashCommands.push({ command: 'mcp', detail: localize('slashCommand.mcp', "View and manage MCP servers"), sortText: 'z3_mcp', executeImmediately: true, - execute: openCustomizations, + execute: openSection(AICustomizationManagementSection.McpServers), }); this._slashCommands.push({ command: 'models', detail: localize('slashCommand.models', "View and manage models"), sortText: 'z3_models', executeImmediately: true, - execute: openCustomizations, + execute: openSection(AICustomizationManagementSection.Models), }); } From 9071918c0387ecad47bec31ccd21702dab2664be Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 14:12:54 -0800 Subject: [PATCH 037/541] Remove unused slash commands from NewChatWidget to streamline functionality --- .../contrib/chat/browser/newChatViewPane.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 17081e5835b4b..8a4f7d4d3fcbe 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -1111,23 +1111,6 @@ class NewChatWidget extends Disposable { const openSection = (section: AICustomizationManagementSection) => () => this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, section); - this._slashCommands.push({ - command: 'clear', - detail: localize('slashCommand.clear', "Start a new chat and archive the current one"), - sortText: 'z2_clear', - executeImmediately: true, - execute: () => this.sessionsManagementService.openNewSession(), - }); - this._slashCommands.push({ - command: 'help', - detail: localize('slashCommand.help', "Show available slash commands"), - sortText: 'z1_help', - executeImmediately: true, - execute: () => { - const helpLines = this._slashCommands.map(c => ` /${c.command} — ${c.detail}`); - this.logService.info(`Available slash commands:\n${helpLines.join('\n')}`); - }, - }); this._slashCommands.push({ command: 'agents', detail: localize('slashCommand.agents', "View and manage custom agents"), From f6fcc6d90eff9ba115d7df0fa3d194b79249645e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Tue, 24 Feb 2026 23:17:09 +0100 Subject: [PATCH 038/541] Inform if user is internal during update requests (#297464) fixes #297453 --- .../platform/assignment/common/assignment.ts | 7 +++ src/vs/platform/update/common/update.ts | 5 ++- src/vs/platform/update/common/updateIpc.ts | 6 +-- .../electron-main/abstractUpdateService.ts | 21 ++++++--- .../electron-main/updateService.darwin.ts | 7 +-- .../electron-main/updateService.linux.ts | 5 ++- .../electron-main/updateService.snap.ts | 2 +- .../electron-main/updateService.win32.ts | 5 ++- .../contrib/update/browser/update.ts | 43 +++++++++++++------ .../assignment/common/assignmentFilters.ts | 6 +-- .../services/update/browser/updateService.ts | 2 +- 11 files changed, 73 insertions(+), 36 deletions(-) diff --git a/src/vs/platform/assignment/common/assignment.ts b/src/vs/platform/assignment/common/assignment.ts index 293eaee739ac1..584bef3b1fdf5 100644 --- a/src/vs/platform/assignment/common/assignment.ts +++ b/src/vs/platform/assignment/common/assignment.ts @@ -177,3 +177,10 @@ export class AssignmentFilterProvider implements IExperimentationFilterProvider return filters; } } + +export function getInternalOrg(organisations: string[] | undefined): 'vscode' | 'github' | 'microsoft' | undefined { + const isVSCodeInternal = organisations?.includes('Visual-Studio-Code'); + const isGitHubInternal = organisations?.includes('github'); + const isMicrosoftInternal = organisations?.includes('microsoft') || organisations?.includes('ms-copilot') || organisations?.includes('MicrosoftCopilot'); + return isVSCodeInternal ? 'vscode' : isGitHubInternal ? 'github' : isMicrosoftInternal ? 'microsoft' : undefined; +} diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 7f30494da4a37..cbeb3a6088856 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -111,7 +111,10 @@ export interface IUpdateService { applyUpdate(): Promise; quitAndInstall(): Promise; + /** + * @deprecated This method should not be used any more. It will be removed in a future release. + */ isLatestVersion(): Promise; _applySpecificUpdate(packagePath: string): Promise; - disableProgressiveReleases(): Promise; + setInternalOrg(internalOrg: string | undefined): Promise; } diff --git a/src/vs/platform/update/common/updateIpc.ts b/src/vs/platform/update/common/updateIpc.ts index 9eaf8210757e2..6b165c49d2146 100644 --- a/src/vs/platform/update/common/updateIpc.ts +++ b/src/vs/platform/update/common/updateIpc.ts @@ -29,7 +29,7 @@ export class UpdateChannel implements IServerChannel { case '_getInitialState': return Promise.resolve(this.service.state); case 'isLatestVersion': return this.service.isLatestVersion(); case '_applySpecificUpdate': return this.service._applySpecificUpdate(arg); - case 'disableProgressiveReleases': return this.service.disableProgressiveReleases(); + case 'setInternalOrg': return this.service.setInternalOrg(arg); } throw new Error(`Call not found: ${command}`); @@ -80,8 +80,8 @@ export class UpdateChannelClient implements IUpdateService { return this.channel.call('_applySpecificUpdate', packagePath); } - disableProgressiveReleases(): Promise { - return this.channel.call('disableProgressiveReleases'); + setInternalOrg(internalOrg: string | undefined): Promise { + return this.channel.call('setInternalOrg', internalOrg); } dispose(): void { diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 698d277ca288b..5470a3553baba 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -20,6 +20,7 @@ import { AvailableForDownload, DisablementReason, IUpdateService, State, StateTy export interface IUpdateURLOptions { readonly background?: boolean; + readonly internalOrg?: string; } export function createUpdateURL(baseUpdateUrl: string, platform: string, quality: string, commit: string, options?: IUpdateURLOptions): string { @@ -29,6 +30,10 @@ export function createUpdateURL(baseUpdateUrl: string, platform: string, quality url.searchParams.set('bg', 'true'); } + if (options?.internalOrg) { + url.searchParams.set('org', options.internalOrg); + } + return url.toString(); } @@ -77,7 +82,7 @@ export abstract class AbstractUpdateService implements IUpdateService { protected _overwrite: boolean = false; private _hasCheckedForOverwriteOnQuit: boolean = false; private readonly overwriteUpdatesCheckInterval = new IntervalTimer(); - private _disableProgressiveReleases: boolean = false; + private _internalOrg: string | undefined = undefined; private readonly _onStateChange = new Emitter(); readonly onStateChange: Event = this._onStateChange.event; @@ -342,13 +347,17 @@ export abstract class AbstractUpdateService implements IUpdateService { // noop } - async disableProgressiveReleases(): Promise { - this.logService.info('update#disableProgressiveReleases'); - this._disableProgressiveReleases = true; + async setInternalOrg(internalOrg: string | undefined): Promise { + if (this._internalOrg === internalOrg) { + return; + } + + this.logService.info('update#setInternalOrg', internalOrg); + this._internalOrg = internalOrg; } - protected shouldDisableProgressiveReleases(): boolean { - return this._disableProgressiveReleases; + protected getInternalOrg(): string | undefined { + return this._internalOrg; } protected getUpdateType(): UpdateType { diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index a0c89233f3d4b..23de97f6d2395 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -127,8 +127,9 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau this.setState(State.CheckingForUpdates(explicit)); - const background = !explicit && !this.shouldDisableProgressiveReleases(); - const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background }); + const internalOrg = this.getInternalOrg(); + const background = !explicit && !internalOrg; + const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background, internalOrg }); if (!url) { return; @@ -205,7 +206,7 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau protected override async doDownloadUpdate(state: AvailableForDownload): Promise { // Rebuild feed URL and trigger download via Electron's auto-updater - this.buildUpdateFeedUrl(this.quality!, state.update.version); + this.buildUpdateFeedUrl(this.quality!, state.update.version, { internalOrg: this.getInternalOrg() }); this.setState(State.CheckingForUpdates(true)); electron.autoUpdater.checkForUpdates(); } diff --git a/src/vs/platform/update/electron-main/updateService.linux.ts b/src/vs/platform/update/electron-main/updateService.linux.ts index ee4b291a87ad3..3ace29fed5aa9 100644 --- a/src/vs/platform/update/electron-main/updateService.linux.ts +++ b/src/vs/platform/update/electron-main/updateService.linux.ts @@ -39,8 +39,9 @@ export class LinuxUpdateService extends AbstractUpdateService { return; } - const background = !explicit && !this.shouldDisableProgressiveReleases(); - const url = this.buildUpdateFeedUrl(this.quality, this.productService.commit!, { background }); + const internalOrg = this.getInternalOrg(); + const background = !explicit && !internalOrg; + const url = this.buildUpdateFeedUrl(this.quality, this.productService.commit!, { background, internalOrg }); this.setState(State.CheckingForUpdates(explicit)); this.requestService.request({ url }, CancellationToken.None) diff --git a/src/vs/platform/update/electron-main/updateService.snap.ts b/src/vs/platform/update/electron-main/updateService.snap.ts index b09111d023506..a68a25e577be2 100644 --- a/src/vs/platform/update/electron-main/updateService.snap.ts +++ b/src/vs/platform/update/electron-main/updateService.snap.ts @@ -133,7 +133,7 @@ abstract class AbstractUpdateService implements IUpdateService { // noop } - async disableProgressiveReleases(): Promise { + async setInternalOrg(_internalOrg: string | undefined): Promise { // noop - not applicable for snap } diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index c4b6083f99a69..257edf1af198a 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -188,8 +188,9 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return; } - const background = !explicit && !this.shouldDisableProgressiveReleases(); - const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background }); + const internalOrg = this.getInternalOrg(); + const background = !explicit && !internalOrg; + const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background, internalOrg }); // Only set CheckingForUpdates if we're not already in Overwriting state if (this.state.type !== StateType.Overwriting) { diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index a9a3ee1932ec6..68b982cf45282 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -26,11 +26,12 @@ import { IHostService } from '../../../services/host/browser/host.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IUserDataSyncEnablementService, IUserDataSyncService, IUserDataSyncStoreManagementService, SyncStatus, UserDataSyncStoreType } from '../../../../platform/userDataSync/common/userDataSync.js'; import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys.js'; -import { Promises } from '../../../../base/common/async.js'; +import { Promises, Throttler } from '../../../../base/common/async.js'; import { IUserDataSyncWorkbenchService } from '../../../services/userDataSync/common/userDataSync.js'; import { Event } from '../../../../base/common/event.js'; import { toAction } from '../../../../base/common/actions.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { getInternalOrg } from '../../../../platform/assignment/common/assignment.js'; export const CONTEXT_UPDATE_STATE = new RawContextKey('updateState', StateType.Uninitialized); export const MAJOR_MINOR_UPDATE_AVAILABLE = new RawContextKey('majorMinorUpdateAvailable', false); @@ -675,9 +676,14 @@ export class SwitchProductQualityContribution extends Disposable implements IWor export class DefaultAccountUpdateContribution extends Disposable implements IWorkbenchContribution { + private static readonly STORAGE_KEY = 'update/internalOrg'; + #internalOrg: string | undefined = undefined; + private throttler: Throttler = this._register(new Throttler()); + constructor( @IUpdateService private readonly updateService: IUpdateService, - @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IStorageService private readonly storageService: IStorageService ) { super(); @@ -685,25 +691,36 @@ export class DefaultAccountUpdateContribution extends Disposable implements IWor return; // Electron only } + this.#internalOrg = this.storageService.get(DefaultAccountUpdateContribution.STORAGE_KEY, StorageScope.APPLICATION, undefined); + this.throttler.queue(() => this.updateService.setInternalOrg(this.#internalOrg)); + // Check on startup - this.checkDefaultAccount(); + this.refresh(); // Listen for account changes - this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => { - this.checkDefaultAccount(); - })); + this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.refresh())); + } + + private refresh(): void { + this.throttler.queue(() => this.doRefresh()); } - private async checkDefaultAccount(): Promise { + private async doRefresh(): Promise { try { const defaultAccount = await this.defaultAccountService.getDefaultAccount(); - const shouldDisable = defaultAccount?.entitlementsData?.organization_login_list?.some( - org => org.toLowerCase() === 'visual-studio-code' - ) ?? false; + const internalOrg = getInternalOrg(defaultAccount?.entitlementsData?.organization_login_list); + + if (internalOrg === this.#internalOrg) { + return; + } + + this.#internalOrg = internalOrg; + await this.updateService.setInternalOrg(this.#internalOrg); - if (shouldDisable) { - await this.updateService.disableProgressiveReleases(); - this.dispose(); + if (this.#internalOrg) { + this.storageService.store(DefaultAccountUpdateContribution.STORAGE_KEY, internalOrg, StorageScope.APPLICATION, StorageTarget.MACHINE); + } else { + this.storageService.remove(DefaultAccountUpdateContribution.STORAGE_KEY, StorageScope.APPLICATION); } } catch (error) { // Silently ignore errors - if we can't get the account, we don't disable background updates diff --git a/src/vs/workbench/services/assignment/common/assignmentFilters.ts b/src/vs/workbench/services/assignment/common/assignmentFilters.ts index 024c915e1eebd..e8f1a70f616d8 100644 --- a/src/vs/workbench/services/assignment/common/assignmentFilters.ts +++ b/src/vs/workbench/services/assignment/common/assignmentFilters.ts @@ -11,6 +11,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { Emitter } from '../../../../base/common/event.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IChatEntitlementService } from '../../chat/common/chatEntitlementService.js'; +import { getInternalOrg } from '../../../../platform/assignment/common/assignment.js'; export enum ExtensionsFilter { @@ -135,10 +136,7 @@ export class CopilotAssignmentFilterProvider extends Disposable implements IExpe private updateCopilotEntitlementInfo() { const newSku = this._chatEntitlementService.sku; const newTrackingId = this._chatEntitlementService.copilotTrackingId; - const newIsGitHubInternal = this._chatEntitlementService.organisations?.includes('github'); - const newIsMicrosoftInternal = this._chatEntitlementService.organisations?.includes('microsoft') || this._chatEntitlementService.organisations?.includes('ms-copilot') || this._chatEntitlementService.organisations?.includes('MicrosoftCopilot'); - const newIsVSCodeInternal = this._chatEntitlementService.organisations?.includes('Visual-Studio-Code'); - const newInternalOrg = newIsVSCodeInternal ? 'vscode' : newIsGitHubInternal ? 'github' : newIsMicrosoftInternal ? 'microsoft' : undefined; + const newInternalOrg = getInternalOrg(this._chatEntitlementService.organisations); if (this.copilotSku === newSku && this.copilotInternalOrg === newInternalOrg && this.copilotTrackingId === newTrackingId) { return; diff --git a/src/vs/workbench/services/update/browser/updateService.ts b/src/vs/workbench/services/update/browser/updateService.ts index 611eef4f9463c..d9cb9499f8801 100644 --- a/src/vs/workbench/services/update/browser/updateService.ts +++ b/src/vs/workbench/services/update/browser/updateService.ts @@ -98,7 +98,7 @@ export class BrowserUpdateService extends Disposable implements IUpdateService { // noop } - async disableProgressiveReleases(): Promise { + async setInternalOrg(_internalOrg: string | undefined): Promise { // noop - not applicable in browser } } From 88f25bc452b870c61efe79798c53c47c4c01e682 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:20:38 -0800 Subject: [PATCH 039/541] Add reference support too --- .../src/packageDocumentL10nSupport.ts | 191 +++++++++++++++--- 1 file changed, 159 insertions(+), 32 deletions(-) diff --git a/extensions/extension-editing/src/packageDocumentL10nSupport.ts b/extensions/extension-editing/src/packageDocumentL10nSupport.ts index 2ec2b876a07fb..4d844e98d5f71 100644 --- a/extensions/extension-editing/src/packageDocumentL10nSupport.ts +++ b/extensions/extension-editing/src/packageDocumentL10nSupport.ts @@ -4,32 +4,140 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { getLocation, getNodeValue, parseTree, findNodeAtLocation } from 'jsonc-parser'; +import { getLocation, getNodeValue, parseTree, findNodeAtLocation, visit } from 'jsonc-parser'; -export class PackageDocumentL10nSupport implements vscode.DefinitionProvider, vscode.Disposable { +const packageJsonSelector: vscode.DocumentSelector = { language: 'json', pattern: '**/package.json' }; +const packageNlsJsonSelector: vscode.DocumentSelector = { language: 'json', pattern: '**/package.nls.json' }; - private readonly _registration: vscode.Disposable; +export class PackageDocumentL10nSupport implements vscode.DefinitionProvider, vscode.ReferenceProvider, vscode.Disposable { + + private readonly _disposables: vscode.Disposable[] = []; constructor() { - this._registration = vscode.languages.registerDefinitionProvider( - { language: 'json', pattern: '**/package.json' }, - this, - ); + this._disposables.push(vscode.languages.registerDefinitionProvider(packageJsonSelector, this)); + this._disposables.push(vscode.languages.registerDefinitionProvider(packageNlsJsonSelector, this)); + + this._disposables.push(vscode.languages.registerReferenceProvider(packageNlsJsonSelector, this)); + this._disposables.push(vscode.languages.registerReferenceProvider(packageJsonSelector, this)); } dispose(): void { - this._registration.dispose(); + for (const d of this._disposables) { + d.dispose(); + } } public async provideDefinition(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken): Promise { - const nlsRef = this.getNlsReferenceAtPosition(document, position); + const basename = document.uri.path.split('/').pop()?.toLowerCase(); + if (basename === 'package.json') { + return this.provideNlsValueDefinition(document, position); + } + + if (basename === 'package.nls.json') { + return this.provideNlsKeyDefinition(document, position); + } + + return undefined; + } + + private async provideNlsValueDefinition(packageJsonDoc: vscode.TextDocument, position: vscode.Position): Promise { + const nlsRef = this.getNlsReferenceAtPosition(packageJsonDoc, position); + if (!nlsRef) { + return undefined; + } + + const nlsUri = vscode.Uri.joinPath(packageJsonDoc.uri, '..', 'package.nls.json'); + return this.resolveNlsDefinition(nlsRef, nlsUri); + } + + private async provideNlsKeyDefinition(nlsDoc: vscode.TextDocument, position: vscode.Position): Promise { + const nlsKey = this.getNlsKeyDefinitionAtPosition(nlsDoc, position); + if (!nlsKey) { + return undefined; + } + return this.resolveNlsDefinition(nlsKey, nlsDoc.uri); + } + + private async resolveNlsDefinition(origin: { key: string; range: vscode.Range }, nlsUri: vscode.Uri): Promise { + const target = await this.findNlsKeyDeclaration(origin.key, nlsUri); + if (!target) { + return undefined; + } + + return [{ + originSelectionRange: origin.range, + targetUri: target.uri, + targetRange: target.range, + }]; + } + + private getNlsReferenceAtPosition(packageJsonDoc: vscode.TextDocument, position: vscode.Position): { key: string; range: vscode.Range } | undefined { + const location = getLocation(packageJsonDoc.getText(), packageJsonDoc.offsetAt(position)); + if (!location.previousNode || location.previousNode.type !== 'string') { + return undefined; + } + + const value = getNodeValue(location.previousNode); + if (typeof value !== 'string') { + return undefined; + } + + const match = value.match(/^%(.+)%$/); + if (!match) { + return undefined; + } + + const nodeStart = packageJsonDoc.positionAt(location.previousNode.offset); + const nodeEnd = packageJsonDoc.positionAt(location.previousNode.offset + location.previousNode.length); + return { key: match[1], range: new vscode.Range(nodeStart, nodeEnd) }; + } + + public async provideReferences(document: vscode.TextDocument, position: vscode.Position, context: vscode.ReferenceContext, _token: vscode.CancellationToken): Promise { + const basename = document.uri.path.split('/').pop()?.toLowerCase(); + if (basename === 'package.nls.json') { + return this.provideNlsKeyReferences(document, position, context); + } + if (basename === 'package.json') { + return this.provideNlsValueReferences(document, position, context); + } + return undefined; + } + + private async provideNlsKeyReferences(nlsDoc: vscode.TextDocument, position: vscode.Position, context: vscode.ReferenceContext): Promise { + const nlsKey = this.getNlsKeyDefinitionAtPosition(nlsDoc, position); + if (!nlsKey) { + return undefined; + } + + const packageJsonUri = vscode.Uri.joinPath(nlsDoc.uri, '..', 'package.json'); + return this.findAllNlsReferences(nlsKey.key, packageJsonUri, nlsDoc.uri, context); + } + + private async provideNlsValueReferences(packageJsonDoc: vscode.TextDocument, position: vscode.Position, context: vscode.ReferenceContext): Promise { + const nlsRef = this.getNlsReferenceAtPosition(packageJsonDoc, position); if (!nlsRef) { return undefined; } - const nlsUri = vscode.Uri.joinPath(document.uri, '..', 'package.nls.json'); + const nlsUri = vscode.Uri.joinPath(packageJsonDoc.uri, '..', 'package.nls.json'); + return this.findAllNlsReferences(nlsRef.key, packageJsonDoc.uri, nlsUri, context); + } + + private async findAllNlsReferences(nlsKey: string, packageJsonUri: vscode.Uri, nlsUri: vscode.Uri, context: vscode.ReferenceContext): Promise { + const locations = await this.findNlsReferencesInPackageJson(nlsKey, packageJsonUri); + + if (context.includeDeclaration) { + const decl = await this.findNlsKeyDeclaration(nlsKey, nlsUri); + if (decl) { + locations.push(decl); + } + } + + return locations; + } + private async findNlsKeyDeclaration(nlsKey: string, nlsUri: vscode.Uri): Promise { try { const nlsDoc = await vscode.workspace.openTextDocument(nlsUri); const nlsTree = parseTree(nlsDoc.getText()); @@ -37,41 +145,60 @@ export class PackageDocumentL10nSupport implements vscode.DefinitionProvider, vs return undefined; } - const node = findNodeAtLocation(nlsTree, [nlsRef.key]); - if (!node) { + const node = findNodeAtLocation(nlsTree, [nlsKey]); + if (!node?.parent) { return undefined; } - const targetStart = nlsDoc.positionAt(node.offset); - const targetEnd = nlsDoc.positionAt(node.offset + node.length); - return [{ - originSelectionRange: nlsRef.range, - targetUri: nlsUri, - targetRange: new vscode.Range(targetStart, targetEnd), - }]; + const keyNode = node.parent.children?.[0]; + if (!keyNode) { + return undefined; + } + + const start = nlsDoc.positionAt(keyNode.offset); + const end = nlsDoc.positionAt(keyNode.offset + keyNode.length); + return new vscode.Location(nlsUri, new vscode.Range(start, end)); } catch { return undefined; } } - private getNlsReferenceAtPosition(document: vscode.TextDocument, position: vscode.Position): { key: string; range: vscode.Range } | undefined { - const location = getLocation(document.getText(), document.offsetAt(position)); - if (!location.previousNode || location.previousNode.type !== 'string') { - return undefined; + private async findNlsReferencesInPackageJson(nlsKey: string, packageJsonUri: vscode.Uri): Promise { + let packageJsonDoc: vscode.TextDocument; + try { + packageJsonDoc = await vscode.workspace.openTextDocument(packageJsonUri); + } catch { + return []; } - const value = getNodeValue(location.previousNode); - if (typeof value !== 'string') { - return undefined; - } + const text = packageJsonDoc.getText(); + const needle = `%${nlsKey}%`; + const locations: vscode.Location[] = []; - const match = value.match(/^%(.+)%$/); - if (!match) { + visit(text, { + onLiteralValue(value, offset, length) { + if (value === needle) { + const start = packageJsonDoc.positionAt(offset); + const end = packageJsonDoc.positionAt(offset + length); + locations.push(new vscode.Location(packageJsonUri, new vscode.Range(start, end))); + } + } + }); + + return locations; + } + + private getNlsKeyDefinitionAtPosition(nlsDoc: vscode.TextDocument, position: vscode.Position): { key: string; range: vscode.Range } | undefined { + const location = getLocation(nlsDoc.getText(), nlsDoc.offsetAt(position)); + + // Must be on a top-level property key + if (location.path.length !== 1 || !location.isAtPropertyKey || !location.previousNode) { return undefined; } - const nodeStart = document.positionAt(location.previousNode.offset); - const nodeEnd = document.positionAt(location.previousNode.offset + location.previousNode.length); - return { key: match[1], range: new vscode.Range(nodeStart, nodeEnd) }; + const key = location.path[0] as string; + const start = nlsDoc.positionAt(location.previousNode.offset); + const end = nlsDoc.positionAt(location.previousNode.offset + location.previousNode.length); + return { key, range: new vscode.Range(start, end) }; } } From 8979069f1d24de06fe43767bbdd04bc22069f406 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:23:04 -0800 Subject: [PATCH 040/541] Align js/ts extension name Fixes #297310 Matches the new setting scope as well as the terms we often use in product --- extensions/typescript-language-features/package.nls.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 7cd88f9bf609b..97b1c12e8abda 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -1,5 +1,5 @@ { - "displayName": "TypeScript and JavaScript Language Features", + "displayName": "JavaScript and TypeScript Language Features", "description": "Provides rich language support for JavaScript and TypeScript.", "workspaceTrust": "The extension requires workspace trust when the workspace version is used because it executes code specified by the workspace.", "virtualWorkspaces": "In virtual workspaces, resolving and finding references across files is not supported.", From 6220f98a590d05e4738fac945b632a17d68db09c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 24 Feb 2026 23:36:21 +0100 Subject: [PATCH 041/541] support remove element on action list item (#297512) --- .../actionWidget/browser/actionList.ts | 57 ++++++++++++++++--- .../contrib/chat/browser/folderPicker.ts | 17 ++---- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 6d2cfda35684d..4330686d2e3de 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -10,7 +10,7 @@ import { getAnchorRect, IAnchor } from '../../../base/browser/ui/contextview/con import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js'; -import { IAction } from '../../../base/common/actions.js'; +import { IAction, toAction } from '../../../base/common/actions.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Codicon } from '../../../base/common/codicons.js'; import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; @@ -96,6 +96,11 @@ export interface IActionListItem { * When true, this item is always shown when filtering produces no other results. */ readonly showAlways?: boolean; + /** + * Optional callback invoked when the item is removed via the built-in remove button. + * When set, a close button is automatically added to the item toolbar. + */ + readonly onRemove?: () => void; } interface IActionMenuTemplateData { @@ -176,6 +181,7 @@ class ActionItemRenderer implements IListRenderer, IAction constructor( private readonly _supportsPreview: boolean, + private readonly _onRemoveItem: ((item: IActionListItem) => void) | undefined, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -297,11 +303,23 @@ class ActionItemRenderer implements IListRenderer, IAction // Clear and render toolbar actions dom.clearNode(data.toolbar); - data.container.classList.toggle('has-toolbar', !!element.toolbarActions?.length); - if (element.toolbarActions?.length) { + const toolbarActions = [...(element.toolbarActions ?? [])]; + if (element.onRemove) { + toolbarActions.push(toAction({ + id: 'actionList.remove', + label: localize('actionList.remove', "Remove"), + class: ThemeIcon.asClassName(Codicon.close), + run: () => { + element.onRemove!(); + this._onRemoveItem?.(element); + }, + })); + } + data.container.classList.toggle('has-toolbar', toolbarActions.length > 0); + if (toolbarActions.length > 0) { const actionBar = new ActionBar(data.toolbar); data.elementDisposables.add(actionBar); - actionBar.push(element.toolbarActions, { icon: true, label: false }); + actionBar.push(toolbarActions, { icon: true, label: false }); } } @@ -369,7 +387,7 @@ export class ActionList extends Disposable { private readonly _headerLineHeight = 24; private readonly _separatorLineHeight = 8; - private readonly _allMenuItems: readonly IActionListItem[]; + private _allMenuItems: IActionListItem[]; private readonly cts = this._register(new CancellationTokenSource()); @@ -437,7 +455,7 @@ export class ActionList extends Disposable { this._list = this._register(new List(user, this.domNode, virtualDelegate, [ - new ActionItemRenderer>(preview, this._keybindingService, this._openerService), + new ActionItemRenderer(preview, (item) => this._removeItem(item), this._keybindingService, this._openerService), new HeaderRenderer(), new SeparatorRenderer(), ], { @@ -482,7 +500,7 @@ export class ActionList extends Disposable { this._register(this._list.onDidChangeFocus(() => this.onFocus())); this._register(this._list.onDidChangeSelection(e => this.onListSelection(e))); - this._allMenuItems = items; + this._allMenuItems = [...items]; // Create filter input if (this._options?.showFilter) { @@ -815,7 +833,7 @@ export class ActionList extends Disposable { element.style.width = 'auto'; const width = element.getBoundingClientRect().width; element.style.width = ''; - itemWidths.push(width); + itemWidths.push(width + this._computeToolbarWidth(allItems[i])); } } @@ -834,7 +852,7 @@ export class ActionList extends Disposable { element.style.width = 'auto'; const width = element.getBoundingClientRect().width; element.style.width = ''; - itemWidths.push(width); + itemWidths.push(width + this._computeToolbarWidth(this._list.element(i))); } } return Math.max(...itemWidths, effectiveMinWidth); @@ -1017,6 +1035,27 @@ export class ActionList extends Disposable { } } + private _removeItem(item: IActionListItem): void { + const index = this._allMenuItems.indexOf(item); + if (index >= 0) { + this._allMenuItems.splice(index, 1); + this._applyFilter(); + } + } + + private _computeToolbarWidth(item: IActionListItem): number { + let actionCount = item.toolbarActions?.length ?? 0; + if (item.onRemove) { + actionCount++; + } + if (actionCount === 0) { + return 0; + } + // Each toolbar action button is ~22px (16px icon + padding) plus 6px row gap + const actionButtonWidth = 22; + return actionCount * actionButtonWidth + 6; + } + private _getRowElement(index: number): HTMLElement | null { // eslint-disable-next-line no-restricted-syntax return this.domNode.ownerDocument.getElementById(this._list.getElementID(index)); diff --git a/src/vs/sessions/contrib/chat/browser/folderPicker.ts b/src/vs/sessions/contrib/chat/browser/folderPicker.ts index 1080d93df7dce..e79013e57f80a 100644 --- a/src/vs/sessions/contrib/chat/browser/folderPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/folderPicker.ts @@ -18,8 +18,6 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { INewSession } from './newSession.js'; -import { toAction } from '../../../../base/common/actions.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; const STORAGE_KEY_LAST_FOLDER = 'agentSessions.lastPickedFolder'; const STORAGE_KEY_RECENT_FOLDERS = 'agentSessions.recentlyPickedFolders'; @@ -152,6 +150,8 @@ export class FolderPicker extends Disposable { onHide: () => { triggerElement.focus(); }, }; + const listOptions = showFilter ? { showFilter: true, filterPlaceholder: localize('folderPicker.filter', "Filter folders...") } : undefined; + this.actionWidgetService.show( 'folderPicker', false, @@ -164,7 +164,7 @@ export class FolderPicker extends Disposable { getAriaLabel: (item) => item.label ?? '', getWidgetAriaLabel: () => localize('folderPicker.ariaLabel', "Folder Picker"), }, - showFilter ? { showFilter: true, filterPlaceholder: localize('folderPicker.filter', "Filter folders...") } : undefined, + listOptions, ); } @@ -248,12 +248,7 @@ export class FolderPicker extends Disposable { label, group: { title: '', icon: Codicon.blank }, item: { uri: folder.uri, label }, - toolbarActions: [toAction({ - id: 'folderPicker.remove', - label: localize('folderPicker.remove', "Remove"), - class: ThemeIcon.asClassName(Codicon.close), - run: () => this._removeFolder(folder.uri), - })], + onRemove: () => this._removeFolder(folder.uri), }); } @@ -284,10 +279,6 @@ export class FolderPicker extends Disposable { // Remove from globally recently opened this.workspacesService.removeRecentlyOpened([folderUri]); - - // Re-show the picker with updated items - this.actionWidgetService.hide(); - this.showPicker(); } private _isCopilotWorktree(uri: URI): boolean { From 9acfea7745f9f8122c60597b10b62e255c8e1f69 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 25 Feb 2026 00:45:30 +0100 Subject: [PATCH 042/541] remove recent folders from workspaces in folder picker (#297520) --- .../contrib/chat/browser/folderPicker.ts | 40 +++---------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/folderPicker.ts b/src/vs/sessions/contrib/chat/browser/folderPicker.ts index e79013e57f80a..12a0ec3a381aa 100644 --- a/src/vs/sessions/contrib/chat/browser/folderPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/folderPicker.ts @@ -15,7 +15,6 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../ import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { INewSession } from './newSession.js'; @@ -42,7 +41,6 @@ export class FolderPicker extends Disposable { private _selectedFolderUri: URI | undefined; private _recentlyPickedFolders: URI[] = []; - private _cachedRecentFolders: { uri: URI; label?: string }[] = []; private _newSession: INewSession | undefined; private _triggerElement: HTMLElement | undefined; @@ -64,7 +62,6 @@ export class FolderPicker extends Disposable { @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @IStorageService private readonly storageService: IStorageService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IWorkspacesService private readonly workspacesService: IWorkspacesService, @IFileDialogService private readonly fileDialogService: IFileDialogService, ) { super(); @@ -82,15 +79,6 @@ export class FolderPicker extends Disposable { this._recentlyPickedFolders = JSON.parse(stored).map((s: string) => URI.parse(s)); } } catch { /* ignore */ } - - // Pre-fetch recently opened folders, filtering out copilot worktrees - this.workspacesService.getRecentlyOpened().then(recent => { - this._cachedRecentFolders = recent.workspaces - .filter(isRecentFolder) - .filter(r => !this._isCopilotWorktree(r.folderUri)) - .slice(0, MAX_RECENT_FOLDERS) - .map(r => ({ uri: r.folderUri, label: r.label })); - }).catch(() => { /* ignore */ }); } /** @@ -231,24 +219,20 @@ export class FolderPicker extends Disposable { }); } - // Combine recently picked folders and recently opened folders - const allFolders: { uri: URI; label?: string }[] = [ - ...this._recentlyPickedFolders.map(uri => ({ uri })), - ...this._cachedRecentFolders, - ]; - for (const folder of allFolders) { - const key = folder.uri.toString(); + // Recently picked folders + for (const folderUri of this._recentlyPickedFolders) { + const key = folderUri.toString(); if (seenUris.has(key)) { continue; } seenUris.add(key); - const label = folder.label || basename(folder.uri); + const label = basename(folderUri); items.push({ kind: ActionListItemKind.Action, label, group: { title: '', icon: Codicon.blank }, - item: { uri: folder.uri, label }, - onRemove: () => this._removeFolder(folder.uri), + item: { uri: folderUri, label }, + onRemove: () => this._removeFolder(folderUri), }); } @@ -270,20 +254,8 @@ export class FolderPicker extends Disposable { } private _removeFolder(folderUri: URI): void { - // Remove from recently picked folders this._recentlyPickedFolders = this._recentlyPickedFolders.filter(f => !isEqual(f, folderUri)); this.storageService.store(STORAGE_KEY_RECENT_FOLDERS, JSON.stringify(this._recentlyPickedFolders.map(f => f.toString())), StorageScope.PROFILE, StorageTarget.MACHINE); - - // Remove from cached recent folders - this._cachedRecentFolders = this._cachedRecentFolders.filter(f => !isEqual(f.uri, folderUri)); - - // Remove from globally recently opened - this.workspacesService.removeRecentlyOpened([folderUri]); - } - - private _isCopilotWorktree(uri: URI): boolean { - const name = basename(uri); - return name.startsWith('copilot-worktree-'); } private _updateTriggerLabel(trigger: HTMLElement | undefined): void { From f2196b0f1dc4b3f2b134a64f178b7ae7c018508e Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 16:02:20 -0800 Subject: [PATCH 043/541] Refactor slash command decoration registration in NewChatWidget for improved static handling --- .../sessions/contrib/chat/browser/newChatViewPane.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 8a4f7d4d3fcbe..b1eb45232e365 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -1164,17 +1164,17 @@ class NewChatWidget extends Disposable { private static readonly _slashDecoType = 'sessions-slash-command'; private static readonly _slashPlaceholderDecoType = 'sessions-slash-placeholder'; - private _slashDecosRegistered = false; + private static _slashDecosRegistered = false; private _registerSlashCommandDecorations(): void { - if (!this._slashDecosRegistered) { - this._slashDecosRegistered = true; - this._register(this.codeEditorService.registerDecorationType('sessions-chat', NewChatWidget._slashDecoType, { + if (!NewChatWidget._slashDecosRegistered) { + NewChatWidget._slashDecosRegistered = true; + this.codeEditorService.registerDecorationType('sessions-chat', NewChatWidget._slashDecoType, { color: themeColorFromId(chatSlashCommandForeground), backgroundColor: themeColorFromId(chatSlashCommandBackground), borderRadius: '3px', - })); - this._register(this.codeEditorService.registerDecorationType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, {})); + }); + this.codeEditorService.registerDecorationType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, {}); } this._register(this._editor.onDidChangeModelContent(() => this._updateSlashCommandDecorations())); From 32cbaeba992510bf6734a0bc9273f06e2d0db2b3 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 16:03:10 -0800 Subject: [PATCH 044/541] Refactor slash command decoration range calculation in NewChatWidget for improved accuracy --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index b1eb45232e365..a1955eb7014a7 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -1182,7 +1182,8 @@ class NewChatWidget extends Disposable { } private _updateSlashCommandDecorations(): void { - const value = this._editor.getValue(); + const model = this._editor.getModel(); + const value = model?.getValue() ?? ''; const match = value.match(/^\/(\w+)\s?/); if (!match) { @@ -1211,7 +1212,7 @@ class NewChatWidget extends Disposable { if (!restOfInput && slashCommand.detail) { const placeholderCol = match[0].length + 1; const placeholderDeco: IDecorationOptions[] = [{ - range: { startLineNumber: 1, startColumn: placeholderCol, endLineNumber: 1, endColumn: 1000 }, + range: { startLineNumber: 1, startColumn: placeholderCol, endLineNumber: 1, endColumn: model!.getLineMaxColumn(1) }, renderOptions: { after: { contentText: slashCommand.detail, From 368485b61f1bfc003e54161a0e881c14de611139 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 16:06:06 -0800 Subject: [PATCH 045/541] Refactor section selection handling in AICustomizationManagementActionsContribution for improved type safety --- .../browser/aiCustomizationManagement.contribution.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts index f1430ed2633c4..d7588d9e9394b 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts @@ -281,8 +281,8 @@ class AICustomizationManagementActionsContribution extends Disposable implements const editorGroupsService = accessor.get(IEditorGroupsService); const input = AICustomizationManagementEditorInput.getOrCreate(); const pane = await editorGroupsService.activeGroup.openEditor(input, { pinned: true }); - if (section && pane && 'selectSectionById' in pane) { - (pane as { selectSectionById(s: AICustomizationManagementSection): void }).selectSectionById(section); + if (section && pane && typeof (pane as Record).selectSectionById === 'function') { + (pane as unknown as { selectSectionById(s: AICustomizationManagementSection): void }).selectSectionById(section); } } })); From 296965cb64e5b6e5e4ba47063f5cae7804b158ef Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 16:07:01 -0800 Subject: [PATCH 046/541] Fix type assertion for selectSectionById method in AICustomizationManagementActionsContribution --- .../browser/aiCustomizationManagement.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts index d7588d9e9394b..60ecfb651e3a8 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts @@ -281,7 +281,7 @@ class AICustomizationManagementActionsContribution extends Disposable implements const editorGroupsService = accessor.get(IEditorGroupsService); const input = AICustomizationManagementEditorInput.getOrCreate(); const pane = await editorGroupsService.activeGroup.openEditor(input, { pinned: true }); - if (section && pane && typeof (pane as Record).selectSectionById === 'function') { + if (section && pane && typeof (pane as unknown as Record).selectSectionById === 'function') { (pane as unknown as { selectSectionById(s: AICustomizationManagementSection): void }).selectSectionById(section); } } From 1e55f598c0f5bd04fde2449f6a245228c46c75e4 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 16:28:05 -0800 Subject: [PATCH 047/541] Auto-open folder/repo picker when sending without selection When the user tries to send a message without having selected a folder (Local/Background) or repository (Cloud), automatically open the relevant picker instead of silently doing nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/newChatViewPane.ts | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 7fbfe0a318729..402de6f50311b 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -769,7 +769,15 @@ class NewChatWidget extends Disposable { private _send(): void { const query = this._editor.getModel()?.getValue().trim(); const session = this._newSession.value; - if (!query || !session || session.disabled || this._sending) { + if (!query || !session || this._sending) { + return; + } + + // If the session is disabled due to missing folder/repo, open the picker + if (session.disabled) { + if (!this._hasRequiredRepoOrFolderSelection(session.target)) { + this._openRepoOrFolderPicker(session.target); + } return; } @@ -800,6 +808,64 @@ class NewChatWidget extends Disposable { }); } + /** + * Checks whether the required folder/repo selection exists for the given session type. + * For Local/Background targets, checks the folder picker. + * For other targets, checks extension-contributed repo/folder option groups. + */ + private _hasRequiredRepoOrFolderSelection(sessionType: AgentSessionProviders): boolean { + if (sessionType === AgentSessionProviders.Local || sessionType === AgentSessionProviders.Background) { + return !!this._folderPicker.selectedFolderUri; + } + + const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(sessionType); + if (!optionGroups) { + return true; + } + for (const group of optionGroups) { + if (!isRepoOrFolderGroup(group)) { + continue; + } + const selected = this._selectedOptions.get(group.id); + if (selected) { + return true; + } + const defaultItem = this._getDefaultOptionForGroup(group); + if (defaultItem) { + return true; + } + } + // No repo/folder groups exist — nothing required + return !optionGroups.some(g => isRepoOrFolderGroup(g)); + } + + /** + * Opens the appropriate folder/repo picker for the given session type. + * For Local/Background targets, opens the folder picker. + * For other targets, opens the first visible repo/folder extension picker widget. + */ + private _openRepoOrFolderPicker(sessionType: AgentSessionProviders): void { + if (sessionType === AgentSessionProviders.Local || sessionType === AgentSessionProviders.Background) { + this._folderPicker.showPicker(); + return; + } + + const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(sessionType); + if (!optionGroups) { + return; + } + for (const group of optionGroups) { + if (!isRepoOrFolderGroup(group)) { + continue; + } + const widget = this._pickerWidgets.get(group.id); + if (widget) { + widget.show(); + return; + } + } + } + // --- Layout --- layout(_height: number, _width: number): void { From 47e419b0d33712138ae10ce9f1c57af4a53a2f6f Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 16:42:16 -0800 Subject: [PATCH 048/541] Update dependencies in Cargo.lock and refactor import statements in newChatViewPane.ts for improved clarity --- cli/Cargo.lock | 69 ++++++++++++++++++- .../contrib/chat/browser/newChatViewPane.ts | 3 +- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index cd9b8de6afba6..afe353213b1b5 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -447,6 +447,7 @@ dependencies = [ "uuid", "winapi", "winreg 0.50.0", + "winresource", "zbus", "zip", ] @@ -2645,6 +2646,15 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3004,12 +3014,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -3017,10 +3051,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.13.0", - "toml_datetime", - "winnow", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow 0.7.14", ] +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "tower-service" version = "0.3.3" @@ -3699,6 +3748,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + [[package]] name = "winreg" version = "0.8.0" @@ -3718,6 +3773,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winresource" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e287ced0f21cd11f4035fe946fd3af145f068d1acb708afd248100f89ec7432d" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 7c6144d79a96f..5c8579ba5aaab 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -13,7 +13,7 @@ import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; @@ -28,7 +28,6 @@ import { IDecorationOptions } from '../../../../editor/common/editorCommon.js'; import { Position } from '../../../../editor/common/core/position.js'; import { Range } from '../../../../editor/common/core/range.js'; import { getWordAtText } from '../../../../editor/common/core/wordHelper.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { themeColorFromId } from '../../../../base/common/themables.js'; import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; From f900fee390dac58ae8f782d7a9dbd00b13c4ec67 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 24 Feb 2026 16:43:33 -0800 Subject: [PATCH 049/541] Rebrand as agent debug panel (#297541) --- ...on.ts => chatOpenAgentDebugPanelAction.ts} | 44 +++++++++---------- .../chat/browser/actions/chatPluginActions.ts | 1 + .../contrib/chat/browser/chat.contribution.ts | 4 +- .../chat/browser/chatDebug/chatDebugEditor.ts | 2 +- .../browser/chatDebug/chatDebugEditorInput.ts | 2 +- .../browser/chatDebug/chatDebugFilters.ts | 2 +- .../chatDebug/chatDebugFlowChartView.ts | 2 +- .../browser/chatDebug/chatDebugHomeView.ts | 2 +- .../browser/chatDebug/chatDebugLogsView.ts | 2 +- .../chatDebug/chatDebugOverviewView.ts | 2 +- 10 files changed, 32 insertions(+), 31 deletions(-) rename src/vs/workbench/contrib/chat/browser/actions/{chatOpenDebugPanelAction.ts => chatOpenAgentDebugPanelAction.ts} (66%) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts similarity index 66% rename from src/vs/workbench/contrib/chat/browser/actions/chatOpenDebugPanelAction.ts rename to src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index 17860ac6b1f3b..8d2316272d3cb 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -3,9 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { URI } from '../../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -16,16 +18,16 @@ import { ChatDebugEditorInput } from '../chatDebug/chatDebugEditorInput.js'; import { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; /** - * Registers the Open Debug Panel and View Logs actions. + * Registers the Open Agent Debug Panel and Show Agent Logs actions. */ -export function registerChatOpenDebugPanelAction() { - registerAction2(class OpenDebugViewAction extends Action2 { +export function registerChatOpenAgentDebugPanelAction() { + registerAction2(class OpenAgentDebugPanelAction extends Action2 { constructor() { super({ - id: 'workbench.action.chat.openDebugView', - title: localize2('chat.openDebugView.label', "Open Debug Panel"), + id: 'workbench.action.chat.openAgentDebugPanel', + title: localize2('chat.openAgentDebugPanel.label', "Open Agent Debug Panel"), f1: true, - category: CHAT_CATEGORY, + category: Categories.Developer, precondition: ChatContextKeys.enabled, }); } @@ -42,40 +44,38 @@ export function registerChatOpenDebugPanelAction() { } }); - registerAction2(class TroubleshootAction extends Action2 { + registerAction2(class OpenAgentDebugPanelForSessionAction extends Action2 { constructor() { super({ - id: 'workbench.action.chat.troubleshoot', - title: localize2('chat.troubleshoot.label', "View Logs"), + id: 'workbench.action.chat.openAgentDebugPanelForSession', + title: localize2('chat.openAgentDebugPanelForSession.label', "Show Agent Logs"), f1: false, category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.chatSessionHasDebugData), menu: [{ - id: MenuId.ChatContext, - group: 'z_clear', - order: -1, - when: ChatContextKeys.chatSessionHasDebugData - }, { id: CHAT_CONFIG_MENU_ID, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ChatContextKeys.chatSessionHasDebugData), - order: 14, - group: '3_configure' + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), + order: 0, + group: '4_logs' }, { id: MenuId.ChatWelcomeContext, group: '2_settings', order: 0, - when: ContextKeyExpr.and(ChatContextKeys.inChatEditor.negate(), ChatContextKeys.chatSessionHasDebugData) + when: ChatContextKeys.inChatEditor.negate() }] }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, sessionResource?: URI): Promise { const editorService = accessor.get(IEditorService); const chatWidgetService = accessor.get(IChatWidgetService); const chatDebugService = accessor.get(IChatDebugService); - // Get the active chat session resource from the last focused widget - const widget = chatWidgetService.lastFocusedWidget; - const sessionResource = widget?.viewModel?.sessionResource; + // Use provided session resource, or fall back to the last focused widget + if (!sessionResource) { + const widget = chatWidgetService.lastFocusedWidget; + sessionResource = widget?.viewModel?.sessionResource; + } chatDebugService.activeSessionResource = sessionResource; const options: IChatDebugEditorOptions = { pinned: true, sessionResource, viewHint: 'logs' }; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts index 8dc3610a23e31..d310d80e80cef 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts @@ -57,6 +57,7 @@ class ManagePluginsAction extends Action2 { precondition: ChatContextKeys.enabled, menu: [{ id: CHAT_CONFIG_MENU_ID, + group: '2_plugins', }], f1: true }); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index c9f4f1d02f5c5..3cc0fd6d4e05a 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -89,7 +89,7 @@ import { registerChatTitleActions } from './actions/chatTitleActions.js'; import { registerChatElicitationActions } from './actions/chatElicitationActions.js'; import { registerChatToolActions } from './actions/chatToolActions.js'; import { ChatTransferContribution } from './actions/chatTransfer.js'; -import { registerChatOpenDebugPanelAction } from './actions/chatOpenDebugPanelAction.js'; +import { registerChatOpenAgentDebugPanelAction } from './actions/chatOpenAgentDebugPanelAction.js'; import { IChatDebugService } from '../common/chatDebugService.js'; import { ChatDebugServiceImpl } from '../common/chatDebugServiceImpl.js'; import { ChatDebugEditor } from './chatDebug/chatDebugEditor.js'; @@ -1620,7 +1620,7 @@ registerWorkbenchContribution2(AgentPluginsViewsContribution.ID, AgentPluginsVie registerChatActions(); registerChatAccessibilityActions(); registerChatCopyActions(); -registerChatOpenDebugPanelAction(); +registerChatOpenAgentDebugPanelAction(); registerChatCodeBlockActions(); registerChatCodeCompareBlockActions(); registerChatFileTreeActions(); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts index 772e96585a257..59b90cb9d04c2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts @@ -30,7 +30,7 @@ const $ = DOM.$; type ChatDebugPanelOpenedClassification = { owner: 'vijayu'; - comment: 'Event fired when the chat debug panel is opened'; + comment: 'Event fired when the agent debug panel is opened'; }; type ChatDebugViewSwitchedEvent = { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditorInput.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditorInput.ts index fb7ab5d0020b2..6b160d58d282f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditorInput.ts @@ -41,7 +41,7 @@ export class ChatDebugEditorInput extends EditorInput { readonly resource = ChatDebugEditorInput.RESOURCE; override getName(): string { - return localize('chatDebugInputName', "Chat Debug Panel"); + return localize('chatDebugInputName', "Agent Debug Panel"); } override getIcon(): ThemeIcon { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts index 50ce745c94fe7..4a9c73eb3d602 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts @@ -21,7 +21,7 @@ import { } from './chatDebugTypes.js'; /** - * Shared filter state for the Chat Debug Panel. + * Shared filter state for the Agent Debug Panel. * * Both the Logs view and the Flow Chart view read from this single source of * truth. Toggle commands modify the state and fire `onDidChange` so every diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts index a61f134a9a8f3..d923f5e4a4135 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts @@ -192,7 +192,7 @@ export class ChatDebugFlowChartView extends Disposable { } const sessionTitle = this.chatService.getSessionTitle(this.currentSessionResource) || LocalChatSessionUri.parseLocalSessionId(this.currentSessionResource) || this.currentSessionResource.toString(); this.breadcrumbWidget.setItems([ - new TextBreadcrumbItem(localize('chatDebug.title', "Chat Debug Panel"), true), + new TextBreadcrumbItem(localize('chatDebug.title', "Agent Debug Panel"), true), new TextBreadcrumbItem(sessionTitle, true), new TextBreadcrumbItem(localize('chatDebug.flowChart', "Agent Flow Chart")), ]); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts index 802ca0183afcc..b109f9feb1686 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts @@ -55,7 +55,7 @@ export class ChatDebugHomeView extends Disposable { DOM.clearNode(this.scrollContent); this.renderDisposables.clear(); - DOM.append(this.scrollContent, $('h2.chat-debug-home-title', undefined, localize('chatDebug.title', "Chat Debug Panel"))); + DOM.append(this.scrollContent, $('h2.chat-debug-home-title', undefined, localize('chatDebug.title', "Agent Debug Panel"))); // Determine the active session resource const activeWidget = this.chatWidgetService.lastFocusedWidget; diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index 5a260445faefa..2e6a725b8e6e4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -265,7 +265,7 @@ export class ChatDebugLogsView extends Disposable { } const sessionTitle = this.chatService.getSessionTitle(this.currentSessionResource) || LocalChatSessionUri.parseLocalSessionId(this.currentSessionResource) || this.currentSessionResource.toString(); this.breadcrumbWidget.setItems([ - new TextBreadcrumbItem(localize('chatDebug.title', "Chat Debug Panel"), true), + new TextBreadcrumbItem(localize('chatDebug.title', "Agent Debug Panel"), true), new TextBreadcrumbItem(sessionTitle, true), new TextBreadcrumbItem(localize('chatDebug.logs', "Logs")), ]); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts index 87a59577dc1ec..d64ed954f2612 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts @@ -113,7 +113,7 @@ export class ChatDebugOverviewView extends Disposable { } const sessionTitle = this.chatService.getSessionTitle(this.currentSessionResource) || LocalChatSessionUri.parseLocalSessionId(this.currentSessionResource) || this.currentSessionResource.toString(); this.breadcrumbWidget.setItems([ - new TextBreadcrumbItem(localize('chatDebug.title', "Chat Debug Panel"), true), + new TextBreadcrumbItem(localize('chatDebug.title', "Agent Debug Panel"), true), new TextBreadcrumbItem(sessionTitle), ]); } From b2c29c0263f8ee4ee55aed4d8655c3ab3681148e Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 24 Feb 2026 16:57:48 -0800 Subject: [PATCH 050/541] chat: support installing plugins from private marketplaces (#297525) * chat: support installing plugins from private marketplaces Adds support for installing plugins from private git repositories, git URIs, and file paths. Introduces a new agentPluginRepositoryService to handle cloning and managing custom marketplace repositories. - Extracts shared repository logic into a dedicated common service - Adds support for fallback to private repo cloning when public marketplace lookup fails - Allows \https://\ and SCP-style git URIs for marketplace references - Allows \ ile:///\ URIs for local marketplace directories - Adds comprehensive unit tests for new marketplace functionality Fixes https://github.com/microsoft/vscode/issues/297524 (Commit message generated by Copilot) * comments and ci * bump --- .../browser/agentPluginRepositoryService.ts | 215 ++++++++++ .../contrib/chat/browser/agentPluginsView.ts | 6 +- .../contrib/chat/browser/chat.contribution.ts | 5 +- .../chat/browser/pluginInstallService.ts | 119 +----- .../plugins/agentPluginRepositoryService.ts | 64 +++ .../plugins/pluginMarketplaceService.ts | 386 +++++++++++++++++- .../agentPluginRepositoryService.test.ts | 173 ++++++++ .../plugins/pluginMarketplaceService.test.ts | 104 +++++ 8 files changed, 960 insertions(+), 112 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts create mode 100644 src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts new file mode 100644 index 0000000000000..7b2244345e865 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action } from '../../../../base/common/actions.js'; +import { Lazy } from '../../../../base/common/lazy.js'; +import { revive } from '../../../../base/common/marshalling.js'; +import { dirname, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; +import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; +import { IMarketplacePlugin, IMarketplaceReference, MarketplaceReferenceKind, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js'; + +const MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1'; + +interface IMarketplaceIndexEntry { + repositoryUri: URI; + marketplaceType?: MarketplaceType; +} + +type IStoredMarketplaceIndex = Dto>; + +export class AgentPluginRepositoryService implements IAgentPluginRepositoryService { + declare readonly _serviceBrand: undefined; + + private readonly _cacheRoot: URI; + private readonly _marketplaceIndex = new Lazy>(() => this._loadMarketplaceIndex()); + + constructor( + @ICommandService private readonly _commandService: ICommandService, + @IEnvironmentService environmentService: IEnvironmentService, + @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService, + @INotificationService private readonly _notificationService: INotificationService, + @IProgressService private readonly _progressService: IProgressService, + @IStorageService private readonly _storageService: IStorageService, + ) { + this._cacheRoot = joinPath(environmentService.cacheHome, 'agentPlugins'); + } + + getRepositoryUri(marketplace: IMarketplaceReference, marketplaceType?: MarketplaceType): URI { + if (marketplace.kind === MarketplaceReferenceKind.LocalFileUri && marketplace.localRepositoryUri) { + return marketplace.localRepositoryUri; + } + + const indexed = this._marketplaceIndex.value.get(marketplace.canonicalId); + if (indexed?.repositoryUri) { + return indexed.repositoryUri; + } + + return this._getRepoCacheDirForReference(marketplace); + } + + getPluginInstallUri(plugin: IMarketplacePlugin): URI { + const repoDir = this.getRepositoryUri(plugin.marketplaceReference, plugin.marketplaceType); + return this._getPluginDir(repoDir, plugin.source); + } + + async ensureRepository(marketplace: IMarketplaceReference, options?: IEnsureRepositoryOptions): Promise { + const repoDir = this.getRepositoryUri(marketplace, options?.marketplaceType); + const repoExists = await this._fileService.exists(repoDir); + if (repoExists) { + this._updateMarketplaceIndex(marketplace, repoDir, options?.marketplaceType); + return repoDir; + } + + if (marketplace.kind === MarketplaceReferenceKind.LocalFileUri) { + throw new Error(`Local marketplace repository does not exist: ${repoDir.fsPath}`); + } + + const progressTitle = options?.progressTitle ?? localize('preparingMarketplace', "Preparing plugin marketplace '{0}'...", marketplace.displayLabel); + const failureLabel = options?.failureLabel ?? marketplace.displayLabel; + await this._cloneRepository(repoDir, marketplace.cloneUrl, progressTitle, failureLabel); + this._updateMarketplaceIndex(marketplace, repoDir, options?.marketplaceType); + return repoDir; + } + + async pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise { + const repoDir = this.getRepositoryUri(marketplace, options?.marketplaceType); + const repoExists = await this._fileService.exists(repoDir); + if (!repoExists) { + this._logService.warn(`[AgentPluginRepositoryService] Cannot update plugin '${options?.pluginName ?? marketplace.displayLabel}': repository not cloned`); + return; + } + + const updateLabel = options?.pluginName ?? marketplace.displayLabel; + + try { + await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: localize('updatingPlugin', "Updating plugin '{0}'...", updateLabel), + cancellable: false, + }, + async () => { + await this._commandService.executeCommand('_git.pull', repoDir.fsPath); + } + ); + } catch (err) { + this._logService.error(`[AgentPluginRepositoryService] Failed to update ${marketplace.displayLabel}:`, err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('pullFailed', "Failed to update plugin '{0}': {1}", options?.failureLabel ?? updateLabel, err?.message ?? String(err)), + actions: { + primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { + this._commandService.executeCommand('git.showOutput'); + })], + }, + }); + } + } + + private _getRepoCacheDirForReference(reference: IMarketplaceReference): URI { + return joinPath(this._cacheRoot, ...reference.cacheSegments); + } + + private _loadMarketplaceIndex(): Map { + const result = new Map(); + const stored = this._storageService.getObject(MARKETPLACE_INDEX_STORAGE_KEY, StorageScope.APPLICATION); + if (!stored) { + return result; + } + + const revived = revive(stored); + for (const [canonicalId, entry] of Object.entries(revived)) { + if (!entry || !entry.repositoryUri) { + continue; + } + + result.set(canonicalId, { + repositoryUri: entry.repositoryUri, + marketplaceType: entry.marketplaceType, + }); + } + + return result; + } + + private _updateMarketplaceIndex(marketplace: IMarketplaceReference, repositoryUri: URI, marketplaceType?: MarketplaceType): void { + if (marketplace.kind === MarketplaceReferenceKind.LocalFileUri) { + return; + } + + const previous = this._marketplaceIndex.value.get(marketplace.canonicalId); + if (previous && previous.repositoryUri.toString() === repositoryUri.toString() && previous.marketplaceType === marketplaceType) { + return; + } + + this._marketplaceIndex.value.set(marketplace.canonicalId, { repositoryUri, marketplaceType }); + this._saveMarketplaceIndex(); + } + + private _saveMarketplaceIndex(): void { + const serialized: IStoredMarketplaceIndex = {}; + for (const [canonicalId, entry] of this._marketplaceIndex.value) { + serialized[canonicalId] = JSON.parse(JSON.stringify({ + repositoryUri: entry.repositoryUri, + marketplaceType: entry.marketplaceType, + })); + } + + if (Object.keys(serialized).length === 0) { + this._storageService.remove(MARKETPLACE_INDEX_STORAGE_KEY, StorageScope.APPLICATION); + return; + } + + this._storageService.store(MARKETPLACE_INDEX_STORAGE_KEY, JSON.stringify(serialized), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string): Promise { + try { + await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: progressTitle, + cancellable: false, + }, + async () => { + await this._fileService.createFolder(dirname(repoDir)); + await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, dirname(repoDir).fsPath); + } + ); + } catch (err) { + this._logService.error(`[AgentPluginRepositoryService] Failed to clone ${cloneUrl}:`, err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('cloneFailed', "Failed to install plugin '{0}': {1}", failureLabel, err?.message ?? String(err)), + actions: { + primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { + this._commandService.executeCommand('git.showOutput'); + })], + }, + }); + throw err; + } + } + + private _getPluginDir(repoDir: URI, source: string): URI { + const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, ''); + const pluginDir = normalizedSource ? joinPath(repoDir, normalizedSource) : repoDir; + if (!isEqualOrParent(pluginDir, repoDir)) { + throw new Error(`Invalid plugin source path '${source}'`); + } + return pluginDir; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 1f8a8d05b212e..04cc9460358d9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -38,7 +38,7 @@ import { DefaultViewsContext, SearchAgentPluginsContext } from '../../extensions import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { IAgentPlugin, IAgentPluginService } from '../common/plugins/agentPluginService.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; -import { IMarketplacePlugin, IPluginMarketplaceService, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js'; export const HasInstalledAgentPluginsContext = new RawContextKey('hasInstalledAgentPlugins', false); @@ -63,6 +63,7 @@ interface IMarketplacePluginItem { readonly description: string; readonly source: string; readonly marketplace: string; + readonly marketplaceReference: IMarketplaceReference; readonly marketplaceType: MarketplaceType; readonly readmeUri?: URI; } @@ -83,6 +84,7 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin description: plugin.description, source: plugin.source, marketplace: plugin.marketplace, + marketplaceReference: plugin.marketplaceReference, marketplaceType: plugin.marketplaceType, readmeUri: plugin.readmeUri, }; @@ -109,6 +111,7 @@ class InstallPluginAction extends Action { version: '', source: this.item.source, marketplace: this.item.marketplace, + marketplaceReference: this.item.marketplaceReference, marketplaceType: this.item.marketplaceType, readmeUri: this.item.readmeUri, }); @@ -426,6 +429,7 @@ export class AgentPluginsListView extends AbstractExtensionsListView { - const repoDir = this._getRepoCacheDir(plugin.marketplaceType, plugin.marketplace); - const repoExists = await this._fileService.exists(repoDir); - - if (!repoExists) { - const repoUrl = `https://github.com/${plugin.marketplace}.git`; - try { - await this._progressService.withProgress( - { - location: ProgressLocation.Notification, - title: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name), - cancellable: false, - }, - async () => { - await this._commandService.executeCommand('_git.cloneRepository', repoUrl, dirname(repoDir).fsPath); - } - ); - } catch (err) { - this._logService.error(`[PluginInstallService] Failed to clone ${repoUrl}:`, err); - this._notificationService.notify({ - severity: Severity.Error, - message: localize('cloneFailed', "Failed to install plugin '{0}': {1}", plugin.name, err?.message ?? String(err)), - actions: { - primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { - this._commandService.executeCommand('git.showOutput'); - })], - }, - }); - return; - } + try { + await this._pluginRepositoryService.ensureRepository(plugin.marketplaceReference, { + progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name), + failureLabel: plugin.name, + marketplaceType: plugin.marketplaceType, + }); + } catch { + return; } let pluginDir: URI; try { - pluginDir = this._getPluginDir(repoDir, plugin.source); + pluginDir = this._pluginRepositoryService.getPluginInstallUri(plugin); } catch { this._notificationService.notify({ severity: Severity.Error, @@ -91,58 +58,11 @@ export class PluginInstallService implements IPluginInstallService { } async updatePlugin(plugin: IMarketplacePlugin): Promise { - const repoDir = this._getRepoCacheDir(plugin.marketplaceType, plugin.marketplace); - const repoExists = await this._fileService.exists(repoDir); - if (!repoExists) { - this._logService.warn(`[PluginInstallService] Cannot update plugin '${plugin.name}': repository not cloned`); - return; - } - - try { - await this._progressService.withProgress( - { - location: ProgressLocation.Notification, - title: localize('updatingPlugin', "Updating plugin '{0}'...", plugin.name), - cancellable: false, - }, - async () => { - await this._commandService.executeCommand('_git.pull', repoDir.fsPath); - } - ); - } catch (err) { - this._logService.error(`[PluginInstallService] Failed to update ${plugin.marketplace}:`, err); - this._notificationService.notify({ - severity: Severity.Error, - message: localize('pullFailed', "Failed to update plugin '{0}': {1}", plugin.name, err?.message ?? String(err)), - actions: { - primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { - this._commandService.executeCommand('git.showOutput'); - })], - }, - }); - } - } - - /** - * Computes the cache directory for a marketplace repository. - * Structure: `cacheRoot/{type}/{owner}/{repo}` - */ - private _getRepoCacheDir(type: MarketplaceType, marketplace: string): URI { - const [owner, repo] = marketplace.split('/'); - return joinPath(this._cacheRoot, type, owner, repo); - } - - /** - * Computes the plugin directory within a cloned repository using the - * marketplace plugin's `source` field (the subdirectory path within the repo). - */ - private _getPluginDir(repoDir: URI, source: string): URI { - const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, ''); - const pluginDir = normalizedSource ? joinPath(repoDir, normalizedSource) : repoDir; - if (!isEqualOrParent(pluginDir, repoDir)) { - throw new Error(`Invalid plugin source path '${source}'`); - } - return pluginDir; + return this._pluginRepositoryService.pullRepository(plugin.marketplaceReference, { + pluginName: plugin.name, + failureLabel: plugin.name, + marketplaceType: plugin.marketplaceType, + }); } async uninstallPlugin(pluginUri: URI): Promise { @@ -150,8 +70,7 @@ export class PluginInstallService implements IPluginInstallService { } getPluginInstallUri(plugin: IMarketplacePlugin): URI { - const repoDir = this._getRepoCacheDir(plugin.marketplaceType, plugin.marketplace); - return this._getPluginDir(repoDir, plugin.source); + return this._pluginRepositoryService.getPluginInstallUri(plugin); } /** diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts new file mode 100644 index 0000000000000..3f9a9bffda545 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IMarketplacePlugin, IMarketplaceReference, MarketplaceType } from './pluginMarketplaceService.js'; + +export const IAgentPluginRepositoryService = createDecorator('agentPluginRepositoryService'); + +/** + * Options for ensuring a marketplace repository is available locally. + */ +export interface IEnsureRepositoryOptions { + /** Optional progress notification title shown during clone. */ + readonly progressTitle?: string; + /** Label used in clone failure messaging. */ + readonly failureLabel?: string; + /** Marketplace type metadata to persist in the marketplace index. */ + readonly marketplaceType?: MarketplaceType; +} + +/** + * Options for pulling the latest changes from a cloned marketplace repository. + */ +export interface IPullRepositoryOptions { + /** Optional plugin name used in progress messaging. */ + readonly pluginName?: string; + /** Label used in pull failure messaging. */ + readonly failureLabel?: string; + /** Marketplace type metadata for repository index updates. */ + readonly marketplaceType?: MarketplaceType; +} + +/** + * Manages cloning, cache location resolution, and update operations for + * agent plugin marketplace repositories. + */ +export interface IAgentPluginRepositoryService { + readonly _serviceBrand: undefined; + + /** + * Returns the local cache URI for a marketplace repository reference. + * Uses a storage-backed marketplace index when available. + */ + getRepositoryUri(marketplace: IMarketplaceReference, marketplaceType?: MarketplaceType): URI; + + /** + * Returns the local install URI for a plugin source directory inside its + * marketplace repository cache. + */ + getPluginInstallUri(plugin: IMarketplacePlugin): URI; + + /** + * Ensures a marketplace repository is cloned locally and returns its cache URI. + */ + ensureRepository(marketplace: IMarketplaceReference, options?: IEnsureRepositoryOptions): Promise; + + /** + * Pulls latest changes for a cloned marketplace repository. + */ + pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise; +} diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 32f158121bced..c7824a81fc969 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -4,27 +4,53 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { parse as parseJSONC } from '../../../../../base/common/json.js'; +import { Lazy } from '../../../../../base/common/lazy.js'; +import { revive } from '../../../../../base/common/marshalling.js'; import { joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { asJson, IRequestService } from '../../../../../platform/request/common/request.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import type { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; import { ChatConfiguration } from '../constants.js'; +import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; export const enum MarketplaceType { Copilot = 'copilot', Claude = 'claude', } +export const enum MarketplaceReferenceKind { + GitHubShorthand = 'githubShorthand', + GitUri = 'gitUri', + LocalFileUri = 'localFileUri', +} + +export interface IMarketplaceReference { + readonly rawValue: string; + readonly displayLabel: string; + readonly cloneUrl: string; + readonly canonicalId: string; + readonly cacheSegments: readonly string[]; + readonly kind: MarketplaceReferenceKind; + readonly githubRepo?: string; + readonly localRepositoryUri?: URI; +} + export interface IMarketplacePlugin { readonly name: string; readonly description: string; readonly version: string; /** Subdirectory within the repository where the plugin lives. */ readonly source: string; - /** The `owner/repo` identifier of the marketplace repository. */ + /** Marketplace label shown in UI and plugin provenance. */ readonly marketplace: string; + /** Canonical reference for clone/update/install location resolution. */ + readonly marketplaceReference: IMarketplaceReference; /** The type of marketplace this plugin comes from. */ readonly marketplaceType: MarketplaceType; readonly readmeUri?: URI; @@ -58,26 +84,61 @@ const MARKETPLACE_DEFINITIONS: { type: MarketplaceType; path: string }[] = [ { type: MarketplaceType.Claude, path: '.claude-plugin/marketplace.json' }, ]; +const GITHUB_MARKETPLACE_CACHE_TTL_MS = 8 * 60 * 60 * 1000; +const GITHUB_MARKETPLACE_CACHE_STORAGE_KEY = 'chat.plugins.marketplaces.githubCache.v1'; + +interface IGitHubMarketplaceCacheEntry { + readonly plugins: readonly IMarketplacePlugin[]; + readonly expiresAt: number; + readonly referenceRawValue: string; +} + +type IStoredGitHubMarketplaceCache = Dto>; + export class PluginMarketplaceService implements IPluginMarketplaceService { declare readonly _serviceBrand: undefined; + private readonly _gitHubMarketplaceCache = new Lazy>(() => this._loadPersistedGitHubMarketplaceCache()); constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IRequestService private readonly _requestService: IRequestService, + @IFileService private readonly _fileService: IFileService, + @IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService, @ILogService private readonly _logService: ILogService, + @IStorageService private readonly _storageService: IStorageService, ) { } async fetchMarketplacePlugins(token: CancellationToken): Promise { - const repos: string[] = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; + const configuredRefs = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; + const refs = parseMarketplaceReferences(configuredRefs); + + for (const value of configuredRefs) { + if (typeof value !== 'string' || !parseMarketplaceReference(value)) { + this._logService.debug(`[PluginMarketplaceService] Ignoring invalid marketplace entry: ${String(value)}`); + } + } + const results = await Promise.all( - repos - .filter(repo => typeof repo === 'string' && /^[^/]+\/[^/]+$/.test(repo.trim())) - .map(repo => this._fetchFromRepo(repo.trim(), token)) + refs.map(ref => { + if (ref.kind === MarketplaceReferenceKind.GitHubShorthand && ref.githubRepo) { + return this._fetchFromGitHubRepo(ref, ref.githubRepo, token); + } + return this._fetchFromClonedRepo(ref, token); + }) ); return results.flat(); } - private async _fetchFromRepo(repo: string, token: CancellationToken): Promise { + private async _fetchFromGitHubRepo(reference: IMarketplaceReference, repo: string, token: CancellationToken): Promise { + const cache = this._gitHubMarketplaceCache.value; + + const cached = this._getCachedGitHubMarketplacePlugins(cache, reference.canonicalId); + if (cached) { + return cached; + } + + let repoMayBePrivate = true; + for (const def of MARKETPLACE_DEFINITIONS) { if (token.isCancellationRequested) { return []; @@ -85,8 +146,10 @@ export class PluginMarketplaceService implements IPluginMarketplaceService { const url = `https://raw.githubusercontent.com/${repo}/main/${def.path}`; try { const context = await this._requestService.request({ type: 'GET', url }, token); - if (context.res.statusCode !== 200) { - this._logService.debug(`[PluginMarketplaceService] ${url} returned status ${context.res.statusCode}, skipping`); + const statusCode = context.res.statusCode; + if (statusCode !== 200) { + repoMayBePrivate &&= statusCode !== undefined && statusCode >= 400 && statusCode < 500; + this._logService.debug(`[PluginMarketplaceService] ${url} returned status ${statusCode}, skipping`); continue; } const json = await asJson(context); @@ -94,7 +157,7 @@ export class PluginMarketplaceService implements IPluginMarketplaceService { this._logService.debug(`[PluginMarketplaceService] ${url} did not contain a valid plugins array, skipping`); continue; } - return json.plugins + const plugins = json.plugins .filter((p): p is { name: string; description?: string; version?: string; source?: string } => typeof p.name === 'string' && !!p.name ) @@ -110,19 +173,317 @@ export class PluginMarketplaceService implements IPluginMarketplaceService { description: p.description ?? '', version: p.version ?? '', source, - marketplace: repo, + marketplace: reference.displayLabel, + marketplaceReference: reference, marketplaceType: def.type, readmeUri: getMarketplaceReadmeUri(repo, source), }]; }); + + cache.set(reference.canonicalId, { + plugins, + expiresAt: Date.now() + GITHUB_MARKETPLACE_CACHE_TTL_MS, + referenceRawValue: reference.rawValue, + }); + this._savePersistedGitHubMarketplaceCache(cache); + + return plugins; } catch (err) { this._logService.debug(`[PluginMarketplaceService] Failed to fetch marketplace.json from ${url}:`, err); continue; } } + + if (repoMayBePrivate) { + this._logService.debug(`[PluginMarketplaceService] ${repo} may be private, attempting clone-based marketplace discovery`); + return this._fetchFromClonedRepo(reference, token); + } + this._logService.debug(`[PluginMarketplaceService] No marketplace.json found in ${repo}`); return []; } + + private _getCachedGitHubMarketplacePlugins(cache: Map, cacheKey: string): IMarketplacePlugin[] | undefined { + const cached = cache.get(cacheKey); + if (!cached) { + return undefined; + } + + if (cached.expiresAt <= Date.now()) { + cache.delete(cacheKey); + this._savePersistedGitHubMarketplaceCache(cache); + return undefined; + } + + return [...cached.plugins]; + } + + private _loadPersistedGitHubMarketplaceCache(): Map { + const cache = new Map(); + const now = Date.now(); + const stored = this._storageService.getObject(GITHUB_MARKETPLACE_CACHE_STORAGE_KEY, StorageScope.APPLICATION); + if (!stored) { + return cache; + } + + const revived = revive(stored); + + for (const [cacheKey, entry] of Object.entries(revived)) { + if (!entry || !Array.isArray(entry.plugins) || typeof entry.expiresAt !== 'number' || entry.expiresAt <= now || typeof entry.referenceRawValue !== 'string') { + continue; + } + + const reference = parseMarketplaceReference(entry.referenceRawValue); + if (!reference) { + continue; + } + + const plugins = entry.plugins.map(plugin => ({ + ...plugin, + marketplace: reference.displayLabel, + marketplaceReference: reference, + })); + + cache.set(cacheKey, { + plugins, + expiresAt: entry.expiresAt, + referenceRawValue: entry.referenceRawValue, + }); + } + + return cache; + } + + private _savePersistedGitHubMarketplaceCache(cache: Map): void { + const serialized: IStoredGitHubMarketplaceCache = {}; + for (const [cacheKey, entry] of cache) { + if (!entry.plugins.length || entry.expiresAt <= Date.now()) { + continue; + } + + serialized[cacheKey] = { + expiresAt: entry.expiresAt, + referenceRawValue: entry.referenceRawValue, + plugins: entry.plugins, + }; + } + + if (Object.keys(serialized).length === 0) { + this._storageService.remove(GITHUB_MARKETPLACE_CACHE_STORAGE_KEY, StorageScope.APPLICATION); + return; + } + + this._storageService.store( + GITHUB_MARKETPLACE_CACHE_STORAGE_KEY, + JSON.stringify(serialized), + StorageScope.APPLICATION, + StorageTarget.MACHINE, + ); + } + + private async _fetchFromClonedRepo(reference: IMarketplaceReference, token: CancellationToken): Promise { + let repoDir: URI; + try { + repoDir = await this._pluginRepositoryService.ensureRepository(reference); + } catch (err) { + this._logService.debug(`[PluginMarketplaceService] Failed to prepare marketplace repository ${reference.rawValue}:`, err); + return []; + } + + for (const def of MARKETPLACE_DEFINITIONS) { + if (token.isCancellationRequested) { + return []; + } + + const definitionUri = joinPath(repoDir, def.path); + let json: IMarketplaceJson | undefined; + try { + const contents = await this._fileService.readFile(definitionUri); + json = parseJSONC(contents.value.toString()) as IMarketplaceJson | undefined; + } catch { + continue; + } + + if (!json?.plugins || !Array.isArray(json.plugins)) { + this._logService.debug(`[PluginMarketplaceService] ${definitionUri.toString()} did not contain a valid plugins array, skipping`); + continue; + } + + return json.plugins + .filter((p): p is { name: string; description?: string; version?: string; source?: string } => + typeof p.name === 'string' && !!p.name + ) + .flatMap(p => { + const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? ''); + if (source === undefined) { + this._logService.warn(`[PluginMarketplaceService] Skipping plugin '${p.name}' in ${reference.rawValue}: invalid source path '${p.source ?? ''}' with pluginRoot '${json.metadata?.pluginRoot ?? ''}'`); + return []; + } + + return [{ + name: p.name, + description: p.description ?? '', + version: p.version ?? '', + source, + marketplace: reference.displayLabel, + marketplaceReference: reference, + marketplaceType: def.type, + readmeUri: getMarketplaceReadmeFileUri(repoDir, source), + }]; + }); + } + + this._logService.debug(`[PluginMarketplaceService] No marketplace.json found in ${reference.rawValue}`); + return []; + } +} + +export function parseMarketplaceReferences(values: readonly unknown[]): IMarketplaceReference[] { + const byCanonicalId = new Map(); + + for (const value of values) { + if (typeof value !== 'string') { + continue; + } + + const parsed = parseMarketplaceReference(value); + if (!parsed) { + continue; + } + + if (!byCanonicalId.has(parsed.canonicalId)) { + byCanonicalId.set(parsed.canonicalId, parsed); + } + } + + return [...byCanonicalId.values()]; +} + +export function parseMarketplaceReference(value: string): IMarketplaceReference | undefined { + const rawValue = value.trim(); + if (!rawValue) { + return undefined; + } + + const uriReference = parseUriMarketplaceReference(rawValue); + if (uriReference) { + return uriReference; + } + + const scpReference = parseScpMarketplaceReference(rawValue); + if (scpReference) { + return scpReference; + } + + const shorthandMatch = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(rawValue); + if (shorthandMatch) { + const owner = shorthandMatch[1]; + const repo = shorthandMatch[2]; + return { + rawValue, + displayLabel: `${owner}/${repo}`, + cloneUrl: `https://github.com/${owner}/${repo}.git`, + canonicalId: getGitHubCanonicalId(owner, repo), + cacheSegments: ['github.com', owner, repo], + kind: MarketplaceReferenceKind.GitHubShorthand, + githubRepo: `${owner}/${repo}`, + }; + } + + return undefined; +} + +function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | undefined { + let uri: URI; + try { + uri = URI.parse(rawValue); + } catch { + return undefined; + } + + const scheme = uri.scheme.toLowerCase(); + if (scheme === 'file' && /^file:\/\//i.test(rawValue)) { + const localRepositoryUri = URI.file(uri.fsPath); + return { + rawValue, + displayLabel: localRepositoryUri.fsPath, + cloneUrl: rawValue, + canonicalId: `file:${localRepositoryUri.toString().toLowerCase()}`, + cacheSegments: [], + kind: MarketplaceReferenceKind.LocalFileUri, + localRepositoryUri, + }; + } + + if (scheme !== 'http' && scheme !== 'https' && scheme !== 'ssh') { + return undefined; + } + + if (!uri.authority) { + return undefined; + } + + const normalizedPath = normalizeGitRepoPath(uri.path); + if (!normalizedPath) { + return undefined; + } + + const sanitizedAuthority = sanitizePathSegment(uri.authority.toLowerCase()); + const pathSegments = normalizedPath.slice(1, -4).split('/').map(sanitizePathSegment); + return { + rawValue, + displayLabel: rawValue, + cloneUrl: rawValue, + canonicalId: `git:${uri.authority.toLowerCase()}/${normalizedPath.slice(1).toLowerCase()}`, + cacheSegments: [sanitizedAuthority, ...pathSegments], + kind: MarketplaceReferenceKind.GitUri, + }; +} + +function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | undefined { + const match = /^([^@\s]+)@([^:\s]+):(.+\.git)$/i.exec(rawValue); + if (!match) { + return undefined; + } + + const authority = match[2]; + const pathWithGit = match[3].replace(/^\/+/, ''); + if (!pathWithGit.toLowerCase().endsWith('.git')) { + return undefined; + } + + const pathSegments = pathWithGit.slice(0, -4).split('/').map(sanitizePathSegment); + return { + rawValue, + displayLabel: rawValue, + cloneUrl: rawValue, + canonicalId: `git:${authority.toLowerCase()}/${pathWithGit.toLowerCase()}`, + cacheSegments: [sanitizePathSegment(authority.toLowerCase()), ...pathSegments], + kind: MarketplaceReferenceKind.GitUri, + }; +} + +function normalizeGitRepoPath(path: string): string | undefined { + const trimmed = path.replace(/\/+/g, '/').replace(/\/+$/g, ''); + if (!trimmed.toLowerCase().endsWith('.git')) { + return undefined; + } + + const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + const pathWithoutGit = withLeadingSlash.slice(1, -4); + if (!pathWithoutGit || !pathWithoutGit.includes('/')) { + return undefined; + } + + return withLeadingSlash; +} + +function getGitHubCanonicalId(owner: string, repo: string): string { + return `github:${owner.toLowerCase()}/${repo.toLowerCase()}`; +} + +function sanitizePathSegment(value: string): string { + return value.replace(/[\\/:*?"<>|]/g, '_'); } function normalizeMarketplacePath(value: string): string { @@ -160,3 +521,8 @@ function getMarketplaceReadmeUri(repo: string, source: string): URI { const readmePath = normalizedSource ? `${normalizedSource}/README.md` : 'README.md'; return URI.parse(`https://github.com/${repo}/blob/main/${readmePath}`); } + +function getMarketplaceReadmeFileUri(repoDir: URI, source: string): URI { + const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, ''); + return normalizedSource ? joinPath(repoDir, normalizedSource, 'README.md') : joinPath(repoDir, 'README.md'); +} diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts new file mode 100644 index 0000000000000..d5b0366678f07 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; +import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { AgentPluginRepositoryService } from '../../../browser/agentPluginRepositoryService.js'; +import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference } from '../../../common/plugins/pluginMarketplaceService.js'; + +suite('AgentPluginRepositoryService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createPlugin(marketplace: string, source: string): IMarketplacePlugin { + const marketplaceReference = parseMarketplaceReference(marketplace); + assert.ok(marketplaceReference); + if (!marketplaceReference) { + throw new Error('Expected marketplace reference to parse.'); + } + + return { + name: 'test-plugin', + description: '', + version: '', + source, + marketplace: marketplaceReference.displayLabel, + marketplaceReference, + marketplaceType: MarketplaceType.Copilot, + }; + } + + function createService( + onExists?: (resource: URI) => Promise, + onExecuteCommand?: (id: string) => void, + ): AgentPluginRepositoryService { + const instantiationService = store.add(new TestInstantiationService()); + + const fileService = { + exists: async (resource: URI) => onExists ? onExists(resource) : true, + } as unknown as IFileService; + + const progressService = { + withProgress: async (_options: unknown, callback: (...args: unknown[]) => Promise) => callback(), + } as unknown as IProgressService; + + instantiationService.stub(ICommandService, { + executeCommand: async (id: string) => { + onExecuteCommand?.(id); + return undefined; + }, + } as unknown as ICommandService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IFileService, fileService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); + instantiationService.stub(IProgressService, progressService); + instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + + return instantiationService.createInstance(AgentPluginRepositoryService); + } + + test('uses cacheSegments path for GitHub shorthand plugin references', () => { + const service = createService(); + const plugin = createPlugin('microsoft/vscode', 'plugins/myPlugin'); + const uri = service.getRepositoryUri(plugin.marketplaceReference, plugin.marketplaceType); + + assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/microsoft/vscode'); + }); + + test('uses marketplaces cache path for direct git URI plugin references', () => { + const service = createService(); + const plugin = createPlugin('https://example.com/org/repo.git', 'plugins/myPlugin'); + const uri = service.getRepositoryUri(plugin.marketplaceReference, plugin.marketplaceType); + + assert.strictEqual(uri.path, '/cache/agentPlugins/example.com/org/repo'); + }); + + test('uses same cache path for equivalent GitHub shorthand and URI references', () => { + const service = createService(); + const shorthandPlugin = createPlugin('microsoft/vscode', 'plugins/myPlugin'); + const uriPlugin = createPlugin('https://github.com/microsoft/vscode.git', 'plugins/myPlugin'); + + const shorthandUri = service.getRepositoryUri(shorthandPlugin.marketplaceReference, shorthandPlugin.marketplaceType); + const uriRefUri = service.getRepositoryUri(uriPlugin.marketplaceReference, uriPlugin.marketplaceType); + + assert.strictEqual(shorthandUri.path, '/cache/agentPlugins/github.com/microsoft/vscode'); + assert.strictEqual(uriRefUri.path, '/cache/agentPlugins/github.com/microsoft/vscode'); + }); + + test('ensures plugin repositories via cacheSegments path', async () => { + let checkedPath: string | undefined; + const service = createService(async resource => { + checkedPath = resource.path; + return true; + }); + const plugin = createPlugin('microsoft/vscode', 'plugins/myPlugin'); + const uri = await service.ensureRepository(plugin.marketplaceReference, { marketplaceType: plugin.marketplaceType }); + + assert.strictEqual(checkedPath, '/cache/agentPlugins/github.com/microsoft/vscode'); + assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/microsoft/vscode'); + }); + + test('builds install URI from source inside repository root', () => { + const service = createService(); + const plugin = createPlugin('microsoft/vscode', 'plugins/myPlugin'); + const uri = service.getPluginInstallUri(plugin); + + assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/microsoft/vscode/plugins/myPlugin'); + }); + + test('uses indexed repository URI when available', () => { + const storage = store.add(new InMemoryStorageService()); + storage.store('chat.plugins.marketplaces.index.v1', JSON.stringify({ + 'github:microsoft/vscode': { + repositoryUri: URI.file('/cache/agentPlugins/indexed/microsoft/vscode'), + marketplaceType: MarketplaceType.Copilot, + }, + }), StorageScope.APPLICATION, StorageTarget.MACHINE); + + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IFileService, { exists: async () => true } as unknown as IFileService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); + instantiationService.stub(IProgressService, { withProgress: async (_options: unknown, callback: (...args: unknown[]) => Promise) => callback() } as unknown as IProgressService); + instantiationService.stub(IStorageService, storage); + + const service = instantiationService.createInstance(AgentPluginRepositoryService); + const plugin = createPlugin('microsoft/vscode', 'plugins/myPlugin'); + const uri = service.getRepositoryUri(plugin.marketplaceReference, plugin.marketplaceType); + + assert.strictEqual(uri.path, '/cache/agentPlugins/indexed/microsoft/vscode'); + }); + + test('rejects plugin source paths that escape repository root', () => { + const service = createService(); + const plugin = createPlugin('microsoft/vscode', '../outside'); + + assert.throws(() => service.getPluginInstallUri(plugin)); + }); + + test('uses local repository URI for file marketplace references', () => { + const service = createService(); + const plugin = createPlugin('file:///tmp/marketplace-repo', 'plugins/myPlugin'); + const uri = service.getRepositoryUri(plugin.marketplaceReference, plugin.marketplaceType); + + assert.strictEqual(uri.scheme, 'file'); + assert.strictEqual(uri.path, '/tmp/marketplace-repo'); + }); + + test('does not invoke clone command when ensuring existing local file repository', async () => { + let commandInvocationCount = 0; + const service = createService(async () => true, () => { + commandInvocationCount++; + }); + const plugin = createPlugin('file:///tmp/marketplace-repo', 'plugins/myPlugin'); + + const uri = await service.ensureRepository(plugin.marketplaceReference, { marketplaceType: plugin.marketplaceType }); + + assert.strictEqual(uri.path, '/tmp/marketplace-repo'); + assert.strictEqual(commandInvocationCount, 0); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts new file mode 100644 index 0000000000000..4db8ae689147f --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences } from '../../../common/plugins/pluginMarketplaceService.js'; + +suite('PluginMarketplaceService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('parses GitHub shorthand marketplace', () => { + const parsed = parseMarketplaceReference('microsoft/vscode'); + assert.ok(parsed); + if (!parsed) { + return; + } + assert.strictEqual(parsed.kind, MarketplaceReferenceKind.GitHubShorthand); + assert.strictEqual(parsed.cloneUrl, 'https://github.com/microsoft/vscode.git'); + assert.strictEqual(parsed.canonicalId, 'github:microsoft/vscode'); + assert.strictEqual(parsed.displayLabel, 'microsoft/vscode'); + assert.deepStrictEqual(parsed.cacheSegments, ['github.com', 'microsoft', 'vscode']); + }); + + test('parses direct HTTPS and SSH marketplaces ending in .git', () => { + const https = parseMarketplaceReference('https://example.com/org/repo.git'); + assert.ok(https); + if (!https) { + return; + } + assert.strictEqual(https.kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(https.displayLabel, 'https://example.com/org/repo.git'); + assert.deepStrictEqual(https.cacheSegments, ['example.com', 'org', 'repo']); + + const ssh = parseMarketplaceReference('ssh://git@example.com/org/repo.git'); + assert.ok(ssh); + if (!ssh) { + return; + } + assert.strictEqual(ssh.kind, MarketplaceReferenceKind.GitUri); + assert.deepStrictEqual(ssh.cacheSegments, ['git@example.com', 'org', 'repo']); + }); + + test('parses scp-like git URI marketplaces', () => { + const parsed = parseMarketplaceReference('git@example.com:org/repo.git'); + assert.ok(parsed); + if (!parsed) { + return; + } + assert.strictEqual(parsed.kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(parsed.cloneUrl, 'git@example.com:org/repo.git'); + assert.strictEqual(parsed.canonicalId, 'git:example.com/org/repo.git'); + assert.deepStrictEqual(parsed.cacheSegments, ['example.com', 'org', 'repo']); + }); + + test('parses local file marketplace references', () => { + const parsed = parseMarketplaceReference('file:///tmp/marketplace-repo'); + assert.ok(parsed); + if (!parsed) { + return; + } + assert.strictEqual(parsed.kind, MarketplaceReferenceKind.LocalFileUri); + assert.strictEqual(parsed.localRepositoryUri?.scheme, 'file'); + assert.strictEqual(parsed.cloneUrl, 'file:///tmp/marketplace-repo'); + assert.deepStrictEqual(parsed.cacheSegments, []); + }); + + test('rejects non-shorthand marketplace entries without .git', () => { + assert.strictEqual(parseMarketplaceReference('https://example.com/org/repo'), undefined); + assert.strictEqual(parseMarketplaceReference('ssh://git@example.com/org/repo'), undefined); + assert.strictEqual(parseMarketplaceReference('git@example.com:org/repo'), undefined); + }); + + test('parses HTTPS URI with trailing slash after .git', () => { + const parsed = parseMarketplaceReference('https://example.com/org/repo.git/'); + assert.ok(parsed); + if (!parsed) { + return; + } + assert.strictEqual(parsed.kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(parsed.canonicalId, 'git:example.com/org/repo.git'); + assert.deepStrictEqual(parsed.cacheSegments, ['example.com', 'org', 'repo']); + }); + + test('deduplicates equivalent Git URI forms but keeps shorthand distinct', () => { + const parsed = parseMarketplaceReferences([ + 'microsoft/vscode', + 'https://github.com/microsoft/vscode.git', + 'git@github.com:microsoft/vscode.git', + ]); + + assert.deepStrictEqual(parsed.map(r => r.canonicalId), [ + 'github:microsoft/vscode', + 'git:github.com/microsoft/vscode.git', + ]); + }); + + test('parseMarketplaceReferences ignores non-string entries', () => { + const parsed = parseMarketplaceReferences([null, 42, {}, 'microsoft/vscode']); + assert.strictEqual(parsed.length, 1); + assert.strictEqual(parsed[0].canonicalId, 'github:microsoft/vscode'); + }); +}); From d3a1bce4b1a674d4a52d5a6811ecd37d7c1aeac8 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 24 Feb 2026 16:58:12 -0800 Subject: [PATCH 051/541] chat: simplify plugin management (#297536) Replace the manage plugins quick pick UI with a direct opening of the extensions marketplace with @agentPlugins search pre-filled. This provides a simpler UX by removing an extra dialog layer. - Simplifies ManagePluginsAction to open extensions sidebar with preset search - Removes the entire quick pick UI and related helper functions - Users can now directly browse and install plugins from the marketplace Fixes https://github.com/microsoft/vscode/issues/297368 Fixes https://github.com/microsoft/vscode/issues/297517 (Commit message generated by Copilot) --- .../chat/browser/actions/chatPluginActions.ts | 221 +----------------- 1 file changed, 4 insertions(+), 217 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts index d310d80e80cef..3a5f9993b0f71 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts @@ -3,48 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize, localize2 } from '../../../../../nls.js'; +import { localize2 } from '../../../../../nls.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../../platform/quickinput/common/quickInput.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { dirname } from '../../../../../base/common/resources.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IAgentPlugin, IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; -import { ResourceSet } from '../../../../../base/common/map.js'; -import { ChatConfiguration } from '../../common/constants.js'; -import { IConfigurationService, ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; -import { IPaneCompositePartService } from '../../../../services/panecomposite/browser/panecomposite.js'; -import { IExtensionsViewPaneContainer, VIEWLET_ID } from '../../../extensions/common/extensions.js'; -import { ViewContainerLocation } from '../../../../common/views.js'; - -const enum ManagePluginItemKind { - Plugin = 'plugin', - FindMore = 'findMore', - AddFromFolder = 'addFromFolder', -} - -interface IPluginPickItem extends IQuickPickItem { - readonly kind: ManagePluginItemKind.Plugin; - plugin: IAgentPlugin; -} - -interface IFindMorePickItem extends IQuickPickItem { - readonly kind: ManagePluginItemKind.FindMore; -} - -interface IAddFromFolderPickItem extends IQuickPickItem { - readonly kind: ManagePluginItemKind.AddFromFolder; -} - -interface IManagePluginsPickResult { - action: 'apply' | 'findMore' | 'addFromFolder'; - selectedPluginItems: IPluginPickItem[]; -} +import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; class ManagePluginsAction extends Action2 { static readonly ID = 'workbench.action.chat.managePlugins'; @@ -52,7 +16,7 @@ class ManagePluginsAction extends Action2 { constructor() { super({ id: ManagePluginsAction.ID, - title: localize2('managePlugins', 'Manage Plugins...'), + title: localize2('plugins', 'Plugins'), category: CHAT_CATEGORY, precondition: ChatContextKeys.enabled, menu: [{ @@ -64,187 +28,10 @@ class ManagePluginsAction extends Action2 { } async run(accessor: ServicesAccessor): Promise { - const agentPluginService = accessor.get(IAgentPluginService); - const quickInputService = accessor.get(IQuickInputService); - const labelService = accessor.get(ILabelService); - const dialogService = accessor.get(IDialogService); - const fileDialogService = accessor.get(IFileDialogService); - const configurationService = accessor.get(IConfigurationService); - const workspaceContextService = accessor.get(IWorkspaceContextService); - const paneCompositeService = accessor.get(IPaneCompositePartService); - - const allPlugins = agentPluginService.allPlugins.get(); - const hasWorkspace = workspaceContextService.getWorkspace().folders.length > 0; - - // Group plugins by parent directory label - const groups = new Map(); - for (const plugin of allPlugins) { - const groupLabel = labelService.getUriLabel(dirname(plugin.uri), { relative: true }); - let group = groups.get(groupLabel); - if (!group) { - group = []; - groups.set(groupLabel, group); - } - group.push(plugin); - } - - const items: QuickPickInput[] = []; - const preselectedPluginItems: IPluginPickItem[] = []; - for (const [groupLabel, plugins] of groups) { - items.push({ type: 'separator', label: groupLabel }); - for (const plugin of plugins) { - const pluginName = plugin.uri.path.split('/').at(-1) ?? ''; - const item: IPluginPickItem = { - kind: ManagePluginItemKind.Plugin, - label: pluginName, - plugin, - picked: plugin.enabled.get(), - }; - if (item.picked) { - preselectedPluginItems.push(item); - } - items.push(item); - } - } - - if (items.length > 0 || hasWorkspace) { - items.push({ type: 'separator' }); - } - - if (hasWorkspace) { - items.push({ - kind: ManagePluginItemKind.FindMore, - label: localize('findMorePlugins', 'Find More Plugins...'), - pickable: false, - } satisfies IFindMorePickItem); - } - - items.push({ - kind: ManagePluginItemKind.AddFromFolder, - label: localize('addFromFolder', 'Add from Folder...'), - pickable: false, - } satisfies IAddFromFolderPickItem); - - const result = await showManagePluginsQuickPick(quickInputService, items, preselectedPluginItems); - - if (!result) { - return; - } - - if (result.action === 'findMore') { - const viewlet = await paneCompositeService.openPaneComposite(VIEWLET_ID, ViewContainerLocation.Sidebar, true); - const view = viewlet?.getViewPaneContainer() as IExtensionsViewPaneContainer | undefined; - view?.search('@agentPlugins '); - return; - } - - if (result.action === 'addFromFolder') { - const selectedUris = await fileDialogService.showOpenDialog({ - title: localize('pickPluginFolderTitle', 'Pick Plugin Folder'), - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - defaultUri: workspaceContextService.getWorkspace().folders[0]?.uri, - }); - - const folderUri = selectedUris?.[0]; - if (!folderUri) { - return; - } - - const currentPaths = configurationService.getValue>(ChatConfiguration.PluginPaths) ?? {}; - const nextPath = folderUri.fsPath; - if (!Object.prototype.hasOwnProperty.call(currentPaths, nextPath)) { - await configurationService.updateValue(ChatConfiguration.PluginPaths, { ...currentPaths, [nextPath]: true }, ConfigurationTarget.USER_LOCAL); - } - return; - } - - if (allPlugins.length === 0) { - dialogService.info( - localize('noPlugins', 'No plugins found.'), - localize('noPluginsDetail', 'There are currently no agent plugins discovered in this workspace.') - ); - return; - } - - const enabledUris = new ResourceSet(result.selectedPluginItems.map(i => i.plugin.uri)); - for (const plugin of allPlugins) { - const wasEnabled = plugin.enabled.get(); - const isNowEnabled = enabledUris.has(plugin.uri); - - if (!wasEnabled && isNowEnabled) { - plugin.setEnabled(true); - } else if (wasEnabled && !isNowEnabled) { - plugin.setEnabled(false); - } - } + accessor.get(IExtensionsWorkbenchService).openSearch('@agentPlugins '); } } -async function showManagePluginsQuickPick( - quickInputService: IQuickInputService, - items: QuickPickInput[], - preselectedPluginItems: IPluginPickItem[] -): Promise { - const quickPick = quickInputService.createQuickPick({ useSeparators: true }); - const disposables = new DisposableStore(); - disposables.add(quickPick); - - quickPick.canSelectMany = true; - quickPick.title = localize('managePluginsTitle', 'Manage Plugins'); - quickPick.placeholder = localize('managePluginsPlaceholder', 'Choose which plugins are enabled'); - quickPick.items = items; - quickPick.selectedItems = preselectedPluginItems; - - const result = await new Promise(resolve => { - let resolved = false; - - const complete = (value: IManagePluginsPickResult | undefined) => { - if (resolved) { - return; - } - resolved = true; - resolve(value); - }; - - disposables.add(quickPick.onDidAccept(() => { - const activeItem = quickPick.activeItems[0]; - if (activeItem?.kind === ManagePluginItemKind.FindMore) { - complete({ - action: 'findMore', - selectedPluginItems: [], - }); - quickPick.hide(); - return; - } - - if (activeItem?.kind === ManagePluginItemKind.AddFromFolder) { - complete({ - action: 'addFromFolder', - selectedPluginItems: [], - }); - quickPick.hide(); - return; - } - - complete({ - action: 'apply', - selectedPluginItems: quickPick.selectedItems.filter((item): item is IPluginPickItem => item.kind === ManagePluginItemKind.Plugin), - }); - quickPick.hide(); - })); - - disposables.add(quickPick.onDidHide(() => { - complete(undefined); - disposables.dispose(); - })); - - quickPick.show(); - }); - return result; -} - export function registerChatPluginActions() { registerAction2(ManagePluginsAction); } From 784fc2b81a1e7fdcca0f2786a2eba989eebe26f6 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 24 Feb 2026 16:58:42 -0800 Subject: [PATCH 052/541] chat: fix plugin folder action to reveal folder instead of opening in editor (#297537) The 'Open Plugin Folder' action was incorrectly using IOpenerService.open() on the plugin directory, which attempted to open the directory in the editor, resulting in an error page. Fixed to use the 'revealFileInOS' command instead, which properly reveals the folder in the file explorer. Changes: - Replace IOpenerService with ICommandService dependency - Use 'revealFileInOS' command to reveal the plugin folder - Update label from 'Open Containing Folder' to 'Open Plugin Folder' - Open plugin URI directly instead of its parent directory Fixes https://github.com/microsoft/vscode/issues/297250 (Commit message generated by Copilot) --- .../contrib/chat/browser/agentPluginsView.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 04cc9460358d9..24ecf77313998 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -23,6 +23,7 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { WorkbenchPagedList } from '../../../../platform/list/browser/listService.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -168,13 +169,19 @@ class OpenPluginFolderAction extends Action { constructor( private readonly plugin: IAgentPlugin, + @ICommandService private readonly commandService: ICommandService, @IOpenerService private readonly openerService: IOpenerService, ) { - super(OpenPluginFolderAction.ID, localize('openContainingFolder', "Open Containing Folder")); + super(OpenPluginFolderAction.ID, localize('openPluginFolder', "Open Plugin Folder")); } override async run(): Promise { - await this.openerService.open(dirname(this.plugin.uri)); + try { + await this.commandService.executeCommand('revealFileInOS', this.plugin.uri); + } catch { + // Fallback for web where 'revealFileInOS' is not available + await this.openerService.open(dirname(this.plugin.uri)); + } } } From f641d8bcc05a4d4e2c0113e8897769b2e3a132f5 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 24 Feb 2026 17:00:02 -0800 Subject: [PATCH 053/541] chat: add search icon to Agent Plugins installed view (#297540) Adds a search icon to the Agent Plugins - Installed view toolbar to allow users to easily browse and search for agent plugins, matching the pattern already established in the MCP Servers view. - Creates AgentPluginsBrowseCommand action that opens the extensions search with the @agentPlugins filter - Registers the command in the view toolbar (MenuId.ViewTitle) - Exports InstalledAgentPluginsViewId constant for consistent view ID usage - Adds necessary imports for Action2, MenuId, Codicon, and services Fixes https://github.com/microsoft/vscode/issues/297269 (Commit message generated by Copilot) --- .../contrib/chat/browser/agentPluginsView.ts | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 24ecf77313998..35bcd764d101f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -8,6 +8,7 @@ import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; import { IPagedRenderer } from '../../../../base/browser/ui/list/listPaging.js'; import { Action, IAction, Separator } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; @@ -16,12 +17,13 @@ import { IPagedModel, PagedModel } from '../../../../base/common/paging.js'; import { basename, dirname, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; @@ -35,13 +37,14 @@ import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IViewDescriptorService, IViewsRegistry, Extensions as ViewExtensions } from '../../../common/views.js'; import { VIEW_CONTAINER } from '../../extensions/browser/extensions.contribution.js'; import { AbstractExtensionsListView } from '../../extensions/browser/extensionsViews.js'; -import { DefaultViewsContext, SearchAgentPluginsContext } from '../../extensions/common/extensions.js'; +import { DefaultViewsContext, extensionsFilterSubMenu, IExtensionsWorkbenchService, SearchAgentPluginsContext } from '../../extensions/common/extensions.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { IAgentPlugin, IAgentPluginService } from '../common/plugins/agentPluginService.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js'; export const HasInstalledAgentPluginsContext = new RawContextKey('hasInstalledAgentPlugins', false); +export const InstalledAgentPluginsViewId = 'workbench.views.agentPlugins.installed'; //#region Item model @@ -494,6 +497,36 @@ class DefaultBrowseAgentPluginsView extends AgentPluginsListView { //#endregion +//#region Browse command + +class AgentPluginsBrowseCommand extends Action2 { + constructor() { + super({ + id: 'workbench.agentPlugins.browse', + title: localize2('agentPlugins.browse', "Agent Plugins"), + tooltip: localize2('agentPlugins.browse.tooltip', "Browse Agent Plugins"), + icon: Codicon.search, + precondition: ChatContextKeys.Setup.hidden.negate(), + menu: [{ + id: extensionsFilterSubMenu, + group: '1_predefined', + order: 2, + when: ChatContextKeys.Setup.hidden.negate(), + }, { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', InstalledAgentPluginsViewId), ChatContextKeys.Setup.hidden.negate()), + group: 'navigation', + }], + }); + } + + async run(accessor: ServicesAccessor) { + accessor.get(IExtensionsWorkbenchService).openSearch('@agentPlugins '); + } +} + +//#endregion + //#region Views contribution export class AgentPluginsViewsContribution extends Disposable implements IWorkbenchContribution { @@ -511,9 +544,11 @@ export class AgentPluginsViewsContribution extends Disposable implements IWorkbe hasInstalledKey.set(agentPluginService.allPlugins.read(reader).length > 0); })); + registerAction2(AgentPluginsBrowseCommand); + Registry.as(ViewExtensions.ViewsRegistry).registerViews([ { - id: 'workbench.views.agentPlugins.installed', + id: InstalledAgentPluginsViewId, name: localize2('agent-plugins-installed', "Agent Plugins - Installed"), ctorDescriptor: new SyncDescriptor(AgentPluginsListView), when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledAgentPluginsContext, ChatContextKeys.Setup.hidden.negate()), From 1908fdd2f5fe74515f37afde35caa9f0ff539f97 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:03:22 -0800 Subject: [PATCH 054/541] Browser: selectively hide file chooser during agent interactions (#297217) --- .../browserView/node/playwrightTab.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 231daf0fba047..544bfcabd05f9 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -42,7 +42,6 @@ export class PlaywrightTab { page.on('console', event => this._handleConsoleMessage(event)) .on('pageerror', error => this._handlePageError(error)) .on('requestfailed', request => this._handleRequestFailed(request)) - .on('filechooser', chooser => this._handleFileChooser(chooser)) .on('dialog', dialog => this._handleDialog(dialog)) .on('download', download => this._handleDownload(download)); @@ -118,8 +117,12 @@ export class PlaywrightTab { /** * Run a callback against the page and wait for it to complete. + * * Because dialogs pause the page, execution races against any dialog that opens -- if a dialog * appears before the callback finishes, the method throws so the caller can surface it to the agent. + * + * Also allows for interactions to be handled differently when triggered by agents. + * E.g. file dialogs should appear when the user triggers one, but not when the agent does. */ async safeRunAgainstPage(action: (page: playwright.Page, token: CancellationToken) => Promise): Promise { if (this._dialog) { @@ -130,8 +133,20 @@ export class PlaywrightTab { let result: T | void; const dialogOpened = Event.toPromise(this._onDialogStateChanged.event); const actionCompleted = createCancelablePromise(async (token) => { - result = await this.runAndWaitForCompletion((token) => action(this.page, token), token); - actionDidComplete = true; + + // Whenever the page has a `filechooser` handler, the default file chooser is disabled. + // We don't want this during normal user interactions, but we do for agentic interactions. + // So we add a handler just during the action, and remove it afterwards. + // This isn't perfect (e.g. the user could trigger it while an action is running), but it's a best effort. + const handleFileChooser = (chooser: playwright.FileChooser) => this._handleFileChooser(chooser); + this.page.on('filechooser', handleFileChooser); + + try { + result = await this.runAndWaitForCompletion((token) => action(this.page, token), token); + actionDidComplete = true; + } finally { + this.page.off('filechooser', handleFileChooser); + } }); return raceCancellablePromises([dialogOpened, actionCompleted]).then(() => { From 6c1c0127e7e764d89437f9067937f21bff794f9b Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:03:29 -0800 Subject: [PATCH 055/541] Browser: Fix contents shifting slightly during paused state (#297535) Browser: fix contents slightly shifting during paused state --- .../electron-browser/browserEditor.ts | 12 +++++- .../electron-browser/media/browser.css | 37 +++++++++++++------ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 234991e440b54..89b50a46a6d00 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -236,6 +236,7 @@ export class BrowserEditor extends EditorPane { private _currentKeyDownEvent: IBrowserViewKeyDownEvent | undefined; private _navigationBar!: BrowserNavigationBar; + private _browserContainerWrapper!: HTMLElement; private _browserContainer!: HTMLElement; private _placeholderScreenshot!: HTMLElement; private _overlayPauseContainer!: HTMLElement; @@ -325,10 +326,15 @@ export class BrowserEditor extends EditorPane { }); this._register(toDisposable(() => this._findWidget.rawValue?.dispose())); + // Create browser container wrapper (flex item that fills remaining space) + this._browserContainerWrapper = $('.browser-container-wrapper'); + this._browserContainerWrapper.style.setProperty('--zoom-factor', String(getZoomFactor(this.window))); + root.appendChild(this._browserContainerWrapper); + // Create browser container (stub element for positioning) this._browserContainer = $('.browser-container'); this._browserContainer.tabIndex = 0; // make focusable - root.appendChild(this._browserContainer); + this._browserContainerWrapper.appendChild(this._browserContainer); // Create placeholder screenshot (background placeholder when WebContentsView is hidden) this._placeholderScreenshot = $('.browser-placeholder-screenshot'); @@ -497,6 +503,8 @@ export class BrowserEditor extends EditorPane { // Listen for zoom level changes and update browser view zoom factor this._inputDisposables.add(onDidChangeZoomLevel(targetWindowId => { if (targetWindowId === this.window.vscodeWindowId) { + // Update CSS variable for size calculations + this._browserContainerWrapper.style.setProperty('--zoom-factor', String(getZoomFactor(this.window))); this.layoutBrowserContainer(); } })); @@ -652,7 +660,7 @@ export class BrowserEditor extends EditorPane { const sharingEnabled = this.contextKeyService.contextMatchesRules(canShareBrowserWithAgentContext); const isShared = sharingEnabled && !!this._model && this._model.sharedWithAgent; - this._browserContainer.classList.toggle('shared', isShared); + this._browserContainerWrapper.classList.toggle('shared', isShared); this._navigationBar.setShared(isShared); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index 958e00af4ab0b..db45b5b9b4836 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -97,24 +97,20 @@ } } - .browser-container { + .browser-container-wrapper { flex: 1; min-height: 0; - margin: 0 2px 2px; - overflow: visible; position: relative; z-index: 0; /* Important: creates a new stacking context for the gradient border trick */ - outline: none !important; - border-radius: 2px; &.shared { &::before { content: ''; position: absolute; top: -2px; - left: -2px; - right: -2px; - bottom: -2px; + left: 0; + right: 0; + bottom: 0; z-index: -2; background: linear-gradient(135deg in lab, color-mix(in srgb, #51a2ff 100%, transparent), @@ -127,10 +123,10 @@ &::after { content: ''; position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + top: 1px; + left: 3px; + right: 3px; + bottom: 3px; z-index: -1; background-color: var(--vscode-editor-background); pointer-events: none; @@ -138,6 +134,23 @@ } } + .browser-container { + /* + * Snap dimensions to multiples of 1/zoomFactor CSS pixels so that + * width*zoomFactor and height*zoomFactor are always integers. This + * avoids sub-pixel mismatches between the WebContentsView + * (whose bounds are rounded to integer DIPs) and this CSS container, + * which would cause visible shifts when swapping between the live + * view and the placeholder screenshot. + */ + width: round(down, 100% - 4px, calc(1px / var(--zoom-factor, 1))); + height: round(down, 100% - 2px, calc(1px / var(--zoom-factor, 1))); + margin: 0 auto; + overflow: visible; + position: relative; + outline: none !important; + } + .browser-placeholder-screenshot { position: absolute; top: 0; From 83cc1058152380497c5cb61892506491632af654 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:35:18 -0800 Subject: [PATCH 056/541] De-duplicate browser open tools (#297552) * De-duplicate browser open tools * feedback --- .../common/browserViewTelemetry.ts | 2 + .../tools/browserTools.contribution.ts | 48 ++++++++------- .../electron-browser/tools/openBrowserTool.ts | 2 +- .../tools/openBrowserToolNonAgentic.ts | 59 +++++++++++++++++++ 4 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts diff --git a/src/vs/platform/browserView/common/browserViewTelemetry.ts b/src/vs/platform/browserView/common/browserViewTelemetry.ts index f261d0261a20b..66853e50999a2 100644 --- a/src/vs/platform/browserView/common/browserViewTelemetry.ts +++ b/src/vs/platform/browserView/common/browserViewTelemetry.ts @@ -9,6 +9,8 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; export type IntegratedBrowserOpenSource = /** Created via CDP, such as by the agent using Playwright tools. */ | 'cdpCreated' + /** Opened via a (non-agentic) chat tool invocation. */ + | 'chatTool' /** Opened via the "Open Integrated Browser" command without a URL argument. * This typically means the user ran the command manually from the Command Palette. */ | 'commandWithoutUrl' diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index 340a48a10c1e9..ee655808073ed 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -12,7 +12,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribution } from '../../../../common/contributions.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; -import { ILanguageModelToolsService, ToolDataSource } from '../../../chat/common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; import { BrowserEditorInput } from '../browserEditorInput.js'; import { ClickBrowserTool, ClickBrowserToolData } from './clickBrowserTool.js'; import { DragElementTool, DragElementToolData } from './dragElementTool.js'; @@ -20,6 +20,7 @@ import { HandleDialogBrowserTool, HandleDialogBrowserToolData } from './handleDi import { HoverElementTool, HoverElementToolData } from './hoverElementTool.js'; import { NavigateBrowserTool, NavigateBrowserToolData } from './navigateBrowserTool.js'; import { OpenBrowserTool, OpenBrowserToolData } from './openBrowserTool.js'; +import { OpenBrowserToolNonAgentic, OpenBrowserToolNonAgenticData } from './openBrowserToolNonAgentic.js'; import { ReadBrowserTool, ReadBrowserToolData } from './readBrowserTool.js'; import { RunPlaywrightCodeTool, RunPlaywrightCodeToolData } from './runPlaywrightCodeTool.js'; import { ScreenshotBrowserTool, ScreenshotBrowserToolData } from './screenshotBrowserTool.js'; @@ -31,6 +32,7 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench private static readonly CONTEXT_ID = 'browserView.trackedPages'; private readonly _toolsStore = this._register(new DisposableStore()); + private readonly _browserToolSet: ToolSet; private _trackedIds: ReadonlySet = new Set(); @@ -44,6 +46,16 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench ) { super(); + this._browserToolSet = this._register(this.toolsService.createToolSet( + ToolDataSource.Internal, + 'browser', + 'browser', + { + icon: Codicon.globe, + description: localize('browserToolSet.description', 'Open and interact with integrated browser pages'), + } + )); + this._updateToolRegistrations(); this._register(configurationService.onDidChangeConfiguration(e => { @@ -57,20 +69,14 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench this._toolsStore.clear(); if (!this.configurationService.getValue('workbench.browser.enableChatTools')) { + // If chat tools are disabled, we only register the non-agentic open tool, + // which allows opening browser pages without granting access to their contents. + this._toolsStore.add(this.toolsService.registerTool(OpenBrowserToolNonAgenticData, this.instantiationService.createInstance(OpenBrowserToolNonAgentic))); + this._toolsStore.add(this._browserToolSet.addTool(OpenBrowserToolNonAgenticData)); this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, []); return; } - const browserToolSet = this._toolsStore.add(this.toolsService.createToolSet( - ToolDataSource.Internal, - 'browser', - 'browser', - { - icon: Codicon.globe, - description: localize('browserToolSet.description', 'Open and interact with integrated browser pages'), - } - )); - this._toolsStore.add(this.toolsService.registerTool(OpenBrowserToolData, this.instantiationService.createInstance(OpenBrowserTool))); this._toolsStore.add(this.toolsService.registerTool(ReadBrowserToolData, this.instantiationService.createInstance(ReadBrowserTool))); this._toolsStore.add(this.toolsService.registerTool(ScreenshotBrowserToolData, this.instantiationService.createInstance(ScreenshotBrowserTool))); @@ -82,16 +88,16 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench this._toolsStore.add(this.toolsService.registerTool(RunPlaywrightCodeToolData, this.instantiationService.createInstance(RunPlaywrightCodeTool))); this._toolsStore.add(this.toolsService.registerTool(HandleDialogBrowserToolData, this.instantiationService.createInstance(HandleDialogBrowserTool))); - this._toolsStore.add(browserToolSet.addTool(OpenBrowserToolData)); - this._toolsStore.add(browserToolSet.addTool(ReadBrowserToolData)); - this._toolsStore.add(browserToolSet.addTool(ScreenshotBrowserToolData)); - this._toolsStore.add(browserToolSet.addTool(NavigateBrowserToolData)); - this._toolsStore.add(browserToolSet.addTool(ClickBrowserToolData)); - this._toolsStore.add(browserToolSet.addTool(DragElementToolData)); - this._toolsStore.add(browserToolSet.addTool(HoverElementToolData)); - this._toolsStore.add(browserToolSet.addTool(TypeBrowserToolData)); - this._toolsStore.add(browserToolSet.addTool(RunPlaywrightCodeToolData)); - this._toolsStore.add(browserToolSet.addTool(HandleDialogBrowserToolData)); + this._toolsStore.add(this._browserToolSet.addTool(OpenBrowserToolData)); + this._toolsStore.add(this._browserToolSet.addTool(ReadBrowserToolData)); + this._toolsStore.add(this._browserToolSet.addTool(ScreenshotBrowserToolData)); + this._toolsStore.add(this._browserToolSet.addTool(NavigateBrowserToolData)); + this._toolsStore.add(this._browserToolSet.addTool(ClickBrowserToolData)); + this._toolsStore.add(this._browserToolSet.addTool(DragElementToolData)); + this._toolsStore.add(this._browserToolSet.addTool(HoverElementToolData)); + this._toolsStore.add(this._browserToolSet.addTool(TypeBrowserToolData)); + this._toolsStore.add(this._browserToolSet.addTool(RunPlaywrightCodeToolData)); + this._toolsStore.add(this._browserToolSet.addTool(HandleDialogBrowserToolData)); // Publish tracked browser pages as workspace context for chat requests this.playwrightService.getTrackedPages().then(ids => { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index f0b215e0d5d97..24420493758ae 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -32,7 +32,7 @@ export const OpenBrowserToolData: IToolData = { }, }; -interface IOpenBrowserToolParams { +export interface IOpenBrowserToolParams { url: string; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts new file mode 100644 index 0000000000000..444e2a483b0a6 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { localize } from '../../../../../nls.js'; +import { logBrowserOpen } from '../../../../../platform/browserView/common/browserViewTelemetry.js'; +import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { errorResult } from './browserToolHelpers.js'; +import { IOpenBrowserToolParams, OpenBrowserToolData } from './openBrowserTool.js'; + +export const OpenBrowserToolNonAgenticData: IToolData = { + ...OpenBrowserToolData, + modelDescription: 'Open a new browser page in the integrated browser at the given URL.', +}; + +export class OpenBrowserToolNonAgentic implements IToolImpl { + constructor( + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IEditorService private readonly editorService: IEditorService, + ) { } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const params = context.parameters as IOpenBrowserToolParams; + return { + invocationMessage: localize('browser.open.nonAgentic.invocation', "Opening browser page at {0}", params.url ?? 'about:blank'), + pastTenseMessage: localize('browser.open.nonAgentic.past', "Opened browser page at {0}", params.url ?? 'about:blank'), + confirmationMessages: { + title: localize('browser.open.nonAgentic.confirmTitle', 'Open Browser Page?'), + message: localize('browser.open.nonAgentic.confirmMessage', 'This will open {0} in the integrated browser. The agent will not be able to read its contents.', params.url ?? 'about:blank'), + allowAutoConfirm: true, + }, + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as IOpenBrowserToolParams; + + if (!params.url) { + return errorResult('The "url" parameter is required.'); + } + + logBrowserOpen(this.telemetryService, 'chatTool'); + + const browserUri = BrowserViewUri.forUrl(params.url); + await this.editorService.openEditor({ resource: browserUri, options: { pinned: true } }); + + return { + content: [{ + kind: 'text', + value: `Page opened successfully. Note that you do not have access to the page contents unless the user enables agentic tools via the \`workbench.browser.enableChatTools\` setting.`, + }] + }; + } +} From 39c1dd71bf52967e5e1f62407c347c6328368b15 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 17:47:04 -0800 Subject: [PATCH 057/541] Reset cli/Cargo.lock to main Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/Cargo.lock | 69 ++------------------------------------------------ 1 file changed, 2 insertions(+), 67 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index afe353213b1b5..cd9b8de6afba6 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -447,7 +447,6 @@ dependencies = [ "uuid", "winapi", "winreg 0.50.0", - "winresource", "zbus", "zip", ] @@ -2646,15 +2645,6 @@ dependencies = [ "syn 2.0.115", ] -[[package]] -name = "serde_spanned" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3014,36 +3004,12 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.13.0", - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.14", -] - [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.19.15" @@ -3051,25 +3017,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.13.0", - "toml_datetime 0.6.11", - "winnow 0.5.40", -] - -[[package]] -name = "toml_parser" -version = "1.0.9+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" -dependencies = [ - "winnow 0.7.14", + "toml_datetime", + "winnow", ] -[[package]] -name = "toml_writer" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" - [[package]] name = "tower-service" version = "0.3.3" @@ -3748,12 +3699,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" - [[package]] name = "winreg" version = "0.8.0" @@ -3773,16 +3718,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "winresource" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e287ced0f21cd11f4035fe946fd3af145f068d1acb708afd248100f89ec7432d" -dependencies = [ - "toml", - "version_check", -] - [[package]] name = "wit-bindgen" version = "0.51.0" From 873e82d7da85d95e090acd7dd77adb95fb0f2bd7 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:48:02 -0800 Subject: [PATCH 058/541] Chat Debug Panel: Cleanup and simplify filtering (#297569) Cleanup and simplify filtering --- .../browser/chatDebug/chatDebugFilters.ts | 87 +++++-------------- .../chatDebug/chatDebugFlowChartView.ts | 14 ++- .../browser/chatDebug/chatDebugFlowGraph.ts | 10 ++- .../browser/chatDebug/chatDebugLogsView.ts | 22 ++--- .../chat/browser/chatDebug/chatDebugTypes.ts | 16 +--- 5 files changed, 47 insertions(+), 102 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts index 4a9c73eb3d602..2a82fb2fd227c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts @@ -12,12 +12,9 @@ import { CommandsRegistry } from '../../../../../platform/commands/common/comman import { viewFilterSubmenu } from '../../../../browser/parts/views/viewFilter.js'; import { CHAT_DEBUG_FILTER_ACTIVE, - CHAT_DEBUG_KIND_TOOL_CALL, CHAT_DEBUG_KIND_MODEL_TURN, CHAT_DEBUG_KIND_GENERIC, CHAT_DEBUG_KIND_SUBAGENT, - CHAT_DEBUG_KIND_USER_MESSAGE, CHAT_DEBUG_KIND_AGENT_RESPONSE, - CHAT_DEBUG_LEVEL_TRACE, CHAT_DEBUG_LEVEL_INFO, CHAT_DEBUG_LEVEL_WARNING, CHAT_DEBUG_LEVEL_ERROR, - CHAT_DEBUG_CMD_TOGGLE_TOOL_CALL, CHAT_DEBUG_CMD_TOGGLE_MODEL_TURN, CHAT_DEBUG_CMD_TOGGLE_GENERIC, - CHAT_DEBUG_CMD_TOGGLE_SUBAGENT, CHAT_DEBUG_CMD_TOGGLE_USER_MESSAGE, CHAT_DEBUG_CMD_TOGGLE_AGENT_RESPONSE, - CHAT_DEBUG_CMD_TOGGLE_TRACE, CHAT_DEBUG_CMD_TOGGLE_INFO, CHAT_DEBUG_CMD_TOGGLE_WARNING, CHAT_DEBUG_CMD_TOGGLE_ERROR, + CHAT_DEBUG_KIND_TOOL_CALL, CHAT_DEBUG_KIND_MODEL_TURN, CHAT_DEBUG_KIND_PROMPT_DISCOVERY, CHAT_DEBUG_KIND_SUBAGENT, + CHAT_DEBUG_CMD_TOGGLE_TOOL_CALL, CHAT_DEBUG_CMD_TOGGLE_MODEL_TURN, CHAT_DEBUG_CMD_TOGGLE_PROMPT_DISCOVERY, + CHAT_DEBUG_CMD_TOGGLE_SUBAGENT, } from './chatDebugTypes.js'; /** @@ -35,45 +32,38 @@ export class ChatDebugFilterState extends Disposable { // Kind visibility filterKindToolCall: boolean = true; filterKindModelTurn: boolean = true; - filterKindGeneric: boolean = true; + filterKindPromptDiscovery: boolean = true; filterKindSubagent: boolean = true; - filterKindUserMessage: boolean = true; - filterKindAgentResponse: boolean = true; - - // Level visibility - filterLevelTrace: boolean = true; - filterLevelInfo: boolean = true; - filterLevelWarning: boolean = true; - filterLevelError: boolean = true; // Text filter textFilter: string = ''; - isKindVisible(kind: string): boolean { + isKindVisible(kind: string, category?: string): boolean { switch (kind) { case 'toolCall': return this.filterKindToolCall; case 'modelTurn': return this.filterKindModelTurn; - case 'generic': return this.filterKindGeneric; + case 'generic': + // The "Prompt Discovery" toggle only hides events produced by + // the prompt discovery pipeline (category === 'discovery'). + // Other generic events (e.g. from external providers) are + // always visible and are not affected by this toggle. + if (category !== 'discovery') { + return true; + } + return this.filterKindPromptDiscovery; case 'subagentInvocation': return this.filterKindSubagent; - case 'userMessage': return this.filterKindUserMessage; - case 'agentResponse': return this.filterKindAgentResponse; + default: return true; } } isAllKindsVisible(): boolean { return this.filterKindToolCall && this.filterKindModelTurn && - this.filterKindGeneric && this.filterKindSubagent && - this.filterKindUserMessage && this.filterKindAgentResponse; - } - - isAllLevelsVisible(): boolean { - return this.filterLevelTrace && this.filterLevelInfo && - this.filterLevelWarning && this.filterLevelError; + this.filterKindPromptDiscovery && this.filterKindSubagent; } isAllFiltersDefault(): boolean { - return this.isAllKindsVisible() && this.isAllLevelsVisible(); + return this.isAllKindsVisible(); } setTextFilter(text: string): void { @@ -106,23 +96,10 @@ export function registerFilterMenuItems( kindToolCallKey.set(true); const kindModelTurnKey = CHAT_DEBUG_KIND_MODEL_TURN.bindTo(scopedContextKeyService); kindModelTurnKey.set(true); - const kindGenericKey = CHAT_DEBUG_KIND_GENERIC.bindTo(scopedContextKeyService); - kindGenericKey.set(true); + const kindPromptDiscoveryKey = CHAT_DEBUG_KIND_PROMPT_DISCOVERY.bindTo(scopedContextKeyService); + kindPromptDiscoveryKey.set(true); const kindSubagentKey = CHAT_DEBUG_KIND_SUBAGENT.bindTo(scopedContextKeyService); kindSubagentKey.set(true); - const kindUserMessageKey = CHAT_DEBUG_KIND_USER_MESSAGE.bindTo(scopedContextKeyService); - kindUserMessageKey.set(true); - const kindAgentResponseKey = CHAT_DEBUG_KIND_AGENT_RESPONSE.bindTo(scopedContextKeyService); - kindAgentResponseKey.set(true); - const levelTraceKey = CHAT_DEBUG_LEVEL_TRACE.bindTo(scopedContextKeyService); - levelTraceKey.set(true); - const levelInfoKey = CHAT_DEBUG_LEVEL_INFO.bindTo(scopedContextKeyService); - levelInfoKey.set(true); - const levelWarningKey = CHAT_DEBUG_LEVEL_WARNING.bindTo(scopedContextKeyService); - levelWarningKey.set(true); - const levelErrorKey = CHAT_DEBUG_LEVEL_ERROR.bindTo(scopedContextKeyService); - levelErrorKey.set(true); - const registerToggle = ( id: string, title: string, key: RawContextKey, group: string, getter: () => boolean, setter: (v: boolean) => void, ctxKey: IContextKey, @@ -142,15 +119,8 @@ export function registerFilterMenuItems( registerToggle(CHAT_DEBUG_CMD_TOGGLE_TOOL_CALL, localize('chatDebug.filter.toolCall', "Tool Calls"), CHAT_DEBUG_KIND_TOOL_CALL, '1_kind', () => state.filterKindToolCall, v => { state.filterKindToolCall = v; }, kindToolCallKey); registerToggle(CHAT_DEBUG_CMD_TOGGLE_MODEL_TURN, localize('chatDebug.filter.modelTurn', "Model Turns"), CHAT_DEBUG_KIND_MODEL_TURN, '1_kind', () => state.filterKindModelTurn, v => { state.filterKindModelTurn = v; }, kindModelTurnKey); - registerToggle(CHAT_DEBUG_CMD_TOGGLE_GENERIC, localize('chatDebug.filter.generic', "Generic"), CHAT_DEBUG_KIND_GENERIC, '1_kind', () => state.filterKindGeneric, v => { state.filterKindGeneric = v; }, kindGenericKey); + registerToggle(CHAT_DEBUG_CMD_TOGGLE_PROMPT_DISCOVERY, localize('chatDebug.filter.promptDiscovery', "Prompt Discovery"), CHAT_DEBUG_KIND_PROMPT_DISCOVERY, '1_kind', () => state.filterKindPromptDiscovery, v => { state.filterKindPromptDiscovery = v; }, kindPromptDiscoveryKey); registerToggle(CHAT_DEBUG_CMD_TOGGLE_SUBAGENT, localize('chatDebug.filter.subagent', "Subagent Invocations"), CHAT_DEBUG_KIND_SUBAGENT, '1_kind', () => state.filterKindSubagent, v => { state.filterKindSubagent = v; }, kindSubagentKey); - registerToggle(CHAT_DEBUG_CMD_TOGGLE_USER_MESSAGE, localize('chatDebug.filter.userMessage', "User Messages"), CHAT_DEBUG_KIND_USER_MESSAGE, '1_kind', () => state.filterKindUserMessage, v => { state.filterKindUserMessage = v; }, kindUserMessageKey); - registerToggle(CHAT_DEBUG_CMD_TOGGLE_AGENT_RESPONSE, localize('chatDebug.filter.agentResponse', "Agent Responses"), CHAT_DEBUG_KIND_AGENT_RESPONSE, '1_kind', () => state.filterKindAgentResponse, v => { state.filterKindAgentResponse = v; }, kindAgentResponseKey); - - registerToggle(CHAT_DEBUG_CMD_TOGGLE_TRACE, localize('chatDebug.filter.trace', "Trace"), CHAT_DEBUG_LEVEL_TRACE, '2_level', () => state.filterLevelTrace, v => { state.filterLevelTrace = v; }, levelTraceKey); - registerToggle(CHAT_DEBUG_CMD_TOGGLE_INFO, localize('chatDebug.filter.info', "Info"), CHAT_DEBUG_LEVEL_INFO, '2_level', () => state.filterLevelInfo, v => { state.filterLevelInfo = v; }, levelInfoKey); - registerToggle(CHAT_DEBUG_CMD_TOGGLE_WARNING, localize('chatDebug.filter.warning', "Warning"), CHAT_DEBUG_LEVEL_WARNING, '2_level', () => state.filterLevelWarning, v => { state.filterLevelWarning = v; }, levelWarningKey); - registerToggle(CHAT_DEBUG_CMD_TOGGLE_ERROR, localize('chatDebug.filter.error', "Error"), CHAT_DEBUG_LEVEL_ERROR, '2_level', () => state.filterLevelError, v => { state.filterLevelError = v; }, levelErrorKey); return store; } @@ -166,25 +136,12 @@ export function bindFilterContextKeys( CHAT_DEBUG_FILTER_ACTIVE.bindTo(scopedContextKeyService).set(true); const kindToolCallKey = CHAT_DEBUG_KIND_TOOL_CALL.bindTo(scopedContextKeyService); const kindModelTurnKey = CHAT_DEBUG_KIND_MODEL_TURN.bindTo(scopedContextKeyService); - const kindGenericKey = CHAT_DEBUG_KIND_GENERIC.bindTo(scopedContextKeyService); + const kindPromptDiscoveryKey = CHAT_DEBUG_KIND_PROMPT_DISCOVERY.bindTo(scopedContextKeyService); const kindSubagentKey = CHAT_DEBUG_KIND_SUBAGENT.bindTo(scopedContextKeyService); - const kindUserMessageKey = CHAT_DEBUG_KIND_USER_MESSAGE.bindTo(scopedContextKeyService); - const kindAgentResponseKey = CHAT_DEBUG_KIND_AGENT_RESPONSE.bindTo(scopedContextKeyService); - const levelTraceKey = CHAT_DEBUG_LEVEL_TRACE.bindTo(scopedContextKeyService); - const levelInfoKey = CHAT_DEBUG_LEVEL_INFO.bindTo(scopedContextKeyService); - const levelWarningKey = CHAT_DEBUG_LEVEL_WARNING.bindTo(scopedContextKeyService); - const levelErrorKey = CHAT_DEBUG_LEVEL_ERROR.bindTo(scopedContextKeyService); - return () => { kindToolCallKey.set(state.filterKindToolCall); kindModelTurnKey.set(state.filterKindModelTurn); - kindGenericKey.set(state.filterKindGeneric); + kindPromptDiscoveryKey.set(state.filterKindPromptDiscovery); kindSubagentKey.set(state.filterKindSubagent); - kindUserMessageKey.set(state.filterKindUserMessage); - kindAgentResponseKey.set(state.filterKindAgentResponse); - levelTraceKey.set(state.filterLevelTrace); - levelInfoKey.set(state.filterLevelInfo); - levelWarningKey.set(state.filterLevelWarning); - levelErrorKey.set(state.filterLevelError); }; } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts index d923f5e4a4135..334d5d0456785 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts @@ -224,7 +224,7 @@ export class ChatDebugFlowChartView extends Disposable { // Build, filter, slice, and render the flow chart const flowNodes = buildFlowGraph(events); const filtered = filterFlowNodes(flowNodes, { - isKindVisible: kind => this.filterState.isKindVisible(kind), + isKindVisible: (kind, category) => this.filterState.isKindVisible(kind, category), textFilter: this.filterState.textFilter, }); @@ -541,9 +541,17 @@ export class ChatDebugFlowChartView extends Disposable { } const svgWidth = parseFloat(this.svgElement.getAttribute('width') || '0'); const svgHeight = parseFloat(this.svgElement.getAttribute('height') || '0'); + if (svgWidth <= 0 || svgHeight <= 0) { + return; + } - this.translateX = (containerRect.width - svgWidth) / 2; - this.translateY = Math.max(20, (containerRect.height - svgHeight) / 2); + const PADDING = 20; + // Pin the top of the diagram near the top of the viewport so the start + // of the flow is immediately visible. Center horizontally when the + // diagram fits; otherwise align to the left edge with padding so + // nothing is clipped behind overflow:hidden. + this.translateX = Math.max(PADDING, (containerRect.width - svgWidth) / 2); + this.translateY = PADDING; this.applyTransform(); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts index 41125ffd836a5..b2cf11e212980 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts @@ -10,6 +10,8 @@ import { IChatDebugEvent } from '../../common/chatDebugService.js'; export interface FlowNode { readonly id: string; readonly kind: IChatDebugEvent['kind']; + /** For `generic` nodes: the event category (e.g. `'discovery'`). Used to narrow filtering. */ + readonly category?: string; readonly label: string; readonly sublabel?: string; readonly description?: string; @@ -20,7 +22,7 @@ export interface FlowNode { } export interface FlowFilterOptions { - readonly isKindVisible: (kind: string) => boolean; + readonly isKindVisible: (kind: string, category?: string) => boolean; readonly textFilter: string; } @@ -179,6 +181,7 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { return { id: event.id ?? `event-${events.indexOf(event)}`, kind: event.kind, + category: event.kind === 'generic' ? event.category : undefined, label: getEventLabel(event), sublabel, description, @@ -277,11 +280,11 @@ export function filterFlowNodes(nodes: FlowNode[], options: FlowFilterOptions): return result; } -function filterByKind(nodes: FlowNode[], isKindVisible: (kind: string) => boolean): FlowNode[] { +function filterByKind(nodes: FlowNode[], isKindVisible: (kind: string, category?: string) => boolean): FlowNode[] { const result: FlowNode[] = []; let changed = false; for (const node of nodes) { - if (!isKindVisible(node.kind)) { + if (!isKindVisible(node.kind, node.category)) { changed = true; // For subagents, drop the entire subgraph if (node.kind === 'subagentInvocation') { @@ -302,6 +305,7 @@ function filterByKind(nodes: FlowNode[], isKindVisible: (kind: string) => boolea return changed ? result : nodes; } + function nodeMatchesText(node: FlowNode, text: string): boolean { return node.label.toLowerCase().includes(text) || (node.sublabel?.toLowerCase().includes(text) ?? false) || diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index 2e6a725b8e6e4..d85996c888d9f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -21,7 +21,7 @@ import { ServiceCollection } from '../../../../../platform/instantiation/common/ import { WorkbenchList, WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { FilterWidget } from '../../../../browser/parts/views/viewFilter.js'; -import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; +import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { ChatDebugEventRenderer, ChatDebugEventDelegate, ChatDebugEventTreeRenderer } from './chatDebugEventList.js'; @@ -290,23 +290,11 @@ export class ChatDebugLogsView extends Disposable { refreshList(): void { let filtered = this.events; - // Filter by kind toggles - filtered = filtered.filter(e => this.filterState.isKindVisible(e.kind)); - - // Filter by level toggles + // Filter by kind toggles (pass category for generic events so only + // discovery-category events are affected by the Prompt Discovery toggle) filtered = filtered.filter(e => { - if (e.kind === 'generic') { - switch (e.level) { - case ChatDebugLogLevel.Trace: return this.filterState.filterLevelTrace; - case ChatDebugLogLevel.Info: return this.filterState.filterLevelInfo; - case ChatDebugLogLevel.Warning: return this.filterState.filterLevelWarning; - case ChatDebugLogLevel.Error: return this.filterState.filterLevelError; - } - } - if (e.kind === 'toolCall' && e.result === 'error') { - return this.filterState.filterLevelError; - } - return true; + const category = e.kind === 'generic' ? e.category : undefined; + return this.filterState.isKindVisible(e.kind, category); }); // Filter by text search diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts index aab9e46a9383c..c34697347125e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts @@ -36,26 +36,14 @@ export const enum LogsViewMode { export const CHAT_DEBUG_FILTER_ACTIVE = new RawContextKey('chatDebugFilterActive', false); export const CHAT_DEBUG_KIND_TOOL_CALL = new RawContextKey('chatDebug.kindToolCall', true); export const CHAT_DEBUG_KIND_MODEL_TURN = new RawContextKey('chatDebug.kindModelTurn', true); -export const CHAT_DEBUG_KIND_GENERIC = new RawContextKey('chatDebug.kindGeneric', true); +export const CHAT_DEBUG_KIND_PROMPT_DISCOVERY = new RawContextKey('chatDebug.kindPromptDiscovery', true); export const CHAT_DEBUG_KIND_SUBAGENT = new RawContextKey('chatDebug.kindSubagent', true); -export const CHAT_DEBUG_KIND_USER_MESSAGE = new RawContextKey('chatDebug.kindUserMessage', true); -export const CHAT_DEBUG_KIND_AGENT_RESPONSE = new RawContextKey('chatDebug.kindAgentResponse', true); -export const CHAT_DEBUG_LEVEL_TRACE = new RawContextKey('chatDebug.levelTrace', true); -export const CHAT_DEBUG_LEVEL_INFO = new RawContextKey('chatDebug.levelInfo', true); -export const CHAT_DEBUG_LEVEL_WARNING = new RawContextKey('chatDebug.levelWarning', true); -export const CHAT_DEBUG_LEVEL_ERROR = new RawContextKey('chatDebug.levelError', true); // Filter toggle command IDs export const CHAT_DEBUG_CMD_TOGGLE_TOOL_CALL = 'chatDebug.filter.toggleToolCall'; export const CHAT_DEBUG_CMD_TOGGLE_MODEL_TURN = 'chatDebug.filter.toggleModelTurn'; -export const CHAT_DEBUG_CMD_TOGGLE_GENERIC = 'chatDebug.filter.toggleGeneric'; +export const CHAT_DEBUG_CMD_TOGGLE_PROMPT_DISCOVERY = 'chatDebug.filter.togglePromptDiscovery'; export const CHAT_DEBUG_CMD_TOGGLE_SUBAGENT = 'chatDebug.filter.toggleSubagent'; -export const CHAT_DEBUG_CMD_TOGGLE_USER_MESSAGE = 'chatDebug.filter.toggleUserMessage'; -export const CHAT_DEBUG_CMD_TOGGLE_AGENT_RESPONSE = 'chatDebug.filter.toggleAgentResponse'; -export const CHAT_DEBUG_CMD_TOGGLE_TRACE = 'chatDebug.filter.toggleTrace'; -export const CHAT_DEBUG_CMD_TOGGLE_INFO = 'chatDebug.filter.toggleInfo'; -export const CHAT_DEBUG_CMD_TOGGLE_WARNING = 'chatDebug.filter.toggleWarning'; -export const CHAT_DEBUG_CMD_TOGGLE_ERROR = 'chatDebug.filter.toggleError'; export class TextBreadcrumbItem extends BreadcrumbsItem { constructor( From f3a3ccca51b3960af1cfaab1b8b34da88e9d9e1a Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 24 Feb 2026 17:50:53 -0800 Subject: [PATCH 059/541] Refactor section selection handling in AICustomizationManagementActionsContribution for improved type safety --- .../aiCustomization/aiCustomizationManagement.contribution.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index ef47809a663e3..faa022a5e4666 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -302,8 +302,8 @@ class AICustomizationManagementActionsContribution extends Disposable implements const editorService = accessor.get(IEditorService); const input = AICustomizationManagementEditorInput.getOrCreate(); const pane = await editorService.openEditor(input, { pinned: true }, MODAL_GROUP); - if (section && pane && typeof (pane as unknown as Record).selectSectionById === 'function') { - (pane as unknown as { selectSectionById(s: AICustomizationManagementSection): void }).selectSectionById(section); + if (section && pane instanceof AICustomizationManagementEditor) { + pane.selectSectionById(section); } } })); From d57d4ebe7c1bb4a322a66b714623997fb86c9666 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 24 Feb 2026 20:51:36 -0800 Subject: [PATCH 060/541] Agent debug panel UI cleanup (#297571) --- ... => chatCustomizationDiscoveryRenderer.ts} | 4 +- .../browser/chatDebug/chatDebugCollapsible.ts | 71 ++++++++++++++++++ .../browser/chatDebug/chatDebugDetailPanel.ts | 19 +++-- .../browser/chatDebug/chatDebugHomeView.ts | 8 +- .../browser/chatDebug/chatDebugLogsView.ts | 2 +- .../chatDebugMessageContentRenderer.ts | 73 +------------------ .../chatDebug/chatDebugOverviewView.ts | 10 +-- .../browser/chatDebug/media/chatDebug.css | 43 ++++++----- 8 files changed, 114 insertions(+), 116 deletions(-) rename src/vs/workbench/contrib/chat/browser/chatDebug/{chatDebugFileListRenderer.ts => chatCustomizationDiscoveryRenderer.ts} (97%) create mode 100644 src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCollapsible.ts diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFileListRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatCustomizationDiscoveryRenderer.ts similarity index 97% rename from src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFileListRenderer.ts rename to src/vs/workbench/contrib/chat/browser/chatDebug/chatCustomizationDiscoveryRenderer.ts index d1d1756523b55..6d4c0f40740ad 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFileListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatCustomizationDiscoveryRenderer.ts @@ -22,7 +22,7 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { IChatDebugEventFileListContent } from '../../common/chatDebugService.js'; import { InlineAnchorWidget } from '../widget/chatContentParts/chatInlineAnchorWidget.js'; -import { setupCollapsibleToggle } from './chatDebugMessageContentRenderer.js'; +import { setupCollapsibleToggle } from './chatDebugCollapsible.js'; const $ = DOM.$; @@ -153,7 +153,7 @@ function appendLocationBadge(row: HTMLElement, file: { extensionId?: string }, b /** * Render a file list resolved content as a rich HTML element. */ -export function renderFileListContent(content: IChatDebugEventFileListContent, openerService: IOpenerService, modelService: IModelService, languageService: ILanguageService, hoverService: IHoverService, labelService: ILabelService): { element: HTMLElement; disposables: DisposableStore } { +export function renderCustomizationDiscoveryContent(content: IChatDebugEventFileListContent, openerService: IOpenerService, modelService: IModelService, languageService: ILanguageService, hoverService: IHoverService, labelService: ILabelService): { element: HTMLElement; disposables: DisposableStore } { const disposables = new DisposableStore(); const container = $('div.chat-debug-file-list'); container.tabIndex = 0; diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCollapsible.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCollapsible.ts new file mode 100644 index 0000000000000..afbe701f809d1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugCollapsible.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { IChatDebugMessageSection } from '../../common/chatDebugService.js'; + +const $ = DOM.$; + +/** + * Wire up a collapsible toggle on a chevron+header+content triple. + * Handles icon switching and display toggling. + */ +export function setupCollapsibleToggle(chevron: HTMLElement, header: HTMLElement, contentEl: HTMLElement, disposables: DisposableStore, initiallyCollapsed: boolean = false): void { + let collapsed = initiallyCollapsed; + + // Accessibility: make header keyboard-focusable and expose toggle semantics + header.tabIndex = 0; + header.role = 'button'; + chevron.setAttribute('aria-hidden', 'true'); + + const updateState = () => { + DOM.clearNode(chevron); + const icon = collapsed ? Codicon.chevronRight : Codicon.chevronDown; + chevron.classList.add(...ThemeIcon.asClassName(icon).split(' ')); + contentEl.style.display = collapsed ? 'none' : 'block'; + header.style.borderRadius = collapsed ? '' : '3px 3px 0 0'; + header.setAttribute('aria-expanded', String(!collapsed)); + }; + + updateState(); + + disposables.add(DOM.addDisposableListener(header, DOM.EventType.CLICK, () => { + collapsed = !collapsed; + chevron.className = 'chat-debug-message-section-chevron'; + updateState(); + })); + + disposables.add(DOM.addDisposableListener(header, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + header.click(); + } + })); +} + +/** + * Render a collapsible section with a clickable header and pre-formatted content + * wrapped in a scrollable element. + */ +export function renderCollapsibleSection(parent: HTMLElement, section: IChatDebugMessageSection, disposables: DisposableStore, initiallyCollapsed: boolean = false): void { + const sectionEl = DOM.append(parent, $('div.chat-debug-message-section')); + + const header = DOM.append(sectionEl, $('div.chat-debug-message-section-header')); + + const chevron = DOM.append(header, $(`span.chat-debug-message-section-chevron`)); + DOM.append(header, $('span.chat-debug-message-section-title', undefined, section.name)); + + const contentEl = $('pre.chat-debug-message-section-content'); + contentEl.textContent = section.content; + contentEl.tabIndex = 0; + + const wrapper = DOM.append(sectionEl, $('div.chat-debug-message-section-content-wrapper')); + wrapper.appendChild(contentEl); + + setupCollapsibleToggle(chevron, header, wrapper, disposables, initiallyCollapsed); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts index 70ab35f071ec7..70860d47632a8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts @@ -20,7 +20,7 @@ import { IUntitledTextResourceEditorInput } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; import { formatEventDetail } from './chatDebugEventDetailRenderer.js'; -import { renderFileListContent, fileListToPlainText } from './chatDebugFileListRenderer.js'; +import { renderCustomizationDiscoveryContent, fileListToPlainText } from './chatCustomizationDiscoveryRenderer.js'; import { renderUserMessageContent, renderAgentResponseContent, messageEventToPlainText, renderResolvedMessageContent, resolvedMessageToPlainText } from './chatDebugMessageContentRenderer.js'; const $ = DOM.$; @@ -36,6 +36,7 @@ export class ChatDebugDetailPanel extends Disposable { readonly onDidHide = this._onDidHide.event; readonly element: HTMLElement; + private readonly contentContainer: HTMLElement; private readonly detailDisposables = this._register(new DisposableStore()); private currentDetailText: string = ''; private currentDetailEventId: string | undefined; @@ -51,6 +52,7 @@ export class ChatDebugDetailPanel extends Disposable { ) { super(); this.element = DOM.append(parent, $('.chat-debug-detail-panel')); + this.contentContainer = $('.chat-debug-detail-content'); DOM.hide(this.element); // Handle Ctrl+A / Cmd+A to select all within the detail panel @@ -83,10 +85,12 @@ export class ChatDebugDetailPanel extends Disposable { DOM.show(this.element); DOM.clearNode(this.element); + DOM.clearNode(this.contentContainer); this.detailDisposables.clear(); // Header with action buttons const header = DOM.append(this.element, $('.chat-debug-detail-header')); + this.element.appendChild(this.contentContainer); const fullScreenButton = this.detailDisposables.add(new Button(header, { ariaLabel: localize('chatDebug.openInEditor', "Open in Editor"), title: localize('chatDebug.openInEditor', "Open in Editor") })); fullScreenButton.element.classList.add('chat-debug-detail-button'); @@ -112,27 +116,27 @@ export class ChatDebugDetailPanel extends Disposable { if (resolved && resolved.kind === 'fileList') { this.currentDetailText = fileListToPlainText(resolved); const { element: contentEl, disposables: contentDisposables } = this.instantiationService.invokeFunction(accessor => - renderFileListContent(resolved, this.openerService, accessor.get(IModelService), accessor.get(ILanguageService), this.hoverService, accessor.get(ILabelService)) + renderCustomizationDiscoveryContent(resolved, this.openerService, accessor.get(IModelService), accessor.get(ILanguageService), this.hoverService, accessor.get(ILabelService)) ); this.detailDisposables.add(contentDisposables); - this.element.appendChild(contentEl); + this.contentContainer.appendChild(contentEl); } else if (resolved && resolved.kind === 'message') { this.currentDetailText = resolvedMessageToPlainText(resolved); const { element: contentEl, disposables: contentDisposables } = renderResolvedMessageContent(resolved); this.detailDisposables.add(contentDisposables); - this.element.appendChild(contentEl); + this.contentContainer.appendChild(contentEl); } else if (event.kind === 'userMessage') { this.currentDetailText = messageEventToPlainText(event); const { element: contentEl, disposables: contentDisposables } = renderUserMessageContent(event); this.detailDisposables.add(contentDisposables); - this.element.appendChild(contentEl); + this.contentContainer.appendChild(contentEl); } else if (event.kind === 'agentResponse') { this.currentDetailText = messageEventToPlainText(event); const { element: contentEl, disposables: contentDisposables } = renderAgentResponseContent(event); this.detailDisposables.add(contentDisposables); - this.element.appendChild(contentEl); + this.contentContainer.appendChild(contentEl); } else { - const pre = DOM.append(this.element, $('pre')); + const pre = DOM.append(this.contentContainer, $('pre')); pre.tabIndex = 0; if (resolved) { this.currentDetailText = resolved.value; @@ -147,6 +151,7 @@ export class ChatDebugDetailPanel extends Disposable { this.currentDetailEventId = undefined; DOM.hide(this.element); DOM.clearNode(this.element); + DOM.clearNode(this.contentContainer); this.detailDisposables.clear(); this._onDidHide.fire(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts index b109f9feb1686..5f177aa9e21b0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../../base/browser/dom.js'; -import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; @@ -26,7 +25,6 @@ export class ChatDebugHomeView extends Disposable { readonly container: HTMLElement; private readonly scrollContent: HTMLElement; - private readonly scrollable: DomScrollableElement; private readonly renderDisposables = this._register(new DisposableStore()); constructor( @@ -37,9 +35,7 @@ export class ChatDebugHomeView extends Disposable { ) { super(); this.container = DOM.append(parent, $('.chat-debug-home')); - this.scrollContent = $('div.chat-debug-home-content'); - this.scrollable = this._register(new DomScrollableElement(this.scrollContent, {})); - DOM.append(this.container, this.scrollable.getDomNode()); + this.scrollContent = DOM.append(this.container, $('div.chat-debug-home-content')); } show(): void { @@ -159,7 +155,5 @@ export class ChatDebugHomeView extends Disposable { } })); } - - this.scrollable.scanDomNode(); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index d85996c888d9f..5fa4cde6b6f52 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -110,7 +110,7 @@ export class ChatDebugLogsView extends Disposable { // View mode toggle this.viewModeToggle = this._register(new Button(this.headerContainer, { ...defaultButtonStyles, secondary: true, title: localize('chatDebug.toggleViewMode', "Toggle between list and tree view") })); - this.viewModeToggle.element.classList.add('chat-debug-view-mode-toggle'); + this.viewModeToggle.element.classList.add('chat-debug-view-mode-toggle', 'monaco-text-button'); this.updateViewModeToggle(); this._register(this.viewModeToggle.onDidClick(() => { this.toggleViewMode(); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugMessageContentRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugMessageContentRenderer.ts index 9d2b717a8e098..c06be60cbaae7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugMessageContentRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugMessageContentRenderer.ts @@ -4,82 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../../base/browser/dom.js'; -import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize } from '../../../../../nls.js'; -import { IChatDebugMessageSection, IChatDebugUserMessageEvent, IChatDebugAgentResponseEvent, IChatDebugEventMessageContent } from '../../common/chatDebugService.js'; +import { IChatDebugUserMessageEvent, IChatDebugAgentResponseEvent, IChatDebugEventMessageContent } from '../../common/chatDebugService.js'; +import { renderCollapsibleSection } from './chatDebugCollapsible.js'; const $ = DOM.$; -/** - * Wire up a collapsible toggle on a chevron+header+content triple. - * Handles icon switching and display toggling. - */ -export function setupCollapsibleToggle(chevron: HTMLElement, header: HTMLElement, contentEl: HTMLElement, disposables: DisposableStore, initiallyCollapsed: boolean = false): void { - let collapsed = initiallyCollapsed; - - // Accessibility: make header keyboard-focusable and expose toggle semantics - header.tabIndex = 0; - header.role = 'button'; - chevron.setAttribute('aria-hidden', 'true'); - - const updateState = () => { - DOM.clearNode(chevron); - const icon = collapsed ? Codicon.chevronRight : Codicon.chevronDown; - chevron.classList.add(...ThemeIcon.asClassName(icon).split(' ')); - contentEl.style.display = collapsed ? 'none' : 'block'; - header.style.borderRadius = collapsed ? '' : '3px 3px 0 0'; - header.setAttribute('aria-expanded', String(!collapsed)); - }; - - updateState(); - - disposables.add(DOM.addDisposableListener(header, DOM.EventType.CLICK, () => { - collapsed = !collapsed; - chevron.className = 'chat-debug-message-section-chevron'; - updateState(); - })); - - disposables.add(DOM.addDisposableListener(header, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - header.click(); - } - })); -} - -/** - * Render a collapsible section with a clickable header and pre-formatted content. - */ -function renderCollapsibleSection(parent: HTMLElement, section: IChatDebugMessageSection, disposables: DisposableStore, initiallyCollapsed: boolean = false): void { - const sectionEl = DOM.append(parent, $('div.chat-debug-message-section')); - - const header = DOM.append(sectionEl, $('div.chat-debug-message-section-header')); - - const chevron = DOM.append(header, $(`span.chat-debug-message-section-chevron`)); - DOM.append(header, $('span.chat-debug-message-section-title', undefined, section.name)); - - const contentEl = $('pre.chat-debug-message-section-content'); - contentEl.textContent = section.content; - contentEl.tabIndex = 0; - - const scrollable = new DomScrollableElement(contentEl, {}); - disposables.add(scrollable); - - const wrapper = scrollable.getDomNode(); - wrapper.classList.add('chat-debug-message-section-content-wrapper'); - DOM.append(sectionEl, wrapper); - - setupCollapsibleToggle(chevron, header, wrapper, disposables, initiallyCollapsed); - - // Scan after toggle so scrollbar dimensions are correct when expanded - disposables.add(DOM.addDisposableListener(header, DOM.EventType.CLICK, () => { - scrollable.scanDomNode(); - })); -} - /** * Render a user message event with collapsible prompt sections. */ diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts index d64ed954f2612..6b9b2773b53d3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts @@ -6,7 +6,6 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; -import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; @@ -37,7 +36,6 @@ export class ChatDebugOverviewView extends Disposable { readonly container: HTMLElement; private readonly content: HTMLElement; - private readonly scrollable: DomScrollableElement; private readonly breadcrumbWidget: BreadcrumbsWidget; private readonly loadDisposables = this._register(new DisposableStore()); @@ -71,12 +69,7 @@ export class ChatDebugOverviewView extends Disposable { } })); - this.content = $('.chat-debug-overview-content'); - this.scrollable = this._register(new DomScrollableElement(this.content, {})); - const scrollDom = this.scrollable.getDomNode(); - scrollDom.style.flex = '1'; - scrollDom.style.minHeight = '0'; - DOM.append(this.container, scrollDom); + this.content = DOM.append(this.container, $('.chat-debug-overview-content')); } setSession(sessionResource: URI): void { @@ -152,7 +145,6 @@ export class ChatDebugOverviewView extends Disposable { const events = this.chatDebugService.getEvents(this.currentSessionResource); this.renderDerivedOverview(events, this.isFirstLoad); this.isFirstLoad = false; - this.scrollable.scanDomNode(); } private renderSessionDetails(sessionUri: URI): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css index 8eee2975cf60b..5e70e6491f7b2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -21,6 +21,9 @@ flex-direction: column; align-items: center; padding: 48px 24px; + flex: 1; + min-height: 0; + overflow-y: auto; } .chat-debug-home-title { font-size: 18px; @@ -61,6 +64,13 @@ .chat-debug-home-session-item:hover { background: var(--vscode-list-hoverBackground); } +.chat-debug-home-session-item:disabled { + cursor: default; + opacity: 0.7; +} +.chat-debug-home-session-item:disabled:hover { + background: transparent; +} .chat-debug-home-session-item-title { flex: 1; overflow: hidden; @@ -132,6 +142,9 @@ } .chat-debug-overview-content { padding: 16px 24px; + flex: 1; + min-height: 0; + overflow-y: auto; } .chat-debug-overview-title-row { display: flex; @@ -271,18 +284,14 @@ flex: 1; max-width: 500px; } +.chat-debug-editor-header .viewpane-filter-container .monaco-inputbox { + border-color: var(--vscode-panelInput-border, transparent) !important; +} .chat-debug-view-mode-toggle.monaco-button { width: auto; display: inline-flex; align-items: center; gap: 6px; - padding: 4px 8px; - border-radius: 2px; - font-size: 12px; - cursor: pointer; -} -.chat-debug-view-mode-toggle.monaco-button:focus { - border-color: var(--vscode-focusBorder); } .chat-debug-view-mode-labels { display: grid; @@ -343,7 +352,6 @@ align-items: center; padding: 0 16px; height: 28px; - border-bottom: 1px solid var(--vscode-widget-border, transparent); font-size: 12px; } .chat-debug-log-row .chat-debug-log-created { @@ -351,6 +359,10 @@ flex-shrink: 0; color: var(--vscode-descriptionForeground); } +.monaco-list:focus .monaco-list-row.selected .chat-debug-log-created, +.monaco-list:focus .monaco-list-row.focused .chat-debug-log-created { + color: inherit; +} .chat-debug-log-row .chat-debug-log-name { width: 200px; flex-shrink: 0; @@ -415,7 +427,7 @@ border-left: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); border-top: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); box-shadow: -6px 0 6px -6px var(--vscode-widget-shadow); - background: var(--vscode-editorWidget-background); + background: var(--vscode-panel-background); font-size: 12px; position: relative; overflow: hidden; @@ -442,9 +454,10 @@ opacity: 1; background: var(--vscode-toolbar-hoverBackground); } -.chat-debug-detail-panel > pre { +.chat-debug-detail-content { flex: 1; min-height: 0; + overflow-y: auto; } .chat-debug-detail-panel pre { margin: 0; @@ -462,10 +475,6 @@ } /* ---- File List Content ---- */ -.chat-debug-detail-panel > .chat-debug-file-list { - flex: 1; - min-height: 0; -} .chat-debug-file-list { padding: 8px 16px; font-size: 12px; @@ -553,10 +562,6 @@ } /* ---- Message Content (User Message / Agent Response) ---- */ -.chat-debug-detail-panel > .chat-debug-message-content { - flex: 1; - min-height: 0; -} .chat-debug-message-content { padding: 8px 16px; font-size: 12px; @@ -620,7 +625,7 @@ .chat-debug-message-section-content-wrapper { border-top: 1px solid var(--vscode-widget-border, transparent); max-height: 300px; - overflow: hidden; + overflow-y: auto; } .chat-debug-message-section-content { margin: 0; From 32659feaf90de5ddc728ccb785c4ceb6fc5e9e08 Mon Sep 17 00:00:00 2001 From: Jainam Patel <59076689+jainampatel27@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:41:19 +0530 Subject: [PATCH 061/541] Fix spelling errors in nls.localize strings in extensions activation events (#297378) --- .../services/extensions/common/extensionsRegistry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index 6704355abae66..2ea95cdb8c41a 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -266,7 +266,7 @@ export const schema: IJSONSchema = { defaultSnippets: [ { label: 'onWebviewPanel', - description: nls.localize('vscode.extension.activationEvents.onWebviewPanel', 'An activation event emmited when a webview is loaded of a certain viewType'), + description: nls.localize('vscode.extension.activationEvents.onWebviewPanel', 'An activation event emitted when a webview is loaded of a certain viewType'), body: 'onWebviewPanel:viewType' }, { @@ -421,7 +421,7 @@ export const schema: IJSONSchema = { }, { label: 'onMcpCollection', - description: nls.localize('vscode.extension.activationEvents.onMcpCollection', 'An activation event emitted whenver a tool from the MCP server is requested.'), + description: nls.localize('vscode.extension.activationEvents.onMcpCollection', 'An activation event emitted whenever a tool from the MCP server is requested.'), body: 'onMcpCollection:${2:collectionId}', }, { From b57baf84e3daef345e310599cd056f838f30249e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 00:12:27 -0600 Subject: [PATCH 062/541] make question markdown string (#297515) --- .../chatQuestionCarouselPart.ts | 12 +++++------- .../chatQuestionCarouselPart.test.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 15170e05679de..13931c98e94f8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -498,8 +498,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent title.setAttribute('aria-label', messageContent); - if (isMarkdownString(questionText)) { - const renderedTitle = questionRenderStore.add(this._markdownRendererService.render(MarkdownString.lift(questionText))); + if (question.message !== undefined) { + const messageMd = isMarkdownString(questionText) ? MarkdownString.lift(questionText) : new MarkdownString(questionText); + const renderedTitle = questionRenderStore.add(this._markdownRendererService.render(messageMd)); title.appendChild(renderedTitle.element); } else { // Check for subtitle in parentheses at the end @@ -1224,11 +1225,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } private getQuestionText(questionText: string | IMarkdownString): string { - if (typeof questionText === 'string') { - return questionText; - } - - return renderAsPlaintext(questionText); + const md = typeof questionText === 'string' ? new MarkdownString(questionText) : questionText; + return renderAsPlaintext(md); } hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 2d91102c1e1b0..65d42852f534d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -105,6 +105,25 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(link, 'markdown link should render as anchor'); }); + test('renders markdown in plain string question message', () => { + const carousel = createMockCarousel([ + { + id: 'q1', + type: 'text', + title: 'Question', + message: 'Please review **details** in [docs](https://example.com)' + } + ]); + createWidget(carousel); + + const title = widget.domNode.querySelector('.chat-question-title'); + assert.ok(title, 'title element should exist'); + assert.ok(title?.querySelector('.rendered-markdown'), 'markdown content should be rendered for plain string messages'); + assert.strictEqual(title?.textContent?.includes('**details**'), false, 'markdown syntax should not be shown as raw text'); + const link = title?.querySelector('a') as HTMLAnchorElement | null; + assert.ok(link, 'markdown link should render as anchor'); + }); + test('renders progress indicator correctly', () => { const carousel = createMockCarousel([ { id: 'q1', type: 'text', title: 'Question 1', message: 'Question 1' }, From cf6e184fbe50f168fc5cd44d243e83bb59be8fd2 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 00:12:35 -0600 Subject: [PATCH 063/541] tighten tip `when` conditions, spilt out `/create` commands (#297555) --- .../contrib/chat/browser/chatTipService.ts | 183 ++++++++++++------ .../createSlashCommandsUsageTracker.ts | 9 +- .../chat/common/chatService/chatService.ts | 2 +- .../common/chatService/chatServiceImpl.ts | 4 +- .../chat/test/browser/chatTipService.test.ts | 142 +++++++++----- .../common/chatService/mockChatService.ts | 2 +- 6 files changed, 222 insertions(+), 120 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 9c78ef90cd9a5..1c82947166de0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -25,6 +25,7 @@ import { IChatService } from '../common/chatService/chatService.js'; import { CreateSlashCommandsUsageTracker } from './createSlashCommandsUsageTracker.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, IParsedChatRequest } from '../common/requestParser/chatParserTypes.js'; type ChatTipEvent = { tipId: string; @@ -40,6 +41,12 @@ type ChatTipClassification = { comment: 'Tracks user interactions with chat tips to understand which tips resonate and which are dismissed.'; }; +const ATTACH_FILES_REFERENCE_TRACKING_COMMAND = 'chat.tips.attachFiles.referenceUsed'; +const CREATE_INSTRUCTION_TRACKING_COMMAND = 'chat.tips.createInstruction.commandUsed'; +const CREATE_PROMPT_TRACKING_COMMAND = 'chat.tips.createPrompt.commandUsed'; +const CREATE_AGENT_TRACKING_COMMAND = 'chat.tips.createAgent.commandUsed'; +const CREATE_SKILL_TRACKING_COMMAND = 'chat.tips.createSkill.commandUsed'; + export const IChatTipService = createDecorator('chatTipService'); export interface IChatTip { @@ -182,26 +189,55 @@ const TIP_CATALOG: ITipDefinition[] = [ onlyWhenModelIds: ['gpt-4.1'], }, { - id: 'tip.createSlashCommands', + id: 'tip.createInstruction', message: localize( - 'tip.createSlashCommands', - "Tip: Use [/create-instruction](command:workbench.action.chat.generateInstruction), [/create-prompt](command:workbench.action.chat.generatePrompt), [/create-agent](command:workbench.action.chat.generateAgent), or [/create-skill](command:workbench.action.chat.generateSkill) to generate reusable agent customization files." + 'tip.createInstruction', + "Tip: Use [/create-instruction](command:workbench.action.chat.generateInstruction) to generate an on-demand instruction file with the agent." ), - when: ContextKeyExpr.and( - ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), - ChatContextKeys.hasUsedCreateSlashCommands.negate(), - ), - enabledCommands: [ + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + enabledCommands: ['workbench.action.chat.generateInstruction'], + excludeWhenCommandsExecuted: [ 'workbench.action.chat.generateInstruction', - 'workbench.action.chat.generatePrompt', - 'workbench.action.chat.generateAgent', - 'workbench.action.chat.generateSkill', + CREATE_INSTRUCTION_TRACKING_COMMAND, ], + }, + { + id: 'tip.createPrompt', + message: localize( + 'tip.createPrompt', + "Tip: Use [/create-prompt](command:workbench.action.chat.generatePrompt) to generate a reusable prompt file with the agent." + ), + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + enabledCommands: ['workbench.action.chat.generatePrompt'], excludeWhenCommandsExecuted: [ - 'workbench.action.chat.generateInstruction', 'workbench.action.chat.generatePrompt', + CREATE_PROMPT_TRACKING_COMMAND, + ], + }, + { + id: 'tip.createAgent', + message: localize( + 'tip.createAgent', + "Tip: Use [/create-agent](command:workbench.action.chat.generateAgent) to scaffold a custom agent for your workflow." + ), + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + enabledCommands: ['workbench.action.chat.generateAgent'], + excludeWhenCommandsExecuted: [ 'workbench.action.chat.generateAgent', + CREATE_AGENT_TRACKING_COMMAND, + ], + }, + { + id: 'tip.createSkill', + message: localize( + 'tip.createSkill', + "Tip: Use [/create-skill](command:workbench.action.chat.generateSkill) to create a skill the agent can load when relevant." + ), + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + enabledCommands: ['workbench.action.chat.generateSkill'], + excludeWhenCommandsExecuted: [ 'workbench.action.chat.generateSkill', + CREATE_SKILL_TRACKING_COMMAND, ], }, { @@ -222,7 +258,7 @@ const TIP_CATALOG: ITipDefinition[] = [ { id: 'tip.attachFiles', message: localize('tip.attachFiles', "Tip: Reference files or folders with # to give the agent more context about the task."), - excludeWhenCommandsExecuted: ['workbench.action.chat.attachContext', 'workbench.action.chat.attachFile', 'workbench.action.chat.attachFolder', 'workbench.action.chat.attachSelection'], + excludeWhenCommandsExecuted: ['workbench.action.chat.attachContext', 'workbench.action.chat.attachFile', 'workbench.action.chat.attachFolder', 'workbench.action.chat.attachSelection', ATTACH_FILES_REFERENCE_TRACKING_COMMAND], }, { id: 'tip.codeActions', @@ -241,29 +277,6 @@ const TIP_CATALOG: ITipDefinition[] = [ ), excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], }, - { - id: 'tip.customInstructions', - message: localize('tip.customInstructions', "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) apply coding conventions across all agent sessions."), - enabledCommands: ['workbench.action.chat.generateInstructions'], - excludeWhenCommandsExecuted: ['workbench.action.chat.generateInstructions'], - excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, - }, - { - id: 'tip.customAgent', - message: localize('tip.customAgent', "Tip: [Create a custom agent](command:workbench.command.new.agent) to define reusable personas with tailored instructions and tools for your workflow."), - when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - enabledCommands: ['workbench.command.new.agent'], - excludeWhenCommandsExecuted: ['workbench.command.new.agent'], - excludeWhenPromptFilesExist: { promptType: PromptsType.agent, excludeUntilChecked: true }, - }, - { - id: 'tip.skill', - message: localize('tip.skill', "Tip: [Create a skill](command:workbench.command.new.skill) to teach the agent specialized workflows, loaded only when relevant."), - when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - enabledCommands: ['workbench.command.new.skill'], - excludeWhenCommandsExecuted: ['workbench.command.new.skill'], - excludeWhenPromptFilesExist: { promptType: PromptsType.skill, excludeUntilChecked: true }, - }, { id: 'tip.messageQueueing', message: localize('tip.messageQueueing', "Tip: Steer the agent mid-task by sending follow-up messages. They queue and apply in order."), @@ -324,7 +337,6 @@ export class TipEligibilityTracker extends Disposable { private static readonly _COMMANDS_STORAGE_KEY = 'chat.tips.executedCommands'; private static readonly _MODES_STORAGE_KEY = 'chat.tips.usedModes'; private static readonly _TOOLS_STORAGE_KEY = 'chat.tips.invokedTools'; - private static readonly _INSTRUCTION_FILES_EVER_DETECTED_KEY = 'chat.tips.instructionFilesEverDetected'; private readonly _executedCommands: Set; private readonly _usedModes: Set; @@ -349,7 +361,6 @@ export class TipEligibilityTracker extends Disposable { /** Generation counter per tip ID to discard stale async file-check results. */ private readonly _fileCheckGeneration = new Map(); private readonly _fileChecksInFlight = new Map>(); - private _instructionFilesEverDetected: boolean; constructor( tips: readonly ITipDefinition[], @@ -372,8 +383,6 @@ export class TipEligibilityTracker extends Disposable { const storedTools = this._readApplicationWithProfileFallback(TipEligibilityTracker._TOOLS_STORAGE_KEY); this._invokedTools = new Set(storedTools ? JSON.parse(storedTools) : []); - this._instructionFilesEverDetected = this._storageService.getBoolean(TipEligibilityTracker._INSTRUCTION_FILES_EVER_DETECTED_KEY, StorageScope.APPLICATION, false); - // --- Derive what still needs tracking ---------------------------------- this._pendingCommands = new Set(); @@ -407,15 +416,7 @@ export class TipEligibilityTracker extends Disposable { if (this._pendingCommands.size > 0) { this._commandListener.value = commandService.onDidExecuteCommand(e => { - if (this._pendingCommands.has(e.commandId)) { - this._executedCommands.add(e.commandId); - this._persistSet(TipEligibilityTracker._COMMANDS_STORAGE_KEY, this._executedCommands); - this._pendingCommands.delete(e.commandId); - - if (this._pendingCommands.size === 0) { - this._commandListener.clear(); - } - } + this.recordCommandExecuted(e.commandId); }); } @@ -441,11 +442,6 @@ export class TipEligibilityTracker extends Disposable { this._tipsWithFileExclusions = tips.filter(t => t.excludeWhenPromptFilesExist); for (const tip of this._tipsWithFileExclusions) { - if (this._instructionFilesEverDetected && tip.id === 'tip.customInstructions') { - this._excludedByFiles.add(tip.id); - continue; - } - if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { this._excludedByFiles.add(tip.id); } @@ -462,6 +458,20 @@ export class TipEligibilityTracker extends Disposable { })); } + recordCommandExecuted(commandId: string): void { + if (!this._pendingCommands.has(commandId)) { + return; + } + + this._executedCommands.add(commandId); + this._persistSet(TipEligibilityTracker._COMMANDS_STORAGE_KEY, this._executedCommands); + this._pendingCommands.delete(commandId); + + if (this._pendingCommands.size === 0) { + this._commandListener.clear(); + } + } + /** * Records the current chat mode (kind + name) so future tip eligibility * checks can exclude mode-related tips. No-ops once all tracked modes @@ -531,11 +541,6 @@ export class TipEligibilityTracker extends Disposable { */ refreshPromptFileExclusions(): void { for (const tip of this._tipsWithFileExclusions) { - if (this._instructionFilesEverDetected && tip.id === 'tip.customInstructions') { - this._excludedByFiles.add(tip.id); - continue; - } - if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { this._excludedByFiles.add(tip.id); } @@ -583,11 +588,6 @@ export class TipEligibilityTracker extends Disposable { : false; const hasPromptFilesOrAgentFile = hasPromptFiles || hasAgentFile; - if (tip.id === 'tip.customInstructions' && hasPromptFilesOrAgentFile) { - this._instructionFilesEverDetected = true; - this._storageService.store(TipEligibilityTracker._INSTRUCTION_FILES_EVER_DETECTED_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - } - if (hasPromptFilesOrAgentFile) { this._excludedByFiles.add(tip.id); } else { @@ -684,6 +684,22 @@ export class ChatTipService extends Disposable implements IChatTipService { } })); + this._register(this._chatService.onDidSubmitRequest(e => { + const message = e.message ?? this._chatService.getSession(e.chatSessionResource)?.lastRequest?.message; + if (!message) { + return; + } + + if (this._hasFileOrFolderReference(message)) { + this._tracker.recordCommandExecuted(ATTACH_FILES_REFERENCE_TRACKING_COMMAND); + } + + const createCommandTrackingId = this._getCreateSlashCommandTrackingId(message); + if (createCommandTrackingId) { + this._tracker.recordCommandExecuted(createCommandTrackingId); + } + })); + // Track whether yolo mode was ever enabled this._yoloModeEverEnabled = this._storageService.getBoolean(ChatTipService._YOLO_EVER_ENABLED_KEY, StorageScope.APPLICATION, false); if (!this._yoloModeEverEnabled && this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove)) { @@ -718,6 +734,45 @@ export class ChatTipService extends Disposable implements IChatTipService { } } + private _hasFileOrFolderReference(message: IParsedChatRequest): boolean { + return message.parts.some(part => { + if (part.kind !== ChatRequestDynamicVariablePart.Kind) { + return false; + } + + const dynamicPart = part as ChatRequestDynamicVariablePart; + return dynamicPart.isFile === true || dynamicPart.isDirectory === true; + }); + } + + private _getCreateSlashCommandTrackingId(message: IParsedChatRequest): string | undefined { + for (const part of message.parts) { + if (part.kind === ChatRequestSlashCommandPart.Kind) { + const slashCommand = (part as ChatRequestSlashCommandPart).slashCommand.command; + return this._toCreateSlashCommandTrackingId(slashCommand); + } + } + + const trimmed = message.text.trimStart(); + const match = /^\/(create-(?:instruction|prompt|agent|skill))(?:\s|$)/.exec(trimmed); + return match ? this._toCreateSlashCommandTrackingId(match[1]) : undefined; + } + + private _toCreateSlashCommandTrackingId(command: string): string | undefined { + switch (command) { + case 'create-instruction': + return CREATE_INSTRUCTION_TRACKING_COMMAND; + case 'create-prompt': + return CREATE_PROMPT_TRACKING_COMMAND; + case 'create-agent': + return CREATE_AGENT_TRACKING_COMMAND; + case 'create-skill': + return CREATE_SKILL_TRACKING_COMMAND; + default: + return undefined; + } + } + resetSession(): void { this._shownTip = undefined; this._tipRequestId = undefined; diff --git a/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts b/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts index 0ad8f07e43c24..c22041aa98018 100644 --- a/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts +++ b/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts @@ -21,13 +21,12 @@ export class CreateSlashCommandsUsageTracker extends Disposable { super(); this._register(this._chatService.onDidSubmitRequest(e => { - const model = this._chatService.getSession(e.chatSessionResource); - const lastRequest = model?.lastRequest; - if (!lastRequest) { + const message = e.message ?? this._chatService.getSession(e.chatSessionResource)?.lastRequest?.message; + if (!message) { return; } - for (const part of lastRequest.message.parts) { + for (const part of message.parts) { if (part.kind === ChatRequestSlashCommandPart.Kind) { const slash = part as ChatRequestSlashCommandPart; if (CreateSlashCommandsUsageTracker._isCreateSlashCommand(slash.slashCommand.command)) { @@ -38,7 +37,7 @@ export class CreateSlashCommandsUsageTracker extends Disposable { } // Fallback when parsing doesn't produce a slash command part. - const trimmed = lastRequest.message.text.trimStart(); + const trimmed = message.text.trimStart(); const match = /^\/(create-(?:instruction|prompt|agent|skill))(?:\s|$)/.exec(trimmed); if (match && CreateSlashCommandsUsageTracker._isCreateSlashCommand(match[1])) { this._markUsed(); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index ecf9c393b6b5c..70a63b90a1eee 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1345,7 +1345,7 @@ export interface IChatService { _serviceBrand: undefined; transferredSessionResource: URI | undefined; - readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI }>; + readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>; readonly onDidCreateModel: Event; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 0743b82d5f28c..42b409dc8b31d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -107,7 +107,7 @@ export class ChatService extends Disposable implements IChatService { return this._transferredSessionResource; } - private readonly _onDidSubmitRequest = this._register(new Emitter<{ readonly chatSessionResource: URI }>()); + private readonly _onDidSubmitRequest = this._register(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); public readonly onDidSubmitRequest = this._onDidSubmitRequest.event; public get onDidCreateModel() { return this._sessionModels.onDidCreateModel; } @@ -1228,7 +1228,7 @@ export class ChatService extends Disposable implements IChatService { if (options?.userSelectedModelId) { this.languageModelsService.addToRecentlyUsedList(options.userSelectedModelId); } - this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource }); + this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource, message: parsedRequest }); return { responseCreatedPromise: responseCreated.p, responseCompletePromise: rawResponsePromise, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 5af2338c70289..8be5fe9be000b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -28,7 +28,7 @@ import { TestChatEntitlementService } from '../../../../test/common/workbenchTes import { IChatService } from '../../common/chatService/chatService.js'; import { MockChatService } from '../common/chatService/mockChatService.js'; import { CreateSlashCommandsUsageTracker } from '../../browser/createSlashCommandsUsageTracker.js'; -import { ChatRequestSlashCommandPart } from '../../common/requestParser/chatParserTypes.js'; +import { ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; @@ -117,6 +117,62 @@ suite('ChatTipService', () => { assert.ok(tip.content.value.length > 0, 'Tip should have content'); }); + test('records # file reference usage for attach files tip eligibility', () => { + const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); + instantiationService.stub(IChatService, { + onDidSubmitRequest: submitRequestEmitter.event, + getSession: () => undefined, + } as Partial as IChatService); + + createService(); + + submitRequestEmitter.fire({ + chatSessionResource: URI.parse('chat:session-attach-file'), + message: { + text: 'what does #file:README.md say', + parts: [new ChatRequestDynamicVariablePart( + new OffsetRange(10, 26), + new Range(1, 11, 1, 27), + '#file:README.md', + 'file', + undefined, + URI.file('/workspace/README.md'), + undefined, + undefined, + true, + false, + )], + }, + }); + + const executedCommands = JSON.parse(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION) ?? '[]') as string[]; + assert.ok(executedCommands.includes('chat.tips.attachFiles.referenceUsed')); + }); + + test('records only matching create tip usage for submitted create command', () => { + const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); + instantiationService.stub(IChatService, { + onDidSubmitRequest: submitRequestEmitter.event, + getSession: () => undefined, + } as Partial as IChatService); + + createService(); + + submitRequestEmitter.fire({ + chatSessionResource: URI.parse('chat:session-create-prompt'), + message: { + text: '/create-prompt scaffold a reusable prompt', + parts: [], + }, + }); + + const executedCommands = JSON.parse(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION) ?? '[]') as string[]; + assert.ok(executedCommands.includes('chat.tips.createPrompt.commandUsed')); + assert.ok(!executedCommands.includes('chat.tips.createInstruction.commandUsed')); + assert.ok(!executedCommands.includes('chat.tips.createAgent.commandUsed')); + assert.ok(!executedCommands.includes('chat.tips.createSkill.commandUsed')); + }); + test('returns Auto switch tip when current model is gpt-4.1', () => { const service = createService(); contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'gpt-4.1'); @@ -846,53 +902,55 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); }); - test('shows tip.createSlashCommands when context key is false', () => { + test('shows all create slash command tips in local chat sessions', () => { const service = createService(); - contextKeyService.createKey(ChatContextKeys.hasUsedCreateSlashCommands.key, false); contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); - // Dismiss tips until we find createSlashCommands or run out - let found = false; + const expectedCreateTips = new Set(['tip.createInstruction', 'tip.createPrompt', 'tip.createAgent', 'tip.createSkill']); + const seenCreateTips = new Set(); for (let i = 0; i < 100; i++) { const tip = service.getWelcomeTip(contextKeyService); if (!tip) { break; } - if (tip.id === 'tip.createSlashCommands') { - found = true; - break; + if (expectedCreateTips.has(tip.id)) { + seenCreateTips.add(tip.id); + if (seenCreateTips.size === expectedCreateTips.size) { + break; + } } service.dismissTip(); } - assert.ok(found, 'Should eventually show tip.createSlashCommands when context key is false'); + assert.deepStrictEqual([...seenCreateTips].sort(), [...expectedCreateTips].sort()); }); - test('does not show tip.createSlashCommands in non-local chat sessions', () => { + test('does not show create slash command tips in non-local chat sessions', () => { const service = createService(); - contextKeyService.createKey(ChatContextKeys.hasUsedCreateSlashCommands.key, false); contextKeyService.createKey(ChatContextKeys.chatSessionType.key, 'cloud'); + const createTipIds = new Set(['tip.createInstruction', 'tip.createPrompt', 'tip.createAgent', 'tip.createSkill']); for (let i = 0; i < 100; i++) { const tip = service.getWelcomeTip(contextKeyService); if (!tip) { break; } - assert.notStrictEqual(tip.id, 'tip.createSlashCommands', 'Should not show tip.createSlashCommands in non-local sessions'); + assert.ok(!createTipIds.has(tip.id), 'Should not show create slash command tips in non-local sessions'); service.dismissTip(); } }); - test('does not show tip.createSlashCommands when context key is true', () => { - storageService.store('chat.tips.usedCreateSlashCommands', true, StorageScope.APPLICATION, StorageTarget.MACHINE); + test('does not show create prompt tip when create prompt was already used', () => { + storageService.store('chat.tips.executedCommands', JSON.stringify(['chat.tips.createPrompt.commandUsed']), StorageScope.APPLICATION, StorageTarget.MACHINE); const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); for (let i = 0; i < 100; i++) { const tip = service.getWelcomeTip(contextKeyService); if (!tip) { break; } - assert.notStrictEqual(tip.id, 'tip.createSlashCommands', 'Should not show tip.createSlashCommands when context key is true'); + assert.notStrictEqual(tip.id, 'tip.createPrompt', 'Should not show tip.createPrompt when create-prompt was used'); service.dismissTip(); } }); @@ -1269,36 +1327,6 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after refresh finds instruction files'); }); - test('keeps tip.customInstructions excluded after instruction files were detected once', async () => { - const tip: ITipDefinition = { - id: 'tip.customInstructions', - message: 'test', - excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, - }; - - const tracker1 = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService([], [{ uri: URI.file('/.github/instructions/coding.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }]) as IPromptsService, - createMockToolsService(), - new NullLogService(), - )); - - await new Promise(r => setTimeout(r, 0)); - assert.strictEqual(tracker1.isExcluded(tip), true, 'Should be excluded when instruction files exist'); - - const tracker2 = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService() as IPromptsService, - createMockToolsService(), - new NullLogService(), - )); - - assert.strictEqual(tracker2.isExcluded(tip), true, 'Should remain excluded based on persisted detection signal'); - }); }); suite('CreateSlashCommandsUsageTracker', () => { @@ -1306,13 +1334,13 @@ suite('CreateSlashCommandsUsageTracker', () => { let storageService: InMemoryStorageService; let contextKeyService: MockContextKeyService; - let submitRequestEmitter: Emitter<{ readonly chatSessionResource: URI }>; + let submitRequestEmitter: Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>; let sessions: Map; setup(() => { storageService = testDisposables.add(new InMemoryStorageService()); contextKeyService = new MockContextKeyService(); - submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI }>()); + submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); sessions = new Map(); }); @@ -1425,6 +1453,26 @@ suite('CreateSlashCommandsUsageTracker', () => { ); }); + test('detects create command from submitted message payload when session has no last request', () => { + const sessionResource = URI.parse('chat:session-payload'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + submitRequestEmitter.fire({ + chatSessionResource: sessionResource, + message: { + text: '/create-prompt payload-test', + parts: [], + }, + }); + + assert.strictEqual( + storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), + true, + 'Storage should persist usage detected from submitted message payload', + ); + }); + test('does not mark used for non-create slash commands', () => { const sessionResource = URI.parse('chat:session4'); const tracker = createTracker(); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index 8fb1d803deea2..df44c9f3fd461 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -20,7 +20,7 @@ export class MockChatService implements IChatService { _serviceBrand: undefined; editingSessions = []; transferredSessionResource: URI | undefined; - readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI }> = Event.None; + readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }> = Event.None; readonly onDidCreateModel: Event = Event.None; private sessions = new ResourceMap(); From 6d003859b29e2bb365b8534e605362d85842e29d Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:15:36 -0800 Subject: [PATCH 064/541] Improve dialog handling via browser tools (#297596) * Improve dialog handling via browser tools * Update src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../electron-main/browserViewDebugger.ts | 13 ++++++-- .../browserView/node/playwrightTab.ts | 4 +-- .../tools/handleDialogBrowserTool.ts | 32 ++++++++++++------- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts index e9956c91b185e..6e2a837d2f7b8 100644 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts @@ -60,7 +60,7 @@ export class BrowserViewDebugger extends Disposable implements ICDPTarget { }) as { sessionId: string }; const sessionId = result.sessionId; - const session = new DebugSession(sessionId, this._electronDebugger); + const session = new DebugSession(sessionId, this.view, this._electronDebugger); this._sessions.set(sessionId, session); session.onClose(() => this._sessions.deleteAndDispose(sessionId)); @@ -182,6 +182,7 @@ class DebugSession extends Disposable implements ICDPConnection { constructor( public readonly sessionId: string, + private readonly _view: BrowserView, private readonly _electronDebugger: Electron.Debugger ) { super(); @@ -193,7 +194,15 @@ class DebugSession extends Disposable implements ICDPConnection { return Promise.resolve({}); } - return this._electronDebugger.sendCommand(method, params, this.sessionId); + const result = await this._electronDebugger.sendCommand(method, params, this.sessionId); + + // Electron overrides dialog behavior in a way that this command does not auto-dismiss the dialog. + // So we manually emit the (internal) event to dismiss open dialogs when this command is sent. + if (method === 'Page.handleJavaScriptDialog') { + this._view.webContents.emit('-cancel-dialogs'); + } + + return result; } override dispose(): void { diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 544bfcabd05f9..0a73676455fe1 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -69,7 +69,7 @@ export class PlaywrightTab { async replyToDialog(accept?: boolean, promptText?: string) { if (!this._dialog) { - throw new Error('No active dialog to respond to'); + throw new Error('No active modal dialog to respond to'); } const dialog = this._dialog; this._dialog = undefined; @@ -89,7 +89,7 @@ export class PlaywrightTab { async replyToFileChooser(files: string[]) { if (!this._fileChooser) { - throw new Error('No active file chooser to respond to'); + throw new Error('No active file chooser dialog to respond to'); } const chooser = this._fileChooser; this._fileChooser = undefined; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts index 34d8798bc72f9..deed4c4b38b55 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts @@ -16,7 +16,7 @@ export const HandleDialogBrowserToolData: IToolData = { toolReferenceName: 'handleDialog', displayName: localize('handleDialogBrowserTool.displayName', 'Handle Dialog'), userDescription: localize('handleDialogBrowserTool.userDescription', 'Respond to a dialog in a browser page'), - modelDescription: 'Respond to a pending dialog (alert, confirm, prompt) or file chooser dialog on a browser page.', + modelDescription: 'Respond to a pending modal (alert, confirm, prompt) or file chooser dialog on a browser page.', icon: Codicon.comment, source: ToolDataSource.Internal, inputSchema: { @@ -26,29 +26,29 @@ export const HandleDialogBrowserToolData: IToolData = { type: 'string', description: `The browser page ID, acquired from context or ${OpenPageToolId}.` }, - accept: { + acceptModal: { type: 'boolean', - description: 'Whether to accept (true) or dismiss (false) the dialog.' + description: 'Whether to accept (true) or dismiss (false) a modal dialog.' }, promptText: { type: 'string', - description: 'Text to enter into a prompt dialog. Only applicable for prompt dialogs.' + description: 'Text to enter into a prompt dialog.' }, - files: { + selectFiles: { type: 'array', items: { type: 'string' }, - description: 'Absolute paths of files to select. Required for file chooser dialogs.' + description: 'Absolute paths of files to select, or empty to dismiss. Required for file chooser dialogs.' }, }, - required: ['pageId', 'accept'], + required: ['pageId'], }, }; interface IHandleDialogBrowserToolParams { pageId: string; - accept: boolean; + acceptModal: boolean; promptText?: string; - files?: string[]; + selectFiles?: string[]; } export class HandleDialogBrowserTool implements IToolImpl { @@ -70,12 +70,20 @@ export class HandleDialogBrowserTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + if (params.selectFiles !== undefined && (params.acceptModal !== undefined || params.promptText !== undefined)) { + return errorResult(`Invalid parameters. 'selectFiles' cannot be used with 'acceptModal' or 'promptText'.`); + } + + if (!Array.isArray(params.selectFiles) && (params.acceptModal === undefined || params.acceptModal === null)) { + return errorResult(`Invalid parameters. Either 'selectFiles' or 'acceptModal' must be provided.`); + } + try { let result; - if (params.files !== undefined) { - result = await this.playwrightService.replyToFileChooser(params.pageId, params.accept ? params.files : []); + if (params.selectFiles !== undefined) { + result = await this.playwrightService.replyToFileChooser(params.pageId, params.selectFiles); } else { - result = await this.playwrightService.replyToDialog(params.pageId, params.accept, params.promptText); + result = await this.playwrightService.replyToDialog(params.pageId, params.acceptModal, params.promptText); } return { content: [{ kind: 'text', value: result.summary }] }; } catch (e) { From 0aa520dbb9d7512ab57343dcf1587788ecafc62a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:20:25 -0800 Subject: [PATCH 065/541] Bump minimatch from 9.0.5 to 9.0.6 in /test/sanity (#297542) Bumps [minimatch](https://github.com/isaacs/minimatch) from 9.0.5 to 9.0.6. - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v9.0.5...v9.0.6) --- updated-dependencies: - dependency-name: minimatch dependency-version: 9.0.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- test/sanity/package-lock.json | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/test/sanity/package-lock.json b/test/sanity/package-lock.json index 27d7cd8110ed8..6113958376736 100644 --- a/test/sanity/package-lock.json +++ b/test/sanity/package-lock.json @@ -107,18 +107,24 @@ "license": "Python-2.0" }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/browser-stdout": { @@ -648,12 +654,12 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" From 5a537659ee33a210c86503656edc36eac6eac590 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 24 Feb 2026 22:21:07 -0800 Subject: [PATCH 066/541] chat: add plugins.enabled preview setting (#297545) * chat: add plugins.enabled preview setting Adds a new 'chat.plugins.enabled' preview setting to control whether plugins are available in chat. When disabled, both IAgentPluginService will return no plugins and IPluginMarketplaceService will return no marketplace plugins. - Adds PluginsEnabled constant to ChatConfiguration enum - Registers 'chat.plugins.enabled' boolean setting with default value true - Gates AgentPluginService to return empty plugin lists when disabled - Gates PluginMarketplaceService to return no marketplace plugins when disabled (Commit message generated by Copilot) * refresh the marketplace when enablement or upstreams change --- .../contrib/chat/browser/agentPluginsView.ts | 6 ++++++ .../contrib/chat/browser/chat.contribution.ts | 6 ++++++ src/vs/workbench/contrib/chat/common/constants.ts | 1 + .../chat/common/plugins/agentPluginServiceImpl.ts | 10 +++++++++- .../common/plugins/pluginMarketplaceService.ts | 15 ++++++++++++++- 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 35bcd764d101f..700220058029c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -331,6 +331,12 @@ export class AgentPluginsListView extends AbstractExtensionsListView { + if (this.list && this.isBodyVisible()) { + this.refreshOnPluginsChangedScheduler.schedule(); + } + })); } protected override renderBody(container: HTMLElement): void { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 4344617d4c41d..97ca2db45d3b1 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -650,6 +650,12 @@ configurationRegistry.registerConfiguration({ }, } }, + [ChatConfiguration.PluginsEnabled]: { + type: 'boolean', + description: nls.localize('chat.plugins.enabled', "Enable agent plugin integration in chat."), + default: true, + tags: ['preview'], + }, [ChatConfiguration.PluginPaths]: { type: 'object', additionalProperties: { type: 'boolean' }, diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index d9c776a3618ec..d7ce75b61bd31 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -10,6 +10,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', + PluginsEnabled = 'chat.plugins.enabled', PluginPaths = 'chat.plugins.paths', PluginMarketplaces = 'chat.plugins.marketplaces', AgentEnabled = 'chat.agent.enabled', diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 5385d796b40d0..51391adb6e7cd 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -91,9 +91,12 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic constructor( @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); + const pluginsEnabled = observableConfigValue(ChatConfiguration.PluginsEnabled, true, configurationService); + const discoveries: IAgentPluginDiscovery[] = []; for (const descriptor of agentPluginDiscoveryRegistry.getAll()) { const discovery = instantiationService.createInstance(descriptor); @@ -103,7 +106,12 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic } - this.allPlugins = derived(read => this._dedupeAndSort(discoveries.flatMap(d => d.plugins.read(read)))); + this.allPlugins = derived(read => { + if (!pluginsEnabled.read(read)) { + return []; + } + return this._dedupeAndSort(discoveries.flatMap(d => d.plugins.read(read))); + }); this.plugins = derived(reader => { const all = this.allPlugins.read(reader); diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index c7824a81fc969..46cf8c7015530 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Lazy } from '../../../../../base/common/lazy.js'; import { revive } from '../../../../../base/common/marshalling.js'; @@ -72,6 +73,7 @@ export const IPluginMarketplaceService = createDecorator; fetchMarketplacePlugins(token: CancellationToken): Promise; } @@ -99,6 +101,8 @@ export class PluginMarketplaceService implements IPluginMarketplaceService { declare readonly _serviceBrand: undefined; private readonly _gitHubMarketplaceCache = new Lazy>(() => this._loadPersistedGitHubMarketplaceCache()); + readonly onDidChangeMarketplaces: Event; + constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IRequestService private readonly _requestService: IRequestService, @@ -106,9 +110,18 @@ export class PluginMarketplaceService implements IPluginMarketplaceService { @IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService, @ILogService private readonly _logService: ILogService, @IStorageService private readonly _storageService: IStorageService, - ) { } + ) { + this.onDidChangeMarketplaces = Event.filter( + _configurationService.onDidChangeConfiguration, + e => e.affectsConfiguration(ChatConfiguration.PluginsEnabled) || e.affectsConfiguration(ChatConfiguration.PluginMarketplaces), + ) as Event as Event; + } async fetchMarketplacePlugins(token: CancellationToken): Promise { + if (!this._configurationService.getValue(ChatConfiguration.PluginsEnabled)) { + return []; + } + const configuredRefs = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; const refs = parseMarketplaceReferences(configuredRefs); From 83956769b75676c676239e721ae455f5093c8b10 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 24 Feb 2026 23:08:36 -0800 Subject: [PATCH 067/541] agent plugins: 'Installed' view shows only installed plugins (#297546) chore: use constructor param instead of subclass for installedOnly option --- .../contrib/chat/browser/agentPluginsView.ts | 75 ++++++++++--------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 700220058029c..9d2bc74d70922 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -289,6 +289,10 @@ class AgentPluginRenderer implements IPagedRenderer { private readonly actionStore = this._register(new DisposableStore()); @@ -308,6 +312,7 @@ export class AgentPluginsListView extends AbstractExtensionsListView> { this.currentQuery = query; - const text = query.replace(/@agentPlugins/i, '').trim(); - - const [installed, marketplace] = await Promise.all([ - this.queryInstalled(), - this.queryMarketplace(text), - ]); - - // Filter out marketplace items that are already installed - const installedPaths = new Set(installed.map(i => i.plugin.uri.toString())); - const filteredMarketplace = marketplace.filter(m => { - const expectedUri = this.pluginInstallService.getPluginInstallUri({ - name: m.name, - description: m.description, - version: '', - source: m.source, - marketplace: m.marketplace, - marketplaceReference: m.marketplaceReference, - marketplaceType: m.marketplaceType, + const text = query.replace(/@agentPlugins/i, '').trim().toLowerCase(); + + let installed = this.queryInstalled(); + if (text) { + installed = installed.filter(p => + p.name.toLowerCase().includes(text) || + p.description.toLowerCase().includes(text) + ); + } + + let items: IAgentPluginItem[] = installed; + + if (!this.listOptions.installedOnly) { + const marketplace = await this.queryMarketplace(text); + + // Filter out marketplace items that are already installed + const installedPaths = new Set(installed.map(i => i.plugin.uri.toString())); + const filteredMarketplace = marketplace.filter(m => { + const expectedUri = this.pluginInstallService.getPluginInstallUri({ + name: m.name, + description: m.description, + version: '', + source: m.source, + marketplace: m.marketplace, + marketplaceReference: m.marketplaceReference, + marketplaceType: m.marketplaceType, + }); + return !installedPaths.has(expectedUri.toString()); }); - return !installedPaths.has(expectedUri.toString()); - }); - const model = new PagedModel([...installed, ...filteredMarketplace]); + items = [...installed, ...filteredMarketplace]; + } + + const model = new PagedModel(items); if (this.list) { this.list.model = model; } @@ -493,16 +509,6 @@ export class AgentPluginsListView extends AbstractExtensionsListView> { - return super.show('@agentPlugins'); - } -} - -//#endregion - //#region Browse command class AgentPluginsBrowseCommand extends Action2 { @@ -532,7 +538,6 @@ class AgentPluginsBrowseCommand extends Action2 { } //#endregion - //#region Views contribution export class AgentPluginsViewsContribution extends Disposable implements IWorkbenchContribution { @@ -556,7 +561,7 @@ export class AgentPluginsViewsContribution extends Disposable implements IWorkbe { id: InstalledAgentPluginsViewId, name: localize2('agent-plugins-installed', "Agent Plugins - Installed"), - ctorDescriptor: new SyncDescriptor(AgentPluginsListView), + ctorDescriptor: new SyncDescriptor(AgentPluginsListView, [{ installedOnly: true }]), when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledAgentPluginsContext, ChatContextKeys.Setup.hidden.negate()), weight: 30, order: 5, @@ -565,7 +570,7 @@ export class AgentPluginsViewsContribution extends Disposable implements IWorkbe { id: 'workbench.views.agentPlugins.default.marketplace', name: localize2('agent-plugins', "Agent Plugins"), - ctorDescriptor: new SyncDescriptor(DefaultBrowseAgentPluginsView), + ctorDescriptor: new SyncDescriptor(AgentPluginsListView, [{}]), when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledAgentPluginsContext.toNegated(), ChatContextKeys.Setup.hidden.negate()), weight: 30, order: 5, @@ -575,7 +580,7 @@ export class AgentPluginsViewsContribution extends Disposable implements IWorkbe { id: 'workbench.views.agentPlugins.marketplace', name: localize2('agent-plugins', "Agent Plugins"), - ctorDescriptor: new SyncDescriptor(AgentPluginsListView), + ctorDescriptor: new SyncDescriptor(AgentPluginsListView, [{}]), when: ContextKeyExpr.and(SearchAgentPluginsContext, ChatContextKeys.Setup.hidden.negate()), }, ], VIEW_CONTAINER); From ca22571811b203617d3e607baa58256d7e4eccfb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 07:17:35 +0000 Subject: [PATCH 068/541] Stop Escape propagation in settings list edit widgets (#297605) * Initial plan * fix: stop Escape propagation in settings list widgets to prevent modal dialog from closing When editing a list item in the settings editor (e.g., Add Item for less.lint.validProperties), pressing Escape should cancel the edit operation, not close the entire modal settings dialog. The Escape key handlers in ListSettingWidget and ObjectSettingDropdownWidget were calling preventDefault() but not stopPropagation(), allowing the event to bubble up to the modal container's Escape handler which closed the dialog. Fixes #296337 Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> --- src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts b/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts index 1322fca29ed23..6d6f60085f73e 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts @@ -659,6 +659,7 @@ export class ListSettingWidget extends Abst } else if (e.equals(KeyCode.Escape)) { this.cancelEdit(); e.preventDefault(); + e.stopPropagation(); } rowElement?.focus(); }; @@ -1198,6 +1199,7 @@ export class ObjectSettingDropdownWidget extends AbstractListSettingWidget Date: Wed, 25 Feb 2026 08:25:11 +0100 Subject: [PATCH 069/541] sessions - refresh sessions when window gains focus (#297608) * feat - add host service to refresh sessions on focus * feat - add host service to refresh sessions on focus --- .../contrib/sessions/browser/sessionsViewPane.ts | 9 +++++++++ .../chat/browser/widgetHosts/viewPane/chatViewPane.ts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 9bef481d40592..06d4a6c566eea 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -44,6 +44,7 @@ import { IViewsService } from '../../../../workbench/services/views/common/views import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { Menus } from '../../../browser/menus.js'; import { getCustomizationTotalCount } from './customizationCounts.js'; +import { IHostService } from '../../../../workbench/services/host/browser/host.js'; const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; @@ -75,6 +76,7 @@ export class AgenticSessionsViewPane extends ViewPane { @IMcpService private readonly mcpService: IMcpService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + @IHostService private readonly hostService: IHostService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); } @@ -146,6 +148,13 @@ export class AgenticSessionsViewPane extends ViewPane { })); this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible))); + // Refresh sessions when window gets focus to compensate for missing events + this._register(this.hostService.onDidChangeFocus(hasFocus => { + if (hasFocus) { + sessionsControl.refresh(); + } + })); + // Listen to tree updates and restore selection if nothing is selected this._register(sessionsControl.onDidUpdate(() => { if (!sessionsControl.hasFocusOrSelection()) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 8db0ce90838c9..096b218471899 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -66,6 +66,7 @@ import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IHostService } from '../../../../../services/host/browser/host.js'; interface IChatViewPaneState extends Partial { /** @@ -126,6 +127,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @ICommandService private readonly commandService: ICommandService, @IActivityService private readonly activityService: IActivityService, @IWorkbenchEnvironmentService private readonly workbenchEnvironmentService: IWorkbenchEnvironmentService, + @IHostService private readonly hostService: IHostService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -397,6 +399,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { sessionsToolbar.context = sessionsControl; + // Refresh sessions when window gets focus to compensate for missing events + this._register(this.hostService.onDidChangeFocus(hasFocus => { + if (hasFocus) { + sessionsControl.refresh(); + } + })); + // Deal with orientation configuration this._register(Event.runAndSubscribe(Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.ChatViewSessionsOrientation)), e => { const newSessionsViewerOrientationConfiguration = this.configurationService.getValue<'stacked' | 'sideBySide' | unknown>(ChatConfiguration.ChatViewSessionsOrientation); From 99ea24abb848bcf8d90d4f402e40a213ae7b45ff Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 25 Feb 2026 08:29:43 +0100 Subject: [PATCH 070/541] Refactor new chat pane pickers into self-contained widgets (#297544) * Refactor new chat pane pickers into self-contained widgets Extract picker logic from NewChatWidget into independent widget classes that follow a consistent pattern (trigger button + action list dropdown): - RepoPicker: Cloud repository selection with storage persistence, recently used list, and browse command integration - CloudModelPicker: Cloud model selection from session option groups - IsolationModePicker: Worktree/Folder mode using action widget - BranchPicker: Git branch icons Simplify RemoteNewSession: - getModelOptionGroup/getOtherOptionGroups for extension option groups - When-clause evaluation and context key listening - Remove dead code (getRepositoryOptionGroup, _syncValuesFromService, onDidChangeOptionValues, _cachedRepoGroup) Use instantiation service for session object creation. Add toolbar pickers CSS for flex layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use resolved model group ID instead of hardcoded 'models' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Validate storage shape when restoring repo picker state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Guard _initDefaultModel against overwriting user's model selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Exclude repository option groups from getOtherOptionGroups Prevents duplicate repo selection UI when extension registers a repositories option group alongside the dedicated RepoPicker widget. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Re-add onDidChangeSessionOptions listener to RemoteNewSession Ensures UI re-renders when an extension updates session options (e.g. after browse command), and disabled state is re-evaluated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert "Re-add onDidChangeSessionOptions listener to RemoteNewSession" This reverts commit 0ed09f1fb98bcc5e1389abf8073ecb6160c2c9cf. * Revert "Exclude repository option groups from getOtherOptionGroups" This reverts commit 08370f78a328a0e2ec4e6a978aa47395beca3e90. * Revert "Guard _initDefaultModel against overwriting user's model selection" This reverts commit d5e0b6dac70aae92ad342b5fbd2e925d96fa7003. * Revert "Validate storage shape when restoring repo picker state" This reverts commit 22b9719032846f265dfbc1333e5cf393296d6555. * Revert "Use resolved model group ID instead of hardcoded 'models'" This reverts commit f81f5496e71a307b05e44b1d09a930e18ddc5177. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/branchPicker.ts | 2 +- .../contrib/chat/browser/folderPicker.ts | 20 +- .../contrib/chat/browser/media/chatWidget.css | 6 + .../contrib/chat/browser/modelPicker.ts | 204 +++++++++ .../contrib/chat/browser/newChatViewPane.ts | 431 +++++++----------- .../contrib/chat/browser/newSession.ts | 142 +++++- .../contrib/chat/browser/repoPicker.ts | 270 +++++++++++ .../chat/browser/sessionTargetPicker.ts | 159 ++++--- .../browser/sessionsManagementService.ts | 4 +- 9 files changed, 872 insertions(+), 366 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/modelPicker.ts create mode 100644 src/vs/sessions/contrib/chat/browser/repoPicker.ts diff --git a/src/vs/sessions/contrib/chat/browser/branchPicker.ts b/src/vs/sessions/contrib/chat/browser/branchPicker.ts index 7744e54a3dacf..e12427b28ca57 100644 --- a/src/vs/sessions/contrib/chat/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/branchPicker.ts @@ -177,7 +177,7 @@ export class BranchPicker extends Disposable { return this._branches.map(branch => ({ kind: ActionListItemKind.Action, label: branch, - group: { title: '', icon: this._selectedBranch === branch ? Codicon.check : Codicon.blank }, + group: { title: '', icon: Codicon.gitBranch }, item: { name: branch }, })); } diff --git a/src/vs/sessions/contrib/chat/browser/folderPicker.ts b/src/vs/sessions/contrib/chat/browser/folderPicker.ts index 12a0ec3a381aa..a83b40962a9f1 100644 --- a/src/vs/sessions/contrib/chat/browser/folderPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/folderPicker.ts @@ -214,25 +214,29 @@ export class FolderPicker extends Disposable { items.push({ kind: ActionListItemKind.Action, label: basename(currentFolderUri), - group: { title: '', icon: Codicon.check }, + group: { title: '', icon: Codicon.folder }, item: { uri: currentFolderUri, label: basename(currentFolderUri) }, }); } - // Recently picked folders + // Recently picked folders (sorted by name) + const dedupedFolders: { uri: URI; label: string }[] = []; for (const folderUri of this._recentlyPickedFolders) { const key = folderUri.toString(); if (seenUris.has(key)) { continue; } seenUris.add(key); - const label = basename(folderUri); + dedupedFolders.push({ uri: folderUri, label: basename(folderUri) }); + } + dedupedFolders.sort((a, b) => a.label.localeCompare(b.label)); + for (const folder of dedupedFolders) { items.push({ kind: ActionListItemKind.Action, - label, - group: { title: '', icon: Codicon.blank }, - item: { uri: folderUri, label }, - onRemove: () => this._removeFolder(folderUri), + label: folder.label, + group: { title: '', icon: Codicon.folder }, + item: { uri: folder.uri, label: folder.label }, + onRemove: () => this._removeFolder(folder.uri), }); } @@ -246,7 +250,7 @@ export class FolderPicker extends Disposable { items.push({ kind: ActionListItemKind.Action, label: localize('browseFolder', "Browse..."), - group: { title: '', icon: Codicon.folderOpened }, + group: { title: '', icon: Codicon.search }, item: { uri: URI.from({ scheme: 'command', path: 'browse' }), label: localize('browseFolder', "Browse...") }, }); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 4fca1a7a5018a..e04f2c308737c 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -66,6 +66,12 @@ flex: 1; } +.sessions-chat-toolbar-pickers { + display: flex; + align-items: center; + gap: 4px; +} + /* Model picker - uses workbench ModelPickerActionItem */ .sessions-chat-model-picker { display: flex; diff --git a/src/vs/sessions/contrib/chat/browser/modelPicker.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts new file mode 100644 index 0000000000000..1cdae827ae9b1 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { RemoteNewSession } from './newSession.js'; + +const FILTER_THRESHOLD = 10; + +interface IModelItem { + readonly id: string; + readonly name: string; + readonly description?: string; +} + +/** + * A self-contained widget for selecting a model in cloud sessions. + * Reads the model option group from the {@link RemoteNewSession} and + * renders an action list dropdown with the available models. + */ +export class CloudModelPicker extends Disposable { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private _triggerElement: HTMLElement | undefined; + private _slotElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _sessionDisposables = this._register(new DisposableStore()); + + private _session: RemoteNewSession | undefined; + private _selectedModel: IModelItem | undefined; + private _models: IModelItem[] = []; + + get selectedModel(): IModelItem | undefined { + return this._selectedModel; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + ) { + super(); + } + + /** + * Sets the remote session and loads the available models from it. + */ + setSession(session: RemoteNewSession): void { + this._session = session; + this._sessionDisposables.clear(); + this._loadModels(session); + + // Sync selected model to the new session + if (this._selectedModel) { + session.setModelId(this._selectedModel.id); + session.setOptionValue('models', { id: this._selectedModel.id, name: this._selectedModel.name }); + } + + // Re-load models when option groups change + this._sessionDisposables.add(session.onDidChangeOptionGroups(() => { + this._loadModels(session); + })); + } + + /** + * Renders the model picker trigger button into the given container. + */ + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._slotElement = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._showPicker(); + } + })); + + return slot; + } + + /** + * Shows or hides the picker. + */ + setVisible(visible: boolean): void { + if (this._slotElement) { + this._slotElement.style.display = visible ? '' : 'none'; + } + } + + private _loadModels(session: RemoteNewSession): void { + const modelOption = session.getModelOptionGroup(); + if (modelOption?.group.items.length) { + this._models = modelOption.group.items.map(item => ({ + id: item.id, + name: item.name, + description: item.description, + })); + + // Select the session's current value, or the default, or the first + if (!this._selectedModel || !this._models.some(m => m.id === this._selectedModel!.id)) { + const value = modelOption.value; + this._selectedModel = value + ? { id: value.id, name: value.name, description: value.description } + : this._models[0]; + } + } else { + this._models = []; + } + this._updateTriggerLabel(); + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible || this._models.length === 0) { + return; + } + + const items = this._buildItems(); + const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + this._selectModel(item); + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'remoteModelPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('modelPicker.ariaLabel', "Model Picker"), + }, + showFilter ? { showFilter: true, filterPlaceholder: localize('modelPicker.filter', "Filter models...") } : undefined, + ); + } + + private _buildItems(): IActionListItem[] { + return this._models.map(model => ({ + kind: ActionListItemKind.Action, + label: model.name, + group: { title: '', icon: this._selectedModel?.id === model.id ? Codicon.check : Codicon.blank }, + item: model, + })); + } + + private _selectModel(item: IModelItem): void { + this._selectedModel = item; + this._updateTriggerLabel(); + + if (this._session) { + this._session.setModelId(item.id); + this._session.setOptionValue('models', { id: item.id, name: item.name }); + } + this._onDidChange.fire({ id: item.id, name: item.name, description: item.description }); + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + const label = this._selectedModel?.name ?? localize('modelPicker.auto', "Auto"); + + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + this._slotElement?.classList.toggle('disabled', this._models.length === 0); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 7c0adb7780827..65201f7b6cfcf 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -32,7 +32,7 @@ import { themeColorFromId } from '../../../../base/common/themables.js'; import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService, IContextKey, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -53,7 +53,7 @@ import { ISessionsManagementService } from '../../sessions/browser/sessionsManag import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; import { SearchableOptionPickerActionItem } from '../../../../workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.js'; -import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { IModelPickerDelegate } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; import { EnhancedModelPickerActionItem } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.js'; @@ -63,14 +63,15 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; -import { isString } from '../../../../base/common/types.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { FolderPicker } from './folderPicker.js'; import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { IsolationModePicker, SessionTargetPicker } from './sessionTargetPicker.js'; import { BranchPicker } from './branchPicker.js'; -import { INewSession } from './newSession.js'; +import { INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; +import { RepoPicker } from './repoPicker.js'; +import { CloudModelPicker } from './modelPicker.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; @@ -137,16 +138,18 @@ class NewChatWidget extends Disposable { // Welcome part private _pickersContainer: HTMLElement | undefined; private _extensionPickersLeftContainer: HTMLElement | undefined; - private _extensionPickersRightContainer: HTMLElement | undefined; + private _toolbarPickersContainer: HTMLElement | undefined; + private _localModelPickerContainer: HTMLElement | undefined; private _inputSlot: HTMLElement | undefined; private readonly _folderPicker: FolderPicker; private _folderPickerContainer: HTMLElement | undefined; - private readonly _pickerWidgets = new Map(); - private readonly _pickerWidgetDisposables = this._register(new DisposableStore()); + private readonly _repoPicker: RepoPicker; + private _repoPickerContainer: HTMLElement | undefined; + private readonly _cloudModelPicker: CloudModelPicker; + private readonly _toolbarPickerWidgets = new Map(); + private readonly _toolbarPickerDisposables = this._register(new DisposableStore()); private readonly _optionEmitters = new Map>(); - private readonly _selectedOptions = new Map(); private readonly _optionContextKeys = new Map>(); - private readonly _whenClauseKeys = new Set(); // Attached context private readonly _contextAttachments: NewChatContextAttachments; @@ -157,7 +160,6 @@ class NewChatWidget extends Disposable { constructor( options: INewChatWidgetOptions, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IModelService private readonly modelService: IModelService, @IConfigurationService private readonly configurationService: IConfigurationService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @@ -177,6 +179,8 @@ class NewChatWidget extends Disposable { super(); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); this._folderPicker = this._register(this.instantiationService.createInstance(FolderPicker)); + this._repoPicker = this._register(this.instantiationService.createInstance(RepoPicker)); + this._cloudModelPicker = this._register(this.instantiationService.createInstance(CloudModelPicker)); this._targetPicker = this._register(new SessionTargetPicker(options.allowedTargets, options.defaultTarget)); this._isolationModePicker = this._register(this.instantiationService.createInstance(IsolationModePicker)); this._branchPicker = this._register(this.instantiationService.createInstance(BranchPicker)); @@ -191,12 +195,6 @@ class NewChatWidget extends Disposable { this._focusEditor(); })); - this._register(this.contextKeyService.onDidChangeContext(e => { - if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) { - this._renderExtensionPickers(true); - } - })); - // Register slash commands this._registerSlashCommands(); @@ -216,6 +214,11 @@ class NewChatWidget extends Disposable { this._register(this._isolationModePicker.onDidChange(() => { this._focusEditor(); })); + + // When language models change (e.g., extension activates), reinitialize if no model selected + this._register(this.languageModelsService.onDidChangeLanguageModels(() => { + this._initDefaultModel(); + })); } // --- Rendering --- @@ -306,10 +309,11 @@ class NewChatWidget extends Disposable { // Wire pickers to the new session this._folderPicker.setNewSession(session); + this._repoPicker.setNewSession(session); this._isolationModePicker.setNewSession(session); this._branchPicker.setNewSession(session); - // Set the current model on the session + // Set the current model on the session (for local sessions) const currentModel = this._currentLanguageModel.get(); if (currentModel) { session.setModelId(currentModel.identifier); @@ -320,26 +324,30 @@ class NewChatWidget extends Disposable { this._openRepository(session.repoUri); } - // Render extension pickers for the new session - this._renderExtensionPickers(true); - // Listen for session changes - this._newSessionListener.value = session.onDidChange((changeType) => { + const listeners = new DisposableStore(); + listeners.add(session.onDidChange((changeType) => { if (changeType === 'repoUri' && session.repoUri) { this._openRepository(session.repoUri); } if (changeType === 'isolationMode') { this._branchPicker.setVisible(session.isolationMode === 'worktree'); } - if (changeType === 'options') { - this._syncOptionsFromSession(session.resource); - this._renderExtensionPickers(); - } if (changeType === 'disabled') { this._updateSendButtonState(); } - }); + })); + if (session instanceof RemoteNewSession) { + this._renderRemoteSessionPickers(session, true); + listeners.add(session.onDidChangeOptionGroups(() => { + this._renderRemoteSessionPickers(session); + })); + } else { + this._renderLocalSessionPickers(); + } + + this._newSessionListener.value = listeners; this._updateSendButtonState(); } @@ -491,18 +499,14 @@ class NewChatWidget extends Disposable { return this._folderPicker.selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; } - // For cloud targets, look for a repository option in the selected options - for (const [groupId, option] of this._selectedOptions) { - if (isRepoOrFolderGroup({ id: groupId, name: groupId, items: [] })) { - const nwo = option.id; // e.g. "owner/repo" - if (nwo && nwo.includes('/')) { - return URI.from({ - scheme: GITHUB_REMOTE_FILE_SCHEME, - authority: 'github', - path: `/${nwo}/HEAD`, - }); - } - } + // For cloud targets, use the repo picker's selection + const selectedRepo = this._repoPicker.selectedRepo; + if (selectedRepo && selectedRepo.includes('/')) { + return URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${selectedRepo}/HEAD`, + }); } return undefined; @@ -513,8 +517,15 @@ class NewChatWidget extends Disposable { this._createAttachButton(toolbar); - const modelPickerContainer = dom.append(toolbar, dom.$('.sessions-chat-model-picker')); - this._createModelPicker(modelPickerContainer); + // Local model picker (EnhancedModelPickerActionItem) + this._localModelPickerContainer = dom.append(toolbar, dom.$('.sessions-chat-model-picker')); + this._createLocalModelPicker(this._localModelPickerContainer); + + // Remote model picker (action list dropdown) + this._cloudModelPicker.render(toolbar); + this._cloudModelPicker.setVisible(false); + + this._toolbarPickersContainer = dom.append(toolbar, dom.$('.sessions-chat-toolbar-pickers')); dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); @@ -534,7 +545,7 @@ class NewChatWidget extends Disposable { // --- Model picker --- - private _createModelPicker(container: HTMLElement): void { + private _createLocalModelPicker(container: HTMLElement): void { const delegate: IModelPickerDelegate = { currentModel: this._currentLanguageModel, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { @@ -562,27 +573,14 @@ class NewChatWidget extends Disposable { } private _initDefaultModel(): void { - const lastModelId = this.storageService.get(STORAGE_KEY_LAST_MODEL, StorageScope.PROFILE); const models = this._getAvailableModels(); + const lastModelId = this.storageService.get(STORAGE_KEY_LAST_MODEL, StorageScope.PROFILE); const lastModel = lastModelId ? models.find(m => m.identifier === lastModelId) : undefined; if (lastModel) { this._currentLanguageModel.set(lastModel, undefined); } else if (models.length > 0) { this._currentLanguageModel.set(models[0], undefined); } - - this._register(this.languageModelsService.onDidChangeLanguageModels(() => { - if (!this._currentLanguageModel.get()) { - const storedId = this.storageService.get(STORAGE_KEY_LAST_MODEL, StorageScope.PROFILE); - const updated = this._getAvailableModels(); - const stored = storedId ? updated.find(m => m.identifier === storedId) : undefined; - if (stored) { - this._currentLanguageModel.set(stored, undefined); - } else if (updated.length > 0) { - this._currentLanguageModel.set(updated[0], undefined); - } - } - })); } private _getAvailableModels(): ILanguageModelChatMetadataAndIdentifier[] { @@ -601,7 +599,7 @@ class NewChatWidget extends Disposable { return; } - this._clearExtensionPickers(); + this._clearAllPickers(); dom.clearNode(this._pickersContainer); const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers')); @@ -614,178 +612,135 @@ class NewChatWidget extends Disposable { // Right half: separator + pickers (left-justified within its half) const rightHalf = dom.append(pickersRow, dom.$('.sessions-chat-pickers-right-half')); this._extensionPickersLeftContainer = dom.append(rightHalf, dom.$('.sessions-chat-pickers-left-separator')); - this._extensionPickersRightContainer = dom.append(rightHalf, dom.$('.sessions-chat-extension-pickers-right')); + this._extensionPickersLeftContainer.style.display = 'none'; + + // Repo picker for cloud (rendered once, shown/hidden based on target) + this._repoPickerContainer = dom.append(rightHalf, dom.$('.sessions-chat-extension-pickers-right')); + this._repoPickerContainer.style.display = 'none'; + this._repoPicker.render(this._repoPickerContainer); - // Folder picker (rendered once, shown/hidden based on target) + // Folder picker for local (rendered once, shown/hidden based on target) this._folderPickerContainer = this._folderPicker.render(rightHalf); this._folderPickerContainer.style.display = 'none'; + } + + // --- Local session pickers --- - this._renderExtensionPickers(); + private _renderLocalSessionPickers(): void { + this._clearAllPickers(); + if (this._folderPickerContainer) { + this._folderPickerContainer.style.display = ''; + } + if (this._extensionPickersLeftContainer) { + this._extensionPickersLeftContainer.style.display = 'block'; + } + // Show local model picker, hide remote + if (this._localModelPickerContainer) { + this._localModelPickerContainer.style.display = ''; + } + this._cloudModelPicker.setVisible(false); } - // --- Welcome: Extension option pickers (Cloud target only) --- + // --- Remote session pickers --- - private _renderExtensionPickers(force?: boolean): void { - if (!this._extensionPickersRightContainer) { + private _renderRemoteSessionPickers(session: RemoteNewSession, force?: boolean): void { + if (!this._repoPickerContainer) { return; } - const activeSessionType = this._targetPicker.selectedTarget; + // Hide local-only pickers + if (this._folderPickerContainer) { + this._folderPickerContainer.style.display = 'none'; + } - // Extension pickers are only shown for Cloud target - if (activeSessionType === AgentSessionProviders.Background) { - this._clearExtensionPickers(); - if (this._folderPickerContainer) { - this._folderPickerContainer.style.display = ''; - } - if (this._extensionPickersLeftContainer) { - this._extensionPickersLeftContainer.style.display = 'block'; - } - return; + // Show remote model picker, hide local + if (this._localModelPickerContainer) { + this._localModelPickerContainer.style.display = 'none'; } + this._cloudModelPicker.setSession(session); + this._cloudModelPicker.setVisible(true); - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); - if (!optionGroups || optionGroups.length === 0) { - this._clearExtensionPickers(); - return; + // Show repo picker and separator + if (this._extensionPickersLeftContainer) { + this._extensionPickersLeftContainer.style.display = 'block'; } + this._repoPickerContainer.style.display = ''; - const visibleGroups: IChatSessionProviderOptionGroup[] = []; - this._whenClauseKeys.clear(); - for (const group of optionGroups) { - if (isModelOptionGroup(group)) { - continue; - } - if (group.when) { - const expr = ContextKeyExpr.deserialize(group.when); - if (expr) { - for (const key of expr.keys()) { - this._whenClauseKeys.add(key); - } - } - } - const hasItems = group.items.length > 0 || (group.commands || []).length > 0 || !!group.searchable; - const passesWhenClause = this._evaluateOptionGroupVisibility(group); - if (hasItems && passesWhenClause) { - visibleGroups.push(group); - } + // Render toolbar pickers (other groups) + this._renderToolbarPickers(session, force); + } + + private _renderToolbarPickers(session: RemoteNewSession, force?: boolean): void { + if (!this._toolbarPickersContainer) { + return; } + const toolbarOptions = session.getOtherOptionGroups(); + + // Filter by item availability (when-clause filtering is done by the session) + const visibleGroups = toolbarOptions.filter(option => { + const group = option.group; + return group.items.length > 0 || (group.commands || []).length > 0 || !!group.searchable; + }); + if (visibleGroups.length === 0) { - this._clearExtensionPickers(); + this._clearToolbarPickers(); return; } - if (!force && this._pickerWidgets.size === visibleGroups.length) { - const allMatch = visibleGroups.every(g => this._pickerWidgets.has(g.id)); + if (!force) { + const allMatch = visibleGroups.length === this._toolbarPickerWidgets.size && visibleGroups.every(o => this._toolbarPickerWidgets.has(o.group.id)); if (allMatch) { return; } } - this._clearExtensionPickers(); - - if (this._extensionPickersLeftContainer) { - this._extensionPickersLeftContainer.style.display = 'block'; - } - - for (const optionGroup of visibleGroups) { - const initialItem = this._getDefaultOptionForGroup(optionGroup); - const initialState = { group: optionGroup, item: initialItem }; - - if (initialItem) { - this._updateOptionContextKey(optionGroup.id, initialItem.id); - } + this._clearToolbarPickers(); - const emitter = this._getOrCreateOptionEmitter(optionGroup.id); - const itemDelegate: IChatSessionPickerDelegate = { - getCurrentOption: () => this._selectedOptions.get(optionGroup.id) ?? this._getDefaultOptionForGroup(optionGroup), - onDidChangeOption: emitter.event, - setOption: (option: IChatSessionProviderOptionItem) => { - this._selectedOptions.set(optionGroup.id, option); - this._updateOptionContextKey(optionGroup.id, option.id); - emitter.fire(option); - - this._newSession.value?.setOption(optionGroup.id, option); - - this._renderExtensionPickers(true); - this._focusEditor(); - }, - getOptionGroup: () => { - const groups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); - return groups?.find((g: { id: string }) => g.id === optionGroup.id); - }, - getSessionResource: () => this._newSession.value?.resource, - }; - - const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } }); - const widget = this.instantiationService.createInstance( - optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, - action, initialState, itemDelegate - ); - - this._pickerWidgetDisposables.add(widget); - this._pickerWidgets.set(optionGroup.id, widget); - - const slot = dom.append(this._extensionPickersRightContainer!, dom.$('.sessions-chat-picker-slot')); - widget.render(slot); + for (const option of visibleGroups) { + this._renderToolbarPickerWidget(option, session); } } - private _evaluateOptionGroupVisibility(optionGroup: { id: string; when?: string }): boolean { - if (!optionGroup.when) { - return true; - } - const expr = ContextKeyExpr.deserialize(optionGroup.when); - return !expr || this.contextKeyService.contextMatchesRules(expr); - } + private _renderToolbarPickerWidget(option: ISessionOptionGroup, session: RemoteNewSession): void { + const { group: optionGroup, value: initialItem } = option; - private _getDefaultOptionForGroup(optionGroup: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined { - const selectedOption = this._selectedOptions.get(optionGroup.id); - if (selectedOption) { - return selectedOption; + if (initialItem) { + this._updateOptionContextKey(optionGroup.id, initialItem.id); } - if (this._newSession.value) { - const sessionOption = this.chatSessionsService.getSessionOption(this._newSession.value.resource, optionGroup.id); - if (!isString(sessionOption)) { - return sessionOption; - } - } + const initialState = { group: optionGroup, item: initialItem }; + const emitter = this._getOrCreateOptionEmitter(optionGroup.id); + const itemDelegate: IChatSessionPickerDelegate = { + getCurrentOption: () => session.getOptionValue(optionGroup.id) ?? initialItem, + onDidChangeOption: emitter.event, + setOption: (item: IChatSessionProviderOptionItem) => { + this._updateOptionContextKey(optionGroup.id, item.id); + emitter.fire(item); + session.setOptionValue(optionGroup.id, item); + this._focusEditor(); + }, + getOptionGroup: () => { + const modelOpt = session.getModelOptionGroup(); + if (modelOpt?.group.id === optionGroup.id) { + return modelOpt.group; + } + return session.getOtherOptionGroups().find(o => o.group.id === optionGroup.id)?.group; + }, + getSessionResource: () => session.resource, + }; - return optionGroup.items.find((item) => item.default === true); - } + const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } }); + const widget = this.instantiationService.createInstance( + optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, + action, initialState, itemDelegate + ); - private _syncOptionsFromSession(sessionResource: URI): void { - const activeSessionType = this._targetPicker.selectedTarget; - if (!activeSessionType) { - return; - } - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); - if (!optionGroups) { - return; - } - for (const optionGroup of optionGroups) { - if (isModelOptionGroup(optionGroup)) { - continue; - } - const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroup.id); - if (!currentOption) { - continue; - } - let item: IChatSessionProviderOptionItem | undefined; - if (typeof currentOption === 'string') { - item = optionGroup.items.find((m: { id: string }) => m.id === currentOption.trim()); - } else { - item = currentOption; - } - if (item) { - const { locked: _locked, ...unlocked } = item; - this._selectedOptions.set(optionGroup.id, unlocked as IChatSessionProviderOptionItem); - this._updateOptionContextKey(optionGroup.id, item.id); - this._optionEmitters.get(optionGroup.id)?.fire(item); - } - } + this._toolbarPickerDisposables.add(widget); + this._toolbarPickerWidgets.set(optionGroup.id, widget); + + const slot = dom.append(this._toolbarPickersContainer!, dom.$('.sessions-chat-picker-slot')); + widget.render(slot); } private _updateOptionContextKey(optionGroupId: string, optionItemId: string): void { @@ -803,24 +758,31 @@ class NewChatWidget extends Disposable { if (!emitter) { emitter = new Emitter(); this._optionEmitters.set(optionGroupId, emitter); - this._pickerWidgetDisposables.add(emitter); + this._toolbarPickerDisposables.add(emitter); } return emitter; } - private _clearExtensionPickers(): void { - this._pickerWidgetDisposables.clear(); - this._pickerWidgets.clear(); + private _clearToolbarPickers(): void { + this._toolbarPickerDisposables.clear(); + this._toolbarPickerWidgets.clear(); this._optionEmitters.clear(); + if (this._toolbarPickersContainer) { + dom.clearNode(this._toolbarPickersContainer); + } + } + + private _clearAllPickers(): void { + this._clearToolbarPickers(); if (this._folderPickerContainer) { this._folderPickerContainer.style.display = 'none'; } + if (this._repoPickerContainer) { + this._repoPickerContainer.style.display = 'none'; + } if (this._extensionPickersLeftContainer) { this._extensionPickersLeftContainer.style.display = 'none'; } - if (this._extensionPickersRightContainer) { - dom.clearNode(this._extensionPickersRightContainer); - } } // --- Send --- @@ -1104,52 +1066,14 @@ class NewChatWidget extends Disposable { if (sessionType === AgentSessionProviders.Local || sessionType === AgentSessionProviders.Background) { return !!this._folderPicker.selectedFolderUri; } - - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(sessionType); - if (!optionGroups) { - return true; - } - for (const group of optionGroups) { - if (!isRepoOrFolderGroup(group)) { - continue; - } - const selected = this._selectedOptions.get(group.id); - if (selected) { - return true; - } - const defaultItem = this._getDefaultOptionForGroup(group); - if (defaultItem) { - return true; - } - } - // No repo/folder groups exist — nothing required - return !optionGroups.some(g => isRepoOrFolderGroup(g)); + return !!this._repoPicker.selectedRepo; } - /** - * Opens the appropriate folder/repo picker for the given session type. - * For Local/Background targets, opens the folder picker. - * For other targets, opens the first visible repo/folder extension picker widget. - */ private _openRepoOrFolderPicker(sessionType: AgentSessionProviders): void { if (sessionType === AgentSessionProviders.Local || sessionType === AgentSessionProviders.Background) { this._folderPicker.showPicker(); - return; - } - - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(sessionType); - if (!optionGroups) { - return; - } - for (const group of optionGroups) { - if (!isRepoOrFolderGroup(group)) { - continue; - } - const widget = this._pickerWidgets.get(group.id); - if (widget) { - widget.show(); - return; - } + } else { + this._repoPicker.showPicker(); } } @@ -1240,28 +1164,3 @@ export class NewChatViewPane extends ViewPane { } // #endregion - -/** - * Check whether an option group represents the model picker. - * The convention is `id: 'models'` but extensions may use different IDs - * per session type, so we also fall back to name matching. - */ -function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { - if (group.id === 'models') { - return true; - } - const nameLower = group.name.toLowerCase(); - return nameLower === 'model' || nameLower === 'models'; -} - -/** - * Check whether an option group represents a repository or folder picker. - * These are placed on the right side of the pickers row. - */ -function isRepoOrFolderGroup(group: IChatSessionProviderOptionGroup): boolean { - const idLower = group.id.toLowerCase(); - const nameLower = group.name.toLowerCase(); - return idLower === 'repositories' || idLower === 'folders' || - nameLower === 'repository' || nameLower === 'repositories' || - nameLower === 'folder' || nameLower === 'folders'; -} diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index 2e601215b535a..760f9dc27ea7d 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -6,16 +6,24 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; -import { isEqual } from '../../../../base/common/resources.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IsolationMode } from './sessionTargetPicker.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options' | 'disabled'; +/** + * Represents a resolved option group with its current selected value. + */ +export interface ISessionOptionGroup { + readonly group: IChatSessionProviderOptionGroup; + readonly value: IChatSessionProviderOptionItem | undefined; +} + /** * A new session represents a session being configured before the first * request is sent. It holds the user's selections (repoUri, isolationMode) @@ -85,8 +93,8 @@ export class LocalNewSession extends Disposable implements INewSession { constructor( readonly resource: URI, defaultRepoUri: URI | undefined, - private readonly chatSessionsService: IChatSessionsService, - private readonly logService: ILogService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @ILogService private readonly logService: ILogService, ) { super(); if (defaultRepoUri) { @@ -149,13 +157,12 @@ export class LocalNewSession extends Disposable implements INewSession { /** * Remote new session for Cloud agent sessions. - * Fires `onDidChange` and notifies the extension service when `repoUri` changes. - * Ignores `isolationMode` (not relevant for cloud). + * Manages extension-driven option groups (models, etc.) and their values. + * Fires events for option group changes. */ export class RemoteNewSession extends Disposable implements INewSession { private _repoUri: URI | undefined; - private _isolationMode: IsolationMode = 'worktree'; private _modelId: string | undefined; private _query: string | undefined; private _attachedContext: IChatRequestVariableEntry[] | undefined; @@ -163,33 +170,42 @@ export class RemoteNewSession extends Disposable implements INewSession { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; + private readonly _onDidChangeOptionGroups = this._register(new Emitter()); + readonly onDidChangeOptionGroups: Event = this._onDidChangeOptionGroups.event; + readonly selectedOptions = new Map(); get repoUri(): URI | undefined { return this._repoUri; } - get isolationMode(): IsolationMode { return this._isolationMode; } + get isolationMode(): IsolationMode { return 'worktree'; } get branch(): string | undefined { return undefined; } get modelId(): string | undefined { return this._modelId; } get query(): string | undefined { return this._query; } get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } get disabled(): boolean { - return !this._repoUri && !this._hasRepositoryOption(); + return !this._repoUri && !this.selectedOptions.has('repositories'); } + private readonly _whenClauseKeys = new Set(); + constructor( readonly resource: URI, readonly target: AgentSessionProviders, - private readonly chatSessionsService: IChatSessionsService, - private readonly logService: ILogService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ILogService private readonly logService: ILogService, ) { super(); - // Listen for extension-driven option group and session option changes + this._updateWhenClauseKeys(); + this._register(this.chatSessionsService.onDidChangeOptionGroups(() => { + this._updateWhenClauseKeys(); + this._onDidChangeOptionGroups.fire(); this._onDidChange.fire('options'); })); - this._register(this.chatSessionsService.onDidChangeSessionOptions((e: URI | undefined) => { - if (isEqual(this.resource, e)) { - this._onDidChange.fire('options'); + this._register(this.contextKeyService.onDidChangeContext(e => { + if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) { + this._onDidChangeOptionGroups.fire(); } })); } @@ -202,11 +218,11 @@ export class RemoteNewSession extends Disposable implements INewSession { } setIsolationMode(_mode: IsolationMode): void { - // No-op for remote sessions — isolation mode is not relevant + // No-op for remote sessions } setBranch(_branch: string | undefined): void { - // No-op for remote sessions — branch is not relevant + // No-op for remote sessions } setModelId(modelId: string | undefined): void { @@ -233,7 +249,95 @@ export class RemoteNewSession extends Disposable implements INewSession { ).catch((err) => this.logService.error(`Failed to notify extension of ${optionId} change:`, err)); } - private _hasRepositoryOption(): boolean { - return this.selectedOptions.has('repositories'); + // --- Option group accessors --- + + getModelOptionGroup(): ISessionOptionGroup | undefined { + const groups = this._getOptionGroups(); + if (!groups) { + return undefined; + } + const group = groups.find(g => isModelOptionGroup(g)); + if (!group) { + return undefined; + } + return { group, value: this._getValueForGroup(group) }; + } + + getOtherOptionGroups(): ISessionOptionGroup[] { + const groups = this._getOptionGroups(); + if (!groups) { + return []; + } + return groups + .filter(g => !isModelOptionGroup(g) && this._isOptionGroupVisible(g)) + .map(g => ({ group: g, value: this._getValueForGroup(g) })); + } + + getOptionValue(groupId: string): IChatSessionProviderOptionItem | undefined { + return this.selectedOptions.get(groupId); + } + + setOptionValue(groupId: string, value: IChatSessionProviderOptionItem): void { + this.setOption(groupId, value); + } + + // --- Internals --- + + private _getOptionGroups(): IChatSessionProviderOptionGroup[] | undefined { + return this.chatSessionsService.getOptionGroupsForSessionType(this.target); + } + + private _isOptionGroupVisible(group: IChatSessionProviderOptionGroup): boolean { + if (!group.when) { + return true; + } + const expr = ContextKeyExpr.deserialize(group.when); + return !expr || this.contextKeyService.contextMatchesRules(expr); + } + + private _updateWhenClauseKeys(): void { + this._whenClauseKeys.clear(); + const groups = this._getOptionGroups(); + if (!groups) { + return; + } + for (const group of groups) { + if (group.when) { + const expr = ContextKeyExpr.deserialize(group.when); + if (expr) { + for (const key of expr.keys()) { + this._whenClauseKeys.add(key); + } + } + } + } + } + + private _getValueForGroup(group: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined { + const selected = this.selectedOptions.get(group.id); + if (selected) { + return selected; + } + // Check for extension-set session option + const sessionOption = this.chatSessionsService.getSessionOption(this.resource, group.id); + if (sessionOption && typeof sessionOption !== 'string') { + return sessionOption; + } + if (typeof sessionOption === 'string') { + const item = group.items.find(i => i.id === sessionOption.trim()); + if (item) { + return item; + } + } + // Default to first item marked as default, or first item + return group.items.find(i => i.default === true) ?? group.items[0]; + } +} + +function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { + if (group.id === 'models') { + return true; } + const nameLower = group.name.toLowerCase(); + return nameLower === 'model' || nameLower === 'models'; } diff --git a/src/vs/sessions/contrib/chat/browser/repoPicker.ts b/src/vs/sessions/contrib/chat/browser/repoPicker.ts new file mode 100644 index 0000000000000..ca45f083cd2e8 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/repoPicker.ts @@ -0,0 +1,270 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { INewSession } from './newSession.js'; + +const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; +const STORAGE_KEY_LAST_REPO = 'agentSessions.lastPickedRepo'; +const STORAGE_KEY_RECENT_REPOS = 'agentSessions.recentlyPickedRepos'; +const MAX_RECENT_REPOS = 10; +const FILTER_THRESHOLD = 10; + +interface IRepoItem { + readonly id: string; + readonly name: string; +} + +/** + * A self-contained widget for selecting the repository in cloud sessions. + * Uses the `github.copilot.chat.cloudSessions.openRepository` command for + * browsing repositories. Manages recently used repos in storage. + * Behaves like FolderPicker: trigger button with dropdown, storage persistence, + * recently used list with remove buttons. + */ +export class RepoPicker extends Disposable { + + private readonly _onDidSelectRepo = this._register(new Emitter()); + readonly onDidSelectRepo: Event = this._onDidSelectRepo.event; + + private _triggerElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + private _browseGeneration = 0; + + private _newSession: INewSession | undefined; + private _selectedRepo: IRepoItem | undefined; + private _recentlyPickedRepos: IRepoItem[] = []; + + get selectedRepo(): string | undefined { + return this._selectedRepo?.id; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IStorageService private readonly storageService: IStorageService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + + // Restore last picked repo + try { + const last = this.storageService.get(STORAGE_KEY_LAST_REPO, StorageScope.PROFILE); + if (last) { + this._selectedRepo = JSON.parse(last); + } + } catch { /* ignore */ } + + // Restore recently picked repos + try { + const stored = this.storageService.get(STORAGE_KEY_RECENT_REPOS, StorageScope.PROFILE); + if (stored) { + this._recentlyPickedRepos = JSON.parse(stored); + } + } catch { /* ignore */ } + } + + /** + * Sets the pending session that this picker writes to. + * If a repository is already selected, notifies the session. + */ + setNewSession(session: INewSession | undefined): void { + this._newSession = session; + this._browseGeneration++; + if (session && this._selectedRepo) { + session.setOption('repositories', this._selectedRepo); + } + } + + /** + * Renders the repo picker trigger button into the given container. + * Returns the container element. + */ + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this.showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this.showPicker(); + } + })); + + return slot; + } + + /** + * Shows the repo picker dropdown anchored to the trigger element. + */ + showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const items = this._buildItems(); + const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + if (item.id === 'browse') { + this._browseForRepo(); + } else { + this._selectRepo(item); + } + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'repoPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('repoPicker.ariaLabel', "Repository Picker"), + }, + showFilter ? { showFilter: true, filterPlaceholder: localize('repoPicker.filter', "Filter repositories...") } : undefined, + ); + } + + /** + * Programmatically set the selected repository. + */ + setSelectedRepo(repoPath: string): void { + this._selectRepo({ id: repoPath, name: repoPath }); + } + + /** + * Clears the selected repository. + */ + clearSelection(): void { + this._selectedRepo = undefined; + this._updateTriggerLabel(); + } + + private _selectRepo(item: IRepoItem): void { + this._selectedRepo = item; + this._addToRecentlyPicked(item); + this.storageService.store(STORAGE_KEY_LAST_REPO, JSON.stringify(item), StorageScope.PROFILE, StorageTarget.MACHINE); + this._updateTriggerLabel(); + this._newSession?.setOption('repositories', item); + this._onDidSelectRepo.fire(item.id); + } + + private async _browseForRepo(): Promise { + const generation = this._browseGeneration; + try { + const result: string | undefined = await this.commandService.executeCommand(OPEN_REPO_COMMAND); + if (result && generation === this._browseGeneration) { + this._selectRepo({ id: result, name: result }); + } + } catch { + // command was cancelled or failed — nothing to do + } + } + + private _addToRecentlyPicked(item: IRepoItem): void { + this._recentlyPickedRepos = [ + { id: item.id, name: item.name }, + ...this._recentlyPickedRepos.filter(r => r.id !== item.id), + ].slice(0, MAX_RECENT_REPOS); + this.storageService.store(STORAGE_KEY_RECENT_REPOS, JSON.stringify(this._recentlyPickedRepos), StorageScope.PROFILE, StorageTarget.MACHINE); + } + + private _buildItems(): IActionListItem[] { + const seenIds = new Set(); + const items: IActionListItem[] = []; + + // Currently selected (shown first, checked) + if (this._selectedRepo) { + seenIds.add(this._selectedRepo.id); + items.push({ + kind: ActionListItemKind.Action, + label: this._selectedRepo.name, + group: { title: '', icon: Codicon.repo }, + item: this._selectedRepo, + }); + } + + // Recently picked repos (sorted by name) + const dedupedRepos = this._recentlyPickedRepos.filter(r => !seenIds.has(r.id)); + dedupedRepos.sort((a, b) => a.name.localeCompare(b.name)); + for (const repo of dedupedRepos) { + seenIds.add(repo.id); + items.push({ + kind: ActionListItemKind.Action, + label: repo.name, + group: { title: '', icon: Codicon.repo }, + item: repo, + onRemove: () => this._removeRepo(repo.id), + }); + } + + // Separator + Browse... + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + items.push({ + kind: ActionListItemKind.Action, + label: localize('browseRepo', "Browse..."), + group: { title: '', icon: Codicon.search }, + item: { id: 'browse', name: localize('browseRepo', "Browse...") }, + }); + + return items; + } + + private _removeRepo(repoId: string): void { + this._recentlyPickedRepos = this._recentlyPickedRepos.filter(r => r.id !== repoId); + this.storageService.store(STORAGE_KEY_RECENT_REPOS, JSON.stringify(this._recentlyPickedRepos), StorageScope.PROFILE, StorageTarget.MACHINE); + + // Re-show picker with updated items + this.actionWidgetService.hide(); + this.showPicker(); + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + const label = this._selectedRepo?.name ?? localize('pickRepo', "Pick Repository"); + + dom.append(this._triggerElement, renderIcon(Codicon.repo)); + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts index 1d762632f9f71..9b3de3cff0589 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts @@ -5,41 +5,17 @@ import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { toAction } from '../../../../base/common/actions.js'; import { Radio } from '../../../../base/browser/ui/radio/radio.js'; -import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; import { INewSession } from './newSession.js'; -/** - * A dropdown menu action item that shows an icon, a text label, and a chevron. - */ -class LabeledDropdownMenuActionViewItem extends DropdownMenuActionViewItem { - protected override renderLabel(element: HTMLElement): null { - const classNames = typeof this.options.classNames === 'string' - ? this.options.classNames.split(/\s+/g).filter(s => !!s) - : (this.options.classNames ?? []); - if (classNames.length > 0) { - const icon = dom.append(element, dom.$('span')); - icon.classList.add('codicon', ...classNames); - } - - const label = dom.append(element, dom.$('span.sessions-chat-dropdown-label')); - label.textContent = this._action.label; - - dom.append(element, renderIcon(Codicon.chevronDown)); - - return null; - } -} - // #region --- Session Target Picker --- /** @@ -168,15 +144,15 @@ export class IsolationModePicker extends Disposable { readonly onDidChange: Event = this._onDidChange.event; private readonly _renderDisposables = this._register(new DisposableStore()); - private _container: HTMLElement | undefined; - private _dropdownContainer: HTMLElement | undefined; + private _slotElement: HTMLElement | undefined; + private _triggerElement: HTMLElement | undefined; get isolationMode(): IsolationMode { return this._isolationMode; } constructor( - @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, ) { super(); } @@ -199,66 +175,89 @@ export class IsolationModePicker extends Disposable { } else if (this._isolationMode === 'worktree') { this._setMode('workspace'); } - this._renderDropdown(); + this._updateTriggerLabel(); } /** - * Renders the isolation mode dropdown into the given container. + * Renders the isolation mode picker into the given container. */ render(container: HTMLElement): void { - this._container = container; - this._dropdownContainer = dom.append(container, dom.$('.sessions-chat-local-mode-left')); - this._renderDropdown(); + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._slotElement = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._showPicker(); + } + })); } /** * Shows or hides the picker. */ setVisible(visible: boolean): void { - if (this._container) { - this._container.style.visibility = visible ? '' : 'hidden'; + if (this._slotElement) { + this._slotElement.style.display = visible ? '' : 'none'; } } - private _renderDropdown(): void { - if (!this._dropdownContainer) { + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible || !this._repository) { return; } - this._renderDisposables.clear(); - dom.clearNode(this._dropdownContainer); - - const modeLabel = this._isolationMode === 'worktree' - ? localize('isolationMode.worktree', "Worktree") - : localize('isolationMode.folder', "Folder"); - const modeIcon = this._isolationMode === 'worktree' ? Codicon.worktree : Codicon.folder; - const isDisabled = !this._repository; + const items: IActionListItem[] = [ + { + kind: ActionListItemKind.Action, + label: localize('isolationMode.folder', "Folder"), + group: { title: '', icon: Codicon.folder }, + item: 'workspace', + }, + { + kind: ActionListItemKind.Action, + label: localize('isolationMode.worktree', "Worktree"), + group: { title: '', icon: Codicon.worktree }, + item: 'worktree', + }, + ]; - const modeAction = toAction({ id: 'isolationMode', label: modeLabel, run: () => { } }); - const modeDropdown = this._renderDisposables.add(new LabeledDropdownMenuActionViewItem( - modeAction, + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (mode) => { + this.actionWidgetService.hide(); + this._setMode(mode); + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'isolationModePicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], { - getActions: () => isDisabled ? [] : [ - toAction({ - id: 'isolationMode.worktree', - label: localize('isolationMode.worktree', "Worktree"), - checked: this._isolationMode === 'worktree', - run: () => this._setMode('worktree'), - }), - toAction({ - id: 'isolationMode.folder', - label: localize('isolationMode.folder', "Folder"), - checked: this._isolationMode === 'workspace', - run: () => this._setMode('workspace'), - }), - ], + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('isolationModePicker.ariaLabel', "Isolation Mode"), }, - this.contextMenuService, - { classNames: [...ThemeIcon.asClassNameArray(modeIcon)] } - )); - const modeSlot = dom.append(this._dropdownContainer, dom.$('.sessions-chat-picker-slot')); - modeDropdown.render(modeSlot); - modeSlot.classList.toggle('disabled', isDisabled); + ); } private _setMode(mode: IsolationMode): void { @@ -266,8 +265,28 @@ export class IsolationModePicker extends Disposable { this._isolationMode = mode; this._newSession?.setIsolationMode(mode); this._onDidChange.fire(mode); - this._renderDropdown(); + this._updateTriggerLabel(); + } + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; } + + dom.clearNode(this._triggerElement); + const isDisabled = !this._repository; + const modeIcon = this._isolationMode === 'worktree' ? Codicon.worktree : Codicon.folder; + const modeLabel = this._isolationMode === 'worktree' + ? localize('isolationMode.worktree', "Worktree") + : localize('isolationMode.folder', "Folder"); + + dom.append(this._triggerElement, renderIcon(modeIcon)); + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = modeLabel; + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + this._slotElement?.classList.toggle('disabled', isDisabled); } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 059c8e51cdd7f..bc9392da04e94 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -233,9 +233,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa let newSession: INewSession; if (target === AgentSessionProviders.Background || target === AgentSessionProviders.Local) { - newSession = new LocalNewSession(sessionResource, defaultRepoUri, this.chatSessionsService, this.logService); + newSession = this.instantiationService.createInstance(LocalNewSession, sessionResource, defaultRepoUri); } else { - newSession = new RemoteNewSession(sessionResource, target, this.chatSessionsService, this.logService); + newSession = this.instantiationService.createInstance(RemoteNewSession, sessionResource, target); } this._newSession.value = newSession; this.setActiveSession(newSession); From 330498949f926cb0f6610e078352655bcd830f1e Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 25 Feb 2026 08:29:46 +0100 Subject: [PATCH 071/541] :lipstick: --- .../browser/parts/notifications/notificationsActions.ts | 7 +++---- .../browser/parts/notifications/notificationsViewer.ts | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts index e0912c3f276d2..a97e459fe3c12 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts @@ -22,6 +22,9 @@ const expandIcon = registerIcon('notifications-expand', Codicon.chevronUp, local const expandDownIcon = registerIcon('notifications-expand-down', Codicon.chevronDown, localize('expandDownIcon', 'Icon for the expand action in notifications when the notification center is at the top.')); const collapseIcon = registerIcon('notifications-collapse', Codicon.chevronDown, localize('collapseIcon', 'Icon for the collapse action in notifications.')); const collapseUpIcon = registerIcon('notifications-collapse-up', Codicon.chevronUp, localize('collapseUpIcon', 'Icon for the collapse action in notifications when the notification center is at the top.')); +const configureIcon = registerIcon('notifications-configure', Codicon.gear, localize('configureIcon', 'Icon for the configure action in notifications.')); +const doNotDisturbIcon = registerIcon('notifications-do-not-disturb', Codicon.bellSlash, localize('doNotDisturbIcon', 'Icon for the mute all action in notifications.')); +export const positionIcon = registerIcon('notifications-position', Codicon.arrowSwap, localize('positionIcon', 'Icon for the position action in notifications.')); export function getNotificationExpandIcon(position: NotificationsPosition): ThemeIcon { return position === NotificationsPosition.TOP_RIGHT ? expandDownIcon : expandIcon; @@ -31,10 +34,6 @@ export function getNotificationCollapseIcon(position: NotificationsPosition): Th return position === NotificationsPosition.TOP_RIGHT ? collapseUpIcon : collapseIcon; } -const configureIcon = registerIcon('notifications-configure', Codicon.gear, localize('configureIcon', 'Icon for the configure action in notifications.')); -const doNotDisturbIcon = registerIcon('notifications-do-not-disturb', Codicon.bellSlash, localize('doNotDisturbIcon', 'Icon for the mute all action in notifications.')); -export const positionIcon = registerIcon('notifications-position', Codicon.arrowSwap, localize('positionIcon', 'Icon for the position action in notifications.')); - export class ClearNotificationAction extends Action { static readonly ID = CLEAR_NOTIFICATION; diff --git a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts index 79d82d23be627..7c01c6202c395 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts @@ -321,6 +321,7 @@ export class NotificationTemplateRenderer extends Disposable { if (!NotificationTemplateRenderer.expandNotificationAction) { return; } + const position = getNotificationsPosition(configurationService); NotificationTemplateRenderer.expandNotificationAction.class = ThemeIcon.asClassName(getNotificationExpandIcon(position)); NotificationTemplateRenderer.collapseNotificationAction.class = ThemeIcon.asClassName(getNotificationCollapseIcon(position)); From 1bb7b74f672a796c16597728708a8b5b79a7bbb7 Mon Sep 17 00:00:00 2001 From: Robo Date: Wed, 25 Feb 2026 16:44:49 +0900 Subject: [PATCH 072/541] chore: update cli dependency (#297618) --- cli/Cargo.lock | 69 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index cd9b8de6afba6..afe353213b1b5 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -447,6 +447,7 @@ dependencies = [ "uuid", "winapi", "winreg 0.50.0", + "winresource", "zbus", "zip", ] @@ -2645,6 +2646,15 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3004,12 +3014,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -3017,10 +3051,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.13.0", - "toml_datetime", - "winnow", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow 0.7.14", ] +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "tower-service" version = "0.3.3" @@ -3699,6 +3748,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + [[package]] name = "winreg" version = "0.8.0" @@ -3718,6 +3773,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winresource" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e287ced0f21cd11f4035fe946fd3af145f068d1acb708afd248100f89ec7432d" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.51.0" From 4ae85be6838e2fc471957ced23b091339e5e95ec Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 25 Feb 2026 09:03:30 +0100 Subject: [PATCH 073/541] separate user data dir (#297621) * eng - add launch config for sessions * separate user data dir --- .vscode/launch.json | 60 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index d116d2c003389..04fc39061884c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -278,6 +278,51 @@ "hidden": true, }, }, + { + "type": "chrome", + "request": "launch", + "name": "Launch VS Sessions Internal", + "windows": { + "runtimeExecutable": "${workspaceFolder}/scripts/code.bat" + }, + "osx": { + "runtimeExecutable": "${workspaceFolder}/scripts/code.sh" + }, + "linux": { + "runtimeExecutable": "${workspaceFolder}/scripts/code.sh" + }, + "port": 9222, + "timeout": 0, + "env": { + "VSCODE_EXTHOST_WILL_SEND_SOCKET": null, + "VSCODE_SKIP_PRELAUNCH": "1", + "VSCODE_DEV_DEBUG_OBSERVABLES": "1", + }, + "cleanUp": "wholeBrowser", + "killBehavior": "polite", + "runtimeArgs": [ + "--inspect-brk=5875", + "--no-cached-data", + "--crash-reporter-directory=${workspaceFolder}/.profile-oss/crashes", + // for general runtime freezes: https://github.com/microsoft/vscode/issues/127861#issuecomment-904144910 + "--disable-features=CalculateNativeWinOcclusion", + "--disable-extension=vscode.vscode-api-tests", + "--sessions" + ], + "userDataDir": "${userHome}/.vscode-oss-sessions-dev", + "webRoot": "${workspaceFolder}", + "cascadeTerminateToConfigurations": [ + "Attach to Extension Host" + ], + "pauseForSourceMap": false, + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "browserLaunchLocation": "workspace", + "presentation": { + "hidden": true, + }, + }, { // To debug observables you also need the extension "ms-vscode.debug-value-editor" "type": "chrome", @@ -653,6 +698,21 @@ "order": 1 } }, + { + "name": "VS Sessions", + "stopAll": true, + "configurations": [ + "Launch VS Sessions Internal", + "Attach to Main Process", + "Attach to Extension Host", + "Attach to Shared Process", + ], + "preLaunchTask": "Ensure Prelaunch Dependencies", + "presentation": { + "group": "0_vscode", + "order": 1 + } + }, { "name": "VS Code (Hot Reload)", "stopAll": true, From eef0f0fb9f7777c9e6ebdf73722d19aedb3563f6 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 25 Feb 2026 09:35:29 +0100 Subject: [PATCH 074/541] Add tests --- .../sessionsConfigurationService.test.ts | 25 ++ .../sessionsTerminalContribution.test.ts | 357 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index 46e663337003c..e0a7961c314a7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -44,6 +44,7 @@ suite('SessionsConfigurationService', () => { let sentCommands: { command: string }[]; let committedFiles: { session: IActiveSessionItem; fileUris: URI[] }[]; let storageService: InMemoryStorageService; + let readFileCalls: URI[]; const userSettingsUri = URI.parse('file:///user/settings.json'); const repoUri = URI.parse('file:///repo'); @@ -55,11 +56,13 @@ suite('SessionsConfigurationService', () => { createdTerminals = []; sentCommands = []; committedFiles = []; + readFileCalls = []; const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IFileService, new class extends mock() { override async readFile(resource: URI) { + readFileCalls.push(resource); const content = fileContents.get(resource.toString()); if (content === undefined) { throw new Error('file not found'); @@ -167,6 +170,28 @@ suite('SessionsConfigurationService', () => { assert.deepStrictEqual(obs.get().map(t => t.label), ['serve']); }); + test('getSessionTasks does not re-read files on repeated calls for the same folder', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('build', 'npm run build', true), + ])); + fileContents.set(userTasksUri.toString(), tasksJsonContent([])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + + // Call getSessionTasks multiple times for the same session/folder + service.getSessionTasks(session); + service.getSessionTasks(session); + service.getSessionTasks(session); + + await new Promise(r => setTimeout(r, 10)); + + // _refreshSessionTasks reads two files (workspace + user tasks.json). + // If refresh triggered more than once, we'd see > 2 reads. + assert.strictEqual(readFileCalls.length, 2, 'should read files only once (no duplicate refresh)'); + }); + // --- getNonSessionTasks --- test('getNonSessionTasks returns only tasks without inSessions', async () => { diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts new file mode 100644 index 0000000000000..aba8ca51fce95 --- /dev/null +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -0,0 +1,357 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js'; +import { ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js'; +import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IAgentSession, IAgentSessionsModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IActiveSessionItem, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js'; + +function makeAgentSession(opts: { + repository?: URI; + worktree?: URI; + providerType?: string; + isArchived?: boolean; + worktreePath?: string; +}): IActiveSessionItem & IAgentSession { + return { + resource: URI.parse('file:///session'), + repository: opts.repository, + worktree: opts.worktree, + providerType: opts.providerType ?? AgentSessionProviders.Local, + setArchived: () => { }, + setRead: () => { }, + isArchived: () => opts.isArchived ?? false, + isRead: () => true, + metadata: opts.worktreePath ? { worktreePath: opts.worktreePath } : undefined, + } as unknown as IActiveSessionItem & IAgentSession; +} + +function makeNonAgentSession(opts: { repository?: URI; worktree?: URI }): IActiveSessionItem { + return { + repository: opts.repository, + worktree: opts.worktree, + } as IActiveSessionItem; +} + +suite('SessionsTerminalContribution', () => { + + const store = new DisposableStore(); + let contribution: SessionsTerminalContribution; + let activeSessionObs: ReturnType>; + let onDidChangeSessionArchivedState: Emitter; + let onDidDisposeInstance: Emitter; + + let createdTerminals: { cwd: URI }[]; + let activeInstanceSet: number[]; + let focusCalls: number; + let disposedInstances: ITerminalInstance[]; + let nextInstanceId: number; + let terminalInstances: Map; + + setup(() => { + createdTerminals = []; + activeInstanceSet = []; + focusCalls = 0; + disposedInstances = []; + nextInstanceId = 1; + terminalInstances = new Map(); + + const instantiationService = store.add(new TestInstantiationService()); + + activeSessionObs = observableValue('activeSession', undefined); + onDidChangeSessionArchivedState = store.add(new Emitter()); + onDidDisposeInstance = store.add(new Emitter()); + + instantiationService.stub(ILogService, new NullLogService()); + + instantiationService.stub(ISessionsManagementService, new class extends mock() { + override activeSession = activeSessionObs; + }); + + instantiationService.stub(ITerminalService, new class extends mock() { + override onDidDisposeInstance = onDidDisposeInstance.event; + override async createTerminal(opts?: any): Promise { + const id = nextInstanceId++; + const instance = { instanceId: id } as ITerminalInstance; + createdTerminals.push({ cwd: opts?.config?.cwd }); + terminalInstances.set(id, instance); + return instance; + } + override getInstanceFromId(id: number): ITerminalInstance | undefined { + return terminalInstances.get(id); + } + override setActiveInstance(instance: ITerminalInstance): void { + activeInstanceSet.push(instance.instanceId); + } + override async focusActiveInstance(): Promise { + focusCalls++; + } + override async safeDisposeTerminal(instance: ITerminalInstance): Promise { + disposedInstances.push(instance); + terminalInstances.delete(instance.instanceId); + } + }); + + instantiationService.stub(IAgentSessionsService, new class extends mock() { + override model = { + onDidChangeSessionArchivedState: onDidChangeSessionArchivedState.event, + } as unknown as IAgentSessionsModel; + }); + + contribution = store.add(instantiationService.createInstance(SessionsTerminalContribution)); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // --- getSessionCwd logic (via active session changes) --- + + test('creates a terminal when active session has a worktree (non-cloud agent)', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ worktree: worktreeUri, repository: URI.file('/repo'), providerType: AgentSessionProviders.Local }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath); + }); + + test('creates a terminal with repository for cloud agent sessions', async () => { + const repoUri = URI.file('/repo'); + const session = makeAgentSession({ worktree: URI.file('/worktree'), repository: repoUri, providerType: AgentSessionProviders.Cloud }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); + }); + + test('creates a terminal with repository for non-agent sessions', async () => { + const repoUri = URI.file('/repo'); + const session = makeNonAgentSession({ repository: repoUri }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); + }); + + test('does not create a terminal when no path is available', async () => { + const session = makeNonAgentSession({}); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 0); + }); + + test('does not recreate terminal for the same path', async () => { + const worktreeUri = URI.file('/worktree'); + const session1 = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Local }); + activeSessionObs.set(session1, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + + // Setting a different session with the same worktree should not create a new terminal + const session2 = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Local }); + activeSessionObs.set(session2, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + }); + + test('creates new terminal when switching to a different path', async () => { + const worktree1 = URI.file('/worktree1'); + const worktree2 = URI.file('/worktree2'); + + activeSessionObs.set(makeAgentSession({ worktree: worktree1, providerType: AgentSessionProviders.Local }), undefined); + await tick(); + + activeSessionObs.set(makeAgentSession({ worktree: worktree2, providerType: AgentSessionProviders.Local }), undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 2); + assert.strictEqual(createdTerminals[1].cwd.fsPath, worktree2.fsPath); + }); + + // --- ensureTerminal --- + + test('ensureTerminal creates terminal and sets it active', async () => { + const cwd = URI.file('/test-cwd'); + await contribution.ensureTerminal(cwd, false); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, cwd.fsPath); + assert.strictEqual(activeInstanceSet.length, 1); + assert.strictEqual(focusCalls, 0); + }); + + test('ensureTerminal focuses when requested', async () => { + const cwd = URI.file('/test-cwd'); + await contribution.ensureTerminal(cwd, true); + + assert.strictEqual(focusCalls, 1); + }); + + test('ensureTerminal reuses existing terminal for same path', async () => { + const cwd = URI.file('/test-cwd'); + await contribution.ensureTerminal(cwd, false); + await contribution.ensureTerminal(cwd, false); + + assert.strictEqual(createdTerminals.length, 1, 'should reuse the existing terminal'); + assert.strictEqual(activeInstanceSet.length, 2, 'should set active instance both times'); + }); + + test('ensureTerminal creates new terminal for different path', async () => { + await contribution.ensureTerminal(URI.file('/cwd1'), false); + await contribution.ensureTerminal(URI.file('/cwd2'), false); + + assert.strictEqual(createdTerminals.length, 2); + }); + + test('ensureTerminal path comparison is case-insensitive', async () => { + await contribution.ensureTerminal(URI.file('/Test/CWD'), false); + await contribution.ensureTerminal(URI.file('/test/cwd'), false); + + assert.strictEqual(createdTerminals.length, 1, 'should match case-insensitively'); + }); + + // --- onDidChangeSessionArchivedState --- + + test('closes terminals when session is archived', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + assert.strictEqual(createdTerminals.length, 1); + + const session = makeAgentSession({ + isArchived: true, + worktreePath: worktreeUri.fsPath, + }); + onDidChangeSessionArchivedState.fire(session); + + assert.strictEqual(disposedInstances.length, 1); + }); + + test('does not close terminals when session is not archived', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + const session = makeAgentSession({ + isArchived: false, + worktreePath: worktreeUri.fsPath, + }); + onDidChangeSessionArchivedState.fire(session); + + assert.strictEqual(disposedInstances.length, 0); + }); + + test('does not close terminals when archived session has no worktreePath', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + const session = makeAgentSession({ isArchived: true }); + onDidChangeSessionArchivedState.fire(session); + + assert.strictEqual(disposedInstances.length, 0); + }); + + // --- onDidDisposeInstance --- + + test('cleans up path mapping when terminal is disposed externally', async () => { + const cwd = URI.file('/test-cwd'); + await contribution.ensureTerminal(cwd, false); + assert.strictEqual(createdTerminals.length, 1); + + // Simulate external disposal of the terminal + const instanceId = activeInstanceSet[0]; + const instance = terminalInstances.get(instanceId)!; + onDidDisposeInstance.fire(instance); + + // Now ensureTerminal should create a new one since the mapping was cleaned up + await contribution.ensureTerminal(cwd, false); + assert.strictEqual(createdTerminals.length, 2, 'should create a new terminal after the old one was disposed'); + }); + + // --- agent session with worktree preferred over repository for non-cloud --- + + test('prefers worktree over repository for local agent session', async () => { + const worktreeUri = URI.file('/worktree'); + const repoUri = URI.file('/repo'); + const session = makeAgentSession({ + worktree: worktreeUri, + repository: repoUri, + providerType: AgentSessionProviders.Local, + }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath); + }); + + test('falls back to repository when worktree is undefined for agent session', async () => { + const repoUri = URI.file('/repo'); + const session = makeAgentSession({ + repository: repoUri, + providerType: AgentSessionProviders.Local, + }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); + }); + + test('uses repository for cloud agent session even when worktree exists', async () => { + const worktreeUri = URI.file('/worktree'); + const repoUri = URI.file('/repo'); + const session = makeAgentSession({ + worktree: worktreeUri, + repository: repoUri, + providerType: AgentSessionProviders.Cloud, + }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); + }); + + // --- switching back to previously used path reuses terminal --- + + test('switching back to a previously used path reuses the existing terminal', async () => { + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Local }), undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 1); + + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Local }), undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 2); + + // Switch back to cwd1 - should reuse terminal, not create a new one + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Local }), undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 2, 'should reuse the terminal for cwd1'); + }); +}); + +function tick(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} From f8f3e01afce0f5aea3acc6b87befe91a27aafa3f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 25 Feb 2026 09:56:09 +0100 Subject: [PATCH 075/541] restructure and fix showing repo picker twice (#297633) * restructure * fix showing repositories picker twice --- .../contrib/chat/browser/newChatViewPane.ts | 265 +----------------- .../contrib/chat/browser/newSession.ts | 6 +- .../contrib/chat/browser/slashCommands.ts | 265 ++++++++++++++++++ 3 files changed, 280 insertions(+), 256 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/slashCommands.ts diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 65201f7b6cfcf..cfd5e2a7bf61e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -13,42 +13,34 @@ import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; import { IModelService } from '../../../../editor/common/services/model.js'; -import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; -import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; -import { CompletionContext, CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js'; -import { ITextModel } from '../../../../editor/common/model.js'; -import { IDecorationOptions } from '../../../../editor/common/editorCommon.js'; -import { Position } from '../../../../editor/common/core/position.js'; -import { Range } from '../../../../editor/common/core/range.js'; -import { getWordAtText } from '../../../../editor/common/core/wordHelper.js'; -import { themeColorFromId } from '../../../../base/common/themables.js'; + + import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { inputPlaceholderForeground } from '../../../../platform/theme/common/colorRegistry.js'; + import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; + import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; @@ -73,20 +65,7 @@ import { INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession import { RepoPicker } from './repoPicker.js'; import { CloudModelPicker } from './modelPicker.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; -import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; - -/** - * Minimal slash command descriptor for the sessions new-chat widget. - * Self-contained copy of the essential fields from core's `IChatSlashData` - * to avoid a direct dependency on the workbench chat slash command service. - */ -interface ISessionsSlashCommandData { - readonly command: string; - readonly detail: string; - readonly sortText?: string; - readonly executeImmediately?: boolean; - readonly execute: (args: string) => void; -} +import { SlashCommandHandler } from './slashCommands.js'; const STORAGE_KEY_LAST_MODEL = 'sessions.selectedModel'; @@ -155,7 +134,7 @@ class NewChatWidget extends Disposable { private readonly _contextAttachments: NewChatContextAttachments; // Slash commands - private readonly _slashCommands: ISessionsSlashCommandData[] = []; + private _slashCommandHandler: SlashCommandHandler | undefined; constructor( options: INewChatWidgetOptions, @@ -169,10 +148,6 @@ class NewChatWidget extends Disposable { @IHoverService private readonly hoverService: IHoverService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, - @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, - @ICodeEditorService private readonly codeEditorService: ICodeEditorService, - @IThemeService private readonly themeService: IThemeService, - @ICommandService private readonly commandService: ICommandService, @IGitService private readonly gitService: IGitService, @IStorageService private readonly storageService: IStorageService, ) { @@ -195,9 +170,6 @@ class NewChatWidget extends Disposable { this._focusEditor(); })); - // Register slash commands - this._registerSlashCommands(); - this._register(this._branchPicker.onDidChangeLoading(loading => { this._branchLoading = loading; this._updateInputLoadingState(); @@ -461,11 +433,8 @@ class NewChatWidget extends Disposable { this._editor.layout(); })); - // Register slash command completions for this editor - this._registerSlashCommandCompletions(); - - // Register slash command decorations (blue highlight + placeholder) - this._registerSlashCommandDecorations(); + // Slash commands + this._slashCommandHandler = this._register(this.instantiationService.createInstance(SlashCommandHandler, this._editor)); this._register(this._editor.onDidChangeModelContent(() => { this._updateSendButtonState(); @@ -811,7 +780,7 @@ class NewChatWidget extends Disposable { } // Check for slash commands first - if (this._tryExecuteSlashCommand(query)) { + if (this._slashCommandHandler?.tryExecuteSlashCommand(query)) { this._editor.getModel()?.setValue(''); return; } @@ -843,220 +812,6 @@ class NewChatWidget extends Disposable { }); } - // --- Slash commands --- - - private _registerSlashCommands(): void { - const openSection = (section: AICustomizationManagementSection) => - () => this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, section); - - this._slashCommands.push({ - command: 'agents', - detail: localize('slashCommand.agents', "View and manage custom agents"), - sortText: 'z3_agents', - executeImmediately: true, - execute: openSection(AICustomizationManagementSection.Agents), - }); - this._slashCommands.push({ - command: 'skills', - detail: localize('slashCommand.skills', "View and manage skills"), - sortText: 'z3_skills', - executeImmediately: true, - execute: openSection(AICustomizationManagementSection.Skills), - }); - this._slashCommands.push({ - command: 'instructions', - detail: localize('slashCommand.instructions', "View and manage instructions"), - sortText: 'z3_instructions', - executeImmediately: true, - execute: openSection(AICustomizationManagementSection.Instructions), - }); - this._slashCommands.push({ - command: 'prompts', - detail: localize('slashCommand.prompts', "View and manage prompt files"), - sortText: 'z3_prompts', - executeImmediately: true, - execute: openSection(AICustomizationManagementSection.Prompts), - }); - this._slashCommands.push({ - command: 'hooks', - detail: localize('slashCommand.hooks', "View and manage hooks"), - sortText: 'z3_hooks', - executeImmediately: true, - execute: openSection(AICustomizationManagementSection.Hooks), - }); - this._slashCommands.push({ - command: 'mcp', - detail: localize('slashCommand.mcp', "View and manage MCP servers"), - sortText: 'z3_mcp', - executeImmediately: true, - execute: openSection(AICustomizationManagementSection.McpServers), - }); - this._slashCommands.push({ - command: 'models', - detail: localize('slashCommand.models', "View and manage models"), - sortText: 'z3_models', - executeImmediately: true, - execute: openSection(AICustomizationManagementSection.Models), - }); - } - - private static readonly _slashDecoType = 'sessions-slash-command'; - private static readonly _slashPlaceholderDecoType = 'sessions-slash-placeholder'; - private static _slashDecosRegistered = false; - - private _registerSlashCommandDecorations(): void { - if (!NewChatWidget._slashDecosRegistered) { - NewChatWidget._slashDecosRegistered = true; - this.codeEditorService.registerDecorationType('sessions-chat', NewChatWidget._slashDecoType, { - color: themeColorFromId(chatSlashCommandForeground), - backgroundColor: themeColorFromId(chatSlashCommandBackground), - borderRadius: '3px', - }); - this.codeEditorService.registerDecorationType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, {}); - } - - this._register(this._editor.onDidChangeModelContent(() => this._updateSlashCommandDecorations())); - this._updateSlashCommandDecorations(); - } - - private _updateSlashCommandDecorations(): void { - const model = this._editor.getModel(); - const value = model?.getValue() ?? ''; - const match = value.match(/^\/(\w+)\s?/); - - if (!match) { - this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashDecoType, []); - this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, []); - return; - } - - const commandName = match[1]; - const slashCommand = this._slashCommands.find(c => c.command === commandName); - if (!slashCommand) { - this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashDecoType, []); - this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, []); - return; - } - - // Highlight the slash command text in blue - const commandEnd = match[0].trimEnd().length; - const commandDeco: IDecorationOptions[] = [{ - range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: commandEnd + 1 }, - }]; - this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashDecoType, commandDeco); - - // Show the command description as a placeholder after the command - const restOfInput = value.slice(match[0].length).trim(); - if (!restOfInput && slashCommand.detail) { - const placeholderCol = match[0].length + 1; - const placeholderDeco: IDecorationOptions[] = [{ - range: { startLineNumber: 1, startColumn: placeholderCol, endLineNumber: 1, endColumn: model!.getLineMaxColumn(1) }, - renderOptions: { - after: { - contentText: slashCommand.detail, - color: this._getPlaceholderColor(), - } - } - }]; - this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, placeholderDeco); - } else { - this._editor.setDecorationsByType('sessions-chat', NewChatWidget._slashPlaceholderDecoType, []); - } - } - - private _getPlaceholderColor(): string | undefined { - const theme = this.themeService.getColorTheme(); - return theme.getColor(inputPlaceholderForeground)?.toString(); - } - - /** - * Attempts to parse and execute a slash command from the input. - * Returns `true` if a command was handled. - */ - private _tryExecuteSlashCommand(query: string): boolean { - const match = query.match(/^\/(\w+)\s*(.*)/s); - if (!match) { - return false; - } - - const commandName = match[1]; - const slashCommand = this._slashCommands.find(c => c.command === commandName); - if (!slashCommand) { - return false; - } - - slashCommand.execute(match[2]?.trim() ?? ''); - return true; - } - - private _registerSlashCommandCompletions(): void { - const uri = this._editor.getModel()?.uri; - if (!uri) { - return; - } - - // Built-in slash commands - this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { - _debugDisplayName: 'sessionsSlashCommands', - triggerCharacters: ['/'], - provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { - const range = this._computeCompletionRanges(model, position, /\/\w*/g); - if (!range) { - return null; - } - - // Only allow slash commands at the start of input - const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); - if (textBefore.trim() !== '') { - return null; - } - - return { - suggestions: this._slashCommands.map((c, i): CompletionItem => { - const withSlash = `/${c.command}`; - return { - label: withSlash, - insertText: `${withSlash} `, - detail: c.detail, - range, - sortText: c.sortText ?? 'a'.repeat(i + 1), - kind: CompletionItemKind.Text, - }; - }) - }; - } - })); - } - - /** - * Compute insert and replace ranges for completion at the given position. - * Minimal copy of the helper from chatInputCompletions. - */ - private _computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp): { insert: Range; replace: Range } | undefined { - const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0); - if (!varWord && model.getWordUntilPosition(position).word) { - return; - } - - if (!varWord && position.column > 1) { - const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column)); - if (textBefore !== ' ') { - return; - } - } - - let insert: Range; - let replace: Range; - if (!varWord) { - insert = replace = Range.fromPositions(position); - } else { - insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column); - replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn); - } - - return { insert, replace }; - } - /** * Checks whether the required folder/repo selection exists for the given session type. * For Local/Background targets, checks the folder picker. diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index 760f9dc27ea7d..c0879b5875d7a 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -269,7 +269,7 @@ export class RemoteNewSession extends Disposable implements INewSession { return []; } return groups - .filter(g => !isModelOptionGroup(g) && this._isOptionGroupVisible(g)) + .filter(g => !isModelOptionGroup(g) && !isRepositoriesOptionGroup(g) && this._isOptionGroupVisible(g)) .map(g => ({ group: g, value: this._getValueForGroup(g) })); } @@ -341,3 +341,7 @@ function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { const nameLower = group.name.toLowerCase(); return nameLower === 'model' || nameLower === 'models'; } + +function isRepositoriesOptionGroup(group: IChatSessionProviderOptionGroup): boolean { + return group.id === 'repositories'; +} diff --git a/src/vs/sessions/contrib/chat/browser/slashCommands.ts b/src/vs/sessions/contrib/chat/browser/slashCommands.ts new file mode 100644 index 0000000000000..401961417c0be --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/slashCommands.ts @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { themeColorFromId } from '../../../../base/common/themables.js'; +import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { CompletionContext, CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { IDecorationOptions } from '../../../../editor/common/editorCommon.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { getWordAtText } from '../../../../editor/common/core/wordHelper.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { inputPlaceholderForeground } from '../../../../platform/theme/common/colorRegistry.js'; +import { localize } from '../../../../nls.js'; +import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; + +/** + * Minimal slash command descriptor for the sessions new-chat widget. + * Self-contained copy of the essential fields from core's `IChatSlashData` + * to avoid a direct dependency on the workbench chat slash command service. + */ +interface ISessionsSlashCommandData { + readonly command: string; + readonly detail: string; + readonly sortText?: string; + readonly executeImmediately?: boolean; + readonly execute: (args: string) => void; +} + +/** + * Manages slash commands for the sessions new-chat input widget — registration, + * autocompletion, decorations (syntax highlighting + placeholder text), and execution. + */ +export class SlashCommandHandler extends Disposable { + + private static readonly _slashDecoType = 'sessions-slash-command'; + private static readonly _slashPlaceholderDecoType = 'sessions-slash-placeholder'; + private static _slashDecosRegistered = false; + + private readonly _slashCommands: ISessionsSlashCommandData[] = []; + + constructor( + private readonly _editor: CodeEditorWidget, + @ICommandService private readonly commandService: ICommandService, + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IThemeService private readonly themeService: IThemeService, + ) { + super(); + this._registerSlashCommands(); + this._registerCompletions(); + this._registerDecorations(); + } + + /** + * Attempts to parse and execute a slash command from the input. + * Returns `true` if a command was handled. + */ + tryExecuteSlashCommand(query: string): boolean { + const match = query.match(/^\/(\w+)\s*(.*)/s); + if (!match) { + return false; + } + + const commandName = match[1]; + const slashCommand = this._slashCommands.find(c => c.command === commandName); + if (!slashCommand) { + return false; + } + + slashCommand.execute(match[2]?.trim() ?? ''); + return true; + } + + private _registerSlashCommands(): void { + const openSection = (section: AICustomizationManagementSection) => + () => this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, section); + + this._slashCommands.push({ + command: 'agents', + detail: localize('slashCommand.agents', "View and manage custom agents"), + sortText: 'z3_agents', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Agents), + }); + this._slashCommands.push({ + command: 'skills', + detail: localize('slashCommand.skills', "View and manage skills"), + sortText: 'z3_skills', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Skills), + }); + this._slashCommands.push({ + command: 'instructions', + detail: localize('slashCommand.instructions', "View and manage instructions"), + sortText: 'z3_instructions', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Instructions), + }); + this._slashCommands.push({ + command: 'prompts', + detail: localize('slashCommand.prompts', "View and manage prompt files"), + sortText: 'z3_prompts', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Prompts), + }); + this._slashCommands.push({ + command: 'hooks', + detail: localize('slashCommand.hooks', "View and manage hooks"), + sortText: 'z3_hooks', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Hooks), + }); + this._slashCommands.push({ + command: 'mcp', + detail: localize('slashCommand.mcp', "View and manage MCP servers"), + sortText: 'z3_mcp', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.McpServers), + }); + this._slashCommands.push({ + command: 'models', + detail: localize('slashCommand.models', "View and manage models"), + sortText: 'z3_models', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Models), + }); + } + + private _registerDecorations(): void { + if (!SlashCommandHandler._slashDecosRegistered) { + SlashCommandHandler._slashDecosRegistered = true; + this.codeEditorService.registerDecorationType('sessions-chat', SlashCommandHandler._slashDecoType, { + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), + borderRadius: '3px', + }); + this.codeEditorService.registerDecorationType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, {}); + } + + this._register(this._editor.onDidChangeModelContent(() => this._updateDecorations())); + this._updateDecorations(); + } + + private _updateDecorations(): void { + const model = this._editor.getModel(); + const value = model?.getValue() ?? ''; + const match = value.match(/^\/(\w+)\s?/); + + if (!match) { + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, []); + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []); + return; + } + + const commandName = match[1]; + const slashCommand = this._slashCommands.find(c => c.command === commandName); + if (!slashCommand) { + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, []); + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []); + return; + } + + // Highlight the slash command text + const commandEnd = match[0].trimEnd().length; + const commandDeco: IDecorationOptions[] = [{ + range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: commandEnd + 1 }, + }]; + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, commandDeco); + + // Show the command description as a placeholder after the command + const restOfInput = value.slice(match[0].length).trim(); + if (!restOfInput && slashCommand.detail) { + const placeholderCol = match[0].length + 1; + const placeholderDeco: IDecorationOptions[] = [{ + range: { startLineNumber: 1, startColumn: placeholderCol, endLineNumber: 1, endColumn: model!.getLineMaxColumn(1) }, + renderOptions: { + after: { + contentText: slashCommand.detail, + color: this._getPlaceholderColor(), + } + } + }]; + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, placeholderDeco); + } else { + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []); + } + } + + private _getPlaceholderColor(): string | undefined { + const theme = this.themeService.getColorTheme(); + return theme.getColor(inputPlaceholderForeground)?.toString(); + } + + private _registerCompletions(): void { + const uri = this._editor.getModel()?.uri; + if (!uri) { + return; + } + + this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { + _debugDisplayName: 'sessionsSlashCommands', + triggerCharacters: ['/'], + provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + const range = this._computeCompletionRanges(model, position, /\/\w*/g); + if (!range) { + return null; + } + + // Only allow slash commands at the start of input + const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); + if (textBefore.trim() !== '') { + return null; + } + + return { + suggestions: this._slashCommands.map((c, i): CompletionItem => { + const withSlash = `/${c.command}`; + return { + label: withSlash, + insertText: `${withSlash} `, + detail: c.detail, + range, + sortText: c.sortText ?? 'a'.repeat(i + 1), + kind: CompletionItemKind.Text, + }; + }) + }; + } + })); + } + + private _computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp): { insert: Range; replace: Range } | undefined { + const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0); + if (!varWord && model.getWordUntilPosition(position).word) { + return; + } + + if (!varWord && position.column > 1) { + const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column)); + if (textBefore !== ' ') { + return; + } + } + + let insert: Range; + let replace: Range; + if (!varWord) { + insert = replace = Range.fromPositions(position); + } else { + insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column); + replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn); + } + + return { insert, replace }; + } +} From 70d4bf6d506d5e288babb272d154f440a2235cf7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:04:54 +0000 Subject: [PATCH 076/541] Initial plan From bd72bb9983665aee27da7cc29191cc7877b1756e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:06:53 +0000 Subject: [PATCH 077/541] Initial plan From 26b55f7c8d866a8467a305955ccf9cffea9b2237 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:08:52 +0000 Subject: [PATCH 078/541] fix: guard CancelSessionAction menu with CTX_HOVER_MODE.negate() Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../workbench/contrib/inlineChat/browser/inlineChatActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 3007bac0d9158..2c049c4fe55b5 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -405,7 +405,7 @@ export class CancelSessionAction extends KeepOrUndoSessionAction { id: MenuId.ChatEditorInlineExecute, group: 'navigation', order: 100, - when: ctxHasRequestInProgress + when: ContextKeyExpr.and(CTX_HOVER_MODE.negate(), ctxHasRequestInProgress) }] }); } From 55b26c00b5a32d3301191411b63eb0a73bc40b23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:09:52 +0000 Subject: [PATCH 079/541] Enable inlineChat.fixDiagnostics by default Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- src/vs/workbench/contrib/inlineChat/common/inlineChat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 5eb1312bd5a33..9a995020bd230 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -87,7 +87,7 @@ Registry.as(Extensions.Configuration).registerConfigurat }, [InlineChatConfigKeys.FixDiagnostics]: { description: localize('fixDiagnostics', "Controls whether the Fix action is shown for diagnostics in the editor."), - default: false, + default: true, type: 'boolean', experiment: { mode: 'auto' From 196148fe8164ec4d69aa5ff7eb6a6f685e9b3dd5 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 25 Feb 2026 10:15:32 +0100 Subject: [PATCH 080/541] Enhance message sending functionality in NewChatWidget to open a new session after sending with Alt + Enter --- .../sessions/contrib/chat/browser/newChatViewPane.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 7f549ee1cd900..05d374becfb38 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -386,6 +386,11 @@ class NewChatWidget extends Disposable { e.stopPropagation(); this._send(); } + if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && e.altKey) { + e.preventDefault(); + e.stopPropagation(); + this._send(/* openNewAfterSend */ true); + } })); this._register(this._editor.onDidContentSizeChange(() => { @@ -776,7 +781,7 @@ class NewChatWidget extends Disposable { this._sendButton.enabled = !this._sending && hasText && !(this._newSession.value?.disabled ?? true); } - private _send(): void { + private _send(openNewAfterSend = false): void { const query = this._editor.getModel()?.getValue().trim(); const session = this._newSession.value; if (!query || !session || session.disabled || this._sending) { @@ -800,6 +805,9 @@ class NewChatWidget extends Disposable { this._newSession.clearAndLeak(); this._newSessionListener.clear(); this._contextAttachments.clear(); + if (openNewAfterSend) { + this.sessionsManagementService.openNewSessionView(); + } }, e => { this.logService.error('Failed to send request:', e); }).finally(() => { From 86c7c6bb41950d6177add4b0c7df021c0e9809f5 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 25 Feb 2026 10:47:28 +0100 Subject: [PATCH 081/541] notifications - go back to default (#297635) --- src/vs/workbench/browser/workbench.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 36923cbd8191f..77efbf97beeac 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -620,7 +620,7 @@ const registry = Registry.as(ConfigurationExtensions.Con [NotificationsSettings.NOTIFICATIONS_POSITION]: { 'type': 'string', 'enum': [NotificationsPosition.BOTTOM_RIGHT, NotificationsPosition.BOTTOM_LEFT, NotificationsPosition.TOP_RIGHT], - 'default': product.quality !== 'stable' ? NotificationsPosition.TOP_RIGHT : NotificationsPosition.BOTTOM_RIGHT, + 'default': NotificationsPosition.BOTTOM_RIGHT, 'description': localize('notificationsPosition', "Controls the position of the notification toasts and notification center."), 'enumDescriptions': [ localize('workbench.notifications.position.bottom-right', "Show notifications in the bottom right corner."), From 86aaef9c8273fbc7f2d8ce7926c514dcb7dcf550 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 25 Feb 2026 10:58:11 +0100 Subject: [PATCH 082/541] Add Alt key support for send button in NewChatWidget --- .../contrib/chat/browser/newChatViewPane.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 05d374becfb38..1038a951ae2bd 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -98,6 +98,7 @@ class NewChatWidget extends Disposable { // Send button private _sendButton: Button | undefined; private _sending = false; + private _altKeyDown = false; // Repository loading private readonly _openRepositoryCts = this._register(new MutableDisposable()); @@ -466,7 +467,19 @@ class NewChatWidget extends Disposable { ariaLabel: localize('send', "Send"), })); sendButton.icon = Codicon.send; - this._register(sendButton.onDidClick(() => this._send())); + this._register(sendButton.onDidClick(() => this._send(this._altKeyDown))); + this._register(dom.addDisposableListener(dom.getWindow(container), dom.EventType.KEY_DOWN, e => { + if (e.key === 'Alt') { + this._altKeyDown = true; + sendButton.icon = Codicon.runAbove; + } + })); + this._register(dom.addDisposableListener(dom.getWindow(container), dom.EventType.KEY_UP, e => { + if (e.key === 'Alt') { + this._altKeyDown = false; + sendButton.icon = Codicon.send; + } + })); this._updateSendButtonState(); } From 141e8bd1ee61f641ae4609bec9b58b30087d0fcd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:07:26 +0000 Subject: [PATCH 083/541] fix: show Cancel action only in hover mode (CTX_HOVER_MODE) Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../workbench/contrib/inlineChat/browser/inlineChatActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 2c049c4fe55b5..022402a7f9d99 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -405,7 +405,7 @@ export class CancelSessionAction extends KeepOrUndoSessionAction { id: MenuId.ChatEditorInlineExecute, group: 'navigation', order: 100, - when: ContextKeyExpr.and(CTX_HOVER_MODE.negate(), ctxHasRequestInProgress) + when: ContextKeyExpr.and(CTX_HOVER_MODE, ctxHasRequestInProgress) }] }); } From b141d4987882d43ec52e6c4b73f96a0f93a40a8a Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 25 Feb 2026 11:18:47 +0100 Subject: [PATCH 084/541] Enhance session management to support opening a new session view after sending a request --- .../contrib/chat/browser/newChatViewPane.ts | 6 ++---- .../browser/sessionsManagementService.ts | 16 +++++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 1038a951ae2bd..6c45f615804f4 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -812,15 +812,13 @@ class NewChatWidget extends Disposable { this._updateInputLoadingState(); this.sessionsManagementService.sendRequestForNewSession( - session.resource + session.resource, + openNewAfterSend ? { openNewSessionView: true } : undefined ).then(() => { // Release ref without disposing - the service owns disposal this._newSession.clearAndLeak(); this._newSessionListener.clear(); this._contextAttachments.clear(); - if (openNewAfterSend) { - this.sessionsManagementService.openNewSessionView(); - } }, e => { this.logService.error('Failed to send request:', e); }).finally(() => { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 059c8e51cdd7f..bcfd988d9fff7 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -82,8 +82,10 @@ export interface ISessionsManagementService { /** * Open a new session, apply options, and send the initial request. * Looks up the session by resource URI and builds send options from it. + * When `openNewSessionView` is true, opens a new session view after sending + * instead of navigating to the newly created session. */ - sendRequestForNewSession(sessionResource: URI): Promise; + sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean }): Promise; /** * Commit files in a worktree and refresh the agent sessions model @@ -265,7 +267,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.logService.info(`[ActiveSessionService] Active session changed (new): ${sessionResource.toString()}, repository: ${repository?.toString() ?? 'none'}`); } - async sendRequestForNewSession(sessionResource: URI): Promise { + async sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean }): Promise { const session = this._newSession.value; if (!session) { this.logService.error(`[SessionsManagementService] No new session found for resource: ${sessionResource.toString()}`); @@ -299,13 +301,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa }; await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); - await this.doSendRequestForNewSession(session, query, sendOptions, session.selectedOptions); + await this.doSendRequestForNewSession(session, query, sendOptions, session.selectedOptions, options?.openNewSessionView); // Clean up the session after sending (setter disposes the previous value) this._newSession.value = undefined; } - private async doSendRequestForNewSession(session: INewSession, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise { + private async doSendRequestForNewSession(session: INewSession, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap, openNewSessionView?: boolean): Promise { // 1. Open the session - loads the model and shows the ChatViewPane await this.openSession(session.resource); @@ -362,7 +364,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } if (newSession) { - this.setActiveSession(newSession); + if (openNewSessionView) { + this.openNewSessionView(); + } else { + this.setActiveSession(newSession); + } } } From ad49d116addb99e24d3bcdb7038d4bbf8fc68684 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 25 Feb 2026 11:37:51 +0100 Subject: [PATCH 085/541] Prevent opening a session view when the openNewSessionView option is false in doSendRequestForNewSession --- .../contrib/sessions/browser/sessionsManagementService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index bcfd988d9fff7..774887cfd42c6 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -309,7 +309,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private async doSendRequestForNewSession(session: INewSession, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap, openNewSessionView?: boolean): Promise { // 1. Open the session - loads the model and shows the ChatViewPane - await this.openSession(session.resource); + if (!openNewSessionView) { + await this.openSession(session.resource); + } // 2. Apply selected options (repository, branch, etc.) to the contributed session if (selectedOptions && selectedOptions.size > 0) { From 5038b2d92ddd9b752cdbc0b7920ecda04e5b30e0 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 25 Feb 2026 11:44:51 +0100 Subject: [PATCH 086/541] improve active session change event (#297650) --- .../browser/sessionsManagementService.ts | 35 +++++++++++-------- .../browser/sessionsTitleBarWidget.ts | 12 ++----- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index bc9392da04e94..d825b066c960b 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { equals } from '../../../../base/common/objects.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -35,18 +36,12 @@ const repositoryOptionId = 'repository'; * - For agent session items: repository is the workingDirectory from metadata * - For new sessions: repository comes from the session option with id 'repository' */ -export type IActiveSessionItem = (INewSession | IAgentSession) & { - readonly label?: string; - /** - * The repository URI for this session. - */ +export interface IActiveSessionItem { + readonly resource: URI; + readonly label: string | undefined; readonly repository: URI | undefined; - - /** - * The worktree URI for this session. - */ readonly worktree: URI | undefined; -}; +} export interface ISessionsManagementService { readonly _serviceBrand: undefined; @@ -383,29 +378,39 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.lastSelectedSession = session.resource; const [repository, worktree] = this.getRepositoryFromMetadata(session.metadata); activeSessionItem = { - ...session, + label: session.label, + resource: session.resource, repository, worktree, }; } else { activeSessionItem = { - ...session, + label: undefined, + resource: session.resource, repository: session.repoUri, worktree: undefined, }; this._activeSessionDisposables.add(session.onDidChange(e => { if (e === 'repoUri') { this._activeSession.set({ - ...session, + label: undefined, + resource: session.resource, repository: session.repoUri, worktree: undefined, }, undefined); } })); } - this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}, repository: ${activeSessionItem.repository?.toString() ?? 'none'}`); + } + + if (equals(this._activeSession.get(), activeSessionItem)) { + return; + } + + if (activeSessionItem) { + this.logService.info(`[ActiveSessionService] Active session changed: ${activeSessionItem.resource.toString()}, repository: ${activeSessionItem.repository?.toString() ?? 'none'}`); } else { - this.logService.info('[ActiveSessionService] Active session cleared'); + this.logService.trace('[ActiveSessionService] Active session cleared'); } this._activeSession.set(activeSessionItem, undefined); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index e21c25d56e9e7..ec1d688fa414b 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -25,7 +25,7 @@ import { autorun } from '../../../../base/common/observable.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { getAgentChangesSummary, hasValidDiff, IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { getAgentChangesSummary, hasValidDiff } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { getAgentSessionProvider, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { basename } from '../../../../base/common/resources.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -328,14 +328,8 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { return undefined; } - let changes: IAgentSession['changes'] | undefined; - - if (isAgentSession(activeSession)) { - changes = activeSession.changes; - } else { - const agentSession = this.agentSessionsService.getSession(activeSession.resource); - changes = agentSession?.changes; - } + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + const changes = agentSession?.changes; if (!changes || !hasValidDiff(changes)) { return undefined; From 702760efe958aae22d9b7a841d0d1ab6d1f80697 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 25 Feb 2026 11:54:37 +0100 Subject: [PATCH 087/541] Update openSession method to conditionally set isNewChatSessionContext based on hidden option --- .../sessions/browser/sessionsManagementService.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 774887cfd42c6..273654620d9ed 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -218,8 +218,10 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return this._activeSession.get(); } - async openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise { - this.isNewChatSessionContext.set(false); + async openSession(sessionResource: URI, openOptions?: ISessionOpenOptions & { hidden?: boolean }): Promise { + if (!openOptions?.hidden) { + this.isNewChatSessionContext.set(false); + } const existingSession = this.agentSessionsService.model.getSession(sessionResource); if (existingSession) { await this.openExistingSession(existingSession, openOptions); @@ -309,9 +311,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private async doSendRequestForNewSession(session: INewSession, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap, openNewSessionView?: boolean): Promise { // 1. Open the session - loads the model and shows the ChatViewPane - if (!openNewSessionView) { - await this.openSession(session.resource); - } + await this.openSession(session.resource, openNewSessionView ? { hidden: true } : undefined); // 2. Apply selected options (repository, branch, etc.) to the contributed session if (selectedOptions && selectedOptions.size > 0) { From cd0c48e898c35cd41db9570a1ec04f8d427a9e4a Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 25 Feb 2026 12:09:04 +0100 Subject: [PATCH 088/541] modal - preserve maximised state in memory (#297652) --- src/vs/platform/editor/common/editor.ts | 5 ++++ .../browser/parts/editor/editorParts.ts | 9 +++++- .../browser/parts/editor/modalEditorPart.ts | 7 ++++- .../test/browser/modalEditorGroup.test.ts | 30 +++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index 281ee03246a20..f1477768f3920 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -329,6 +329,11 @@ export interface IEditorOptions { export interface IModalEditorPartOptions { + /** + * Whether the modal editor should be maximized. + */ + readonly maximized?: boolean; + /** * The navigation context for navigating between items * within this modal editor. Pass `undefined` to clear. diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index c2ccf4a7316b0..eee5f12fe0841 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -158,6 +158,8 @@ export class EditorParts extends MultiWindowParts { // Reuse existing modal editor part if it exists @@ -167,7 +169,7 @@ export class EditorParts extends MultiWindowParts { + this.modalEditorMaximized = maximized; + })); + // Events this._onDidAddGroup.fire(part.activeGroup); diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 3e3270d3ee67d..f53f8530a8748 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -294,7 +294,7 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { private readonly _onDidChangeNavigation = this._register(new Emitter()); readonly onDidChangeNavigation = this._onDidChangeNavigation.event; - private _maximized = false; + private _maximized: boolean; get maximized(): boolean { return this._maximized; } private _navigation: IModalEditorNavigation | undefined; @@ -320,6 +320,7 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { const id = ModalEditorPartImpl.COUNTER++; super(editorPartsView, `workbench.parts.modalEditor.${id}`, localize('modalEditorPart', "Modal Editor Area"), windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); + this._maximized = options?.maximized ?? false; this._navigation = options?.navigation; this.enforceModalPartOptions(); @@ -350,6 +351,10 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { } updateOptions(options?: IModalEditorPartOptions): void { + if (typeof options?.maximized === 'boolean' && options.maximized !== this._maximized) { + this.toggleMaximized(); + } + this._navigation = options?.navigation; this._onDidChangeNavigation.fire(options?.navigation); diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index 865cb028552ff..4b65d8b11cec2 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -506,6 +506,36 @@ suite('Modal Editor Group', () => { modalPart.close(); }); + test('modal editor part remembers maximized state across instances', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Open modal and maximize it + const modalPart1 = await parts.createModalEditorPart(); + modalPart1.toggleMaximized(); + assert.strictEqual(modalPart1.maximized, true); + modalPart1.close(); + + // Open a new modal - should remember maximized state + const modalPart2 = await parts.createModalEditorPart(); + assert.strictEqual(modalPart2.maximized, true); + modalPart2.close(); + + // Open another modal after un-maximizing + const modalPart3 = await parts.createModalEditorPart(); + assert.strictEqual(modalPart3.maximized, true); + modalPart3.toggleMaximized(); + assert.strictEqual(modalPart3.maximized, false); + modalPart3.close(); + + // Should now remember non-maximized state + const modalPart4 = await parts.createModalEditorPart(); + assert.strictEqual(modalPart4.maximized, false); + modalPart4.close(); + }); + suite('useModal: all', () => { test('findGroup creates modal and returns its active group', async () => { From 6be9f0fb0f28fe10707f675f6d8f4e7ba99376c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Wed, 25 Feb 2026 12:12:28 +0100 Subject: [PATCH 089/541] fixes #297620 (#297648) --- build/gulpfile.vscode.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index c50bdfcda3f7c..2a8b923395756 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -255,7 +255,6 @@ gulp.task(coreCIOld); const coreCIEsbuild = task.define('core-ci-esbuild', task.series( copyCodiconsTask, - cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask, writeISODate('out-build'), From b9a94fe6d8aa25b92023b3ae926179897a372f93 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 25 Feb 2026 12:28:34 +0100 Subject: [PATCH 090/541] sessions - show file changes summary in changes window (#297656) --- .../contrib/chat/browser/widget/chatListRenderer.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index ae17dc3f8ba9f..ce6710d72ef03 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1403,9 +1403,15 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.checkpoints.showFileChanges'); + return isLocalSession && this.configService.getValue('chat.checkpoints.showFileChanges'); } private getDataForProgressiveRender(element: IChatResponseViewModel) { From 3fdfda2ce181b144be36821d2f71f85d416240a5 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 25 Feb 2026 12:30:09 +0100 Subject: [PATCH 091/541] fix scripts and support npm tasks --- .../contrib/chat/browser/runScriptAction.ts | 9 ++++--- .../browser/sessionsConfigurationService.ts | 24 +++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index 765f8466e2f92..8bfb9f7bc6d01 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -225,7 +225,10 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr return; } - await this._sessionsConfigService.createAndAddTask(command, session, target); + const newTask = await this._sessionsConfigService.createAndAddTask(command, session, target); + if (newTask) { + await this._sessionsConfigService.runTask(newTask, session); + } } private async _pickStorageTarget(session: IActiveSessionItem): Promise { @@ -267,13 +270,13 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr picker.onDidAccept(() => { const selected = picker.activeItems[0]; if (selected && (selected.target !== 'workspace' || hasWorktree)) { - picker.dispose(); resolve(selected.target); + picker.dispose(); } }); picker.onDidHide(() => { - picker.dispose(); resolve(undefined); + picker.dispose(); }); picker.show(); }); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts index 4183a311f60a2..6e9470bf1225f 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -67,7 +67,7 @@ export interface ISessionsConfigurationService { * Creates a new shell task with `inSessions: true` and writes it to * the appropriate tasks.json (user or workspace). */ - createAndAddTask(command: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise; + createAndAddTask(command: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise; /** * Runs a task entry in a terminal, resolving the correct platform @@ -88,6 +88,7 @@ export class SessionsConfigurationService extends Disposable implements ISession declare readonly _serviceBrand: undefined; private static readonly _LAST_RUN_TASK_LABELS_KEY = 'agentSessions.lastRunTaskLabels'; + private static readonly _SUPPORTED_TASK_TYPES = new Set(['shell', 'npm']); private readonly _sessionTasks = observableValue(this, []); private readonly _fileWatcher = this._register(new MutableDisposable()); @@ -151,10 +152,10 @@ export class SessionsConfigurationService extends Disposable implements ISession } } - async createAndAddTask(command: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise { + async createAndAddTask(command: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { - return; + return undefined; } const tasksJson = await this._readTasksJson(tasksJsonUri); @@ -174,6 +175,8 @@ export class SessionsConfigurationService extends Disposable implements ISession if (target === 'workspace') { await this._commitTasksFile(session); } + + return newTask; } async runTask(task: ITaskEntry, session: IActiveSessionItem): Promise { @@ -265,7 +268,7 @@ export class SessionsConfigurationService extends Disposable implements ISession if (workspaceUri) { const workspaceJson = await this._readTasksJson(workspaceUri); if (workspaceJson.tasks) { - result.push(...workspaceJson.tasks); + result.push(...workspaceJson.tasks.filter(t => this._isSupportedTask(t))); } } @@ -274,14 +277,21 @@ export class SessionsConfigurationService extends Disposable implements ISession if (userUri) { const userJson = await this._readTasksJson(userUri); if (userJson.tasks) { - result.push(...userJson.tasks); + result.push(...userJson.tasks.filter(t => this._isSupportedTask(t))); } } return result; } + private _isSupportedTask(task: ITaskEntry): boolean { + return !!task.type && SessionsConfigurationService._SUPPORTED_TASK_TYPES.has(task.type); + } + private _resolveCommand(task: ITaskEntry): string | undefined { + if (task.type === 'npm') { + return task.script ? `npm run ${task.script}` : undefined; + } if (isWindows && task.windows?.command) { return task.windows.command; } @@ -321,12 +331,12 @@ export class SessionsConfigurationService extends Disposable implements ISession const tasksUri = joinPath(folder, '.vscode', 'tasks.json'); const tasksJson = await this._readTasksJson(tasksUri); - const sessionTasks = (tasksJson.tasks ?? []).filter(t => t.inSessions); + const sessionTasks = (tasksJson.tasks ?? []).filter(t => t.inSessions && this._isSupportedTask(t)); // Also include user-level session tasks const userUri = joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json'); const userJson = await this._readTasksJson(userUri); - const userSessionTasks = (userJson.tasks ?? []).filter(t => t.inSessions); + const userSessionTasks = (userJson.tasks ?? []).filter(t => t.inSessions && this._isSupportedTask(t)); transaction(tx => this._sessionTasks.set([...sessionTasks, ...userSessionTasks], tx)); } From a30d791c612c50c6ab23eacdaf86a8d086c2c125 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:38:07 +0100 Subject: [PATCH 092/541] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/sessionsConfigurationService.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts index 6e9470bf1225f..f1a859d4aada1 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -290,7 +290,13 @@ export class SessionsConfigurationService extends Disposable implements ISession private _resolveCommand(task: ITaskEntry): string | undefined { if (task.type === 'npm') { - return task.script ? `npm run ${task.script}` : undefined; + if (!task.script) { + return undefined; + } + if (task.path) { + return `npm --prefix ${task.path} run ${task.script}`; + } + return `npm run ${task.script}`; } if (isWindows && task.windows?.command) { return task.windows.command; From f81c1c042ee8e4543e1c141233abe889683a350a Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 25 Feb 2026 12:38:18 +0100 Subject: [PATCH 093/541] add tests --- .../sessionsConfigurationService.test.ts | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index 46e663337003c..16ad176e190e4 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -30,6 +30,14 @@ function makeTask(label: string, command?: string, inSessions?: boolean): ITaskE return { label, type: 'shell', command: command ?? label, inSessions }; } +function makeNpmTask(label: string, script: string, inSessions?: boolean): ITaskEntry { + return { label, type: 'npm', script, inSessions }; +} + +function makeUnsupportedTask(label: string, inSessions?: boolean): ITaskEntry { + return { label, type: 'gulp', command: label, inSessions }; +} + function tasksJsonContent(tasks: ITaskEntry[]): string { return JSON.stringify({ version: '2.0.0', tasks }); } @@ -128,6 +136,8 @@ suite('SessionsConfigurationService', () => { makeTask('build', 'npm run build', true), makeTask('lint', 'npm run lint', false), makeTask('test', 'npm test', true), + makeNpmTask('watch', 'watch', true), + makeUnsupportedTask('gulp-task', true), ])); // user tasks.json — empty const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); @@ -140,7 +150,7 @@ suite('SessionsConfigurationService', () => { await new Promise(r => setTimeout(r, 10)); const tasks = obs.get(); - assert.deepStrictEqual(tasks.map(t => t.label), ['build', 'test']); + assert.deepStrictEqual(tasks.map(t => t.label), ['build', 'test', 'watch']); }); test('getSessionTasks returns empty array when no worktree', async () => { @@ -169,12 +179,14 @@ suite('SessionsConfigurationService', () => { // --- getNonSessionTasks --- - test('getNonSessionTasks returns only tasks without inSessions', async () => { + test('getNonSessionTasks returns only tasks without inSessions and with supported types', async () => { const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ makeTask('build', 'npm run build', true), makeTask('lint', 'npm run lint', false), makeTask('test', 'npm test'), + makeNpmTask('watch', 'watch', false), + makeUnsupportedTask('gulp-task', false), ])); const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); fileContents.set(userTasksUri.toString(), tasksJsonContent([])); @@ -182,7 +194,7 @@ suite('SessionsConfigurationService', () => { const session = makeSession({ worktree: worktreeUri, repository: repoUri }); const nonSessionTasks = await service.getNonSessionTasks(session); - assert.deepStrictEqual(nonSessionTasks.map(t => t.label), ['lint', 'test']); + assert.deepStrictEqual(nonSessionTasks.map(t => t.label), ['lint', 'test', 'watch']); }); test('getNonSessionTasks reads from repository when no worktree', async () => { @@ -305,6 +317,28 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(sentCommands[0].command, 'npm run build'); }); + test('runTask resolves npm task to npm run