From a453d99821eb0bfcb8084f6030cac8ace2e85fcc Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:10:15 -0800 Subject: [PATCH 01/26] Port git extension to use esbuild Fixes #296355 --- eslint.config.js | 30 ++++++ extensions/git/.vscodeignore | 2 +- extensions/git/esbuild.mts | 20 ++++ extensions/git/extension.webpack.config.js | 17 ---- extensions/git/src/actionButton.ts | 3 +- extensions/git/src/api/api1.ts | 3 +- extensions/git/src/api/extension.ts | 2 +- extensions/git/src/api/git.constants.ts | 98 +++++++++++++++++++ extensions/git/src/artifactProvider.ts | 3 +- extensions/git/src/askpass.ts | 2 +- extensions/git/src/autofetch.ts | 2 +- extensions/git/src/blame.ts | 2 +- extensions/git/src/branchProtection.ts | 2 +- extensions/git/src/commands.ts | 5 +- extensions/git/src/decorationProvider.ts | 3 +- .../git/src/editSessionIdentityProvider.ts | 2 +- extensions/git/src/git.ts | 7 +- .../git/src/historyItemDetailsProvider.ts | 2 +- extensions/git/src/historyProvider.ts | 3 +- extensions/git/src/main.ts | 2 +- extensions/git/src/model.ts | 2 +- extensions/git/src/postCommitCommands.ts | 2 +- extensions/git/src/pushError.ts | 2 +- extensions/git/src/quickDiffProvider.ts | 2 +- extensions/git/src/remotePublisher.ts | 2 +- extensions/git/src/repository.ts | 3 +- extensions/git/src/repositoryCache.ts | 2 +- extensions/git/src/statusbar.ts | 3 +- extensions/git/src/test/smoke.test.ts | 3 +- extensions/git/src/timelineProvider.ts | 2 +- extensions/git/src/uri.ts | 3 +- 31 files changed, 190 insertions(+), 46 deletions(-) create mode 100644 extensions/git/esbuild.mts delete mode 100644 extensions/git/extension.webpack.config.js create mode 100644 extensions/git/src/api/git.constants.ts diff --git a/eslint.config.js b/eslint.config.js index 29ffd569c6085..ec5efb7c5fc94 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -828,6 +828,36 @@ export default tseslint.config( ] } }, + // git extension - ban non-type imports from git.d.ts (use git.constants for runtime values) + { + files: [ + 'extensions/git/src/**/*.ts', + ], + ignores: [ + 'extensions/git/src/api/git.constants.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + rules: { + 'no-restricted-imports': 'off', + '@typescript-eslint/no-restricted-imports': [ + 'warn', + { + 'patterns': [ + { + 'group': ['*/api/git'], + 'allowTypeImports': true, + 'message': 'Use \'import type\' for types from git.d.ts and import runtime const enum values from git.constants instead' + }, + ] + } + ] + } + }, // vscode API { files: [ diff --git a/extensions/git/.vscodeignore b/extensions/git/.vscodeignore index a1fc5df7d26b8..9de840770944a 100644 --- a/extensions/git/.vscodeignore +++ b/extensions/git/.vscodeignore @@ -3,5 +3,5 @@ test/** out/** tsconfig*.json build/** -extension.webpack.config.js +esbuild*.mts package-lock.json diff --git a/extensions/git/esbuild.mts b/extensions/git/esbuild.mts new file mode 100644 index 0000000000000..35c8f6c63f0da --- /dev/null +++ b/extensions/git/esbuild.mts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'main': path.join(srcDir, 'main.ts'), + 'askpass-main': path.join(srcDir, 'askpass-main.ts'), + 'git-editor-main': path.join(srcDir, 'git-editor-main.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/git/extension.webpack.config.js b/extensions/git/extension.webpack.config.js deleted file mode 100644 index 34f801e2eca4e..0000000000000 --- a/extensions/git/extension.webpack.config.js +++ /dev/null @@ -1,17 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - entry: { - main: './src/main.ts', - ['askpass-main']: './src/askpass-main.ts', - ['git-editor-main']: './src/git-editor-main.ts' - } -}); - -export const StripOutSourceMaps = ['dist/askpass-main.js']; diff --git a/extensions/git/src/actionButton.ts b/extensions/git/src/actionButton.ts index 63eefb1de028a..5804c23f69755 100644 --- a/extensions/git/src/actionButton.ts +++ b/extensions/git/src/actionButton.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Command, Disposable, Event, EventEmitter, SourceControlActionButton, Uri, workspace, l10n, LogOutputChannel } from 'vscode'; -import { Branch, RefType, Status } from './api/git'; +import type { Branch } from './api/git'; +import { RefType, Status } from './api/git.constants'; import { OperationKind } from './operation'; import { CommitCommandsCenter } from './postCommitCommands'; import { Repository } from './repository'; diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 0791401665ec0..abe5c33107422 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -5,7 +5,8 @@ import { Model } from '../model'; import { Repository as BaseRepository, Resource } from '../repository'; -import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, GitErrorCodes, CloneOptions, CommitShortStat, DiffChange, Worktree, RepositoryKind, RepositoryAccessDetails } from './git'; +import type { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, Ref, Submodule, Commit, Change, RepositoryUIState, LogOptions, APIState, CommitOptions, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, CloneOptions, CommitShortStat, DiffChange, Worktree, RepositoryKind, RepositoryAccessDetails } from './git'; +import { ForcePushMode, GitErrorCodes, RefType, Status } from './git.constants'; import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands, CancellationToken } from 'vscode'; import { combinedDisposable, filterEvent, mapEvent } from '../util'; import { toGitUri } from '../uri'; diff --git a/extensions/git/src/api/extension.ts b/extensions/git/src/api/extension.ts index 7b0313b6c26e6..a4c6af087ce1b 100644 --- a/extensions/git/src/api/extension.ts +++ b/extensions/git/src/api/extension.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Model } from '../model'; -import { GitExtension, Repository, API } from './git'; +import type { GitExtension, Repository, API } from './git'; import { ApiRepository, ApiImpl } from './api1'; import { Event, EventEmitter } from 'vscode'; import { CloneManager } from '../cloneManager'; diff --git a/extensions/git/src/api/git.constants.ts b/extensions/git/src/api/git.constants.ts new file mode 100644 index 0000000000000..5847e21d5d0da --- /dev/null +++ b/extensions/git/src/api/git.constants.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as git from './git'; + +export type ForcePushMode = git.ForcePushMode; +export type RefType = git.RefType; +export type Status = git.Status; +export type GitErrorCodes = git.GitErrorCodes; + +export const ForcePushMode = Object.freeze({ + Force: 0, + ForceWithLease: 1, + ForceWithLeaseIfIncludes: 2, +}) satisfies typeof git.ForcePushMode; + +export const RefType = Object.freeze({ + Head: 0, + RemoteHead: 1, + Tag: 2, +}) satisfies typeof git.RefType; + +export const Status = Object.freeze({ + INDEX_MODIFIED: 0, + INDEX_ADDED: 1, + INDEX_DELETED: 2, + INDEX_RENAMED: 3, + INDEX_COPIED: 4, + + MODIFIED: 5, + DELETED: 6, + UNTRACKED: 7, + IGNORED: 8, + INTENT_TO_ADD: 9, + INTENT_TO_RENAME: 10, + TYPE_CHANGED: 11, + + ADDED_BY_US: 12, + ADDED_BY_THEM: 13, + DELETED_BY_US: 14, + DELETED_BY_THEM: 15, + BOTH_ADDED: 16, + BOTH_DELETED: 17, + BOTH_MODIFIED: 18, +}) satisfies typeof git.Status; + +export const GitErrorCodes = Object.freeze({ + BadConfigFile: 'BadConfigFile', + BadRevision: 'BadRevision', + AuthenticationFailed: 'AuthenticationFailed', + NoUserNameConfigured: 'NoUserNameConfigured', + NoUserEmailConfigured: 'NoUserEmailConfigured', + NoRemoteRepositorySpecified: 'NoRemoteRepositorySpecified', + NotAGitRepository: 'NotAGitRepository', + NotASafeGitRepository: 'NotASafeGitRepository', + NotAtRepositoryRoot: 'NotAtRepositoryRoot', + Conflict: 'Conflict', + StashConflict: 'StashConflict', + UnmergedChanges: 'UnmergedChanges', + PushRejected: 'PushRejected', + ForcePushWithLeaseRejected: 'ForcePushWithLeaseRejected', + ForcePushWithLeaseIfIncludesRejected: 'ForcePushWithLeaseIfIncludesRejected', + RemoteConnectionError: 'RemoteConnectionError', + DirtyWorkTree: 'DirtyWorkTree', + CantOpenResource: 'CantOpenResource', + GitNotFound: 'GitNotFound', + CantCreatePipe: 'CantCreatePipe', + PermissionDenied: 'PermissionDenied', + CantAccessRemote: 'CantAccessRemote', + RepositoryNotFound: 'RepositoryNotFound', + RepositoryIsLocked: 'RepositoryIsLocked', + BranchNotFullyMerged: 'BranchNotFullyMerged', + NoRemoteReference: 'NoRemoteReference', + InvalidBranchName: 'InvalidBranchName', + BranchAlreadyExists: 'BranchAlreadyExists', + NoLocalChanges: 'NoLocalChanges', + NoStashFound: 'NoStashFound', + LocalChangesOverwritten: 'LocalChangesOverwritten', + NoUpstreamBranch: 'NoUpstreamBranch', + IsInSubmodule: 'IsInSubmodule', + WrongCase: 'WrongCase', + CantLockRef: 'CantLockRef', + CantRebaseMultipleBranches: 'CantRebaseMultipleBranches', + PatchDoesNotApply: 'PatchDoesNotApply', + NoPathFound: 'NoPathFound', + UnknownPath: 'UnknownPath', + EmptyCommitMessage: 'EmptyCommitMessage', + BranchFastForwardRejected: 'BranchFastForwardRejected', + BranchNotYetBorn: 'BranchNotYetBorn', + TagConflict: 'TagConflict', + CherryPickEmpty: 'CherryPickEmpty', + CherryPickConflict: 'CherryPickConflict', + WorktreeContainsChanges: 'WorktreeContainsChanges', + WorktreeAlreadyExists: 'WorktreeAlreadyExists', + WorktreeBranchAlreadyUsed: 'WorktreeBranchAlreadyUsed', +}) satisfies Record; diff --git a/extensions/git/src/artifactProvider.ts b/extensions/git/src/artifactProvider.ts index f63899efa3edb..832b5626ae0a8 100644 --- a/extensions/git/src/artifactProvider.ts +++ b/extensions/git/src/artifactProvider.ts @@ -6,7 +6,8 @@ import { LogOutputChannel, SourceControlArtifactProvider, SourceControlArtifactGroup, SourceControlArtifact, Event, EventEmitter, ThemeIcon, l10n, workspace, Uri, Disposable, Command } from 'vscode'; import { coalesce, dispose, filterEvent, IDisposable, isCopilotWorktree } from './util'; import { Repository } from './repository'; -import { Ref, RefType, Worktree } from './api/git'; +import type { Ref, Worktree } from './api/git'; +import { RefType } from './api/git.constants'; import { OperationKind } from './operation'; /** diff --git a/extensions/git/src/askpass.ts b/extensions/git/src/askpass.ts index 1cb1890e24245..cc9e607f08f48 100644 --- a/extensions/git/src/askpass.ts +++ b/extensions/git/src/askpass.ts @@ -6,7 +6,7 @@ import { window, InputBoxOptions, Uri, Disposable, workspace, QuickPickOptions, l10n, LogOutputChannel } from 'vscode'; import { IDisposable, EmptyDisposable, toDisposable, extractFilePathFromArgs } from './util'; import { IIPCHandler, IIPCServer } from './ipc/ipcServer'; -import { CredentialsProvider, Credentials } from './api/git'; +import type { CredentialsProvider, Credentials } from './api/git'; import { ITerminalEnvironmentProvider } from './terminal'; import { AskpassPaths } from './askpassManager'; diff --git a/extensions/git/src/autofetch.ts b/extensions/git/src/autofetch.ts index 00d6450b3baf8..201bf647f1a11 100644 --- a/extensions/git/src/autofetch.ts +++ b/extensions/git/src/autofetch.ts @@ -6,7 +6,7 @@ import { workspace, Disposable, EventEmitter, Memento, window, MessageItem, ConfigurationTarget, Uri, ConfigurationChangeEvent, l10n, env } from 'vscode'; import { Repository } from './repository'; import { eventToPromise, filterEvent, onceEvent } from './util'; -import { GitErrorCodes } from './api/git'; +import { GitErrorCodes } from './api/git.constants'; export class AutoFetcher { diff --git a/extensions/git/src/blame.ts b/extensions/git/src/blame.ts index 8773264eb70f2..83a60ec9e1879 100644 --- a/extensions/git/src/blame.ts +++ b/extensions/git/src/blame.ts @@ -13,7 +13,7 @@ import { fromGitUri, isGitUri, toGitUri } from './uri'; import { emojify, ensureEmojis } from './emoji'; import { getWorkingTreeAndIndexDiffInformation, getWorkingTreeDiffInformation } from './staging'; import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider'; -import { AvatarQuery, AvatarQueryCommit } from './api/git'; +import type { AvatarQuery, AvatarQueryCommit } from './api/git'; import { LRUCache } from './cache'; import { AVATAR_SIZE, getCommitHover, getHoverCommitHashCommands, processHoverRemoteCommands } from './hover'; diff --git a/extensions/git/src/branchProtection.ts b/extensions/git/src/branchProtection.ts index 0fbb3b7d4b166..b142a333b24a1 100644 --- a/extensions/git/src/branchProtection.ts +++ b/extensions/git/src/branchProtection.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, Event, EventEmitter, Uri, workspace } from 'vscode'; -import { BranchProtection, BranchProtectionProvider } from './api/git'; +import type { BranchProtection, BranchProtectionProvider } from './api/git'; import { dispose, filterEvent } from './util'; export interface IBranchProtectionProviderRegistry { diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 5c93d8f7a1ffb..3b338ddc40197 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -8,7 +8,8 @@ import * as path from 'path'; import { Command, commands, Disposable, MessageOptions, Position, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage, Tab, TabInputNotebook, QuickInputButtonLocation, languages, SourceControlArtifact, ProgressLocation } from 'vscode'; import TelemetryReporter from '@vscode/extension-telemetry'; import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator'; -import { ForcePushMode, GitErrorCodes, RefType, Status, CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git'; +import type { CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git'; +import { ForcePushMode, GitErrorCodes, RefType, Status } from './api/git.constants'; import { Git, GitError, Repository as GitRepository, Stash, Worktree } from './git'; import { Model } from './model'; import { GitResourceGroup, Repository, Resource, ResourceGroupType } from './repository'; @@ -106,6 +107,8 @@ class RefItem implements QuickPickItem { return `refs/remotes/${this.ref.name}`; case RefType.Tag: return `refs/tags/${this.ref.name}`; + default: + throw new Error('Unknown ref type'); } } get refName(): string | undefined { return this.ref.name; } diff --git a/extensions/git/src/decorationProvider.ts b/extensions/git/src/decorationProvider.ts index fb895d5aff2b3..11778f7f8f582 100644 --- a/extensions/git/src/decorationProvider.ts +++ b/extensions/git/src/decorationProvider.ts @@ -9,7 +9,8 @@ import { Repository, GitResourceGroup } from './repository'; import { Model } from './model'; import { debounce } from './decorators'; import { filterEvent, dispose, anyEvent, PromiseSource, combinedDisposable, runAndSubscribeEvent } from './util'; -import { Change, GitErrorCodes, Status } from './api/git'; +import type { Change } from './api/git'; +import { GitErrorCodes, Status } from './api/git.constants'; function equalSourceControlHistoryItemRefs(ref1?: SourceControlHistoryItemRef, ref2?: SourceControlHistoryItemRef): boolean { if (ref1 === ref2) { diff --git a/extensions/git/src/editSessionIdentityProvider.ts b/extensions/git/src/editSessionIdentityProvider.ts index 8380f03ecfd94..a3336d441743d 100644 --- a/extensions/git/src/editSessionIdentityProvider.ts +++ b/extensions/git/src/editSessionIdentityProvider.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; -import { RefType } from './api/git'; +import { RefType } from './api/git.constants'; import { Model } from './model'; export class GitEditSessionIdentityProvider implements vscode.EditSessionIdentityProvider, vscode.Disposable { diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 5127ae0fbb95f..5f7d1100f709c 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -13,7 +13,8 @@ import { EventEmitter } from 'events'; import * as filetype from 'file-type'; import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter, Versions, isWindows, pathEquals, isMacintosh, isDescendant, relativePathWithNoFallback, Mutable } from './util'; import { CancellationError, CancellationToken, ConfigurationChangeEvent, LogOutputChannel, Progress, Uri, workspace } from 'vscode'; -import { Commit as ApiCommit, Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, RefQuery as ApiRefQuery, InitOptions, DiffChange, Worktree as ApiWorktree } from './api/git'; +import type { Commit as ApiCommit, Ref, Branch, Remote, LogOptions, Change, CommitOptions, RefQuery as ApiRefQuery, InitOptions, DiffChange, Worktree as ApiWorktree } from './api/git'; +import { RefType, ForcePushMode, GitErrorCodes, Status } from './api/git.constants'; import * as byline from 'byline'; import { StringDecoder } from 'string_decoder'; @@ -1073,7 +1074,7 @@ function parseGitChanges(repositoryRoot: string, raw: string): Change[] { let uri = originalUri; let renameUri = originalUri; - let status = Status.UNTRACKED; + let status: Status = Status.UNTRACKED; // Copy or Rename status comes with a number (ex: 'R100'). // We don't need the number, we use only first character of the status. @@ -1138,7 +1139,7 @@ function parseGitChangesRaw(repositoryRoot: string, raw: string): DiffChange[] { let uri = originalUri; let renameUri = originalUri; - let status = Status.UNTRACKED; + let status: Status = Status.UNTRACKED; switch (change[0]) { case 'A': diff --git a/extensions/git/src/historyItemDetailsProvider.ts b/extensions/git/src/historyItemDetailsProvider.ts index be0e2b337f8f6..cccdf508fe3a8 100644 --- a/extensions/git/src/historyItemDetailsProvider.ts +++ b/extensions/git/src/historyItemDetailsProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Command, Disposable } from 'vscode'; -import { AvatarQuery, SourceControlHistoryItemDetailsProvider } from './api/git'; +import type { AvatarQuery, SourceControlHistoryItemDetailsProvider } from './api/git'; import { Repository } from './repository'; import { ApiRepository } from './api/api1'; diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index 29e8705e04b35..c658b4c005eec 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -8,7 +8,8 @@ import { CancellationToken, Disposable, Event, EventEmitter, FileDecoration, Fil import { Repository, Resource } from './repository'; import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent, subject, truncate } from './util'; import { toMultiFileDiffEditorUris } from './uri'; -import { AvatarQuery, AvatarQueryCommit, Branch, LogOptions, Ref, RefType } from './api/git'; +import type { AvatarQuery, AvatarQueryCommit, Branch, LogOptions, Ref } from './api/git'; +import { RefType } from './api/git.constants'; import { emojify, ensureEmojis } from './emoji'; import { Commit } from './git'; import { OperationKind, OperationResult } from './operation'; diff --git a/extensions/git/src/main.ts b/extensions/git/src/main.ts index b37ae9c79c5b7..b2690b24a7cdd 100644 --- a/extensions/git/src/main.ts +++ b/extensions/git/src/main.ts @@ -12,7 +12,7 @@ import { GitDecorations } from './decorationProvider'; import { Askpass } from './askpass'; import { toDisposable, filterEvent, eventToPromise } from './util'; import TelemetryReporter from '@vscode/extension-telemetry'; -import { GitExtension } from './api/git'; +import type { GitExtension } from './api/git'; import { GitProtocolHandler } from './protocolHandler'; import { GitExtensionImpl } from './api/extension'; import * as path from 'path'; diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index 1d65d3dc2d755..deecc7c28629a 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -12,7 +12,7 @@ import { Git } from './git'; import * as path from 'path'; import * as fs from 'fs'; import { fromGitUri } from './uri'; -import { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher, PostCommitCommandsProvider, BranchProtectionProvider, SourceControlHistoryItemDetailsProvider } from './api/git'; +import type { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher, PostCommitCommandsProvider, BranchProtectionProvider, SourceControlHistoryItemDetailsProvider } from './api/git'; import { Askpass } from './askpass'; import { IPushErrorHandlerRegistry } from './pushError'; import { ApiRepository } from './api/api1'; diff --git a/extensions/git/src/postCommitCommands.ts b/extensions/git/src/postCommitCommands.ts index 69a18114a41e2..50658d14202ba 100644 --- a/extensions/git/src/postCommitCommands.ts +++ b/extensions/git/src/postCommitCommands.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Command, commands, Disposable, Event, EventEmitter, Memento, Uri, workspace, l10n } from 'vscode'; -import { PostCommitCommandsProvider } from './api/git'; +import type { PostCommitCommandsProvider } from './api/git'; import { IRepositoryResolver, Repository } from './repository'; import { ApiRepository } from './api/api1'; import { dispose } from './util'; diff --git a/extensions/git/src/pushError.ts b/extensions/git/src/pushError.ts index 6222923ff6864..71f564e8fa255 100644 --- a/extensions/git/src/pushError.ts +++ b/extensions/git/src/pushError.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from 'vscode'; -import { PushErrorHandler } from './api/git'; +import type { PushErrorHandler } from './api/git'; export interface IPushErrorHandlerRegistry { registerPushErrorHandler(provider: PushErrorHandler): Disposable; diff --git a/extensions/git/src/quickDiffProvider.ts b/extensions/git/src/quickDiffProvider.ts index 3b1aa64c8faae..961f5387555fd 100644 --- a/extensions/git/src/quickDiffProvider.ts +++ b/extensions/git/src/quickDiffProvider.ts @@ -7,7 +7,7 @@ import { FileType, l10n, LogOutputChannel, QuickDiffProvider, Uri, workspace } f import { IRepositoryResolver, Repository } from './repository'; import { isDescendant, pathEquals } from './util'; import { toGitUri } from './uri'; -import { Status } from './api/git'; +import { Status } from './api/git.constants'; export class GitQuickDiffProvider implements QuickDiffProvider { readonly label = l10n.t('Git Local Changes (Working Tree)'); diff --git a/extensions/git/src/remotePublisher.ts b/extensions/git/src/remotePublisher.ts index 1326776cde4a0..eb8ec7b8e19bb 100644 --- a/extensions/git/src/remotePublisher.ts +++ b/extensions/git/src/remotePublisher.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, Event } from 'vscode'; -import { RemoteSourcePublisher } from './api/git'; +import type { RemoteSourcePublisher } from './api/git'; export interface IRemoteSourcePublisherRegistry { readonly onDidAddRemoteSourcePublisher: Event; diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index f3b1afb4689e0..6810f3cca4223 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -11,7 +11,8 @@ import picomatch from 'picomatch'; import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, Disposable, Event, EventEmitter, ExcludeSettingOptions, FileDecoration, l10n, LogLevel, LogOutputChannel, Memento, ProgressLocation, ProgressOptions, RelativePattern, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit } from 'vscode'; import { ActionButton } from './actionButton'; import { ApiRepository } from './api/api1'; -import { Branch, BranchQuery, Change, CommitOptions, DiffChange, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, RepositoryKind, Status } from './api/git'; +import type { Branch, BranchQuery, Change, CommitOptions, DiffChange, FetchOptions, LogOptions, Ref, Remote, RepositoryKind } from './api/git'; +import { ForcePushMode, GitErrorCodes, RefType, Status } from './api/git.constants'; import { AutoFetcher } from './autofetch'; import { GitBranchProtectionProvider, IBranchProtectionProviderRegistry } from './branchProtection'; import { debounce, memoize, sequentialize, throttle } from './decorators'; diff --git a/extensions/git/src/repositoryCache.ts b/extensions/git/src/repositoryCache.ts index 6aa998b7679bb..8f03d8998c771 100644 --- a/extensions/git/src/repositoryCache.ts +++ b/extensions/git/src/repositoryCache.ts @@ -5,7 +5,7 @@ import { LogOutputChannel, Memento, Uri, workspace } from 'vscode'; import { LRUCache } from './cache'; -import { Remote, RepositoryAccessDetails } from './api/git'; +import type { Remote, RepositoryAccessDetails } from './api/git'; import { isDescendant } from './util'; export interface RepositoryCacheInfo { diff --git a/extensions/git/src/statusbar.ts b/extensions/git/src/statusbar.ts index d5cbe86ee7c88..32fb1f588642b 100644 --- a/extensions/git/src/statusbar.ts +++ b/extensions/git/src/statusbar.ts @@ -6,7 +6,8 @@ import { Disposable, Command, EventEmitter, Event, workspace, Uri, l10n } from 'vscode'; import { Repository } from './repository'; import { anyEvent, dispose, filterEvent } from './util'; -import { Branch, RefType, RemoteSourcePublisher } from './api/git'; +import type { Branch, RemoteSourcePublisher } from './api/git'; +import { RefType } from './api/git.constants'; import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { CheckoutOperation, CheckoutTrackingOperation, OperationKind } from './operation'; diff --git a/extensions/git/src/test/smoke.test.ts b/extensions/git/src/test/smoke.test.ts index d9a5776824b2e..c2870a2631ee3 100644 --- a/extensions/git/src/test/smoke.test.ts +++ b/extensions/git/src/test/smoke.test.ts @@ -9,7 +9,8 @@ import { workspace, commands, window, Uri, WorkspaceEdit, Range, TextDocument, e import * as cp from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { GitExtension, API, Repository, Status } from '../api/git'; +import type { GitExtension, API, Repository } from '../api/git'; +import { Status } from '../api/git.constants'; import { eventToPromise } from '../util'; suite('git smoke test', function () { diff --git a/extensions/git/src/timelineProvider.ts b/extensions/git/src/timelineProvider.ts index 1ccf04a423d8d..a07eb4bfba78e 100644 --- a/extensions/git/src/timelineProvider.ts +++ b/extensions/git/src/timelineProvider.ts @@ -12,7 +12,7 @@ import { CommandCenter } from './commands'; import { OperationKind, OperationResult } from './operation'; import { truncate } from './util'; import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider'; -import { AvatarQuery, AvatarQueryCommit } from './api/git'; +import type { AvatarQuery, AvatarQueryCommit } from './api/git'; import { getCommitHover, getHoverCommitHashCommands, processHoverRemoteCommands } from './hover'; export class GitTimelineItem extends TimelineItem { diff --git a/extensions/git/src/uri.ts b/extensions/git/src/uri.ts index 8b04fabe583eb..1d79e67e8e67b 100644 --- a/extensions/git/src/uri.ts +++ b/extensions/git/src/uri.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Uri } from 'vscode'; -import { Change, Status } from './api/git'; +import type { Change } from './api/git'; +import { Status } from './api/git.constants'; export interface GitUriParams { path: string; From c100c1fd24fbaaf750cb589f9aa8220b18799b1f Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:26:55 -0800 Subject: [PATCH 02/26] Align `js/ts.suggest.completeJSDocs` with other names Fixes #298734 Aligning this to use the id `js/ts.suggest.jsdoc.enabled` because we have another setting in the `#js/ts.suggest.jsdoc.*` scope --- extensions/typescript-language-features/package.json | 8 ++++---- extensions/typescript-language-features/package.nls.json | 4 ++-- .../src/languageFeatures/jsDocCompletions.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 952dbfe0ea537..ed85772ade5fd 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -1919,10 +1919,10 @@ "markdownDeprecationMessage": "%configuration.suggest.paths.unifiedDeprecationMessage%", "scope": "resource" }, - "js/ts.suggest.completeJSDocs": { + "js/ts.suggest.jsdoc.enabled": { "type": "boolean", "default": true, - "description": "%configuration.suggest.completeJSDocs%", + "description": "%configuration.suggest.jsdoc.enabled%", "scope": "language-overridable", "keywords": [ "JavaScript", @@ -1932,14 +1932,14 @@ "javascript.suggest.completeJSDocs": { "type": "boolean", "default": true, - "description": "%configuration.suggest.completeJSDocs%", + "description": "%configuration.suggest.jsdoc.enabled%", "markdownDeprecationMessage": "%configuration.suggest.completeJSDocs.unifiedDeprecationMessage%", "scope": "language-overridable" }, "typescript.suggest.completeJSDocs": { "type": "boolean", "default": true, - "description": "%configuration.suggest.completeJSDocs%", + "description": "%configuration.suggest.jsdoc.enabled%", "markdownDeprecationMessage": "%configuration.suggest.completeJSDocs.unifiedDeprecationMessage%", "scope": "language-overridable" }, diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 97b1c12e8abda..8c28dd87ccdf2 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -248,8 +248,8 @@ "configuration.autoClosingTags.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.autoClosingTags.enabled#` instead.", "typescript.suggest.enabled": "Enable/disable autocomplete suggestions.", "configuration.suggest.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.suggest.enabled#` instead.", - "configuration.suggest.completeJSDocs": "Enable/disable suggestion to complete JSDoc comments.", - "configuration.suggest.completeJSDocs.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.suggest.completeJSDocs#` instead.", + "configuration.suggest.jsdoc.enabled": "Enable/disable suggestion to complete JSDoc comments.", + "configuration.suggest.completeJSDocs.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.suggest.jsdoc.enabled#` instead.", "configuration.tsserver.useVsCodeWatcher": "Use VS Code's file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace.", "configuration.tsserver.watchOptions": "Configure which watching strategies should be used to keep track of files and directories.", "configuration.tsserver.watchOptions.vscode": "Use VS Code's file watchers instead of TypeScript's. Requires using TypeScript 5.4+ in the workspace.", diff --git a/extensions/typescript-language-features/src/languageFeatures/jsDocCompletions.ts b/extensions/typescript-language-features/src/languageFeatures/jsDocCompletions.ts index 9e1c3c7cf9dcb..a060d7600147f 100644 --- a/extensions/typescript-language-features/src/languageFeatures/jsDocCompletions.ts +++ b/extensions/typescript-language-features/src/languageFeatures/jsDocCompletions.ts @@ -46,7 +46,7 @@ class JsDocCompletionProvider implements vscode.CompletionItemProvider { position: vscode.Position, token: vscode.CancellationToken ): Promise { - if (!readUnifiedConfig('suggest.completeJSDocs', true, { scope: document, fallbackSection: this.language.id })) { + if (!readUnifiedConfig('suggest.jsdoc.enabled', true, { scope: document, fallbackSection: this.language.id, fallbackSubSectionNameOverride: 'suggest.completeJSDocs' })) { return undefined; } From b36e69af37477463843aee43c621d65c1cc64870 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Mon, 2 Mar 2026 17:39:23 +0100 Subject: [PATCH 03/26] fix setting repo uri for new session (#298735) --- src/vs/sessions/contrib/chat/browser/folderPicker.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vs/sessions/contrib/chat/browser/folderPicker.ts b/src/vs/sessions/contrib/chat/browser/folderPicker.ts index dbc253e1cbfa2..2a1872ad461d6 100644 --- a/src/vs/sessions/contrib/chat/browser/folderPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/folderPicker.ts @@ -58,6 +58,9 @@ export class FolderPicker extends Disposable { */ setNewSession(session: INewSession | undefined): void { this._newSession = session; + if (session && this._selectedFolderUri) { + session.setRepoUri(this._selectedFolderUri); + } } constructor( From 0321be04fe71bc7ccfebbf4d6fb747f0473cbfc9 Mon Sep 17 00:00:00 2001 From: David Dossett <25163139+daviddossett@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:04:27 -0800 Subject: [PATCH 04/26] Simplify chat input toolbar responsive behavior (#298467) * Reduce chat input label hide threshold from 650 to 400 * Collapse chat input picker buttons to 22x22 icons at narrow widths When the chat input is narrow (<250px), hide chevrons on mode, session target, model, and workspace pickers. Mode and session target pickers collapse to centered 22x22 icon-only buttons matching the add-context button size. Update actionMinWidth to 22 and toolbar gap to 4px. * Simplify chat input toolbar responsive behavior * Apply initial hideChevrons state in render() --- .../contrib/chat/browser/newChatViewPane.ts | 2 +- .../browser/widget/input/chatInputPart.ts | 44 ++++++++++++------- .../widget/input/chatInputPickerActionItem.ts | 12 ++++- .../browser/widget/input/chatModelPicker.ts | 22 +++++++++- .../widget/input/modePickerActionItem.ts | 13 ++++-- .../widget/input/modelPickerActionItem.ts | 4 +- .../widget/input/modelPickerActionItem2.ts | 1 + .../input/sessionTargetPickerActionItem.ts | 10 ++++- .../widget/input/workspacePickerActionItem.ts | 4 +- .../chat/browser/widget/media/chat.css | 36 ++++++++++++++- 10 files changed, 120 insertions(+), 28 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 9928b50f61611..247501dfc64e9 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -619,7 +619,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { }; const pickerOptions: IChatInputPickerOptions = { - onlyShowIconsForDefaultActions: observableValue('onlyShowIcons', false), + hideChevrons: observableValue('hideChevrons', false), hoverPosition: { hoverPosition: HoverPosition.ABOVE }, }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 6fe3ad2ee47d3..627dbaea7a7d1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -35,7 +35,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../../../editor/browser/config/editorConfiguration.js'; import { EditorExtensionsRegistry } from '../../../../../../editor/browser/editorExtensions.js'; import { CodeEditorWidget } from '../../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { EditorLayoutInfo, EditorOptions, IEditorOptions } from '../../../../../../editor/common/config/editorOptions.js'; +import { EditorOptions, IEditorOptions } from '../../../../../../editor/common/config/editorOptions.js'; import { IDimension } from '../../../../../../editor/common/core/2d/dimension.js'; import { IPosition } from '../../../../../../editor/common/core/position.js'; import { IRange, Range } from '../../../../../../editor/common/core/range.js'; @@ -209,6 +209,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private static _counter = 0; private _workingSetCollapsed = observableValue('chatInputPart.workingSetCollapsed', true); + private _stableInputPartWidth = observableValue('chatInputPart.stableInputPartWidth', 0); private readonly _chatInputTodoListWidget = this._register(new MutableDisposable()); private readonly _chatQuestionCarouselWidget = this._register(new MutableDisposable()); private readonly _chatQuestionCarouselDisposables = this._register(new DisposableStore()); @@ -220,6 +221,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _onDidLoadInputState: Emitter = this._register(new Emitter()); readonly onDidLoadInputState: Event = this._onDidLoadInputState.event; + private readonly _toolbarRelayoutScheduler = this._register(new RunOnceScheduler(() => { + if (typeof this.cachedWidth === 'number') { + this.layout(this.cachedWidth); + } + }, 0)); private _onDidFocus = this._register(new Emitter()); readonly onDidFocus: Event = this._onDidFocus.event; @@ -2148,10 +2154,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const pickerOptions: IChatInputPickerOptions = { getOverflowAnchor: () => this.inputActionsToolbar.getElement(), actionContext: { widget }, - onlyShowIconsForDefaultActions: observableFromEvent( - this._inputEditor.onDidLayoutChange, - (l?: EditorLayoutInfo) => (l?.width ?? this._inputEditor.getLayoutInfo().width) < 650 /* This is a magical number based on testing*/ - ).recomputeInitiallyAndOnChange(this._store), + hideChevrons: derived(reader => this._stableInputPartWidth.read(reader) < 400), hoverPosition: { forcePosition: true, hoverPosition: location === ChatWidgetLocation.SidebarRight && !isMaximized ? HoverPosition.LEFT : HoverPosition.RIGHT @@ -2169,7 +2172,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge enabled: true, kind: 'last', minItems: 1, - actionMinWidth: 40 + actionMinWidth: 22 }, actionViewItemProvider: (action, options) => { if (action.id === OpenModelPickerAction.ID && action instanceof MenuItemAction) { @@ -2230,17 +2233,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.EMPTY && this.options.workspacePickerDelegate) { return this.instantiationService.createInstance(WorkspacePickerActionItem, action, this.options.workspacePickerDelegate, pickerOptions); } else { - const empty = new BaseActionViewItem(undefined, action); - if (empty.element) { - empty.element.style.display = 'none'; - } - return empty; + return new HiddenActionViewItem(action); } } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { // Create all pickers and return a container action view item const widgets = this.createChatSessionPickerWidgets(action); if (widgets.length === 0) { - return undefined; + return new HiddenActionViewItem(action); } // Create a container to hold all picker widgets return this.instantiationService.createInstance(ChatSessionPickersContainerActionItem, action, widgets); @@ -2258,7 +2257,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatSessionPickerContainer = container as HTMLElement | undefined; if (this.cachedWidth && typeof this.cachedInputToolbarWidth === 'number' && this.cachedInputToolbarWidth !== this.inputActionsToolbar.getItemsWidth()) { - this.layout(this.cachedWidth); + this._toolbarRelayoutScheduler.schedule(); } })); this.executeToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarsContainer, this.options.menus.executeToolbar, { @@ -2273,7 +2272,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.executeToolbar.context = { widget } satisfies IChatExecuteActionContext; this._register(this.executeToolbar.onDidChangeMenuItems(() => { if (this.cachedWidth && typeof this.cachedExecuteToolbarWidth === 'number' && this.cachedExecuteToolbarWidth !== this.executeToolbar.getItemsWidth()) { - this.layout(this.cachedWidth); + this._toolbarRelayoutScheduler.schedule(); } })); if (this.options.menus.inputSideToolbar) { @@ -2994,6 +2993,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge */ layout(width: number) { this.cachedWidth = width; + this._stableInputPartWidth.set(width, undefined); return this._layout(width); } @@ -3037,10 +3037,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const inputSideToolbarWidth = this.inputSideToolbarContainer ? dom.getTotalWidth(this.inputSideToolbarContainer) : 0; const getToolbarsWidthCompact = () => { + const toolbarItemGap = 4; const executeToolbarWidth = this.cachedExecuteToolbarWidth = this.executeToolbar.getItemsWidth(); const inputToolbarWidth = this.cachedInputToolbarWidth = this.inputActionsToolbar.getItemsWidth(); - const executeToolbarPadding = (this.executeToolbar.getItemsLength() - 1) * 4; - const inputToolbarPadding = this.inputActionsToolbar.getItemsLength() ? (this.inputActionsToolbar.getItemsLength() - 1) * 4 : 0; + const executeToolbarPadding = (this.executeToolbar.getItemsLength() - 1) * toolbarItemGap; + const inputToolbarPadding = this.inputActionsToolbar.getItemsLength() ? (this.inputActionsToolbar.getItemsLength() - 1) * toolbarItemGap : 0; const contextUsageWidth = dom.getTotalWidth(this.contextUsageWidgetContainer); const inputToolbarsPadding = 12; // pdading between input toolbar/execute toolbar/contextUsage. return executeToolbarWidth + executeToolbarPadding + contextUsageWidth + (this.options.renderInputToolbarBelowInput ? 0 : inputToolbarWidth + inputToolbarPadding + inputToolbarsPadding); @@ -3132,3 +3133,14 @@ class ChatSessionPickersContainerActionItem extends ActionViewItem { super.dispose(); } } + +class HiddenActionViewItem extends BaseActionViewItem { + constructor(action: IAction) { + super(undefined, action); + } + + override render(container: HTMLElement): void { + super.render(container); + container.style.display = 'none'; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index 1377aa5260793..13682b907d93c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -24,7 +24,7 @@ export interface IChatInputPickerOptions { readonly actionContext?: IChatExecuteActionContext; - readonly onlyShowIconsForDefaultActions: IObservable; + readonly hideChevrons: IObservable; readonly hoverPosition?: IHoverPositionOptions; } @@ -53,8 +53,9 @@ export abstract class ChatInputPickerActionViewItem extends ActionWidgetDropdown super(action, optionsWithAnchor, actionWidgetService, keybindingService, contextKeyService, telemetryService); this._register(autorun(reader => { - this.pickerOptions.onlyShowIconsForDefaultActions.read(reader); + const hideChevrons = this.pickerOptions.hideChevrons.read(reader); if (this.element) { + this.element.classList.toggle('hide-chevrons', hideChevrons); this.renderLabel(this.element); } })); @@ -74,5 +75,12 @@ export abstract class ChatInputPickerActionViewItem extends ActionWidgetDropdown override render(container: HTMLElement): void { super.render(container); container.classList.add('chat-input-picker-item'); + + // Apply initial collapsed state now that this.element exists + const hideChevrons = this.pickerOptions.hideChevrons.get(); + if (this.element) { + this.element.classList.toggle('hide-chevrons', hideChevrons); + this.renderLabel(this.element); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index e5c907aed38d1..0257f20252dd0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -12,6 +12,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; import { ActionListItemKind, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; @@ -459,6 +460,7 @@ export class ModelPickerWidget extends Disposable { private _selectedModel: ILanguageModelChatMetadataAndIdentifier | undefined; private _badge: ModelPickerBadge | undefined; + private _hideChevrons: IObservable | undefined; private _domNode: HTMLElement | undefined; private _badgeIcon: HTMLElement | undefined; @@ -484,6 +486,17 @@ export class ModelPickerWidget extends Disposable { super(); } + setHideChevrons(hideChevrons: IObservable): void { + this._hideChevrons = hideChevrons; + this._register(autorun(reader => { + const hide = hideChevrons.read(reader); + if (this._domNode) { + this._domNode.classList.toggle('hide-chevrons', hide); + } + this._renderLabel(); + })); + } + setSelectedModel(model: ILanguageModelChatMetadataAndIdentifier | undefined): void { this._selectedModel = model; this._renderLabel(); @@ -501,6 +514,11 @@ export class ModelPickerWidget extends Disposable { this._domNode.setAttribute('aria-haspopup', 'true'); this._domNode.setAttribute('aria-expanded', 'false'); + // Apply initial collapsed state now that _domNode exists + if (this._hideChevrons?.get()) { + this._domNode.classList.toggle('hide-chevrons', true); + } + this._badgeIcon = dom.append(this._domNode, dom.$('span.model-picker-badge')); this._updateBadge(); @@ -637,7 +655,9 @@ export class ModelPickerWidget extends Disposable { domChildren.push(this._badgeIcon); } - domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); + if (!this._hideChevrons?.get()) { + domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); + } dom.reset(this._domNode, ...domChildren); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 3b552379ad1e8..773f896f6e846 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -260,11 +260,15 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { return menuContributions; } + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('chat-mode-picker-item'); + } + protected override renderLabel(element: HTMLElement): IDisposable | null { this.setAriaLabelAttributes(element); const currentMode = this.delegate.currentMode.get(); - const isDefault = currentMode.id === ChatMode.Agent.id; const state = currentMode.label.get(); let icon = currentMode.icon.get(); @@ -274,13 +278,16 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { } const labelElements = []; + const collapsed = this.pickerOptions.hideChevrons.get(); if (icon) { labelElements.push(...renderLabelWithIcons(`$(${icon.id})`)); } - if (!isDefault || !icon || !this.pickerOptions.onlyShowIconsForDefaultActions.get()) { + if (!collapsed || !icon) { labelElements.push(dom.$('span.chat-input-picker-label', undefined, state)); } - labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); + if (!collapsed) { + labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); + } dom.reset(element, ...labelElements); return null; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index af61812b3a91f..04bfc652890fc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -209,7 +209,9 @@ export class ModelPickerActionItem extends ChatInputPickerActionViewItem { } domChildren.push(dom.$('span.chat-input-picker-label', undefined, name ?? localize('chat.modelPicker.auto', "Auto"))); - domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); + if (!this.pickerOptions.hideChevrons.get()) { + domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); + } dom.reset(element, ...domChildren); this.setAriaLabelAttributes(element); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts index 6d302d2e00366..5a4d402e79074 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts @@ -41,6 +41,7 @@ export class EnhancedModelPickerActionItem extends BaseActionViewItem { this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget, delegate)); this._pickerWidget.setSelectedModel(delegate.currentModel.get()); + this._pickerWidget.setHideChevrons(pickerOptions.hideChevrons); // Sync delegate → widget when model list or selection changes externally this._register(autorun(t => { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 5a31e7f187cb8..897c927f25fd8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -200,6 +200,11 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { return undefined; } + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('chat-session-target-picker-item'); + } + protected override renderLabel(element: HTMLElement): IDisposable | null { this.setAriaLabelAttributes(element); const currentType = this._getSelectedSessionType(); @@ -208,11 +213,12 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const icon = getAgentSessionProviderIcon(currentType ?? AgentSessionProviders.Local); const labelElements = []; + const collapsed = this.pickerOptions.hideChevrons.get(); labelElements.push(...renderLabelWithIcons(`$(${icon.id})`)); - if (!this.pickerOptions.onlyShowIconsForDefaultActions.get()) { + if (!collapsed) { labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); } - labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); dom.reset(element, ...labelElements); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts index 989c3bc64e8ed..828664f9a8853 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerActionItem.ts @@ -118,7 +118,9 @@ export class WorkspacePickerActionItem extends ChatInputPickerActionViewItem { labelElements.push(dom.$('span.chat-input-picker-label', undefined, localize('selectWorkspace', "Workspace"))); } - labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); + if (!this.pickerOptions.hideChevrons.get()) { + labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); + } dom.reset(element, ...labelElements); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 98a0e4aad6a65..ea94c7c0a0999 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1325,6 +1325,10 @@ have to be updated for changes to the rules above, or to support more deeply nes margin-right: auto; } +.interactive-session .chat-input-toolbars > .chat-input-toolbar .actions-container:first-child { + margin-right: 0; +} + .interactive-session .chat-input-toolbars .tool-warning-indicator { position: absolute; bottom: 0; @@ -1402,12 +1406,42 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label, .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label { height: 16px; - padding: 3px 0px 3px 6px; + padding: 3px 1px 3px 7px; display: flex; align-items: center; color: var(--vscode-icon-foreground); } +/* When chevrons are hidden and only showing an icon (no label), size to 22x22 with centered icon */ +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.hide-chevrons:not(:has(.chat-input-picker-label)), +.interactive-session .chat-input-toolbar .chat-input-picker-item.hide-chevrons .action-label:not(:has(.chat-input-picker-label)), +.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.hide-chevrons:not(:has(.chat-input-picker-label)) { + width: 22px; + min-width: 22px; + height: 22px; + padding: 0; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + + .codicon { + justify-content: center; + } +} + +/* When chevrons are hidden but label is still shown (e.g. model picker), use equal padding */ +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.hide-chevrons:has(.chat-input-picker-label), +.interactive-session .chat-input-toolbar .chat-input-picker-item.hide-chevrons .action-label:has(.chat-input-picker-label), +.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.hide-chevrons:has(.chat-input-picker-label) { + padding: 3px 7px; +} + +/* Hide the tools button when the toolbar is in collapsed state */ +.interactive-session .chat-input-toolbar:has(.hide-chevrons) .action-item:has(.codicon-tools) { + display: none; +} + .monaco-workbench .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label .codicon-chevron-down, .monaco-workbench .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label .codicon-chevron-down { font-size: 12px; From 534cace4f7635e3777c66df546fd8f9bfe8279b9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Mon, 2 Mar 2026 18:13:29 +0100 Subject: [PATCH 05/26] fix setting active session for new session (#298742) --- src/vs/sessions/contrib/chat/browser/folderPicker.ts | 3 --- .../contrib/sessions/browser/sessionsManagementService.ts | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/folderPicker.ts b/src/vs/sessions/contrib/chat/browser/folderPicker.ts index 2a1872ad461d6..dbc253e1cbfa2 100644 --- a/src/vs/sessions/contrib/chat/browser/folderPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/folderPicker.ts @@ -58,9 +58,6 @@ export class FolderPicker extends Disposable { */ setNewSession(session: INewSession | undefined): void { this._newSession = session; - if (session && this._selectedFolderUri) { - session.setRepoUri(this._selectedFolderUri); - } } constructor( diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index d929df11a2d28..6e830f21c6700 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -260,7 +260,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } let newSession: INewSession; - if (target === AgentSessionProviders.Background || target === AgentSessionProviders.Local) { + if (target === AgentSessionProviders.Background) { newSession = this.instantiationService.createInstance(LocalNewSession, sessionResource, defaultRepoUri); } else { newSession = this.instantiationService.createInstance(RemoteNewSession, sessionResource, target); @@ -414,8 +414,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (this.isNewChatSessionContext.get()) { return; } - this.isNewChatSessionContext.set(true); this.setActiveSession(undefined); + this.isNewChatSessionContext.set(true); } private setActiveSession(session: IAgentSession | INewSession | undefined): void { From 0158c05e5ccbb8b8a703a2372da373a16b59276d Mon Sep 17 00:00:00 2001 From: Ben Villalobos Date: Mon, 2 Mar 2026 09:28:37 -0800 Subject: [PATCH 06/26] Bump minor version to 1.111 (#298150) --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index cbf7d5d6d9c5d..bdd3590681315 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-oss-dev", - "version": "1.110.0", + "version": "1.111.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.110.0", + "version": "1.111.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0945d9edaa197..5ca2b0ba3b7bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-oss-dev", - "version": "1.110.0", + "version": "1.111.0", "distro": "31b6675a65c3b72a5a20fdc648050340585879c3", "author": { "name": "Microsoft Corporation" @@ -250,4 +250,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} From d356f40d9053e910c9455d0c5223278b311a0664 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 2 Mar 2026 13:06:15 -0500 Subject: [PATCH 07/26] weight and maintain specific order for foundational tips (#298745) --- .../contrib/chat/browser/chatTipCatalog.ts | 34 ++++ .../contrib/chat/browser/chatTipService.ts | 171 +++++++++++------- .../chat/test/browser/chatTipService.test.ts | 168 ++++++++++++++++- 3 files changed, 311 insertions(+), 62 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts index b29cc69ac81da..17e36d55d38e4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts @@ -21,6 +21,11 @@ import { INSERT_FORK_CONVERSATION_COMMAND_ID, } from './actions/chatActions.js'; +export const enum ChatTipTier { + Foundational = 'foundational', + Qol = 'qol', +} + /** * Context provided to tip builders for dynamic message construction. */ @@ -75,6 +80,12 @@ export function extractCommandIds(markdown: string): string[] { */ export interface ITipDefinition extends ITipExclusionConfig { readonly id: string; + readonly tier: ChatTipTier; + /** + * Optional priority for ordering tips within the same tier. + * Lower values are shown first. + */ + readonly priority?: number; /** * Builds the tip message dynamically at runtime. * This enables keybindings and command labels to be looked up fresh. @@ -110,6 +121,8 @@ export interface ITipDefinition extends ITipExclusionConfig { export const TIP_CATALOG: readonly ITipDefinition[] = [ { id: 'tip.switchToAuto', + tier: ChatTipTier.Foundational, + priority: 0, buildMessage(ctx) { const label = getCommandLabel('workbench.action.chat.openModelPicker'); const kb = formatKeybinding(ctx, 'workbench.action.chat.openModelPicker'); @@ -126,6 +139,8 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.createInstruction', + tier: ChatTipTier.Foundational, + priority: 50, buildMessage(ctx) { const kb = formatKeybinding(ctx, GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID); return new MarkdownString( @@ -146,6 +161,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.createPrompt', + tier: ChatTipTier.Foundational, buildMessage(ctx) { const kb = formatKeybinding(ctx, GENERATE_PROMPT_COMMAND_ID); return new MarkdownString( @@ -166,6 +182,8 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.createAgent', + tier: ChatTipTier.Foundational, + priority: 30, buildMessage(ctx) { const kb = formatKeybinding(ctx, GENERATE_AGENT_COMMAND_ID); return new MarkdownString( @@ -186,6 +204,8 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.createSkill', + tier: ChatTipTier.Foundational, + priority: 40, buildMessage(ctx) { const kb = formatKeybinding(ctx, GENERATE_SKILL_COMMAND_ID); return new MarkdownString( @@ -206,6 +226,8 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.agentMode', + tier: ChatTipTier.Foundational, + priority: 10, buildMessage(ctx) { const label = getCommandLabel('workbench.action.chat.openEditSession'); const kb = formatKeybinding(ctx, 'workbench.action.chat.openEditSession'); @@ -223,6 +245,8 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.planMode', + tier: ChatTipTier.Foundational, + priority: 20, buildMessage(ctx) { const kb = formatKeybinding(ctx, 'workbench.action.chat.openPlan'); return new MarkdownString( @@ -240,6 +264,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.attachFiles', + tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( localize('tip.attachFiles', "Reference files or folders with # to give the agent more context about the task.") @@ -255,6 +280,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.codeActions', + tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( localize('tip.codeActions', "Select a code block in the editor and right-click to access more AI actions.") @@ -264,6 +290,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.undoChanges', + tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( localize('tip.undoChanges', "Select \"Restore Checkpoint\" to undo changes after that point in the chat conversation.") @@ -280,6 +307,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.messageQueueing', + tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( localize('tip.messageQueueing', "Steer the agent mid-task by sending follow-up messages. They queue and apply in order.") @@ -290,6 +318,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.forkConversation', + tier: ChatTipTier.Qol, buildMessage(ctx) { const kb = formatKeybinding(ctx, INSERT_FORK_CONVERSATION_COMMAND_ID); return new MarkdownString( @@ -310,6 +339,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.yoloMode', + tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( localize( @@ -329,6 +359,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.agenticBrowser', + tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( localize( @@ -347,6 +378,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.mermaid', + tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( localize('tip.mermaid', "Ask the agent to draw an architectural diagram or flow chart; it can render Mermaid diagrams directly in chat.") @@ -357,6 +389,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.subagents', + tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( localize('tip.subagents', "Ask the agent to work in parallel to complete large tasks faster.") @@ -367,6 +400,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ }, { id: 'tip.thinkingPhrases', + tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( localize( diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 57ee53a820f0e..61e2ff7f0e0f7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -23,7 +23,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, IParsedChatRequest } from '../common/requestParser/chatParserTypes.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { TipEligibilityTracker } from './chatTipEligibilityTracker.js'; -import { extractCommandIds, ITipBuildContext, ITipDefinition, TIP_CATALOG } from './chatTipCatalog.js'; +import { ChatTipTier, extractCommandIds, ITipBuildContext, ITipDefinition, TIP_CATALOG } from './chatTipCatalog.js'; import { ChatTipStorageKeys, TipTrackingCommands } from './chatTipStorageKeys.js'; type ChatTipEvent = { @@ -410,6 +410,22 @@ export class ChatTipService extends Disposable implements IChatTipService { // Return the already-shown tip for stable rerenders if (this._tipRequestId === 'welcome' && this._shownTip) { + if (this._shownTip.id !== 'tip.switchToAuto') { + const switchToAutoTip = TIP_CATALOG.find(tip => tip.id === 'tip.switchToAuto'); + if (switchToAutoTip) { + const dismissedIds = new Set(this._getDismissedTipIds()); + if (!dismissedIds.has(switchToAutoTip.id) && this._isEligible(switchToAutoTip, contextKeyService)) { + this._shownTip = switchToAutoTip; + this._storageService.store(ChatTipStorageKeys.LastTipId, switchToAutoTip.id, StorageScope.APPLICATION, StorageTarget.USER); + const tip = this._createTip(switchToAutoTip); + this._logTipTelemetry(switchToAutoTip.id, 'shown'); + this._trackTipCommandClicks(switchToAutoTip); + this._onDidNavigateTip.fire(tip); + return tip; + } + } + } + if (!this._isEligible(this._shownTip, contextKeyService)) { const nextTip = this._findNextEligibleTip(this._shownTip.id, contextKeyService); if (nextTip) { @@ -453,29 +469,15 @@ export class ChatTipService extends Disposable implements IChatTipService { this._tracker.recordCurrentMode(contextKeyService); const dismissedIds = new Set(this._getDismissedTipIds()); - let selectedTip: ITipDefinition | undefined; + const eligibleTips = TIP_CATALOG.filter(tip => !dismissedIds.has(tip.id) && this._isEligible(tip, contextKeyService)); - // Determine where to start in the catalog based on the last-shown tip. - const lastTipId = this._readApplicationWithProfileFallback(ChatTipStorageKeys.LastTipId); - const lastCatalogIndex = lastTipId ? TIP_CATALOG.findIndex(tip => tip.id === lastTipId) : -1; - const startIndex = lastCatalogIndex === -1 ? 0 : (lastCatalogIndex + 1) % TIP_CATALOG.length; - - // Pass 1: walk TIP_CATALOG in a ring, picking the first tip that is both - // not dismissed and eligible for the current context. - for (let i = 0; i < TIP_CATALOG.length; i++) { - const idx = (startIndex + i) % TIP_CATALOG.length; - const candidate = TIP_CATALOG[idx]; - if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { - selectedTip = candidate; - break; - } - } + const selectedTip = this._selectTipByTier(eligibleTips); if (!selectedTip) { return undefined; } - // Persist the selected tip id so the next use advances to the following one. + // Persist the selected tip ID for compatibility with existing storage consumers. this._storageService.store(ChatTipStorageKeys.LastTipId, selectedTip.id, StorageScope.APPLICATION, StorageTarget.USER); // Record that we've shown a tip this session @@ -488,6 +490,21 @@ export class ChatTipService extends Disposable implements IChatTipService { return this._createTip(selectedTip); } + private _selectTipByTier(eligibleTips: readonly ITipDefinition[]): ITipDefinition | undefined { + const foundationalTips = eligibleTips.filter(tip => tip.tier === ChatTipTier.Foundational); + if (foundationalTips.length) { + return this._sortByPriorityAndCatalogOrder(foundationalTips)[0]; + } + + const qolTips = eligibleTips.filter(tip => tip.tier === ChatTipTier.Qol); + if (!qolTips.length) { + return undefined; + } + + const randomIndex = Math.floor(Math.random() * qolTips.length); + return qolTips[randomIndex]; + } + navigateToNextTip(): IChatTip | undefined { if (!this._contextKeyService) { return undefined; @@ -507,24 +524,41 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } - this._createSlashCommandsUsageTracker.syncContextKey(this._contextKeyService); - const currentIndex = TIP_CATALOG.findIndex(t => t.id === this._shownTip!.id); - if (currentIndex === -1) { + const contextKeyService = this._contextKeyService; + this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); + const currentTipId = this._shownTip.id; + const orderedTips = this._getOrderedEligibleTips(contextKeyService, { includeTipId: currentTipId }); + if (!orderedTips.length) { return undefined; } - const dismissedIds = new Set(this._getDismissedTipIds()); - for (let i = 1; i < TIP_CATALOG.length; i++) { - const idx = (currentIndex + i) % TIP_CATALOG.length; - const candidate = TIP_CATALOG[idx]; - if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, this._contextKeyService)) { - // Found the next eligible tip - update state and return it - this._shownTip = candidate; - this._tipRequestId = 'welcome'; - this._storageService.store(ChatTipStorageKeys.LastTipId, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); - this._logTipTelemetry(candidate.id, 'shown'); - this._trackTipCommandClicks(candidate); - return this._createTip(candidate); + const currentIndex = orderedTips.findIndex(tip => tip.id === currentTipId); + const candidate = this._getNextTipFromOrderedList(orderedTips, currentIndex, currentTipId); + if (candidate) { + // Found the next eligible tip - update state and return it + this._shownTip = candidate; + this._tipRequestId = 'welcome'; + this._storageService.store(ChatTipStorageKeys.LastTipId, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); + this._logTipTelemetry(candidate.id, 'shown'); + this._trackTipCommandClicks(candidate); + return this._createTip(candidate); + } + + return undefined; + } + + private _getNextTipFromOrderedList(orderedTips: readonly ITipDefinition[], startIndex: number, currentTipId: string): ITipDefinition | undefined { + if (!orderedTips.length) { + return undefined; + } + + const fallbackIndex = 0; + const normalizedStartIndex = startIndex === -1 ? fallbackIndex : startIndex; + for (let i = 1; i <= orderedTips.length; i++) { + const index = (normalizedStartIndex + i) % orderedTips.length; + const candidate = orderedTips[index]; + if (candidate.id !== currentTipId) { + return candidate; } } @@ -545,13 +579,21 @@ export class ChatTipService extends Disposable implements IChatTipService { if (!this._shownTip) { return undefined; } + const orderedTips = this._getOrderedEligibleTips(contextKeyService); + if (!orderedTips.length) { + return undefined; + } - const currentIndex = TIP_CATALOG.findIndex(t => t.id === this._shownTip!.id); - if (currentIndex === -1) { + const currentIndex = orderedTips.findIndex(tip => tip.id === this._shownTip!.id); + if (orderedTips.length === 1 && currentIndex !== -1) { return undefined; } - const candidate = this._getNavigableTip(direction, currentIndex, contextKeyService); + const fallbackIndex = direction === 1 ? 0 : orderedTips.length - 1; + const nextIndex = currentIndex === -1 + ? fallbackIndex + : (currentIndex + direction + orderedTips.length) % orderedTips.length; + const candidate = orderedTips[nextIndex]; if (candidate) { this._logTipTelemetry(this._shownTip.id, direction === 1 ? 'navigateNext' : 'navigatePrevious'); this._shownTip = candidate; @@ -568,44 +610,51 @@ export class ChatTipService extends Disposable implements IChatTipService { } private _hasNavigableTip(contextKeyService: IContextKeyService): boolean { - if (!this._shownTip) { + const orderedTips = this._getOrderedEligibleTips(contextKeyService); + if (!orderedTips.length) { return false; } - const currentIndex = TIP_CATALOG.findIndex(t => t.id === this._shownTip!.id); - if (currentIndex === -1) { - return false; + if (!this._shownTip) { + return orderedTips.length > 1; + } + + if (orderedTips.length > 1) { + return true; } - return !!this._getNavigableTip(1, currentIndex, contextKeyService); + return orderedTips[0].id !== this._shownTip.id; } - private _getNavigableTip(direction: 1 | -1, currentIndex: number, contextKeyService: IContextKeyService): ITipDefinition | undefined { + private _getOrderedEligibleTips(contextKeyService: IContextKeyService, options?: { excludeShownTip?: boolean; includeTipId?: string }): ITipDefinition[] { const dismissedIds = new Set(this._getDismissedTipIds()); - - let eligibleTipCount = 0; - for (const tip of TIP_CATALOG) { - if (!dismissedIds.has(tip.id) && this._isEligible(tip, contextKeyService)) { - eligibleTipCount++; - if (eligibleTipCount > 1) { - break; - } + const eligibleTips = TIP_CATALOG.filter(tip => { + if (options?.includeTipId && tip.id === options.includeTipId) { + return true; } - } + if (options?.excludeShownTip && this._shownTip && tip.id === this._shownTip.id) { + return false; + } + return !dismissedIds.has(tip.id) && this._isEligible(tip, contextKeyService); + }); - if (eligibleTipCount <= 1) { - return undefined; - } + const foundationalTips = this._sortByPriorityAndCatalogOrder(eligibleTips.filter(tip => tip.tier === ChatTipTier.Foundational)); + const qolTips = this._sortByPriorityAndCatalogOrder(eligibleTips.filter(tip => tip.tier === ChatTipTier.Qol)); + return [...foundationalTips, ...qolTips]; + } - for (let i = 1; i < TIP_CATALOG.length; i++) { - const idx = ((currentIndex + direction * i) % TIP_CATALOG.length + TIP_CATALOG.length) % TIP_CATALOG.length; - const candidate = TIP_CATALOG[idx]; - if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { - return candidate; + private _sortByPriorityAndCatalogOrder(tips: readonly ITipDefinition[]): ITipDefinition[] { + return [...tips].sort((a, b) => { + const aPriority = a.priority ?? Number.POSITIVE_INFINITY; + const bPriority = b.priority ?? Number.POSITIVE_INFINITY; + if (aPriority !== bPriority) { + return aPriority - bPriority; } - } - return undefined; + const aCatalogIndex = TIP_CATALOG.findIndex(tip => tip.id === a.id); + const bCatalogIndex = TIP_CATALOG.findIndex(tip => tip.id === b.id); + return aCatalogIndex - bCatalogIndex; + }); } private _isEligible(tip: ITipDefinition, contextKeyService: IContextKeyService): boolean { 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 f6e5316b0bb0b..be41fddaa4dd6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -25,6 +25,7 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; +import { ChatTipTier } from '../../browser/chatTipCatalog.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { TestChatEntitlementService } from '../../../../test/common/workbenchTestServices.js'; import { IChatService } from '../../common/chatService/chatService.js'; @@ -86,9 +87,10 @@ suite('ChatTipService', () => { * Creates a mock ITipDefinition with a buildMessage function. * Tests can provide any ITipDefinition properties except buildMessage. */ - function createMockTip(overrides: Omit & { message?: string }): ITipDefinition { + function createMockTip(overrides: Omit, 'buildMessage'> & Pick & { message?: string }): ITipDefinition { const { message, ...rest } = overrides; return { + tier: ChatTipTier.Qol, ...rest, buildMessage: () => new MarkdownString(message ?? 'test'), }; @@ -366,6 +368,42 @@ suite('ChatTipService', () => { } }); + test('navigateToNextTip keeps foundational tips before QoL tips', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + + const firstTip = service.getWelcomeTip(contextKeyService); + assert.ok(firstTip); + assert.strictEqual(firstTip.id, 'tip.planMode'); + + const secondTip = service.navigateToNextTip(); + assert.ok(secondTip); + assert.strictEqual(secondTip.id, 'tip.createAgent', 'Expected next tip to remain in foundational tips before QoL tips'); + }); + + test('navigateToPreviousTip follows reverse of preferred order', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + + const firstTip = service.getWelcomeTip(contextKeyService); + assert.ok(firstTip); + assert.strictEqual(firstTip.id, 'tip.planMode'); + + const secondTip = service.navigateToNextTip(); + assert.ok(secondTip); + assert.strictEqual(secondTip.id, 'tip.createAgent'); + + const previousTip = service.navigateToPreviousTip(); + assert.ok(previousTip); + assert.strictEqual(previousTip.id, 'tip.planMode', 'Expected previous tip to reverse the preferred ordering'); + }); + test('getNextEligibleTip returns next tip even when only one remains', async () => { const service = createService(); @@ -422,6 +460,49 @@ suite('ChatTipService', () => { assert.strictEqual(nextTip, undefined, 'getNextEligibleTip should return undefined when all tips are dismissed'); }); + test('getNextEligibleTip keeps preferred onboarding order after dismissing plan tip', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + + const firstTip = service.getWelcomeTip(contextKeyService); + assert.ok(firstTip); + assert.strictEqual(firstTip.id, 'tip.planMode'); + + service.dismissTip(); + const secondTip = service.getNextEligibleTip(); + assert.ok(secondTip); + assert.strictEqual(secondTip.id, 'tip.createAgent', 'Expected next tip to follow preferred onboarding order before QoL tips'); + }); + + test('getNextEligibleTip picks next relative to current tip after dismissing from middle of order', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + + const firstTip = service.getWelcomeTip(contextKeyService); + assert.ok(firstTip); + + const secondTip = service.navigateToNextTip(); + assert.ok(secondTip); + + const expectedNextAfterSecond = service.navigateToNextTip(); + assert.ok(expectedNextAfterSecond, 'Expected at least three tips to validate relative ordering'); + + const backToSecond = service.navigateToPreviousTip(); + assert.ok(backToSecond); + assert.strictEqual(backToSecond.id, secondTip.id); + + service.dismissTip(); + const actualNext = service.getNextEligibleTip(); + assert.ok(actualNext); + assert.strictEqual(actualNext.id, expectedNextAfterSecond.id, 'Expected getNextEligibleTip to advance relative to current tip rather than restart from top priority tip'); + }); + test('dismissTip fires onDidDismissTip event', () => { const service = createService(); @@ -854,6 +935,91 @@ suite('ChatTipService', () => { assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted mode exclusion from workspace storage'); }); + test('prioritizes foundational tips over QoL tips when both are eligible', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.planMode', 'Expected foundational tip to be prioritized before eligible QoL tips'); + }); + + test('prioritizes preferred onboarding tips in requested order', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + + const seen: string[] = []; + for (let i = 0; i < 3; i++) { + const tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip); + seen.push(tip.id); + service.dismissTip(); + } + + assert.deepStrictEqual(seen, ['tip.planMode', 'tip.createAgent', 'tip.createSkill']); + }); + + test('randomizes QoL tips when no foundational tips are eligible', () => { + const service = createService(); + const modeKindKey = contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + const modeNameKey = contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Plan'); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, 'cloud'); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + + const originalRandom = Math.random; + try { + Math.random = () => 0; + const firstTip = service.getWelcomeTip(contextKeyService); + + service.resetSession(); + + Math.random = () => 0.9999; + const secondTip = service.getWelcomeTip(contextKeyService); + + assert.ok(firstTip); + assert.ok(secondTip); + assert.notStrictEqual(firstTip.id, secondTip.id, 'Expected different QoL tips for different random values'); + assert.notStrictEqual(firstTip.id, 'tip.planMode'); + assert.notStrictEqual(secondTip.id, 'tip.planMode'); + } finally { + Math.random = originalRandom; + modeKindKey.set(ChatModeKind.Agent); + modeNameKey.set('Plan'); + } + }); + + test('resetSession reevaluates foundational tips for the next chat session', () => { + const service = createService(); + const modeKindKey = contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + const modeNameKey = contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Plan'); + const sessionTypeKey = contextKeyService.createKey(ChatContextKeys.chatSessionType.key, 'cloud'); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + + const originalRandom = Math.random; + try { + Math.random = () => 0.9999; + const qolTip = service.getWelcomeTip(contextKeyService); + assert.ok(qolTip); + assert.notStrictEqual(qolTip.id, 'tip.planMode'); + + service.resetSession(); + modeNameKey.set('Agent'); + sessionTypeKey.set(localChatSessionType); + + const foundationalTip = service.getWelcomeTip(contextKeyService); + assert.ok(foundationalTip); + assert.strictEqual(foundationalTip.id, 'tip.createAgent', 'Expected foundational ordering to restart on new chat session'); + } finally { + Math.random = originalRandom; + modeKindKey.set(ChatModeKind.Agent); + } + }); + test('resetSession allows a new welcome tip', () => { const service = createService(); From 9497369946455ac0478669ec1a99d649052b7de9 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 2 Mar 2026 13:06:38 -0500 Subject: [PATCH 08/26] when in question carousel, do not allow delete to undo requests (#298752) fixes #294109 --- .../contrib/chat/browser/chatEditing/chatEditingActions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 2e6371ba03160..1f021914c0b2e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -515,7 +515,7 @@ registerAction2(class RemoveAction extends Action2 { mac: { primary: KeyMod.CtrlCmd | KeyCode.Backspace, }, - when: ContextKeyExpr.and(ChatContextKeys.inChatSession, EditorContextKeys.textInputFocus.negate()), + when: ContextKeyExpr.and(ChatContextKeys.inChatSession, EditorContextKeys.textInputFocus.negate(), ChatContextKeys.inChatQuestionCarousel.negate()), weight: KeybindingWeight.WorkbenchContrib, }, menu: [ @@ -564,7 +564,7 @@ registerAction2(class RestoreCheckpointAction extends Action2 { mac: { primary: KeyMod.CtrlCmd | KeyCode.Backspace, }, - when: ContextKeyExpr.and(ChatContextKeys.inChatSession, EditorContextKeys.textInputFocus.negate()), + when: ContextKeyExpr.and(ChatContextKeys.inChatSession, EditorContextKeys.textInputFocus.negate(), ChatContextKeys.inChatQuestionCarousel.negate()), weight: KeybindingWeight.WorkbenchContrib, }, menu: [ From cceac1afda8335625e2ba0ddd49781cf7be04640 Mon Sep 17 00:00:00 2001 From: John Sanchirico <59657471+sanchirico@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:07:18 -0500 Subject: [PATCH 09/26] Fix chat terminal flicker during streaming (#298598) --- .../browser/chatTerminalCommandMirror.ts | 20 ++++++++----------- .../browser/chatTerminalCommandMirror.test.ts | 6 ++---- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 64715a47835cd..6d39991038882 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -308,12 +308,10 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach // if we blindly append. const canAppend = !!this._lastVT && vt.text.length >= this._lastVT.length && this._vtBoundaryMatches(vt.text, this._lastVT.length); if (!canAppend) { - // Reset the terminal if we had previous content (can't append, need full rewrite) - if (this._lastVT) { - detached.xterm.reset(); - } - if (vt.text) { - detached.xterm.write(vt.text, resolve); + // Use \x1bc (RIS) + new content in one write to avoid a blank frame + const payload = this._lastVT ? `\x1bc${vt.text}` : vt.text; + if (payload) { + detached.xterm.write(payload, resolve); } else { resolve(); } @@ -542,12 +540,10 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach const canAppend = !!this._lastVT && startLine >= previousCursor && vt.text.length >= this._lastVT.length && this._vtBoundaryMatches(vt.text, this._lastVT.length); await new Promise(resolve => { if (!canAppend) { - // Reset the terminal if we had previous content (can't append, need full rewrite) - if (this._lastVT) { - detachedRaw.reset(); - } - if (vt.text) { - detachedRaw.write(vt.text, resolve); + // Use \x1bc (RIS) + new content in one write to avoid a blank frame + const payload = this._lastVT ? `\x1bc${vt.text}` : vt.text; + if (payload) { + detachedRaw.write(payload, resolve); } else { resolve(); } diff --git a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts index 851c3d5f70297..3d91b41d178dc 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts @@ -261,10 +261,8 @@ suite('Workbench - ChatTerminalCommandMirror', () => { // Boundary should NOT match because the prefix diverged strictEqual(boundaryMatches, false, 'Boundary check should detect divergence'); - // When boundary doesn't match, the fix does a full reset + rewrite - // instead of corrupting the output by blind slicing - mirror.raw.reset(); - await write(mirror, vt2); + // Use \x1bc (RIS) + new content in one write to avoid a blank frame + await write(mirror, `\x1bc${vt2}`); // Final content should be the complete new VT, not corrupted strictEqual(getBufferText(mirror), 'DifferentPrefixLine3'); From 7a45ba79843f08702a8a8944ccdcf53cc55745f1 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 2 Mar 2026 10:20:49 -0800 Subject: [PATCH 10/26] chat: add plugin details editor with reactive action updates (#298370) * chat: add plugin details editor with reactive action updates - Implements a new AgentPluginEditor details pane that displays plugin information, readme, and action buttons for both installed plugins and marketplace items - Adds reactive action button updates: when a plugin is enabled/disabled, installed/uninstalled, buttons update in real-time without re-rendering the entire editor - Makes the marketplace name a clickable link to the GitHub repository (when githubRepo is available) - Adds proper CSS classes to action buttons (install, enable, disable, uninstall) so they render correctly in the header - Handles state transitions: marketplace items automatically become installed items when installed, and vice versa - Supports readme rendering from local files, remote repositories, and GitHub blob URLs (converting to raw.githubusercontent.com for proper fetching) Fixes #297246 (Commit message generated by Copilot) * pr comments * test * merge * fix circular dep --- .../agentPluginEditor/agentPluginEditor.ts | 570 ++++++++++++++++++ .../agentPluginEditorInput.ts | 65 ++ .../agentPluginEditor/agentPluginItems.ts | 34 ++ .../media/agentPluginEditor.css | 32 + .../contrib/chat/browser/agentPluginsView.ts | 45 +- .../contrib/chat/browser/chat.contribution.ts | 12 + .../common/plugins/pluginInstallService.ts | 1 + 7 files changed, 730 insertions(+), 29 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditorInput.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentPluginEditor/media/agentPluginEditor.css diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts new file mode 100644 index 0000000000000..df8124325ee48 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts @@ -0,0 +1,570 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, Dimension, EventType, addDisposableListener, append, reset, setParentFlowTo } from '../../../../../base/browser/dom.js'; +import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; +import { Action } from '../../../../../base/common/actions.js'; +import * as arrays from '../../../../../base/common/arrays.js'; +import { Cache, CacheResult } from '../../../../../base/common/cache.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas, matchesScheme } from '../../../../../base/common/network.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { basename, dirname, joinPath } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { generateTokensCSSForColorMap } from '../../../../../editor/common/languages/supports/tokenization.js'; +import { localize } from '../../../../../nls.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IRequestService, asText } from '../../../../../platform/request/common/request.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../../browser/parts/editor/editorPane.js'; +import { IEditorOpenContext } from '../../../../common/editor.js'; +import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { DEFAULT_MARKDOWN_STYLES, renderMarkdownDocument } from '../../../markdown/browser/markdownDocumentRenderer.js'; +import { IWebview, IWebviewService } from '../../../webview/browser/webview.js'; +import { IAgentPlugin, IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { IPluginInstallService } from '../../common/plugins/pluginInstallService.js'; +import { AgentPluginEditorInput } from './agentPluginEditorInput.js'; +import { AgentPluginItemKind, IAgentPluginItem, IInstalledPluginItem, IMarketplacePluginItem } from './agentPluginItems.js'; +import './media/agentPluginEditor.css'; + +interface IAgentPluginEditorTemplate { + name: HTMLElement; + description: HTMLElement; + marketplace: HTMLElement; + actionBar: ActionBar; + content: HTMLElement; + header: HTMLElement; +} + +interface ILayoutParticipant { + layout(): void; +} + +interface IActiveElement { + focus(): void; +} + +const enum WebviewIndex { + Readme, +} + +export class AgentPluginEditor extends EditorPane { + + static readonly ID: string = 'workbench.editor.agentPlugin'; + + private template: IAgentPluginEditorTemplate | undefined; + + private pluginReadme: Cache | null = null; + + private initialScrollProgress: Map = new Map(); + private currentIdentifier: string = ''; + + private layoutParticipants: ILayoutParticipant[] = []; + private readonly contentDisposables = this._register(new DisposableStore()); + private readonly transientDisposables = this._register(new DisposableStore()); + private activeElement: IActiveElement | null = null; + private dimension: Dimension | undefined; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IOpenerService private readonly openerService: IOpenerService, + @IStorageService storageService: IStorageService, + @IExtensionService private readonly extensionService: IExtensionService, + @IWebviewService private readonly webviewService: IWebviewService, + @ILanguageService private readonly languageService: ILanguageService, + @IFileService private readonly fileService: IFileService, + @IRequestService private readonly requestService: IRequestService, + @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, + @ILabelService private readonly labelService: ILabelService, + ) { + super(AgentPluginEditor.ID, group, telemetryService, themeService, storageService); + } + + protected createEditor(parent: HTMLElement): void { + const root = append(parent, $('.extension-editor.agent-plugin-editor')); + + root.tabIndex = 0; + root.style.outline = 'none'; + root.setAttribute('role', 'document'); + const header = append(root, $('.header')); + + const iconContainer = append(header, $('.icon-container')); + const icon = append(iconContainer, $('span.codicon.codicon-extensions')); + icon.style.fontSize = '64px'; + + const details = append(header, $('.details')); + const title = append(details, $('.title')); + const name = append(title, $('span.name', { role: 'heading', tabIndex: 0 })); + + const description = append(details, $('.description')); + + const subtitle = append(details, $('.subtitle')); + const marketplace = append(subtitle, $('span.subtitle-entry')); + + const actionsAndStatusContainer = append(details, $('.actions-status-container')); + const actionBar = this._register(new ActionBar(actionsAndStatusContainer, { + focusOnlyEnabledItems: true + })); + actionBar.setFocusable(true); + + const body = append(root, $('.body')); + const content = append(body, $('.content')); + content.id = generateUuid(); + + this.template = { + content, + description, + header, + name, + marketplace, + actionBar, + }; + } + + override async setInput(input: AgentPluginEditorInput, options: undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + await super.setInput(input, options, context, token); + if (this.template) { + await this.render(input.item, this.template); + } + } + + private async render(item: IAgentPluginItem, template: IAgentPluginEditorTemplate): Promise { + this.activeElement = null; + this.transientDisposables.clear(); + this.contentDisposables.clear(); + template.content.innerText = ''; + + const cts = new CancellationTokenSource(); + this.transientDisposables.add(toDisposable(() => cts.dispose(true))); + const token = cts.token; + + const itemId = item.kind === AgentPluginItemKind.Installed ? item.plugin.uri.toString() : `${item.marketplaceReference.canonicalId}/${item.source}`; + + if (this.currentIdentifier !== itemId) { + this.initialScrollProgress.clear(); + this.currentIdentifier = itemId; + } + + this.pluginReadme = new Cache(() => this.fetchReadme(item, token)); + + template.name.textContent = item.name; + template.description.textContent = item.description; + + // Set up marketplace link + const marketplaceLabel = item.marketplace ?? ''; + const githubRepo = item.kind === AgentPluginItemKind.Marketplace + ? item.marketplaceReference.githubRepo + : item.plugin.fromMarketplace?.marketplaceReference.githubRepo; + if (marketplaceLabel && githubRepo) { + const url = `https://github.com/${githubRepo}`; + const link = $('a.marketplace-link', { href: url }, marketplaceLabel); + this.transientDisposables.add(addDisposableListener(link, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.openerService.open(URI.parse(url)); + })); + reset(template.marketplace, link); + } else { + reset(template.marketplace, marketplaceLabel); + } + + // Set up actions reactively + const actionDisposables = this.transientDisposables.add(new DisposableStore()); + this.transientDisposables.add(autorun(reader => { + actionDisposables.clear(); + template.actionBar.clear(); + + // Read observables to subscribe to changes + const allPlugins = this.agentPluginService.allPlugins.read(reader); + + let currentItem = item; + + // If this was a marketplace item, check if it got installed + if (item.kind === AgentPluginItemKind.Marketplace) { + const expectedUri = this.pluginInstallService.getPluginInstallUri({ + name: item.name, + description: item.description, + version: '', + source: item.source, + marketplace: item.marketplace, + marketplaceReference: item.marketplaceReference, + marketplaceType: item.marketplaceType, + }); + const installedPlugin = allPlugins.find(p => p.uri.toString() === expectedUri.toString()); + if (installedPlugin) { + currentItem = this.installedPluginToItem(installedPlugin); + } + } else { + // If this was an installed item, check if it got uninstalled + const stillInstalled = allPlugins.find(p => p.uri.toString() === item.plugin.uri.toString()); + if (!stillInstalled) { + // Plugin was uninstalled — show as marketplace if we have the info + if (item.plugin.fromMarketplace) { + const mp = item.plugin.fromMarketplace; + currentItem = { + kind: AgentPluginItemKind.Marketplace, + name: item.name, + description: mp.description, + source: mp.source, + marketplace: mp.marketplace, + marketplaceReference: mp.marketplaceReference, + marketplaceType: mp.marketplaceType, + readmeUri: mp.readmeUri, + }; + } else { + // Non-marketplace plugin was uninstalled — no actions to show + return; + } + } else { + // Read enabled state for reactivity + stillInstalled.enabled.read(reader); + currentItem = this.installedPluginToItem(stillInstalled); + } + } + + const actions = this.getItemActions(currentItem); + if (actions.length > 0) { + template.actionBar.push(actions, { icon: true, label: true }); + } + for (const action of actions) { + actionDisposables.add(action); + } + })); + + // Open readme + this.activeElement = await this.openDetails(item, template, token); + } + + private getItemActions(item: IAgentPluginItem): Action[] { + if (item.kind === AgentPluginItemKind.Marketplace) { + return [this.instantiationService.createInstance(InstallPluginEditorAction, item)]; + } + + const actions: Action[] = []; + if (item.plugin.enabled.get()) { + actions.push(this.instantiationService.createInstance(DisablePluginEditorAction, item.plugin)); + } else { + actions.push(this.instantiationService.createInstance(EnablePluginEditorAction, item.plugin)); + } + actions.push(this.instantiationService.createInstance(UninstallPluginEditorAction, item.plugin)); + return actions; + } + + private installedPluginToItem(plugin: IAgentPlugin): IInstalledPluginItem { + const name = basename(plugin.uri); + const description = plugin.fromMarketplace?.description ?? this.labelService.getUriLabel(dirname(plugin.uri), { relative: true }); + const marketplace = plugin.fromMarketplace?.marketplace; + return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin }; + } + + private async fetchReadme(item: IAgentPluginItem, token: CancellationToken): Promise { + let readmeUri: URI | undefined; + if (item.kind === AgentPluginItemKind.Installed) { + readmeUri = joinPath(item.plugin.uri, 'README.md'); + } else { + readmeUri = item.readmeUri; + } + + if (!readmeUri) { + return ''; + } + + if (readmeUri.scheme === Schemas.file || readmeUri.scheme === Schemas.vscodeRemote) { + try { + const content = await this.fileService.readFile(readmeUri); + return content.value.toString(); + } catch { + return ''; + } + } + + // For https GitHub URLs, convert blob URL to raw URL + if (readmeUri.scheme === Schemas.https) { + let rawUrl = readmeUri.toString(); + const githubBlobMatch = rawUrl.match(/^https:\/\/github\.com\/(?[^/]+)\/(?[^/]+)\/blob\/(?.+)$/); + if (githubBlobMatch?.groups) { + rawUrl = `https://raw.githubusercontent.com/${githubBlobMatch.groups['owner']}/${githubBlobMatch.groups['repo']}/${githubBlobMatch.groups['rest']}`; + } + try { + const context = await this.requestService.request({ type: 'GET', url: rawUrl }, token); + const text = await asText(context); + return text ?? ''; + } catch { + return ''; + } + } + + return ''; + } + + private async openDetails(item: IAgentPluginItem, template: IAgentPluginEditorTemplate, token: CancellationToken): Promise { + const details = append(template.content, $('.details')); + const readmeContainer = append(details, $('.content-container')); + + const layout = () => details.classList.toggle('narrow', this.dimension !== undefined && this.dimension.width < 500); + layout(); + this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout }))); + + return this.openMarkdown(this.pluginReadme!.get(), localize('noReadme', "No README available."), readmeContainer, WebviewIndex.Readme, localize('Readme title', "Readme"), token); + } + + private async openMarkdown(cacheResult: CacheResult, noContentCopy: string, container: HTMLElement, webviewIndex: WebviewIndex, title: string, token: CancellationToken): Promise { + try { + const body = await this.renderMarkdown(cacheResult, container, token); + if (token.isCancellationRequested) { + return null; + } + + const webview = this.contentDisposables.add(this.webviewService.createWebviewOverlay({ + title, + options: { + enableFindWidget: true, + tryRestoreScrollPosition: true, + disableServiceWorker: true, + }, + contentOptions: {}, + extension: undefined, + })); + + webview.initialScrollProgress = this.initialScrollProgress.get(webviewIndex) || 0; + + webview.claim(this, this.window, undefined); + setParentFlowTo(webview.container, container); + webview.layoutWebviewOverElement(container); + + webview.setHtml(body); + webview.claim(this, this.window, undefined); + + this.contentDisposables.add(webview.onDidFocus(() => this._onDidFocus?.fire())); + + this.contentDisposables.add(webview.onDidScroll(() => this.initialScrollProgress.set(webviewIndex, webview.initialScrollProgress))); + + const removeLayoutParticipant = arrays.insert(this.layoutParticipants, { + layout: () => { + webview.layoutWebviewOverElement(container); + } + }); + this.contentDisposables.add(toDisposable(removeLayoutParticipant)); + + let isDisposed = false; + this.contentDisposables.add(toDisposable(() => { isDisposed = true; })); + + this.contentDisposables.add(this.themeService.onDidColorThemeChange(async () => { + const body = await this.renderMarkdown(cacheResult, container); + if (!isDisposed) { + webview.setHtml(body); + } + })); + + this.contentDisposables.add(webview.onDidClickLink(link => { + if (!link) { + return; + } + if (matchesScheme(link, Schemas.http) || matchesScheme(link, Schemas.https) || matchesScheme(link, Schemas.mailto)) { + this.openerService.open(link); + } + })); + + return webview; + } catch (e) { + const p = append(container, $('p.nocontent')); + p.textContent = noContentCopy; + return p; + } + } + + private async renderMarkdown(cacheResult: CacheResult, container: HTMLElement, token?: CancellationToken): Promise { + const contents = await this.loadContents(() => cacheResult, container); + if (token?.isCancellationRequested) { + return ''; + } + + const content = await renderMarkdownDocument(contents, this.extensionService, this.languageService, {}, token); + if (token?.isCancellationRequested) { + return ''; + } + + return this.renderBody(content); + } + + private renderBody(body: TrustedHTML): string { + const nonce = generateUuid(); + const colorMap = TokenizationRegistry.getColorMap(); + const css = colorMap ? generateTokensCSSForColorMap(colorMap) : ''; + return ` + + + + + + + + + ${body} + + `; + } + + private loadContents(loadingTask: () => CacheResult, container: HTMLElement): Promise { + container.classList.add('loading'); + + const result = this.contentDisposables.add(loadingTask()); + const onDone = () => container.classList.remove('loading'); + result.promise.then(onDone, onDone); + + return result.promise; + } + + override clearInput(): void { + this.contentDisposables.clear(); + this.transientDisposables.clear(); + super.clearInput(); + } + + override focus(): void { + super.focus(); + this.activeElement?.focus(); + } + + public get activeWebview(): IWebview | undefined { + if (!this.activeElement || !(this.activeElement as IWebview).runFindAction) { + return undefined; + } + return this.activeElement as IWebview; + } + + layout(dimension: Dimension): void { + this.dimension = dimension; + this.layoutParticipants.forEach(p => p.layout()); + } +} + +//#region Actions + +class InstallPluginEditorAction extends Action { + static readonly ID = 'agentPlugin.editor.install'; + + constructor( + private readonly item: IMarketplacePluginItem, + @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, + ) { + super(InstallPluginEditorAction.ID, localize('install', "Install"), 'extension-action label prominent install'); + } + + override async run(): Promise { + await this.pluginInstallService.installPlugin({ + name: this.item.name, + description: this.item.description, + version: '', + source: this.item.source, + marketplace: this.item.marketplace, + marketplaceReference: this.item.marketplaceReference, + marketplaceType: this.item.marketplaceType, + readmeUri: this.item.readmeUri, + }); + } +} + +class EnablePluginEditorAction extends Action { + static readonly ID = 'agentPlugin.editor.enable'; + + constructor( + private readonly plugin: IAgentPlugin, + @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + ) { + super(EnablePluginEditorAction.ID, localize('enable', "Enable"), 'extension-action label prominent'); + } + + override async run(): Promise { + this.agentPluginService.setPluginEnabled(this.plugin.uri, true); + } +} + +class DisablePluginEditorAction extends Action { + static readonly ID = 'agentPlugin.editor.disable'; + + constructor( + private readonly plugin: IAgentPlugin, + @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + ) { + super(DisablePluginEditorAction.ID, localize('disable', "Disable"), 'extension-action label disable'); + } + + override async run(): Promise { + this.agentPluginService.setPluginEnabled(this.plugin.uri, false); + } +} + +class UninstallPluginEditorAction extends Action { + static readonly ID = 'agentPlugin.editor.uninstall'; + + constructor(private readonly plugin: IAgentPlugin) { + super(UninstallPluginEditorAction.ID, localize('uninstall', "Uninstall"), 'extension-action label uninstall'); + } + + override async run(): Promise { + this.plugin.remove(); + } +} + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditorInput.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditorInput.ts new file mode 100644 index 0000000000000..d2acd9f94f757 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditorInput.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { registerIcon } from '../../../../../platform/theme/common/iconRegistry.js'; +import { EditorInputCapabilities, IUntypedEditorInput } from '../../../../common/editor.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; +import { AgentPluginItemKind, IAgentPluginItem } from './agentPluginItems.js'; + +const AgentPluginEditorIcon = registerIcon('agent-plugin-editor-icon', Codicon.extensions, localize('agentPluginEditorLabelIcon', 'Icon of the Agent Plugin editor.')); + +function getPluginId(item: IAgentPluginItem): string { + if (item.kind === AgentPluginItemKind.Installed) { + return item.plugin.uri.toString(); + } + return `${item.marketplaceReference.canonicalId}/${item.source}`; +} + +export class AgentPluginEditorInput extends EditorInput { + + static readonly ID = 'workbench.agentPlugin.input'; + + override get typeId(): string { + return AgentPluginEditorInput.ID; + } + + override get capabilities(): EditorInputCapabilities { + return EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton; + } + + override get resource() { + return URI.from({ + scheme: Schemas.extension, + path: `/agentPlugin/${encodeURIComponent(getPluginId(this._item))}` + }); + } + + constructor(private _item: IAgentPluginItem) { + super(); + } + + get item(): IAgentPluginItem { return this._item; } + + override getName(): string { + return localize('agentPluginInputName', "Plugin: {0}", this._item.name); + } + + override getIcon(): ThemeIcon | undefined { + return AgentPluginEditorIcon; + } + + override matches(other: EditorInput | IUntypedEditorInput): boolean { + if (super.matches(other)) { + return true; + } + + return other instanceof AgentPluginEditorInput && getPluginId(this._item) === getPluginId(other._item); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts new file mode 100644 index 0000000000000..20ec5ed400985 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { IAgentPlugin } from '../../common/plugins/agentPluginService.js'; +import type { IMarketplaceReference, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js'; + +export const enum AgentPluginItemKind { + Installed = 'installed', + Marketplace = 'marketplace', +} + +export interface IInstalledPluginItem { + readonly kind: AgentPluginItemKind.Installed; + readonly name: string; + readonly description: string; + readonly marketplace?: string; + readonly plugin: IAgentPlugin; +} + +export interface IMarketplacePluginItem { + readonly kind: AgentPluginItemKind.Marketplace; + readonly name: string; + readonly description: string; + readonly source: string; + readonly marketplace: string; + readonly marketplaceReference: IMarketplaceReference; + readonly marketplaceType: MarketplaceType; + readonly readmeUri?: URI; +} + +export type IAgentPluginItem = IInstalledPluginItem | IMarketplacePluginItem; diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/media/agentPluginEditor.css b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/media/agentPluginEditor.css new file mode 100644 index 0000000000000..27ce848b8d3db --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/media/agentPluginEditor.css @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agent-plugin-editor { + & .marketplace-link { + color: var(--vscode-textLink-foreground); + text-decoration: none; + cursor: pointer; + + &:hover { + text-decoration: underline; + color: var(--vscode-textLink-activeForeground); + } + } + + & > .body > .content > .details { + display: flex; + height: 100%; + + &.narrow { + flex-direction: column; + } + + & > .content-container { + flex: 1; + min-width: 0; + height: 100%; + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 3ca175477f688..21c59e68f7691 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -8,9 +8,10 @@ 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 { Codicon } from '../../../../base/common/codicons.js'; +import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { IPagedModel, PagedModel } from '../../../../base/common/paging.js'; @@ -18,6 +19,7 @@ import { basename, dirname, joinPath } from '../../../../base/common/resources.j import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.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'; @@ -25,7 +27,6 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.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'; import { WorkbenchPagedList } from '../../../../platform/list/browser/listService.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -35,45 +36,22 @@ import { getLocationBasedViewColors } from '../../../browser/parts/views/viewPan import { IViewletViewOptions } from '../../../browser/parts/views/viewsViewlet.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IViewDescriptorService, IViewsRegistry, Extensions as ViewExtensions } from '../../../common/views.js'; +import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { VIEW_CONTAINER } from '../../extensions/browser/extensions.contribution.js'; import { AbstractExtensionsListView } from '../../extensions/browser/extensionsViews.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'; +import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; +import { AgentPluginEditorInput } from './agentPluginEditor/agentPluginEditorInput.js'; +import { AgentPluginItemKind, IAgentPluginItem, IInstalledPluginItem, IMarketplacePluginItem } from './agentPluginEditor/agentPluginItems.js'; export const HasInstalledAgentPluginsContext = new RawContextKey('hasInstalledAgentPlugins', false); export const InstalledAgentPluginsViewId = 'workbench.views.agentPlugins.installed'; //#region Item model -const enum AgentPluginItemKind { - Installed = 'installed', - Marketplace = 'marketplace', -} - -interface IInstalledPluginItem { - readonly kind: AgentPluginItemKind.Installed; - readonly name: string; - readonly description: string; - readonly marketplace?: string; - readonly plugin: IAgentPlugin; -} - -interface IMarketplacePluginItem { - readonly kind: AgentPluginItemKind.Marketplace; - readonly name: string; - readonly description: string; - readonly source: string; - readonly marketplace: string; - readonly marketplaceReference: IMarketplaceReference; - readonly marketplaceType: MarketplaceType; - readonly readmeUri?: URI; -} - -type IAgentPluginItem = IInstalledPluginItem | IMarketplacePluginItem; - function installedPluginToItem(plugin: IAgentPlugin, labelService: ILabelService): IInstalledPluginItem { const name = basename(plugin.uri); const description = plugin.fromMarketplace?.description ?? labelService.getUriLabel(dirname(plugin.uri), { relative: true }); @@ -326,6 +304,7 @@ export class AgentPluginsListView extends AbstractExtensionsListView); this._register(this.list.onContextMenu(e => this.onContextMenu(e), this)); + + this._register(Event.debounce(Event.filter(this.list.onDidOpen, e => e.element !== null), (_, event) => event, 75, true)(options => { + this.editorService.openEditor( + this.instantiationService.createInstance(AgentPluginEditorInput, options.element!), + options.editorOptions, + MODAL_GROUP + ); + })); } private onContextMenu(e: IListContextMenuEvent): void { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 569411deae595..bd3e4f30373ec 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -148,6 +148,8 @@ import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepo import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; import { IPluginMarketplaceService, PluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; import { AgentPluginsViewsContribution } from './agentPluginsView.js'; +import { AgentPluginEditor } from './agentPluginEditor/agentPluginEditor.js'; +import { AgentPluginEditorInput } from './agentPluginEditor/agentPluginEditorInput.js'; import { AgentPluginRepositoryService } from './agentPluginRepositoryService.js'; import { PluginInstallService } from './pluginInstallService.js'; import './promptSyntax/promptCodingAgentActionContribution.js'; @@ -1256,6 +1258,16 @@ Registry.as(EditorExtensions.EditorPane).registerEditorPane new SyncDescriptor(ChatDebugEditorInput) ] ); +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + AgentPluginEditor, + AgentPluginEditor.ID, + nls.localize('agentPlugin', "Agent Plugin") + ), + [ + new SyncDescriptor(AgentPluginEditorInput) + ] +); Registry.as(Extensions.ConfigurationMigration).registerConfigurationMigrations([ { key: 'chat.experimental.detectParticipant.enabled', diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts index 10a89c2e4b18d..0f66af02f9174 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts @@ -18,6 +18,7 @@ export interface IPluginInstallService { */ installPlugin(plugin: IMarketplacePlugin): Promise; + /** * Pulls the latest changes for an already-cloned marketplace repository. */ From 20af5962cfaa95fdd626eedf5b466ea5bf90d4a6 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 2 Mar 2026 10:36:27 -0800 Subject: [PATCH 11/26] chore: bump distro (#298761) lm: fix mcpServerDefinitions proposal validation for packed extensions Updates distro hash to include a fix for mcpServerDefinitions API proposal validation when extensions are installed as VSIX. Extensions can now properly declare and use vscode.lm.startMcpGateway without validation errors. - Fixes validation logic for packed extension API proposal declarations - Enables mcpServerDefinitions to be properly recognized in extension package.json enabledApiProposals - Resolves failure when calling vscode.lm.startMcpGateway on installed VSIX extensions Fixes https://github.com/microsoft/vscode/issues/298643 (Commit message generated by Copilot) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5ca2b0ba3b7bb..6fffbbd64b4fc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.111.0", - "distro": "31b6675a65c3b72a5a20fdc648050340585879c3", + "distro": "2d27db3342ed252ed49f3df55fd394721aa2e451", "author": { "name": "Microsoft Corporation" }, From 83601ca509b6dc2c780a23817df2649c04b6e056 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:46:54 -0800 Subject: [PATCH 12/26] Dialog Notification when MCP server start fails in sandbox mode. (#297797) * changes for showing start up errors in a dialog * changes for showing start up errors in a dialog * changes for showing start up errors in a dialog * changes for showing start up errors in a dialog * changes * changes * changes * migrating to event from taillog * changes for runtime errors * refactoring changes * refactoring changes * refactoring changes * changes --- .../mcp/common/mcpResourceScannerService.ts | 14 +- .../discovery/installedMcpServersDiscovery.ts | 2 +- .../contrib/mcp/common/mcpSandboxService.ts | 200 ++++++++++++++++-- .../workbench/contrib/mcp/common/mcpServer.ts | 123 ++++++++++- .../contrib/mcp/common/mcpServerConnection.ts | 70 +++++- .../workbench/contrib/mcp/common/mcpTypes.ts | 13 +- .../mcp/test/common/mcpRegistry.test.ts | 16 +- 7 files changed, 411 insertions(+), 27 deletions(-) diff --git a/src/vs/platform/mcp/common/mcpResourceScannerService.ts b/src/vs/platform/mcp/common/mcpResourceScannerService.ts index cd8e0a9f0eb88..e0c67bb8b83e0 100644 --- a/src/vs/platform/mcp/common/mcpResourceScannerService.ts +++ b/src/vs/platform/mcp/common/mcpResourceScannerService.ts @@ -47,6 +47,7 @@ export interface IMcpResourceScannerService { readonly _serviceBrand: undefined; scanMcpServers(mcpResource: URI, target?: McpResourceTarget): Promise; addMcpServers(servers: IInstallableMcpServer[], mcpResource: URI, target?: McpResourceTarget): Promise; + updateSandboxConfig(updateFn: (data: IScannedMcpServers) => IScannedMcpServers, mcpResource: URI, target?: McpResourceTarget): Promise; removeMcpServers(serverNames: string[], mcpResource: URI, target?: McpResourceTarget): Promise; } @@ -82,6 +83,10 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc }); } + async updateSandboxConfig(updateFn: (data: IScannedMcpServers) => IScannedMcpServers, mcpResource: URI, target?: McpResourceTarget): Promise { + await this.withProfileMcpServers(mcpResource, target, updateFn); + } + async removeMcpServers(serverNames: string[], mcpResource: URI, target?: McpResourceTarget): Promise { await this.withProfileMcpServers(mcpResource, target, scannedMcpServers => { for (const serverName of serverNames) { @@ -139,7 +144,9 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc } private async writeScannedMcpServers(mcpResource: URI, scannedMcpServers: IScannedMcpServers): Promise { - if ((scannedMcpServers.servers && Object.keys(scannedMcpServers.servers).length > 0) || (scannedMcpServers.inputs && scannedMcpServers.inputs.length > 0)) { + if ((scannedMcpServers.servers && Object.keys(scannedMcpServers.servers).length > 0) + || (scannedMcpServers.inputs && scannedMcpServers.inputs.length > 0) + || scannedMcpServers.sandbox !== undefined) { await this.fileService.writeFile(mcpResource, VSBuffer.fromString(JSON.stringify(scannedMcpServers, null, '\t'))); } else { await this.fileService.del(mcpResource); @@ -196,7 +203,8 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc if (servers.length > 0) { scannedMcpServers.servers = {}; for (const [serverName, config] of servers) { - scannedMcpServers.servers[serverName] = this.sanitizeServer(config, scannedWorkspaceFolderMcpServers.sandbox); + const serverConfig = this.sanitizeServer(config, scannedMcpServers.sandbox); + scannedMcpServers.servers[serverName] = serverConfig; } } return scannedMcpServers; @@ -219,7 +227,7 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc (>server).type = (server).command ? McpServerType.LOCAL : McpServerType.REMOTE; } - if (sandbox && server.type === McpServerType.LOCAL && !(server as IMcpStdioServerConfiguration).sandbox && server.sandboxEnabled) { + if (sandbox && server.type === McpServerType.LOCAL) { (>server).sandbox = sandbox; } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts index 601ce1eff09bb..1dc178647978d 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts @@ -103,7 +103,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc label: server.name, launch, sandboxEnabled: config.type === 'http' ? undefined : config.sandboxEnabled, - sandbox: config.type === 'http' || !config.sandboxEnabled ? undefined : config.sandbox, + sandbox: config.type === 'http' ? undefined : config.sandbox, cacheNonce: await McpServerLaunch.hash(launch), roots: mcpConfigPath?.workspaceFolder ? [mcpConfigPath.workspaceFolder.uri] : undefined, variableReplacement: { diff --git a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts index f4cf13707d20c..0f8db744c9359 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts @@ -10,15 +10,18 @@ import { dirname, posix, win32 } from '../../../../base/common/path.js'; import { OperatingSystem, OS } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; import { ConfigurationTarget, ConfigurationTargetToString } from '../../../../platform/configuration/common/configuration.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IMcpResourceScannerService, McpResourceTarget } from '../../../../platform/mcp/common/mcpResourceScannerService.js'; import { IRemoteAgentEnvironment } from '../../../../platform/remote/common/remoteAgentEnvironment.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; -import { IMcpSandboxConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; -import { McpServerDefinition, McpServerLaunch, McpServerTransportType } from './mcpTypes.js'; +import { IMcpSandboxConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { IMcpPotentialSandboxBlock, McpServerDefinition, McpServerLaunch, McpServerTransportType } from './mcpTypes.js'; +import { Mutable } from '../../../../base/common/types.js'; export const IMcpSandboxService = createDecorator('mcpSandboxService'); @@ -26,8 +29,20 @@ export interface IMcpSandboxService { readonly _serviceBrand: undefined; launchInSandboxIfEnabled(serverDef: McpServerDefinition, launch: McpServerLaunch, remoteAuthority: string | undefined, configTarget: ConfigurationTarget): Promise; isEnabled(serverDef: McpServerDefinition, serverLabel?: string): Promise; + getSandboxConfigSuggestionMessage(serverLabel: string, potentialBlocks: readonly IMcpPotentialSandboxBlock[], existingSandboxConfig?: IMcpSandboxConfiguration): SandboxConfigSuggestionResult | undefined; + applySandboxConfigSuggestion(serverDef: McpServerDefinition, mcpResource: URI, configTarget: ConfigurationTarget, potentialBlocks: readonly IMcpPotentialSandboxBlock[], suggestedSandboxConfig?: IMcpSandboxConfiguration): Promise; } +type SandboxConfigSuggestions = { + allowWrite: readonly string[]; + allowedDomains: readonly string[]; +}; + +type SandboxConfigSuggestionResult = { + message: string; + sandboxConfig: IMcpSandboxConfiguration; +}; + type SandboxLaunchDetails = { execPath: string | undefined; srtPath: string | undefined; @@ -40,13 +55,14 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService private _sandboxSettingsId: string | undefined; private _remoteEnvDetailsPromise: Promise; - private readonly _defaultAllowedDomains: readonly string[] = ['*.npmjs.org']; + private readonly _defaultAllowedDomains: readonly string[] = ['registry.npmjs.org']; // Default allowed domains that are commonly needed for MCP servers, even if the user doesn't specify them in their sandbox config private _sandboxConfigPerConfigurationTarget: Map = new Map(); constructor( @IFileService private readonly _fileService: IFileService, @IEnvironmentService private readonly _environmentService: IEnvironmentService, @ILogService private readonly _logService: ILogService, + @IMcpResourceScannerService private readonly _mcpResourceScannerService: IMcpResourceScannerService, @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, ) { super(); @@ -69,7 +85,7 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService } if (await this.isEnabled(serverDef, remoteAuthority)) { this._logService.trace(`McpSandboxService: Launching with config target ${configTarget}`); - const launchDetails = await this._resolveSandboxLaunchDetails(configTarget, remoteAuthority, serverDef.sandbox); + const launchDetails = await this._resolveSandboxLaunchDetails(configTarget, remoteAuthority, serverDef.sandbox, launch.cwd); const sandboxArgs = this._getSandboxCommandArgs(launch.command, launch.args, launchDetails.sandboxConfigPath); const sandboxEnv = this._getSandboxEnvVariables(launchDetails.tempDir, remoteAuthority); if (launchDetails.srtPath) { @@ -100,7 +116,158 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService return launch; } - private async _resolveSandboxLaunchDetails(configTarget: ConfigurationTarget, remoteAuthority?: string, sandboxConfig?: IMcpSandboxConfiguration): Promise { + public getSandboxConfigSuggestionMessage(serverLabel: string, potentialBlocks: readonly IMcpPotentialSandboxBlock[], existingSandboxConfig?: IMcpSandboxConfiguration): SandboxConfigSuggestionResult | undefined { + const suggestions = this._getSandboxConfigSuggestions(potentialBlocks, existingSandboxConfig); + if (!suggestions) { + return undefined; + } + + const allowWriteList = suggestions.allowWrite; + const allowedDomainsList = suggestions.allowedDomains; + const suggestionLines: string[] = []; + + if (allowedDomainsList.length) { + const shown = allowedDomainsList.map(domain => `"${domain}"`).join(', '); + suggestionLines.push(localize('mcpSandboxSuggestion.allowedDomains', "Add to `sandbox.network.allowedDomains`: {0}", shown)); + } + + if (allowWriteList.length) { + const shown = allowWriteList.map(path => `"${path}"`).join(', '); + suggestionLines.push(localize('mcpSandboxSuggestion.allowWrite', "Add to `sandbox.filesystem.allowWrite`: {0}", shown)); + } + + const sandboxConfig: IMcpSandboxConfiguration = {}; + if (allowedDomainsList.length) { + sandboxConfig.network = { allowedDomains: [...allowedDomainsList] }; + } + if (allowWriteList.length) { + sandboxConfig.filesystem = { allowWrite: [...allowWriteList] }; + } + + return { + message: localize( + 'mcpSandboxSuggestion.message', + "The MCP server {0} reported potential sandbox blocks. VS Code found possible sandbox configuration updates:\n{1}", + serverLabel, + suggestionLines.join('\n') + ), + sandboxConfig, + }; + } + + public async applySandboxConfigSuggestion(serverDef: McpServerDefinition, mcpResource: URI, configTarget: ConfigurationTarget, potentialBlocks: readonly IMcpPotentialSandboxBlock[], suggestedSandboxConfig?: IMcpSandboxConfiguration): Promise { + const scanTarget = this._toMcpResourceTarget(configTarget); + let didChange = false; + + await this._mcpResourceScannerService.updateSandboxConfig(data => { + const existingSandbox = data.sandbox ?? serverDef.sandbox; + const suggestedAllowedDomains = suggestedSandboxConfig?.network?.allowedDomains ?? []; + const suggestedAllowWrite = suggestedSandboxConfig?.filesystem?.allowWrite ?? []; + + const currentAllowedDomains = new Set(existingSandbox?.network?.allowedDomains ?? []); + for (const domain of suggestedAllowedDomains) { + if (domain && !currentAllowedDomains.has(domain)) { + currentAllowedDomains.add(domain); + } + } + + const currentAllowWrite = new Set(existingSandbox?.filesystem?.allowWrite ?? []); + for (const path of suggestedAllowWrite) { + if (path && !currentAllowWrite.has(path)) { + currentAllowWrite.add(path); + } + } + + didChange = currentAllowedDomains.size !== (existingSandbox?.network?.allowedDomains?.length ?? 0) + || currentAllowWrite.size !== (existingSandbox?.filesystem?.allowWrite?.length ?? 0); + + if (!didChange) { + return data; + } + + const nextSandboxConfig: IMcpSandboxConfiguration = { + ...existingSandbox, + }; + + if (currentAllowedDomains.size > 0 || existingSandbox?.network?.deniedDomains?.length) { + nextSandboxConfig.network = { + ...existingSandbox?.network, + allowedDomains: [...currentAllowedDomains], + }; + } + + if (currentAllowWrite.size > 0 || existingSandbox?.filesystem?.denyRead?.length || existingSandbox?.filesystem?.denyWrite?.length) { + nextSandboxConfig.filesystem = { + ...existingSandbox?.filesystem, + allowWrite: [...currentAllowWrite], + }; + } + + //always remove sandbox at server level when writing back, it should only exist at the top level. This is to sanitize any old or malformed configs that may have sandbox defined at the server level. + if (data.servers) { + for (const serverName in data.servers) { + const serverConfig = data.servers[serverName]; + if (serverConfig.type === McpServerType.LOCAL) { + delete (serverConfig as Mutable).sandbox; + } + } + } + + return { + ...data, + sandbox: nextSandboxConfig, + }; + }, mcpResource, scanTarget); + + return didChange; + } + + private _getSandboxConfigSuggestions(potentialBlocks: readonly IMcpPotentialSandboxBlock[], existingSandboxConfig?: IMcpSandboxConfiguration): SandboxConfigSuggestions | undefined { + if (!potentialBlocks.length) { + return undefined; + } + + const allowWrite = new Set(); + const allowedDomains = new Set(); + const existingAllowWrite = new Set(existingSandboxConfig?.filesystem?.allowWrite ?? []); + const existingAllowedDomains = new Set(existingSandboxConfig?.network?.allowedDomains ?? []); + + for (const block of potentialBlocks) { + if (block.kind === 'network' && block.host && !existingAllowedDomains.has(block.host)) { + allowedDomains.add(block.host); + } + + if (block.kind === 'filesystem' && block.path && !existingAllowWrite.has(block.path)) { + allowWrite.add(block.path); + } + } + + if (!allowWrite.size && !allowedDomains.size) { + return undefined; + } + + return { + allowWrite: [...allowWrite], + allowedDomains: [...allowedDomains], + }; + } + + private _toMcpResourceTarget(configTarget: ConfigurationTarget): McpResourceTarget { + switch (configTarget) { + case ConfigurationTarget.USER: + case ConfigurationTarget.USER_LOCAL: + case ConfigurationTarget.USER_REMOTE: + return ConfigurationTarget.USER; + case ConfigurationTarget.WORKSPACE: + return ConfigurationTarget.WORKSPACE; + case ConfigurationTarget.WORKSPACE_FOLDER: + return ConfigurationTarget.WORKSPACE_FOLDER; + default: + return ConfigurationTarget.USER; + } + } + + private async _resolveSandboxLaunchDetails(configTarget: ConfigurationTarget, remoteAuthority?: string, sandboxConfig?: IMcpSandboxConfiguration, launchCwd?: string): Promise { const os = await this._getOperatingSystem(remoteAuthority); if (os === OperatingSystem.Windows) { return { execPath: undefined, srtPath: undefined, sandboxConfigPath: undefined, tempDir: undefined }; @@ -110,7 +277,7 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService const execPath = await this._getExecPath(os, appRoot, remoteAuthority); const tempDir = await this._getTempDir(remoteAuthority); const srtPath = this._pathJoin(os, appRoot, 'node_modules', '@anthropic-ai', 'sandbox-runtime', 'dist', 'cli.js'); - const sandboxConfigPath = tempDir ? await this._updateSandboxConfig(tempDir, configTarget, sandboxConfig) : undefined; + const sandboxConfigPath = tempDir ? await this._updateSandboxConfig(tempDir, configTarget, sandboxConfig, launchCwd) : undefined; this._logService.debug(`McpSandboxService: Updated sandbox config path: ${sandboxConfigPath}`); return { execPath, srtPath, sandboxConfigPath, tempDir }; } @@ -181,8 +348,8 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService return tempDir; } - private async _updateSandboxConfig(tempDir: URI, configTarget: ConfigurationTarget, sandboxConfig?: IMcpSandboxConfiguration): Promise { - const normalizedSandboxConfig = this._withDefaultSandboxConfig(sandboxConfig); + private async _updateSandboxConfig(tempDir: URI, configTarget: ConfigurationTarget, sandboxConfig?: IMcpSandboxConfiguration, launchCwd?: string): Promise { + const normalizedSandboxConfig = this._withDefaultSandboxConfig(sandboxConfig, launchCwd); let configFileUri: URI; const configTargetKey = ConfigurationTargetToString(configTarget); if (this._sandboxConfigPerConfigurationTarget.has(configTargetKey)) { @@ -197,9 +364,9 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService // this method merges the default allowWrite paths and allowedDomains with the ones provided in the sandbox config, to ensure that the default necessary paths and domains are always included in the sandbox config used for launching, // even if they are not explicitly specified in the config provided by the user or the MCP server config. - private _withDefaultSandboxConfig(sandboxConfig?: IMcpSandboxConfiguration): IMcpSandboxConfiguration { + private _withDefaultSandboxConfig(sandboxConfig?: IMcpSandboxConfiguration, launchCwd?: string): IMcpSandboxConfiguration { const mergedAllowWrite = new Set(sandboxConfig?.filesystem?.allowWrite ?? []); - for (const defaultAllowWrite of this._getDefaultAllowWrite()) { + for (const defaultAllowWrite of this._getDefaultAllowWrite(launchCwd ? [launchCwd] : undefined)) { if (defaultAllowWrite) { mergedAllowWrite.add(defaultAllowWrite); } @@ -226,10 +393,15 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService }; } - private _getDefaultAllowWrite(): readonly string[] { - return [ - '~/.npm' - ]; + private _getDefaultAllowWrite(directories?: string[]): readonly string[] { + const defaultAllowWrite: string[] = ['~/.npm']; + for (const launchCwd of directories ?? []) { + const trimmed = launchCwd.trim(); + if (trimmed) { + defaultAllowWrite.push(trimmed); + } + } + return defaultAllowWrite; } private _pathJoin = (os: OperatingSystem, ...segments: string[]) => { diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 27cd47200856f..143f856c0f479 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -8,7 +8,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { Iterable } from '../../../../base/common/iterator.js'; import * as json from '../../../../base/common/json.js'; import { normalizeDriveLetter } from '../../../../base/common/labels.js'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { mapValues } from '../../../../base/common/objects.js'; @@ -19,6 +19,7 @@ import { createURITransformer } from '../../../../base/common/uriTransformer.js' import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; import { INotificationService, IPromptChoice, Severity } from '../../../../platform/notification/common/notification.js'; @@ -36,9 +37,10 @@ import { mcpActivationEvent } from './mcpConfiguration.js'; import { McpDevModeServerAttache } from './mcpDevMode.js'; import { McpIcons, parseAndValidateMcpIcon, StoredMcpIcons } from './mcpIcons.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; +import { IMcpSandboxService } from './mcpSandboxService.js'; import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; import { McpTaskManager } from './mcpTaskManager.js'; -import { ElicitationKind, extensionMcpCollectionPrefix, IMcpElicitationService, IMcpIcons, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpResourceTemplate, IMcpSamplingService, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, IMcpToolCallContext, McpCapability, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, mcpPromptReplaceSpecialChars, McpResourceURI, McpServerCacheState, McpServerDefinition, McpServerStaticToolAvailability, McpServerTransportType, McpToolName, McpToolVisibility, MpcResponseError, UserInteractionRequiredError } from './mcpTypes.js'; +import { ElicitationKind, extensionMcpCollectionPrefix, IMcpElicitationService, IMcpIcons, IMcpPotentialSandboxBlock, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpResourceTemplate, IMcpSamplingService, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, IMcpToolCallContext, McpCapability, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, mcpPromptReplaceSpecialChars, McpResourceURI, McpServerCacheState, McpServerDefinition, McpServerStaticToolAvailability, McpServerTransportType, McpToolName, McpToolVisibility, MpcResponseError, UserInteractionRequiredError } from './mcpTypes.js'; import { MCP } from './modelContextProtocol.js'; import { McpApps } from './modelContextProtocolApps.js'; import { UriTemplate } from './uriTemplate.js'; @@ -415,6 +417,10 @@ export class McpServer extends Disposable implements IMcpServer { private readonly _loggerId: string; private readonly _logger: ILogger; private _lastModeDebugged = false; + private _isQuietStart = false; + private _isSandboxSuggestionDialogVisible = false; + private _potentialSandboxBlocks: IMcpPotentialSandboxBlock[] = []; + private _potentialSandboxBlockListener = this._register(new MutableDisposable()); /** Count of running tool calls, used to detect if sampling is during an LM call */ public runningToolCalls = new Set(); @@ -433,10 +439,12 @@ export class McpServer extends Disposable implements IMcpServer { @ITelemetryService private readonly _telemetryService: ITelemetryService, @ICommandService private readonly _commandService: ICommandService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IDialogService private readonly _dialogService: IDialogService, @INotificationService private readonly _notificationService: INotificationService, @IOpenerService private readonly _openerService: IOpenerService, @IMcpSamplingService private readonly _samplingService: IMcpSamplingService, @IMcpElicitationService private readonly _elicitationService: IMcpElicitationService, + @IMcpSandboxService private readonly _mcpSandboxService: IMcpSandboxService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, ) { super(); @@ -493,6 +501,11 @@ export class McpServer extends Disposable implements IMcpServer { } })); + this._register(autorun(reader => { + const cnx = this._connection.read(reader); + this._potentialSandboxBlockListener.value = cnx?.onPotentialSandboxBlock(block => this.recordPotentialSandboxBlock(block)); + })); + const staticMetadata = derived(reader => { const def = this._fullDefinitions.read(reader).server; return def && def.cacheNonce !== this._tools.fromCache?.nonce ? def.staticMetadata : undefined; @@ -589,6 +602,7 @@ export class McpServer extends Disposable implements IMcpServer { } let connection = this._connection.get(); + this._isQuietStart = !!errorOnUserInteraction; if (connection && McpConnectionState.canBeStarted(connection.state.get().state)) { connection.dispose(); connection = undefined; @@ -629,6 +643,8 @@ export class McpServer extends Disposable implements IMcpServer { } } + this._potentialSandboxBlocks.length = 0; + const start = Date.now(); let state = await connection.start({ createMessageRequestHandler: (params, token) => this._samplingService.sample({ @@ -656,10 +672,6 @@ export class McpServer extends Disposable implements IMcpServer { time: Date.now() - start, }); - if (state.state === McpConnectionState.Kind.Error) { - this.showInteractiveError(connection, state, debug); - } - // MCP servers that need auth can 'start' but will stop with an interaction-needed // error they first make a request. In this case, wait until the handler fully // initializes before resolving (throwing if it ends up needing auth) @@ -684,6 +696,23 @@ export class McpServer extends Disposable implements IMcpServer { }).finally(() => disposable.dispose()); } + if (state.state === McpConnectionState.Kind.Error) { + let disposable: IDisposable; + state = await new Promise((resolve, reject) => { + disposable = autorun(reader => { + const cnx = this._connection.read(reader); + const state = cnx?.state.read(reader); + if (cnx && state?.state === McpConnectionState.Kind.Error) { + if (!this._isQuietStart) { + this.showInteractiveError(cnx, state, this._lastModeDebugged); + } else { + reject(new UserInteractionRequiredError('start')); + } + } + }); + }).finally(() => disposable.dispose()); + } + return state; }).finally(() => { interaction?.participants.set(this.definition.id, { s: 'resolved' }); @@ -691,6 +720,12 @@ export class McpServer extends Disposable implements IMcpServer { } private showInteractiveError(cnx: IMcpServerConnection, error: McpConnectionState.Error, debug?: boolean) { + if (cnx.definition.sandboxEnabled) { + if (!this.showSandboxConfigSuggestionFromPotentialBlocks(cnx, this._potentialSandboxBlocks)) { + this._notificationService.warn(localize('mcpServerError', 'The MCP server {0} could not be started: {1}', cnx.definition.label, error.message)); + } + return; + } if (error.code === 'ENOENT' && cnx.launchDefinition.type === McpServerTransportType.Stdio) { let docsLink: string | undefined; switch (cnx.launchDefinition.command) { @@ -734,6 +769,82 @@ export class McpServer extends Disposable implements IMcpServer { } } + public showSandboxConfigSuggestionFromPotentialBlocks(cnx: IMcpServerConnection, potentialBlocks: readonly IMcpPotentialSandboxBlock[]): boolean { + if (!cnx.definition.sandboxEnabled || !potentialBlocks.length || this._isSandboxSuggestionDialogVisible) { + return false; + } + if (this._isQuietStart) { + throw new UserInteractionRequiredError('sandbox-suggestion'); + } + + const existingSandboxConfig = this._fullDefinitions.get().collection?.sandbox; + const suggestion = this._mcpSandboxService.getSandboxConfigSuggestionMessage(cnx.definition.label, potentialBlocks, existingSandboxConfig); + if (!suggestion) { + // clear potential blocks as there are no suggestions for them. + this._removePotentialSandboxBlocks(potentialBlocks); + return false; + } + + this._confirmAndApplySandboxConfigSuggestion(cnx, potentialBlocks, suggestion); + return true; + } + + private _confirmAndApplySandboxConfigSuggestion(cnx: IMcpServerConnection, potentialBlocks: readonly IMcpPotentialSandboxBlock[], suggestion: NonNullable>): void { + const mcpResource = cnx.definition.presentation?.origin?.uri ?? this.collection.presentation?.origin; + const configTarget = this._fullDefinitions.get().collection?.configTarget; + this._isSandboxSuggestionDialogVisible = true; + + void this._dialogService.confirm({ + type: 'warning', + message: localize('mcpSandboxSuggestion.confirm.message', "Update sandbox configuration in mcp.json for {0}?", cnx.definition.label), + detail: suggestion.message, + primaryButton: localize('mcpSandboxSuggestion.confirm.yes', "Yes"), + cancelButton: localize('mcpSandboxSuggestion.confirm.no', "No"), + }).then(async result => { + if (!result.confirmed) { + return; + } + + if (!mcpResource || configTarget === undefined) { + this._notificationService.warn(localize('mcpSandboxSuggestion.apply.unavailable', "Couldn't determine where to update sandbox configuration for {0}.", cnx.definition.label)); + return; + } + + try { + const updated = await this._mcpSandboxService.applySandboxConfigSuggestion(cnx.definition, mcpResource, configTarget, potentialBlocks, suggestion.sandboxConfig); + if (updated) { + this._removePotentialSandboxBlocks(potentialBlocks); + this._notificationService.info(localize('mcpSandboxSuggestion.apply.success', "Updated sandbox configuration for {0} in mcp.json. Restart server.", cnx.definition.label)); + } + } catch (e) { + this._notificationService.error(localize('mcpSandboxSuggestion.apply.error', "Failed to update sandbox configuration for {0}: {1}", cnx.definition.label, e instanceof Error ? e.message : String(e))); + } + }).finally(() => { + this._isSandboxSuggestionDialogVisible = false; + }); + } + + public recordPotentialSandboxBlock(block: IMcpPotentialSandboxBlock): void { + this._potentialSandboxBlocks.push(block); + if (this._potentialSandboxBlocks.length > 200) { + this._potentialSandboxBlocks.splice(0, this._potentialSandboxBlocks.length - 200); + } + + const connection = this._connection.get(); + if (connection?.state.get().state === McpConnectionState.Kind.Running) { + this.showSandboxConfigSuggestionFromPotentialBlocks(connection, this._potentialSandboxBlocks); + } + } + + private _removePotentialSandboxBlocks(blocks: readonly IMcpPotentialSandboxBlock[]): void { + if (!blocks.length || !this._potentialSandboxBlocks.length) { + return; + } + + const toRemove = new Set(blocks); + this._potentialSandboxBlocks = this._potentialSandboxBlocks.filter(block => !toRemove.has(block)); + } + public stop(): Promise { return this._connection.get()?.stop() || Promise.resolve(); } diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts index 7adde629336ff..b67ba4f17a741 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts @@ -5,6 +5,7 @@ import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; +import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, IObservable, observableValue } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; @@ -13,15 +14,17 @@ import { ILogger, log, LogLevel } from '../../../../platform/log/common/log.js'; import { IMcpHostDelegate, IMcpMessageTransport } from './mcpRegistryTypes.js'; import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; import { McpTaskManager } from './mcpTaskManager.js'; -import { IMcpClientMethods, IMcpServerConnection, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch } from './mcpTypes.js'; +import { IMcpClientMethods, IMcpPotentialSandboxBlock, IMcpServerConnection, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch } from './mcpTypes.js'; export class McpServerConnection extends Disposable implements IMcpServerConnection { private readonly _launch = this._register(new MutableDisposable>()); private readonly _state = observableValue('mcpServerState', { state: McpConnectionState.Kind.Stopped }); private readonly _requestHandler = observableValue('mcpServerRequestHandler', undefined); + private readonly _onPotentialSandboxBlock = this._register(new Emitter()); public readonly state: IObservable = this._state; public readonly handler: IObservable = this._requestHandler; + public readonly onPotentialSandboxBlock = this._onPotentialSandboxBlock.event; constructor( private readonly _collection: McpCollectionDefinition, @@ -69,6 +72,10 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect store.add(launch); store.add(launch.onDidLog(({ level, message }) => { log(this._logger, level, message); + const potentialBlock = this._toPotentialSandboxBlock(message); + if (potentialBlock) { + this._onPotentialSandboxBlock.fire(potentialBlock); + } })); let didStart = false; @@ -141,4 +148,65 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect }); }); } + + private _toPotentialSandboxBlock(message: string): IMcpPotentialSandboxBlock | undefined { + if (!this.definition.sandboxEnabled) { + return undefined; + } + + if (/No matching config rule, denying:/i.test(message)) { + return { + kind: 'network', + message, + host: this._extractSandboxHost(message), + }; + } + + if (/\b(?:EAI_AGAIN|ENOTFOUND)\b/i.test(message)) { + return { + kind: 'network', + message, + host: this._extractSandboxHost(message), + }; + } + + if (/(?:\b(?:EACCES|EPERM|ENOENT|fail(?:ed|ure)?)\b|not accessible)/i.test(message)) { + return { + kind: 'filesystem', + message, + path: this._extractSandboxPath(message), + }; + } + + return undefined; + } + + private _extractSandboxPath(line: string): string | undefined { + const bracketedPath = line.match(/\[(\/[^\]\r\n]+)\]/); + if (bracketedPath?.[1]) { + return bracketedPath[1].trim(); + } + + const quotedPath = line.match(/["'](\/[^"']+)["']/); + if (quotedPath?.[1]) { + return quotedPath[1]; + } + + const trailingPath = line.match(/(\/[\w.\-~/ ]+)$/); + return trailingPath?.[1]?.trim(); + } + + private _extractSandboxHost(value: string): string | undefined { + const deniedMatch = value.match(/No matching config rule, denying:\s+(.+)$/i); + const matchTarget = deniedMatch?.[1] ?? value; + const trimmed = matchTarget.trim().replace(/^["'`]+|["'`,.;]+$/g, ''); + if (!trimmed) { + return undefined; + } + + const withoutProtocol = trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); + const firstToken = withoutProtocol.split(/[\s/]/, 1)[0] ?? ''; + const host = firstToken.replace(/:\d+$/, ''); + return host || undefined; + } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 2387b8b1abc80..8ce685dfb1d48 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -62,6 +62,8 @@ export interface McpCollectionDefinition { readonly scope: StorageScope; /** Configuration target where configuration related to this server should be stored. */ readonly configTarget: ConfigurationTarget; + /** Root-level sandbox settings from the mcp config file. */ + readonly sandbox?: IMcpSandboxConfiguration; /** Resolves a server definition. If present, always called before a server starts. */ resolveServerLanch?(definition: McpServerDefinition): Promise; @@ -112,7 +114,8 @@ export namespace McpCollectionDefinition { return a.id === b.id && a.remoteAuthority === b.remoteAuthority && a.label === b.label - && a.trustBehavior === b.trustBehavior; + && a.trustBehavior === b.trustBehavior + && objectsEqual(a.sandbox, b.sandbox); } } @@ -581,10 +584,18 @@ export namespace McpServerLaunch { * stopped, and restarted. Once started and in a running state, it will * eventually build a {@link IMcpServerConnection.handler}. */ +export interface IMcpPotentialSandboxBlock { + readonly kind: 'network' | 'filesystem'; + readonly message: string; + readonly host?: string; + readonly path?: string; +} + export interface IMcpServerConnection extends IDisposable { readonly definition: McpServerDefinition; readonly state: IObservable; readonly handler: IObservable; + readonly onPotentialSandboxBlock: Event; /** * Resolved launch definition. Might not match the `definition.launch` due to diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts index e48fa86019459..e052e97d131e6 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -8,6 +8,7 @@ import * as sinon from 'sinon'; import { timeout } from '../../../../../base/common/async.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; import { upcast } from '../../../../../base/common/types.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -17,6 +18,7 @@ import { ServiceCollection } from '../../../../../platform/instantiation/common/ import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogger, ILoggerService, ILogService, NullLogger, NullLogService } from '../../../../../platform/log/common/log.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js'; +import { IMcpSandboxConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { TestNotificationService } from '../../../../../platform/notification/test/common/testNotificationService.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; @@ -33,7 +35,7 @@ import { IMcpHostDelegate, IMcpMessageTransport } from '../../common/mcpRegistry import { IMcpSandboxService } from '../../common/mcpSandboxService.js'; import { McpServerConnection } from '../../common/mcpServerConnection.js'; import { McpTaskManager } from '../../common/mcpTaskManager.js'; -import { LazyCollectionState, McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpServerTransportStdio, McpServerTransportType, McpServerTrust, McpStartServerInteraction } from '../../common/mcpTypes.js'; +import { IMcpPotentialSandboxBlock, LazyCollectionState, McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpServerTransportStdio, McpServerTransportType, McpServerTrust, McpStartServerInteraction } from '../../common/mcpTypes.js'; import { TestMcpMessageTransport } from './mcpRegistryTypes.js'; class TestConfigurationResolverService { @@ -161,6 +163,14 @@ class TestMcpSandboxService implements IMcpSandboxService { isEnabled(serverDef: McpServerDefinition): Promise { return Promise.resolve(this.enabled); } + + getSandboxConfigSuggestionMessage(_serverLabel: string, _potentialBlocks: readonly IMcpPotentialSandboxBlock[], _existingSandboxConfig?: IMcpSandboxConfiguration): { message: string; sandboxConfig: IMcpSandboxConfiguration } | undefined { + return undefined; + } + + applySandboxConfigSuggestion(_serverDef: McpServerDefinition, _mcpResource: URI, _configTarget: ConfigurationTarget, _potentialBlocks: readonly IMcpPotentialSandboxBlock[], _suggestedSandboxConfig?: IMcpSandboxConfiguration): Promise { + return Promise.resolve(false); + } } suite('Workbench - MCP - Registry', () => { @@ -371,11 +381,15 @@ suite('Workbench - MCP - Registry', () => { test('resolveConnection calls launchInSandboxIfEnabled with expected arguments when sandboxing is enabled', async () => { testMcpSandboxService.enabled = true; + const mcpResource = URI.file('/test/mcp.json'); const sandboxCollection: McpCollectionDefinition & { serverDefinitions: ISettableObservable } = { ...testCollection, id: 'sandbox-collection', remoteAuthority: 'ssh-remote+test', + presentation: { + origin: mcpResource, + }, }; const definition: McpServerDefinition = { From cff799b502e1230d1225637b2b8293e10d988fe3 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 2 Mar 2026 14:15:30 -0500 Subject: [PATCH 13/26] replace `/create-instructions` tip with `/init` one (#298764) fix #298759 --- .../contrib/chat/browser/chatTipCatalog.ts | 16 +++++----- .../contrib/chat/browser/chatTipService.ts | 3 +- .../chat/browser/chatTipStorageKeys.ts | 2 +- .../chat/test/browser/chatTipService.test.ts | 29 +++++++++++++++++-- 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts index 17e36d55d38e4..f0bc32db08f41 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts @@ -15,7 +15,7 @@ import { ITipExclusionConfig } from './chatTipEligibilityTracker.js'; import { TipTrackingCommands } from './chatTipStorageKeys.js'; import { GENERATE_AGENT_COMMAND_ID, - GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, + GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID, GENERATE_PROMPT_COMMAND_ID, GENERATE_SKILL_COMMAND_ID, INSERT_FORK_CONVERSATION_COMMAND_ID, @@ -138,24 +138,24 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ onlyWhenModelIds: ['gpt-4.1'], }, { - id: 'tip.createInstruction', + id: 'tip.init', tier: ChatTipTier.Foundational, priority: 50, buildMessage(ctx) { - const kb = formatKeybinding(ctx, GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID); + const kb = formatKeybinding(ctx, GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID); return new MarkdownString( localize( - 'tip.createInstruction', - "Use [{0}](command:{1}){2} to generate an on-demand instructions file with the agent.", - '/create-instructions', - GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, + 'tip.init', + "Use [{0}](command:{1}){2} to generate or update a workspace instructions file for AI coding agents.", + '/init', + GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID, kb ) ); }, when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), excludeWhenCommandsExecuted: [ - GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, + GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID, TipTrackingCommands.CreateAgentInstructionsUsed, ], }, diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 61e2ff7f0e0f7..3c44c35dc9938 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -278,12 +278,13 @@ export class ChatTipService extends Disposable implements IChatTipService { } const trimmed = message.text.trimStart(); - const match = /^\/(create-(?:instructions|prompt|agent|skill)|fork)(?:\s|$)/.exec(trimmed); + const match = /^\/(init|create-(?:instructions|prompt|agent|skill)|fork)(?:\s|$)/.exec(trimmed); return match ? this._toSlashCommandTrackingId(match[1]) : undefined; } private _toSlashCommandTrackingId(command: string): string | undefined { switch (command) { + case 'init': case 'create-instructions': return CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND; case 'create-prompt': diff --git a/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts b/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts index f7ccacdd94ccc..547cb11b836aa 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts @@ -37,7 +37,7 @@ export const TipEligibilityStorageKeys = { export const TipTrackingCommands = { /** Tracked when user attaches a file/folder reference with #. */ AttachFilesReferenceUsed: 'chat.tips.attachFiles.referenceUsed', - /** Tracked when user executes /create-instructions. */ + /** Tracked when user executes /init or /create-instructions. */ CreateAgentInstructionsUsed: 'chat.tips.createAgentInstructions.commandUsed', /** Tracked when user executes /create-prompt. */ CreatePromptUsed: 'chat.tips.createPrompt.commandUsed', 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 be41fddaa4dd6..04477fad57cb4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -194,6 +194,31 @@ suite('ChatTipService', () => { assert.ok(!executedCommands.includes(FORK_CONVERSATION_TRACKING_COMMAND)); }); + test('records init tip usage for submitted /init 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-init'), + message: { + text: '/init', + parts: [], + }, + }); + + const executedCommands = JSON.parse(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION) ?? '[]') as string[]; + assert.ok(executedCommands.includes(CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND)); + assert.ok(!executedCommands.includes(CREATE_PROMPT_TRACKING_COMMAND)); + assert.ok(!executedCommands.includes(CREATE_AGENT_TRACKING_COMMAND)); + assert.ok(!executedCommands.includes(CREATE_SKILL_TRACKING_COMMAND)); + assert.ok(!executedCommands.includes(FORK_CONVERSATION_TRACKING_COMMAND)); + }); + test('records fork tip usage for submitted /fork command', () => { const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); instantiationService.stub(IChatService, { @@ -1155,7 +1180,7 @@ suite('ChatTipService', () => { const service = createService(); contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); - const expectedCreateTips = new Set(['tip.createInstruction', 'tip.createPrompt', 'tip.createAgent', 'tip.createSkill']); + const expectedCreateTips = new Set(['tip.init', 'tip.createPrompt', 'tip.createAgent', 'tip.createSkill']); const seenCreateTips = new Set(); for (let i = 0; i < 100; i++) { const tip = service.getWelcomeTip(contextKeyService); @@ -1177,7 +1202,7 @@ suite('ChatTipService', () => { test('does not show create slash command tips in non-local chat sessions', () => { const service = createService(); contextKeyService.createKey(ChatContextKeys.chatSessionType.key, 'cloud'); - const createTipIds = new Set(['tip.createInstruction', 'tip.createPrompt', 'tip.createAgent', 'tip.createSkill']); + const createTipIds = new Set(['tip.init', 'tip.createPrompt', 'tip.createAgent', 'tip.createSkill']); for (let i = 0; i < 100; i++) { const tip = service.getWelcomeTip(contextKeyService); From 7373db526df7c2483024c61181e2316c015539bd Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 2 Mar 2026 14:44:14 -0500 Subject: [PATCH 14/26] hide tip widget for session if a tip is dismissed or actioned (#298766) fixes #297682 --- .../contrib/chat/browser/chatTipService.ts | 34 +++++++++++++++++++ .../chatContentParts/chatTipContentPart.ts | 14 ++------ .../chat/test/browser/chatTipService.test.ts | 17 ++++++++++ 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 3c44c35dc9938..0760a8ecbf45a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -106,12 +106,22 @@ export interface IChatTipService { */ dismissTip(): void; + /** + * Dismisses the current tip and hides all tips for the rest of the current chat session. + */ + dismissTipForSession(): void; + /** * Hides the tip widget without permanently dismissing the tip. * The tip may be shown again in a future session. */ hideTip(): void; + /** + * Hides all tips for the rest of the current chat session. + */ + hideTipsForSession(): void; + /** * Disables tips permanently by setting the `chat.tips.enabled` configuration to false. */ @@ -185,6 +195,7 @@ export class ChatTipService extends Disposable implements IChatTipService { private readonly _createSlashCommandsUsageTracker: CreateSlashCommandsUsageTracker; private _yoloModeEverEnabled: boolean; private _thinkingPhrasesEverModified: boolean; + private _tipsHiddenForSession = false; private readonly _tipCommandListener = this._register(new MutableDisposable()); constructor( @@ -304,6 +315,7 @@ export class ChatTipService extends Disposable implements IChatTipService { this._shownTip = undefined; this._tipRequestId = undefined; this._contextKeyService = undefined; + this._tipsHiddenForSession = false; } dismissTip(): void { @@ -319,12 +331,18 @@ export class ChatTipService extends Disposable implements IChatTipService { this._onDidDismissTip.fire(); } + dismissTipForSession(): void { + this.dismissTip(); + this.hideTipsForSession(); + } + clearDismissedTips(): void { this._storageService.remove(ChatTipStorageKeys.DismissedTips, StorageScope.APPLICATION); this._storageService.remove(ChatTipStorageKeys.DismissedTips, StorageScope.PROFILE); this._shownTip = undefined; this._tipRequestId = undefined; this._contextKeyService = undefined; + this._tipsHiddenForSession = false; this._onDidDismissTip.fire(); } @@ -363,6 +381,17 @@ export class ChatTipService extends Disposable implements IChatTipService { this._onDidHideTip.fire(); } + hideTipsForSession(): void { + if (this._tipsHiddenForSession) { + return; + } + + this._tipsHiddenForSession = true; + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidHideTip.fire(); + } + async disableTips(): Promise { if (this._shownTip) { this._logTipTelemetry(this._shownTip.id, 'disabled'); @@ -386,6 +415,10 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + if (this._tipsHiddenForSession) { + return undefined; + } + // Store the scoped context key service for later navigation calls this._contextKeyService = contextKeyService; @@ -803,6 +836,7 @@ export class ChatTipService extends Disposable implements IChatTipService { if (dismissCommandSet.has(e.commandId)) { this.dismissTip(); } + this.hideTipsForSession(); } }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 75ac2bef68ec2..3e195b32ada8e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -74,15 +74,7 @@ export class ChatTipContentPart extends Disposable { this._renderTip(tip); this._register(this._chatTipService.onDidDismissTip(() => { - // Use getNextEligibleTip instead of navigateToNextTip to show the next - // available tip even if it's the only one left (no multiple-tip requirement) - const nextTip = this._chatTipService.getNextEligibleTip(); - if (nextTip) { - this._renderTip(nextTip); - dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => this.focus()); - } else { - this._onDidHide.fire(); - } + this._onDidHide.fire(); })); this._register(this._chatTipService.onDidNavigateTip(tip => { @@ -230,7 +222,7 @@ registerAction2(class DismissTipToolbarAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - accessor.get(IChatTipService).dismissTip(); + accessor.get(IChatTipService).dismissTipForSession(); } }); @@ -253,7 +245,7 @@ registerAction2(class DismissTipAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - accessor.get(IChatTipService).dismissTip(); + accessor.get(IChatTipService).dismissTipForSession(); } }); 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 04477fad57cb4..d03e8e547dcb8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -393,6 +393,20 @@ suite('ChatTipService', () => { } }); + test('dismissTipForSession hides tips until resetSession', () => { + const service = createService(); + + const tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip); + + service.dismissTipForSession(); + + assert.strictEqual(service.getWelcomeTip(contextKeyService), undefined, 'Tips should stay hidden for the current session after dismissing'); + + service.resetSession(); + assert.ok(service.getWelcomeTip(contextKeyService), 'Tips should reappear after resetting the session'); + }); + test('navigateToNextTip keeps foundational tips before QoL tips', () => { const service = createService(); contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); @@ -1377,6 +1391,9 @@ suite('ChatTipService', () => { commandExecutedEmitter.fire({ commandId: 'workbench.action.openSettings', args: [] }); assert.strictEqual(dismissed, true, `${tipId} should dismiss when its settings command is clicked`); + assert.strictEqual(service.getWelcomeTip(contextKeyService), undefined, 'Tips should hide for the rest of the session after actioning a tip'); + + service.resetSession(); assertTipNeverShown(service, tipId); }); } From 5b46a7924f8fcd97253154f602223e71f7dd3aa5 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 2 Mar 2026 11:45:16 -0800 Subject: [PATCH 15/26] chat: make sendChatRequest extensionIdentifier parameter optional (#298767) * chat: make sendChatRequest extensionIdentifier parameter optional Makes the 'from' parameter optional (ExtensionIdentifier | undefined) in the sendChatRequest method chain across ILanguageModelsService, RPC protocol, and implementations. This allows internal VS Code calls to pass undefined instead of instantiating invalid ExtensionIdentifier('core') identifiers. - Updates ILanguageModelsService.sendChatRequest signature - Updates ILanguageModelChatProvider.sendChatRequest signature - Updates ExtHostLanguageModelsShape protocol - Updates ExtHostLanguageModels \ implementation - Removes new ExtensionIdentifier('core') from 5 internal callers - Passes undefined as requestInitiator when from is absent - Bumps vscode.proposed.chatProvider API version to 5 - Updates test mocks to accept optional from parameter Refs https://github.com/microsoft/vscode/issues/290436 (Commit message generated by Copilot) * keep api as old version for now * Update src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .vscode/notebooks/my-work.github-issues | 2 +- src/vs/workbench/api/common/extHost.protocol.ts | 2 +- src/vs/workbench/api/common/extHostLanguageModels.ts | 5 +++-- .../chatEditing/chatEditingExplanationModelManager.ts | 3 +-- .../widget/chatContentParts/chatThinkingContentPart.ts | 3 +-- .../chat/browser/widget/chatQuestionCarouselAutoReply.ts | 5 ++--- src/vs/workbench/contrib/chat/common/languageModels.ts | 8 ++++---- .../contrib/chat/test/common/languageModels.test.ts | 2 +- .../workbench/contrib/chat/test/common/languageModels.ts | 2 +- src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts | 4 +--- .../browser/tools/monitoring/outputMonitor.ts | 7 +++---- src/vscode-dts/vscode.proposed.chatProvider.d.ts | 3 ++- 12 files changed, 21 insertions(+), 25 deletions(-) diff --git a/.vscode/notebooks/my-work.github-issues b/.vscode/notebooks/my-work.github-issues index b6c82fff3590b..c4bc569e9da31 100644 --- a/.vscode/notebooks/my-work.github-issues +++ b/.vscode/notebooks/my-work.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce repo:microsoft/vscode-copilot-issues repo:microsoft/vscode-extension-samples\n\n// current milestone name\n$MILESTONE=milestone:\"February 2026\"\n" + "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce repo:microsoft/vscode-copilot-issues repo:microsoft/vscode-extension-samples\n\n// current milestone name\n$MILESTONE=milestone:\"March 2026\"\n" }, { "kind": 1, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 8abfea0b8d317..0f5821f2662b2 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1368,7 +1368,7 @@ export interface MainThreadLanguageModelsShape extends IDisposable { export interface ExtHostLanguageModelsShape { $provideLanguageModelChatInfo(vendor: string, options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; $updateModelAccesslist(data: { from: ExtensionIdentifier; to: ExtensionIdentifier; enabled: boolean }[]): void; - $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier, messages: SerializableObjectWithBuffers, options: { [name: string]: any }, token: CancellationToken): Promise; + $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier | undefined, messages: SerializableObjectWithBuffers, options: { [name: string]: any }, token: CancellationToken): Promise; $acceptResponsePart(requestId: number, chunk: SerializableObjectWithBuffers): Promise; $acceptResponseDone(requestId: number, error: SerializedError | undefined): Promise; $provideTokenLength(modelId: string, value: string | IChatMessage, token: CancellationToken): Promise; diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index 18c2045825c0d..9325afc01855d 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -258,7 +258,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { return modelMetadataAndIdentifier; } - async $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier, messages: SerializableObjectWithBuffers, options: vscode.LanguageModelChatRequestOptions, token: CancellationToken): Promise { + async $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier | undefined, messages: SerializableObjectWithBuffers, options: vscode.LanguageModelChatRequestOptions, token: CancellationToken): Promise { const knownModel = this._localModels.get(modelId); if (!knownModel) { throw new Error('Model not found'); @@ -319,7 +319,8 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { value = data.provider.provideLanguageModelChatResponse( knownModel.info, messages.value.map(typeConvert.LanguageModelChatMessage2.to), - { ...options, modelOptions: options.modelOptions ?? {}, requestInitiator: ExtensionIdentifier.toKey(from), toolMode: options.toolMode ?? extHostTypes.LanguageModelChatToolMode.Auto }, + // todo@connor4312: move `core` -> `undefined` after 1.111 Insiders is out + { ...options, modelOptions: options.modelOptions ?? {}, requestInitiator: from ? ExtensionIdentifier.toKey(from) : 'core', toolMode: options.toolMode ?? extHostTypes.LanguageModelChatToolMode.Auto }, progress, token ); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts index b26f5ced875aa..e752209f42896 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts @@ -14,7 +14,6 @@ import { DetailedLineRangeMapping, LineRangeMapping } from '../../../../../edito import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { ChatMessageRole, ILanguageModelsService } from '../../common/languageModels.js'; -import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import * as nls from '../../../../../nls.js'; /** @@ -284,7 +283,7 @@ Example response format: const response = await this._languageModelsService.sendChatRequest( models[0], - new ExtensionIdentifier('core'), + undefined, [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], {}, cancellationToken diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 473db6ff51a02..bbab1de37aa36 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -32,7 +32,6 @@ import { autorun } from '../../../../../../base/common/observable.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js'; -import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import './media/chatThinkingContent.css'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; @@ -958,7 +957,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): const response = await this.languageModelsService.sendChatRequest( models[0], - new ExtensionIdentifier('core'), + undefined, [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], {}, cts.token diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatQuestionCarouselAutoReply.ts b/src/vs/workbench/contrib/chat/browser/widget/chatQuestionCarouselAutoReply.ts index eca6e1608144a..51ea07629f023 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatQuestionCarouselAutoReply.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatQuestionCarouselAutoReply.ts @@ -13,7 +13,6 @@ import { hasKey } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IChatQuestion, IChatQuestionCarousel } from '../../common/chatService/chatService.js'; @@ -191,7 +190,7 @@ export class ChatQuestionCarouselAutoReply extends Disposable { const prompt = this.buildPrompt(carousel, requestMessageText, false); const response = await this.languageModelsService.sendChatRequest( modelId, - new ExtensionIdentifier('core'), + undefined, [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], {}, token, @@ -205,7 +204,7 @@ export class ChatQuestionCarouselAutoReply extends Disposable { const retryPrompt = this.buildPrompt(carousel, requestMessageText, true); const retryResponse = await this.languageModelsService.sendChatRequest( modelId, - new ExtensionIdentifier('core'), + undefined, [{ role: ChatMessageRole.User, content: [{ type: 'text', value: retryPrompt }] }], {}, token, diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 803c331fb67ec..08912e24f6614 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -263,13 +263,13 @@ export async function getTextResponseFromStream(response: ILanguageModelChatResp export interface ILanguageModelChatProvider { readonly onDidChange: Event; provideLanguageModelChatInfo(options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; - sendChatRequest(modelId: string, messages: IChatMessage[], from: ExtensionIdentifier, options: { [name: string]: unknown }, token: CancellationToken): Promise; + sendChatRequest(modelId: string, messages: IChatMessage[], from: ExtensionIdentifier | undefined, options: { [name: string]: unknown }, token: CancellationToken): Promise; provideTokenCount(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise; } export interface ILanguageModelChat { metadata: ILanguageModelChatMetadata; - sendChatRequest(messages: IChatMessage[], from: ExtensionIdentifier, options: { [name: string]: unknown }, token: CancellationToken): Promise; + sendChatRequest(messages: IChatMessage[], from: ExtensionIdentifier | undefined, options: { [name: string]: unknown }, token: CancellationToken): Promise; provideTokenCount(message: string | IChatMessage, token: CancellationToken): Promise; } @@ -355,7 +355,7 @@ export interface ILanguageModelsService { deltaLanguageModelChatProviderDescriptors(added: IUserFriendlyLanguageModel[], removed: IUserFriendlyLanguageModel[]): void; // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendChatRequest(modelId: string, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise; + sendChatRequest(modelId: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise; computeTokenLength(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise; @@ -927,7 +927,7 @@ export class LanguageModelsService implements ILanguageModelsService { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - async sendChatRequest(modelId: string, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { + async sendChatRequest(modelId: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { const provider = this._providers.get(this._modelCache.get(modelId)?.vendor || ''); if (!provider) { throw new Error(`Chat provider for model ${modelId} is not registered.`); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index ddac0e86cc3dc..5790fab95ddc0 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -160,7 +160,7 @@ suite('LanguageModels', function () { })); return modelMetadataAndIdentifier; }, - sendChatRequest: async (modelId: string, messages: IChatMessage[], _from: ExtensionIdentifier, _options: { [name: string]: any }, token: CancellationToken) => { + sendChatRequest: async (modelId: string, messages: IChatMessage[], _from: ExtensionIdentifier | undefined, _options: { [name: string]: any }, token: CancellationToken) => { // const message = messages.at(-1); const defer = new DeferredPromise(); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index 77b874be830d0..8ead7eab0a9b0 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -67,7 +67,7 @@ export class NullLanguageModelsService implements ILanguageModelsService { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendChatRequest(identifier: string, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { + sendChatRequest(identifier: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts b/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts index 317b4a0473bd5..a49f79b70dc64 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts @@ -15,7 +15,6 @@ import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, getConfigValueInTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { ChatAgentLocation, ChatConfiguration } from '../../chat/common/constants.js'; @@ -80,8 +79,7 @@ export class McpSamplingService extends Disposable implements IMcpSamplingServic } const model = await this._modelSequencer.queue(() => this._getMatchingModel(opts)); - // todo@connor4312: nullExtensionDescription.identifier -> undefined with API update - const response = await this._languageModelsService.sendChatRequest(model, new ExtensionIdentifier('core'), messages, {}, token); + const response = await this._languageModelsService.sendChatRequest(model, undefined, messages, {}, token); let responseText = ''; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 56f9617ea7bfd..eef1a76329b34 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -13,7 +13,6 @@ import { Disposable, MutableDisposable, type IDisposable } from '../../../../../ import { isObject, isString } from '../../../../../../../base/common/types.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { localize } from '../../../../../../../nls.js'; -import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; import { IChatWidgetService } from '../../../../../chat/browser/chat.js'; import { ChatElicitationRequestPart } from '../../../../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatModel } from '../../../../../chat/common/model/chatModel.js'; @@ -473,7 +472,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const response = await this._languageModelsService.sendChatRequest( model, - new ExtensionIdentifier('core'), + undefined, [{ role: ChatMessageRole.User, content: [{ type: 'text', value: `Evaluate this terminal output to determine if there were errors. If there are errors, return them. Otherwise, return undefined: ${buffer}.` }] }], {}, token @@ -546,7 +545,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { ${lastLines} `; - const response = await this._languageModelsService.sendChatRequest(model, new ExtensionIdentifier('core'), [{ role: ChatMessageRole.User, content: [{ type: 'text', value: promptText }] }], {}, token); + const response = await this._languageModelsService.sendChatRequest(model, undefined, [{ role: ChatMessageRole.User, content: [{ type: 'text', value: promptText }] }], {}, token); const responseText = await getTextResponseFromStream(response); try { const match = responseText.match(/\{[\s\S]*\}/); @@ -652,7 +651,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (model) { try { const promptText = `Given the following confirmation prompt and options from a terminal output, which option is the default?\nPrompt: "${prompt}"\nOptions: ${JSON.stringify(options)}\nRespond with only the option string.`; - const response = await this._languageModelsService.sendChatRequest(model, new ExtensionIdentifier('core'), [ + const response = await this._languageModelsService.sendChatRequest(model, undefined, [ { role: ChatMessageRole.User, content: [{ type: 'text', value: promptText }] } ], {}, token); diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index 1ded6ac9ba730..b19b106205b8e 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -13,7 +13,8 @@ declare module 'vscode' { export interface ProvideLanguageModelChatResponseOptions { /** - * What extension initiated the request to the language model + * What extension initiated the request to the language model, or + * `undefined` if the request was initiated by other functionality in the editor. */ readonly requestInitiator: string; } From c62b267ddfe56c11a04389818bf6aa08eea22398 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 2 Mar 2026 20:46:00 +0100 Subject: [PATCH 16/26] sessions - revert too annoying toasts display (#298771) --- .../contrib/configuration/browser/configuration.contribution.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 50b8be0e52141..dc83de217d11d 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { isMacintosh } from '../../../../base/common/platform.js'; import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -12,7 +11,6 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'chat.agent.maxRequests': 1000, 'chat.customizationsMenu.userStoragePath': '~/.copilot', 'chat.viewSessions.enabled': false, - 'chat.notifyWindowOnConfirmation': isMacintosh ? 'always' : 'windowNotFocused', // macOS can confirm from the toast so we show it always 'chat.implicitContext.suggestedContext': false, 'breadcrumbs.enabled': false, From bafa1dff7b421f4f47f62d3638ae32e88e627603 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Mon, 2 Mar 2026 13:02:18 +0100 Subject: [PATCH 17/26] removes dependency --- .vscode/extensions/vscode-selfhost-test-provider/package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/.vscode/extensions/vscode-selfhost-test-provider/package.json b/.vscode/extensions/vscode-selfhost-test-provider/package.json index 1a894a5405532..6f0db218fb254 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/package.json +++ b/.vscode/extensions/vscode-selfhost-test-provider/package.json @@ -6,9 +6,6 @@ "testObserver", "testRelatedCode" ], - "extensionDependencies": [ - "ms-vscode.vscode-extras" - ], "engines": { "vscode": "^1.88.0" }, From b4e1496065684a3d68ef4b17c02d898d7b766939 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 2 Mar 2026 21:47:24 +0100 Subject: [PATCH 18/26] sessions - restore sessions window when all windows closed (#298793) --- src/vs/code/electron-main/app.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 7881f739531cc..78641318c5f10 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -408,7 +408,11 @@ export class CodeApplication extends Disposable { // Mac only event: open new window when we get activated if (!hasVisibleWindows) { - await this.windowsMainService?.openEmptyWindow({ context: OpenContext.DOCK }); + if ((process as INodeProcess).isEmbeddedApp || (this.environmentMainService.args['sessions'] && this.productService.quality !== 'stable')) { + await this.windowsMainService?.openSessionsWindow({ context: OpenContext.DOCK }); + } else { + await this.windowsMainService?.openEmptyWindow({ context: OpenContext.DOCK }); + } } }); From 305a82d7626921439a6f07e37bfb81d8c3260249 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:07:56 -0800 Subject: [PATCH 19/26] Port github-authentication extension to use esbuild --- .../github-authentication/.vscodeignore | 4 +- .../github-authentication/esbuild.browser.mts | 39 +++++++++++++++++++ ...xtension.webpack.config.js => esbuild.mts} | 19 +++++---- .../extension-browser.webpack.config.js | 24 ------------ extensions/github-authentication/package.json | 4 +- .../src/browser/authServer.ts | 6 +++ .../tsconfig.browser.json | 8 ++++ 7 files changed, 69 insertions(+), 35 deletions(-) create mode 100644 extensions/github-authentication/esbuild.browser.mts rename extensions/github-authentication/{extension.webpack.config.js => esbuild.mts} (51%) delete mode 100644 extensions/github-authentication/extension-browser.webpack.config.js create mode 100644 extensions/github-authentication/tsconfig.browser.json diff --git a/extensions/github-authentication/.vscodeignore b/extensions/github-authentication/.vscodeignore index 0f1797efe9561..fd8583ab8d125 100644 --- a/extensions/github-authentication/.vscodeignore +++ b/extensions/github-authentication/.vscodeignore @@ -3,7 +3,7 @@ src/** !src/common/config.json out/** build/** -extension.webpack.config.js -extension-browser.webpack.config.js +esbuild.mts +esbuild.browser.mts tsconfig*.json package-lock.json diff --git a/extensions/github-authentication/esbuild.browser.mts b/extensions/github-authentication/esbuild.browser.mts new file mode 100644 index 0000000000000..08d66fe4c6411 --- /dev/null +++ b/extensions/github-authentication/esbuild.browser.mts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import type { Plugin } from 'esbuild'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +/** + * Plugin that rewrites `./node/*` imports to `./browser/*` for the web build, + * replacing the platform-specific implementations with their browser equivalents. + */ +const platformModulesPlugin: Plugin = { + name: 'platform-modules', + setup(build) { + build.onResolve({ filter: /\/node\// }, args => { + if (args.kind !== 'import-statement' || !args.resolveDir) { + return; + } + const remapped = args.path.replace('/node/', '/browser/'); + return build.resolve(remapped, { resolveDir: args.resolveDir, kind: args.kind }); + }); + }, +}; + +run({ + platform: 'browser', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + plugins: [platformModulesPlugin], + }, +}, process.argv); diff --git a/extensions/github-authentication/extension.webpack.config.js b/extensions/github-authentication/esbuild.mts similarity index 51% rename from extensions/github-authentication/extension.webpack.config.js rename to extensions/github-authentication/esbuild.mts index 166c1d8b1e340..2b75ca703da06 100644 --- a/extensions/github-authentication/extension.webpack.config.js +++ b/extensions/github-authentication/esbuild.mts @@ -2,12 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts', +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), }, -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/github-authentication/extension-browser.webpack.config.js b/extensions/github-authentication/extension-browser.webpack.config.js deleted file mode 100644 index 70a7fd87cf4a3..0000000000000 --- a/extensions/github-authentication/extension-browser.webpack.config.js +++ /dev/null @@ -1,24 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import path from 'path'; -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - node: false, - entry: { - extension: './src/extension.ts', - }, - resolve: { - alias: { - 'uuid': path.resolve(import.meta.dirname, 'node_modules/uuid/dist/esm-browser/index.js'), - './node/authServer': path.resolve(import.meta.dirname, 'src/browser/authServer'), - './node/crypto': path.resolve(import.meta.dirname, 'src/browser/crypto'), - './node/fetch': path.resolve(import.meta.dirname, 'src/browser/fetch'), - './node/buffer': path.resolve(import.meta.dirname, 'src/browser/buffer'), - } - } -}); diff --git a/extensions/github-authentication/package.json b/extensions/github-authentication/package.json index e343bb19900ac..28877821ed171 100644 --- a/extensions/github-authentication/package.json +++ b/extensions/github-authentication/package.json @@ -79,9 +79,9 @@ "browser": "./dist/browser/extension.js", "scripts": { "compile": "gulp compile-extension:github-authentication", - "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", + "compile-web": "node esbuild.browser.mts", "watch": "gulp watch-extension:github-authentication", - "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose", + "watch-web": "node esbuild.browser.mts --watch", "vscode:prepublish": "npm run compile" }, "dependencies": { diff --git a/extensions/github-authentication/src/browser/authServer.ts b/extensions/github-authentication/src/browser/authServer.ts index 60b53c713a85e..b53642ee74a18 100644 --- a/extensions/github-authentication/src/browser/authServer.ts +++ b/extensions/github-authentication/src/browser/authServer.ts @@ -10,3 +10,9 @@ export function startServer(_: any): any { export function createServer(_: any): any { throw new Error('Not implemented'); } + +export class LoopbackAuthServer { + constructor(..._args: any[]) { + throw new Error('Not implemented'); + } +} diff --git a/extensions/github-authentication/tsconfig.browser.json b/extensions/github-authentication/tsconfig.browser.json new file mode 100644 index 0000000000000..233289b8bd578 --- /dev/null +++ b/extensions/github-authentication/tsconfig.browser.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "exclude": [ + "./src/node/**", + "./src/test/**" + ] +} From 46b8e82a0dc18de21ce7680c3de6853619969715 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:22:20 -0800 Subject: [PATCH 20/26] Update extensions/github-authentication/esbuild.browser.mts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/github-authentication/esbuild.browser.mts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/github-authentication/esbuild.browser.mts b/extensions/github-authentication/esbuild.browser.mts index 08d66fe4c6411..20745e1d0870e 100644 --- a/extensions/github-authentication/esbuild.browser.mts +++ b/extensions/github-authentication/esbuild.browser.mts @@ -35,5 +35,6 @@ run({ outdir: outDir, additionalOptions: { plugins: [platformModulesPlugin], + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, }, process.argv); From 2c494f110b1a86f5d4af9a31b6ea063e375969a0 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 2 Mar 2026 23:04:38 +0100 Subject: [PATCH 21/26] sessions - wording (#298799) * fix - update notification messages to use 'session' * fix - update toast action label to 'Open Session' --- .../contrib/chat/browser/chatWindowNotifier.ts | 4 ++-- .../chat/electron-browser/chat.contribution.ts | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatWindowNotifier.ts b/src/vs/workbench/contrib/chat/browser/chatWindowNotifier.ts index 3dce99f35720d..1360bc61881af 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWindowNotifier.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWindowNotifier.ts @@ -93,7 +93,7 @@ export class ChatWindowNotifier extends Disposable implements IWorkbenchContribu } // Create OS notification - const notificationTitle = info.title ? localize('chatTitle', "Chat: {0}", info.title) : localize('chat.untitledChat', "Untitled Chat"); + const notificationTitle = info.title ? localize('chatTitle', "Session: {0}", info.title) : localize('chat.untitledChat', "Untitled Session"); const cts = new CancellationTokenSource(); this._activeNotifications.set(sessionResource, toDisposable(() => cts.dispose(true))); @@ -103,7 +103,7 @@ export class ChatWindowNotifier extends Disposable implements IWorkbenchContribu try { const actionLabel = isQuestionCarousel - ? localize('openChatAction', "Open Chat") + ? localize('openChatAction', "Open Session") : localize('allowAction', "Allow"); const result = await this._hostService.showToast({ diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 4ec33cc52a33a..8731979f149ee 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -146,7 +146,7 @@ class ChatLifecycleHandler extends Disposable { })); this._register(extensionService.onWillStop(e => { - e.veto(this.hasNonCloudSessionInProgress(), localize('chatRequestInProgress', "A chat request is in progress.")); + e.veto(this.hasNonCloudSessionInProgress(), localize('chatRequestInProgress', "A session is in progress.")); })); } @@ -182,20 +182,20 @@ class ChatLifecycleHandler extends Disposable { let detail: string; switch (reason) { case ShutdownReason.CLOSE: - message = localize('closeTheWindow.message', "A chat request is in progress. Are you sure you want to close the window?"); - detail = localize('closeTheWindow.detail', "The chat request will stop if you close the window."); + message = localize('closeTheWindow.message', "A session is in progress. Are you sure you want to close the window?"); + detail = localize('closeTheWindow.detail', "The session will stop if you close the window."); break; case ShutdownReason.LOAD: - message = localize('changeWorkspace.message', "A chat request is in progress. Are you sure you want to change the workspace?"); - detail = localize('changeWorkspace.detail', "The chat request will stop if you change the workspace."); + message = localize('changeWorkspace.message', "A session is in progress. Are you sure you want to change the workspace?"); + detail = localize('changeWorkspace.detail', "The session will stop if you change the workspace."); break; case ShutdownReason.RELOAD: - message = localize('reloadTheWindow.message', "A chat request is in progress. Are you sure you want to reload the window?"); - detail = localize('reloadTheWindow.detail', "The chat request will stop if you reload the window."); + message = localize('reloadTheWindow.message', "A session is in progress. Are you sure you want to reload the window?"); + detail = localize('reloadTheWindow.detail', "The session will stop if you reload the window."); break; default: - message = isMacintosh ? localize('quit.message', "A chat request is in progress. Are you sure you want to quit?") : localize('exit.message', "A chat request is in progress. Are you sure you want to exit?"); - detail = isMacintosh ? localize('quit.detail', "The chat request will stop if you quit.") : localize('exit.detail', "The chat request will stop if you exit."); + message = isMacintosh ? localize('quit.message', "A session is in progress. Are you sure you want to quit?") : localize('exit.message', "A session is in progress. Are you sure you want to exit?"); + detail = isMacintosh ? localize('quit.detail', "The session will stop if you quit.") : localize('exit.detail', "The session will stop if you exit."); break; } From 119cb000f203259f9352eb3ecf62bd41d7d56b94 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 2 Mar 2026 17:07:04 -0500 Subject: [PATCH 22/26] retain input in question carousel (#298795) * retain input state for chat questions * better approach --- .../chatQuestionCarouselPart.ts | 47 +++++++++++++- .../chat/browser/widget/chatListRenderer.ts | 10 +-- .../chatQuestionCarouselData.ts | 2 + .../chatQuestionCarouselPart.test.ts | 64 +++++++++++++++++++ .../model/chatQuestionCarouselData.test.ts | 4 ++ 5 files changed, 121 insertions(+), 6 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 fbdfccb2402f4..5ce3306ae08fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -19,6 +19,7 @@ import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { InputBox } from '../../../../../../base/browser/ui/inputbox/inputBox.js'; import { Checkbox } from '../../../../../../base/browser/ui/toggle/toggle.js'; import { IChatQuestion, IChatQuestionCarousel } from '../../../common/chatService/chatService.js'; +import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { IChatRendererContent, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatTreeItem } from '../../chat.js'; @@ -32,7 +33,6 @@ import './media/chatQuestionCarousel.css'; const PREVIOUS_QUESTION_ACTION_ID = 'workbench.action.chat.previousQuestion'; const NEXT_QUESTION_ACTION_ID = 'workbench.action.chat.nextQuestion'; - export interface IChatQuestionCarouselOptions { onSubmit: (answers: Map | undefined) => void; shouldAutoFocus?: boolean; @@ -100,7 +100,20 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this.domNode.setAttribute('aria-roledescription', localize('chat.questionCarousel.roleDescription', 'chat question')); this._updateAriaLabel(); - // Restore answers from carousel data if already submitted (e.g., after re-render due to virtualization) + // Restore draft state from transient runtime fields when available. + if (carousel instanceof ChatQuestionCarouselData) { + if (typeof carousel.draftCurrentIndex === 'number') { + this._currentIndex = Math.max(0, Math.min(carousel.draftCurrentIndex, carousel.questions.length - 1)); + } + + if (carousel.draftAnswers) { + for (const [key, value] of Object.entries(carousel.draftAnswers)) { + this._answers.set(key, value); + } + } + } + + // Restore submitted answers for summary rendering. if (carousel.data) { for (const [key, value] of Object.entries(carousel.data)) { this._answers.set(key, value); @@ -219,7 +232,20 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const answer = this.getCurrentAnswer(); if (answer !== undefined) { this._answers.set(currentQuestion.id, answer); + } else { + this._answers.delete(currentQuestion.id); + } + + this.persistDraftState(); + } + + private persistDraftState(): void { + if (this.carousel.isUsed || !(this.carousel instanceof ChatQuestionCarouselData)) { + return; } + + this.carousel.draftAnswers = Object.fromEntries(this._answers.entries()); + this.carousel.draftCurrentIndex = this._currentIndex; } /** @@ -231,6 +257,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (newIndex >= 0 && newIndex < this.carousel.questions.length) { this.saveCurrentAnswer(); this._currentIndex = newIndex; + this.persistDraftState(); this.renderCurrentQuestion(true); } } @@ -245,6 +272,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (this._currentIndex < this.carousel.questions.length - 1) { // Move to next question this._currentIndex++; + this.persistDraftState(); this.renderCurrentQuestion(true); } else { // Submit @@ -648,6 +676,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent placeholder: localize('chat.questionCarousel.enterText', 'Enter your answer'), inputBoxStyles: defaultInputBoxStyles, })); + this._inputBoxes.add(inputBox.onDidChange(() => this.saveCurrentAnswer())); // Restore previous answer if exists const previousAnswer = this._answers.get(question.id); @@ -716,6 +745,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (data) { data.selectedIndex = newIndex; } + + this.saveCurrentAnswer(); }; options.forEach((option, index) => { @@ -810,6 +841,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => { if (freeformTextarea.value.length > 0) { updateSelection(-1); + } else { + this.saveCurrentAnswer(); } })); @@ -963,6 +996,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._inputBoxes.add(checkbox.onChange(() => { listItem.classList.toggle('checked', checkbox.checked); listItem.setAttribute('aria-selected', String(checkbox.checked)); + this.saveCurrentAnswer(); })); // Click handler for the entire row (toggle checkbox) @@ -1007,6 +1041,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Setup auto-resize behavior const autoResize = this.setupTextareaAutoResize(freeformTextarea); + this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => this.saveCurrentAnswer())); freeformContainer.appendChild(freeformTextarea); container.appendChild(freeformContainer); @@ -1272,4 +1307,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent addDisposable(disposable: { dispose(): void }): void { this._register(disposable); } + + override dispose(): void { + if (!this._isSkipped && !this.carousel.isUsed) { + this.saveCurrentAnswer(); + } + + super.dispose(); + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index c3ae8477d422a..f0af7f89d3d87 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -2158,11 +2158,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined, part: ChatQuestionCarouselPart) => { // Mark the carousel as used and store the answers const answersRecord = answers ? Object.fromEntries(answers) : undefined; - if (answersRecord) { - carousel.data = answersRecord; - } + carousel.data = answersRecord ?? {}; carousel.isUsed = true; if (carousel instanceof ChatQuestionCarouselData) { + carousel.draftAnswers = undefined; + carousel.draftCurrentIndex = undefined; carousel.completion.complete({ answers: answersRecord }); } @@ -2183,10 +2183,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined }>(); + public draftAnswers: Record | undefined; + public draftCurrentIndex: number | undefined; constructor( public questions: IChatQuestion[], 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 66d10c9b487c3..10045c7dce3d4 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 @@ -11,6 +11,7 @@ import { workbenchInstantiationService } from '../../../../../../test/browser/wo import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../../../../browser/widget/chatContentParts/chatQuestionCarouselPart.js'; import { IChatQuestionCarousel } from '../../../../common/chatService/chatService.js'; import { IChatContentPartRenderContext } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; +import { ChatQuestionCarouselData } from '../../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; function createMockCarousel(questions: IChatQuestionCarousel['questions'], allowSkip: boolean = true): IChatQuestionCarousel { return { @@ -578,6 +579,69 @@ suite('ChatQuestionCarouselPart', () => { }); suite('Used Carousel Summary', () => { + test('retains current question after navigation without editing', () => { + const carousel = new ChatQuestionCarouselData([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + + const firstWidget = createWidget(carousel); + const nextButton = firstWidget.domNode.querySelector('.chat-question-nav-next') as HTMLElement | null; + assert.ok(nextButton, 'next button should exist'); + nextButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + firstWidget.dispose(); + firstWidget.domNode.remove(); + + const recreatedWidget = createWidget(carousel); + const stepIndicator = recreatedWidget.domNode.querySelector('.chat-question-step-indicator'); + assert.strictEqual(stepIndicator?.textContent, '2/2', 'should restore the current question index after navigation'); + + const title = recreatedWidget.domNode.querySelector('.chat-question-title'); + assert.ok(title?.textContent?.includes('Question 2'), 'should restore to the second question view'); + }); + + test('retains draft answers and current question after widget recreation', () => { + const carousel = new ChatQuestionCarouselData([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + + const firstWidget = createWidget(carousel); + const firstInput = firstWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null; + assert.ok(firstInput, 'first question input should exist'); + firstInput.value = 'first draft answer'; + firstInput.dispatchEvent(new Event('input', { bubbles: true })); + + const nextButton = firstWidget.domNode.querySelector('.chat-question-nav-next') as HTMLElement | null; + assert.ok(nextButton, 'next button should exist'); + nextButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + const secondInput = firstWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null; + assert.ok(secondInput, 'second question input should exist'); + secondInput.value = 'second draft answer'; + secondInput.dispatchEvent(new Event('input', { bubbles: true })); + + firstWidget.dispose(); + firstWidget.domNode.remove(); + + const recreatedWidget = createWidget(carousel); + const stepIndicator = recreatedWidget.domNode.querySelector('.chat-question-step-indicator'); + assert.strictEqual(stepIndicator?.textContent, '2/2', 'should restore the current question index'); + + const recreatedSecondInput = recreatedWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null; + assert.ok(recreatedSecondInput, 'recreated second question input should exist'); + assert.strictEqual(recreatedSecondInput.value, 'second draft answer', 'should restore draft input for current question'); + + const prevButton = recreatedWidget.domNode.querySelector('.chat-question-nav-prev') as HTMLElement | null; + assert.ok(prevButton, 'previous button should exist'); + prevButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + const recreatedFirstInput = recreatedWidget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement | null; + assert.ok(recreatedFirstInput, 'recreated first question input should exist'); + assert.strictEqual(recreatedFirstInput.value, 'first draft answer', 'should restore draft input for previous question'); + }); + test('shows summary with answers after skip()', () => { const carousel = createMockCarousel([ { id: 'q1', type: 'text', title: 'Question 1', defaultValue: 'default answer' } diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts index 1c022960b237a..5707ebbe4ab63 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts @@ -52,6 +52,8 @@ suite('ChatQuestionCarouselData', () => { test('toJSON strips the completion promise', () => { const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id', { q1: 'saved' }, true); + carousel.draftAnswers = { q2: 'draft' }; + carousel.draftCurrentIndex = 1; const json = carousel.toJSON(); @@ -60,6 +62,8 @@ suite('ChatQuestionCarouselData', () => { assert.deepStrictEqual(json.data, { q1: 'saved' }); assert.strictEqual(json.isUsed, true); assert.strictEqual((json as { completion?: unknown }).completion, undefined, 'toJSON should not include completion'); + assert.strictEqual((json as { draftAnswers?: unknown }).draftAnswers, undefined, 'toJSON should not include draftAnswers'); + assert.strictEqual((json as { draftCurrentIndex?: unknown }).draftCurrentIndex, undefined, 'toJSON should not include draftCurrentIndex'); }); test('multiple carousels can have independent completion promises', async () => { From c6ae208533cf2d66649bad02593fbff0325fea11 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Mon, 2 Mar 2026 14:13:11 -0800 Subject: [PATCH 23/26] chat: deduplicate global auto-approve warning across windows (#298787) * chat: deduplicate global auto-approve warning across windows Add CancellationToken support to IDialogService.prompt() and implement cross-window deduplication for the global auto-approve confirmation dialog. When \chat.tools.global.autoApprove\ is enabled, the opt-in warning previously appeared in every window because each window independently checked the APPLICATION-scoped storage flag and showed the dialog without coordination. Changes: - Add to IBaseDialogOptions, allowing dialogs to be programmatically dismissed via cancellation - Update BrowserDialogHandler.doShow() to register token cancellation and dispose the dialog when the token fires - In LanguageModelToolsService._checkGlobalAutoApprove(), use CancellationTokenSource + storageService.onDidChangeValue listener to detect when another window stores the opt-in flag and cancel the dialog - Apply the same pattern to the /autoApprove slash command handler - Within-window deduplication: pending promise cached in _pendingGlobalAutoApproveCheck prevents duplicate dialogs from multiple simultaneous tool invocations After opt-in is stored by any window, other windows detect the storage change via the token listener and cancel their dialogs. The prompt then resolves as if approved, avoiding duplicate user interaction. Fixes https://github.com/microsoft/vscode/issues/292150 (Commit message generated by Copilot) * pr comments --- src/vs/platform/dialogs/common/dialogs.ts | 12 +++ .../browser/parts/dialogs/dialogHandler.ts | 13 ++- .../contrib/chat/browser/chatSlashCommands.ts | 51 +++++++---- .../tools/languageModelToolsService.ts | 87 +++++++++++++------ 4 files changed, 118 insertions(+), 45 deletions(-) diff --git a/src/vs/platform/dialogs/common/dialogs.ts b/src/vs/platform/dialogs/common/dialogs.ts index f80914ca0b58f..fc73e57f82433 100644 --- a/src/vs/platform/dialogs/common/dialogs.ts +++ b/src/vs/platform/dialogs/common/dialogs.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; @@ -37,6 +38,17 @@ export interface IBaseDialogOptions { * Allows to enforce use of custom dialog even in native environments. */ readonly custom?: boolean | ICustomDialogOptions; + + /** + * An optional cancellation token that can be used to dismiss the dialog + * programmatically for custom dialog implementations. + * + * When cancelled, the custom dialog resolves as if the cancel button was + * pressed. Native dialog handlers cannot currently be dismissed + * programmatically and ignore this option unless a custom dialog is + * explicitly enforced via the {@link custom} option. + */ + readonly token?: CancellationToken; } export interface IConfirmDialogArgs { diff --git a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts index 16e11df36305b..5af4f540bac41 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IConfirmation, IConfirmationResult, IInputResult, ICheckbox, IInputElement, ICustomDialogOptions, IInput, AbstractDialogHandler, DialogType, IPrompt, IAsyncPromptResult } from '../../../../platform/dialogs/common/dialogs.js'; import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -47,7 +48,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { const buttons = this.getPromptButtons(prompt); - const { button, checkboxChecked } = await this.doShow(prompt.type, prompt.message, buttons, prompt.detail, prompt.cancelButton ? buttons.length - 1 : -1 /* Disabled */, prompt.checkbox, undefined, typeof prompt?.custom === 'object' ? prompt.custom : undefined); + const { button, checkboxChecked } = await this.doShow(prompt.type, prompt.message, buttons, prompt.detail, prompt.cancelButton ? buttons.length - 1 : -1 /* Disabled */, prompt.checkbox, undefined, typeof prompt?.custom === 'object' ? prompt.custom : undefined, prompt.token); return this.getPromptResult(prompt, button, checkboxChecked); } @@ -57,7 +58,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { const buttons = this.getConfirmationButtons(confirmation); - const { button, checkboxChecked } = await this.doShow(confirmation.type ?? 'question', confirmation.message, buttons, confirmation.detail, buttons.length - 1, confirmation.checkbox, undefined, typeof confirmation?.custom === 'object' ? confirmation.custom : undefined); + const { button, checkboxChecked } = await this.doShow(confirmation.type ?? 'question', confirmation.message, buttons, confirmation.detail, buttons.length - 1, confirmation.checkbox, undefined, typeof confirmation?.custom === 'object' ? confirmation.custom : undefined, confirmation.token); return { confirmed: button === 0, checkboxChecked }; } @@ -67,7 +68,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { const buttons = this.getInputButtons(input); - const { button, checkboxChecked, values } = await this.doShow(input.type ?? 'question', input.message, buttons, input.detail, buttons.length - 1, input?.checkbox, input.inputs, typeof input.custom === 'object' ? input.custom : undefined); + const { button, checkboxChecked, values } = await this.doShow(input.type ?? 'question', input.message, buttons, input.detail, buttons.length - 1, input?.checkbox, input.inputs, typeof input.custom === 'object' ? input.custom : undefined, input.token); return { confirmed: button === 0, checkboxChecked, values }; } @@ -90,7 +91,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { } } - private async doShow(type: Severity | DialogType | undefined, message: string, buttons?: string[], detail?: string, cancelId?: number, checkbox?: ICheckbox, inputs?: IInputElement[], customOptions?: ICustomDialogOptions): Promise { + private async doShow(type: Severity | DialogType | undefined, message: string, buttons?: string[], detail?: string, cancelId?: number, checkbox?: ICheckbox, inputs?: IInputElement[], customOptions?: ICustomDialogOptions, token?: CancellationToken): Promise { const dialogDisposables = new DisposableStore(); const renderBody = customOptions ? (parent: HTMLElement) => { @@ -126,6 +127,10 @@ export class BrowserDialogHandler extends AbstractDialogHandler { dialogDisposables.add(dialog); + if (token) { + dialogDisposables.add(token.onCancellationRequested(() => dialogDisposables.dispose())); + } + const result = await dialog.show(); dialogDisposables.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 16c998243669a..1ffcf9f1cbb79 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -5,7 +5,8 @@ import { timeout } from '../../../../base/common/async.js'; import { MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import * as nls from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -26,7 +27,10 @@ import { CONFIGURE_INSTRUCTIONS_ACTION_ID } from './promptSyntax/attachInstructi import { showConfigureHooksQuickPick } from './promptSyntax/hookActions.js'; import { CONFIGURE_PROMPTS_ACTION_ID } from './promptSyntax/runPromptAction.js'; import { CONFIGURE_SKILLS_ACTION_ID } from './promptSyntax/skillActions.js'; -import { globalAutoApproveDescription } from './tools/languageModelToolsService.js'; +import { + AutoApproveStorageKeys, + globalAutoApproveDescription +} from './tools/languageModelToolsService.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; import { Target } from '../common/promptSyntax/service/promptsService.js'; @@ -187,23 +191,38 @@ export class ChatSlashCommandsContribution extends Disposable { notificationService.info(nls.localize('autoApprove.alreadyEnabled', "Global auto-approve is already enabled.")); return; } - const alreadyOptedIn = storageService.getBoolean('chat.tools.global.autoApprove.optIn', StorageScope.APPLICATION, false); + const alreadyOptedIn = storageService.getBoolean(AutoApproveStorageKeys.GlobalAutoApproveOptIn, StorageScope.APPLICATION, false); if (!alreadyOptedIn) { - const result = await dialogService.prompt({ - type: Severity.Warning, - message: nls.localize('autoApprove.enable.title', 'Enable global auto approve?'), - buttons: [ - { label: nls.localize('autoApprove.enable.button', 'Enable'), run: () => true }, - { label: nls.localize('autoApprove.cancel.button', 'Cancel'), run: () => false }, - ], - custom: { - markdownDetails: [{ markdown: new MarkdownString(globalAutoApproveDescription.value, { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }) }], + const store = new DisposableStore(); + try { + const cts = new CancellationTokenSource(); + store.add(cts); + store.add(storageService.onDidChangeValue(StorageScope.APPLICATION, AutoApproveStorageKeys.GlobalAutoApproveOptIn, store)(() => { + if (storageService.getBoolean(AutoApproveStorageKeys.GlobalAutoApproveOptIn, StorageScope.APPLICATION, false)) { + cts.cancel(); + } + })); + + const result = await dialogService.prompt({ + type: Severity.Warning, + message: nls.localize('autoApprove.enable.title', 'Enable global auto approve?'), + buttons: [ + { label: nls.localize('autoApprove.enable.button', 'Enable'), run: () => true }, + { label: nls.localize('autoApprove.cancel.button', 'Cancel'), run: () => false }, + ], + custom: { + markdownDetails: [{ markdown: new MarkdownString(globalAutoApproveDescription.value, { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }) }], + }, + token: cts.token, + }); + + if (!cts.token.isCancellationRequested && result.result !== true) { + return; } - }); - if (result.result !== true) { - return; + storageService.store(AutoApproveStorageKeys.GlobalAutoApproveOptIn, true, StorageScope.APPLICATION, StorageTarget.USER); + } finally { + store.dispose(); } - storageService.store('chat.tools.global.autoApprove.optIn', true, StorageScope.APPLICATION, StorageTarget.USER); } await configurationService.updateValue(ChatConfiguration.GlobalAutoApprove, true); notificationService.info(nls.localize('autoApprove.enabled', "Global auto-approve enabled — all tool calls will be approved automatically")); diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 93f94e85917b9..41ef02090b8fd 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -61,7 +61,7 @@ interface ITrackedCall { store: IDisposable; } -const enum AutoApproveStorageKeys { +export const enum AutoApproveStorageKeys { GlobalAutoApproveOptIn = 'chat.tools.global.autoApprove.optIn' } @@ -110,6 +110,9 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo /** Pending tool calls in the streaming phase, keyed by toolCallId */ private readonly _pendingToolCalls = new Map(); + /** Deduplicates _checkGlobalAutoApprove calls within this window */ + private _pendingGlobalAutoApproveCheck: Promise | undefined; + private readonly _isAgentModeEnabled: IObservable; constructor( @@ -1092,34 +1095,68 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return true; } - const promptResult = await this._dialogService.prompt({ - type: Severity.Warning, - message: localize('autoApprove2.title', 'Enable global auto approve?'), - buttons: [ - { - label: localize('autoApprove2.button.enable', 'Enable'), - run: () => true - }, - { - label: localize('autoApprove2.button.disable', 'Disable'), - run: () => false + if (this._pendingGlobalAutoApproveCheck) { + return this._pendingGlobalAutoApproveCheck; + } + + this._pendingGlobalAutoApproveCheck = this._doCheckGlobalAutoApprove(); + try { + return await this._pendingGlobalAutoApproveCheck; + } finally { + this._pendingGlobalAutoApproveCheck = undefined; + } + } + + private async _doCheckGlobalAutoApprove(): Promise { + const store = new DisposableStore(); + try { + // Dismiss the dialog automatically if another window stores the + // opt-in flag, avoiding duplicate approval prompts. + const cts = new CancellationTokenSource(); + store.add(cts); + store.add(this._storageService.onDidChangeValue(StorageScope.APPLICATION, AutoApproveStorageKeys.GlobalAutoApproveOptIn, store)(() => { + if (this._storageService.getBoolean(AutoApproveStorageKeys.GlobalAutoApproveOptIn, StorageScope.APPLICATION, false)) { + cts.cancel(); + } + })); + + const promptResult = await this._dialogService.prompt({ + type: Severity.Warning, + message: localize('autoApprove2.title', 'Enable global auto approve?'), + buttons: [ + { + label: localize('autoApprove2.button.enable', 'Enable'), + run: () => true + }, + { + label: localize('autoApprove2.button.disable', 'Disable'), + run: () => false + }, + ], + custom: { + icon: Codicon.warning, + markdownDetails: [{ + markdown: new MarkdownString(globalAutoApproveDescription.value, { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }), + }], }, - ], - custom: { - icon: Codicon.warning, - markdownDetails: [{ - markdown: new MarkdownString(globalAutoApproveDescription.value, { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }), - }], + token: cts.token, + }); + + // If cancelled by cross-window approval, treat as approved + if (cts.token.isCancellationRequested) { + return true; } - }); - if (promptResult.result !== true) { - await this._configurationService.updateValue(ChatConfiguration.GlobalAutoApprove, false); - return false; - } + if (promptResult.result !== true) { + await this._configurationService.updateValue(ChatConfiguration.GlobalAutoApprove, false); + return false; + } - this._storageService.store(AutoApproveStorageKeys.GlobalAutoApproveOptIn, true, StorageScope.APPLICATION, StorageTarget.USER); - return true; + this._storageService.store(AutoApproveStorageKeys.GlobalAutoApproveOptIn, true, StorageScope.APPLICATION, StorageTarget.USER); + return true; + } finally { + store.dispose(); + } } private cleanupCallDisposables(requestId: string | undefined, store: DisposableStore): void { From 0b8d74a070acfbd02df760e10ea774e2744c7888 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:13:31 +0100 Subject: [PATCH 24/26] Add Cmd+W keybinding for new chat session in session management (#298807) * Add IsNewChatSessionContext to sessions management service import and register Cmd+W keybinding for new session * Add secondary keybinding for opening new session with Cmd+W in non-empty state * Enhance Cmd+W keybinding condition to require non-empty active editor group * Fix Cmd+W keybinding condition to check for visible editors instead of empty editor group --- .../contrib/sessions/browser/sessionsViewPane.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index eb76bcf42f222..d4c0b5dbfc7fe 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -13,6 +13,7 @@ import { MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { EditorsVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -31,7 +32,7 @@ import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browse import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { ISessionsManagementService } from './sessionsManagementService.js'; +import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; @@ -322,6 +323,16 @@ KeybindingsRegistry.registerKeybindingRule({ primary: KeyMod.CtrlCmd | KeyCode.KeyN, }); +// Register Cmd+W / Ctrl+W to open new session when the current session is non-empty, +// mirroring how Cmd+W closes the active editor in the normal workbench. +KeybindingsRegistry.registerKeybindingRule({ + id: ACTION_ID_NEW_CHAT, + weight: KeybindingWeight.WorkbenchContrib + 1, + when: ContextKeyExpr.and(IsNewChatSessionContext.negate(), EditorsVisibleContext.negate()), + primary: KeyMod.CtrlCmd | KeyCode.KeyW, + win: { primary: KeyMod.CtrlCmd | KeyCode.F4, secondary: [KeyMod.CtrlCmd | KeyCode.KeyW] }, +}); + MenuRegistry.appendMenuItem(MenuId.ViewTitle, { submenu: SessionsViewFilterSubMenu, title: localize2('filterAgentSessions', "Filter Sessions"), From 21d2bedcc5e2d5403c8c946c96657f95a8077d0c Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:15:17 -0800 Subject: [PATCH 25/26] [Terminal Sandbox]Wrapping the command in single quotes and escaping it to prevent any shell injection issues. (#298790) * wrapping the command in single quotes and escaping it to prevent any shell injection issues * Update src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../common/terminalSandboxService.ts | 8 ++- .../browser/terminalSandboxService.test.ts | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index 8b1bcca004799..b3ce3a196ce0c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -105,8 +105,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } // Use ELECTRON_RUN_AS_NODE=1 to make Electron executable behave as Node.js // TMPDIR must be set as environment variable before the command - // Use -c to pass the command string directly (like sh -c), avoiding argument parsing issues - const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c "${command}"`; + // Quote shell arguments so the wrapped command cannot break out of the outer shell. + const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(command)}`; if (this._remoteEnvDetails) { return `${wrappedCommand}`; } @@ -130,6 +130,10 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return this._sandboxConfigPath; } + private _quoteShellArgument(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; + } + private async _resolveSrtPath(): Promise { if (this._srtPathResolved) { return; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index e48d807dec883..fdc4ab166ab87 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -266,4 +266,68 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { 'Wrapped command should include PATH modification with ripgrep' ); }); + + test('should pass wrapped command as a single quoted argument', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const command = '";echo SANDBOX_ESCAPE_REPRO; # $(uname) `id`'; + const wrappedCommand = sandboxService.wrapCommand(command); + + ok( + wrappedCommand.includes(`-c '";echo SANDBOX_ESCAPE_REPRO; # $(uname) \`id\`'`), + 'Wrapped command should shell-quote the command argument using single quotes' + ); + ok( + !wrappedCommand.includes(`-c "${command}"`), + 'Wrapped command should not embed the command in double quotes' + ); + }); + + test('should keep variable and command substitution payloads literal', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const command = 'echo $HOME $(curl eth0.me) `id`'; + const wrappedCommand = sandboxService.wrapCommand(command); + + ok( + wrappedCommand.includes(`-c 'echo $HOME $(curl eth0.me) \`id\`'`), + 'Wrapped command should keep variable and command substitutions inside the quoted argument' + ); + ok( + !wrappedCommand.includes(`-c ${command}`), + 'Wrapped command should not pass substitution payloads to -c without quoting' + ); + }); + + test('should escape single-quote breakout payloads in wrapped command argument', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const command = `';curl eth0.me; #'`; + const wrappedCommand = sandboxService.wrapCommand(command); + + ok( + wrappedCommand.includes(`-c '`), + 'Wrapped command should continue to use a single-quoted -c argument' + ); + ok( + wrappedCommand.includes('curl eth0.me'), + 'Wrapped command should preserve the payload text literally' + ); + ok( + !wrappedCommand.includes(`-c '${command}'`), + 'Wrapped command should not embed attacker-controlled single quotes without escaping' + ); + strictEqual((wrappedCommand.match(/\\''/g) ?? []).length, 2, 'Single quote breakout payload should escape each embedded single quote'); + }); + + test('should escape embedded single quotes in wrapped command argument', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrappedCommand = sandboxService.wrapCommand(`echo 'hello'`); + strictEqual((wrappedCommand.match(/\\''/g) ?? []).length, 2, 'Single quote escapes should be inserted for each embedded single quote'); + }); }); From 9d6ae94c149dfcb5e9708684aea7f7773d05b125 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 2 Mar 2026 17:17:28 -0500 Subject: [PATCH 26/26] only show tip widget if there's exactly one chat session in the foreground (#298772) fixes #297759 --- .../contrib/chat/browser/chat.contribution.ts | 62 ++++++++++++++++++- .../contrib/chat/browser/chatTipService.ts | 6 ++ .../contrib/chat/browser/widget/chatWidget.ts | 23 +++++-- .../chat/common/actions/chatContextKeys.ts | 2 + .../chat/test/browser/chatTipService.test.ts | 26 ++++++++ 5 files changed, 110 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index bd3e4f30373ec..09612521caf4e 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -16,7 +16,7 @@ import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurati import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { McpAccessValue, McpAutoStartValue, mcpAccessConfig, mcpAutoStartConfig, mcpGalleryServiceEnablementConfig, mcpGalleryServiceUrlConfig, mcpAppsEnabledConfig } from '../../../../platform/mcp/common/mcpManagement.js'; import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -26,7 +26,9 @@ import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; +import { IViewsService } from '../../../services/views/common/viewsService.js'; import { AddConfigurationType, AssistedTypes } from '../../mcp/browser/mcpCommandsAddConfiguration.js'; import { allDiscoverySources, discoverySourceSettingsLabel, mcpDiscoverySection, mcpServerSamplingSection } from '../../mcp/common/mcpConfiguration.js'; import { ChatAgentNameService, ChatAgentService, IChatAgentNameService, IChatAgentService } from '../common/participants/chatAgents.js'; @@ -44,7 +46,7 @@ import { ChatTodoListService, IChatTodoListService } from '../common/tools/chatT import { ChatTransferService, IChatTransferService } from '../common/model/chatTransferService.js'; import { IChatVariablesService } from '../common/attachments/chatVariables.js'; import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/widget/chatWidgetHistoryService.js'; -import { ChatConfiguration, ChatNotificationMode } from '../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatNotificationMode } from '../common/constants.js'; import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } from '../common/ignoredFiles.js'; import { ILanguageModelsService, LanguageModelsService } from '../common/languageModels.js'; import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; @@ -101,7 +103,7 @@ import './agentSessions/agentSessions.contribution.js'; import { backgroundAgentDisplayName } from './agentSessions/agentSessions.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; -import { IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService } from './chat.js'; +import { ChatViewId, IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService, isIChatResourceViewContext, isIChatViewViewContext } from './chat.js'; import { ChatAccessibilityService } from './accessibility/chatAccessibilityService.js'; import './attachments/chatAttachmentModel.js'; import './widget/input/chatStatusWidget.js'; @@ -1465,6 +1467,59 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr } } +class ChatForegroundSessionCountContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatForegroundSessionCount'; + + private readonly foregroundSessionCountContextKey: IContextKey; + + constructor( + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IViewsService private readonly viewsService: IViewsService, + @IEditorService private readonly editorService: IEditorService, + ) { + super(); + this.foregroundSessionCountContextKey = ChatContextKeys.foregroundSessionCount.bindTo(this.contextKeyService); + + this._register(this.chatWidgetService.onDidAddWidget(() => { + this.updateForegroundSessionCount(); + })); + + this._register(this.editorService.onDidVisibleEditorsChange(() => { + this.updateForegroundSessionCount(); + })); + + this._register(Event.filter(this.viewsService.onDidChangeViewVisibility, e => e.id === ChatViewId)(() => { + this.updateForegroundSessionCount(); + })); + + this.updateForegroundSessionCount(); + } + + private updateForegroundSessionCount(): void { + let count = this.viewsService.isViewVisible(ChatViewId) ? 1 : 0; + + for (const widget of this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat)) { + if (widget.domNode.offsetParent === null) { + continue; + } + + if (isIChatViewViewContext(widget.viewContext)) { + continue; + } + + if (isIChatResourceViewContext(widget.viewContext) && widget.viewContext.isQuickChat) { + continue; + } + + count++; + } + + this.foregroundSessionCountContextKey.set(count); + } +} + /** * Given builtin and custom modes, returns only the custom mode IDs that should have actions registered. @@ -1662,6 +1717,7 @@ registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribu registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(RenameToolContribution.ID, RenameToolContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatForegroundSessionCountContribution.ID, ChatForegroundSessionCountContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(HookSchemaAssociationContribution.ID, HookSchemaAssociationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 0760a8ecbf45a..40a007908ddaf 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -437,6 +437,12 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + // Only show tips when there is exactly one foreground chat session visible. + const foregroundSessionCount = contextKeyService.getContextKeyValue(ChatContextKeys.foregroundSessionCount.key); + if (foregroundSessionCount !== 1) { + return undefined; + } + // Don't show tips when chat quota is exceeded, the upgrade widget is more relevant if (this._isChatQuotaExceeded(contextKeyService)) { return undefined; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 40e88107acad0..63f9b4d754c7c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1012,18 +1012,23 @@ export class ChatWidget extends Disposable implements IChatWidget { const tipContainer = this.inputPart.gettingStartedTipContainerElement; - // Already showing a tip - if (this._gettingStartedTipPart.value) { - dom.setVisibility(true, tipContainer); - return; - } - const tip = this.chatTipService.getWelcomeTip(this.contextKeyService); if (!tip) { + if (this._gettingStartedTipPart.value) { + this._gettingStartedTipPartRef = undefined; + this._gettingStartedTipPart.clear(); + dom.clearNode(tipContainer); + } dom.setVisibility(false, tipContainer); return; } + // Already showing an eligible tip + if (this._gettingStartedTipPart.value) { + dom.setVisibility(true, tipContainer); + return; + } + const store = new DisposableStore(); const renderer = this.instantiationService.createInstance(ChatContentMarkdownRenderer); const tipPart = store.add(this.instantiationService.createInstance(ChatTipContentPart, @@ -1819,6 +1824,12 @@ export class ChatWidget extends Disposable implements IChatWidget { this.renderFollowups(); this.renderChatSuggestNextWidget(); })); + const foregroundSessionCountContextKeys = new Set([ChatContextKeys.foregroundSessionCount.key]); + this._register(this.contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(foregroundSessionCountContextKeys) && this.isEmpty()) { + this.renderGettingStartedTipIfNeeded(); + } + })); let previousModelIdentifier: string | undefined; this._register(autorun(reader => { const modelIdentifier = this.inputPart.selectedLanguageModel.read(reader)?.identifier; diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 8e703e221f7b6..a85c46b7bfba0 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -113,6 +113,8 @@ export namespace ChatContextKeys { toolsCount: new RawContextKey('toolsCount', 0, { type: 'number', description: localize('toolsCount', "The count of tools available in the chat.") }) }; + export const foregroundSessionCount = new RawContextKey('chatForegroundSessionCount', 0, { type: 'number', description: localize('chatForegroundSessionCount', "The number of foreground chat sessions visible across chat surfaces.") }); + export const Modes = { hasCustomChatModes: new RawContextKey('chatHasCustomAgents', false, { type: 'boolean', description: localize('chatHasAgents', "True when the chat has custom agents available.") }), agentModeDisabledByPolicy: new RawContextKey('chatAgentModeDisabledByPolicy', false, { type: 'boolean', description: localize('chatAgentModeDisabledByPolicy', "True when agent mode is disabled by organization policy.") }), 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 d03e8e547dcb8..f0946ccfb22ff 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -99,6 +99,7 @@ suite('ChatTipService', () => { setup(() => { instantiationService = testDisposables.add(new TestInstantiationService()); contextKeyService = new MockContextKeyServiceWithRulesMatching(); + contextKeyService.createKey(ChatContextKeys.foregroundSessionCount.key, 1); configurationService = new TestConfigurationService(); commandExecutedEmitter = testDisposables.add(new Emitter()); storageService = testDisposables.add(new InMemoryStorageService()); @@ -304,6 +305,7 @@ suite('ChatTipService', () => { assert.strictEqual(firstTip.id, 'tip.switchToAuto'); const switchedContextKeyService = new MockContextKeyServiceWithRulesMatching(); + switchedContextKeyService.createKey(ChatContextKeys.foregroundSessionCount.key, 1); switchedContextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); const nextTip = service.getWelcomeTip(switchedContextKeyService); @@ -365,6 +367,30 @@ suite('ChatTipService', () => { assert.strictEqual(tip, undefined, 'Should not return a tip in editor inline chat'); }); + test('returns a tip when foreground session count is exactly one', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.foregroundSessionCount.key, 1); + + const tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip, 'Should return a tip when exactly one foreground chat session is visible'); + }); + + test('returns undefined when foreground session count is zero', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.foregroundSessionCount.key, 0); + + const tip = service.getWelcomeTip(contextKeyService); + assert.strictEqual(tip, undefined, 'Should not return a tip when no foreground chat sessions are visible'); + }); + + test('returns undefined when foreground session count is greater than one', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.foregroundSessionCount.key, 2); + + const tip = service.getWelcomeTip(contextKeyService); + assert.strictEqual(tip, undefined, 'Should not return a tip when multiple foreground chat sessions are visible'); + }); + test('dismissTip excludes the dismissed tip and allows a new one', () => { const service = createService();