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" }, 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/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 4c852f4cfa541..1fc850565de8e 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; 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..20745e1d0870e --- /dev/null +++ b/extensions/github-authentication/esbuild.browser.mts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * 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], + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, 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/**" + ] +} 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; } 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..6fffbbd64b4fc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", - "version": "1.110.0", - "distro": "31b6675a65c3b72a5a20fdc648050340585879c3", + "version": "1.111.0", + "distro": "2d27db3342ed252ed49f3df55fd394721aa2e451", "author": { "name": "Microsoft Corporation" }, @@ -250,4 +250,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} 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 }); + } } }); 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/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/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/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, 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 { 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"), 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/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/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..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'; @@ -148,6 +150,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 +1260,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', @@ -1453,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. @@ -1650,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/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: [ 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/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/chatTipCatalog.ts b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts index b29cc69ac81da..f0bc32db08f41 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts @@ -15,12 +15,17 @@ 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, } 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'); @@ -125,27 +138,30 @@ 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, ], }, { 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..40a007908ddaf 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 = { @@ -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( @@ -278,12 +289,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': @@ -303,6 +315,7 @@ export class ChatTipService extends Disposable implements IChatTipService { this._shownTip = undefined; this._tipRequestId = undefined; this._contextKeyService = undefined; + this._tipsHiddenForSession = false; } dismissTip(): void { @@ -318,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(); } @@ -362,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'); @@ -385,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; @@ -403,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; @@ -410,6 +450,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 +509,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 +530,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 +564,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 +619,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 +650,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; } - return !!this._getNavigableTip(1, currentIndex, contextKeyService); + if (orderedTips.length > 1) { + return true; + } + + 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 { @@ -753,6 +842,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/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/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/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 { 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/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/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/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 { + 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/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; 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/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/common/model/chatProgressTypes/chatQuestionCarouselData.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts index f0d598be6dbbc..ef8ba5ae529c5 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts @@ -14,6 +14,8 @@ import { IChatQuestion, IChatQuestionCarousel } from '../../chatService/chatServ export class ChatQuestionCarouselData implements IChatQuestionCarousel { public readonly kind = 'questionCarousel' as const; public readonly completion = new DeferredPromise<{ answers: Record | undefined }>(); + public draftAnswers: Record | undefined; + public draftCurrentIndex: number | undefined; constructor( public questions: IChatQuestion[], 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. */ 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; } 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..f0946ccfb22ff 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'), }; @@ -97,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()); @@ -192,6 +195,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, { @@ -277,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); @@ -338,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(); @@ -366,6 +419,56 @@ 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); + 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 +525,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 +1000,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(); @@ -989,7 +1220,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); @@ -1011,7 +1242,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); @@ -1186,6 +1417,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); }); } 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/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/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 () => { 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/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/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 = { 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'); 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/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'); + }); }); 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; }