diff --git a/.eslint-ignore b/.eslint-ignore index 57d8bc4ba67ff..c16b228723218 100644 --- a/.eslint-ignore +++ b/.eslint-ignore @@ -1,9 +1,25 @@ **/build/*/**/*.js -**/dist/**/*.js +**/dist/**/* **/extensions/**/*.d.ts **/extensions/**/build/** **/extensions/**/colorize-fixtures/** -**/extensions/copilot/** +**/extensions/copilot/coverage/** +**/extensions/copilot/.esbuild/** +**/extensions/copilot/.simulation/** +**/extensions/copilot/.eslintplugin/** +**/extensions/copilot/chat-lib/** +**/extensions/copilot/test/simulation/fixtures/** +**/extensions/copilot/test/scenarios/** +**/extensions/copilot/test/aml/out/** +**/extensions/copilot/src/util/vs/** +**/extensions/copilot/src/platform/parser/test/node/fixtures/** +**/extensions/copilot/src/extension/test/node/fixtures/** +**/extensions/copilot/src/extension/prompts/node/test/fixtures/** +**/extensions/copilot/src/extension/typescriptContext/serverPlugin/fixtures/** +**/extensions/copilot/src/extension/typescriptContext/serverPlugin/lib/** +**/extensions/copilot/src/extension/typescriptContext/serverPlugin/dist/** +**/extensions/copilot/src/extension/completions-core/**/testdata/* +**/extensions/copilot/.vscode/extensions/test-extension/dist/** **/extensions/css-language-features/server/test/pathCompletionFixtures/** **/extensions/html-language-features/server/lib/jquery.d.ts **/extensions/html-language-features/server/src/test/pathCompletionFixtures/** @@ -36,4 +52,5 @@ **/test/automation/out/** **/typings/** **/.build/** +**/.vscode-test/** !.vscode diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 70c65ddc42662..7225387d12315 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -288,10 +288,6 @@ jobs: working-directory: extensions/copilot run: npm run typecheck - - name: Lint - working-directory: extensions/copilot - run: npm run lint - - name: Compile working-directory: extensions/copilot run: npm run compile @@ -387,10 +383,6 @@ jobs: working-directory: extensions/copilot run: npm run typecheck - - name: Lint - working-directory: extensions/copilot - run: npm run lint - - name: Compile working-directory: extensions/copilot run: npm run compile diff --git a/build/azure-pipelines/product-copilot-recovery.yml b/build/azure-pipelines/product-copilot-recovery.yml index 056bdedaed7b3..8b120eb1d604e 100644 --- a/build/azure-pipelines/product-copilot-recovery.yml +++ b/build/azure-pipelines/product-copilot-recovery.yml @@ -70,5 +70,6 @@ extends: generateNotice: true publishExtension: ${{ parameters.publishExtension }} - ghReleasePublishVSIX: true + ghCreateTag: ${{ parameters.publishExtension }} ghTagPrefix: 'copilot/' + ghCreateRelease: false diff --git a/build/filters.ts b/build/filters.ts index f43780b6b182f..0c7c77d30092f 100644 --- a/build/filters.ts +++ b/build/filters.ts @@ -233,10 +233,8 @@ export const tsFormattingFilter = Object.freeze([ ]); export const eslintFilter = Object.freeze([ - '**/*.js', - '**/*.cjs', - '**/*.mjs', - '**/*.ts', + '**/*.{js,cjs,mjs}', + '**/*.{ts,tsx,mts,cts}', '.eslint-plugin-local/**/*.ts', ...readFileSync(join(import.meta.dirname, '..', '.eslint-ignore')) .toString() diff --git a/build/hygiene.ts b/build/hygiene.ts index 8dd2927cabe70..71f500161343e 100644 --- a/build/hygiene.ts +++ b/build/hygiene.ts @@ -314,20 +314,6 @@ if (import.meta.main) { } } - // Run copilot pre-commit checks if copilot files are staged - if (some.some(f => f.startsWith('extensions/copilot/'))) { - console.log('Running copilot pre-commit checks...'); - const result = cp.spawnSync('npx', ['lint-staged'], { - cwd: path.join(process.cwd(), 'extensions', 'copilot'), - stdio: 'inherit', - shell: true, - }); - if (result.status !== 0) { - console.error('Copilot pre-commit checks failed.'); - process.exit(1); - } - } - console.log('Reading git index versions...'); createGitIndexVinyls(some) diff --git a/eslint.config.js b/eslint.config.js index 24d0abad2184e..d62bd457e88d5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ // @ts-check import fs from 'fs'; +import { builtinModules } from 'module'; import path from 'path'; import tseslint from 'typescript-eslint'; import stylisticTs from '@stylistic/eslint-plugin-ts'; import * as pluginLocal from './.eslint-plugin-local/index.ts'; +import * as pluginCopilotLocal from './extensions/copilot/.eslintplugin/index.ts'; +import pluginImport from 'eslint-plugin-import'; import pluginJsdoc from 'eslint-plugin-jsdoc'; import pluginHeader from 'eslint-plugin-header'; @@ -193,6 +196,7 @@ export default tseslint.config( 'src/bootstrap-node.ts', 'build/lib/extensions.ts', 'build/lib/test/render.test.ts', + 'extensions/copilot/**/*', 'extensions/debug-auto-launch/src/extension.ts', 'extensions/emmet/src/updateImageSize.ts', 'extensions/emmet/src/util.ts', @@ -2296,16 +2300,16 @@ export default tseslint.config( 'comma-dangle': ['warn', 'only-multiline'] } }, - // Extension main sources (excluding tests) + // Ban dynamic require() and import() calls in extensions to ensure tree-shaking works { files: [ - 'extensions/**/*.ts', + 'extensions/**/*.{ts,tsx}', ], ignores: [ 'extensions/**/*.test.ts', + 'extensions/copilot/**/*', ], rules: { - // Ban dynamic require() and import() calls in extensions to ensure tree-shaking works 'no-restricted-syntax': [ 'warn', { @@ -2387,6 +2391,406 @@ export default tseslint.config( '@typescript-eslint/consistent-generic-constructors': ['warn', 'constructor'], } }, + // copilot extension - main sources + { + files: [ + 'extensions/copilot/src/**/*.{ts,tsx}', + 'extensions/copilot/test/**/*.{ts,tsx}', + ], + ignores: [ + 'extensions/copilot/**/.esbuild.ts', + 'extensions/copilot/src/extension/completions-core/vscode-node/bridge/src/completionsTelemetryServiceBridge.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + 'import': pluginImport, + 'copilot-local': pluginCopilotLocal, + }, + rules: { + 'local/code-no-dangerous-type-assertions': 'off', + 'local/code-no-any-casts': 'off', + 'local/code-no-deep-import-of-internal': 'off', + 'no-restricted-imports': [ + 'warn', + // node: builtins + ...builtinModules, + // node: dependencies + '@humanwhocodes/gitignore-to-minimatch', + '@vscode/extension-telemetry', + 'applicationinsights', + 'ignore', + 'isbinaryfile', + 'minimatch', + 'source-map-support', + 'vscode-tas-client', + 'web-tree-sitter' + ], + 'import/no-restricted-paths': [ + 'warn', + { + zones: [ + { + target: '**/common/**', + from: [ + '**/vscode/**', + '**/node/**', + '**/vscode-node/**', + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/vscode/**', + from: [ + '**/node/**', + '**/vscode-node/**', + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/node/**', + from: [ + '**/vscode/**', + '**/vscode-node/**', + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/vscode-node/**', + from: [ + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/worker/**', + from: [ + '**/vscode/**', + '**/node/**', + '**/vscode-node/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/vscode-worker/**', + from: [ + '**/node/**', + '**/vscode-node/**' + ] + }, + { + target: './extensions/copilot/src/', + from: './extensions/copilot/test/' + }, + { + target: './extensions/copilot/src/shared-fetch-utils', + from: ['./extensions/copilot/src/extension', './extensions/copilot/src/platform', './extensions/copilot/src/util', './extensions/copilot/src/lib'] + }, + { + target: './extensions/copilot/src/util', + from: ['./extensions/copilot/src/platform', './extensions/copilot/src/extension'] + }, + { + target: './extensions/copilot/src/platform', + from: ['./extensions/copilot/src/extension'] + }, + { + target: ['./extensions/copilot/test', '!./extensions/copilot/test/base/extHostContext/*.ts'], + from: ['**/vscode-node/**', '**/vscode-worker/**'] + }, + { + target: 'extensions/copilot/src/!(lib)/**', + from: './extensions/copilot/src/lib' + } + ] + } + ], + 'copilot-local/no-instanceof-uri': ['warn'], + 'copilot-local/no-test-imports': ['warn'], + 'copilot-local/no-runtime-import': [ + 'warn', + { + test: ['vscode'], + 'src/**/common/**/*': ['vscode'], + 'src/**/node/**/*': ['vscode'] + } + ], + 'copilot-local/no-funny-filename': ['warn'], + 'copilot-local/no-bad-gdpr-comment': ['warn'], + 'copilot-local/no-gdpr-event-name-mismatch': ['warn'], + 'copilot-local/no-unlayered-files': ['warn'], + 'copilot-local/no-restricted-copilot-pr-string': [ + 'warn', + { + className: 'GitHubPullRequestProviders', + string: 'Generate with Copilot' + } + ], + 'copilot-local/no-nls-localize': ['warn'], + } + }, + // copilot extension - allow node imports in node layer + { + files: [ + 'extensions/copilot/**/{vscode-node,node}/**/*.ts', + 'extensions/copilot/**/{vscode-node,node}/**/*.tsx', + ], + rules: { + 'no-restricted-imports': 'off' + } + }, + // copilot extension - override files (tests, build, etc.) + { + files: [ + 'extensions/copilot/test/**', + 'extensions/copilot/src/vscodeTypes.ts', + 'extensions/copilot/script/**', + 'extensions/copilot/src/extension/*.d.ts', + 'extensions/copilot/build/**', + ], + rules: { + 'copilot-local/no-unlayered-files': 'off', + 'no-restricted-imports': 'off' + } + }, + // copilot extension - TSX linebreak rule + { + files: [ + 'extensions/copilot/src/extension/**/*.tsx', + ], + plugins: { + 'copilot-local': pluginCopilotLocal, + }, + rules: { + 'copilot-local/no-missing-linebreak': 'warn' + } + }, + // copilot extension - test-only rule + { + files: [ + 'extensions/copilot/**/*.test.ts', + 'extensions/copilot/**/*.test.tsx', + ], + plugins: { + 'copilot-local': pluginCopilotLocal, + }, + rules: { + 'copilot-local/no-test-only': 'warn' + } + }, + // copilot extension - no-explicit-any + { + files: [ + 'extensions/copilot/src/**/*.ts', + ], + ignores: [ + 'extensions/copilot/src/util/vs/**/*.ts', + 'extensions/copilot/src/**/*.spec.ts', + 'extensions/copilot/src/extension/agents/copilotcli/node/nodePtyShim.ts', + 'extensions/copilot/src/extension/byok/common/anthropicMessageConverter.ts', + 'extensions/copilot/src/extension/byok/common/geminiFunctionDeclarationConverter.ts', + 'extensions/copilot/src/extension/byok/common/geminiMessageConverter.ts', + 'extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts', + 'extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts', + 'extensions/copilot/src/extension/byok/vscode-node/ollamaProvider.ts', + 'extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts', + 'extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts', + 'extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts', + 'extensions/copilot/src/extension/codeBlocks/vscode-node/provider.ts', + 'extensions/copilot/src/extension/configuration/vscode-node/configurationMigration.ts', + 'extensions/copilot/src/extension/context/node/resolvers/genericInlineIntentInvocation.ts', + 'extensions/copilot/src/extension/context/node/resolvers/genericPanelIntentInvocation.ts', + 'extensions/copilot/src/extension/context/node/resolvers/inlineFixIntentInvocation.ts', + 'extensions/copilot/src/extension/context/node/resolvers/promptWorkspaceLabels.ts', + 'extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts', + 'extensions/copilot/src/extension/conversation/vscode-node/userActions.ts', + 'extensions/copilot/src/extension/extension/vscode/services.ts', + 'extensions/copilot/src/extension/inlineChat/node/rendererVisualization.ts', + 'extensions/copilot/src/extension/inlineChat/vscode-node/inlineChatCommands.ts', + 'extensions/copilot/src/extension/inlineEdits/common/observableWorkspaceRecordingReplayer.ts', + 'extensions/copilot/src/extension/inlineEdits/vscode-node/parts/vscodeWorkspace.ts', + 'extensions/copilot/src/extension/intents/node/editCodeIntent.ts', + 'extensions/copilot/src/extension/intents/node/editCodeStep.ts', + 'extensions/copilot/src/extension/intents/node/fixIntent.ts', + 'extensions/copilot/src/extension/intents/node/newIntent.ts', + 'extensions/copilot/src/extension/intents/node/searchIntent.ts', + 'extensions/copilot/src/extension/languageContextProvider/vscode-node/languageContextProviderService.ts', + 'extensions/copilot/src/extension/linkify/common/commands.ts', + 'extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts', + 'extensions/copilot/src/extension/linkify/test/node/util.ts', + 'extensions/copilot/src/extension/log/vscode-node/loggingActions.ts', + 'extensions/copilot/src/extension/log/vscode-node/requestLogTree.ts', + 'extensions/copilot/src/extension/mcp/test/vscode-node/util.ts', + 'extensions/copilot/src/extension/mcp/vscode-node/commands.ts', + 'extensions/copilot/src/extension/mcp/vscode-node/nuget.ts', + 'extensions/copilot/src/extension/onboardDebug/node/copilotDebugWorker/rpc.ts', + 'extensions/copilot/src/extension/onboardDebug/node/parseLaunchConfigFromResponse.ts', + 'extensions/copilot/src/extension/onboardDebug/vscode-node/copilotDebugCommandHandle.ts', + 'extensions/copilot/src/extension/prompt/common/toolCallRound.ts', + 'extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts', + 'extensions/copilot/src/extension/prompt/node/chatParticipantTelemetry.ts', + 'extensions/copilot/src/extension/prompt/node/editGeneration.ts', + 'extensions/copilot/src/extension/prompt/node/intents.ts', + 'extensions/copilot/src/extension/prompt/node/todoListContextProvider.ts', + 'extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts', + 'extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts', + 'extensions/copilot/src/extension/prompts/node/agent/promptRegistry.ts', + 'extensions/copilot/src/extension/prompts/node/base/promptElement.ts', + 'extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts', + 'extensions/copilot/src/extension/prompts/node/test/utils.ts', + 'extensions/copilot/src/extension/replay/common/chatReplayResponses.ts', + 'extensions/copilot/src/extension/replay/node/replayParser.ts', + 'extensions/copilot/src/extension/replay/vscode-node/replayDebugSession.ts', + 'extensions/copilot/src/extension/review/node/githubReviewAgent.ts', + 'extensions/copilot/src/extension/test/node/services.ts', + 'extensions/copilot/src/extension/test/vscode-node/extension.test.ts', + 'extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts', + 'extensions/copilot/src/extension/test/vscode-node/session.test.ts', + 'extensions/copilot/src/extension/tools/common/toolSchemaNormalizer.ts', + 'extensions/copilot/src/extension/tools/common/toolsService.ts', + 'extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/baseContextProviders.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/contextProvider.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/utils.ts', + 'extensions/copilot/src/extension/typescriptContext/vscode-node/inspector.ts', + 'extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts', + 'extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts', + 'extensions/copilot/src/extension/workspaceSemanticSearch/node/semanticSearchTextSearchProvider.ts', + 'extensions/copilot/src/lib/node/chatLibMain.ts', + 'extensions/copilot/src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts', + 'extensions/copilot/src/platform/chat/common/blockedExtensionService.ts', + 'extensions/copilot/src/platform/chunking/common/chunkingEndpointClientImpl.ts', + 'extensions/copilot/src/platform/commands/common/mockRunCommandExecutionService.ts', + 'extensions/copilot/src/platform/commands/common/runCommandExecutionService.ts', + 'extensions/copilot/src/platform/commands/vscode/runCommandExecutionServiceImpl.ts', + 'extensions/copilot/src/platform/configuration/common/configurationService.ts', + 'extensions/copilot/src/platform/configuration/common/validator.ts', + 'extensions/copilot/src/platform/configuration/test/common/inMemoryConfigurationService.ts', + 'extensions/copilot/src/platform/configuration/vscode/configurationServiceImpl.ts', + 'extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts', + 'extensions/copilot/src/platform/debug/vscode/debugOutputListener.ts', + 'extensions/copilot/src/platform/diff/node/diffWorkerMain.ts', + 'extensions/copilot/src/platform/editing/common/notebookDocumentSnapshot.ts', + 'extensions/copilot/src/platform/editing/common/textDocumentSnapshot.ts', + 'extensions/copilot/src/platform/embeddings/common/embeddingsGrouper.ts', + 'extensions/copilot/src/platform/embeddings/common/embeddingsIndex.ts', + 'extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts', + 'extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts', + 'extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts', + 'extensions/copilot/src/platform/env/common/packagejson.ts', + 'extensions/copilot/src/platform/extensions/common/extensionsService.ts', + 'extensions/copilot/src/platform/filesystem/common/fileSystemService.ts', + 'extensions/copilot/src/platform/github/common/githubService.ts', + 'extensions/copilot/src/platform/github/common/nullOctokitServiceImpl.ts', + 'extensions/copilot/src/platform/inlineEdits/common/dataTypes/edit.ts', + 'extensions/copilot/src/platform/inlineEdits/common/dataTypes/textEditLengthHelper/length.ts', + 'extensions/copilot/src/platform/inlineEdits/common/editReason.ts', + 'extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts', + 'extensions/copilot/src/platform/inlineEdits/common/utils/observable.ts', + 'extensions/copilot/src/platform/languages/common/languageDiagnosticsService.ts', + 'extensions/copilot/src/platform/log/common/logExecTime.ts', + 'extensions/copilot/src/platform/log/common/logService.ts', + 'extensions/copilot/src/platform/log/vscode/outputChannelLogTarget.ts', + 'extensions/copilot/src/platform/nesFetch/common/completionsFetchService.ts', + 'extensions/copilot/src/platform/nesFetch/node/completionsFetchServiceImpl.ts', + 'extensions/copilot/src/platform/networking/common/fetch.ts', + 'extensions/copilot/src/platform/networking/common/fetcherService.ts', + 'extensions/copilot/src/platform/networking/common/networking.ts', + 'extensions/copilot/src/platform/networking/common/openai.ts', + 'extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts', + 'extensions/copilot/src/platform/networking/node/chatStream.ts', + 'extensions/copilot/src/platform/networking/node/fetcherFallback.ts', + 'extensions/copilot/src/platform/networking/node/nodeFetchFetcher.ts', + 'extensions/copilot/src/platform/networking/node/nodeFetcher.ts', + 'extensions/copilot/src/platform/networking/node/stream.ts', + 'extensions/copilot/src/platform/networking/node/test/nodeFetcherService.ts', + 'extensions/copilot/src/platform/networking/vscode-node/electronFetcher.ts', + 'extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts', + 'extensions/copilot/src/platform/notification/common/notificationService.ts', + 'extensions/copilot/src/platform/notification/vscode/notificationServiceImpl.ts', + 'extensions/copilot/src/platform/openai/node/fetch.ts', + 'extensions/copilot/src/platform/parser/node/nodes.ts', + 'extensions/copilot/src/platform/parser/node/parserServiceImpl.ts', + 'extensions/copilot/src/platform/parser/node/parserWorker.ts', + 'extensions/copilot/src/platform/parser/node/treeSitterQueries.ts', + 'extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts', + 'extensions/copilot/src/platform/remoteSearch/node/codeOrDocsSearchClientImpl.ts', + 'extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts', + 'extensions/copilot/src/platform/scopeSelection/vscode-node/scopeSelectionImpl.ts', + 'extensions/copilot/src/platform/snippy/common/snippyTypes.ts', + 'extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts', + 'extensions/copilot/src/platform/tasks/vscode/tasksService.ts', + 'extensions/copilot/src/platform/telemetry/common/failingTelemetryReporter.ts', + 'extensions/copilot/src/platform/telemetry/common/telemetryData.ts', + 'extensions/copilot/src/platform/telemetry/node/azureInsightsReporter.ts', + 'extensions/copilot/src/platform/telemetry/node/spyingTelemetryService.ts', + 'extensions/copilot/src/platform/terminal/common/terminalService.ts', + 'extensions/copilot/src/platform/terminal/vscode/terminalServiceImpl.ts', + 'extensions/copilot/src/platform/test/common/endpointTestFixtures.ts', + 'extensions/copilot/src/platform/test/common/testExtensionsService.ts', + 'extensions/copilot/src/platform/test/node/extensionContext.ts', + 'extensions/copilot/src/platform/test/node/fetcher.ts', + 'extensions/copilot/src/platform/test/node/services.ts', + 'extensions/copilot/src/platform/test/node/simulationWorkspace.ts', + 'extensions/copilot/src/platform/test/node/telemetry.ts', + 'extensions/copilot/src/platform/test/node/testWorkbenchService.ts', + 'extensions/copilot/src/platform/testing/common/nullWorkspaceMutationManager.ts', + 'extensions/copilot/src/platform/thinking/common/thinking.ts', + 'extensions/copilot/src/platform/tokenizer/node/tikTokenizerWorker.ts', + 'extensions/copilot/src/platform/tokenizer/node/tokenizer.ts', + 'extensions/copilot/src/platform/workbench/common/workbenchService.ts', + 'extensions/copilot/src/platform/workbench/vscode/workbenchServiceImpt.ts', + 'extensions/copilot/src/platform/workspaceChunkSearch/node/nullWorkspaceFileIndex.ts', + 'extensions/copilot/src/platform/workspaceChunkSearch/node/tfidfChunkSearch.ts', + 'extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceFileIndex.ts', + 'extensions/copilot/src/platform/workspaceRecorder/common/resolvedRecording/resolvedRecording.ts', + 'extensions/copilot/src/util/common/async.ts', + 'extensions/copilot/src/util/common/cache.ts', + 'extensions/copilot/src/util/common/chatResponseStreamImpl.ts', + 'extensions/copilot/src/util/common/debounce.ts', + 'extensions/copilot/src/util/common/debugValueEditorGlobals.ts', + 'extensions/copilot/src/util/common/diff.ts', + 'extensions/copilot/src/util/common/progress.ts', + 'extensions/copilot/src/util/common/test/shims/chatTypes.ts', + 'extensions/copilot/src/util/common/test/shims/editing.ts', + 'extensions/copilot/src/util/common/test/shims/l10n.ts', + 'extensions/copilot/src/util/common/test/shims/notebookDocument.ts', + 'extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts', + 'extensions/copilot/src/util/common/test/simpleMock.ts', + 'extensions/copilot/src/util/common/timeTravelScheduler.ts', + 'extensions/copilot/src/util/common/types.ts', + 'extensions/copilot/src/util/node/worker.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + rules: { + '@typescript-eslint/no-explicit-any': [ + 'warn', + { + 'fixToUnknown': true + } + ] + } + }, + // copilot extension - chatLibMain exception + { + files: [ + 'extensions/copilot/src/lib/node/chatLibMain.ts', + ], + rules: { + 'import/no-restricted-paths': 'off' + } + }, // Allow querySelector/querySelectorAll in test files - it's acceptable for test assertions { files: [ diff --git a/extensions/copilot/.esbuild.ts b/extensions/copilot/.esbuild.ts index 68dd47035789c..2377e0cc6e58b 100644 --- a/extensions/copilot/.esbuild.ts +++ b/extensions/copilot/.esbuild.ts @@ -45,11 +45,11 @@ const baseNodeBuildOptions = { ...(isDev ? [] : ['dotenv', 'source-map-support']) ], platform: 'node', - mainFields: ["module", "main"], // needed for jsonc-parser, + mainFields: ['module', 'main'], // needed for jsonc-parser, define: { 'process.env.APPLICATIONINSIGHTS_CONFIGURATION_CONTENT': JSON.stringify(JSON.stringify({ - proxyHttpUrl: "", - proxyHttpsUrl: "" + proxyHttpUrl: '', + proxyHttpsUrl: '' })) }, } satisfies esbuild.BuildOptions; @@ -232,7 +232,7 @@ const nodeSimulationBuildOptions = { const nodeSimulationWorkbenchUIBuildOptions = { ...baseNodeBuildOptions, platform: 'browser', // @ulugbekna: important to target 'browser' for correct bundling using 'window' - mainFields: ["browser", "module", "main"], + mainFields: ['browser', 'module', 'main'], entryPoints: [ { in: './test/simulation/workbench/simulationWorkbench.tsx', out: 'simulationWorkbench' }, ], @@ -277,8 +277,8 @@ const typeScriptServerPluginBuildOptions = { sourcesContent: false, treeShaking: true, external: [ - "typescript", - "typescript/lib/tsserverlibrary" + 'typescript', + 'typescript/lib/tsserverlibrary', ], entryPoints: [ { in: './src/extension/typescriptContext/serverPlugin/src/node/main.ts', out: 'main' }, diff --git a/extensions/copilot/.eslint-ignore b/extensions/copilot/.eslint-ignore deleted file mode 100644 index 86f3fb98c20ae..0000000000000 --- a/extensions/copilot/.eslint-ignore +++ /dev/null @@ -1,31 +0,0 @@ -node_modules -dist -coverage -lint-staged.config.js -vite.config.ts -**/vscode.proposed.*.ts -**/vscode.d.ts -.esbuild/extension.esbuild.ts -test/simulation/fixtures/** -test/scenarios/** -.simulation/** -.eslintplugin/** -chat-lib/** -test/aml/out/** -.vscode-test/** - -# ignore vs -src/util/vs/** - -# ignore test fixtures -src/platform/parser/test/node/fixtures/** -src/extension/test/node/fixtures/** -src/extension/prompts/node/test/fixtures/** - -# TypeScript server plugin -src/extension/typescriptContext/serverPlugin/fixtures/** -src/extension/typescriptContext/serverPlugin/lib/** -src/extension/typescriptContext/serverPlugin/dist/** - -# Ignore Built test-extension -.vscode/extensions/test-extension/dist/** diff --git a/extensions/copilot/.eslintplugin/no-unexternalized-strings.ts b/extensions/copilot/.eslintplugin/no-unexternalized-strings.ts deleted file mode 100644 index a7065cb2a0db9..0000000000000 --- a/extensions/copilot/.eslintplugin/no-unexternalized-strings.ts +++ /dev/null @@ -1,192 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; -import * as eslint from 'eslint'; -import type * as ESTree from 'estree'; - -function isStringLiteral(node: TSESTree.Node | ESTree.Node | null | undefined): node is TSESTree.StringLiteral { - return !!node && node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string'; -} - -function isDoubleQuoted(node: TSESTree.StringLiteral): boolean { - return node.raw[0] === '"' && node.raw[node.raw.length - 1] === '"'; -} - -/** - * Enable bulk fixing double-quoted strings to single-quoted strings with the --fix eslint flag - * - * Disabled by default as this is often not the desired fix. Instead the string should be localized. However it is - * useful for bulk conversations of existing code. - */ -const enableDoubleToSingleQuoteFixes = false; - - -export default new class NoUnexternalizedStrings implements eslint.Rule.RuleModule { - - private static _rNlsKeys = /^[_a-zA-Z0-9][ .\-_a-zA-Z0-9]*$/; - - readonly meta: eslint.Rule.RuleMetaData = { - messages: { - doubleQuoted: 'Only use double-quoted strings for externalized strings.', - badKey: 'The key \'{{key}}\' doesn\'t conform to a valid localize identifier.', - duplicateKey: 'Duplicate key \'{{key}}\' with different message value.', - badMessage: 'Message argument to \'{{message}}\' must be a string literal.' - }, - schema: false, - fixable: enableDoubleToSingleQuoteFixes ? 'code' : undefined, - }; - - create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - - const externalizedStringLiterals = new Map(); - const doubleQuotedStringLiterals = new Set(); - - function collectDoubleQuotedStrings(node: ESTree.Literal) { - if (isStringLiteral(node) && isDoubleQuoted(node)) { - doubleQuotedStringLiterals.add(node); - } - } - - function visitLocalizeCall(node: TSESTree.CallExpression) { - - // localize(key, message) - const [keyNode, messageNode] = node.arguments; - - // (1) - // extract key so that it can be checked later - let key: string | undefined; - if (isStringLiteral(keyNode)) { - doubleQuotedStringLiterals.delete(keyNode); - key = keyNode.value; - - } else if (keyNode.type === AST_NODE_TYPES.ObjectExpression) { - for (const property of keyNode.properties) { - if (property.type === AST_NODE_TYPES.Property && !property.computed) { - if (property.key.type === AST_NODE_TYPES.Identifier && property.key.name === 'key') { - if (isStringLiteral(property.value)) { - doubleQuotedStringLiterals.delete(property.value); - key = property.value.value; - break; - } - } - } - } - } - if (typeof key === 'string') { - let array = externalizedStringLiterals.get(key); - if (!array) { - array = []; - externalizedStringLiterals.set(key, array); - } - array.push({ call: node, message: messageNode }); - } - - // (2) - // remove message-argument from doubleQuoted list and make - // sure it is a string-literal - doubleQuotedStringLiterals.delete(messageNode); - if (!isStringLiteral(messageNode)) { - context.report({ - loc: messageNode.loc, - messageId: 'badMessage', - data: { message: context.getSourceCode().getText(node as ESTree.Node) } - }); - } - } - - function visitL10NCall(node: TSESTree.CallExpression) { - - // localize(key, message) - const [messageNode] = (node as TSESTree.CallExpression).arguments; // remove message-argument from doubleQuoted list and make - // sure it is a string-literal - if (isStringLiteral(messageNode)) { - doubleQuotedStringLiterals.delete(messageNode); - } else if (messageNode.type === AST_NODE_TYPES.ObjectExpression) { - for (const prop of messageNode.properties) { - if (prop.type === AST_NODE_TYPES.Property) { - if (prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === 'message') { - doubleQuotedStringLiterals.delete(prop.value); - break; - } - } - } - } - } - - function reportBadStringsAndBadKeys() { - // (1) - // report all strings that are in double quotes - for (const node of doubleQuotedStringLiterals) { - context.report({ - loc: node.loc, - messageId: 'doubleQuoted', - fix: enableDoubleToSingleQuoteFixes ? (fixer) => { - // Get the raw string content, unescaping any escaped quotes - const content = (node as ESTree.SimpleLiteral).raw! - .slice(1, -1) - .replace(/(? 1) { - for (let i = 1; i < values.length; i++) { - if (context.getSourceCode().getText(values[i - 1].message as ESTree.Node) !== context.getSourceCode().getText(values[i].message as ESTree.Node)) { - context.report({ loc: values[i].call.loc, messageId: 'duplicateKey', data: { key } }); - } - } - } - } - } - - return { - ['Literal']: (node: ESTree.Literal) => collectDoubleQuotedStrings(node), - ['ExpressionStatement[directive] Literal:exit']: (node: TSESTree.Literal) => doubleQuotedStringLiterals.delete(node), - - // localize(...) - ['CallExpression[callee.type="MemberExpression"][callee.object.name="nls"][callee.property.name="localize"]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - - // localize2(...) - ['CallExpression[callee.type="MemberExpression"][callee.object.name="nls"][callee.property.name="localize2"]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - - // vscode.l10n.t(...) - ['CallExpression[callee.type="MemberExpression"][callee.object.property.name="l10n"][callee.property.name="t"]:exit']: (node: TSESTree.CallExpression) => visitL10NCall(node), - - // l10n.t(...) - ['CallExpression[callee.object.name="l10n"][callee.property.name="t"]:exit']: (node: TSESTree.CallExpression) => visitL10NCall(node), - - ['CallExpression[callee.name="localize"][arguments.length>=2]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - ['CallExpression[callee.name="localize2"][arguments.length>=2]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - ['Program:exit']: reportBadStringsAndBadKeys, - }; - } -}; diff --git a/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts b/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts index 1b51b71a8c039..53a4e27a73ddb 100644 --- a/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts +++ b/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts @@ -5,4 +5,5 @@ import * as vscode from 'vscode'; +// eslint-disable-next-line local/code-no-any-casts (globalThis).projectRoot = vscode.workspace.workspaceFolders?.at(0)?.uri.fsPath ?? __dirname; diff --git a/extensions/copilot/.vscode/extensions/test-extension/main.ts b/extensions/copilot/.vscode/extensions/test-extension/main.ts index b1d3c3d271653..8c0fedbeac8ca 100644 --- a/extensions/copilot/.vscode/extensions/test-extension/main.ts +++ b/extensions/copilot/.vscode/extensions/test-extension/main.ts @@ -218,7 +218,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand( 'debug-value-editor.debug-and-send-request', { - launchConfigName: "Test Visualization Runner STests", + launchConfigName: 'Test Visualization Runner STests', args: args, revealAvailablePropertiesView: true, } diff --git a/extensions/copilot/.vscode/extensions/visualization-runner/entry.js b/extensions/copilot/.vscode/extensions/visualization-runner/entry.js index 7fc321e99f65a..5ea90eb572ec0 100644 --- a/extensions/copilot/.vscode/extensions/visualization-runner/entry.js +++ b/extensions/copilot/.vscode/extensions/visualization-runner/entry.js @@ -4,15 +4,15 @@ *--------------------------------------------------------------------------------------------*/ require('tsx/cjs'); -const { enableHotReload, hotRequire } = require("@hediet/node-reload"); +const { enableHotReload, hotRequire } = require('@hediet/node-reload'); enableHotReload({ entryModule: module }); /** - * @param {import("vscode").ExtensionContext} context + * @param {import('vscode').ExtensionContext} context */ function activate(context) { - context.subscriptions.push(hotRequire(module, "./extension", ext => new ext.Extension())); + context.subscriptions.push(hotRequire(module, './extension', ext => new ext.Extension())); } module.exports = { activate }; diff --git a/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts b/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts index ad36bec57683d..19766ce69d10f 100644 --- a/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts +++ b/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts @@ -42,7 +42,7 @@ export class Extension extends Disposable { title: 'Visualize Test', command: 'debug-value-editor.debug-and-send-request', arguments: [{ - launchConfigName: "Test Visualization Runner", + launchConfigName: 'Test Visualization Runner', args: { fileName: document.fileName, path: t.path, diff --git a/extensions/copilot/eslint.config.mjs b/extensions/copilot/eslint.config.mjs deleted file mode 100644 index 7edfb1982d931..0000000000000 --- a/extensions/copilot/eslint.config.mjs +++ /dev/null @@ -1,538 +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 stylisticEslint from '@stylistic/eslint-plugin'; -import tsEslint from '@typescript-eslint/eslint-plugin'; -import tsParser from '@typescript-eslint/parser'; -import importEslint from 'eslint-plugin-import'; -import jsdocEslint from 'eslint-plugin-jsdoc'; -import fs from 'fs'; -import { builtinModules } from 'module'; -import path from 'path'; -import tseslint from 'typescript-eslint'; -import { fileURLToPath } from 'url'; - -import headerEslint from 'eslint-plugin-header'; -headerEslint.rules.header.meta.schema = false; - -import * as localEslint from './.eslintplugin/index.ts'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ignores = fs.readFileSync(path.join(__dirname, '.eslint-ignore'), 'utf8') - .toString() - .split(/\r\n|\n/) - .filter(line => line && !line.startsWith('#')); - -export default tseslint.config( - // Global ignores - { - ignores: [ - ...ignores, - '!**/.eslint-plugin-local/**/*' - ], - }, - // All js/ts files - { - files: [ - '**/*.{js,jsx,mjs,cjs,ts,tsx}', - ], - ignores: [ - './src/extension/completions-core/**/testdata/*', - ], - languageOptions: { - parser: tsParser, - }, - plugins: { - '@stylistic': stylisticEslint, - 'header': headerEslint, - }, - rules: { - 'indent': [ - 'error', - 'tab', - { - ignoredNodes: [ - 'SwitchCase', - 'ClassDeclaration', - 'TemplateLiteral *', // Conflicts with tsfmt - 'CallExpression > ArrowFunctionExpression', // Conflicts with tsfmt - 'CallExpression > ArrowFunctionExpression > BlockStatement', // Conflicts with tsfmt - 'NewExpression > ArrowFunctionExpression', // Conflicts with tsfmt - 'NewExpression > ArrowFunctionExpression > BlockStatement' // Conflicts with tsfmt - ] - } - ], - 'constructor-super': 'error', - 'curly': 'error', - 'eqeqeq': 'error', - 'prefer-const': [ - 'error', - { - destructuring: 'all' - } - ], - 'no-buffer-constructor': 'error', - 'no-caller': 'error', - 'no-case-declarations': 'error', - 'no-debugger': 'error', - 'no-duplicate-case': 'error', - 'no-duplicate-imports': 'error', - 'no-eval': 'error', - 'no-async-promise-executor': 'error', - 'no-extra-semi': 'error', - 'no-new-wrappers': 'error', - 'no-redeclare': 'off', - 'no-sparse-arrays': 'error', - 'no-throw-literal': 'error', - 'no-unsafe-finally': 'error', - 'no-unused-labels': 'error', - 'no-restricted-globals': [ - 'error', - 'name', - 'length', - 'event', - 'closed', - 'external', - 'status', - 'origin', - 'orientation', - 'context' - ], // non-complete list of globals that are easy to access unintentionally - 'no-var': 'error', - 'semi': 'error', - 'header/header': [ - 'error', - 'block', - [ - '---------------------------------------------------------------------------------------------', - ' * Copyright (c) Microsoft Corporation. All rights reserved.', - ' * Licensed under the MIT License. See License.txt in the project root for license information.', - ' *--------------------------------------------------------------------------------------------' - ] - ] - }, - settings: { - 'import/resolver': { - typescript: { - extensions: ['.ts', '.tsx'] - } - } - }, - }, - // All ts files - { - files: [ - '**/*.{ts,tsx}', - ], - languageOptions: { - parser: tsParser, - }, - plugins: { - '@typescript-eslint': tsEslint, - '@stylistic': stylisticEslint, - 'jsdoc': jsdocEslint, - }, - rules: { - 'jsdoc/no-types': 'error', - '@stylistic/member-delimiter-style': 'error', - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: 'class', - format: ['PascalCase'] - } - ], - }, - settings: { - 'import/resolver': { - typescript: { - extensions: ['.ts', '.tsx'] - } - } - }, - }, - // Main extension sources - { - files: [ - 'src/**/*.{ts,tsx}', - 'test/**/*.{ts,tsx}', - ], - ignores: [ - '**/.esbuild.ts', - './src/extension/completions-core/vscode-node/bridge/src/completionsTelemetryServiceBridge.ts', - ], - languageOptions: { - parser: tseslint.parser, - }, - plugins: { - 'import': importEslint, - 'local': localEslint, - }, - rules: { - 'no-restricted-imports': [ - 'error', - // node: builtins - ...builtinModules, - // node: dependencies - '@humanwhocodes/gitignore-to-minimatch', - '@vscode/extension-telemetry', - 'applicationinsights', - 'ignore', - 'isbinaryfile', - 'minimatch', - 'source-map-support', - 'vscode-tas-client', - 'web-tree-sitter' - ], - 'import/no-restricted-paths': [ - 'error', - { - zones: [ - { - target: '**/common/**', - from: [ - '**/vscode/**', - '**/node/**', - '**/vscode-node/**', - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/vscode/**', - from: [ - '**/node/**', - '**/vscode-node/**', - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/node/**', - from: [ - '**/vscode/**', - '**/vscode-node/**', - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/vscode-node/**', - from: [ - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/worker/**', - from: [ - '**/vscode/**', - '**/node/**', - '**/vscode-node/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/vscode-worker/**', - from: [ - '**/node/**', - '**/vscode-node/**' - ] - }, - { - target: './src/', - from: './test/' - }, - { - target: './src/shared-fetch-utils', - from: ['./src/extension', './src/platform', './src/util', './src/lib'] - }, - { - target: './src/util', - from: ['./src/platform', './src/extension'] - }, - { - target: './src/platform', - from: ['./src/extension'] - }, - { - target: ['./test', '!./test/base/extHostContext/*.ts'], - from: ['**/vscode-node/**', '**/vscode-worker/**'] - }, - { - target: 'src/!(lib)/**', - from: './src/lib' - } - ] - } - ], - 'local/no-instanceof-uri': ['error'], - 'local/no-test-imports': ['error'], - 'local/no-runtime-import': [ - 'error', - { - test: ['vscode'], - 'src/**/common/**/*': ['vscode'], - 'src/**/node/**/*': ['vscode'] - } - ], - 'local/no-funny-filename': ['error'], - 'local/no-bad-gdpr-comment': ['error'], - 'local/no-gdpr-event-name-mismatch': ['error'], - 'local/no-unlayered-files': ['error'], - 'local/no-restricted-copilot-pr-string': [ - 'error', - { - className: 'GitHubPullRequestProviders', - string: 'Generate with Copilot' - } - ], - 'local/no-nls-localize': ['error'], - 'local/no-unexternalized-strings': ['error'], - } - }, - { - files: ['**/{vscode-node,node}/**/*.ts', '**/{vscode-node,node}/**/*.tsx'], - rules: { - 'no-restricted-imports': 'off' - } - }, - { - files: ['**/*.js'], - rules: { - 'jsdoc/no-types': 'off' - } - }, - { - files: ['src/extension/**/*.tsx'], - rules: { - 'local/no-missing-linebreak': 'error' - } - }, - { - files: ['**/*.test.ts', '**/*.test.tsx'], - rules: { - 'local/no-test-only': 'error' - } - }, - { - files: [ - 'test/**', - 'src/vscodeTypes.ts', - 'script/**', - 'src/extension/*.d.ts', - 'build/**' - ], - rules: { - 'local/no-unlayered-files': 'off', - 'no-restricted-imports': 'off' - } - }, - // no-explicit-any - { - files: [ - 'src/**/*.ts', - ], - ignores: [ - 'src/util/vs/**/*.ts', // vendored code - 'src/**/*.spec.ts', // allow in tests - './src/extension/byok/common/anthropicMessageConverter.ts', - './src/extension/byok/common/geminiFunctionDeclarationConverter.ts', - './src/extension/byok/common/geminiMessageConverter.ts', - './src/extension/byok/vscode-node/anthropicProvider.ts', - './src/extension/byok/vscode-node/geminiNativeProvider.ts', - './src/extension/byok/vscode-node/ollamaProvider.ts', - './src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts', - './src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts', - './src/extension/codeBlocks/node/codeBlockProcessor.ts', - './src/extension/codeBlocks/vscode-node/provider.ts', - './src/extension/configuration/vscode-node/configurationMigration.ts', - './src/extension/context/node/resolvers/genericInlineIntentInvocation.ts', - './src/extension/context/node/resolvers/genericPanelIntentInvocation.ts', - './src/extension/context/node/resolvers/inlineFixIntentInvocation.ts', - './src/extension/context/node/resolvers/promptWorkspaceLabels.ts', - './src/extension/contextKeys/vscode-node/contextKeys.contribution.ts', - './src/extension/conversation/vscode-node/userActions.ts', - './src/extension/extension/vscode/services.ts', - './src/extension/inlineChat/node/rendererVisualization.ts', - './src/extension/inlineChat/vscode-node/inlineChatCommands.ts', - './src/extension/inlineEdits/common/observableWorkspaceRecordingReplayer.ts', - './src/extension/inlineEdits/vscode-node/parts/vscodeWorkspace.ts', - './src/extension/intents/node/editCodeIntent.ts', - './src/extension/intents/node/editCodeStep.ts', - './src/extension/intents/node/fixIntent.ts', - './src/extension/intents/node/newIntent.ts', - './src/extension/intents/node/searchIntent.ts', - './src/extension/languageContextProvider/vscode-node/languageContextProviderService.ts', - './src/extension/linkify/common/commands.ts', - './src/extension/linkify/common/responseStreamWithLinkification.ts', - './src/extension/linkify/test/node/util.ts', - './src/extension/log/vscode-node/loggingActions.ts', - './src/extension/log/vscode-node/requestLogTree.ts', - './src/extension/mcp/test/vscode-node/util.ts', - './src/extension/mcp/vscode-node/commands.ts', - './src/extension/mcp/vscode-node/nuget.ts', - './src/extension/onboardDebug/node/copilotDebugWorker/rpc.ts', - './src/extension/onboardDebug/node/parseLaunchConfigFromResponse.ts', - './src/extension/onboardDebug/vscode-node/copilotDebugCommandHandle.ts', - './src/extension/prompt/common/toolCallRound.ts', - './src/extension/prompt/node/chatMLFetcher.ts', - './src/extension/prompt/node/chatParticipantTelemetry.ts', - './src/extension/prompt/node/editGeneration.ts', - './src/extension/prompt/node/intents.ts', - './src/extension/prompt/node/todoListContextProvider.ts', - './src/extension/prompt/vscode-node/endpointProviderImpl.ts', - './src/extension/prompt/vscode-node/requestLoggerImpl.ts', - './src/extension/prompts/node/agent/promptRegistry.ts', - './src/extension/prompts/node/base/promptElement.ts', - './src/extension/prompts/node/base/promptRenderer.ts', - './src/extension/prompts/node/test/utils.ts', - './src/extension/replay/common/chatReplayResponses.ts', - './src/extension/replay/node/replayParser.ts', - './src/extension/replay/vscode-node/replayDebugSession.ts', - './src/extension/review/node/githubReviewAgent.ts', - './src/extension/test/node/services.ts', - './src/extension/test/vscode-node/extension.test.ts', - './src/extension/test/vscode-node/sanity.sanity-test.ts', - './src/extension/test/vscode-node/session.test.ts', - './src/extension/tools/common/toolSchemaNormalizer.ts', - './src/extension/tools/common/toolsService.ts', - './src/extension/typescriptContext/common/serverProtocol.ts', - './src/extension/typescriptContext/serverPlugin/src/common/baseContextProviders.ts', - './src/extension/typescriptContext/serverPlugin/src/common/contextProvider.ts', - './src/extension/typescriptContext/serverPlugin/src/common/protocol.ts', - './src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts', - './src/extension/typescriptContext/serverPlugin/src/common/utils.ts', - './src/extension/typescriptContext/vscode-node/inspector.ts', - './src/extension/typescriptContext/vscode-node/languageContextService.ts', - './src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts', - './src/extension/workspaceSemanticSearch/node/semanticSearchTextSearchProvider.ts', - './src/lib/node/chatLibMain.ts', - './src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts', - './src/platform/chat/common/blockedExtensionService.ts', - './src/platform/chunking/common/chunkingEndpointClientImpl.ts', - './src/platform/commands/common/mockRunCommandExecutionService.ts', - './src/platform/commands/common/runCommandExecutionService.ts', - './src/platform/commands/vscode/runCommandExecutionServiceImpl.ts', - './src/platform/configuration/common/configurationService.ts', - './src/platform/configuration/common/validator.ts', - './src/platform/configuration/test/common/inMemoryConfigurationService.ts', - './src/platform/configuration/vscode/configurationServiceImpl.ts', - './src/platform/customInstructions/common/customInstructionsService.ts', - './src/platform/debug/vscode/debugOutputListener.ts', - './src/platform/diff/node/diffWorkerMain.ts', - './src/platform/editing/common/notebookDocumentSnapshot.ts', - './src/platform/editing/common/textDocumentSnapshot.ts', - './src/platform/embeddings/common/embeddingsGrouper.ts', - './src/platform/embeddings/common/embeddingsIndex.ts', - './src/platform/embeddings/common/remoteEmbeddingsComputer.ts', - './src/platform/endpoint/node/modelMetadataFetcher.ts', - './src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts', - './src/platform/env/common/packagejson.ts', - './src/platform/extensions/common/extensionsService.ts', - './src/platform/filesystem/common/fileSystemService.ts', - './src/platform/github/common/githubService.ts', - './src/platform/github/common/nullOctokitServiceImpl.ts', - './src/platform/inlineEdits/common/dataTypes/edit.ts', - './src/platform/inlineEdits/common/dataTypes/textEditLengthHelper/length.ts', - './src/platform/inlineEdits/common/editReason.ts', - './src/platform/inlineEdits/common/statelessNextEditProvider.ts', - './src/platform/inlineEdits/common/utils/observable.ts', - './src/platform/languages/common/languageDiagnosticsService.ts', - './src/platform/log/common/logExecTime.ts', - './src/platform/log/common/logService.ts', - './src/platform/log/vscode/outputChannelLogTarget.ts', - './src/platform/nesFetch/common/completionsFetchService.ts', - './src/platform/nesFetch/node/completionsFetchServiceImpl.ts', - './src/platform/networking/common/fetch.ts', - './src/platform/networking/common/fetcherService.ts', - './src/platform/networking/common/networking.ts', - './src/platform/networking/common/openai.ts', - './src/platform/networking/node/baseFetchFetcher.ts', - './src/platform/networking/node/chatStream.ts', - './src/platform/networking/node/fetcherFallback.ts', - './src/platform/networking/node/nodeFetchFetcher.ts', - './src/platform/networking/node/nodeFetcher.ts', - './src/platform/networking/node/stream.ts', - './src/platform/networking/node/test/nodeFetcherService.ts', - './src/platform/networking/vscode-node/electronFetcher.ts', - './src/platform/networking/vscode-node/fetcherServiceImpl.ts', - './src/platform/notification/common/notificationService.ts', - './src/platform/notification/vscode/notificationServiceImpl.ts', - './src/platform/openai/node/fetch.ts', - './src/platform/parser/node/nodes.ts', - './src/platform/parser/node/parserServiceImpl.ts', - './src/platform/parser/node/parserWorker.ts', - './src/platform/parser/node/treeSitterQueries.ts', - './src/platform/remoteCodeSearch/common/githubCodeSearchService.ts', - './src/platform/remoteSearch/node/codeOrDocsSearchClientImpl.ts', - './src/platform/review/vscode/reviewServiceImpl.ts', - './src/platform/scopeSelection/vscode-node/scopeSelectionImpl.ts', - './src/platform/snippy/common/snippyTypes.ts', - './src/platform/survey/vscode/surveyServiceImpl.ts', - './src/platform/tasks/vscode/tasksService.ts', - './src/platform/telemetry/common/failingTelemetryReporter.ts', - './src/platform/telemetry/common/telemetryData.ts', - './src/platform/telemetry/node/azureInsightsReporter.ts', - './src/platform/telemetry/node/spyingTelemetryService.ts', - './src/platform/terminal/common/terminalService.ts', - './src/platform/terminal/vscode/terminalServiceImpl.ts', - './src/platform/test/common/endpointTestFixtures.ts', - './src/platform/test/common/testExtensionsService.ts', - './src/platform/test/node/extensionContext.ts', - './src/platform/test/node/fetcher.ts', - './src/platform/test/node/services.ts', - './src/platform/test/node/simulationWorkspace.ts', - './src/platform/test/node/telemetry.ts', - './src/platform/test/node/testWorkbenchService.ts', - './src/platform/testing/common/nullWorkspaceMutationManager.ts', - './src/platform/thinking/common/thinking.ts', - './src/platform/tokenizer/node/tikTokenizerWorker.ts', - './src/platform/tokenizer/node/tokenizer.ts', - './src/platform/workbench/common/workbenchService.ts', - './src/platform/workbench/vscode/workbenchServiceImpt.ts', - './src/platform/workspaceChunkSearch/node/nullWorkspaceFileIndex.ts', - './src/platform/workspaceChunkSearch/node/tfidfChunkSearch.ts', - './src/platform/workspaceChunkSearch/node/workspaceFileIndex.ts', - './src/platform/workspaceRecorder/common/resolvedRecording/resolvedRecording.ts', - './src/util/common/async.ts', - './src/util/common/cache.ts', - './src/util/common/chatResponseStreamImpl.ts', - './src/util/common/debounce.ts', - './src/util/common/debugValueEditorGlobals.ts', - './src/util/common/diff.ts', - './src/util/common/progress.ts', - './src/util/common/test/shims/chatTypes.ts', - './src/util/common/test/shims/editing.ts', - './src/util/common/test/shims/l10n.ts', - './src/util/common/test/shims/notebookDocument.ts', - './src/util/common/test/shims/vscodeTypesShim.ts', - './src/util/common/test/simpleMock.ts', - './src/util/common/timeTravelScheduler.ts', - './src/util/common/types.ts', - './src/util/node/worker.ts', - ], - languageOptions: { - parser: tseslint.parser, - }, - plugins: { - '@typescript-eslint': tseslint.plugin, - }, - rules: { - '@typescript-eslint/no-explicit-any': [ - 'warn', - { - 'fixToUnknown': true - } - ] - } - }, - { - files: ['./src/lib/node/chatLibMain.ts'], - rules: { - 'import/no-restricted-paths': 'off' - } - }, -); diff --git a/extensions/copilot/lint-staged.config.js b/extensions/copilot/lint-staged.config.js deleted file mode 100644 index 5af6ac5f26222..0000000000000 --- a/extensions/copilot/lint-staged.config.js +++ /dev/null @@ -1,30 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -const ESLint = require('eslint').ESLint; - -const removeIgnoredFiles = async (files) => { - const eslint = new ESLint(); - const isIgnored = await Promise.all( - files.map((file) => { - return eslint.isPathIgnored(file); - }) - ); - const filteredFiles = files.filter((_, i) => !isIgnored[i]); - return filteredFiles.join(' '); -}; - -module.exports = { - '!({.esbuild.ts,test/simulation/fixtures/**,test/scenarios/**,.vscode/extensions/**,**/vscode.proposed.*})*{.ts,.js,.tsx}': async (files) => { - const filesToLint = await removeIgnoredFiles(files); - if (!filesToLint) { - return []; - } - return [ - `npm run tsfmt -- ${filesToLint}`, - `eslint --max-warnings=0 ${filesToLint}` - ]; - }, -}; diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 25b714b949869..a94af880a4c38 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -102,17 +102,10 @@ "dotenv": "^17.2.0", "electron": "^39.8.5", "esbuild": "0.27.2", - "eslint": "^9.30.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-header": "^3.1.1", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsdoc": "^51.3.4", - "eslint-plugin-no-only-tests": "^3.3.0", "fastq": "^1.19.1", "glob": "^11.1.0", "js-yaml": "^4.1.1", "keyv": "^5.3.2", - "lint-staged": "15.2.9", "minimist": "^1.2.8", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", @@ -138,7 +131,6 @@ "ts-dedent": "^2.2.0", "tsx": "^4.20.3", "typescript": "^5.8.3", - "typescript-eslint": "^8.36.0", "typescript-formatter": "github:jrieken/typescript-formatter#497efb26bc40b5fa59a350e6eab17bce650a7e4b", "vite-plugin-top-level-await": "^1.5.0", "vite-plugin-wasm": "^3.5.0", @@ -156,15 +148,6 @@ "vscode": "^1.118.0" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -844,40 +827,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emotion/hash": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", @@ -885,23 +834,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@es-joy/jsdoccomment": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.52.0.tgz", - "integrity": "sha512-BXuN7BII+8AyNtn57euU2Yxo9yA/KUDNzrpXyi3pfqKmBhhysR6ZWOebFh3vyPoqA3/j1SOvGgucElMGwlXing==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.34.1", - "comment-parser": "1.4.1", - "esquery": "^1.6.0", - "jsdoc-type-pratt-parser": "~4.1.0" - }, - "engines": { - "node": ">=20.11.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1373,178 +1305,6 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", - "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@floating-ui/core": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", @@ -3434,44 +3194,6 @@ "hono": "^4" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/gitignore-to-minimatch": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz", @@ -3481,33 +3203,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", @@ -4243,19 +3938,6 @@ } } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", - "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" - } - }, "node_modules/@nevware21/ts-async": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/@nevware21/ts-async/-/ts-async-0.5.4.tgz", @@ -5750,13 +5432,6 @@ "win32" ] }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, "node_modules/@secretlint/config-creator": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.1.1.tgz", @@ -6411,17 +6086,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -6571,13 +6235,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -7129,313 +6786,44 @@ "node": ">=18.0.0" } }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.2.tgz", - "integrity": "sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A==", - "cpu": [ - "arm" - ], + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.2.tgz", - "integrity": "sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.2.tgz", - "integrity": "sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.2.tgz", - "integrity": "sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.2.tgz", - "integrity": "sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.2.tgz", - "integrity": "sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.2.tgz", - "integrity": "sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.2.tgz", - "integrity": "sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.2.tgz", - "integrity": "sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.2.tgz", - "integrity": "sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.2.tgz", - "integrity": "sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.2.tgz", - "integrity": "sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.2.tgz", - "integrity": "sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.2.tgz", - "integrity": "sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.9.2.tgz", - "integrity": "sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.2.tgz", - "integrity": "sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.2.tgz", - "integrity": "sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.2.tgz", - "integrity": "sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.2.tgz", - "integrity": "sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { @@ -8519,22 +7907,6 @@ } } }, - "node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -8872,15 +8244,6 @@ "streamx": "^2.15.0" } }, - "node_modules/are-docs-informative": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", - "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", - "dev": true, - "engines": { - "node": ">=14" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -8913,97 +8276,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", + "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", @@ -9954,16 +9234,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -10147,77 +9417,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -10332,13 +9531,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -10368,16 +9560,6 @@ "integrity": "sha512-aE2Y4MTFJ870NuB/+2z1cXBhSBBzRydVVjzhFC4gtenEhpnj15yu0qptWGJsO9YGrcPZ3ezX8AWb1VA391MKpQ==", "dev": true }, - "node_modules/comment-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", - "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -10981,12 +10163,6 @@ "node": ">=4.0.0" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -11577,19 +10753,6 @@ "node": ">=6" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -11729,19 +10892,6 @@ "node": ">= 0.4" } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-to-primitive": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", @@ -11857,501 +11007,87 @@ "source-map": "~0.6.1" } }, - "node_modules/eslint": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", - "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.14.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.30.1", - "@eslint/plugin-kit": "^0.3.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-import-context": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.8.tgz", - "integrity": "sha512-bq+F7nyc65sKpZGT09dY0S0QrOnQtuDVIfyTGQ8uuvtMIF7oHp6CEP3mouN0rrnYF3Jqo6Ke0BfU/5wASZue1w==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "get-tsconfig": "^4.10.1", - "stable-hash-x": "^0.1.1" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint-import-context" - }, - "peerDependencies": { - "unrs-resolver": "^1.0.0" + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "unrs-resolver": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-import-context/node_modules/stable-hash-x": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.1.1.tgz", - "integrity": "sha512-l0x1D6vhnsNUGPFVDx45eif0y6eedVC8nm5uACTrVFJFtl2mLRW17aWtVyxFCpn5t94VUPkjU8vSLwIuwwqtJQ==", + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, "engines": { - "node": ">=12.0.0" + "node": ">=4" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "@types/estree": "^1.0.0" } }, - "node_modules/eslint-import-resolver-typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", - "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", - "dev": true, - "license": "ISC", - "dependencies": { - "debug": "^4.4.1", - "eslint-import-context": "^0.1.8", - "get-tsconfig": "^4.10.1", - "is-bun-module": "^2.0.0", - "stable-hash-x": "^0.2.0", - "tinyglobby": "^0.2.14", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^16.17.0 || >=18.6.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-header": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz", - "integrity": "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==", - "dev": true, - "peerDependencies": { - "eslint": ">=7.7.0" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-jsdoc": { - "version": "51.3.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-51.3.4.tgz", - "integrity": "sha512-maz6qa95+sAjMr9m5oRyfejc+mnyQWsWSe9oyv9371bh4/T0kWOMryJNO4h8rEd97wo/9lbzwi3OOX4rDhnAzg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@es-joy/jsdoccomment": "~0.52.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", - "debug": "^4.4.1", - "escape-string-regexp": "^4.0.0", - "espree": "^10.4.0", - "esquery": "^1.6.0", - "parse-imports-exports": "^0.2.4", - "semver": "^7.7.2", - "spdx-expression-parse": "^4.0.0" - }, - "engines": { - "node": ">=20.11.0" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/eslint-plugin-no-only-tests": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.3.0.tgz", - "integrity": "sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=5.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "engines": { "node": ">=0.10.0" @@ -12376,13 +11112,6 @@ "node": ">=6" } }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, - "license": "MIT" - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -12631,19 +11360,6 @@ "node": ">= 6" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, "node_modules/fast-uri": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz", @@ -12735,19 +11451,6 @@ } } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -12813,37 +11516,6 @@ "flat": "cli.js" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flat-cache/node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -13256,18 +11928,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/glob/node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -13296,19 +11956,6 @@ "node": ">=10.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -13790,23 +12437,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "dev": true }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/import-in-the-middle": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.4.2.tgz", @@ -13834,6 +12464,7 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "optional": true, "engines": { "node": ">=0.8.19" } @@ -14000,16 +12631,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.7.1" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -14105,19 +12726,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-generator-function": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", @@ -14623,16 +13231,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdoc-type-pratt-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", - "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -14690,31 +13288,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "optional": true - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "optional": true }, "node_modules/jsonc-parser": { "version": "3.3.1", @@ -14919,478 +13498,156 @@ "debug": "^4.0.1", "koa-compose": "^4.1.0" }, - "engines": { - "node": ">= 7.6.0" - } - }, - "node_modules/koa-send": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", - "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "http-errors": "^1.7.3", - "resolve-path": "^1.4.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/koa-send/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa-send/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa-send/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa-static": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", - "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.1.0", - "koa-send": "^5.0.0" - }, - "engines": { - "node": ">= 7.6.0" - } - }, - "node_modules/koa-static/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/koa/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", - "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/lint-staged": { - "version": "15.2.9", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.9.tgz", - "integrity": "sha512-BZAt8Lk3sEnxw7tfxM7jeZlPRuT4M68O0/CwZhhaw6eeWu0Lz5eERE3m386InivXB64fp/mDID452h48tvKlRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "~5.3.0", - "commander": "~12.1.0", - "debug": "~4.3.6", - "execa": "~8.0.1", - "lilconfig": "~3.1.2", - "listr2": "~8.2.4", - "micromatch": "~4.0.7", - "pidtree": "~0.6.0", - "string-argv": "~0.3.2", - "yaml": "~2.5.0" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/lint-staged/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/lint-staged/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/lint-staged/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 7.6.0" } }, - "node_modules/lint-staged/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "node_modules/koa-send": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", + "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", "dev": true, + "license": "MIT", "dependencies": { - "mimic-fn": "^4.0.0" + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "resolve-path": "^1.4.0" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 8" } }, - "node_modules/lint-staged/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "node_modules/koa-send/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/lint-staged/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "node_modules/koa-send/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "dev": true, - "engines": { - "node": ">=12" + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 0.6" } }, - "node_modules/lint-staged/node_modules/yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "node_modules/koa-send/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, + "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 0.6" } }, - "node_modules/listr2": { - "version": "8.2.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", - "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", + "node_modules/koa-static": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", + "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "debug": "^3.1.0", + "koa-send": "^5.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">= 7.6.0" } }, - "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "node_modules/koa-static/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "dependencies": { + "ms": "^2.1.1" } }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "node_modules/koa/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">= 0.6" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/koa/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/listr2/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "readable-stream": "^2.0.5" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">= 0.6.3" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" } }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, + "immediate": "~3.0.5" + } + }, + "node_modules/lines-and-columns": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", + "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dependencies": { + "uc.micro": "^2.0.0" } }, "node_modules/load-json-file": { @@ -15520,185 +13777,41 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "dev": true, - "license": "MIT" - }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.zip": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", - "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } + "license": "MIT" }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/log-update/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/lodash.zip": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", + "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } + "license": "MIT" }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/loglevel": { @@ -15925,12 +14038,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -16648,22 +14755,6 @@ "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", "dev": true }, - "node_modules/napi-postinstall": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", - "integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -17501,59 +15592,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -17641,23 +15679,6 @@ } } }, - "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/ora": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", @@ -17915,29 +15936,6 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "dev": true }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-imports-exports": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", - "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-statements": "1.0.11" - } - }, "node_modules/parse-json": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-7.1.1.tgz", @@ -17989,13 +15987,6 @@ "semver": "bin/semver" } }, - "node_modules/parse-statements": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", - "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", - "dev": true, - "license": "MIT" - }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -18182,18 +16173,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/pify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", @@ -18347,15 +16326,6 @@ "node": ">=10" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/prettier": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", @@ -18597,16 +16567,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -19028,16 +16988,6 @@ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", "dev": true }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-path": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz", @@ -19188,13 +17138,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rgb2hex": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.2.5.tgz", @@ -20000,36 +17943,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -20250,16 +18163,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/stable-hash-x": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", - "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/stack-chain": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", @@ -20345,15 +18248,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "engines": { - "node": ">=0.6.19" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -21224,19 +19118,6 @@ "node": ">=6.10" } }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -21779,18 +19660,6 @@ "node": "*" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -21953,29 +19822,6 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.36.0.tgz", - "integrity": "sha512-fTCqxthY+h9QbEgSIBfL9iV6CvKDFuoxg6bHPNpJ9HIUzS+jy2lCEyCmGyZRWEBSaykqcDPf1SJ+BfCI8DRopA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.36.0", - "@typescript-eslint/parser": "8.36.0", - "@typescript-eslint/utils": "8.36.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, "node_modules/typescript-formatter": { "version": "7.2.2", "resolved": "git+ssh://git@github.com/jrieken/typescript-formatter.git#497efb26bc40b5fa59a350e6eab17bce650a7e4b", @@ -22098,41 +19944,6 @@ "node": ">= 0.8" } }, - "node_modules/unrs-resolver": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.9.2.tgz", - "integrity": "sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.2.4" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.9.2", - "@unrs/resolver-binding-android-arm64": "1.9.2", - "@unrs/resolver-binding-darwin-arm64": "1.9.2", - "@unrs/resolver-binding-darwin-x64": "1.9.2", - "@unrs/resolver-binding-freebsd-x64": "1.9.2", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.2", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.2", - "@unrs/resolver-binding-linux-arm64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-arm64-musl": "1.9.2", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-riscv64-musl": "1.9.2", - "@unrs/resolver-binding-linux-s390x-gnu": "1.9.2", - "@unrs/resolver-binding-linux-x64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-x64-musl": "1.9.2", - "@unrs/resolver-binding-wasm32-wasi": "1.9.2", - "@unrs/resolver-binding-win32-arm64-msvc": "1.9.2", - "@unrs/resolver-binding-win32-ia32-msvc": "1.9.2", - "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" - } - }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -22143,16 +19954,6 @@ "node": ">=8" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index c5024d36f75d8..c139f0adcd746 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1051,6 +1051,38 @@ ] } }, + { + "name": "copilot_githubTextSearch", + "legacyToolReferenceFullNames": [ + "githubTextSearch" + ], + "toolReferenceName": "githubTextSearch", + "displayName": "%github.copilot.tools.githubTextSearch.name%", + "modelDescription": "Lexically searches a GitHub repository or organization for files containing specific keywords or code patterns. Use this when looking for exact strings, function names, or identifiers in a GitHub repo or org. Unlike the semantic search tool, this uses keyword matching rather than meaning-based search.", + "userDescription": "%github.copilot.tools.githubTextSearch.userDescription%", + "icon": "$(search)", + "inputSchema": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "The GitHub scope to search. Use 'owner/repo' to search a single repository, or an org name (no slash) to search across an entire organization." + }, + "query": { + "type": "string", + "description": "The keyword search query. Supports GitHub code search syntax such as 'language:typescript', 'extension:ts', 'path:src/', etc." + }, + "maxResults": { + "type": "number", + "description": "Optional. The maximum number of search results to return. Defaults to 100." + } + }, + "required": [ + "scope", + "query" + ] + } + }, { "name": "copilot_switchAgent", "toolReferenceName": "switchAgent", @@ -1271,7 +1303,8 @@ "icon": "$(globe)", "tools": [ "fetch", - "githubRepo" + "githubRepo", + "githubTextSearch" ] } ], @@ -4685,7 +4718,7 @@ }, "github.copilot.chat.cli.sessionController.enabled": { "type": "boolean", - "default": false, + "default": true, "markdownDescription": "%github.copilot.config.cli.sessionController.enabled%", "tags": [ "advanced" @@ -6423,9 +6456,8 @@ "watch:tsc-extension-web": "tsc --noEmit --watch --project tsconfig.worker.json", "watch:tsc-simulation-workbench": "tsc --noEmit --watch --project test/simulation/workbench/tsconfig.json", "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project test/simulation/workbench/tsconfig.json && tsc --noEmit --project tsconfig.worker.json && tsc --noEmit --project src/extension/completions-core/vscode-node/extension/src/copilotPanel/webView/tsconfig.json", - "lint": "eslint . --max-warnings=0", - "lint-staged": "eslint --max-warnings=0", "tsfmt": "npx tsfmt -r --verify", + "lint": "npx eslint .", "test": "npm-run-all test:*", "test:extension": "vscode-test", "test:sanity": "vscode-test --sanity", @@ -6499,17 +6531,10 @@ "dotenv": "^17.2.0", "electron": "^39.8.5", "esbuild": "0.27.2", - "eslint": "^9.30.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-header": "^3.1.1", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsdoc": "^51.3.4", - "eslint-plugin-no-only-tests": "^3.3.0", "fastq": "^1.19.1", "glob": "^11.1.0", "js-yaml": "^4.1.1", "keyv": "^5.3.2", - "lint-staged": "15.2.9", "minimist": "^1.2.8", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", @@ -6535,7 +6560,6 @@ "ts-dedent": "^2.2.0", "tsx": "^4.20.3", "typescript": "^5.8.3", - "typescript-eslint": "^8.36.0", "typescript-formatter": "github:jrieken/typescript-formatter#497efb26bc40b5fa59a350e6eab17bce650a7e4b", "vite-plugin-top-level-await": "^1.5.0", "vite-plugin-wasm": "^3.5.0", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 122540aab726f..ade5bdaa46191 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -274,8 +274,10 @@ "github.copilot.tools.searchResults.name": "Search View Results", "github.copilot.tools.searchResults.description": "Get the results of the search view", "github.copilot.config.getSearchViewResultsSkill.enabled": "Enable the Search View Results skill and disable the corresponding tool.", - "github.copilot.tools.githubRepo.name": "Search GitHub Repository", - "github.copilot.tools.githubRepo.userDescription": "Search a GitHub repository for relevant source code snippets. You can specify a repository using `owner/repo`", + "github.copilot.tools.githubRepo.name": "Semantic Search GitHub Repository", + "github.copilot.tools.githubRepo.userDescription": "Semantic Search a GitHub repository for relevant source code snippets. You can specify a repository using `owner/repo`", + "github.copilot.tools.githubTextSearch.name": "GitHub Text Search", + "github.copilot.tools.githubTextSearch.userDescription": "Text search a GitHub repository or organization for files containing specific keywords or code patterns.", "github.copilot.config.autoFix": "Automatically fix diagnostics for edited files.", "github.copilot.config.rateLimitAutoSwitchToAuto": "Automatically switch to the Auto model and retry when you hit a per-model rate limit.", "github.copilot.tools.createNewWorkspace.userDescription": "Scaffold a new workspace in VS Code", diff --git a/extensions/copilot/script/compareStestAlternativeRuns.ts b/extensions/copilot/script/compareStestAlternativeRuns.ts index 0ef10cc5160f9..4239afe27b3ad 100644 --- a/extensions/copilot/script/compareStestAlternativeRuns.ts +++ b/extensions/copilot/script/compareStestAlternativeRuns.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-dangerous-type-assertions */ import { AssertionError } from 'assert'; import { execFile } from 'child_process'; diff --git a/extensions/copilot/script/setup/getEnv.mts b/extensions/copilot/script/setup/getEnv.mts index 32c6c56cc8973..1044e74b73f17 100644 --- a/extensions/copilot/script/setup/getEnv.mts +++ b/extensions/copilot/script/setup/getEnv.mts @@ -20,22 +20,22 @@ async function setupSecretClient(vaultUri: string) { } // Always add the Azure CLI as an option - credentialOptions.push(new AzureCliCredential({ tenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47" })); + credentialOptions.push(new AzureCliCredential({ tenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47' })); // Check if terminal is interactive, non-interactive environments can't use // InteractiveBrowserCredential and don't necessarily have access to a keychain // For SSH sessions into Azure VMs, keychain is not available, requires managed identity if (process.stdin.isTTY && !process.env.AZURE_CLIENT_ID && !process.env.CODESPACES) { - credentialOptions.push(new InteractiveBrowserCredential({ tenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47" })); + credentialOptions.push(new InteractiveBrowserCredential({ tenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47' })); } // Use DeviceCodeCredential in Codespaces if (process.env.CODESPACES) { const deviceCodeCredential = new DeviceCodeCredential({ - tenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47", + tenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47', userPromptCallback: (info) => { - console.log("To authenticate, visit:", info.verificationUri); - console.log("Enter the code:", info.userCode); + console.log('To authenticate, visit:', info.verificationUri); + console.log('Enter the code:', info.userCode); } }); credentialOptions.push(deviceCodeCredential); @@ -51,20 +51,20 @@ async function fetchSecret(secretClient: SecretClient, secretName: string): Prom } async function fetchSecrets(): Promise<{ [key: string]: string | undefined }> { - const keyVaultClient = await setupSecretClient("https://copilot-automation.vault.azure.net/"); + const keyVaultClient = await setupSecretClient('https://copilot-automation.vault.azure.net/'); const secrets: { [key: string]: string | undefined } = {}; - secrets["HMAC_SECRET"] = await fetchSecret(keyVaultClient, "hmac-secret"); + secrets['HMAC_SECRET'] = await fetchSecret(keyVaultClient, 'hmac-secret'); if (!process.stdin.isTTY) { // only in automation - secrets["GITHUB_OAUTH_TOKEN"] = await fetchSecret(keyVaultClient, "capi-oauth"); - secrets["VSCODE_COPILOT_CHAT_TOKEN"] = await fetchSecret(keyVaultClient, "copilot-token"); - secrets["BLACKBIRD_EMBEDDINGS_KEY"] = await fetchSecret(keyVaultClient, "vsc-aoai-key"); - secrets["BLACKBIRD_REDIS_CACHE_KEY"] = await fetchSecret(keyVaultClient, "blackbird-redis-cache-key"); + secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth'); + secrets['VSCODE_COPILOT_CHAT_TOKEN'] = await fetchSecret(keyVaultClient, 'copilot-token'); + secrets['BLACKBIRD_EMBEDDINGS_KEY'] = await fetchSecret(keyVaultClient, 'vsc-aoai-key'); + secrets['BLACKBIRD_REDIS_CACHE_KEY'] = await fetchSecret(keyVaultClient, 'blackbird-redis-cache-key'); try { - secrets["ANTHROPIC_API_KEY"] = await fetchSecret(keyVaultClient, "anthropic-key"); - secrets["DEEPSEEK_API_KEY"] = await fetchSecret(keyVaultClient, "deepseek-key"); + secrets['ANTHROPIC_API_KEY'] = await fetchSecret(keyVaultClient, 'anthropic-key'); + secrets['DEEPSEEK_API_KEY'] = await fetchSecret(keyVaultClient, 'deepseek-key'); } catch (error) { console.log(red(`Failed to fetch optional evaluation tokens. Skipping...`)); } diff --git a/extensions/copilot/script/setup/getToken.mts b/extensions/copilot/script/setup/getToken.mts index 1eca497f8c0e9..6b9d82c24499e 100644 --- a/extensions/copilot/script/setup/getToken.mts +++ b/extensions/copilot/script/setup/getToken.mts @@ -46,6 +46,7 @@ async function main(): Promise { }, }; const request1 = await fetch(REQUEST1_URL, requestOptions); + // eslint-disable-next-line local/code-no-any-casts const response1 = (await request1.json()) as any; console.log(`Copy this code: ${response1.user_code}`); console.log('Then press any key to launch the authorization page, paste the code in and approve the access.'); @@ -69,6 +70,7 @@ async function main(): Promise { 'Content-Type': 'application/json', }, }; + // eslint-disable-next-line local/code-no-any-casts const response2 = (await (await fetch(REQUEST2_URL, requestOptions)).json()) as any; expiresIn -= response1.interval; await new Promise(resolve => setTimeout(resolve, 1000 * response1.interval)); diff --git a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md index c439aa682639e..4adc4bf2bc269 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md +++ b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md @@ -240,7 +240,9 @@ In multi-root and empty workspaces, a folder picker option appears in the chat s ### Session Metadata Enrichment -Each Claude session item carries metadata that drives the Sessions view UI (button visibility, status indicators). The `ClaudeChatSessionItemController._buildSessionMetadata()` method enriches session items with git repository state: +Each Claude session item carries metadata that drives the Sessions view UI (button visibility, status indicators). The `ClaudeChatSessionItemController._buildSessionMetadata()` method enriches session items with git repository state. + +**Workspace Trust:** Session metadata and git change detection are gated on workspace trust via `IWorkspaceService.isResourceTrusted()`. For untrusted working directories, `_buildSessionMetadata()` returns only the `workingDirectoryPath` (no git data), and `getWorkspaceChanges()` is skipped entirely. The trust check is resolved once in `_createClaudeChatSessionItem` and passed into `_buildSessionMetadata` to avoid redundant calls. When trusted, the metadata fetch and workspace changes fetch run concurrently via `Promise.all`. | Field | Type | Description | |-------|------|-------------| diff --git a/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md b/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md index be8d9f281e568..e938b54ff61db 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md +++ b/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md @@ -230,6 +230,8 @@ Each session in the list displays: Sessions are sorted by recency — the most recent session appears at the top. In the dedicated sidebar, they're also grouped by time period. +> **Note:** Git metadata (branch name, change stats, action buttons) and workspace change detection require the session's working directory to be in a **trusted workspace**. If the folder is untrusted, sessions still appear in the list but without git-related information or actions. + #### Git Action Buttons When a session has a git repository, action buttons appear in the Changes view based on the repository state: diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts index f4b5f0515c071..5d17bcafe68d8 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts @@ -24,7 +24,7 @@ export interface IClaudeCodeSdkService { * @param dir Workspace/project directory path (the SDK resolves this to the session storage location internally) * @returns Array of session info objects */ - listSessions(dir: string): Promise; + listSessions(dir?: string): Promise; /** * Gets detailed information for a specific session @@ -98,7 +98,7 @@ export class ClaudeCodeSdkService implements IClaudeCodeSdkService { return query(options); } - public async listSessions(dir: string): Promise { + public async listSessions(dir?: string): Promise { const { listSessions } = await this._loadSdk(); return listSessions({ dir }); } diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeCodeSessionService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeCodeSessionService.ts index 91f21d7ea340a..337a1fe647952 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeCodeSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeCodeSessionService.ts @@ -27,6 +27,7 @@ import { IWorkspaceService } from '../../../../../platform/workspace/common/work import { createServiceIdentifier } from '../../../../../util/common/services'; import { basename } from '../../../../../util/vs/base/common/resources'; import { URI } from '../../../../../util/vs/base/common/uri'; +import { IAgentSessionsWorkspace } from '../../../../chatSessions/common/agentSessionsWorkspace'; import { IFolderRepositoryManager } from '../../../../chatSessions/common/folderRepositoryManager'; import { ClaudeSessionUri } from '../../common/claudeSessionUri'; import { IClaudeCodeSdkService } from '../claudeCodeSdkService'; @@ -76,6 +77,7 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { @ILogService private readonly _logService: ILogService, @IWorkspaceService private readonly _workspace: IWorkspaceService, @IFolderRepositoryManager private readonly _folderRepositoryManager: IFolderRepositoryManager, + @IAgentSessionsWorkspace private readonly _agentSessionsWorkspace: IAgentSessionsWorkspace, ) { } /** @@ -83,6 +85,16 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { * Delegates to the SDK's `listSessions()` and converts results. */ async getAllSessions(token: CancellationToken): Promise { + if (this._agentSessionsWorkspace.isAgentSessionsWorkspace) { + try { + const sdkSessions = await this._sdkService.listSessions(); + return sdkSessions.map(sdkInfo => sdkSessionInfoToSessionInfo(sdkInfo)); + } catch (e) { + this._logService.debug(`[ClaudeCodeSessionService] Failed to list all sessions: ${e}`); + return []; + } + } + const items: IClaudeCodeSessionInfo[] = []; const projectFolders = await this._getProjectFolders(); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts index fbaa27a8c2f03..d2c22710935c6 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/test/claudeCodeSessionService.spec.ts @@ -14,7 +14,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../util/ import { CancellationToken, CancellationTokenSource } from '../../../../../../util/vs/base/common/cancellation'; import { URI } from '../../../../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation'; -import { FolderRepositoryMRUEntry, IFolderRepositoryManager } from '../../../../../chatSessions/common/folderRepositoryManager'; +import { IFolderRepositoryManager, FolderRepositoryMRUEntry } from '../../../../../chatSessions/common/folderRepositoryManager'; +import { IAgentSessionsWorkspace } from '../../../../../chatSessions/common/agentSessionsWorkspace'; import { createExtensionUnitTestingServices } from '../../../../../test/node/services'; import { IClaudeCodeSdkService } from '../../claudeCodeSdkService'; import { computeFolderSlug } from '../../claudeProjectFolders'; @@ -103,6 +104,7 @@ describe('ClaudeCodeSessionService', () => { const workspaceService = store.add(new TestWorkspaceService([folderUri])); testingServiceCollection.set(IWorkspaceService, workspaceService); testingServiceCollection.define(IFolderRepositoryManager, new MockFolderRepositoryManager()); + testingServiceCollection.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false }); const accessor = testingServiceCollection.createTestingAccessor(); mockFs = accessor.get(IFileSystemService) as MockFileSystemService; @@ -273,6 +275,54 @@ describe('ClaudeCodeSessionService', () => { expect(sessions).toHaveLength(0); }); + + describe('when in agent sessions workspace', () => { + let agentSessionsService: ClaudeCodeSessionService; + let agentSessionsSdkService: MockClaudeCodeSdkService; + + beforeEach(() => { + agentSessionsSdkService = new MockClaudeCodeSdkService(); + const sc = store.add(createExtensionUnitTestingServices(store)); + sc.set(IFileSystemService, new MockFileSystemService()); + sc.set(IClaudeCodeSdkService, agentSessionsSdkService); + sc.set(IWorkspaceService, store.add(new TestWorkspaceService([]))); + sc.define(IFolderRepositoryManager, new MockFolderRepositoryManager()); + sc.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: true }); + + agentSessionsService = sc.createTestingAccessor().get(IInstantiationService).createInstance(ClaudeCodeSessionService); + }); + + it('lists all sessions without a dir argument', async () => { + agentSessionsSdkService.mockSessions = [ + createSdkSessionInfo({ sessionId: 'global-1', summary: 'Global session' }), + createSdkSessionInfo({ sessionId: 'global-2', summary: 'Another session' }), + ]; + + const sessions = await agentSessionsService.getAllSessions(CancellationToken.None); + + expect(sessions).toHaveLength(2); + expect(sessions[0].id).toBe('global-1'); + expect(sessions[1].id).toBe('global-2'); + }); + + it('returns empty array when SDK throws', async () => { + agentSessionsSdkService.listSessions = async () => { throw new Error('SDK failure'); }; + + const sessions = await agentSessionsService.getAllSessions(CancellationToken.None); + + expect(sessions).toHaveLength(0); + }); + + it('does not set folderName on sessions', async () => { + agentSessionsSdkService.mockSessions = [ + createSdkSessionInfo({ sessionId: 'no-folder' }), + ]; + + const sessions = await agentSessionsService.getAllSessions(CancellationToken.None); + + expect(sessions[0].folderName).toBeUndefined(); + }); + }); }); // #endregion @@ -471,6 +521,7 @@ describe('ClaudeCodeSessionService', () => { const emptyWorkspaceService = store.add(new TestWorkspaceService([])); noWorkspaceTestingServiceCollection.set(IWorkspaceService, emptyWorkspaceService); noWorkspaceTestingServiceCollection.define(IFolderRepositoryManager, noWorkspaceFolderManager); + noWorkspaceTestingServiceCollection.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false }); noWorkspaceFolderManager.setMRUEntries([ { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, @@ -500,6 +551,7 @@ describe('ClaudeCodeSessionService', () => { noMruServiceCollection.set(IClaudeCodeSdkService, new MockClaudeCodeSdkService()); noMruServiceCollection.set(IWorkspaceService, store.add(new TestWorkspaceService([]))); noMruServiceCollection.define(IFolderRepositoryManager, noWorkspaceFolderManager); + noMruServiceCollection.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false }); const accessor = noMruServiceCollection.createTestingAccessor(); const noMruService = accessor.get(IInstantiationService).createInstance(ClaudeCodeSessionService); @@ -526,6 +578,7 @@ describe('ClaudeCodeSessionService', () => { multiMruServiceCollection.set(IClaudeCodeSdkService, multiSdkService); multiMruServiceCollection.set(IWorkspaceService, store.add(new TestWorkspaceService([]))); multiMruServiceCollection.define(IFolderRepositoryManager, noWorkspaceFolderManager); + multiMruServiceCollection.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false }); const accessor = multiMruServiceCollection.createTestingAccessor(); const multiMruService = accessor.get(IInstantiationService).createInstance(ClaudeCodeSessionService); @@ -553,6 +606,7 @@ describe('ClaudeCodeSessionService', () => { const multiRootWorkspaceService = store.add(new TestWorkspaceService([folder1, folder2])); multiRootTestingServiceCollection.set(IWorkspaceService, multiRootWorkspaceService); multiRootTestingServiceCollection.define(IFolderRepositoryManager, new MockFolderRepositoryManager()); + multiRootTestingServiceCollection.set(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false }); const accessor = multiRootTestingServiceCollection.createTestingAccessor(); const instaService = accessor.get(IInstantiationService); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/mockClaudeCodeSdkService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/mockClaudeCodeSdkService.ts index 96122ca5902a0..d30cbf4799905 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/mockClaudeCodeSdkService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/mockClaudeCodeSdkService.ts @@ -33,7 +33,7 @@ export class MockClaudeCodeSdkService implements IClaudeCodeSdkService { return this.createMockQuery(options.prompt); } - public async listSessions(dir: string): Promise { + public async listSessions(dir?: string): Promise { return this.mockSessions; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 49ed9d73bf902..6f646fe310d0f 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -49,6 +49,7 @@ import { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor'; import { CopilotCLISession, ICopilotCLISession } from './copilotcliSession'; import { ICopilotCLISkills } from './copilotCLISkills'; import { ICopilotCLIMCPHandler, McpServerMappings, remapCustomAgentTools } from './mcpHandler'; +import { INTEGRATION_ID } from '../../../../platform/endpoint/common/licenseAgreement'; const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile'; @@ -70,7 +71,7 @@ export type ISessionOptions = { mcpServerMappings?: McpServerMappings; additionalWorkspaces?: IWorkspaceInfo[]; sessionParentId?: string; -} +}; export type IGetSessionOptions = ISessionOptions & { sessionId: string }; export type ICreateSessionOptions = ISessionOptions & { sessionId?: string }; @@ -690,6 +691,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const allOptions: SessionOptions = { clientName: 'vscode', + integrationId: INTEGRATION_ID }; const workingDirectory = getWorkingDirectory(options.workspace); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index d73ca8676c824..efcd971492a9e 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -688,7 +688,7 @@ export class ClaudeChatSessionItemController extends Disposable { item.timing = { ...item.timing, lastRequestEnded: Date.now() }; } const session = await this._claudeCodeSessionService.getSession(resource, CancellationToken.None); - if (session?.cwd) { + if (session?.cwd && await this._workspaceService.isResourceTrusted(URI.file(session.cwd))) { item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges( session.cwd, session.gitBranch, @@ -734,12 +734,21 @@ export class ClaudeChatSessionItemController extends Disposable { }; item.iconPath = new vscode.ThemeIcon('claude'); if (session.cwd) { - item.metadata = await this._buildSessionMetadata(session.cwd); - item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges( - session.cwd, - session.gitBranch, - undefined, - ); + const isTrusted = await this._workspaceService.isResourceTrusted(URI.file(session.cwd)); + if (isTrusted) { + const [metadata, changes] = await Promise.all([ + this._buildSessionMetadata(session.cwd, isTrusted), + this._claudeWorkspaceFolderService.getWorkspaceChanges( + session.cwd, + session.gitBranch, + undefined, + ), + ]); + item.metadata = metadata; + item.changes = changes; + } else { + item.metadata = await this._buildSessionMetadata(session.cwd, isTrusted); + } } return item; } @@ -759,8 +768,13 @@ export class ClaudeChatSessionItemController extends Disposable { return repositories.length > 1; } - private async _buildSessionMetadata(cwd: string): Promise { - const repoContext = await this._gitService.getRepository(URI.file(cwd)); + private async _buildSessionMetadata(cwd: string, isTrusted?: boolean): Promise { + const cwdUri = URI.file(cwd); + if (!(isTrusted ?? await this._workspaceService.isResourceTrusted(cwdUri))) { + return { workingDirectoryPath: cwd }; + } + + const repoContext = await this._gitService.getRepository(cwdUri); if (!repoContext) { return { workingDirectoryPath: cwd }; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts index 95af5fe2bbb2a..1cc36660b2747 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts @@ -361,7 +361,7 @@ export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder { if (!uri) { return undefined; } - const isTrusted = await vscode.workspace.isResourceTrusted(uri); + const isTrusted = await this.workspaceService.isResourceTrusted(uri); if (!isTrusted) { return undefined; } diff --git a/extensions/copilot/src/extension/common/modelContextProtocol.ts b/extensions/copilot/src/extension/common/modelContextProtocol.ts index 4d05d046a4c8a..fc1d33efea7df 100644 --- a/extensions/copilot/src/extension/common/modelContextProtocol.ts +++ b/extensions/copilot/src/extension/common/modelContextProtocol.ts @@ -2,8 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ //#region proposals /** diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts index 4c7dc372c8418..42d89a81c22d5 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/NVIDIA/cuda-cpp-grammar/blob/master/syntaxes/cuda-cpp.tmLanguage.json diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts index 1dfb59d1eb21e..26d6d0624311a 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/microsoft/TypeScript-TmLanguage/blob/master/TypeScriptReact.tmLanguage diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts index dfa4c24e3cb37..de4f7508f8d94 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/jlelong/vscode-latex-basics/blob/master/syntaxes/markdown-latex-combined.tmLanguage.json diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts index 999bb5ecad503..57235421a48a8 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/trond-snekvik/vscode-rst/blob/master/syntaxes/rst.tmLanguage.json diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts index b2eedee3c140a..a92ae657a1e03 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts @@ -116,6 +116,7 @@ export async function withInMemoryTelemetry( reporters.setReporter(reporter); reporters.setEnhancedReporter(enhancedReporter); const result = await work(accessor); + // eslint-disable-next-line local/code-no-accessor-after-await const queue = accessor.get(ICompletionsPromiseQueueService) as TestPromiseQueue; await queue.awaitPromises(); diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts index 3cb27c243d3c5..12e4472ef96a1 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts @@ -753,6 +753,7 @@ class IdleDetector { if (isFirstSelectionRun) { isFirstSelectionRun = false; for (const doc of docs) { + // eslint-disable-next-line local/code-no-observable-get-in-reactive-context this._selectionSnapshots.set(doc.id.uri, doc.primarySelectionLine.get()); } return; @@ -770,6 +771,7 @@ class IdleDetector { // Find the doc whose selection line actually changed from what we last saw for (const doc of docs) { const currentDocId = doc.id.uri; + // eslint-disable-next-line local/code-no-observable-get-in-reactive-context const currentLine = doc.primarySelectionLine.get(); const previousLine = this._selectionSnapshots.get(currentDocId); diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index bd6186c9d6965..4a522bcdb2839 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -389,7 +389,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I @ILogService private readonly logService: ILogService, @IExperimentationService private readonly expService: IExperimentationService, @IAutomodeService private readonly automodeService: IAutomodeService, - @IOTelService override readonly otelService: IOTelService, + @IOTelService protected override readonly otelService: IOTelService, @ISessionTranscriptService private readonly sessionTranscriptService: ISessionTranscriptService, ) { super(intent, location, endpoint, request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, otelService); diff --git a/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts b/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts index 9810e9008c68e..8d72c14d1dbbd 100644 --- a/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts +++ b/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts @@ -178,6 +178,7 @@ export class NewNotebookResponseProcessor { const sourceLines = filterFilePathFromCodeBlock2(streamLines(sourceStream.asyncIterable) .filter(LineFilters.createCodeBlockFilter()) .map(line => { + // eslint-disable-next-line local/code-no-unused-expressions newNotebook.value; // force the notebook to be created return line; })); diff --git a/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts b/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts index 6c6f62552a929..3af61b2732316 100644 --- a/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts +++ b/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-unused-expressions */ import { SinonStub, stub } from 'sinon'; diff --git a/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts b/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts index 493aafc980f33..86180d4cc74cc 100644 --- a/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts +++ b/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - +/* eslint-disable local/code-no-unused-expressions */ import { describe, expect, it } from 'vitest'; import type { ChatResult } from 'vscode'; import { ChatVariablesCollection } from '../../common/chatVariablesCollection'; diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_non_edit_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_non_edit_tools.spec.snap index bcb792fd42c92..afbfef22b3053 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_non_edit_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_non_edit_tools.spec.snap @@ -91,6 +91,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_tools.spec.snap index 85814394368b2..4743542bdf0ff 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.5/all_tools.spec.snap @@ -90,6 +90,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_non_edit_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_non_edit_tools.spec.snap index a22d84d1ebe64..3ba1c3a97d206 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_non_edit_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_non_edit_tools.spec.snap @@ -73,6 +73,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_tools.spec.snap index 0aa2cd1657c72..6c10dbbdb81b9 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-opus-4.6/all_tools.spec.snap @@ -73,6 +73,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_non_edit_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_non_edit_tools.spec.snap index bcb792fd42c92..afbfef22b3053 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_non_edit_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_non_edit_tools.spec.snap @@ -91,6 +91,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_tools.spec.snap index 85814394368b2..4743542bdf0ff 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.5/all_tools.spec.snap @@ -90,6 +90,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_non_edit_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_non_edit_tools.spec.snap index ef8381871d947..513c41537c9f8 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_non_edit_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_non_edit_tools.spec.snap @@ -73,6 +73,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_tools.spec.snap b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_tools.spec.snap index 8cc8f00a5689a..8f118188a401c 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_tools.spec.snap +++ b/extensions/copilot/src/extension/prompts/node/agent/test/__snapshots__/agentPrompts-claude-sonnet-4.6/all_tools.spec.snap @@ -73,6 +73,7 @@ get_project_setup_info get_search_view_results get_vscode_api github_repo +github_text_search install_extension read_notebook_cell_output read_project_structure diff --git a/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx b/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx index 637902f654ace..2cfa7e7507bcd 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx @@ -382,6 +382,7 @@ export class StartDebuggingPrompt extends PromptElement 0 && <>Below is a list of information from the Visual Studio Code documentation which might be relevant to the question.
} {state.docSearchResults && state.docSearchResults.map((result) => { if (result?.title && result.contents) { + // eslint-disable-next-line local/code-no-unused-expressions ##{result?.title?.trim()} - {result.path}
{result.contents} diff --git a/extensions/copilot/src/extension/test/node/notebookPromptRendering.spec.ts b/extensions/copilot/src/extension/test/node/notebookPromptRendering.spec.ts index c14772c9de948..a8bff7ab9d57a 100644 --- a/extensions/copilot/src/extension/test/node/notebookPromptRendering.spec.ts +++ b/extensions/copilot/src/extension/test/node/notebookPromptRendering.spec.ts @@ -135,6 +135,9 @@ describe('Notebook Prompt Rendering', function () { override applyEdit(edit: vscode.WorkspaceEdit): Thenable { throw new Error('Method not implemented.'); } + override isResourceTrusted(_resource: vscode.Uri): Thenable { + return Promise.resolve(true); + } override requestResourceTrust(_options: vscode.ResourceTrustRequestOptions): Thenable { return Promise.resolve(true); } diff --git a/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts b/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts index 3de34aada4b08..b5e9dbfedd75a 100644 --- a/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts +++ b/extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts @@ -31,16 +31,11 @@ suite('Copilot Chat Sanity Test', function () { let realContext: vscode.ExtensionContext; let sandbox: sinon.SinonSandbox; const fakeToken = CancellationToken.None; - const sessionItemProviders = new Map(); // Before everything, activate the extension suiteSetup(async function () { sandbox = sinon.createSandbox(); sandbox.stub(vscode.commands, 'registerCommand').returns({ dispose: () => { } }); sandbox.stub(vscode.workspace, 'registerFileSystemProvider').returns({ dispose: () => { } }); - sandbox.stub(vscode.chat, 'registerChatSessionItemProvider').callsFake((scheme, sessionItemProvider) => { - sessionItemProviders.set(scheme, sessionItemProvider); - return { dispose: () => { } }; - }); const extension = vscode.extensions.getExtension('Github.copilot-chat'); assert.ok(extension, 'Extension is not available'); realContext = await extension.activate(); @@ -163,24 +158,6 @@ suite('Copilot Chat Sanity Test', function () { }); }); - test('Copilot CLI lists sessions', async function () { - assert.ok(realInstaAccessor); - - await realInstaAccessor.invokeFunction(async (accessor) => { - - const instaService = accessor.get(IInstantiationService); - const conversationFeature = instaService.createInstance(ConversationFeature); - try { - conversationFeature.activated = true; - const provider = sessionItemProviders.get('copilotcli'); - assert.ok(provider); - await provider.provideChatSessionItems(CancellationToken.None); - } finally { - conversationFeature.activated = false; - } - }); - }); - test.skip('E2E Production Inline Chat Test', async function () { assert.ok(realInstaAccessor); diff --git a/extensions/copilot/src/extension/tools/common/toolNames.ts b/extensions/copilot/src/extension/tools/common/toolNames.ts index d6db8f32bc2be..412c1e50e0914 100644 --- a/extensions/copilot/src/extension/tools/common/toolNames.ts +++ b/extensions/copilot/src/extension/tools/common/toolNames.ts @@ -47,7 +47,8 @@ export enum ToolName { FindTestFiles = 'test_search', GetProjectSetupInfo = 'get_project_setup_info', SearchViewResults = 'get_search_view_results', - GithubRepo = 'github_repo', + GithubSemanticRepoSearch = 'github_repo', + GithubTextSearch = 'github_text_search', CreateDirectory = 'create_directory', RunVscodeCmd = 'run_vscode_command', CoreManageTodoList = 'manage_todo_list', @@ -132,7 +133,8 @@ export enum ContributedToolName { FindTestFiles = 'copilot_findTestFiles', GetProjectSetupInfo = 'copilot_getProjectSetupInfo', SearchViewResults = 'copilot_getSearchResults', - GithubRepo = 'copilot_githubRepo', + GithubSemanticRepoSearch = 'copilot_githubRepo', + GithubTextSearch = 'copilot_githubTextSearch', CreateAndRunTask = 'copilot_createAndRunTask', CreateDirectory = 'copilot_createDirectory', RunVscodeCmd = 'copilot_runVscodeCommand', @@ -223,7 +225,8 @@ export const toolCategories: Record = { // Web Interaction [ToolName.FetchWebPage]: ToolCategory.WebInteraction, - [ToolName.GithubRepo]: ToolCategory.WebInteraction, + [ToolName.GithubSemanticRepoSearch]: ToolCategory.WebInteraction, + [ToolName.GithubTextSearch]: ToolCategory.WebInteraction, [ToolName.CoreOpenBrowserPage]: ToolCategory.WebInteraction, [ToolName.CoreClickElement]: ToolCategory.WebInteraction, [ToolName.CoreScreenshotPage]: ToolCategory.WebInteraction, diff --git a/extensions/copilot/src/extension/tools/node/allTools.ts b/extensions/copilot/src/extension/tools/node/allTools.ts index 22d768a4b8488..8f1170fb9f182 100644 --- a/extensions/copilot/src/extension/tools/node/allTools.ts +++ b/extensions/copilot/src/extension/tools/node/allTools.ts @@ -15,7 +15,8 @@ import './findTextInFilesTool'; import './getErrorsTool'; import './getNotebookCellOutputTool'; import './getSearchViewResultsTool'; -import './githubRepoTool'; +import './githubRepoSemanticSearchTool.tsx'; +import './githubTextSearchTool'; import './insertEditTool'; import './installExtensionTool'; import './listDirTool'; diff --git a/extensions/copilot/src/extension/tools/node/githubRepoTool.tsx b/extensions/copilot/src/extension/tools/node/githubRepoSemanticSearchTool.tsx similarity index 91% rename from extensions/copilot/src/extension/tools/node/githubRepoTool.tsx rename to extensions/copilot/src/extension/tools/node/githubRepoSemanticSearchTool.tsx index 09c83d6022ec0..2b17061d3717a 100644 --- a/extensions/copilot/src/extension/tools/node/githubRepoTool.tsx +++ b/extensions/copilot/src/extension/tools/node/githubRepoSemanticSearchTool.tsx @@ -38,9 +38,8 @@ interface PrepareError { readonly details?: string; } -export class GithubRepoTool implements ICopilotTool { - public static readonly toolName = ToolName.GithubRepo; - +export class GithubRepoSemanticSearchTool implements ICopilotTool { + public static readonly toolName = ToolName.GithubSemanticRepoSearch; constructor( @IRunCommandExecutionService _commandService: IRunCommandExecutionService, @@ -61,14 +60,15 @@ export class GithubRepoTool implements ICopilotTool { throw new Error('No embedding models available'); } - const searchResults = await this._githubCodeSearch.searchRepo({ silent: true }, embeddingType, { githubRepoId, localRepoRoot: undefined, indexedCommit: undefined }, options.input.query, 64, {}, new TelemetryCorrelationId('github-repo-tool'), token); + const searchResults = await this._githubCodeSearch.semanticSearch({ silent: true }, embeddingType, { kind: 'repo', githubRepoId, localRepoRoot: undefined, indexedCommit: undefined }, options.input.query, 64, {}, new TelemetryCorrelationId('github-repo-tool'), token); - // Map the chunks to URIs - // TODO: Won't work for proxima or branches not called main + // Map the chunks to URIs using the remote URL and ref from the search response + const repoBaseUrl = searchResults.remoteUrl ?? `https://github.com/${toGithubNwo(githubRepoId)}`; + const ref = searchResults.refName ?? 'main'; const chunks = searchResults.chunks.map((entry): FileChunkAndScore => ({ chunk: { ...entry.chunk, - file: URI.joinPath(URI.parse('https://github.com'), toGithubNwo(githubRepoId), 'tree', 'main', entry.chunk.file.path).with({ + file: URI.joinPath(URI.parse(repoBaseUrl), 'tree', ref, entry.chunk.file.path).with({ fragment: `L${entry.chunk.range.startLineNumber}-L${entry.chunk.range.endLineNumber}`, }), }, @@ -229,4 +229,4 @@ class GithubChunkSearchResults extends PromptElement { + public static readonly toolName = ToolName.GithubTextSearch; + + constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IGithubCodeSearchService private readonly _githubCodeSearch: IGithubCodeSearchService, + ) { } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken): Promise { + const scope = parseScope(options.input.scope); + if (!scope) { + throw new Error(l10n.t`Invalid input. Could not parse 'scope' argument`); + } + + const maxResults = options.input.maxResults ?? 100; + + const searchResults = await this._githubCodeSearch.lexicalSearch( + { silent: true }, + scope, + options.input.query, + maxResults, + {}, + new TelemetryCorrelationId('github-text-search-tool'), + token, + ); + + const chunks = searchResults.chunks.map((entry): FileChunkAndScore => { + let file = entry.file; + if (file.scheme === 'githubRepoResult') { + // Path format: /owner/repo/relative/file/path + const parts = file.path.split('/').filter(Boolean); + if (parts.length >= 3) { + const nwo = `${parts[0]}/${parts[1]}`; + const relativePath = parts.slice(2).join('/'); + file = URI.joinPath(URI.parse('https://github.com'), nwo, 'tree', 'main', '/' + relativePath).with({ + fragment: entry.range.startLineNumber > 0 + ? `L${entry.range.startLineNumber}-L${entry.range.endLineNumber}` + : undefined, + }); + } + } + return { chunk: { ...entry, file }, distance: undefined }; + }); + + let references: PromptReference[] = []; + const json = await renderPromptElementJSON(this._instantiationService, GithubTextSearchResults, { + chunks, + referencesOut: references, + }); + const result = new ExtendedLanguageModelToolResult([ + new LanguageModelPromptTsxPart(json), + ]); + + references = getUniqueReferences(references); + const scopeLabel = options.input.scope; + result.toolResultMessage = references.length === 0 ? + new MarkdownString(l10n.t`Searched ${scopeLabel} for "${options.input.query}", no results`) : + references.length === 1 ? + new MarkdownString(l10n.t`Searched ${scopeLabel} for "${options.input.query}", 1 result`) : + new MarkdownString(l10n.t`Searched ${scopeLabel} for "${options.input.query}", ${references.length} results`); + result.toolResultDetails = references + .map(r => r.anchor) + .filter(r => isUri(r) || isLocation(r)); + return result; + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions, _token: vscode.CancellationToken): Promise { + if (!options.input.scope) { + throw new Error(l10n.t`Invalid input. No 'scope' argument provided`); + } + if (!parseScope(options.input.scope)) { + throw new Error(l10n.t`Invalid input. Could not parse 'scope' argument`); + } + return { + invocationMessage: l10n.t("Searching '{0}' for '{1}'", options.input.scope, options.input.query), + }; + } +} + +function parseScope(scope: string): GithubCodeSearchScope | undefined { + if (!scope) { + return undefined; + } + if (scope.includes('/')) { + const repoId = GithubRepoId.parse(scope); + if (!repoId) { + return undefined; + } + return { kind: 'repo', githubRepoId: repoId, localRepoRoot: undefined, indexedCommit: undefined }; + } + + return { kind: 'org', org: scope }; +} + +interface GithubTextSearchResultsProps extends BasePromptElementProps { + readonly chunks: FileChunkAndScore[]; + readonly referencesOut: PromptReference[]; +} + +class GithubTextSearchResults extends PromptElement { + override render(_state: void, _sizing: PromptSizing): PromptPiece | undefined { + const references: PromptReference[] = []; + const seenFiles = new Set(); + + const renderedChunks = this.props.chunks + .filter(x => x.chunk.text) + .map(chunk => { + const fileKey = chunk.chunk.file.toString(); + if (!seenFiles.has(fileKey)) { + seenFiles.add(fileKey); + references.push(new PromptReference(chunk.chunk.file)); + } + + const githubInfo = parseGithubFileUrl(chunk.chunk.file); + const displayPath = githubInfo?.path ?? chunk.chunk.file.toString(); + const nwoLabel = githubInfo?.nwo; + + const lineInfo = ` starting at line ${chunk.chunk.range.startLineNumber}`; + + const headerText = nwoLabel + ? `Text match excerpt from \`${nwoLabel}\` in \`${displayPath}\`${lineInfo}:` + : `Text match excerpt in \`${displayPath}\`${lineInfo}:`; + + return + {headerText}
+ {createFencedCodeBlock(getLanguageId(chunk.chunk.file), chunk.chunk.text)}

+
; + }); + + this.props.referencesOut.push(...references); + + return <> + + {renderedChunks} + ; + } +} + +function parseGithubFileUrl(uri: URI): { nwo: string; path: string } | undefined { + if (uri.scheme === 'https' && uri.authority === 'github.com') { + const parts = uri.path.split('/').filter(Boolean); + // Pattern: /owner/repo/tree/branch/...path + if (parts.length >= 4 && parts[2] === 'tree') { + return { + nwo: `${parts[0]}/${parts[1]}`, + path: parts.slice(4).join('/'), + }; + } + } + return undefined; +} + +ToolRegistry.registerTool(GithubTextSearchTool); diff --git a/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx b/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx index c05af74e1f25d..c1350fe1c3ceb 100644 --- a/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx +++ b/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx @@ -24,6 +24,7 @@ export async function renderElementToString(accessor: ServicesAccessor, element: }; const endpoint = await accessor.get(IEndpointProvider).getChatEndpoint('copilot-base'); + // eslint-disable-next-line local/code-no-accessor-after-await const renderer = PromptRenderer.create(accessor.get(IInstantiationService), endpoint, clz, {}); const r = await renderer.render(); diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts index 7f48633ba47c0..ada79c3660b1d 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts @@ -1397,6 +1397,7 @@ export class LanguageContextServiceImpl implements ILanguageContextService, vsco } contextItemResult.updateResponse(body, token); this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, timeTaken, { before: cacheState, after: this.runnableResultManager.getCacheState() }, undefined); + // eslint-disable-next-line local/code-no-unused-expressions isDebugging && forDebugging?.length; this._onCachePopulated.fire({ document, position, source: context.source, items: resolved, summary: contextItemResult }); } else if (protocol.ComputeContextResponse.isError(response)) { @@ -1524,6 +1525,7 @@ export class LanguageContextServiceImpl implements ILanguageContextService, vsco document, position, context, contextItemResult, Date.now() - startTime, { before: cacheState, after: cacheState }, cacheRequest ); + // eslint-disable-next-line local/code-no-unused-expressions isDebugging && forDebugging?.length; this._onContextComputed.fire({ document, position, source: context.source, items: itemsToYield, summary: contextItemResult diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 4b81d0a95cdfd..f440a451d2854 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -621,7 +621,7 @@ export namespace ConfigKey { export const CLIBranchSupport = defineSetting('chat.cli.branchSupport.enabled', ConfigType.Simple, false); export const CLIIsolationOption = defineSetting('chat.cli.isolationOption.enabled', ConfigType.Simple, true); export const CLIAutoCommitEnabled = defineSetting('chat.cli.autoCommit.enabled', ConfigType.Simple, true); - export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, false); + export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, true); export const CLIThinkingEffortEnabled = defineSetting('chat.cli.thinkingEffort.enabled', ConfigType.Simple, true); export const CLIRemoteEnabled = defineSetting('chat.cli.remote.enabled', ConfigType.Simple, false); export const CLISessionControllerForSessionsApp = defineSetting('chat.cli.sessionControllerForSessionsApp.enabled', ConfigType.Simple, false); diff --git a/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts b/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts index e06e47eba7d1a..48a119bb67ccc 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable import/no-restricted-paths */ - import type { ChatRequest, LanguageModelChat } from 'vscode'; import { CacheableRequest, SQLiteCache } from '../../../../../test/base/cache'; import { TestingCacheSalts } from '../../../../../test/base/salts'; diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts index ccee88711f1e8..690e15a308bab 100644 --- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts +++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts @@ -447,6 +447,7 @@ export class GitServiceImpl extends Disposable implements IGitService { onDidChangeStateSignal.read(reader); const selected = selectedObs.read(reader); + // eslint-disable-next-line local/code-no-observable-get-in-reactive-context const activeRepository = this.activeRepository.get(); if (activeRepository && !selected && !isEqual(activeRepository.rootUri, repository.rootUri)) { return; diff --git a/extensions/copilot/src/platform/github/common/githubAPI.ts b/extensions/copilot/src/platform/github/common/githubAPI.ts index dcd7b8cafc260..86b9893e182d2 100644 --- a/extensions/copilot/src/platform/github/common/githubAPI.ts +++ b/extensions/copilot/src/platform/github/common/githubAPI.ts @@ -133,6 +133,8 @@ export interface GitHubAPIRequestOptions { version?: string; type?: 'json' | 'text'; userAgent?: string; + accept?: string; + additionalHeaders?: { [key: string]: string }; returnStatusCodeOnError?: boolean; silent404?: boolean; callSite?: string; @@ -147,9 +149,10 @@ export async function makeGitHubAPIRequest( method: 'GET' | 'POST', token: string | undefined, options?: GitHubAPIRequestOptions) { - const { body, version, type = 'json', userAgent, returnStatusCodeOnError = false, silent404 = false, callSite = 'github-api-rest' } = options ?? {}; + const { body, version, type = 'json', userAgent, accept, additionalHeaders, returnStatusCodeOnError = false, silent404 = false, callSite = 'github-api-rest' } = options ?? {}; const headers: { [key: string]: string } = { - 'Accept': 'application/vnd.github+json', + 'Accept': accept ?? 'application/vnd.github+json', + ...additionalHeaders, }; if (token) { headers['Authorization'] = `Bearer ${token}`; diff --git a/extensions/copilot/src/platform/ignore/node/test/mockWorkspaceService.ts b/extensions/copilot/src/platform/ignore/node/test/mockWorkspaceService.ts index 3bee106f3c8aa..3db4e3b321f79 100644 --- a/extensions/copilot/src/platform/ignore/node/test/mockWorkspaceService.ts +++ b/extensions/copilot/src/platform/ignore/node/test/mockWorkspaceService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { FileSystem, NotebookData, NotebookDocument, NotebookDocumentChangeEvent, ResourceTrustRequestOptions, TextDocument, TextDocumentChangeEvent, TextEditorSelectionChangeEvent, WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersChangeEvent, WorkspaceTrustRequestOptions } from 'vscode'; +import type { FileSystem, NotebookData, NotebookDocument, NotebookDocumentChangeEvent, ResourceTrustRequestOptions, TextDocument, TextDocumentChangeEvent, TextEditorSelectionChangeEvent, Uri, WorkspaceEdit, WorkspaceFolder, WorkspaceFoldersChangeEvent, WorkspaceTrustRequestOptions } from 'vscode'; import { Event } from '../../../../util/vs/base/common/event'; import { URI } from '../../../../util/vs/base/common/uri'; import { NotebookDocumentSnapshot } from '../../../editing/common/notebookDocumentSnapshot'; @@ -87,6 +87,10 @@ export class MockWorkspaceService implements IWorkspaceService { return Promise.resolve(); } + isResourceTrusted(_resource: Uri): Thenable { + return Promise.resolve(true); + } + requestResourceTrust(_options: ResourceTrustRequestOptions): Thenable { return Promise.resolve(true); } diff --git a/extensions/copilot/src/platform/promptFiles/common/promptsService.ts b/extensions/copilot/src/platform/promptFiles/common/promptsService.ts index b2494cb03502c..40c585ba2e5ff 100644 --- a/extensions/copilot/src/platform/promptFiles/common/promptsService.ts +++ b/extensions/copilot/src/platform/promptFiles/common/promptsService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill } from 'vscode'; +import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill, ChatSlashCommand } from 'vscode'; import { createServiceIdentifier } from '../../../util/common/services'; import { Event } from '../../../util/vs/base/common/event'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; @@ -46,7 +46,7 @@ export interface IPromptsService { * Returns the slash command prompt files. These are prompts and skills * from all sources (workspace, user, and extension-provided). */ - getSlashCommands(token: CancellationToken): Promise; + getSlashCommands(token: CancellationToken): Promise; /** * An event that fires when the list of {@link instructions instructions} changes. diff --git a/extensions/copilot/src/platform/promptFiles/test/common/mockPromptsService.ts b/extensions/copilot/src/platform/promptFiles/test/common/mockPromptsService.ts index 61308268ce3b6..2063c60db0969 100644 --- a/extensions/copilot/src/platform/promptFiles/test/common/mockPromptsService.ts +++ b/extensions/copilot/src/platform/promptFiles/test/common/mockPromptsService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill } from 'vscode'; +import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill, ChatSlashCommand } from 'vscode'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { Emitter, Event } from '../../../../util/vs/base/common/event'; import { Disposable } from '../../../../util/vs/base/common/lifecycle'; @@ -31,7 +31,7 @@ export class MockPromptsService extends Disposable implements IPromptsService { readonly onDidChangePlugins: Event = this._onDidChangePlugins.event; private _customAgents: readonly ChatCustomAgent[] = []; - private _slashCommands: readonly ParsedPromptFile[] = []; + private _slashCommands: readonly ChatSlashCommand[] = []; private _instructions: readonly ChatInstruction[] = []; private _skills: readonly ChatSkill[] = []; private _hooks: readonly ChatHook[] = []; @@ -47,7 +47,7 @@ export class MockPromptsService extends Disposable implements IPromptsService { this._onDidChangeCustomAgents.fire(); } - setSlashCommands(commands: readonly ParsedPromptFile[]): void { + setSlashCommands(commands: readonly ChatSlashCommand[]): void { this._slashCommands = commands; } @@ -87,7 +87,7 @@ export class MockPromptsService extends Disposable implements IPromptsService { return Promise.resolve(this._customAgents); } - getSlashCommands(_token: CancellationToken): Promise { + getSlashCommands(_token: CancellationToken): Promise { return Promise.resolve(this._slashCommands); } diff --git a/extensions/copilot/src/platform/promptFiles/vscode/promptsServiceImpl.ts b/extensions/copilot/src/platform/promptFiles/vscode/promptsServiceImpl.ts index f0c6ff8c03b46..368177afac887 100644 --- a/extensions/copilot/src/platform/promptFiles/vscode/promptsServiceImpl.ts +++ b/extensions/copilot/src/platform/promptFiles/vscode/promptsServiceImpl.ts @@ -52,7 +52,7 @@ export class PromptsServiceImpl extends Disposable implements IPromptsService { return Promise.resolve(vscode.chat.getCustomAgents(token)); } - getSlashCommands(token: CancellationToken): Promise { + getSlashCommands(token: CancellationToken): Promise { return Promise.resolve(vscode.chat.getSlashCommands(token)); } diff --git a/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts b/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts index 7fe8f22869c20..9df8122ddee5e 100644 --- a/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts +++ b/extensions/copilot/src/platform/remoteCodeSearch/common/adoCodeSearchService.ts @@ -26,7 +26,7 @@ import { measureExecTime } from '../../log/common/logExecTime'; import { ILogService } from '../../log/common/logService'; import { getRequest, postRequest } from '../../networking/common/networking'; import { ITelemetryService } from '../../telemetry/common/telemetry'; -import { CodeSearchOptions, CodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus } from './remoteCodeSearch'; +import { CodeSearchOptions, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus, SemanticCodeSearchResult } from './remoteCodeSearch'; interface ResponseShape { @@ -100,7 +100,7 @@ export interface IAdoCodeSearchService { options: CodeSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken, - ): Promise; + ): Promise; } /** @@ -251,7 +251,7 @@ export class AdoCodeSearchService extends Disposable implements IAdoCodeSearchSe options: CodeSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken - ): Promise { + ): Promise { const totalSw = new StopWatch(); const authToken = await this.getAdoAuthToken(auth.silent); diff --git a/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts b/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts index 9ecdcc7041ec2..52b87af651ce5 100644 --- a/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts +++ b/extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts @@ -13,19 +13,20 @@ import { URI } from '../../../util/vs/base/common/uri'; import { Range } from '../../../util/vs/editor/common/core/range'; import { createDecorator, IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { IAuthenticationService } from '../../authentication/common/authentication'; -import { FileChunkAndScore } from '../../chunking/common/chunk'; +import { FileChunk, FileChunkAndScore } from '../../chunking/common/chunk'; import { stripChunkTextMetadata, truncateToMaxUtf8Length } from '../../chunking/common/chunkingStringUtils'; import { EmbeddingType } from '../../embeddings/common/embeddingsComputer'; import { ICAPIClientService } from '../../endpoint/common/capiClient'; import { IEnvService } from '../../env/common/envService'; import { GithubRepoId, toGithubNwo } from '../../git/common/gitService'; +import { makeGitHubAPIRequest } from '../../github/common/githubAPI'; import { getGithubMetadataHeaders } from '../../github/common/githubApiFetcherService'; import { IIgnoreService } from '../../ignore/common/ignoreService'; import { ILogService } from '../../log/common/logService'; -import { Response } from '../../networking/common/fetcherService'; +import { IFetcherService, Response } from '../../networking/common/fetcherService'; import { postRequest } from '../../networking/common/networking'; import { ITelemetryService } from '../../telemetry/common/telemetry'; -import { CodeSearchOptions, CodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus } from './remoteCodeSearch'; +import { CodeSearchOptions, LexicalCodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus, SemanticCodeSearchResult } from './remoteCodeSearch'; interface ResponseShape { @@ -46,6 +47,7 @@ type SemanticSearchResult = { location: { path: string; // file path commit_sha: string; + ref_name: string; repo: { nwo: string; url: string; @@ -54,11 +56,19 @@ type SemanticSearchResult = { }; export interface GithubCodeSearchRepoInfo { + readonly kind: 'repo'; readonly githubRepoId: GithubRepoId; readonly localRepoRoot: URI | undefined; readonly indexedCommit: string | undefined; } +export interface GithubCodeSearchOrgInfo { + readonly kind: 'org'; + readonly org: string; +} + +export type GithubCodeSearchScope = GithubCodeSearchRepoInfo | GithubCodeSearchOrgInfo; + export const IGithubCodeSearchService = createDecorator('IGithubCodeSearchService'); export interface IGithubCodeSearchService { @@ -89,16 +99,29 @@ export interface IGithubCodeSearchService { * * The repo must have been indexed first. Make sure to check {@link getRemoteIndexState} or call {@link triggerIndexing}. */ - searchRepo( + semanticSearch( authOptions: { readonly silent: boolean }, embeddingType: EmbeddingType, - repo: GithubCodeSearchRepoInfo, + scope: GithubCodeSearchRepoInfo, + query: string, + maxResults: number, + options: CodeSearchOptions, + telemetryInfo: TelemetryCorrelationId, + token: CancellationToken, + ): Promise; + + /** + * Lexical searches a given github repo or org for relevant code snippets + */ + lexicalSearch( + authOptions: { readonly silent: boolean }, + scope: GithubCodeSearchScope, query: string, maxResults: number, options: CodeSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken, - ): Promise; + ): Promise; } export class GithubCodeSearchService implements IGithubCodeSearchService { @@ -109,6 +132,7 @@ export class GithubCodeSearchService implements IGithubCodeSearchService { @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @ICAPIClientService private readonly _capiClientService: ICAPIClientService, @IEnvService private readonly _envService: IEnvService, + @IFetcherService private readonly _fetcherService: IFetcherService, @IIgnoreService private readonly _ignoreService: IIgnoreService, @ILogService private readonly _logService: ILogService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @@ -252,7 +276,7 @@ export class GithubCodeSearchService implements IGithubCodeSearchService { return Result.ok(true); } - async searchRepo( + async semanticSearch( auth: { readonly silent: boolean }, embeddingType: EmbeddingType, repo: GithubCodeSearchRepoInfo, @@ -261,7 +285,7 @@ export class GithubCodeSearchService implements IGithubCodeSearchService { options: CodeSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken - ): Promise { + ): Promise { const authToken = await this.getGithubAccessToken(auth.silent); if (!authToken) { throw new Error('No valid auth token'); @@ -342,6 +366,80 @@ export class GithubCodeSearchService implements IGithubCodeSearchService { return result; } + async lexicalSearch( + auth: { readonly silent: boolean }, + scope: GithubCodeSearchScope, + query: string, + maxResults: number, + options: CodeSearchOptions, + telemetryInfo: TelemetryCorrelationId, + token: CancellationToken + ): Promise { + const authToken = await this.getGithubAccessToken(auth.silent); + if (!authToken) { + throw new Error('No valid auth token'); + } + + const scopeQualifier = scope.kind === 'org' ? `org:${scope.org}` : `repo:${toGithubNwo(scope.githubRepoId)}`; + const searchQuery = `${query} ${scopeQualifier}`; + const routeSlug = `search/code?q=${encodeURIComponent(searchQuery)}&per_page=${maxResults}`; + + const body = await raceCancellationError(makeGitHubAPIRequest( + this._fetcherService, + this._logService, + this._telemetryService, + this._capiClientService.dotcomAPIURL, + routeSlug, + 'GET', + authToken, + { + accept: 'application/vnd.github.text-match+json', + additionalHeaders: getGithubMetadataHeaders(telemetryInfo.callTracker, this._envService), + callSite: 'github-code-search-lexical', + }, + ), token); + + if (!body) { + /* __GDPR__ + "githubCodeSearch.lexicalSearch.error" : { + "owner": "mjbvz", + "comment": "Information about failed lexical code searches", + "workspaceSearchSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Caller of the search" }, + "workspaceSearchCorrelationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Correlation id for the search" } + } + */ + this._telemetryService.sendMSFTTelemetryEvent('githubCodeSearch.lexicalSearch.error', { + workspaceSearchSource: telemetryInfo.callTracker.toString(), + workspaceSearchCorrelationId: telemetryInfo.correlationId, + }); + + throw new Error(`Code search lexical search failed`); + } + if (!Array.isArray(body.items)) { + throw new Error(`Code search lexical search unexpected response json shape`); + } + + const result = await raceCancellationError(parseLexicalSearchResponse(body, scope, options, this._ignoreService), token); + + /* __GDPR__ + "githubCodeSearch.lexicalSearch.success" : { + "owner": "mjbvz", + "comment": "Information about successful lexical code searches", + "workspaceSearchSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Caller of the search" }, + "workspaceSearchCorrelationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Correlation id for the search" }, + "resultCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of returned items from the search" } + } + */ + this._telemetryService.sendMSFTTelemetryEvent('githubCodeSearch.lexicalSearch.success', { + workspaceSearchSource: telemetryInfo.callTracker.toString(), + workspaceSearchCorrelationId: telemetryInfo.correlationId, + }, { + resultCount: body.items.length, + }); + + return result; + } + private async getGithubAccessToken(silent: boolean) { return (await this._authenticationService.getGitHubSession('permissive', { silent }))?.accessToken ?? (await this._authenticationService.getGitHubSession('any', { silent }))?.accessToken; @@ -370,7 +468,7 @@ export class GithubCodeSearchService implements IGithubCodeSearchService { } } -export async function parseGithubCodeSearchResponse(body: ResponseShape, repo: GithubCodeSearchRepoInfo, options: CodeSearchOptions & { skipVerifyRepo?: boolean }, ignoreService: IIgnoreService): Promise { +export async function parseGithubCodeSearchResponse(body: ResponseShape, repo: GithubCodeSearchRepoInfo, options: CodeSearchOptions & { skipVerifyRepo?: boolean }, ignoreService: IIgnoreService): Promise { let outOfSync = false; const outChunks: FileChunkAndScore[] = []; @@ -415,5 +513,106 @@ export async function parseGithubCodeSearchResponse(body: ResponseShape, repo: G }); })); - return { chunks: outChunks, outOfSync }; + // Extract the remote URL and ref name from the first result + const firstResult = body.results[0]; + let remoteUrl: string | undefined; + let refName: string | undefined; + if (firstResult) { + // Derive the web URL from the API URL (e.g. https://api.github.com/repos/o/r -> https://github.com/o/r) + const apiUrl = firstResult.location.repo.url; + const nwo = firstResult.location.repo.nwo; + try { + const parsed = URI.parse(apiUrl); + const host = parsed.authority === 'api.github.com' ? 'github.com' : parsed.authority.replace(/^api\./, ''); + remoteUrl = `https://${host}/${nwo}`; + } catch { + // Fall back to constructing from nwo + remoteUrl = `https://github.com/${nwo}`; + } + + // Extract branch name from ref_name (e.g. "refs/heads/main" -> "main") + const rawRef = firstResult.location.ref_name; + if (rawRef?.startsWith('refs/heads/')) { + refName = rawRef.slice('refs/heads/'.length); + } else if (rawRef) { + refName = rawRef; + } + } + + return { chunks: outChunks, outOfSync, remoteUrl, refName }; +} + +interface LexicalSearchResponseShape { + readonly total_count: number; + readonly incomplete_results: boolean; + readonly items: readonly LexicalSearchItem[]; +} + +type LexicalSearchItem = { + readonly path: string; + readonly repository: { + readonly full_name: string; + }; + readonly text_matches?: readonly { + readonly fragment: string; + readonly matches: readonly { readonly text: string; readonly indices: readonly [number, number] }[]; + readonly object_type: string; + readonly property: string; + }[]; + readonly score: number; +}; + +export async function parseLexicalSearchResponse(body: LexicalSearchResponseShape, scope: GithubCodeSearchScope & { skipVerifyRepo?: boolean }, options: CodeSearchOptions & { skipVerifyRepo?: boolean }, ignoreService: IIgnoreService): Promise { + const outChunks: FileChunk[] = []; + + await Promise.all(body.items.map(async (item): Promise => { + if (!options.skipVerifyRepo && scope.kind === 'repo' && item.repository.full_name.toLowerCase() !== toGithubNwo(scope.githubRepoId)) { + return; + } + if (!options.skipVerifyRepo && scope.kind === 'org' && item.repository.full_name.toLowerCase().split('/')[0] !== scope.org.toLowerCase()) { + return; + } + + const localRepoRoot = scope.kind === 'repo' ? scope.localRepoRoot : undefined; + let fileUri: URI; + if (localRepoRoot) { + fileUri = URI.joinPath(localRepoRoot, item.path); + if (await ignoreService.isCopilotIgnored(fileUri)) { + return; + } + } else { + fileUri = URI.from({ + scheme: 'githubRepoResult', + path: '/' + item.repository.full_name + '/' + item.path + }); + } + + if (!shouldInclude(fileUri, options.globPatterns)) { + return; + } + + const textMatches = item.text_matches?.filter(m => m.property === 'content'); + if (textMatches && textMatches.length > 0) { + for (const match of textMatches) { + outChunks.push({ + file: fileUri, + text: match.fragment, + rawText: undefined, + range: new Range(0, 0, 0, 0), + isFullFile: false, + }); + } + } else { + // No text matches, include the file as a whole-file result + outChunks.push({ + file: fileUri, + text: '', + rawText: undefined, + range: new Range(0, 0, 0, 0), + isFullFile: true, + }); + } + })); + + return { chunks: outChunks, outOfSync: false }; } diff --git a/extensions/copilot/src/platform/remoteCodeSearch/common/remoteCodeSearch.ts b/extensions/copilot/src/platform/remoteCodeSearch/common/remoteCodeSearch.ts index e53ba6da08f3b..3066f82630ec6 100644 --- a/extensions/copilot/src/platform/remoteCodeSearch/common/remoteCodeSearch.ts +++ b/extensions/copilot/src/platform/remoteCodeSearch/common/remoteCodeSearch.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { GlobIncludeOptions } from '../../../util/common/glob'; -import { FileChunkAndScore } from '../../chunking/common/chunk'; +import { FileChunk, FileChunkAndScore } from '../../chunking/common/chunk'; export enum RemoteCodeSearchIndexStatus { /** The repo index is built and ready to use */ @@ -29,11 +29,23 @@ export type RemoteCodeSearchError = | { readonly type: 'generic-error'; readonly error: Error } ; -export interface CodeSearchResult { - readonly chunks: readonly FileChunkAndScore[]; - +interface BaseCodeSearchResult { /** Tracks if the commit sha code search used differs from the one we used to compute the local diff */ readonly outOfSync: boolean; + + /** The web URL of the remote repo, e.g. `https://github.com/microsoft/vscode` */ + readonly remoteUrl?: string; + + /** The branch name the results are from, e.g. `main` */ + readonly refName?: string; +} + +export interface SemanticCodeSearchResult extends BaseCodeSearchResult { + readonly chunks: readonly FileChunkAndScore[]; +} + +export interface LexicalCodeSearchResult extends BaseCodeSearchResult { + readonly chunks: readonly FileChunk[]; } export interface CodeSearchOptions { diff --git a/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts b/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts index 24226e4a0f349..248a5eabfef42 100644 --- a/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts +++ b/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts @@ -17,7 +17,7 @@ import { TelemetryData, eventPropertiesToSimpleObject } from '../common/telemetr export class BaseGHTelemetrySender implements ITelemetrySender { - protected _disposables: DisposableStore = new DisposableStore(); + protected readonly _disposables: DisposableStore = new DisposableStore(); private _standardTelemetryLogger: TelemetryLogger; private _enhancedTelemetryLogger?: TelemetryLogger; diff --git a/extensions/copilot/src/platform/test/node/extensionContext.ts b/extensions/copilot/src/platform/test/node/extensionContext.ts index f8ecedc2c1f21..71dcd65fa07f7 100644 --- a/extensions/copilot/src/platform/test/node/extensionContext.ts +++ b/extensions/copilot/src/platform/test/node/extensionContext.ts @@ -62,7 +62,7 @@ function constructGlobalStoragePath(globalStoragePath: string): URI { } export class MockExtensionContext implements BrandedService { - _serviceBrand = undefined; + declare _serviceBrand: undefined; extension = { id: 'GitHub.copilot-chat' } as any; extensionUri = URI.from({ scheme: 'file', path: '/mock-extension' }); extensionMode = ExtensionMode.Test; diff --git a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts index 691a158cb985e..f09ba0ae0f3be 100644 --- a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts +++ b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts @@ -124,6 +124,10 @@ export class SimulationWorkspaceService extends AbstractWorkspaceService { return Promise.resolve(true); } + override isResourceTrusted(_resource: vscode.Uri): Thenable { + return Promise.resolve(true); + } + override requestResourceTrust(options: vscode.ResourceTrustRequestOptions): Thenable { return Promise.resolve(true); } diff --git a/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts b/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts index 0e169594ec80e..cf8b6d91b7735 100644 --- a/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts +++ b/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-unused-expressions */ import { beforeEach, expect, suite, test, vi } from 'vitest'; import type * as vscode from 'vscode'; diff --git a/extensions/copilot/src/platform/workspace/common/workspaceService.ts b/extensions/copilot/src/platform/workspace/common/workspaceService.ts index 20cdfedcb0476..3e4885626614c 100644 --- a/extensions/copilot/src/platform/workspace/common/workspaceService.ts +++ b/extensions/copilot/src/platform/workspace/common/workspaceService.ts @@ -49,6 +49,7 @@ export interface IWorkspaceService { * has been downloaded before we can use them. */ ensureWorkspaceIsFullyLoaded(): Promise; + isResourceTrusted(resource: Uri): Thenable; requestResourceTrust(options: ResourceTrustRequestOptions): Thenable; requestWorkspaceTrust(options?: WorkspaceTrustRequestOptions): Thenable; } @@ -75,6 +76,7 @@ export abstract class AbstractWorkspaceService implements IWorkspaceService { abstract showWorkspaceFolderPicker(): Promise; abstract getWorkspaceFolderName(workspaceFolderUri: URI): string; abstract applyEdit(edit: WorkspaceEdit): Thenable; + abstract isResourceTrusted(resource: Uri): Thenable; abstract requestResourceTrust(options: ResourceTrustRequestOptions): Thenable; abstract requestWorkspaceTrust(options?: WorkspaceTrustRequestOptions): Thenable; @@ -229,6 +231,10 @@ export class NullWorkspaceService extends AbstractWorkspaceService implements ID this.disposables.dispose(); } + override isResourceTrusted(_resource: Uri): Thenable { + return Promise.resolve(true); + } + override requestResourceTrust(options: ResourceTrustRequestOptions): Thenable { return Promise.resolve(true); } diff --git a/extensions/copilot/src/platform/workspace/vscode/workspaceServiceImpl.ts b/extensions/copilot/src/platform/workspace/vscode/workspaceServiceImpl.ts index f24d5e8ad854c..95e9b9096a184 100644 --- a/extensions/copilot/src/platform/workspace/vscode/workspaceServiceImpl.ts +++ b/extensions/copilot/src/platform/workspace/vscode/workspaceServiceImpl.ts @@ -111,6 +111,10 @@ export class ExtensionTextDocumentManager extends AbstractWorkspaceService { } + override isResourceTrusted(resource: Uri): Thenable { + return workspace.isResourceTrusted(resource); + } + override requestResourceTrust(options: ResourceTrustRequestOptions): Thenable { return workspace.requestResourceTrust(options); } diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts index c148434c72934..66cf005867aa0 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts @@ -33,7 +33,7 @@ import { Change } from '../../../git/vscode/git'; import { logExecTime, LogExecTime } from '../../../log/common/logExecTime'; import { ILogService } from '../../../log/common/logService'; import { IAdoCodeSearchService } from '../../../remoteCodeSearch/common/adoCodeSearchService'; -import { CodeSearchResult } from '../../../remoteCodeSearch/common/remoteCodeSearch'; +import { SemanticCodeSearchResult } from '../../../remoteCodeSearch/common/remoteCodeSearch'; import { ICodeSearchAuthenticationService } from '../../../remoteCodeSearch/node/codeSearchRepoAuth'; import { isGitHubRemoteRepository } from '../../../remoteRepositories/common/utils'; import { IExperimentationService } from '../../../telemetry/common/nullExperimentationService'; @@ -555,13 +555,13 @@ export class CodeSearchChunkSearch extends Disposable { localSearchCts.cancel(); throw e; }) - : Promise.resolve({ chunks: [], outOfSync: false }); + : Promise.resolve({ chunks: [], outOfSync: false }); const localSearchOperation = raceTimeout(this.searchLocalDiff(diffArray, sizing, query, options, innerTelemetryInfo, localSearchCts.token), this.localDiffSearchTimeout, () => { localSearchCts.cancel(); }); - let codeSearchResults: CodeSearchResult | undefined; + let codeSearchResults: SemanticCodeSearchResult | undefined; let localResults: DiffSearchResult | undefined; try { codeSearchResults = await raceCancellationError(codeSearchOperation, token); @@ -720,7 +720,7 @@ export class CodeSearchChunkSearch extends Disposable { */ this._telemetryService.sendMSFTTelemetryEvent('codeSearchChunkSearch.perf.doCodeSearchWithRetry', { status }, { execTime }); }) - private async doCodeSearch(query: WorkspaceChunkQueryWithEmbeddings, repos: readonly CodeSearchRepo[], sizing: StrategySearchSizing, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { + private async doCodeSearch(query: WorkspaceChunkQueryWithEmbeddings, repos: readonly CodeSearchRepo[], sizing: StrategySearchSizing, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { const results = await Promise.all(repos.map(repo => { return repo.searchRepo({ silent: true }, this._embeddingType, query.queryText, sizing.maxResultCountHint, options, telemetryInfo, token); })); diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchRepo.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchRepo.ts index 8de868ca13d98..11ad3d58c41ab 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchRepo.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchRepo.ts @@ -16,7 +16,7 @@ import { measureExecTime } from '../../../log/common/logExecTime'; import { ILogService } from '../../../log/common/logService'; import { IAdoCodeSearchService } from '../../../remoteCodeSearch/common/adoCodeSearchService'; import { IGithubCodeSearchService } from '../../../remoteCodeSearch/common/githubCodeSearchService'; -import { CodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus } from '../../../remoteCodeSearch/common/remoteCodeSearch'; +import { RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus, SemanticCodeSearchResult } from '../../../remoteCodeSearch/common/remoteCodeSearch'; import { ITelemetryService } from '../../../telemetry/common/telemetry'; import { WorkspaceChunkSearchOptions } from '../../common/workspaceChunkSearch'; import { RepoInfo } from './repoTracker'; @@ -149,7 +149,7 @@ export interface CodeSearchRepo extends IDisposable { options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken - ): Promise; + ): Promise; triggerRemoteIndexingOfRepo(triggerReason: BuildIndexTriggerReason, telemetryInfo: TelemetryCorrelationId): Promise>; @@ -232,7 +232,7 @@ abstract class BaseRemoteCodeSearchRepo extends Disposable implements CodeSearch this._onDidChangeStatus.fire(this._state.status); } - public abstract searchRepo(authOptions: { silent: boolean }, embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise; + public abstract searchRepo(authOptions: { silent: boolean }, embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise; public abstract triggerRemoteIndexingOfRepo(triggerReason: BuildIndexTriggerReason, telemetryInfo: TelemetryCorrelationId): Promise>; public abstract prepareSearch(telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise; @@ -381,8 +381,9 @@ export class GithubCodeSearchRepo extends BaseRemoteCodeSearchRepo { super(repoInfo, remoteInfo, logService, telemetryService); } - public override async searchRepo(authOptions: { silent: boolean }, embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { - const result = await this._githubCodeSearchService.searchRepo(authOptions, embeddingType, { + public override async searchRepo(authOptions: { silent: boolean }, embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { + const result = await this._githubCodeSearchService.semanticSearch(authOptions, embeddingType, { + kind: 'repo', githubRepoId: this._githubRepoId, localRepoRoot: this.repoInfo.rootUri, indexedCommit: undefined, // TODO @@ -502,7 +503,7 @@ export class AdoCodeSearchRepo extends BaseRemoteCodeSearchRepo { super(repoInfo, remoteInfo, logService, telemetryService); } - public searchRepo(authOptions: { silent: boolean }, _embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { + public searchRepo(authOptions: { silent: boolean }, _embeddingType: EmbeddingType, resolvedQuery: string, maxResultCountHint: number, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { return this._adoCodeSearchService.searchRepo(authOptions, { adoRepoId: this._adoRepoId, localRepoRoot: this.repoInfo.rootUri, diff --git a/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts b/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts index b47dc27a2b4e5..4ba0acf97d9a4 100644 --- a/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts +++ b/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts @@ -2,9 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Allow importing vscode here. eslint does not let us exclude this path: https://github.com/import-js/eslint-plugin-import/issues/2800 -/* eslint-disable import/no-restricted-paths */ - import { GitDiffService } from '../../../src/extension/prompt/vscode-node/gitDiffService'; import { IExtensionsService } from '../../../src/platform/extensions/common/extensionsService'; import { VSCodeExtensionsService } from '../../../src/platform/extensions/vscode/extensionsService'; diff --git a/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts b/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts index 0cd2eb1b96b87..259549d78757f 100644 --- a/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts +++ b/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts @@ -2,8 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Allow importing vscode here. eslint does not let us exclude this path: https://github.com/import-js/eslint-plugin-import/issues/2800 -/* eslint-disable import/no-restricted-paths */ import type { CancellationToken, ChatRequest, LanguageModelTool, LanguageModelToolInformation, LanguageModelToolInvocationOptions, LanguageModelToolResult } from 'vscode'; import { getToolName, ToolName } from '../../../src/extension/tools/common/toolNames'; diff --git a/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts b/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts index 455b7a6f16838..b6869227b4c30 100644 --- a/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts +++ b/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ // Allow importing vscode here. eslint does not let us exclude this path: https://github.com/import-js/eslint-plugin-import/issues/2800 -/* eslint-disable local/no-runtime-import */ +/* eslint-disable copilot-local/no-runtime-import */ import { writeFileSync } from 'fs'; import * as vscode from 'vscode'; diff --git a/extensions/copilot/test/base/simuliationWorkspaceChunkSearch.ts b/extensions/copilot/test/base/simuliationWorkspaceChunkSearch.ts index 1bfd2125363e9..c79ae2e2384a3 100644 --- a/extensions/copilot/test/base/simuliationWorkspaceChunkSearch.ts +++ b/extensions/copilot/test/base/simuliationWorkspaceChunkSearch.ts @@ -7,8 +7,8 @@ import { EmbeddingType } from '../../src/platform/embeddings/common/embeddingsCo import { GithubRepoId } from '../../src/platform/git/common/gitService'; import { IIgnoreService } from '../../src/platform/ignore/common/ignoreService'; import { ILogService } from '../../src/platform/log/common/logService'; -import { GithubCodeSearchRepoInfo, IGithubCodeSearchService, parseGithubCodeSearchResponse } from '../../src/platform/remoteCodeSearch/common/githubCodeSearchService'; -import { CodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus } from '../../src/platform/remoteCodeSearch/common/remoteCodeSearch'; +import { GithubCodeSearchScope, IGithubCodeSearchService, parseGithubCodeSearchResponse } from '../../src/platform/remoteCodeSearch/common/githubCodeSearchService'; +import { LexicalCodeSearchResult, RemoteCodeSearchError, RemoteCodeSearchIndexState, RemoteCodeSearchIndexStatus, SemanticCodeSearchResult } from '../../src/platform/remoteCodeSearch/common/remoteCodeSearch'; import { WorkspaceChunkQuery, WorkspaceChunkSearchOptions } from '../../src/platform/workspaceChunkSearch/common/workspaceChunkSearch'; import { BuildIndexTriggerReason, TriggerIndexingError } from '../../src/platform/workspaceChunkSearch/node/codeSearch/codeSearchRepo'; import { IWorkspaceChunkSearchService, WorkspaceChunkSearchResult, WorkspaceChunkSearchSizing, WorkspaceIndexState } from '../../src/platform/workspaceChunkSearch/node/workspaceChunkSearchService'; @@ -34,7 +34,11 @@ class SimulationGithubCodeSearchService extends Disposable implements IGithubCod super(); } - async searchRepo(authOptions: { silent: boolean }, embeddingType: EmbeddingType, repo: GithubCodeSearchRepoInfo, query: string, maxResults: number, options: WorkspaceChunkSearchOptions, _telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { + async lexicalSearch(_authOptions: { silent: boolean }, _scope: GithubCodeSearchScope, _query: string, _maxResults: number, _options: WorkspaceChunkSearchOptions, _telemetryInfo: TelemetryCorrelationId, _token: CancellationToken): Promise { + throw new Error('Method not implemented.'); + } + + async semanticSearch(authOptions: { silent: boolean }, embeddingType: EmbeddingType, repo: GithubCodeSearchScope & { kind: 'repo' }, query: string, maxResults: number, options: WorkspaceChunkSearchOptions, _telemetryInfo: TelemetryCorrelationId, token: CancellationToken): Promise { this._logService.trace(`SimulationGithubCodeSearchService::searchRepo(${repo.githubRepoId}, ${query})`); const response = await fetch(searchEndpoint, { method: 'POST', @@ -97,7 +101,8 @@ export class SimulationCodeSearchChunkSearchService extends Disposable implement async searchFileChunks(sizing: WorkspaceChunkSearchSizing, query: WorkspaceChunkQuery, options: WorkspaceChunkSearchOptions, telemetryInfo: TelemetryCorrelationId, progress: Progress | undefined, token: CancellationToken): Promise { const repo = new GithubRepoId('test-org', 'test-repo'); try { - const results = await this._githubCodeSearchService.searchRepo({ silent: true }, EmbeddingType.text3small_512, { + const results = await this._githubCodeSearchService.semanticSearch({ silent: true }, EmbeddingType.text3small_512, { + kind: 'repo', githubRepoId: repo, indexedCommit: undefined, localRepoRoot: undefined, diff --git a/extensions/copilot/vite.config.ts b/extensions/copilot/vite.config.ts index 44b5f01ea2279..c5c75abcc39cc 100644 --- a/extensions/copilot/vite.config.ts +++ b/extensions/copilot/vite.config.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// eslint-disable-next-line no-restricted-imports import * as path from 'path'; import { loadEnv } from 'vite'; import topLevelAwait from 'vite-plugin-top-level-await'; diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts index 0791391e6af24..2f2e41dad9a0c 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -73,6 +73,23 @@ import { assertNoRpc, closeAllEditors } from '../utils'; assert.strictEqual(window.browserTabs.length, countBefore - 1); }); + test('Closing via workbench.action.closeActiveEditor removes tab from browserTabs', async () => { + const tab = await window.openBrowserTab('about:blank'); + assert.ok(window.browserTabs.includes(tab)); + + const closed = new Promise(resolve => { + const disposable = window.onDidCloseBrowserTab(t => { + disposable.dispose(); + resolve(t); + }); + }); + + await commands.executeCommand('workbench.action.closeActiveEditor'); + const firedTab = await closed; + assert.ok(firedTab); + assert.ok(!window.browserTabs.includes(tab)); + }); + test('Can move a browser tab to a new group and close it successfully', async () => { const tab = await window.openBrowserTab('about:blank'); assert.ok(window.browserTabs.includes(tab)); diff --git a/package-lock.json b/package-lock.json index b09cf211da1b7..020159656200b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -118,6 +118,7 @@ "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsdoc": "^50.3.1", "event-stream": "3.3.4", "fancy-log": "^1.3.3", @@ -2489,6 +2490,13 @@ "node": ">=0.4.0" } }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -2744,6 +2752,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/kerberos": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@types/kerberos/-/kerberos-1.1.2.tgz", @@ -5122,6 +5137,23 @@ "node": ">=12.17" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-differ": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", @@ -5140,6 +5172,29 @@ "node": ">=0.10.0" } }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-initial": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/array-initial/-/array-initial-1.1.0.tgz", @@ -5224,6 +5279,88 @@ "node": ">=0.10.0" } }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/arrify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", @@ -5344,6 +5481,16 @@ "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", "dev": true }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/async-settle": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-1.0.0.tgz", @@ -5389,10 +5536,14 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -5963,16 +6114,16 @@ } }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, + "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -7122,6 +7273,60 @@ "node": ">= 14" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debounce": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.1.0.tgz", @@ -7365,15 +7570,21 @@ } }, "node_modules/define-properties": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", - "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, + "license": "MIT", "dependencies": { - "object-keys": "^1.0.12" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-property": { @@ -7504,6 +7715,19 @@ "randombytes": "^2.0.0" } }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7887,6 +8111,75 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -7937,6 +8230,37 @@ "node": ">= 0.4" } }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es5-ext": { "version": "0.10.64", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", @@ -8112,51 +8436,179 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/eslint-plugin-header": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz", - "integrity": "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==", + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, - "peerDependencies": { - "eslint": ">=7.7.0" + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, - "node_modules/eslint-plugin-jsdoc": { - "version": "50.3.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.3.1.tgz", - "integrity": "sha512-SY9oUuTMr6aWoJggUS40LtMjsRzJPB5ZT7F432xZIHK3EfHF+8i48GbUBpwanrtlL9l1gILNTHK9o8gEhYLcKA==", + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { - "@es-joy/jsdoccomment": "~0.48.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", - "debug": "^4.3.6", - "escape-string-regexp": "^4.0.0", - "espree": "^10.1.0", - "esquery": "^1.6.0", - "parse-imports": "^2.1.1", - "semver": "^7.6.3", - "spdx-expression-parse": "^4.0.0", - "synckit": "^0.9.1" + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, + "license": "MIT", "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/eslint-scope": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-header": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz", + "integrity": "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==", + "dev": true, + "peerDependencies": { + "eslint": ">=7.7.0" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "50.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.3.1.tgz", + "integrity": "sha512-SY9oUuTMr6aWoJggUS40LtMjsRzJPB5ZT7F432xZIHK3EfHF+8i48GbUBpwanrtlL9l1gILNTHK9o8gEhYLcKA==", + "dev": true, + "dependencies": { + "@es-joy/jsdoccomment": "~0.48.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.6", + "escape-string-regexp": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.6.0", + "parse-imports": "^2.1.1", + "semver": "^7.6.3", + "spdx-expression-parse": "^4.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", @@ -9309,12 +9761,19 @@ } }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, + "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/for-in": { @@ -9502,6 +9961,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/geckodriver": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.0.tgz", @@ -9537,6 +10027,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -9639,6 +10139,24 @@ "once": "^1.3.1" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-uri": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", @@ -10143,13 +10661,14 @@ } }, "node_modules/globalthis": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.1.tgz", - "integrity": "sha512-mJPRTc/P39NH/iNG4mXa9aIhNymaQikTrnspeCa2ZuJ+mH2QN/rXwtX3XwKrHqWgUQFbNZKtHM105aHzJalElw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, - "optional": true, + "license": "MIT", "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -11501,18 +12020,6 @@ "xtend": "~4.0.1" } }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", @@ -11534,6 +12041,19 @@ "node": ">=0.10.0" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -11556,6 +12076,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -12064,6 +12600,21 @@ "node": ">= 0.8.0" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -12155,12 +12706,66 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -12173,6 +12778,23 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -12180,10 +12802,11 @@ "dev": true }, "node_modules/is-callable": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", - "integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -12204,12 +12827,16 @@ } }, "node_modules/is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, + "license": "MIT", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12237,15 +12864,50 @@ "node": ">=0.10.0" } }, - "node_modules/is-deflate": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-deflate/-/is-deflate-1.0.0.tgz", - "integrity": "sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==", - "dev": true - }, - "node_modules/is-descriptor": { + "node_modules/is-data-view": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-deflate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-deflate/-/is-deflate-1.0.0.tgz", + "integrity": "sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==", + "dev": true + }, + "node_modules/is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", "dev": true, "dependencies": { @@ -12313,6 +12975,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -12323,10 +13001,18 @@ } }, "node_modules/is-generator-function": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.9.tgz", - "integrity": "sha512-ZJ34p1uvIfptHCN7sFTjGibB9/oBg17sHqzDLfuwhvmN/qLVvIQXRQ8licZQ35WJ8KuEQt/etnnzQFI9C9Ue/A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, "engines": { "node": ">= 0.4" }, @@ -12384,6 +13070,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negated-glob": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", @@ -12393,6 +13092,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -12402,6 +13114,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", @@ -12426,6 +13155,25 @@ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", "dev": true }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-relative": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", @@ -12438,6 +13186,35 @@ "node": ">=0.10.0" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -12447,13 +13224,49 @@ "node": ">=0.10.0" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, + "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -12507,6 +13320,52 @@ "node": ">=0.10.0" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -14401,6 +15260,35 @@ "node": "^16 || ^18 || >= 20" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-fetch": { "version": "2.6.8", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz", @@ -14919,14 +15807,17 @@ } }, "node_modules/object.assign": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", "object-keys": "^1.1.1" }, "engines": { @@ -14951,6 +15842,56 @@ "node": ">=0.10.0" } }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.map": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", @@ -14989,6 +15930,25 @@ "node": ">=0.10.0" } }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -15243,6 +16203,24 @@ "os-tmpdir": "^1.0.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-all/-/p-all-1.0.0.tgz", @@ -15803,6 +16781,16 @@ "node": ">=0.10.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "7.0.39", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", @@ -16420,6 +17408,29 @@ "node": ">= 0.10" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -16433,6 +17444,27 @@ "node": ">=0.10.0" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/remove-bom-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", @@ -16913,14 +17945,65 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", @@ -16930,6 +18013,24 @@ "ret": "~0.1.10" } }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-regex2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", @@ -17163,6 +18264,37 @@ "node": ">= 0.4" } }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -18005,6 +19137,20 @@ "ieee754": "^1.2.1" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-combiner": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", @@ -18157,6 +19303,65 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -18183,6 +19388,16 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strip-bom-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", @@ -18850,6 +20065,32 @@ "code-block-writer": "^13.0.3" } }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, "node_modules/tsec": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tsec/-/tsec-0.2.7.tgz", @@ -19024,6 +20265,84 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -19093,6 +20412,25 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", @@ -19914,6 +21252,80 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", @@ -19921,16 +21333,19 @@ "dev": true }, "node_modules/which-typed-array": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", - "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, + "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index 6cec5c5f57c11..db9c9f95bbc4e 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsdoc": "^50.3.1", "event-stream": "3.3.4", "fancy-log": "^1.3.3", diff --git a/src/vs/base/common/observableInternal/index.ts b/src/vs/base/common/observableInternal/index.ts index 58f26cbfa5b08..805f7d75e7d6c 100644 --- a/src/vs/base/common/observableInternal/index.ts +++ b/src/vs/base/common/observableInternal/index.ts @@ -11,7 +11,7 @@ export { type IObservable, type IObservableWithChange, type IObserver, type IRea export { disposableObservableValue } from './observables/observableValue.js'; export { derived, derivedDisposable, derivedHandleChanges, derivedOpts, derivedWithSetter, derivedWithStore } from './observables/derived.js'; export { type IDerivedReader } from './observables/derivedImpl.js'; -export { ObservableLazy, ObservableLazyPromise, ObservablePromise, PromiseResult, } from './utils/promise.js'; +export { ObservableLazy, ObservableLazyPromise, ObservablePromise, ObservableResolvedPromise, PromiseResult, } from './utils/promise.js'; export { derivedWithCancellationToken, waitForState } from './utils/utilsCancellation.js'; export { debouncedObservable, debouncedObservable2, derivedObservableWithCache, diff --git a/src/vs/base/common/observableInternal/utils/promise.ts b/src/vs/base/common/observableInternal/utils/promise.ts index a6493858f6c56..965105b97e261 100644 --- a/src/vs/base/common/observableInternal/utils/promise.ts +++ b/src/vs/base/common/observableInternal/utils/promise.ts @@ -2,7 +2,9 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IObservable } from '../base.js'; +import { DisposableStore } from '../../lifecycle.js'; +import { IObservable, ISettableObservable } from '../base.js'; +import { autorun } from '../reactions/autorun.js'; import { transaction } from '../transaction.js'; import { derived } from '../observables/derived.js'; import { observableValue } from '../observables/observableValue.js'; @@ -110,6 +112,44 @@ export class PromiseResult { } } +/** + * Tracks a changing {@link ObservablePromise}, exposing the last resolved value + * and whether a newer promise is still pending. + */ +export class ObservableResolvedPromise { + private readonly _lastResolved: ISettableObservable; + public readonly lastResolved: IObservable; + + private readonly _isResolving = observableValue(this, false); + public readonly isResolving: IObservable = this._isResolving; + + private _runningPromise: ObservablePromise | undefined; + + constructor( + source: IObservable>, + initialValue: T, + store: DisposableStore, + ) { + this._lastResolved = observableValue(this, initialValue); + this.lastResolved = this._lastResolved; + + store.add(autorun(reader => { + const current = source.read(reader); + this._runningPromise = current; + + const result = current.promiseResult.read(reader); + if (result) { + if (current === this._runningPromise) { + this._isResolving.set(false, undefined); + this._lastResolved.set(result.getDataOrThrow(), undefined); + } + } else { + this._isResolving.set(true, undefined); + } + })); + } +} + /** * A lazy promise whose state is observable. */ diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts index fd4c9bae7ba9f..22c2a2e13ff1c 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, ITransaction, constObservable, derived, derivedObservableWithWritableCache, mapObservableArrayCached, observableFromValueWithChangeEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IObservable, ITransaction, ObservablePromise, ObservableResolvedPromise, constObservable, derived, derivedObservableWithWritableCache, mapObservableArrayCached, observableFromValueWithChangeEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { timeout } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import { ContextKeyValue } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -20,14 +21,20 @@ import { IDocumentDiffItem, IMultiDiffEditorModel } from './model.js'; export class MultiDiffEditorViewModel extends Disposable { private readonly _documents: IObservable[] | 'loading'>; - private readonly _documentsArr; + private readonly _documentsArr = derived(this, reader => { + const result = this._documents.read(reader); + if (result === 'loading') { return []; } + return result; + }); public readonly isLoading; public readonly items: IObservable; - public readonly focusedDiffItem; - public readonly activeDiffItem; + public readonly focusedDiffItem = derived(this, reader => this.items.read(reader).find(i => i.isFocused.read(reader))); + public readonly activeDiffItem = derivedObservableWithWritableCache(this, + (reader, lastValue) => this.focusedDiffItem.read(reader) ?? (lastValue && this.items.read(reader).indexOf(lastValue) !== -1) ? lastValue : undefined + ); public async waitForDiffs(): Promise { for (const d of this.items.get()) { @@ -57,24 +64,33 @@ export class MultiDiffEditorViewModel extends Disposable { constructor( public readonly model: IMultiDiffEditorModel, - private readonly _instantiationService: IInstantiationService, + private readonly _instantiationService: IInstantiationService ) { super(); this._documents = observableFromValueWithChangeEvent(this.model, this.model.documents); - this._documentsArr = derived(this, reader => { - const result = this._documents.read(reader); - if (result === 'loading') { return []; } - return result; - }); - this.isLoading = derived(this, reader => this._documents.read(reader) === 'loading'); - this.items = mapObservableArrayCached( + + const allItems = mapObservableArrayCached( this, this._documentsArr, (d, store) => store.add(this._instantiationService.createInstance(DocumentDiffItemViewModel, d, this)) ).recomputeInitiallyAndOnChange(this._store); - this.focusedDiffItem = derived(this, reader => this.items.read(reader).find(i => i.isFocused.read(reader))); - this.activeDiffItem = derivedObservableWithWritableCache(this, - (reader, lastValue) => this.focusedDiffItem.read(reader) ?? (lastValue && this.items.read(reader).indexOf(lastValue) !== -1) ? lastValue : undefined + + const waitForNewDiffs: IObservable> = derived(this, reader => { + const next = allItems.read(reader); + const unresolved = next.filter(i => !i.waitForInitialDiffOr1s.promiseResult.read(undefined)); + if (unresolved.length === 0) { + return ObservablePromise.resolved(next); + } + return new ObservablePromise( + Promise.all(unresolved.map(i => i.waitForInitialDiffOr1s.promise)).then(() => next) + ); + }); + + const resolved = new ObservableResolvedPromise(waitForNewDiffs, [] as readonly DocumentDiffItemViewModel[], this._store); + + this.items = resolved.lastResolved; + this.isLoading = derived(this, reader => + this._documents.read(reader) === 'loading' || resolved.isResolving.read(reader) ); } } @@ -87,6 +103,7 @@ export class DocumentDiffItemViewModel extends Disposable { public get diffEditorViewModel(): IDiffEditorViewModel { return this.diffEditorViewModelRef.object; } + public readonly waitForInitialDiffOr1s: ObservablePromise; public readonly collapsed = observableValue(this, false); public readonly lastTemplateData = observableValue<{ contentHeight: number; selections: Selection[] | undefined }>( @@ -106,9 +123,9 @@ export class DocumentDiffItemViewModel extends Disposable { this._isFocusedSource.set(source, tx); } - private readonly documentDiffItemRef: RefCounted; + private readonly _documentDiffItemRef: RefCounted; public get documentDiffItem(): IDocumentDiffItem { - return this.documentDiffItemRef.object; + return this._documentDiffItemRef.object; } public readonly isAlive = observableValue(this, true); @@ -125,7 +142,7 @@ export class DocumentDiffItemViewModel extends Disposable { this.isAlive.set(false, undefined); })); - this.documentDiffItemRef = this._register(documentDiffItem.createNewRef(this)); + this._documentDiffItemRef = this._register(documentDiffItem.createNewRef(this)); function updateOptions(options: IDiffEditorOptions): IDiffEditorOptions { return { @@ -146,7 +163,7 @@ export class DocumentDiffItemViewModel extends Disposable { const diffEditorViewModelStore = new DisposableStore(); const originalTextModel = this.documentDiffItem.original ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); const modifiedTextModel = this.documentDiffItem.modified ?? diffEditorViewModelStore.add(this._modelService.createModel('', null)); - diffEditorViewModelStore.add(this.documentDiffItemRef.createNewRef(this)); + diffEditorViewModelStore.add(this._documentDiffItemRef.createNewRef(this)); this.diffEditorViewModelRef = this._register(RefCounted.createWithDisposable( this._instantiationService.createInstance(DiffEditorViewModel, { @@ -156,6 +173,13 @@ export class DocumentDiffItemViewModel extends Disposable { diffEditorViewModelStore, this )); + + this.waitForInitialDiffOr1s = new ObservablePromise( + Promise.race([ + this.diffEditorViewModel.waitForDiff(), + timeout(1000), + ]) + ); } public getKey(): string { diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index cb0397aaaa42a..f7ace1168baf1 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -453,6 +453,14 @@ export interface IActionListOptions { */ readonly minWidth?: number; + /** + * Fixed width for the action list. When set, DOM-based width measurement is + * skipped and this value is used directly, preventing width fluctuations caused + * by scrollbar presence (which changes with window height). Use this for pickers + * that should have a stable, fixed width (e.g. the workspace picker at 600px). + */ + readonly fixedWidth?: number; + /** * Optional handler for markdown links activated in item descriptions or hovers. * When unset, links open via the opener service with command links allowed. @@ -1470,11 +1478,15 @@ export class ActionListWidget extends Disposable { const hoverHeaderHeight = hoverHeader ? hoverHeader.offsetHeight : 0; const totalPanelHeight = totalHeight + hoverHeaderHeight; const viewportHeight = targetWindow.innerHeight; - let top = anchorRect.top - parentRect.top - 4; + const anchorHeight = anchorRect.height; + let top = anchorRect.top - parentRect.top + (anchorHeight - totalPanelHeight) / 2; const panelBottom = parentRect.top + top + totalPanelHeight; if (panelBottom > viewportHeight) { top -= (panelBottom - viewportHeight + 8); } + if (parentRect.top + top < 0) { + top = -parentRect.top; + } this._submenuContainer.style.top = `${top}px`; } @@ -1608,6 +1620,7 @@ export class ActionList extends Disposable { private _cachedMaxWidth: number | undefined; private _hasLaidOut = false; private _showAbove: boolean | undefined; + private readonly _options: IActionListOptions | undefined; get domNode(): HTMLElement { return this._widget.domNode; @@ -1646,6 +1659,7 @@ export class ActionList extends Disposable { ) { super(); this._anchor = anchor; + this._options = options; this._widget = this._register(instantiationService.createInstance( ActionListWidget, @@ -1710,7 +1724,6 @@ export class ActionList extends Disposable { const listHeight = this._widget.computeListHeight(); const filterHeight = this._widget.filterContainer ? 36 : 0; - const padding = 10; const targetWindow = dom.getWindow(this.domNode); let availableHeight; @@ -1718,8 +1731,9 @@ export class ActionList extends Disposable { const viewportHeight = targetWindow.innerHeight; const anchorRect = getAnchorRect(this._anchor); const anchorTopInViewport = anchorRect.top - targetWindow.pageYOffset; - const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - padding; - const spaceAbove = anchorTopInViewport - padding; + const bottomGap = 30; + const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - bottomGap; + const spaceAbove = anchorTopInViewport; // Lock the direction on first layout based on whether the full // unconstrained list fits below. Once decided, the dropdown stays @@ -1730,6 +1744,7 @@ export class ActionList extends Disposable { } availableHeight = this._showAbove ? spaceAbove : spaceBelow; } else { + const padding = 10; const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; const widgetTop = this.domNode.getBoundingClientRect().top; availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; @@ -1749,7 +1764,16 @@ export class ActionList extends Disposable { const listHeight = this.computeHeight(); this._widget.layout(listHeight); - this._cachedMaxWidth = this._widget.computeMaxWidth(minWidth); + // When a fixedWidth is provided, skip DOM measurement entirely. + // DOM-based measurement varies with scrollbar presence (which depends on + // the list height), causing the width to fluctuate as the window is resized. + let computedWidth: number; + if (this._options?.fixedWidth !== undefined) { + computedWidth = this._options.fixedWidth; + } else { + computedWidth = this._widget.computeMaxWidth(minWidth); + } + this._cachedMaxWidth = computedWidth; this._widget.layout(listHeight, this._cachedMaxWidth); return this._cachedMaxWidth; diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index ec8b5e9d65da7..248b94ceea8a0 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -59,7 +59,6 @@ white-space: nowrap; cursor: pointer; touch-action: none; - width: 100%; border-radius: var(--vscode-cornerRadius-medium); } @@ -233,6 +232,7 @@ .action-widget .monaco-list-row.action .group-title { color: var(--vscode-descriptionForeground); margin-left: 0.5em; + margin-right: 6px; font-size: 12px; flex-shrink: 0; } @@ -253,19 +253,34 @@ /* Inline description mode — description rendered right after the label */ .action-widget .inline-description .monaco-list-row.action { + /* Override the row gap so group-title and toolbar sit flush */ + gap: 0; + .title { - flex: initial; - flex-shrink: 1; + flex: 0 1 auto; min-width: 0; + margin-left: 6px; } .description { flex: 1; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .action-list-item-toolbar { + margin-left: 4px; + margin-right: 10px; + } + + /* When description is hidden (e.g. items with only a submenu), push the + * submenu chevron to the far right using an auto left margin. When + * description is visible it already consumes all available space via + * flex:1, so the auto margin has no additional effect. */ + .action-list-submenu-indicator { margin-left: auto; + margin-right: 10px; } } @@ -375,8 +390,8 @@ .action-list-submenu-hover-header { padding: 4px 8px; - line-height: 1.5em; - min-width: 200px; + line-height: 1.4em; + font-size: 12px; max-width: var(--vscode-hover-maxWidth, 500px); word-wrap: break-word; } diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts index 6d01fe3e41614..6b0c02128c069 100644 --- a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts +++ b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts @@ -34,6 +34,9 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans private _ws: WebSocket | undefined; private _malformedFrames = 0; + /** Guards against firing onClose more than once. */ + private _closeFired = false; + get isOpen(): boolean { return this._ws?.readyState === WebSocket.OPEN; } @@ -42,6 +45,7 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans private readonly _address: string, private readonly _connectionToken?: string, ) { + // TODO: @osortega remove console.logs super(); } @@ -138,20 +142,44 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans }); ws.addEventListener('close', () => { - this._onClose.fire(); + if (!this._closeFired) { + this._closeFired = true; + this._onClose.fire(); + } }); ws.addEventListener('error', () => { // Error always precedes close - closing is handled in the close handler. - this._onClose.fire(); + // Only fire if close hasn't already been fired (e.g. from send failure). + if (!this._closeFired) { + this._closeFired = true; + this._onClose.fire(); + } }); }); } - send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { + /** + * Send a message to the remote end. Returns `true` if the message was + * sent, `false` if it was dropped (socket not open). On failure, the + * transport is force-closed so reconnection is triggered immediately + * rather than silently losing messages. + */ + send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): boolean { if (this._ws?.readyState === WebSocket.OPEN) { this._ws.send(JSON.stringify(message)); + return true; + } + console.warn( + `[WebSocketClientTransport] Message dropped: readyState=${this._ws?.readyState ?? 'no-socket'}` + ); + // Force-close and fire onClose exactly once to trigger reconnection + this._ws?.close(4001, 'send-on-dead-socket'); + if (!this._closeFired) { + this._closeFired = true; + this._onClose.fire(); } + return false; } override dispose(): void { diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts new file mode 100644 index 0000000000000..9a7dcfd63b235 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -0,0 +1,304 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../nls.js'; +import { SessionConfigKey } from './sessionConfigKeys.js'; +import type { SessionConfigPropertySchema, SessionConfigSchema } from './state/protocol/commands.js'; +import { JsonRpcErrorCodes, ProtocolError } from './state/sessionProtocol.js'; + +// ---- Schema builder -------------------------------------------------------- + +/** + * A schema property with a phantom TypeScript type and a precomputed + * runtime validator. + * + * The `` type parameter is the developer's assertion about the + * property's runtime shape; the validator derived from `protocol` + * (`type`, `enum`, `items`, `properties`, `required`) enforces it at + * runtime. + */ +export interface ISchemaProperty { + readonly protocol: SessionConfigPropertySchema; + /** + * Returns `true` iff `value` conforms to {@link protocol}. Narrows + * the type to `T` for callers. The boolean form is preferred for + * control flow; use {@link assertValid} when you want a descriptive + * error for the offending path. + */ + validate(value: unknown): value is T; + /** + * Throws a {@link ProtocolError} with `JsonRpcErrorCodes.InvalidParams` + * describing the offending path (e.g. `'permissions.allow[2]'`) when + * `value` does not conform to {@link protocol}. Otherwise returns and + * narrows the type to `T`. + * + * @param path Dotted path prefix to embed in error messages. Defaults + * to empty (the value itself). + */ + assertValid(value: unknown, path?: string): asserts value is T; +} + +/** + * Defines a strongly-typed schema property whose runtime validator is + * derived from the supplied JSON-schema descriptor. + */ +export function schemaProperty(protocol: SessionConfigPropertySchema): ISchemaProperty { + const assertFn = buildAssert(protocol); + const assertValid = (value: unknown, path: string = ''): asserts value is T => assertFn(value, path); + const validate = (value: unknown): value is T => { + try { + assertFn(value, ''); + return true; + } catch { + return false; + } + }; + return { protocol, validate, assertValid }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SchemaDefinition = Record>; + +export type SchemaValue

= P extends ISchemaProperty ? T : never; + +export type SchemaValues = { + [K in keyof D]?: SchemaValue; +}; + +/** + * A bundle of named schema properties plus helpers for serializing to the + * protocol shape, validating a values bag at write sites, and validating + * a single key at read sites. + */ +export interface ISchema { + readonly definition: D; + /** Returns the protocol-serializable schema for this bundle. */ + toProtocol(): SessionConfigSchema; + /** + * Validates each known key in `values` against its schema and returns + * a new plain record. Throws a {@link ProtocolError} with a path like + * `'permissions.allow[2]'` when any supplied value fails validation. + * Unknown keys are passed through untouched for forward-compatibility. + */ + values(values: SchemaValues): Record; + /** + * Returns `true` iff `value` validates against the schema for `key`. + * Unknown keys return `false`. + */ + validate(key: K, value: unknown): value is SchemaValue; + /** + * Throws a {@link ProtocolError} describing the offending path when + * `value` does not validate against the schema for `key`, or when + * `key` is not defined in the schema. + */ + assertValid(key: K, value: unknown): asserts value is SchemaValue; + /** + * Returns a fully-typed values bag by validating each key of + * `defaults` against `values` and falling back to the default when + * the incoming value is missing or fails validation. + * + * Intended for sanitizing untrusted input at protocol boundaries + * (e.g. `resolveSessionConfig`), where callers want a complete + * type-safe object rather than a throw-on-first-error response. + * Keys that fail validation are silently replaced with their + * default; use {@link values} or {@link assertValid} when you want + * a descriptive {@link ProtocolError} instead. + */ + validateOrDefault }>(values: Record | undefined, defaults: T): T; +} + +export function createSchema(definition: D): ISchema { + return { + definition, + toProtocol(): SessionConfigSchema { + const properties: Record = {}; + for (const key of Object.keys(definition)) { + properties[key] = definition[key].protocol; + } + return { type: 'object', properties }; + }, + values(values) { + const raw = values as Record; + for (const key of Object.keys(definition)) { + const value = raw[key]; + if (value === undefined) { + continue; + } + // Local with explicit annotation so TypeScript accepts the + // assertion-signature call (per TS4104). + const prop: ISchemaProperty = definition[key]; + prop.assertValid(value, key); + } + return { ...raw }; + }, + validate(key: K, value: unknown): value is SchemaValue { + const prop = definition[key]; + return prop ? prop.validate(value) : false; + }, + assertValid(key: K, value: unknown): asserts value is SchemaValue { + const prop: ISchemaProperty | undefined = definition[key]; + if (!prop) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Unknown schema key '${key}'`); + } + // Re-bind post-narrowing to keep the call target explicitly typed + // (required for assertion-signature calls, TS4104). + const narrowed: ISchemaProperty = prop; + narrowed.assertValid(value, key); + }, + validateOrDefault }>(values: Record | undefined, defaults: T): T { + const result: Record = {}; + for (const key of Object.keys(defaults)) { + const raw = values?.[key]; + const prop = definition[key]; + result[key] = prop && raw !== undefined && prop.validate(raw) + ? raw + : (defaults as Record)[key]; + } + return result as T; + }, + }; +} + +// ---- Validator derivation -------------------------------------------------- + +/** + * A validator that throws a {@link ProtocolError} annotated with the + * offending path when `value` does not conform, or returns normally + * when it does. + */ +type AssertValidator = (value: unknown, path: string) => void; + +function buildAssert(schema: SessionConfigPropertySchema): AssertValidator { + if (schema.type === 'object' && schema.properties) { + const propAsserts: Record = {}; + for (const key of Object.keys(schema.properties)) { + propAsserts[key] = buildAssert(schema.properties[key] as SessionConfigPropertySchema); + } + const required = new Set(schema.required ?? []); + return (value, path) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw invalidParams(path, 'object', value); + } + const obj = value as Record; + for (const key of Object.keys(propAsserts)) { + const childPath = joinPath(path, key); + if (obj[key] === undefined) { + if (required.has(key)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Missing required property at '${childPath}'`); + } + continue; + } + propAsserts[key](obj[key], childPath); + } + }; + } + if (schema.type === 'array' && schema.items) { + const itemAssert = buildAssert(schema.items as SessionConfigPropertySchema); + return (value, path) => { + if (!Array.isArray(value)) { + throw invalidParams(path, 'array', value); + } + for (let i = 0; i < value.length; i++) { + itemAssert(value[i], `${path}[${i}]`); + } + }; + } + return buildPrimitiveAssert(schema); +} + +function buildPrimitiveAssert(schema: SessionConfigPropertySchema): AssertValidator { + const enumDynamic = schema.enumDynamic === true; + return (value, path) => { + switch (schema.type) { + case 'string': if (typeof value !== 'string') { throw invalidParams(path, 'string', value); } break; + case 'number': if (typeof value !== 'number') { throw invalidParams(path, 'number', value); } break; + case 'boolean': if (typeof value !== 'boolean') { throw invalidParams(path, 'boolean', value); } break; + case 'array': if (!Array.isArray(value)) { throw invalidParams(path, 'array', value); } break; + case 'object': if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw invalidParams(path, 'object', value); } break; + } + if (schema.enum && !enumDynamic && !schema.enum.includes(value as string)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Invalid value at '${path || ''}': ${safeStringify(value)} is not one of [${schema.enum.map(v => JSON.stringify(v)).join(', ')}]`); + } + }; +} + +function invalidParams(path: string, expected: string, value: unknown): ProtocolError { + return new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Invalid value at '${path || ''}': expected ${expected}, got ${safeStringify(value)}`); +} + +function joinPath(parent: string, key: string): string { + return parent ? `${parent}.${key}` : key; +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +// ---- Platform-owned schema ------------------------------------------------- + +export type AutoApproveLevel = 'default' | 'autoApprove' | 'autopilot'; + +export interface IPermissionsValue { + readonly allow: readonly string[]; + readonly deny: readonly string[]; +} + +/** + * Session-config properties owned by the platform itself — i.e. consumed + * by the agent host rather than by any particular agent. + * + * Agents extend this schema by spreading `platformSessionSchema.definition` + * into their own {@link createSchema} call together with any + * provider-specific properties. + */ +export const platformSessionSchema = createSchema({ + [SessionConfigKey.AutoApprove]: schemaProperty({ + type: 'string', + title: localize('agentHost.sessionConfig.autoApprove', "Approvals"), + description: localize('agentHost.sessionConfig.autoApproveDescription', "Tool approval behavior for this session"), + enum: ['default', 'autoApprove', 'autopilot'], + enumLabels: [ + localize('agentHost.sessionConfig.autoApprove.default', "Default Approvals"), + localize('agentHost.sessionConfig.autoApprove.bypass', "Bypass Approvals"), + localize('agentHost.sessionConfig.autoApprove.autopilot', "Autopilot (Preview)"), + ], + enumDescriptions: [ + localize('agentHost.sessionConfig.autoApprove.defaultDescription', "Copilot uses your configured settings"), + localize('agentHost.sessionConfig.autoApprove.bypassDescription', "All tool calls are auto-approved"), + localize('agentHost.sessionConfig.autoApprove.autopilotDescription', "Autonomously iterates from start to finish"), + ], + default: 'default', + sessionMutable: true, + }), + [SessionConfigKey.Permissions]: schemaProperty({ + type: 'object', + title: localize('agentHost.sessionConfig.permissions', "Permissions"), + description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."), + properties: { + allow: { + type: 'array', + title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), + }, + }, + deny: { + type: 'array', + title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), + }, + }, + }, + default: { allow: [], deny: [] }, + sessionMutable: true, + }), +}); diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 0c05e7d237b4e..66005d03aa40a 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -148,8 +148,6 @@ export interface IAgentCreateSessionConfig { }; } -export const AgentHostSessionConfigBranchNameHintKey = 'branchNameHint'; - export interface IAgentResolveSessionConfigParams { readonly provider?: AgentProvider; readonly workingDirectory?: URI; diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts new file mode 100644 index 0000000000000..c3371bc42d6f1 --- /dev/null +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Well-known keys used in the agent-host configuration value bag. + * + * The Agent Host Protocol's config schema is intentionally generic — agents + * are free to advertise any property names. These constants capture the + * names that the platform itself consumes (e.g. {@link SessionConfigKey.AutoApprove} + * drives tool auto-approval) or that clients interpret via convention + * (e.g. {@link SessionConfigKey.Branch}, {@link SessionConfigKey.Isolation}). + * + * Agents that opt into the corresponding behavior should use these exact + * property names in their `resolveSessionConfig` response. + */ +export const enum SessionConfigKey { + /** `'autoApprove'` — tool auto-approval level. */ + AutoApprove = 'autoApprove', + /** `'permissions'` — per-tool session allow/deny lists. */ + Permissions = 'permissions', + /** `'isolation'` — `'folder'` or `'worktree'`. */ + Isolation = 'isolation', + /** `'branch'` — base branch to work from. */ + Branch = 'branch', + /** `'branchNameHint'` — client-supplied hint used during worktree creation. */ + BranchNameHint = 'branchNameHint', +} + +/** + * The set of enum values the unified permission picker understands for the + * {@link SessionConfigKey.AutoApprove} property. + * + * `default` is the required baseline level; `autoApprove` and `autopilot` + * are optional (an agent may choose to advertise a subset). + */ +export const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet = new Set(['default', 'autoApprove', 'autopilot']); diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts new file mode 100644 index 0000000000000..4b381dedfe1a1 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; +import type { ISchema, SchemaDefinition, SchemaValue } from '../common/agentHostSchema.js'; +import { ProtocolError } from '../common/state/sessionProtocol.js'; +import { ActionType } from '../common/state/sessionActions.js'; +import { parseSubagentSessionUri, type URI as ProtocolURI } from '../common/state/sessionState.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +export const IAgentConfigurationService = createDecorator('agentConfigurationService'); + +/** + * Cohesive read/write surface for agent-host configuration. + * + * All platform-layer consumers (tool auto-approval, side effects, future + * host-config editors) should read and mutate config values through this + * service rather than reaching into raw session state. The service owns + * the `session → parent session → host` inheritance chain so that + * host-level defaults, subagent inheritance, and per-session overrides + * compose the same way everywhere. + * + * Reads go through a caller-supplied {@link ISchema}: each raw value is + * validated against the property's schema before being returned, so a + * malformed value in one layer transparently falls back to the next. + */ +export interface IAgentConfigurationService { + readonly _serviceBrand: undefined; + + /** + * Returns the effective value of `key` for `session`, walking the + * `session → parent session → host` chain and returning the first + * layer that provides a value which validates against + * `schema.definition[key]`. Layers that provide a malformed value + * are logged and skipped. Returns `undefined` when no layer provides + * a valid value. + */ + getEffectiveValue( + session: ProtocolURI, + schema: ISchema, + key: K, + ): SchemaValue | undefined; + + /** + * Returns the effective working directory for a session, falling back + * to the parent (subagent) session's working directory when the + * session itself does not have one set. The host layer does not carry + * a working directory. + */ + getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined; + + /** + * Merges a partial config patch into a session's values via a + * {@link ActionType.SessionConfigChanged} action. Keys not present in + * `patch` are left untouched. The patch is applied atomically through + * the state manager's reducer. + */ + updateSessionConfig(session: ProtocolURI, patch: Record): void; +} + +export class AgentConfigurationService extends Disposable implements IAgentConfigurationService { + declare readonly _serviceBrand: undefined; + + constructor( + private readonly _stateManager: AgentHostStateManager, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + getEffectiveValue( + session: ProtocolURI, + schema: ISchema, + key: K, + ): SchemaValue | undefined { + for (const values of this._effectiveChain(session)) { + const raw = values[key]; + if (raw === undefined) { + continue; + } + try { + schema.assertValid(key, raw); + return raw; + } catch (err) { + const reason = err instanceof ProtocolError ? err.message : String(err); + this._logService.warn(`[AgentConfigurationService] Value for '${key}' on ${session} failed schema validation, falling back: ${reason}`); + } + } + return undefined; + } + + getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined { + const own = this._stateManager.getSessionState(session)?.summary.workingDirectory; + if (own !== undefined) { + return own; + } + const parentInfo = parseSubagentSessionUri(session); + if (parentInfo) { + return this._stateManager.getSessionState(parentInfo.parentSession)?.summary.workingDirectory; + } + return undefined; + } + + updateSessionConfig(session: ProtocolURI, patch: Record): void { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionConfigChanged, + session, + config: patch, + }); + } + + /** + * Yields the raw value bags that contribute to the effective config + * for `session`, in precedence order: session, parent subagent + * session (if any), host. + */ + private *_effectiveChain(session: ProtocolURI): Iterable> { + const own = this._stateManager.getSessionState(session)?.config?.values; + if (own) { + yield own; + } + const parentInfo = parseSubagentSessionUri(session); + if (parentInfo) { + const parent = this._stateManager.getSessionState(parentInfo.parentSession)?.config?.values; + if (parent) { + yield parent; + } + } + const host = this._stateManager.rootState.config?.values; + if (host) { + yield host; + } + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index afefce549c4c5..c6a4983c21afa 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -11,6 +11,8 @@ import { observableValue } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; +import { InstantiationService } from '../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; @@ -19,6 +21,7 @@ import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCom import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, type ResponsePart, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolCallCompletedState, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; +import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js'; @@ -91,11 +94,22 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager = this._register(new AgentHostStateManager(_logService)); this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e))); this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e))); - this._sideEffects = this._register(new AgentSideEffects(this._stateManager, { + + // Build a local instantiation scope so downstream components can + // consume {@link IAgentConfigurationService} (and later {@link ILogService}) + // via DI rather than being plumbed plain-class references. + const configurationService: IAgentConfigurationService = this._register(new AgentConfigurationService(this._stateManager, this._logService)); + const services = new ServiceCollection( + [ILogService, this._logService], + [IAgentConfigurationService, configurationService], + ); + const instantiationService = this._register(new InstantiationService(services, /*strict*/ true)); + + this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, { getAgent: session => this._findProviderForSession(session), sessionDataService: this._sessionDataService, agents: this._agents, - }, this._logService)); + })); // Terminal management — the terminal manager listens to the state // manager's action stream and dispatches PTY output back through it. diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 3b80dfef29d98..91b8f804f4276 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -11,6 +11,7 @@ import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IAgent, IAgentAttachment, IAgentProgressEvent, type IAgentToolCompleteEvent, type IAgentToolReadyEvent } from '../common/agentService.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; @@ -104,11 +105,12 @@ export class AgentSideEffects extends Disposable { constructor( private readonly _stateManager: AgentHostStateManager, private readonly _options: IAgentSideEffectsOptions, - private readonly _logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, ) { super(); this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService)); - this._permissionManager = this._register(new SessionPermissionManager(this._stateManager, this._logService)); + this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager)); // Whenever the agents observable changes, publish to root state. this._register(autorun(reader => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 10e06b56712fe..7b2fdad114812 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -23,7 +23,9 @@ import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentHostSessionConfigBranchNameHintKey, AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { AutoApproveLevel, IPermissionsValue, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; @@ -31,7 +33,6 @@ import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProt import { CustomizationStatus, CustomizationRef, SessionInputResponseKind, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type PolicyState } from '../../common/state/sessionState.js'; import { IAgentHostGitService } from '../agentHostGitService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; -import { SessionPermissionManager } from '../sessionPermissions.js'; import { CopilotAgentSession, SessionWrapperFactory, type IActiveClientSnapshot } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; @@ -574,99 +575,57 @@ export class CopilotAgent extends Disposable implements IAgent { async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { const gitInfo = params.workingDirectory ? await this._getGitInfo(params.workingDirectory) : undefined; - const isolationValue = params.config?.isolation === 'folder' || params.config?.isolation === 'worktree' - ? params.config.isolation - : gitInfo ? 'worktree' : 'folder'; - - const autoApproveValue = params.config?.autoApprove === 'default' || params.config?.autoApprove === 'autoApprove' || params.config?.autoApprove === 'autopilot' - ? params.config.autoApprove - : 'default'; - - const values: Record = { - isolation: isolationValue, - autoApprove: autoApproveValue, - [SessionPermissionManager.PERMISSIONS_CONFIG_KEY]: params.config?.[SessionPermissionManager.PERMISSIONS_CONFIG_KEY] || { allow: [], deny: [] }, - }; - if (gitInfo) { - const branchForMode = isolationValue === 'worktree' ? gitInfo.defaultBranch : gitInfo.currentBranch; - values.branch = typeof params.config?.branch === 'string' && isolationValue === 'worktree' - ? params.config.branch - : branchForMode; - } - const properties: ResolveSessionConfigResult['schema']['properties'] = { - isolation: { - type: 'string', - title: localize('agentHost.sessionConfig.isolation', "Isolation"), - description: localize('agentHost.sessionConfig.isolationDescription', "Where the agent should make changes"), - enum: gitInfo ? ['folder', 'worktree'] : ['folder'], - enumLabels: gitInfo ? [localize('agentHost.sessionConfig.isolation.folder', "Folder"), localize('agentHost.sessionConfig.isolation.worktree', "Worktree")] : [localize('agentHost.sessionConfig.isolation.folder', "Folder")], - enumDescriptions: gitInfo ? [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.isolation.worktreeDescription', "Create a Git worktree for isolation")] : [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder")], - default: gitInfo ? 'worktree' : 'folder', - readOnly: !gitInfo, - }, - autoApprove: { - type: 'string', - title: localize('agentHost.sessionConfig.autoApprove', "Approvals"), - description: localize('agentHost.sessionConfig.autoApproveDescription', "Tool approval behavior for this session"), - enum: ['default', 'autoApprove', 'autopilot'], - enumLabels: [ - localize('agentHost.sessionConfig.autoApprove.default', "Default Approvals"), - localize('agentHost.sessionConfig.autoApprove.bypass', "Bypass Approvals"), - localize('agentHost.sessionConfig.autoApprove.autopilot', "Autopilot (Preview)"), - ], - enumDescriptions: [ - localize('agentHost.sessionConfig.autoApprove.defaultDescription', "Copilot uses your configured settings"), - localize('agentHost.sessionConfig.autoApprove.bypassDescription', "All tool calls are auto-approved"), - localize('agentHost.sessionConfig.autoApprove.autopilotDescription', "Autonomously iterates from start to finish"), - ], - default: 'default', - sessionMutable: true, - }, - [SessionPermissionManager.PERMISSIONS_CONFIG_KEY]: { - type: 'object', - title: localize('agentHost.sessionConfig.permissions', "Permissions"), - description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."), - properties: { - allow: { - type: 'array', - title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"), - items: { - type: 'string', - title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), - }, - }, - deny: { - type: 'array', - title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"), - items: { - type: 'string', - title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), - }, - }, - }, - default: { allow: [], deny: [] }, - sessionMutable: true, - }, - }; + const isolationProperty = schemaProperty<'folder' | 'worktree'>({ + type: 'string', + title: localize('agentHost.sessionConfig.isolation', "Isolation"), + description: localize('agentHost.sessionConfig.isolationDescription', "Where the agent should make changes"), + enum: gitInfo ? ['folder', 'worktree'] : ['folder'], + enumLabels: gitInfo ? [localize('agentHost.sessionConfig.isolation.folder', "Folder"), localize('agentHost.sessionConfig.isolation.worktree', "Worktree")] : [localize('agentHost.sessionConfig.isolation.folder', "Folder")], + enumDescriptions: gitInfo ? [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.isolation.worktreeDescription', "Create a Git worktree for isolation")] : [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder")], + default: gitInfo ? 'worktree' : 'folder', + readOnly: !gitInfo, + }); + + // Resolve isolation first — downstream schema shapes (branch's + // read-only mode + enum restriction) depend on the effective value. + const isolationDefault: 'folder' | 'worktree' = gitInfo ? 'worktree' : 'folder'; + const isolationValue = isolationProperty.validate(params.config?.[SessionConfigKey.Isolation]) + ? params.config[SessionConfigKey.Isolation] as 'folder' | 'worktree' + : isolationDefault; + let branchProperty: ISchemaProperty | undefined; + let branchDefault: string | undefined; if (gitInfo) { const branchReadOnly = isolationValue === 'folder'; - const branchForMode = isolationValue === 'worktree' ? gitInfo.defaultBranch : gitInfo.currentBranch; - properties.branch = { + branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch : gitInfo.currentBranch; + branchProperty = schemaProperty({ type: 'string', title: localize('agentHost.sessionConfig.branch', "Branch"), description: localize('agentHost.sessionConfig.branchDescription', "Base branch to work from"), - enum: [branchForMode], - enumLabels: [branchForMode], - default: branchForMode, + enum: [branchDefault], + enumLabels: [branchDefault], + default: branchDefault, enumDynamic: !branchReadOnly, readOnly: branchReadOnly, - }; + }); } + const sessionSchema = createSchema({ + [SessionConfigKey.Isolation]: isolationProperty, + ...platformSessionSchema.definition, + ...(branchProperty ? { [SessionConfigKey.Branch]: branchProperty } : {}), + }); + + const values = sessionSchema.validateOrDefault(params.config, { + [SessionConfigKey.Isolation]: isolationValue, + [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, + [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue, + ...(branchDefault !== undefined ? { [SessionConfigKey.Branch]: branchDefault } : {}), + }); + return { - schema: { type: 'object', properties }, + schema: sessionSchema.toProtocol(), values, }; } @@ -1046,12 +1005,12 @@ export class CopilotAgent extends Disposable implements IAgent { } const worktreesRoot = getCopilotWorktreesRoot(repositoryRoot); - const branchNameHintRaw = config.config[AgentHostSessionConfigBranchNameHintKey]; + const branchNameHintRaw = config.config[SessionConfigKey.BranchNameHint]; const branchNameHint = typeof branchNameHintRaw === 'string' ? branchNameHintRaw : undefined; const branchName = getCopilotWorktreeBranchName(sessionId, branchNameHint); const worktree = URI.joinPath(worktreesRoot, getCopilotWorktreeName(branchName)); await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); - const baseBranch = typeof config.config.branch === 'string' ? config.config.branch : undefined; + const baseBranch = typeof config.config[SessionConfigKey.Branch] === 'string' ? config.config[SessionConfigKey.Branch] as string : undefined; // `addWorktree`'s signature requires a startPoint, but historically the // runtime accepted undefined when `branch` was not set in config. Preserve // that behavior by passing through whatever value (or undefined) was set. diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 7c45e1a8faa9f..c46d2c75c8945 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -476,6 +476,7 @@ export function createShellTools( }, }, overridesBuiltInTool: true, + skipPermission: true, handler: (args) => { const shells = shellManager.listShells(); const shell = args.shell_id @@ -503,6 +504,7 @@ export function createShellTools( required: ['command'], }, overridesBuiltInTool: true, + skipPermission: true, handler: (args) => { const shells = shellManager.listShells(); const shell = shells[shells.length - 1]; @@ -524,6 +526,7 @@ export function createShellTools( }, }, overridesBuiltInTool: true, + skipPermission: true, handler: (args) => { if (args.shell_id) { const success = shellManager.shutdownShell(args.shell_id); @@ -546,6 +549,7 @@ export function createShellTools( description: `List active ${shellType} shell instances.`, parameters: { type: 'object', properties: {} }, overridesBuiltInTool: true, + skipPermission: true, handler: () => { const shells = shellManager.listShells(); if (shells.length === 0) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index b843dd74ac733..a1428617afff2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -150,6 +150,18 @@ const SHELL_TOOL_NAMES: ReadonlySet = new Set([ CopilotToolName.PowerShell, ]); +/** Set of tool names that write input to an interactive shell session. */ +const WRITE_SHELL_TOOL_NAMES: ReadonlySet = new Set([ + CopilotToolName.WriteBash, + CopilotToolName.WritePowerShell, +]); + +/** Set of tool names that read output from an interactive shell session. */ +const READ_SHELL_TOOL_NAMES: ReadonlySet = new Set([ + CopilotToolName.ReadBash, + CopilotToolName.ReadPowerShell, +]); + /** Set of tool names that spawn subagent sessions. */ const SUBAGENT_TOOL_NAMES: ReadonlySet = new Set([ 'task', @@ -242,6 +254,19 @@ export function getInvocationMessage(toolName: string, displayName: string, para return localize('toolInvoke.shell', "Running {0} command", displayName); } + if (WRITE_SHELL_TOOL_NAMES.has(toolName)) { + const args = parameters as ICopilotShellToolArgs | undefined; + if (args?.command) { + const firstLine = args.command.split('\n')[0]; + return md(localize('toolInvoke.writeShellCmd', "Sending {0} to shell", appendEscapedMarkdownInlineCode(truncate(firstLine, 80)))); + } + return localize('toolInvoke.writeShell', "Sending input to shell"); + } + + if (READ_SHELL_TOOL_NAMES.has(toolName)) { + return localize('toolInvoke.readShell', "Reading shell output"); + } + switch (toolName) { case CopilotToolName.View: { const args = parameters as ICopilotViewToolArgs | undefined; @@ -308,6 +333,19 @@ export function getPastTenseMessage(toolName: string, displayName: string, param return localize('toolComplete.shell', "Ran {0} command", displayName); } + if (WRITE_SHELL_TOOL_NAMES.has(toolName)) { + const args = parameters as ICopilotShellToolArgs | undefined; + if (args?.command) { + const firstLine = args.command.split('\n')[0]; + return md(localize('toolComplete.writeShellCmd', "Sent {0} to shell", appendEscapedMarkdownInlineCode(truncate(firstLine, 80)))); + } + return localize('toolComplete.writeShell', "Sent input to shell"); + } + + if (READ_SHELL_TOOL_NAMES.has(toolName)) { + return localize('toolComplete.readShell', "Read shell output"); + } + switch (toolName) { case CopilotToolName.View: { const args = parameters as ICopilotViewToolArgs | undefined; @@ -365,7 +403,7 @@ export function getToolInputString(toolName: string, parameters: Record | undefined)?.args; @@ -439,7 +477,9 @@ export function getSubagentMetadata(parameters: Record | undefi */ export function getShellLanguage(toolName: string): string { switch (toolName) { - case CopilotToolName.PowerShell: return 'powershell'; + case CopilotToolName.PowerShell: + case CopilotToolName.WritePowerShell: + case CopilotToolName.ReadPowerShell: return 'powershell'; default: return 'shellscript'; } } diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 87bc9105c6cf6..eaf18711995dd 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -10,14 +10,16 @@ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import type { IAgentToolReadyEvent } from '../common/agentService.js'; +import { platformSessionSchema } from '../common/agentHostSchema.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js'; import { ResponsePartKind, ToolCallConfirmationReason, - parseSubagentSessionUri, type URI as ProtocolURI, } from '../common/state/sessionState.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { CommandAutoApprover } from './commandAutoApprover.js'; @@ -34,6 +36,25 @@ export interface IToolApprovalEvent { readonly toolInput?: string; } +/** Standard per-tool confirmation options presented to the user. */ +const ALLOW_SESSION_OPTION_ID = 'allow-session'; +const CONFIRMATION_OPTIONS: readonly ConfirmationOption[] = [ + { id: ALLOW_SESSION_OPTION_ID, label: localize('sessionPermissions.allowSession', "Allow in this Session"), kind: ConfirmationOptionKind.Approve, group: 1 }, + { id: 'allow-once', label: localize('sessionPermissions.allowOnce', "Allow Once"), kind: ConfirmationOptionKind.Approve }, + { id: 'skip', label: localize('sessionPermissions.skip', "Skip"), kind: ConfirmationOptionKind.Deny, group: 2 }, +]; + +/** Default write-path glob rules applied to auto-approved edits. */ +const DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { + '**/*': true, + '**/.vscode/*.json': false, + '**/.git/**': false, + '**/{package.json,server.xml,build.rs,web.config,.gitattributes,.env}': false, + '**/*.{code-workspace,csproj,fsproj,vbproj,vcxproj,proj,targets,props}': false, + '**/*.lock': false, + '**/*-lock.{yaml,json}': false, +}; + /** * Single entry point for all tool-call approval logic in the agent host. * @@ -56,32 +77,15 @@ export interface IToolApprovalEvent { */ export class SessionPermissionManager extends Disposable { - static readonly PERMISSIONS_CONFIG_KEY = 'permissions'; - static readonly ALLOW_SESSION_OPTION_ID = 'allow-session'; - - private static readonly _CONFIRMATION_OPTIONS: readonly ConfirmationOption[] = [ - { id: SessionPermissionManager.ALLOW_SESSION_OPTION_ID, label: localize('sessionPermissions.allowSession', "Allow in this Session"), kind: ConfirmationOptionKind.Approve, group: 1 }, - { id: 'allow-once', label: localize('sessionPermissions.allowOnce', "Allow Once"), kind: ConfirmationOptionKind.Approve }, - { id: 'skip', label: localize('sessionPermissions.skip', "Skip"), kind: ConfirmationOptionKind.Deny, group: 2 }, - ]; // ---- Edit auto-approve patterns ----------------------------------------- - private static readonly _DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { - '**/*': true, - '**/.vscode/*.json': false, - '**/.git/**': false, - '**/{package.json,server.xml,build.rs,web.config,.gitattributes,.env}': false, - '**/*.{code-workspace,csproj,fsproj,vbproj,vcxproj,proj,targets,props}': false, - '**/*.lock': false, - '**/*-lock.{yaml,json}': false, - }; - private readonly _commandAutoApprover: CommandAutoApprover; constructor( private readonly _stateManager: AgentHostStateManager, - private readonly _logService: ILogService, + @IAgentConfigurationService private readonly _configService: IAgentConfigurationService, + @ILogService private readonly _logService: ILogService, ) { super(); this._commandAutoApprover = this._register(new CommandAutoApprover(this._logService)); @@ -112,7 +116,8 @@ export class SessionPermissionManager extends Disposable { * 5. Shell command rules (tree-sitter parsed, default allow/deny) */ getAutoApproval(e: IToolApprovalEvent, sessionKey: ProtocolURI): ToolCallConfirmationReason | undefined { - const { autoApproveLevel, workDir } = this._getInheritedConfig(sessionKey); + const autoApproveLevel = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove); + const workDir = this._configService.getEffectiveWorkingDirectory(sessionKey); // 1. Session-level auto-approve if (autoApproveLevel === 'autoApprove' || autoApproveLevel === 'autopilot') { @@ -178,7 +183,7 @@ export class SessionPermissionManager extends Disposable { toolInput: e.toolInput, confirmationTitle: e.confirmationTitle, edits: e.edits, - options: SessionPermissionManager._CONFIRMATION_OPTIONS.slice(), + options: CONFIRMATION_OPTIONS.slice(), }; } return { @@ -200,7 +205,7 @@ export class SessionPermissionManager extends Disposable { * permission allow list so future calls are auto-approved. */ handleToolCallConfirmed(sessionKey: ProtocolURI, toolCallId: string, selectedOptionId: string | undefined): void { - if (selectedOptionId === SessionPermissionManager.ALLOW_SESSION_OPTION_ID) { + if (selectedOptionId === ALLOW_SESSION_OPTION_ID) { const toolName = this._getToolNameForToolCall(sessionKey, toolCallId); if (toolName) { this._addToolToSessionPermissions(sessionKey, toolName); @@ -210,16 +215,6 @@ export class SessionPermissionManager extends Disposable { // ---- Internal helpers --------------------------------------------------- - private _getInheritedConfig(sessionKey: ProtocolURI): { autoApproveLevel: unknown | undefined; workDir: string | undefined } { - const sessionState = this._stateManager.getSessionState(sessionKey); - const parentInfo = parseSubagentSessionUri(sessionKey); - const parentState = parentInfo ? this._stateManager.getSessionState(parentInfo.parentSession) : undefined; - return { - autoApproveLevel: sessionState?.config?.values?.autoApprove ?? parentState?.config?.values?.autoApprove, - workDir: sessionState?.summary.workingDirectory ?? parentState?.summary.workingDirectory, - }; - } - private _isPathInWorkingDirectory(filePath: string, workDir: string | undefined): boolean { if (!workDir) { return false; @@ -229,9 +224,8 @@ export class SessionPermissionManager extends Disposable { } private _isEditAutoApproved(filePath: string): boolean { - const patterns = SessionPermissionManager._DEFAULT_EDIT_AUTO_APPROVE_PATTERNS; let approved = true; - for (const [pattern, isApproved] of Object.entries(patterns)) { + for (const [pattern, isApproved] of Object.entries(DEFAULT_EDIT_AUTO_APPROVE_PATTERNS)) { if (isApproved !== approved && globMatch(pattern, filePath)) { approved = isApproved; } @@ -244,29 +238,14 @@ export class SessionPermissionManager extends Disposable { if (!toolName) { return false; } - const allowed = this._getPermissions(sessionKey).allow.includes(toolName); + const permissions = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Permissions); + const allowed = permissions?.allow.includes(toolName) ?? false; if (allowed) { this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via session permissions`); } return allowed; } - private _getPermissions(sessionKey: ProtocolURI): { allow: string[]; deny: string[] } { - const sessionState = this._stateManager.getSessionState(sessionKey); - const parentInfo = parseSubagentSessionUri(sessionKey); - const parentState = parentInfo ? this._stateManager.getSessionState(parentInfo.parentSession) : undefined; - const raw = sessionState?.config?.values?.[SessionPermissionManager.PERMISSIONS_CONFIG_KEY] - ?? parentState?.config?.values?.[SessionPermissionManager.PERMISSIONS_CONFIG_KEY]; - if (raw && typeof raw === 'object' && !Array.isArray(raw)) { - const obj = raw as Record; - return { - allow: Array.isArray(obj.allow) ? obj.allow.filter((v): v is string => typeof v === 'string') : [], - deny: Array.isArray(obj.deny) ? obj.deny.filter((v): v is string => typeof v === 'string') : [], - }; - } - return { allow: [], deny: [] }; - } - private _getToolNameForToolCall(sessionKey: ProtocolURI, toolCallId: string): string | undefined { const sessionState = this._stateManager.getSessionState(sessionKey); const parts = sessionState?.activeTurn?.responseParts; @@ -282,16 +261,15 @@ export class SessionPermissionManager extends Disposable { } private _addToolToSessionPermissions(sessionKey: ProtocolURI, toolName: string): void { - const permissions = this._getPermissions(sessionKey); + const permissions = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Permissions) + ?? { allow: [], deny: [] }; if (permissions.allow.includes(toolName)) { return; } - permissions.allow.push(toolName); - this._stateManager.dispatchServerAction({ - type: ActionType.SessionConfigChanged, - session: sessionKey, - config: { - [SessionPermissionManager.PERMISSIONS_CONFIG_KEY]: permissions, + this._configService.updateSessionConfig(sessionKey, { + [SessionConfigKey.Permissions]: { + allow: [...permissions.allow, toolName], + deny: [...permissions.deny], }, }); this._logService.info(`[SessionPermissionManager] Added "${toolName}" to session permissions for ${sessionKey}`); diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts new file mode 100644 index 0000000000000..8a3f806f1536e --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -0,0 +1,281 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { createSchema, platformSessionSchema, schemaProperty, type AutoApproveLevel, type IPermissionsValue } from '../../common/agentHostSchema.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; + +/** + * Invokes `fn` and returns the thrown {@link ProtocolError}. Avoids + * passing an arrow-function validator to `assert.throws` — the unit-test + * assert shim does `actual instanceof expected` with that validator, and + * arrow functions have no `prototype` property, which WebKit rejects. + */ +function captureProtocolError(fn: () => void): ProtocolError { + try { + fn(); + } catch (err) { + assert.ok(err instanceof ProtocolError, `expected ProtocolError, got: ${err}`); + return err; + } + assert.fail('expected fn to throw, but it did not'); +} + +suite('agentHostSchema', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- schemaProperty / individual validators --------------------------- + + suite('schemaProperty', () => { + + test('validates primitive types', () => { + const str = schemaProperty({ type: 'string', title: 's' }); + assert.strictEqual(str.validate('hello'), true); + assert.strictEqual(str.validate(42), false); + assert.strictEqual(str.validate(undefined), false); + assert.strictEqual(str.validate(null), false); + + const num = schemaProperty({ type: 'number', title: 'n' }); + assert.strictEqual(num.validate(42), true); + assert.strictEqual(num.validate('42'), false); + + const bool = schemaProperty({ type: 'boolean', title: 'b' }); + assert.strictEqual(bool.validate(true), true); + assert.strictEqual(bool.validate(0), false); + }); + + test('enforces enum values', () => { + const prop = schemaProperty<'a' | 'b'>({ + type: 'string', + title: 'letters', + enum: ['a', 'b'], + }); + assert.strictEqual(prop.validate('a'), true); + assert.strictEqual(prop.validate('b'), true); + assert.strictEqual(prop.validate('c'), false); + assert.strictEqual(prop.validate(42), false); + }); + + test('enumDynamic bypasses enum check but keeps type check', () => { + const prop = schemaProperty({ + type: 'string', + title: 'dyn', + enum: ['seed'], + enumDynamic: true, + }); + assert.strictEqual(prop.validate('seed'), true); + assert.strictEqual(prop.validate('anything-else'), true); + assert.strictEqual(prop.validate(42), false); + }); + + test('validates nested objects and required keys', () => { + const prop = schemaProperty<{ name: string; age?: number }>({ + type: 'object', + title: 'person', + properties: { + name: { type: 'string', title: 'name' }, + age: { type: 'number', title: 'age' }, + }, + required: ['name'], + }); + assert.strictEqual(prop.validate({ name: 'alice' }), true); + assert.strictEqual(prop.validate({ name: 'alice', age: 30 }), true); + assert.strictEqual(prop.validate({ age: 30 }), false); + assert.strictEqual(prop.validate({ name: 42 }), false); + assert.strictEqual(prop.validate([]), false); + assert.strictEqual(prop.validate(null), false); + }); + + test('validates arrays with item schema', () => { + const prop = schemaProperty({ + type: 'array', + title: 'names', + items: { type: 'string', title: 'name' }, + }); + assert.strictEqual(prop.validate(['a', 'b']), true); + assert.strictEqual(prop.validate([]), true); + assert.strictEqual(prop.validate(['a', 42]), false); + assert.strictEqual(prop.validate('a'), false); + }); + + test('assertValid throws ProtocolError with offending path for primitive mismatch', () => { + const prop = schemaProperty({ type: 'string', title: 's' }); + const err = captureProtocolError(() => prop.assertValid(42, 'myKey')); + assert.strictEqual(err.code, JsonRpcErrorCodes.InvalidParams); + assert.ok(err.message.includes('myKey'), err.message); + assert.ok(err.message.includes('string'), err.message); + }); + + test('assertValid path annotates array index and nested property', () => { + const prop = schemaProperty<{ allow: string[] }>({ + type: 'object', + title: 'perms', + properties: { + allow: { + type: 'array', + title: 'allow', + items: { type: 'string', title: 'name' }, + }, + }, + }); + const err = captureProtocolError(() => prop.assertValid({ allow: ['ok', 42] }, 'permissions')); + assert.ok(err.message.includes('permissions.allow[1]'), err.message); + assert.ok(err.message.includes('string'), err.message); + }); + + test('assertValid path reports missing required property', () => { + const prop = schemaProperty<{ name: string }>({ + type: 'object', + title: 'person', + properties: { name: { type: 'string', title: 'name' } }, + required: ['name'], + }); + const err = captureProtocolError(() => prop.assertValid({}, 'person')); + assert.ok(err.message.includes('person.name'), err.message); + assert.ok(err.message.toLowerCase().includes('required'), err.message); + }); + + test('assertValid reports enum violation with the allowed set', () => { + const prop = schemaProperty<'a' | 'b'>({ + type: 'string', + title: 'letters', + enum: ['a', 'b'], + }); + const err = captureProtocolError(() => prop.assertValid('c', 'choice')); + assert.ok(err.message.includes('choice'), err.message); + assert.ok(err.message.includes('"a"'), err.message); + assert.ok(err.message.includes('"b"'), err.message); + }); + }); + + // ---- createSchema ------------------------------------------------------ + + suite('createSchema', () => { + + const fixture = () => createSchema({ + name: schemaProperty({ type: 'string', title: 'name' }), + count: schemaProperty({ type: 'number', title: 'count' }), + level: schemaProperty<'low' | 'high'>({ + type: 'string', + title: 'level', + enum: ['low', 'high'], + }), + }); + + test('toProtocol emits a JSON-Schema-compatible object', () => { + const schema = fixture(); + const protocol = schema.toProtocol(); + assert.strictEqual(protocol.type, 'object'); + assert.deepStrictEqual(Object.keys(protocol.properties), ['name', 'count', 'level']); + assert.strictEqual(protocol.properties.name.type, 'string'); + assert.deepStrictEqual(protocol.properties.level.enum, ['low', 'high']); + }); + + test('validate returns false for unknown keys', () => { + const schema = fixture(); + assert.strictEqual(schema.validate('name', 'ok'), true); + assert.strictEqual(schema.validate('name', 42), false); + assert.strictEqual(schema.validate('unknown' as 'name', 'ok'), false); + }); + + test('assertValid throws for unknown keys', () => { + const schema = fixture(); + const err = captureProtocolError(() => schema.assertValid('unknown' as 'name', 'x')); + assert.ok(err.message.includes('unknown'), err.message); + }); + + test('values returns a shallow copy and passes through unknown keys', () => { + const schema = fixture(); + const input = { name: 'alice', count: 3, extra: 'forward-compat' }; + const out = schema.values(input); + assert.notStrictEqual(out, input); + assert.deepStrictEqual(out, input); + }); + + test('values skips undefined entries without throwing', () => { + const schema = fixture(); + const out = schema.values({ name: 'alice' }); + assert.deepStrictEqual(out, { name: 'alice' }); + }); + + test('values throws a path-annotated ProtocolError on invalid entry', () => { + const schema = fixture(); + const err = captureProtocolError(() => schema.values({ name: 42 as unknown as string })); + assert.strictEqual(err.code, JsonRpcErrorCodes.InvalidParams); + assert.ok(err.message.includes('name'), err.message); + }); + + test('definition is preserved for spread-based composition', () => { + const base = createSchema({ + a: schemaProperty({ type: 'string', title: 'a' }), + }); + const extended = createSchema({ + ...base.definition, + b: schemaProperty({ type: 'number', title: 'b' }), + }); + assert.deepStrictEqual(Object.keys(extended.toProtocol().properties), ['a', 'b']); + assert.strictEqual(extended.validate('a', 'hi'), true); + assert.strictEqual(extended.validate('b', 3), true); + }); + }); + + // ---- validateOrDefault ------------------------------------------------- + + suite('validateOrDefault', () => { + + const fixture = () => createSchema({ + name: schemaProperty({ type: 'string', title: 'name' }), + count: schemaProperty({ type: 'number', title: 'count' }), + }); + + test('substitutes defaults for missing or invalid values', () => { + const schema = fixture(); + const defaults = { name: 'default', count: 0 }; + const result = schema.validateOrDefault({ name: 42, count: 5 }, defaults); + assert.deepStrictEqual(result, { name: 'default', count: 5 }); + }); + + test('passes through all-valid values', () => { + const schema = fixture(); + const result = schema.validateOrDefault({ name: 'alice', count: 3 }, { name: 'd', count: 0 }); + assert.deepStrictEqual(result, { name: 'alice', count: 3 }); + }); + + test('uses defaults when input is undefined', () => { + const schema = fixture(); + const result = schema.validateOrDefault(undefined, { name: 'd', count: 7 }); + assert.deepStrictEqual(result, { name: 'd', count: 7 }); + }); + + test('ignores keys not in defaults', () => { + const schema = fixture(); + const result = schema.validateOrDefault({ name: 'a', count: 1, ignored: true }, { name: 'd', count: 0 }); + assert.deepStrictEqual(result, { name: 'a', count: 1 }); + }); + }); + + // ---- platformSessionSchema sanity -------------------------------------- + + suite('platformSessionSchema', () => { + + test('validates the three autoApprove levels', () => { + const levels: AutoApproveLevel[] = ['default', 'autoApprove', 'autopilot']; + for (const level of levels) { + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.AutoApprove, level), true, level); + } + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.AutoApprove, 'bogus'), false); + }); + + test('validates permissions shape', () => { + const ok: IPermissionsValue = { allow: ['read'], deny: [] }; + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, ok), true); + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, { allow: [42], deny: [] }), false); + assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, { allow: [] }), true); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts new file mode 100644 index 0000000000000..09db742e06d21 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { createSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import type { SessionConfigState, RootConfigState } from '../../common/state/protocol/state.js'; +import { buildSubagentSessionUri, SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; +import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; + +suite('AgentConfigurationService', () => { + + const disposables = new DisposableStore(); + let manager: AgentHostStateManager; + let service: AgentConfigurationService; + + const schema = createSchema({ + level: schemaProperty<'low' | 'high'>({ + type: 'string', + title: 'level', + enum: ['low', 'high'], + }), + limit: schemaProperty({ type: 'number', title: 'limit' }), + }); + + function seedSessionConfig(sessionUri: string, values: Record): void { + const state = manager.getSessionState(sessionUri); + assert.ok(state, `Session not found: ${sessionUri}`); + const mutable = state as { config?: SessionConfigState }; + mutable.config = { + schema: schema.toProtocol(), + values, + }; + } + + function seedRootConfig(values: Record): void { + const rootMutable = manager.rootState as { config?: RootConfigState }; + rootMutable.config = { + schema: schema.toProtocol(), + values, + }; + } + + function makeSummary(resource: string, workingDirectory?: string): SessionSummary { + return { + resource, + provider: 'copilot', + title: 't', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + project: { uri: 'file:///project', displayName: 'Project' }, + workingDirectory, + }; + } + + setup(() => { + manager = disposables.add(new AgentHostStateManager(new NullLogService())); + service = disposables.add(new AgentConfigurationService(manager, new NullLogService())); + }); + + teardown(() => disposables.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- getEffectiveValue ------------------------------------------------ + + suite('getEffectiveValue', () => { + + test('returns session value when present', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + seedSessionConfig(uri, { level: 'high' }); + assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), 'high'); + }); + + test('falls back to host value when session does not provide the key', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + seedSessionConfig(uri, { limit: 5 }); + seedRootConfig({ level: 'low' }); + assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), 'low'); + }); + + test('inherits from parent subagent session', () => { + const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); + manager.createSession(makeSummary(parent)); + seedSessionConfig(parent, { level: 'high' }); + + const child = buildSubagentSessionUri(parent, 'toolcall-1'); + manager.createSession(makeSummary(child)); + + assert.strictEqual(service.getEffectiveValue(child, schema, 'level'), 'high'); + }); + + test('session value takes precedence over parent and host', () => { + const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); + manager.createSession(makeSummary(parent)); + seedSessionConfig(parent, { level: 'high' }); + + const child = buildSubagentSessionUri(parent, 'tc-2'); + manager.createSession(makeSummary(child)); + seedSessionConfig(child, { level: 'low' }); + seedRootConfig({ level: 'high' }); + + assert.strictEqual(service.getEffectiveValue(child, schema, 'level'), 'low'); + }); + + test('skips layers whose value fails schema validation and falls through', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + seedSessionConfig(uri, { level: 'bogus' }); + seedRootConfig({ level: 'high' }); + assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), 'high'); + }); + + test('returns undefined when no layer provides a valid value', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + seedSessionConfig(uri, {}); + assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), undefined); + }); + }); + + // ---- getEffectiveWorkingDirectory ------------------------------------- + + suite('getEffectiveWorkingDirectory', () => { + + test('returns session working directory when set', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri, 'file:///work')); + assert.strictEqual(service.getEffectiveWorkingDirectory(uri), 'file:///work'); + }); + + test('falls back to parent session working directory for subagents', () => { + const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); + manager.createSession(makeSummary(parent, 'file:///work/parent')); + + const child = buildSubagentSessionUri(parent, 'tc-3'); + manager.createSession(makeSummary(child)); + assert.strictEqual(service.getEffectiveWorkingDirectory(child), 'file:///work/parent'); + }); + + test('returns undefined when neither layer has a working directory', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + assert.strictEqual(service.getEffectiveWorkingDirectory(uri), undefined); + }); + }); + + // ---- updateSessionConfig ---------------------------------------------- + + suite('updateSessionConfig', () => { + + test('merges the patch into the session config values', () => { + const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); + manager.createSession(makeSummary(uri)); + seedSessionConfig(uri, { level: 'low', limit: 1 }); + + service.updateSessionConfig(uri, { limit: 42 }); + + const state = manager.getSessionState(uri); + assert.deepStrictEqual(state?.config?.values, { level: 'low', limit: 42 }); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 6ab9afc7f4fbe..b34ea5d47bacf 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -14,14 +14,17 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { NullLogService } from '../../../log/common/log.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js'; import { buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; +import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentService } from '../../node/agentService.js'; -import { AgentSideEffects } from '../../node/agentSideEffects.js'; +import { AgentSideEffects, IAgentSideEffectsOptions } from '../../node/agentSideEffects.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { createNullSessionDataService, createSessionDataService } from '../common/sessionTestHelpers.js'; @@ -29,6 +32,21 @@ import { MockAgent } from './mockAgent.js'; // ---- Tests ------------------------------------------------------------------ +/** + * Constructs an {@link AgentSideEffects} with a minimal local instantiation + * scope that satisfies its {@link IAgentConfigurationService} / + * {@link ILogService} dependencies. + */ +function createTestSideEffects(disposables: DisposableStore, stateManager: AgentHostStateManager, options: IAgentSideEffectsOptions): AgentSideEffects { + const logService = new NullLogService(); + const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( + [ILogService, logService], + [IAgentConfigurationService, configService], + ), /*strict*/ true)); + return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, options)); +} + suite('AgentSideEffects', () => { const disposables = new DisposableStore(); @@ -75,11 +93,11 @@ suite('AgentSideEffects', () => { disposables.add(toDisposable(() => agent.dispose())); stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); agentList = observableValue('agents', [agent]); - sideEffects = disposables.add(new AgentSideEffects(stateManager, { + sideEffects = createTestSideEffects(disposables, stateManager, { getAgent: () => agent, agents: agentList, sessionDataService: createNullSessionDataService(), - }, new NullLogService())); + }); }); teardown(() => { @@ -110,11 +128,11 @@ suite('AgentSideEffects', () => { test('dispatches session/error when no agent is found', async () => { setupSession(); const emptyAgents = observableValue('agents', []); - const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, { + const noAgentSideEffects = createTestSideEffects(disposables, stateManager, { getAgent: () => undefined, agents: emptyAgents, sessionDataService: {} as ISessionDataService, - }, new NullLogService())); + }); const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); @@ -1166,11 +1184,11 @@ suite('AgentSideEffects', () => { const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const localAgent = new MockAgent(); disposables.add(toDisposable(() => localAgent.dispose())); - const localSideEffects = disposables.add(new AgentSideEffects(localStateManager, { + const localSideEffects = createTestSideEffects(disposables, localStateManager, { getAgent: () => localAgent, agents: observableValue('agents', [localAgent]), sessionDataService, - }, new NullLogService())); + }); localStateManager.createSession({ resource: sessionUri.toString(), @@ -1247,11 +1265,11 @@ suite('AgentSideEffects', () => { const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const localAgent = new MockAgent(); disposables.add(toDisposable(() => localAgent.dispose())); - const localSideEffects = disposables.add(new AgentSideEffects(localStateManager, { + const localSideEffects = createTestSideEffects(disposables, localStateManager, { getAgent: () => localAgent, agents: observableValue('agents', [localAgent]), sessionDataService, - }, new NullLogService())); + }); const session = localStateManager.createSession({ resource: sessionUri.toString(), diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index 5aee3be444229..86d2c10c713ff 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getInvocationMessage, getPastTenseMessage, getPermissionDisplay, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; +import { getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolInputString, getToolKind, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; suite('getPermissionDisplay — cd-prefix stripping', () => { @@ -124,3 +124,172 @@ suite('view tool — view_range display', () => { assert.ok(!invocation({ path: '/repo/file.ts', view_range: 'whatever' }).includes(',')); }); }); + +// ---- write_/read_ shell tool display --------------------------------------- +// +// Coverage for the secondary shell helpers (write_bash, read_bash, and their +// powershell siblings). These never appear in a permission dialog (they're +// registered with `skipPermission: true` — see copilotShellTools.ts), but they +// still flow through the tool-execution display pipeline. + +suite('copilotToolDisplay — write_/read_ shell tools', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getToolKind', () => { + + test('returns terminal for bash', () => { + assert.strictEqual(getToolKind('bash'), 'terminal'); + }); + + test('returns terminal for powershell', () => { + assert.strictEqual(getToolKind('powershell'), 'terminal'); + }); + + test('returns undefined for write_bash (sending input to a running program, not launching a terminal)', () => { + assert.strictEqual(getToolKind('write_bash'), undefined); + }); + + test('returns undefined for write_powershell', () => { + assert.strictEqual(getToolKind('write_powershell'), undefined); + }); + + test('returns undefined for read_bash (reading output, not launching a terminal)', () => { + assert.strictEqual(getToolKind('read_bash'), undefined); + }); + + test('returns undefined for read_powershell', () => { + assert.strictEqual(getToolKind('read_powershell'), undefined); + }); + + test('returns subagent for task', () => { + assert.strictEqual(getToolKind('task'), 'subagent'); + }); + + test('returns undefined for view', () => { + assert.strictEqual(getToolKind('view'), undefined); + }); + }); + + suite('getShellLanguage', () => { + + test('bash returns shellscript', () => { + assert.strictEqual(getShellLanguage('bash'), 'shellscript'); + }); + + test('powershell returns powershell', () => { + assert.strictEqual(getShellLanguage('powershell'), 'powershell'); + }); + + test('write_bash returns shellscript', () => { + assert.strictEqual(getShellLanguage('write_bash'), 'shellscript'); + }); + + test('write_powershell returns powershell', () => { + assert.strictEqual(getShellLanguage('write_powershell'), 'powershell'); + }); + + test('read_bash returns shellscript', () => { + assert.strictEqual(getShellLanguage('read_bash'), 'shellscript'); + }); + + test('read_powershell returns powershell', () => { + assert.strictEqual(getShellLanguage('read_powershell'), 'powershell'); + }); + }); + + suite('getInvocationMessage', () => { + + function getText(msg: ReturnType): string { + return typeof msg === 'string' ? msg : msg.markdown; + } + + test('write_bash with command includes the command text', () => { + const msg = getInvocationMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }); + assert.ok(getText(msg).includes('echo hello'), `expected 'echo hello' in: ${getText(msg)}`); + }); + + test('write_bash without command returns a non-empty fallback message', () => { + const msg = getInvocationMessage('write_bash', 'Write Shell Input', undefined); + assert.ok(getText(msg).length > 0); + assert.ok(!getText(msg).includes('undefined')); + }); + + test('write_powershell with command includes the command text', () => { + const msg = getInvocationMessage('write_powershell', 'Write Shell Input', { command: 'Get-Date' }); + assert.ok(getText(msg).includes('Get-Date'), `expected 'Get-Date' in: ${getText(msg)}`); + }); + + test('read_bash returns a non-empty message', () => { + const msg = getInvocationMessage('read_bash', 'Read Shell Output', undefined); + assert.ok(getText(msg).length > 0); + }); + + test('read_powershell returns a non-empty message', () => { + const msg = getInvocationMessage('read_powershell', 'Read Shell Output', undefined); + assert.ok(getText(msg).length > 0); + }); + + test('write_bash message differs from bash message (distinct wording)', () => { + const writeBashMsg = getText(getInvocationMessage('write_bash', 'Write Shell Input', { command: 'echo hi' })); + const bashMsg = getText(getInvocationMessage('bash', 'Bash', { command: 'echo hi' })); + // Both include the command, but the surrounding text should differ + assert.notStrictEqual(writeBashMsg, bashMsg); + }); + }); + + suite('getPastTenseMessage', () => { + + function getText(msg: ReturnType): string { + return typeof msg === 'string' ? msg : msg.markdown; + } + + test('write_bash with command includes the command text', () => { + const msg = getPastTenseMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }, true); + assert.ok(getText(msg).includes('echo hello'), `expected 'echo hello' in: ${getText(msg)}`); + }); + + test('write_bash without command returns a non-empty fallback message', () => { + const msg = getPastTenseMessage('write_bash', 'Write Shell Input', undefined, true); + assert.ok(getText(msg).length > 0); + }); + + test('write_powershell with command includes the command text', () => { + const msg = getPastTenseMessage('write_powershell', 'Write Shell Input', { command: 'Get-Date' }, true); + assert.ok(getText(msg).includes('Get-Date'), `expected 'Get-Date' in: ${getText(msg)}`); + }); + + test('read_bash success returns a non-empty message', () => { + const msg = getPastTenseMessage('read_bash', 'Read Shell Output', undefined, true); + assert.ok(getText(msg).length > 0); + }); + + test('write_bash failure returns a non-empty error message', () => { + const msg = getPastTenseMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }, false); + assert.ok(getText(msg).length > 0); + }); + }); + + suite('getToolInputString', () => { + + test('write_bash extracts command field', () => { + assert.strictEqual(getToolInputString('write_bash', { command: 'echo hello' }, undefined), 'echo hello'); + }); + + test('write_powershell extracts command field', () => { + assert.strictEqual(getToolInputString('write_powershell', { command: 'Get-Date' }, undefined), 'Get-Date'); + }); + + test('write_bash falls back to rawArguments when no command field', () => { + assert.strictEqual(getToolInputString('write_bash', {}, '{"command":"echo hello"}'), '{"command":"echo hello"}'); + }); + + test('write_bash returns undefined when both parameters and rawArguments are absent', () => { + assert.strictEqual(getToolInputString('write_bash', undefined, undefined), undefined); + }); + + test('read_bash with no parameters returns undefined', () => { + assert.strictEqual(getToolInputString('read_bash', undefined, undefined), undefined); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts index abf7cc2ed1889..f062eb973f5f6 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts @@ -253,6 +253,149 @@ function terminalText(state: TerminalState): string { return removeAnsiEscapeCodes(state.content.map(part => part.type === 'command' ? `${part.commandLine}\n${part.output}` : part.value).join('')); } +/** Looks up the toolName for a toolCallReady by joining against the matching toolCallStart. */ +function findToolNameForCall(c: TestProtocolClient, toolCallId: string): string | undefined { + return c.receivedNotifications(n => isActionNotification(n, 'session/toolCallStart')) + .map(n => getActionEnvelope(n).action as SessionToolCallStartAction) + .find(a => a.toolCallId === toolCallId)?.toolName; +} + +interface IApprovalRule { + /** Tool name this rule applies to (e.g. `'bash'`, `'write_bash'`). */ + toolName: string; + /** Optional predicate over the tool input. If omitted, any input matches. */ + matchInput?: (toolInput: string | undefined) => boolean; + /** + * Optional inspector run for every matched call before approval. + * Push assertion failure messages onto `errors` to fail the test. + */ + inspect?: (info: { + action: SessionToolCallReadyAction; + errors: string[]; + }) => void; +} + +interface IBackgroundApprovalLoopOptions { + /** Starting clientSeq for dispatched toolCallConfirmed actions. Avoids collisions with the test's own dispatches. */ + approvalSeqStart: number; + /** + * Allow-list of tool calls the loop is permitted to auto-approve. Each + * pending confirmation must match exactly one rule (by `toolName` plus + * optional `matchInput` predicate). Calls that don't match are recorded + * as errors and denied — the loop refuses to rubber-stamp anything the + * test didn't anticipate (e.g. an unexpected `rm` from the model). + */ + allow: readonly IApprovalRule[]; +} + +interface IBackgroundApprovalLoop { + /** Errors collected during the run (unmatched tool calls + inspector failures). */ + readonly errors: readonly string[]; + /** Tool names that were observed and approved at least once. */ + readonly approvedToolNames: ReadonlySet; + /** + * Tool names for every permission request observed by the loop, regardless + * of whether they matched the allow-list. Useful for asserting that a + * tool with `skipPermission: true` never triggered a permission flow. + */ + readonly observedToolNames: ReadonlySet; + /** Stops the loop and waits for it to drain. */ + stop(): Promise; +} + +/** + * Starts a background loop that auto-approves pending tool call confirmations + * during a real-SDK turn, but only if they match the supplied allow-list. + * Anything outside the allow-list is denied and recorded as an error so the + * test fails loudly instead of silently approving model-chosen tool calls. + * + * Implementation note: `waitForNotification` does NOT consume notifications from + * the client's queue, so we dedupe by `serverSeq`. + */ +function startBackgroundApprovalLoop(c: TestProtocolClient, options: IBackgroundApprovalLoopOptions): IBackgroundApprovalLoop { + const errors: string[] = []; + const approvedToolNames = new Set(); + const observedToolNames = new Set(); + const processedSeqs = new Set(); + let active = true; + let approvalSeq = options.approvalSeqStart; + + const loop = (async () => { + while (active) { + try { + const ready = await c.waitForNotification(n => { + if (!isActionNotification(n, 'session/toolCallReady')) { + return false; + } + return !processedSeqs.has(getActionEnvelope(n).serverSeq); + }, 2_000); + const envelope = getActionEnvelope(ready); + processedSeqs.add(envelope.serverSeq); + const action = envelope.action as SessionToolCallReadyAction & { session: string; turnId: string }; + if (action.confirmed) { + continue; + } + + const toolName = findToolNameForCall(c, action.toolCallId); + if (toolName) { + observedToolNames.add(toolName); + } + const matchingRule = options.allow.find(rule => + rule.toolName === toolName + && (rule.matchInput?.(action.toolInput) ?? true)); + + if (!matchingRule) { + errors.push(`unexpected tool call: toolName=${toolName ?? ''} input=${JSON.stringify(action.toolInput)}`); + c.notify('dispatchAction', { + clientSeq: ++approvalSeq, + action: { + type: 'session/toolCallConfirmed', + session: action.session, + turnId: action.turnId, + toolCallId: action.toolCallId, + approved: false, + }, + }); + continue; + } + + matchingRule.inspect?.({ action, errors }); + approvedToolNames.add(matchingRule.toolName); + + c.notify('dispatchAction', { + clientSeq: ++approvalSeq, + action: { + type: 'session/toolCallConfirmed', + session: action.session, + turnId: action.turnId, + toolCallId: action.toolCallId, + approved: true, + }, + }); + } catch (e) { + // Only ignore the expected 2-second poll timeout. Any other error + // (e.g. 'Client closed', exception from matchingRule.inspect) is a + // real failure — record it so the test fails deterministically. + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes('Timed out') && !msg.includes('timed out')) { + errors.push(`approval loop error: ${msg}`); + active = false; + } + } + } + })(); + + return { + errors, + approvedToolNames, + observedToolNames, + async stop(): Promise { + active = false; + await loop; + }, + }; +} + (REAL_SDK_ENABLED ? suite : suite.skip)('Protocol WebSocket — Real Copilot SDK', function () { let server: IServerHandle; @@ -873,4 +1016,88 @@ function terminalText(state: TerminalState): string { } } }); + + // ---- write_bash skipPermission regression test -------------------------- + + test('write_bash never triggers a permission request (skipPermission flag)', async function () { + this.timeout(180_000); + + // What this test verifies: + // `write_bash` (and `read_bash` / `bash_shutdown` / `list_bash`) are + // registered as external tools with `skipPermission: true`, mirroring + // the SDK's built-in shell helpers which never call `permissions.request`. + // This regression test catches accidental removal of that flag — if it's + // removed, the SDK will route write_bash through our permission flow and + // the test will fail with `observedToolNames` containing 'write_bash'. + // + // How it works: + // 1. Allow-list permits ONLY `bash` (the interactive prompt). write_bash + // is intentionally absent from the allow list. + // 2. The model is instructed to use `write_bash`. If any permission + // request appears for write_bash, the loop records it in + // `observedToolNames` and we fail the assertion. + // 3. We assert that bash actually ran AND that write_bash appeared in + // toolCallStart notifications (so the test is non-vacuous — the model + // actually tried to use the tool, not just piped input via bash). + + const tempDir = mkdtempSync(`${tmpdir()}/ahp-write-bash-skip-perm-`); + tempDirs.push(tempDir); + const sessionUri = await createRealSession(client, 'real-sdk-write-bash-skip-perm', createdSessions, URI.file(tempDir).toString()); + + const approvalLoop = startBackgroundApprovalLoop(client, { + approvalSeqStart: 100, + allow: [ + { + // Setup bash command — the interactive `read` prompt. + toolName: 'bash', + matchInput: input => !!input && input.includes('read') && input.includes('Got:'), + }, + // Note: write_bash is intentionally NOT in the allow list. With + // skipPermission: true, the SDK won't ask us — so the test passes. + // Without it, the SDK would ask, the loop would deny + record an + // error, and the test would fail loudly. + ], + }); + + dispatchTurn(client, sessionUri, 'turn-write-bash-skip-perm', + 'You MUST demonstrate the `write_bash` tool. Steps, in order:\n' + + '1. Use the `bash` tool to run exactly: read -p "Enter: " v; echo "Got: $v"\n' + + ' This will block waiting for stdin.\n' + + '2. While that bash call is waiting, you MUST use the `write_bash` tool to send the input "hello\\n" to it.\n' + + ' Do NOT pipe the input via the original bash command. Do NOT use `echo hello | ...`.\n' + + ' You MUST go through the `write_bash` tool — that is the entire point of this task.\n' + + '3. After the shell prints "Got: hello", reply with the single word "done".', + 1); + + await client.waitForNotification( + n => isActionNotification(n, 'session/turnComplete') || isActionNotification(n, 'session/error'), + 150_000, + ); + await approvalLoop.stop(); + + // Sanity check: the bash setup command actually ran. Otherwise the + // model ignored the prompt and the write_bash assertion below is vacuous. + assert.ok(approvalLoop.approvedToolNames.has('bash'), + `expected the model to invoke bash for setup; observed approved tools: ${[...approvalLoop.approvedToolNames].join(', ') || ''}`); + + // Non-vacuousness check: write_bash must have actually been invoked + // (seen in a toolCallStart notification). If the model piped input via + // the original bash command instead of using write_bash, this fails. + const writeBashStarts = client.receivedNotifications(n => isActionNotification(n, 'session/toolCallStart')) + .map(n => getActionEnvelope(n).action as { toolName?: string }) + .filter(a => a.toolName === 'write_bash'); + assert.ok(writeBashStarts.length > 0, + `expected write_bash to be invoked at least once (toolCallStart), but it was never called. The model may have piped input via the original bash command instead.`); + + // The actual regression check: write_bash must never reach our + // permission handler. If this fails, `skipPermission: true` was likely + // removed from copilotShellTools.ts. + assert.ok(!approvalLoop.observedToolNames.has('write_bash'), + `write_bash should be auto-approved by the SDK (skipPermission: true) and never trigger a permission request, but the test observed one. Observed permission requests: ${[...approvalLoop.observedToolNames].join(', ')}`); + + // Any other unexpected permission requests (e.g. an unrelated tool the + // model decided to use) would also have been recorded as errors. + assert.deepStrictEqual(approvalLoop.errors, [], + `unexpected approval-loop errors: ${approvalLoop.errors.join('; ')}`); + }); }); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index ecb40a9e856c0..af4f4acd46027 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -5,7 +5,6 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; const commandPrefix = 'workbench.action.browser'; @@ -81,6 +80,49 @@ export interface IBrowserViewCaptureScreenshotOptions { pageRect?: { x: number; y: number; width: number; height: number }; } +/** + * Identifies who owns a browser view's lifecycle. + * The owner is set at creation time and never changes. + */ +export interface IBrowserViewOwner { + /** The main code window ID that owns this view's lifecycle. */ + readonly mainWindowId: number; +} + +/** + * Summary information about a browser view, including its current state and + * ownership. Returned by the main service when listing or creating views. + */ +export interface IBrowserViewInfo { + readonly id: string; + readonly owner: IBrowserViewOwner; + readonly state: IBrowserViewState; +} + +/** + * Editor opening hints passed from the main process to the workbench. + */ +export interface IBrowserViewOpenOptions { + readonly preserveFocus?: boolean; + readonly background?: boolean; + readonly pinned?: boolean; + /** The parent view ID. Used by the workbench to place the new tab in the same editor group. */ + readonly parentViewId?: string; + /** When set, open in an auxiliary (new) window with these bounds. */ + readonly auxiliaryWindow?: { x?: number; y?: number; width?: number; height?: number }; +} + +export interface IBrowserViewCreatedEvent { + readonly info: IBrowserViewInfo; + readonly openOptions: IBrowserViewOpenOptions; +} + +export interface IBrowserViewCreateOptions { + readonly owner: IBrowserViewOwner; + readonly scope: BrowserViewStorageScope; + readonly initialState?: Partial; +} + export interface IBrowserViewState { url: string; title: string; @@ -161,19 +203,6 @@ export interface IBrowserViewFaviconChangeEvent { favicon: string | undefined; } -export enum BrowserNewPageLocation { - Foreground = 'foreground', - Background = 'background', - NewWindow = 'newWindow' -} -export interface IBrowserViewNewPageRequest { - resource: UriComponents; - url: string; - location: BrowserNewPageLocation; - // Only applicable if location is NewWindow - position?: { x?: number; y?: number; width?: number; height?: number }; -} - export interface IBrowserViewFindInPageOptions { recompute?: boolean; forward?: boolean; @@ -214,6 +243,11 @@ export function browserZoomAccessibilityLabel(zoomFactor: number): string { export const browserViewIsolatedWorldId = 999; export interface IBrowserViewService { + /** + * Fires when a new browser view is created from an internal source (e.g. CDP or window.open). + */ + onDidCreateBrowserView: Event; + /** * Dynamic events that return an Event for a specific browser view ID. */ @@ -225,17 +259,21 @@ export interface IBrowserViewService { onDynamicDidKeyCommand(id: string): Event; onDynamicDidChangeTitle(id: string): Event; onDynamicDidChangeFavicon(id: string): Event; - onDynamicDidRequestNewPage(id: string): Event; onDynamicDidFindInPage(id: string): Event; onDynamicDidClose(id: string): Event; /** - * Get or create a browser view instance + * Get all known browser views with their ownership and state information. + */ + getBrowserViews(windowId?: number): Promise; + + /** + * Get or create a browser view instance. Does not fire `onDidCreateBrowserView`. + * * @param id The browser view identifier - * @param scope The storage scope for the browser view. Ignored if the view already exists. - * @param workspaceId Workspace identifier for session isolation. Only used if scope is 'workspace'. + * @param options Creation options. If a view with the given ID already exists, these options are ignored. */ - getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise; + getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): Promise; /** * Destroy a browser view instance diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 2472e8bba1573..609f4b48de273 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -8,18 +8,24 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IElementData } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IElementData, IBrowserViewOwner, IBrowserViewOpenOptions } from '../common/browserView.js'; import { BrowserViewElementInspector } from './browserViewElementInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { ICodeWindow } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; -import { BrowserViewUri } from '../common/browserViewUri.js'; import { BrowserViewDebugger } from './browserViewDebugger.js'; import { ILogService } from '../../log/common/log.js'; import { BrowserSession } from './browserSession.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; -import { hasKey } from '../../../base/common/types.js'; import { SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { logBrowserOpen } from '../common/browserViewTelemetry.js'; + +enum NewPageLocation { + Foreground = 'foreground', + Background = 'background', + NewWindow = 'newWindow' +} /** * Represents a single browser view instance with its WebContentsView and all associated logic. @@ -37,7 +43,9 @@ export class BrowserView extends Disposable { readonly debugger: BrowserViewDebugger; private readonly _inspector: BrowserViewElementInspector; - private _window: ICodeWindow | IAuxiliaryWindow | undefined; + + private _ownerWindow: ICodeWindow; + private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; @@ -67,9 +75,6 @@ export class BrowserView extends Disposable { private readonly _onDidChangeFavicon = this._register(new Emitter()); readonly onDidChangeFavicon: Event = this._onDidChangeFavicon.event; - private readonly _onDidRequestNewPage = this._register(new Emitter()); - readonly onDidRequestNewPage: Event = this._onDidRequestNewPage.event; - private readonly _onDidFindInPage = this._register(new Emitter()); readonly onDidFindInPage: Event = this._onDidFindInPage.event; @@ -78,13 +83,15 @@ export class BrowserView extends Disposable { constructor( public readonly id: string, + public readonly owner: IBrowserViewOwner, public readonly session: BrowserSession, - createChildView: (options?: Electron.WebContentsViewConstructorOptions) => BrowserView, + createChildView: (url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, openOptions: IBrowserViewOpenOptions) => BrowserView, openContextMenu: (view: BrowserView, params: Electron.ContextMenuParams) => void, options: Electron.WebContentsViewConstructorOptions | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); @@ -108,12 +115,18 @@ export class BrowserView extends Disposable { }); this._view.setBackgroundColor('#FFFFFF'); + this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!; + if (!this._ownerWindow) { + throw new Error(`Window with ID ${owner.mainWindowId} not found`); + } + this._register(this._ownerWindow.onDidClose(() => this.dispose())); + this._view.webContents.setWindowOpenHandler((details) => { const location = (() => { switch (details.disposition) { - case 'background-tab': return BrowserNewPageLocation.Background; - case 'foreground-tab': return BrowserNewPageLocation.Foreground; - case 'new-window': return BrowserNewPageLocation.NewWindow; + case 'background-tab': return NewPageLocation.Background; + case 'foreground-tab': return NewPageLocation.Foreground; + case 'new-window': return NewPageLocation.NewWindow; default: return undefined; } })(); @@ -126,15 +139,21 @@ export class BrowserView extends Disposable { return { action: 'allow', createWindow: (options) => { - const childView = createChildView(options); - const resource = BrowserViewUri.forId(childView.id); - - // Fire event for the workbench to open this view - this._onDidRequestNewPage.fire({ - resource, - url: details.url, - location, - position: { x: options.x, y: options.y, width: options.width, height: options.height } + logBrowserOpen(this.telemetryService, (() => { + switch (location) { + case NewPageLocation.NewWindow: return 'browserLinkNewWindow'; + case NewPageLocation.Background: return 'browserLinkBackground'; + case NewPageLocation.Foreground: return 'browserLinkForeground'; + } + })()); + + const childView = createChildView(details.url, options, { + pinned: true, + background: location === NewPageLocation.Background, + parentViewId: id, + auxiliaryWindow: location === NewPageLocation.NewWindow + ? { x: options.x, y: options.y, width: options.width, height: options.height } + : undefined, }); // Return the webContents so Electron can complete the window.open() call @@ -386,12 +405,12 @@ export class BrowserView extends Disposable { }); } - private consumePopupPermission(location: BrowserNewPageLocation): boolean { + private consumePopupPermission(location: NewPageLocation): boolean { switch (location) { - case BrowserNewPageLocation.Foreground: - case BrowserNewPageLocation.Background: + case NewPageLocation.Foreground: + case NewPageLocation.Background: return true; - case BrowserNewPageLocation.NewWindow: + case NewPageLocation.NewWindow: // Each user gesture allows one popup window within 1 second if (this._lastUserGestureTimestamp > Date.now() - 1000) { this._lastUserGestureTimestamp = -Infinity; @@ -442,11 +461,11 @@ export class BrowserView extends Disposable { * Update the layout bounds of this view */ layout(bounds: IBrowserViewBounds): void { - if (this._window?.win?.id !== bounds.windowId) { + if (this._currentWindow?.win?.id !== bounds.windowId) { const newWindow = this._windowById(bounds.windowId); if (newWindow) { - this._window?.win?.contentView.removeChildView(this._view); - this._window = newWindow; + this._currentWindow?.win?.contentView.removeChildView(this._view); + this._currentWindow = newWindow; newWindow.win?.contentView.addChildView(this._view); } } @@ -476,7 +495,7 @@ export class BrowserView extends Disposable { // If the view is focused, pass focus back to the window when hiding if (!visible && this._view.webContents.isFocused()) { - this._window?.win?.webContents.focus(); + this._currentWindow?.win?.webContents.focus(); } this._view.setVisible(visible); @@ -596,7 +615,7 @@ export class BrowserView extends Disposable { */ async focus(force?: boolean): Promise { // By default, only focus the view if its window is already focused. - if (!force && !this._window?.win?.isFocused()) { + if (!force && !this._currentWindow?.win?.isFocused()) { return; } this._view.webContents.focus(); @@ -676,15 +695,7 @@ export class BrowserView extends Disposable { * This can be an auxiliary window, depending on where the view is currently hosted. */ getElectronWindow(): Electron.BrowserWindow | undefined { - return this._window?.win ?? undefined; - } - - /** - * Get the main code window hosting this browser view, if any. This is used for routing commands from the browser view to the correct window. - * If the browser view is hosted in an auxiliary window, this will return the parent code window of that auxiliary window. - */ - getTopCodeWindow(): ICodeWindow | undefined { - return this._window && hasKey(this._window, { parentId: true }) ? this._codeWindowById(this._window.parentId) : undefined; + return this._currentWindow?.win ?? undefined; } override dispose(): void { @@ -697,7 +708,7 @@ export class BrowserView extends Disposable { this.debugger.dispose(); // Remove from parent window - this._window?.win?.contentView.removeChildView(this._view); + this._currentWindow?.win?.contentView.removeChildView(this._view); // Fire close event BEFORE disposing emitters. This signals the view has been destroyed. this._onDidClose.fire(); diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index 901487e3f4ea2..7a903bae3f34e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -202,7 +202,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I throw new Error(`Unknown browser context ${browserContextId}`); } - const target = await this.browserViewMainService.createTarget(url, browserContextId, windowId); + const target = await this.browserViewMainService.createTarget(url, windowId, browserContextId); if (target instanceof BrowserView) { await this.addView(target.id); return this.viewTargets.get(target.id)!; diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index e644063ae95c6..5c60f78d5f66e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -3,17 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../base/common/event.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IElementData } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IElementData, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { BrowserView } from './browserView.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { BrowserViewUri } from '../common/browserViewUri.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; @@ -21,7 +20,6 @@ import { IntegratedBrowserOpenSource, logBrowserOpen } from '../common/browserVi import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { localize } from '../../../nls.js'; import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; -import { ITextEditorOptions } from '../../editor/common/editor.js'; import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -31,8 +29,8 @@ export interface IBrowserViewMainService extends IBrowserViewService { tryGetBrowserView(id: string): BrowserView | undefined; - /** Create a new target, open it in an editor, and return it. */ - createTarget(url: string, browserContextId?: string, windowId?: number): Promise; + /** Create a new target and return it. */ + createTarget(url: string, mainWindowId: number, browserContextId?: string): Promise; } export class BrowserViewMainService extends Disposable implements IBrowserViewMainService { @@ -50,6 +48,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private readonly _activeTokens = new Map(); private _keybindings: { [commandId: string]: string } = Object.create(null); + private readonly _onDidCreateBrowserView = this._register(new Emitter()); + readonly onDidCreateBrowserView: Event = this._onDidCreateBrowserView.event; + constructor( @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -61,36 +62,48 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa super(); } - async getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise { + async getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): Promise { if (this.browserViews.has(id)) { - // Note: scope will be ignored if the view already exists. - // Browser views cannot be moved between sessions after creation. + // Note: options will be ignored if the view already exists. const view = this.browserViews.get(id)!; return view.getState(); } + const ownerWindow = this.windowsMainService.getWindowById(options.owner.mainWindowId); + if (!ownerWindow) { + throw new Error(`Owner window with ID ${options.owner.mainWindowId} not found`); + } + const browserSession = BrowserSession.getOrCreate( id, - scope, + options.scope, this.environmentMainService.workspaceStorageHome, - workspaceId + ownerWindow.openedWorkspace?.id ); - const view = this.createBrowserView(id, browserSession); - return view.getState(); + const view = this.createBrowserView(id, options.owner, browserSession); + + if (options.initialState?.url) { + void view.loadURL(options.initialState.url); + } + + return { + ...view.getState(), + ...options.initialState + }; } tryGetBrowserView(id: string): BrowserView | undefined { return this.browserViews.get(id); } - async createTarget(url: string, browserContextId?: string, windowId?: number): Promise { + async createTarget(url: string, mainWindowId: number, browserContextId?: string): Promise { const browserSession = browserContextId ? BrowserSession.get(browserContextId) : undefined; return this.openNew(url, { + owner: { mainWindowId }, session: browserSession, - windowId, - editorOptions: { preserveFocus: true }, + openOptions: { preserveFocus: true }, source: 'cdpCreated' }); } @@ -106,6 +119,25 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return view; } + private _getViewInfo(view: BrowserView): IBrowserViewInfo { + return { + id: view.id, + owner: view.owner, + state: view.getState() + }; + } + + async getBrowserViews(windowId?: number): Promise { + const result: IBrowserViewInfo[] = []; + for (const [, view] of this.browserViews) { + if (windowId !== undefined && view.owner.mainWindowId !== windowId) { + continue; + } + result.push(this._getViewInfo(view)); + } + return result; + } + onDynamicDidNavigate(id: string) { return this._getBrowserView(id).onDidNavigate; } @@ -138,10 +170,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidChangeFavicon; } - onDynamicDidRequestNewPage(id: string) { - return this._getBrowserView(id).onDidRequestNewPage; - } - onDynamicDidFindInPage(id: string) { return this._getBrowserView(id).onDidFindInPage; } @@ -283,7 +311,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa /** * Create a browser view backed by the given {@link BrowserSession}. */ - private createBrowserView(id: string, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + private createBrowserView(id: string, owner: IBrowserViewOwner, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { if (this.browserViews.has(id)) { throw new Error(`Browser view with id ${id} already exists`); } @@ -293,9 +321,24 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa const view = this.instantiationService.createInstance( BrowserView, id, + owner, browserSession, - // Recursive factory for nested windows (child views share the same session) - (childOptions) => this.createBrowserView(generateUuid(), browserSession, childOptions), + // Recursive factory for nested windows (child views share the same session and owner). + (url, electronOptions, openOptions) => { + const child = this.createBrowserView(generateUuid(), owner, browserSession, electronOptions); + + if (url) { + void child.loadURL(url).catch(() => { }); + } + + const info = this._getViewInfo(child); + this._onDidCreateBrowserView.fire({ + info: url ? { ...info, state: { ...info.state, url } } : info, + openOptions + }); + + return child; + }, (v, params) => this.showContextMenu(v, params), options ); @@ -311,32 +354,31 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private async openNew( url: string, { + owner, session, - windowId, - editorOptions, + openOptions, source }: { + owner: IBrowserViewOwner; session: BrowserSession | undefined; - windowId: number | undefined; - editorOptions: ITextEditorOptions; + openOptions: IBrowserViewOpenOptions; source: IntegratedBrowserOpenSource; } ): Promise { const targetId = generateUuid(); - const view = this.createBrowserView(targetId, session || BrowserSession.getOrCreateEphemeral(targetId)); + const view = this.createBrowserView(targetId, owner, session || BrowserSession.getOrCreateEphemeral(targetId)); - const window = windowId !== undefined ? this.windowsMainService.getWindowById(windowId) : this.windowsMainService.getFocusedWindow(); - if (!window) { - throw new Error(`Window ${windowId} not found`); + if (url) { + void view.loadURL(url).catch(() => { }); } - logBrowserOpen(this.telemetryService, source); - // Request the workbench to open the editor - window.sendWhenReady('vscode:runAction', CancellationToken.None, { - id: '_workbench.open', - args: [BrowserViewUri.forId(targetId), [undefined, { ...editorOptions, viewState: { url } }], undefined] + // Fire creation event so the workbench can open an editor tab + const info = this._getViewInfo(view); + this._onDidCreateBrowserView.fire({ + info: url ? { ...info, state: { ...info.state, url } } : info, + openOptions }); return view; @@ -358,9 +400,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa label: localize('browser.contextMenu.openLinkInNewTab', 'Open Link in New Tab'), click: () => { void this.openNew(params.linkURL, { + owner: view.owner, session: view.session, - windowId: view.getTopCodeWindow()?.id, - editorOptions: { preserveFocus: true, inactive: true }, + openOptions: { preserveFocus: true, background: true }, source: 'browserLinkBackground' }); } @@ -389,9 +431,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa label: localize('browser.contextMenu.openImageInNewTab', 'Open Image in New Tab'), click: () => { void this.openNew(params.srcURL!, { + owner: view.owner, session: view.session, - windowId: view.getTopCodeWindow()?.id, - editorOptions: { preserveFocus: true, inactive: true }, + openOptions: { preserveFocus: true, background: true }, source: 'browserLinkBackground' }); } diff --git a/src/vs/platform/secrets/common/secrets.ts b/src/vs/platform/secrets/common/secrets.ts index d73ad20bdd57c..1840cdd6b284d 100644 --- a/src/vs/platform/secrets/common/secrets.ts +++ b/src/vs/platform/secrets/common/secrets.ts @@ -119,6 +119,10 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora super(); } + protected useSharedStorage(key: string): boolean { + return isWindows && CROSS_APP_SHARED_SECRET_KEYS.includes(key); + } + /** * @Note initialize must be called first so that this can be resolved properly * otherwise it will return 'unknown'. @@ -178,7 +182,8 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora const fullKey = secretStorageKey(key); this._logService.trace('[secrets] deleting secret for key:', fullKey); - storageService.remove(fullKey, StorageScope.APPLICATION); + const scope = this.useSharedStorage(key) ? StorageScope.APPLICATION_SHARED : StorageScope.APPLICATION; + storageService.remove(fullKey, scope); this._logService.trace('[secrets] deleted secret for key:', fullKey); }); } @@ -194,7 +199,7 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora } private getValueFromStorage(key: string, fullKey: string, storageService: IStorageService): string | undefined { - if (isWindows && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) { + if (this.useSharedStorage(key)) { this._logService.trace(`[SecretStorageService] Fetching value for cross-app shared secret: ${fullKey}`); return storageService.get(fullKey, StorageScope.APPLICATION_SHARED); } @@ -202,7 +207,7 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora } private setValueInStorage(key: string, fullKey: string, value: string, storageService: IStorageService): void { - if (isWindows && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) { + if (this.useSharedStorage(key)) { this._logService.trace(`[SecretStorageService] Setting value for cross-app shared secret: ${fullKey}`); storageService.store(fullKey, value, StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); return; diff --git a/src/vs/platform/secrets/test/common/secrets.test.ts b/src/vs/platform/secrets/test/common/secrets.test.ts index e1603df791f36..ba8feee0f46fe 100644 --- a/src/vs/platform/secrets/test/common/secrets.test.ts +++ b/src/vs/platform/secrets/test/common/secrets.test.ts @@ -8,7 +8,7 @@ import * as sinon from 'sinon'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IEncryptionService, KnownStorageProvider } from '../../../encryption/common/encryptionService.js'; import { NullLogService } from '../../../log/common/log.js'; -import { BaseSecretStorageService } from '../../common/secrets.js'; +import { BaseSecretStorageService, CROSS_APP_SHARED_SECRET_KEYS } from '../../common/secrets.js'; import { InMemoryStorageService } from '../../../storage/common/storage.js'; class TestEncryptionService implements IEncryptionService { @@ -217,4 +217,64 @@ suite('secrets', () => { assert.strictEqual(spyNoEncryptionService.decrypt.callCount, 0); }); }); + + suite('BaseSecretStorageService cross-app shared secrets', () => { + + class TestSharedSecretStorageService extends BaseSecretStorageService { + protected override useSharedStorage(key: string): boolean { + return CROSS_APP_SHARED_SECRET_KEYS.includes(key); + } + } + + let service: BaseSecretStorageService; + let storageService: InMemoryStorageService; + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + storageService = store.add(new InMemoryStorageService()); + service = store.add(new TestSharedSecretStorageService( + false, + storageService, + sandbox.spy(new TestEncryptionService()), + store.add(new NullLogService())) + ); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('shared keys are stored and read from APPLICATION_SHARED', async () => { + const sharedKey = CROSS_APP_SHARED_SECRET_KEYS[0]; + const value = 'shared-secret-value'; + await service.set(sharedKey, value); + const result = await service.get(sharedKey); + assert.strictEqual(result, value); + + // Non-shared key should still work via APPLICATION scope + const regularKey = 'regular-secret'; + await service.set(regularKey, 'regular-value'); + assert.strictEqual(await service.get(regularKey), 'regular-value'); + }); + + test('onDidChangeSecret fires for APPLICATION_SHARED changes', async () => { + const sharedKey = CROSS_APP_SHARED_SECRET_KEYS[0]; + let eventFired = false; + store.add(service.onDidChangeSecret(changedKey => { + assert.strictEqual(changedKey, sharedKey); + eventFired = true; + })); + await service.set(sharedKey, 'value'); + assert.strictEqual(eventFired, true); + }); + + test('deleting a shared key removes it', async () => { + const sharedKey = CROSS_APP_SHARED_SECRET_KEYS[0]; + await service.set(sharedKey, 'value'); + assert.strictEqual(await service.get(sharedKey), 'value'); + await service.delete(sharedKey); + assert.strictEqual(await service.get(sharedKey), undefined); + }); + }); }); diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index e22625bd3b037..a62f326e01330 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -664,6 +664,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-23 | Updated mobile layout policy platform detection to use shared `platform.isMobile`, and reduced phone-layout CSS `!important` usage where selector specificity already provides stable overrides. | | 2026-04-22 | Increased the sessions titlebar account widget's GitHub profile image from `16px × 16px` to `18px × 18px` while keeping the existing `22px × 22px` control footprint and avatar border treatment. | | 2026-04-22 | Added sessions-only toast offset overrides so notification toasts now use `right: 15px` in the default bottom-right placement and `left: 15px` in the bottom-left placement, matching the notification center spacing. | | 2026-04-22 | Added a sessions-workbench notification offset override so the shared notification controllers no longer push top-right notifications down to `42px`; sessions now reapply a fixed `40px` top offset for top-right notification center/toast placement. | diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md new file mode 100644 index 0000000000000..7b18d586d8c66 --- /dev/null +++ b/src/vs/sessions/MOBILE.md @@ -0,0 +1,154 @@ +# Mobile Agent Sessions — Architecture + +## Core Principle + +**Every feature accessible in the desktop window must be accessible on mobile — same functionality, different presentation.** Mobile is NOT "desktop minus stuff." It is a parallel UI layer where the same services, views, and actions are rendered through mobile-native interaction patterns. + +## Architecture + +### Mobile Part Subclasses + +Desktop Parts (`ChatBarPart`, `SidebarPart`, `PanelPart`, `AuxiliaryBarPart`) remain unchanged. Each has a **mobile subclass** that extends it and overrides only `layout()` and/or `updateStyles()`. `AgenticPaneCompositePartService` conditionally instantiates the mobile or desktop variant at startup based on viewport width (`< 640px` → phone). + +Each mobile Part checks the current layout class (via `isPhoneLayout(layoutService)`) at every call. When the viewport is phone it applies mobile behavior (full-cell layout, no card chrome, no session-bar subtraction). When the viewport is tablet/desktop — which happens when a real phone rotates past the 640px breakpoint — it delegates to the desktop `super` implementation. This means a `Mobile*Part` instance is safe to keep through a viewport-class transition without producing wrong layout math. + +This means: +- Desktop code has **zero** phone-layout checks — all mobile logic lives in mobile subclasses, `MobileTopBar`, and CSS. +- Phone-instantiated parts adapt correctly to rotation across the 640px breakpoint by delegating to `super`. + +After a viewport-class transition the workbench calls `updateStyles()` on each pane composite part so card-chrome inline styles get re-applied (desktop) or cleared (phone) for the new class. + +### View & Action Gating + +Views, menu items, and actions use `when` clauses with the `sessionsIsPhoneLayout` context key to control visibility in phone layout. This follows a **default-deny** approach for phone: + +- **Desktop-only features** add `when: IsPhoneLayoutContext.negate()` to their view descriptors and menu registrations. They simply don't appear on phone. +- **Phone-compatible features** (chat, sessions list) have no phone gate — they render on all viewports. +- **Phone-specific replacements** (when ready) register with `when: IsPhoneLayoutContext` and live in separate files under `parts/mobile/contributions/`. + +Tablet and larger viewports currently fall back to the desktop layout; no separate tablet design exists yet. + +Two registrations can target the same slot with opposite `when` clauses, pointing to different view classes in different files — giving full file separation with no internal branching. + +#### Current Gating Status + +| Feature | Phone Status | Mechanism | +|---------|--------------|-----------| +| Sessions list (sidebar) | ✅ Compatible | No gate | +| Chat views (ChatBar) | ✅ Compatible | No gate | +| Changes view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | +| Files view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | +| Logs view (Panel) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | +| Terminal actions | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | +| "Open in VS Code" action | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | +| Code review toolbar | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | +| Customizations toolbar | ❌ Hidden | CSS `display: none` on phone | +| Titlebar | ❌ Hidden | Grid `visible: false` + CSS + MobileTopBar replacement | + +### Phone Layout + +On phone-sized viewports (`< 640px` width): + +``` +┌──────────────────────────────────┐ +│ [☰] Session Title [+] │ ← MobileTopBar (prepended before grid) +├──────────────────────────────────┤ +│ │ +│ Chat (edge-to-edge) │ ← Grid: ChatBarPart fills 100% +│ │ +│ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ Chat input │ │ ← Pinned to bottom +│ └──────────────────────────┘ │ +└──────────────────────────────────┘ +``` + +- **MobileTopBar** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and new session (+) button. +- **Sidebar** is hidden by default and opens as an **85% width drawer overlay** with a backdrop when the hamburger is tapped. CSS makes its `split-view-view` absolutely positioned with `z-index: 250`. The workbench manually calls `sidebarPart.layout()` with drawer dimensions after opening. Closing the drawer clears the navigation stack. +- **Titlebar** is hidden in the grid (`visible: false`) and via CSS — replaced by MobileTopBar. +- **SessionCompositeBar** (chat tabs) is hidden via CSS. +- The grid uses `display: flex; flex-direction: column` and all `split-view-view:has(> .part)` containers are positioned absolutely at `100% width/height`. + +### Viewport Classification + +`SessionsLayoutPolicy` classifies the viewport: +- **phone**: `width < 640px` +- **tablet**: `640px ≤ width < 1024px` (treated as desktop; no phone-specific chrome) +- **desktop**: `width ≥ 1024px` + +The workbench toggles the `phone-layout` CSS class on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation, or a real phone rotating across the 640px breakpoint). MobileTopBar lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks. + +### Context Keys + +| Key | Type | Purpose | +|-----|------|---------| +| `sessionsIsPhoneLayout` | `boolean` | `true` when the viewport is phone (< 640px) | +| `sessionsKeyboardVisible` | `boolean` | `true` when the virtual keyboard is visible | + +### Desktop → Mobile Component Mapping + +| Desktop Component | Mobile Equivalent | How Accessed | +|---|---|---| +| **Titlebar** (3-section toolbar) | **MobileTopBar** (☰ / title / +) | Always visible at top | +| **Sidebar** (sessions list) | Drawer overlay (85% width) | Hamburger button (☰) | +| **ChatBar** (chat widget) | Same Part, edge-to-edge, no card chrome | Default view (always visible) | +| **AuxiliaryBar** (files, changes) | Gated — not shown on mobile | Planned: mobile-specific view | +| **Panel** (terminal, output) | Gated — not shown on mobile | Planned: mobile-specific view | +| **SessionCompositeBar** (chat tabs) | Hidden on phone | — | +| **New Session** (sidebar button) | + button in MobileTopBar | Always visible in top bar | + +## File Map + +### Mobile Part Subclasses + +| File | Purpose | +|------|---------| +| `browser/parts/mobile/mobileChatBarPart.ts` | Extends `ChatBarPart`. Overrides `layout()` (no card margins) and `updateStyles()` (no inline card styles). | +| `browser/parts/mobile/mobileSidebarPart.ts` | Extends `SidebarPart`. Overrides `updateStyles()` (no inline card/title styles). | +| `browser/parts/mobile/mobileAuxiliaryBarPart.ts` | Extends `AuxiliaryBarPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | +| `browser/parts/mobile/mobilePanelPart.ts` | Extends `PanelPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | + +### Mobile Chrome Components + +| File | Purpose | +|------|---------| +| `browser/parts/mobile/mobileTopBar.ts` | Phone top bar: hamburger (☰), session title, new session (+). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. | +| `browser/parts/mobile/mobileChatShell.css` | **Single source of truth** for all phone-layout CSS: flex column layout, split-view-view absolute positioning, card chrome removal, part/content width overrides, sidebar title hiding, composite bar hiding, welcome page layout, sash hiding, button focus overrides, mobile pickers. | + +### Layout & Navigation + +| File | Purpose | +|------|---------| +| `browser/layoutPolicy.ts` | `SessionsLayoutPolicy`: observable viewport classification (phone/tablet/desktop), platform flags (isIOS, isAndroid, isTouchDevice), part visibility and size defaults. | +| `browser/mobileNavigationStack.ts` | `MobileNavigationStack`: Android back button integration via `history.pushState` / `popstate`. Supports `push()`, `pop()`, and `clear()`. | +| `common/contextkeys.ts` | Phone context keys: `IsPhoneLayoutContext`, `KeyboardVisibleContext`. | + +### Part Instantiation + +| File | Purpose | +|------|---------| +| `browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService`: checks viewport width at construction time and instantiates `Mobile*Part` vs desktop `*Part` classes accordingly. | + +### Workbench Integration + +| File | Key Changes | +|------|-------------| +| `browser/workbench.ts` | Layout policy integration, MobileTopBar creation/destruction (via `DisposableStore`), sidebar drawer open/close with backdrop, viewport-class-change detection, window resize listener, grid height calculation (subtracts MobileTopBar height), titlebar grid visibility toggle, `ISessionsManagementService` for new session button. | +| `browser/parts/chatBarPart.ts` | `_lastLayout` changed from `private` to `protected` for mobile subclass access. | + +### Styling + +| File | Purpose | +|------|---------| +| `browser/parts/mobile/mobileChatShell.css` | All phone-layout CSS (see above). | +| `browser/parts/media/sidebarPart.css` | Sidebar drawer overlay CSS: 85% width, z-index 250, slide-in animation, backdrop. | +| `browser/media/style.css` | Mobile overscroll containment, 44px touch targets, quick pick bottom sheets, context menu action sheets, dialog sizing, notification positioning, hover card suppression, editor modal full-screen. | + +## Remaining Work + +- **Session title sync**: MobileTopBar shows hardcoded "New Session" — needs to subscribe to `sessionsManagementService.activeSession` and update title when session changes. +- **Files & Terminal access**: Should become phone-specific views gated with `when: IsPhoneLayoutContext`. +- **iOS keyboard handling**: Adjust layout when virtual keyboard appears (context key exists, but no layout response yet). +- **Session list inline actions**: Make always-visible on touch devices (no hover-to-reveal). +- **Customizations on mobile**: Currently hidden — needs a mobile-friendly alternative. diff --git a/src/vs/sessions/browser/layoutPolicy.ts b/src/vs/sessions/browser/layoutPolicy.ts new file mode 100644 index 0000000000000..ded7d705e8b4e --- /dev/null +++ b/src/vs/sessions/browser/layoutPolicy.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../base/common/lifecycle.js'; +import { observableValue, derived, IObservable } from '../../base/common/observable.js'; +import { isIOS, isMobile } from '../../base/common/platform.js'; +import { isAndroid } from '../../base/browser/browser.js'; +import { Gesture } from '../../base/browser/touch.js'; + +/** Viewport classification based on container width. */ +export type ViewportClass = 'phone' | 'tablet' | 'desktop'; + +/** Default visibility for each workbench part. */ +export interface IPartVisibilityDefaults { + readonly sidebar: boolean; + readonly auxiliaryBar: boolean; + readonly panel: boolean; + readonly chatBar: boolean; + readonly editor: boolean; +} + +/** Default sizes (in pixels) for each workbench part. */ +export interface IPartSizeDefaults { + readonly sideBarSize: number; + readonly auxiliaryBarSize: number; + readonly panelSize: number; + readonly chatBarWidth: number; +} + +const PHONE_MAX_WIDTH = 640; +const TABLET_MAX_WIDTH = 1024; + +/** + * Whether the current platform is a phone/tablet OS. The phone layout is + * only applied on actual mobile devices so that resizing a desktop window + * below 640px does not switch the agents workbench into phone mode. + */ +const isMobilePlatform = isMobile; + +/** + * Classifies the viewport into one of three classes based on width. + * Phone and tablet classifications are gated on a mobile OS; desktop + * browsers and Electron always report `desktop` regardless of width. + */ +function classifyViewport(width: number): ViewportClass { + if (!isMobilePlatform) { + return 'desktop'; + } + if (width < PHONE_MAX_WIDTH) { + return 'phone'; + } + if (width < TABLET_MAX_WIDTH) { + return 'tablet'; + } + return 'desktop'; +} + +/** + * Observable-based viewport classification and layout policy for + * the Sessions workbench. Consumed by `SessionsWorkbench` to drive + * part visibility, sizing, and behavior based on viewport dimensions + * and platform. + */ +export class SessionsLayoutPolicy extends Disposable { + + // --- Platform flags (static, read once) --- + + /** Whether the current platform is iOS. */ + readonly isIOS: boolean; + + /** Whether the current platform is Android. */ + readonly isAndroid: boolean; + + /** Whether the current device supports touch input. */ + readonly isTouchDevice: boolean; + + // --- Observables --- + + private readonly _viewportClass = observableValue(this, 'desktop'); + + /** Current viewport class derived from the most recent `update()` call. */ + readonly viewportClass: IObservable = this._viewportClass; + + /** `true` when the viewport class is `phone`. */ + readonly isPhoneLayout: IObservable = derived(this, reader => { + return this._viewportClass.read(reader) === 'phone'; + }); + + constructor() { + super(); + + this.isIOS = isIOS; + this.isAndroid = isAndroid; + this.isTouchDevice = Gesture.isTouchDevice(); + } + + /** + * Update the viewport classification. Call this from the workbench + * `layout()` method whenever the container dimensions change. + * + * @param width Container width in pixels. + * @param height Container height in pixels (reserved for future use). + */ + update(width: number, _height: number): void { + const next = classifyViewport(width); + if (this._viewportClass.get() !== next) { + this._viewportClass.set(next, undefined); + } + } + + /** + * Returns the default part visibility for the given viewport class. + * If no class is supplied the current observed class is used. + */ + getPartVisibilityDefaults(viewportClass?: ViewportClass): IPartVisibilityDefaults { + const vc = viewportClass ?? this._viewportClass.get(); + switch (vc) { + case 'phone': + return { sidebar: false, auxiliaryBar: false, panel: false, chatBar: true, editor: false }; + case 'tablet': + case 'desktop': + // Tablet and desktop share the standard multi-part workbench defaults. + // A dedicated tablet layout has not been designed yet. + return { sidebar: true, auxiliaryBar: true, panel: false, chatBar: true, editor: false }; + } + } + + /** + * Returns the default part sizes for the given viewport dimensions. + * If no viewport class is supplied the current observed class is used. + * + * @param width Container width in pixels. + * @param height Container height in pixels (reserved for future use). + * @param viewportClass Optional explicit viewport class override. + */ + getPartSizes(width: number, _height: number, viewportClass?: ViewportClass): IPartSizeDefaults { + const vc = viewportClass ?? this._viewportClass.get(); + switch (vc) { + case 'phone': + return { + sideBarSize: 0, + auxiliaryBarSize: 0, + panelSize: 0, + chatBarWidth: width, + }; + case 'tablet': + case 'desktop': + // Tablet currently falls back to desktop sizing. + return { + sideBarSize: 300, + auxiliaryBarSize: 340, + panelSize: 300, + chatBarWidth: width - 300, + }; + } + } +} diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 5b6c61e90d0a0..228ae5d448f30 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -651,3 +651,261 @@ .agent-sessions-workbench .badge > .badge-content { border-radius: 4px !important; } + +/* Phone-layout rules for parts, sashes, max-width constraints, and grid + background live in mobileChatShell.css — do not duplicate them here. */ + +/* + * Phone layout (< 640px) styles. Currently the only mobile form factor + * supported by the sessions workbench; tablet/larger viewports fall back + * to the desktop layout. + */ + +/* ---- Phone Layout: Overscroll Containment ---- */ + +/* Prevent body rubber-band on iOS and Chrome pull-to-refresh on Android */ +.agent-sessions-workbench.phone-layout .monaco-scrollable-element > .scrollable-element { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.phone-layout .interactive-session { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.phone-layout .monaco-list { + overscroll-behavior: contain; +} + +/* ---- Phone Layout: Touch Target Sizing ---- */ + +/* Ensure interactive elements meet 44px minimum touch target */ +.agent-sessions-workbench.phone-layout .action-item > .action-label { + min-height: 44px; + min-width: 44px; +} + +/* Touch action for tap responsiveness */ +.agent-sessions-workbench.phone-layout .action-item, +.agent-sessions-workbench.phone-layout button { + touch-action: manipulation; +} + +/* Disable text selection callout on interactive elements */ +.agent-sessions-workbench.phone-layout .action-item, +.agent-sessions-workbench.phone-layout .monaco-toolbar, +.agent-sessions-workbench.phone-layout .sidebar-footer { + -webkit-touch-callout: none; + user-select: none; + -webkit-user-select: none; +} + +/* Titlebar safe-area inset lives in mobileChatShell.css */ + +/* ---- Phone Layout: Mobile Quick Picks ---- */ + +/* Transform quick pick into full-width bottom sheet on phone */ +.agent-sessions-workbench.phone-layout .quick-input-widget { + top: auto !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + width: 100% !important; + max-width: 100% !important; + border-radius: 16px 16px 0 0; + padding-bottom: env(safe-area-inset-bottom); +} + +.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list { + max-height: 50vh; +} + +.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list .monaco-list-row { + min-height: 44px; +} + +/* ---- Phone Layout: Mobile Context Menus ---- */ + +/* Transform context menus into bottom action sheets on phone */ +.agent-sessions-workbench.phone-layout .context-view .monaco-menu { + position: fixed !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + top: auto !important; + width: 100% !important; + max-width: 100% !important; + border-radius: 16px 16px 0 0; + padding-bottom: env(safe-area-inset-bottom); +} + +.agent-sessions-workbench.phone-layout .context-view .monaco-menu .monaco-action-bar .action-item { + min-height: 44px; +} + +.agent-sessions-workbench.phone-layout .context-view .monaco-menu .monaco-action-bar .action-label { + font-size: 16px; + padding: 8px 16px; +} + +/* ---- Phone Layout: Mobile Dialogs ---- */ + +/* Make dialogs near-full-width with larger buttons on phone */ +.agent-sessions-workbench.phone-layout .monaco-dialog-box { + width: calc(100% - 32px); + max-width: calc(100% - 32px); +} + +.agent-sessions-workbench.phone-layout .monaco-dialog-box .dialog-buttons-row .monaco-button { + min-height: 44px; + font-size: 16px; +} + +/* ---- Phone Layout: Mobile Notifications ---- */ + +/* Full-width notification toasts at top of screen */ +.agent-sessions-workbench.phone-layout .notifications-toasts { + left: 8px !important; + right: 8px !important; + bottom: auto !important; + top: calc(env(safe-area-inset-top) + 48px) !important; + width: auto !important; +} + +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast { + width: 100%; + max-width: 100%; +} + +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast .notification-toast-container { + border-radius: 12px; +} + +/* ---- Phone Layout: Hover Cards ---- */ + +/* Disable delayed hover cards on touch devices — they never trigger */ +.agent-sessions-workbench.phone-layout .monaco-hover { + display: none; +} + +/* Exception: keep hovers that are explicitly triggered (e.g., info buttons) */ +.agent-sessions-workbench.phone-layout .monaco-hover.visible-on-mobile { + display: block; +} + +/* ---- Phone Layout: Mobile Editor Modal ---- */ + +/* Full-screen editor modal on phone — no margins, covers entire viewport */ +.agent-sessions-workbench.phone-layout .monaco-modal-editor-part { + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: 100% !important; + border-radius: 0 !important; + margin: 0 !important; + animation: editor-slide-up 250ms ease-out; +} + +@keyframes editor-slide-up { + from { + transform: translateY(30%); + opacity: 0.5; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Make the modal backdrop fully cover the screen on phone */ +.agent-sessions-workbench.phone-layout .monaco-modal-editor-block { + background: rgba(0, 0, 0, 0.7); +} + +/* Safe area padding for editor titlebar on phone */ +.agent-sessions-workbench.phone-layout .monaco-modal-editor-part .title { + padding-top: env(safe-area-inset-top); +} + +/* ---- Phone Layout: Input Auto-Zoom Prevention ---- */ + +/* iOS Safari zooms in on input focus when font-size < 16px. + Force minimum 16px on all input elements on phone. */ +.agent-sessions-workbench.phone-layout input, +.agent-sessions-workbench.phone-layout textarea, +.agent-sessions-workbench.phone-layout .monaco-inputbox input, +.agent-sessions-workbench.phone-layout .chat-input-container textarea { + font-size: 16px; +} + +/* ---- Phone Layout: Native Scroll Preservation ---- */ + +/* Ensure chat content uses momentum scrolling on phone. + The -webkit-overflow-scrolling property is needed for older iOS. */ +.agent-sessions-workbench.phone-layout .interactive-session .monaco-scrollable-element { + -webkit-overflow-scrolling: touch; +} + +/* ---- Phone Layout: Bottom Sheet Panel ---- */ + +/* Panel slides up from bottom as a sheet on phone */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.panel) { + position: absolute !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + height: 60vh !important; + z-index: 200; + animation: panel-slide-up 250ms ease-out; + border-radius: 16px 16px 0 0; + overflow: hidden; +} + +@keyframes panel-slide-up { + from { + transform: translateY(100%); + opacity: 0.5; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Panel drag handle visual indicator */ +.agent-sessions-workbench.phone-layout .part.panel::before { + content: ''; + display: block; + width: 36px; + height: 5px; + background: var(--vscode-foreground); + opacity: 0.3; + border-radius: 3px; + margin: 8px auto 4px auto; +} + +/* ---- Phone Layout: Auxiliary Bar Overlay ---- */ + +/* Auxiliary bar slides in from the right as a full-height overlay on phone */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.auxiliarybar) { + position: absolute !important; + top: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 85vw !important; + max-width: 400px; + z-index: 200; + animation: auxbar-slide-in 250ms ease-out; +} + +@keyframes auxbar-slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} diff --git a/src/vs/sessions/browser/mobileNavigationStack.ts b/src/vs/sessions/browser/mobileNavigationStack.ts new file mode 100644 index 0000000000000..020022bf65c1f --- /dev/null +++ b/src/vs/sessions/browser/mobileNavigationStack.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../base/common/event.js'; +import { mainWindow } from '../../base/browser/window.js'; + +export type MobileNavigationLayer = 'sidebar' | 'editor' | 'panel' | 'auxbar'; + +interface MobileNavigationEntry { + readonly layer: MobileNavigationLayer; + readonly id: number; +} + +/** + * Manages a stack of open overlay layers (sidebar, editor modal, panel sheet, + * aux bar) and integrates with `history.pushState` / `popstate` so that the + * Android back button dismisses overlays in LIFO order. + */ +export class MobileNavigationStack extends Disposable { + + private readonly _stack: MobileNavigationEntry[] = []; + private _nextId = 0; + + private readonly _onDidPop = this._register(new Emitter()); + readonly onDidPop: Event = this._onDidPop.event; + + constructor() { + super(); + + this._register(Event.fromDOMEventEmitter(mainWindow, 'popstate')(e => { + this._onPopState(e); + })); + } + + push(layer: MobileNavigationLayer): void { + const id = this._nextId++; + this._stack.push({ layer, id }); + mainWindow.history.pushState({ layer, id }, ''); + } + + pop(): MobileNavigationLayer | undefined { + const entry = this._stack.pop(); + if (entry) { + this._onDidPop.fire(entry.layer); + } + return entry?.layer; + } + + peek(): MobileNavigationLayer | undefined { + return this._stack.length > 0 + ? this._stack[this._stack.length - 1].layer + : undefined; + } + + has(layer: MobileNavigationLayer): boolean { + return this._stack.some(e => e.layer === layer); + } + + clear(): void { + this._stack.length = 0; + } + + /** + * Removes the topmost entry matching `layer` from the stack (without + * firing {@link onDidPop}) and rewinds the browser history by one entry. + * Use this when a layer is closed by UI interaction (e.g., backdrop click) + * so the history and stack stay in sync without recursing back into + * close handlers. + * + * Concurrent silent pops are handled via a counter: each call increments + * {@link _pendingSilentPops} and the matching {@link _onPopState} decrements + * it, so rapid back-button taps or multiple overlay closes cannot leak + * suppression state across unrelated pops. + */ + popSilently(layer: MobileNavigationLayer): void { + for (let i = this._stack.length - 1; i >= 0; i--) { + if (this._stack[i].layer === layer) { + this._stack.splice(i, 1); + this._pendingSilentPops++; + mainWindow.history.back(); + return; + } + } + } + + private _pendingSilentPops = 0; + + private _onPopState(e: PopStateEvent): void { + if (this._pendingSilentPops > 0) { + this._pendingSilentPops--; + return; + } + + if (this._stack.length === 0) { + return; + } + + const top = this._stack[this._stack.length - 1]; + const state = e.state as { layer?: string; id?: number } | null; + + // Only pop if the event's state id matches expectations — + // the popstate must correspond to a state *before* our top entry, + // meaning the top entry's push was just undone. + if (state && typeof state.id === 'number' && state.id >= top.id) { + return; + } + + this.pop(); + } +} diff --git a/src/vs/sessions/browser/paneCompositePartService.ts b/src/vs/sessions/browser/paneCompositePartService.ts index 060cdfdedade6..1c41eb907db18 100644 --- a/src/vs/sessions/browser/paneCompositePartService.ts +++ b/src/vs/sessions/browser/paneCompositePartService.ts @@ -18,6 +18,12 @@ import { PanelPart } from './parts/panelPart.js'; import { SidebarPart } from './parts/sidebarPart.js'; import { AuxiliaryBarPart } from './parts/auxiliaryBarPart.js'; import { ChatBarPart } from './parts/chatBarPart.js'; +import { MobilePanelPart } from './parts/mobile/mobilePanelPart.js'; +import { MobileSidebarPart } from './parts/mobile/mobileSidebarPart.js'; +import { MobileAuxiliaryBarPart } from './parts/mobile/mobileAuxiliaryBarPart.js'; +import { MobileChatBarPart } from './parts/mobile/mobileChatBarPart.js'; +import { getClientArea } from '../../base/browser/dom.js'; +import { mainWindow } from '../../base/browser/window.js'; import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; export class AgenticPaneCompositePartService extends Disposable implements IPaneCompositePartService { @@ -37,10 +43,13 @@ export class AgenticPaneCompositePartService extends Disposable implements IPane ) { super(); - this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(PanelPart)); - this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(SidebarPart)); - this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(AuxiliaryBarPart)); - this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(ChatBarPart)); + const { width } = getClientArea(mainWindow.document.body); + const isPhoneLayout = width < 640; + + this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(isPhoneLayout ? MobilePanelPart : PanelPart)); + this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(isPhoneLayout ? MobileSidebarPart : SidebarPart)); + this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(isPhoneLayout ? MobileAuxiliaryBarPart : AuxiliaryBarPart)); + this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(isPhoneLayout ? MobileChatBarPart : ChatBarPart)); } private registerPart(location: ViewContainerLocation, part: IPaneCompositePart): void { diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts index 29c7a30c249d2..273b79fce2b46 100644 --- a/src/vs/sessions/browser/parts/chatBarPart.ts +++ b/src/vs/sessions/browser/parts/chatBarPart.ts @@ -58,7 +58,7 @@ export class ChatBarPart extends AbstractPaneCompositePart { // TODO: should not private _sessionCompositeBar: ChatCompositeBar | undefined; - private _lastLayout: { readonly width: number; readonly height: number; readonly top: number; readonly left: number } | undefined; + protected _lastLayout: { readonly width: number; readonly height: number; readonly top: number; readonly left: number } | undefined; get preferredHeight(): number | undefined { return this.layoutService.mainContainerDimension.height * 0.4; diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index a30fd1d55f9dd..43e3e446665cc 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -67,3 +67,57 @@ max-width: 100%; cursor: default; } + +/* ---- Phone Layout: Sidebar Drawer Overlay ---- */ + +/* On phone, the sidebar is a drawer that slides over the chat. + It takes 85% width (max 360px) and sits on top of everything. */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.sidebar) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + bottom: 0 !important; + width: 85% !important; + max-width: 360px !important; + height: 100% !important; + z-index: 250; + animation: sidebar-slide-in 200ms ease-out; +} + +/* The sidebar Part inside fills its container */ +.agent-sessions-workbench.phone-layout .part.sidebar { + width: 100%; + height: 100%; +} + +@keyframes sidebar-slide-in { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +/* Sidebar backdrop — applied via JS when sidebar is open on phone */ +.mobile-sidebar-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 240; + animation: backdrop-fade-in 200ms ease-out; +} + +@keyframes backdrop-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Increase sidebar footer action button height for touch */ +.agent-sessions-workbench.phone-layout .part.sidebar > .sidebar-footer .sidebar-action-button { + min-height: 44px; + padding: 8px 12px; +} diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index db6da32ee33c2..b17ad7d23d265 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -130,3 +130,37 @@ .agent-sessions-workbench.mac .part.titlebar .window-controls-container { -webkit-app-region: drag; } + +/* ---- Phone Layout: Minimal Titlebar ---- */ + +/* On phone, ensure the titlebar left is visible (it holds the hamburger area) + even when sidebar is hidden. Override the nosidebar rule. */ +.agent-sessions-workbench.phone-layout.nosidebar .part.titlebar > .sessions-titlebar-container > .titlebar-left { + display: flex !important; +} + +/* But hide the toolbar content inside it — only structural element remains */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { + display: none !important; +} + +/* Hide the window controls spacer on phone (no native traffic lights on mobile) */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-left > .window-controls-container { + display: none !important; +} + +/* Keep the center (session title) visible and full-width on phone */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-center { + flex: 1; + min-width: 0; +} + +/* On phone, hide ALL right-side action containers (session actions + layout actions) */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container { + display: none !important; +} + +/* Ensure safe area padding on top for notch */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container { + padding-top: env(safe-area-inset-top); +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts new file mode 100644 index 0000000000000..fa402bc65e1f4 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { AuxiliaryBarPart } from '../auxiliaryBarPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; + +/** + * Mobile variant of AuxiliaryBarPart. + * + * On phone-sized viewports the auxiliary bar fills the full grid cell + * without card margins or border insets. On tablet/desktop it falls + * back to the desktop behavior so runtime viewport transitions keep + * working. + */ +export class MobileAuxiliaryBarPart extends AuxiliaryBarPart { + + override updateStyles(): void { + // Always run the desktop implementation first so inline card styles + // are set on tablet/desktop transitions. In phone mode we then + // clear them so CSS can take over (inline styles have the highest + // specificity). + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + } + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!isPhoneLayout(this.layoutService)) { + super.layout(width, height, top, left); + return; + } + + if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + return; + } + + // Full dimensions — no card margins or border subtraction. + // AbstractPaneCompositePart.layout internally calls Part.layout. + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts new file mode 100644 index 0000000000000..f4f82dcd031cb --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { ChatBarPart } from '../chatBarPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; + +/** + * Mobile variant of ChatBarPart. + * + * On phone-sized viewports the chat bar fills the full grid cell without + * card margins, border insets, or session-bar height adjustments. When + * the viewport transitions to tablet/desktop (e.g., device rotation + * crossing the phone breakpoint) this delegates to the desktop + * implementation so layout math stays correct. + */ +export class MobileChatBarPart extends ChatBarPart { + + override updateStyles(): void { + // Always run the desktop implementation first so inline styles are + // set on tablet/desktop transitions. In phone mode we then clear + // the card-specific inline styles so CSS can take over. + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + container.style.color = ''; + } + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!isPhoneLayout(this.layoutService)) { + super.layout(width, height, top, left); + return; + } + + if (!this.layoutService.isVisible(Parts.CHATBAR_PART)) { + return; + } + + this._lastLayout = { width, height, top, left }; + + // Full dimensions — no card margins or session-bar subtraction. + // AbstractPaneCompositePart.layout internally calls Part.layout so + // there is no need to invoke Part.prototype.layout separately. + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css new file mode 100644 index 0000000000000..9e65c868ed321 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -0,0 +1,359 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Mobile Top Bar ---- */ + +.mobile-top-bar { + display: flex; + align-items: center; + height: 48px; + min-height: 48px; + padding: 0 4px; + padding-top: env(safe-area-inset-top); + background: var(--vscode-editor-background); + flex-shrink: 0; + -webkit-touch-callout: none; + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; + z-index: 10; +} + +.mobile-top-bar .mobile-top-bar-button { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: none; + background: none; + color: var(--vscode-foreground); + cursor: pointer; + border-radius: 50%; + flex-shrink: 0; + touch-action: manipulation; + font-size: 18px; + padding: 0; +} + +.monaco-workbench .mobile-top-bar .mobile-top-bar-button:focus { + outline: none !important; +} + +.mobile-top-bar .mobile-top-bar-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.mobile-top-bar .mobile-top-bar-button:active { + background: var(--vscode-toolbar-hoverBackground); +} + +.mobile-top-bar .mobile-session-title { + flex: 1; + min-width: 0; + text-align: center; + font-size: 16px; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 4px; + cursor: pointer; +} + +.mobile-top-bar .mobile-session-title:active { + opacity: 0.7; +} + +/* ---- Phone Layout: Full-screen chat ---- */ + +/* On phone, stack the mobile top bar and grid vertically */ +.agent-sessions-workbench.phone-layout { + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; +} + +/* On phone, split-view-views that directly contain a Part fill the full + grid area. Uses :has(> .part) to target only part containers — NOT + nested split-views inside parts' own content. */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* The grid's own branch nodes (NOT those inside parts) need full sizing. + Target only direct children of the grid root. */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Split-view-views inside the grid root that contain branch nodes */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view:has(> .monaco-grid-branch-node) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Second-level grid branch nodes */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Third-level (top-right section) */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view:has(> .monaco-grid-branch-node) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Remove card appearance from ALL parts on phone */ +.agent-sessions-workbench.phone-layout .part.chatbar, +.agent-sessions-workbench.phone-layout .part.sidebar, +.agent-sessions-workbench.phone-layout .part.auxiliarybar, +.agent-sessions-workbench.phone-layout .part.panel { + margin: 0 !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + --part-border-color: transparent !important; + width: 100% !important; + height: 100% !important; +} + +/* Force content div inside parts to fill the part on phone. + Part.layoutContents() sets inline width/height via size(), which + may use the grid-allocated dimensions rather than the CSS-overridden + 100% dimensions. Override with !important. */ +.agent-sessions-workbench.phone-layout .part.chatbar > .content, +.agent-sessions-workbench.phone-layout .part.sidebar > .content, +.agent-sessions-workbench.phone-layout .part.auxiliarybar > .content, +.agent-sessions-workbench.phone-layout .part.panel > .content { + width: 100% !important; +} + +/* Hide the session composite bar (Copilot CLI / Approvals / Branch) on phone */ +.agent-sessions-workbench.phone-layout .session-composite-bar { + display: none !important; +} + +/* Ensure the grid view element doesn't overflow — flex child must shrink */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view { + flex: 1 1 0% !important; + min-height: 0 !important; + overflow: hidden !important; + height: auto !important; + background-color: var(--vscode-editor-background); +} + +/* Remove max-width constraint on chat content */ +.agent-sessions-workbench.phone-layout .interactive-session .interactive-item-container { + max-width: none !important; +} + +.agent-sessions-workbench.phone-layout .interactive-session > .chat-suggest-next-widget { + max-width: none !important; +} + +.agent-sessions-workbench.phone-layout .interactive-session .interactive-input-part { + max-width: none !important; + padding-bottom: calc(10px + env(safe-area-inset-bottom)) !important; +} + +/* Chat input minimum font size to prevent iOS auto-zoom */ +.agent-sessions-workbench.phone-layout .interactive-session .chat-input-container textarea, +.agent-sessions-workbench.phone-layout .interactive-session .chat-input-container input { + font-size: 16px !important; +} + +/* Hide the desktop titlebar on phone — replaced by mobile top bar */ +.agent-sessions-workbench.phone-layout .part.titlebar { + display: none !important; +} + +/* Sidebar content and customization toolbar should stack and scroll */ +.agent-sessions-workbench.phone-layout .part.sidebar { + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; +} + +.agent-sessions-workbench.phone-layout .part.sidebar > .composite.title { + display: none !important; +} + +.agent-sessions-workbench.phone-layout .part.sidebar > .content { + top: 0 !important; + flex: 1 !important; + min-height: 0 !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; +} + +/* Customization toolbar: hidden on phone (opens editors, not mobile-compatible) */ +.agent-sessions-workbench.phone-layout .part.sidebar .ai-customization-toolbar { + display: none !important; +} + +/* Make sidebar footer touch-friendly */ +.agent-sessions-workbench.phone-layout .part.sidebar > .sidebar-footer .sidebar-action-button { + min-height: 44px; + padding: 8px 12px; +} + +/* Hide the "+ Session" button in the sidebar on phone — replaced by top bar + button */ +.agent-sessions-workbench.phone-layout .agent-sessions-new-button-container { + display: none !important; +} + +/* Hide sashes on phone */ +.agent-sessions-workbench.phone-layout .monaco-sash { + display: none !important; + pointer-events: none !important; +} + +/* Overscroll containment */ +.agent-sessions-workbench.phone-layout .interactive-session { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.phone-layout .monaco-list { + overscroll-behavior: contain; +} + +/* On phone, push the chat input to the bottom of the chat area */ +.agent-sessions-workbench.phone-layout .interactive-session .interactive-input-and-execute-toolbar { + margin-top: auto !important; +} + +/* ---- Phone Layout: Chat Welcome Page ---- */ + +/* Make the welcome page a flex column that fills the chat area */ +.agent-sessions-workbench.phone-layout .new-chat-widget-container { + display: flex !important; + flex-direction: column !important; + height: 100% !important; + padding: 8px 8px 0 8px !important; +} + +.agent-sessions-workbench.phone-layout .new-chat-widget-content { + display: flex !important; + flex-direction: column !important; + flex: 1 !important; + min-height: 0 !important; + max-width: 100% !important; + padding-bottom: 20px !important; +} + +/* Workspace picker centered vertically with icon above */ +.agent-sessions-workbench.phone-layout .new-session-workspace-picker-container { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + max-width: 100% !important; +} + +/* Show the sessions logo above the workspace picker — same asset as the auth page */ +.agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before { + content: ''; + display: block; + width: 64px; + height: 64px; + margin-bottom: 16px; + background-image: url('../../media/sessions-logo-light.svg'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +.vs .agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before, +.hc-light .agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before { + background-image: url('../../media/sessions-logo-dark.svg'); +} + +/* Center the picker text */ +.agent-sessions-workbench.phone-layout .session-workspace-picker { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + gap: 8px !important; + font-size: 16px !important; +} + +.agent-sessions-workbench.phone-layout .session-workspace-picker-label { + font-size: 18px !important; + opacity: 0.6; +} + +/* Input slot pinned to the bottom */ +.agent-sessions-workbench.phone-layout .new-chat-input-container { + flex-shrink: 0 !important; + padding: 0 0 8px 0 !important; + max-width: 100% !important; +} + +/* Make the chat input full-width and edge-to-edge styled */ +.agent-sessions-workbench.phone-layout .sessions-chat-input-area { + border-radius: 16px !important; + max-width: 100% !important; +} + +/* Hide the local mode bar (Copilot CLI / Default Approvals / Branch) on phone */ +.agent-sessions-workbench.phone-layout .new-chat-bottom-container { + display: none !important; +} + +/* Also hide the sessions-chat-widget's DnD overlay on phone */ +.agent-sessions-workbench.phone-layout .sessions-chat-dnd-overlay { + display: none !important; +} + +/* Chat widget fills full width on phone */ +.agent-sessions-workbench.phone-layout .sessions-chat-widget { + width: 100% !important; +} + +/* allow-any-unicode-next-line */ +/* Compact chat toolbar on phone */ +.agent-sessions-workbench.phone-layout .sessions-chat-toolbar { + padding: 0 6px 0 6px !important; + max-height: 32px !important; + gap: 4px !important; +} + +/* Prevent card transitions from flashing on phone */ +.agent-sessions-workbench.phone-layout .part.chatbar, +.agent-sessions-workbench.phone-layout .part.sidebar, +.agent-sessions-workbench.phone-layout .part.auxiliarybar, +.agent-sessions-workbench.phone-layout .part.panel { + transition: none !important; +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileLayout.ts b/src/vs/sessions/browser/parts/mobile/mobileLayout.ts new file mode 100644 index 0000000000000..3d7ca98d99a25 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileLayout.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; + +/** + * CSS class applied to the sessions workbench main container when the + * viewport is classified as phone. Must stay in sync with + * `LayoutClasses.PHONE_LAYOUT` in `workbench.ts`. + */ +const PHONE_LAYOUT_CLASS = 'phone-layout'; + +/** + * Returns true when the sessions workbench currently has the phone + * layout class on its main container. + * + * Mobile Part subclasses are chosen once at construction time, but the + * viewport class can change at runtime (e.g., device rotation crossing + * the phone breakpoint). Parts use this to decide whether to apply + * mobile-specific layout math or defer to the desktop implementation. + */ +export function isPhoneLayout(layoutService: IWorkbenchLayoutService): boolean { + return layoutService.mainContainer.classList.contains(PHONE_LAYOUT_CLASS); +} diff --git a/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts b/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts new file mode 100644 index 0000000000000..2891360e96729 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { PanelPart } from '../panelPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; + +/** + * Mobile variant of PanelPart. + * + * On phone-sized viewports the panel fills the full grid cell + * without card margins or border insets. On tablet/desktop it falls + * back to the desktop behavior so runtime viewport transitions keep + * working. + */ +export class MobilePanelPart extends PanelPart { + + override updateStyles(): void { + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + } + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!isPhoneLayout(this.layoutService)) { + super.layout(width, height, top, left); + return; + } + + if (!this.layoutService.isVisible(Parts.PANEL_PART)) { + return; + } + + // Full dimensions — no card margins or border subtraction. + // AbstractPaneCompositePart.layout internally calls Part.layout. + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts new file mode 100644 index 0000000000000..f062ddfd2f4f8 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { SidebarPart } from '../sidebarPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; + +/** + * Mobile variant of SidebarPart. + * + * On phone-sized viewports the sidebar skips card-specific inline styles + * so that CSS-only theming takes over. On tablet/desktop it falls back + * to the desktop behavior so runtime viewport transitions keep working. + */ +export class MobileSidebarPart extends SidebarPart { + + override updateStyles(): void { + // Run base theme wiring; this also cascades to AbstractPaneCompositePart. + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + // Skip SidebarPart's card / title-area inline styles on phone. + AbstractPaneCompositePart.prototype.updateStyles.call(this); + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.color = ''; + container.style.outlineColor = ''; + } + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts new file mode 100644 index 0000000000000..26a1aed9642d9 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './mobileChatShell.css'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; + +/** + * Mobile top bar component — a simple DOM element prepended to the + * workbench container on phone viewports. Replaces the desktop titlebar + * with a native-feeling mobile app bar. + * + * Layout: [hamburger] [session title] [+ new] + */ +export class MobileTopBar extends Disposable { + + readonly element: HTMLElement; + + private readonly sessionTitleElement: HTMLElement; + + private readonly _onDidClickHamburger = this._register(new Emitter()); + readonly onDidClickHamburger: Event = this._onDidClickHamburger.event; + + private readonly _onDidClickNewSession = this._register(new Emitter()); + readonly onDidClickNewSession: Event = this._onDidClickNewSession.event; + + private readonly _onDidClickTitle = this._register(new Emitter()); + readonly onDidClickTitle: Event = this._onDidClickTitle.event; + + constructor(parent: HTMLElement) { + super(); + + this.element = document.createElement('div'); + this.element.className = 'mobile-top-bar'; + + // Register DOM removal before appending so that any exception + // between this point and the end of the constructor still cleans + // up the element via disposal. + this._register(toDisposable(() => this.element.remove())); + parent.prepend(this.element); + + // Hamburger button + const hamburger = append(this.element, $('button.mobile-top-bar-button')); + hamburger.setAttribute('aria-label', 'Open sessions'); + const hamburgerIcon = append(hamburger, $('span')); + hamburgerIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.menu)); + this._register(addDisposableListener(hamburger, EventType.CLICK, () => this._onDidClickHamburger.fire())); + + // Session title + this.sessionTitleElement = append(this.element, $('div.mobile-session-title')); + this.sessionTitleElement.textContent = localize('mobileTopBar.newSession', "New Session"); + this._register(addDisposableListener(this.sessionTitleElement, EventType.CLICK, () => this._onDidClickTitle.fire())); + + // New session button (+) + const newSession = append(this.element, $('button.mobile-top-bar-button')); + newSession.setAttribute('aria-label', 'New session'); + const newSessionIcon = append(newSession, $('span')); + newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus)); + this._register(addDisposableListener(newSession, EventType.CLICK, () => this._onDidClickNewSession.fire())); + } + + setTitle(title: string): void { + this.sessionTitleElement.textContent = title; + } +} diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index e012ecf4941cc..3789111d77c67 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -7,12 +7,12 @@ import '../../workbench/browser/style.js'; import './media/style.css'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; -import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; +import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle, addDisposableListener, EventType } from '../../base/browser/dom.js'; import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; import { mark } from '../../base/common/performance.js'; import { onUnexpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; -import { isWindows, isLinux, isWeb, isNative, isMacintosh, isMobile } from '../../base/common/platform.js'; +import { isWindows, isLinux, isWeb, isNative, isMacintosh } from '../../base/common/platform.js'; import { Parts, Position, PanelAlignment, IWorkbenchLayoutService, SINGLE_WINDOW_PARTS, MULTI_WINDOW_PARTS, IPartVisibilityChangeEvent, positionToString } from '../../workbench/services/layout/browser/layoutService.js'; import { ILayoutOffsetInfo } from '../../platform/layout/browser/layoutService.js'; import { Part } from '../../workbench/browser/part.js'; @@ -63,12 +63,17 @@ import { SyncDescriptor } from '../../platform/instantiation/common/descriptors. import { TitleService } from './parts/titlebarPart.js'; import { SessionsExperimentalShellGradientBackgroundSettingId } from '../common/configuration.js'; import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; -import { EditorMaximizedContext } from '../common/contextkeys.js'; +import { EditorMaximizedContext, IsPhoneLayoutContext, KeyboardVisibleContext } from '../common/contextkeys.js'; import { NotificationsPosition, NotificationsSettings, getNotificationsPosition } from '../../workbench/common/notifications.js'; +import { SessionsLayoutPolicy } from './layoutPolicy.js'; +import { MobileNavigationStack } from './mobileNavigationStack.js'; +import { MobileTopBar } from './parts/mobile/mobileTopBar.js'; +import { autorun } from '../../base/common/observable.js'; +import { ISessionsManagementService } from '../services/sessions/common/sessionsManagement.js'; //#region Workbench Options @@ -92,7 +97,8 @@ enum LayoutClasses { STATUSBAR_HIDDEN = 'nostatusbar', EXPERIMENTAL_SHELL_GRADIENT_BACKGROUND = 'experimental-shell-gradient-background', FULLSCREEN = 'fullscreen', - MAXIMIZED = 'maximized' + MAXIMIZED = 'maximized', + PHONE_LAYOUT = 'phone-layout' } //#endregion @@ -233,6 +239,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic if (this.isVisible(Parts.TITLEBAR_PART, mainWindow)) { top = this.getPart(Parts.TITLEBAR_PART).maximumHeight; quickPickTop = top; + } else if (this.mobileTopBarElement) { + // On phone layout the MobileTopBar replaces the titlebar + top = this.mobileTopBarElement.offsetHeight; + quickPickTop = top; } return { top, quickPickTop }; @@ -263,6 +273,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private mainWindowFullscreen = false; private readonly maximized = new Set(); + private readonly layoutPolicy = this._register(new SessionsLayoutPolicy()); + private readonly mobileNavStack = this._register(new MobileNavigationStack()); + private mobileTopBarElement: HTMLElement | undefined; + private readonly mobileTopBarDisposables = this._register(new DisposableStore()); private _editorMaximized = false; private _editorLastNonMaximizedVisibility: IPartVisibilityState | undefined; @@ -281,6 +295,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private editorService!: IEditorService; private paneCompositeService!: IPaneCompositePartService; private viewDescriptorService!: IViewDescriptorService; + private sessionsManagementService!: ISessionsManagementService; //#endregion @@ -292,6 +307,26 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic ) { super(); + // Sessions-scoped mobile viewport tweaks. These are applied here + // (rather than in the shared workbench.html) so that the regular + // code-web workbench — which does not handle safe-area insets — is + // not affected on notched mobile devices. + // The viewport `` tag is injected by the shared workbench.html, + // so we cannot use dom.ts `h()` to create it. Look it up by tag name + // and filter by the `name` attribute to avoid a selector query. + // eslint-disable-next-line no-restricted-syntax + const metaElements = mainWindow.document.head.getElementsByTagName('meta'); + let viewportMeta: HTMLMetaElement | undefined; + for (let i = 0; i < metaElements.length; i++) { + if (metaElements[i].name === 'viewport') { + viewportMeta = metaElements[i]; + break; + } + } + if (viewportMeta && !viewportMeta.content.includes('viewport-fit=')) { + viewportMeta.content = `${viewportMeta.content}, viewport-fit=cover`; + } + // Perf: measure workbench startup time mark('code/willStartWorkbench'); @@ -391,6 +426,41 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic editorMaximizedContext.set(this.isEditorMaximized()); })); + // Phone Layout Context Key + const contextKeyService = accessor.get(IContextKeyService); + const isPhoneLayoutCtx = IsPhoneLayoutContext.bindTo(contextKeyService); + this._register(autorun(reader => { + isPhoneLayoutCtx.set(this.layoutPolicy.viewportClass.read(reader) === 'phone'); + })); + + // Virtual keyboard detection via visualViewport API. + // Use `window.innerHeight` (layout viewport) as the baseline + // rather than a captured initial height. Layout viewport + // updates on orientation change and split-screen resizes, so + // comparing against it avoids stale baselines on landscape + // launches, Android split-screen, and iOS URL-bar collapse. + if (mainWindow.visualViewport) { + const keyboardVisibleCtx = KeyboardVisibleContext.bindTo(contextKeyService); + const KEYBOARD_HEIGHT_THRESHOLD_PX = 100; + + const onViewportResize = () => { + const vp = mainWindow.visualViewport; + if (!vp) { + return; + } + const heightDiff = mainWindow.innerHeight - vp.height; + keyboardVisibleCtx.set(heightDiff > KEYBOARD_HEIGHT_THRESHOLD_PX); + }; + + mainWindow.visualViewport.addEventListener('resize', onViewportResize); + this._register({ dispose: () => mainWindow.visualViewport?.removeEventListener('resize', onViewportResize) }); + } + + // Orientation changes produce a window `resize` event which + // is already handled by `registerLayoutListeners()`. No + // separate matchMedia listener is needed — the previous + // implementation caused a redundant second layout. + // Register Listeners this.registerListeners(lifecycleService, storageService, configurationService, hostService, dialogService); @@ -400,6 +470,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Workbench Layout this.createWorkbenchLayout(); + // Create mobile navigation after grid exists (so DOM order is correct) + if (this.layoutPolicy.viewportClass.get() === 'phone') { + this.createMobileTopBar(); + } + // Workbench Management this.createWorkbenchManagement(instantiationService); @@ -547,6 +622,18 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic setARIAContainer(this.mainContainer); setProgressAccessibilitySignalScheduler((msDelayTime: number, msLoopTime?: number) => instantiationService.createInstance(AccessibilityProgressSignalScheduler, msDelayTime, msLoopTime)); + // Initialize viewport classification before building layout classes + const initialDimension = getClientArea(this.parent); + this.layoutPolicy.update(initialDimension.width, initialDimension.height); + + // Apply initial part visibility from layout policy (phone hides sidebar, etc.) + const visibilityDefaults = this.layoutPolicy.getPartVisibilityDefaults(); + this.partVisibility.sidebar = visibilityDefaults.sidebar; + this.partVisibility.auxiliaryBar = visibilityDefaults.auxiliaryBar; + this.partVisibility.panel = visibilityDefaults.panel; + this.partVisibility.chatBar = visibilityDefaults.chatBar; + this.partVisibility.editor = visibilityDefaults.editor; + // State specific classes const platformClass = isWindows ? 'windows' : isLinux ? 'linux' : 'mac'; const workbenchClasses = coalesce([ @@ -593,6 +680,76 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.parent.appendChild(this.mainContainer); } + private createMobileTopBar(): void { + this.mobileTopBarDisposables.clear(); + const mobileTopBar = this.mobileTopBarDisposables.add(new MobileTopBar(this.mainContainer)); + this.mobileTopBarElement = mobileTopBar.element; + + // Hamburger: toggle sidebar drawer overlay + this.mobileTopBarDisposables.add(mobileTopBar.onDidClickHamburger(() => { + this.toggleMobileSidebarDrawer(); + })); + + // New session: open new chat view + this.mobileTopBarDisposables.add(mobileTopBar.onDidClickNewSession(() => { + this.sessionsManagementService.openNewSessionView(); + })); + } + + private sidebarDrawerBackdrop: HTMLElement | undefined; + private readonly sidebarDrawerBackdropDisposables = this._register(new DisposableStore()); + + private toggleMobileSidebarDrawer(): void { + const isOpen = this.partVisibility.sidebar; + if (isOpen) { + this.closeMobileSidebarDrawer(); + } else { + this.openMobileSidebarDrawer(); + } + } + + private openMobileSidebarDrawer(): void { + // Show backdrop — created fresh each open so its click listener is + // tracked by a DisposableStore and cleaned up on close. + if (!this.sidebarDrawerBackdrop) { + const backdrop = document.createElement('div'); + backdrop.className = 'mobile-sidebar-backdrop'; + this.sidebarDrawerBackdropDisposables.add(addDisposableListener(backdrop, EventType.CLICK, () => this.closeMobileSidebarDrawer())); + this.sidebarDrawerBackdropDisposables.add(toDisposable(() => backdrop.remove())); + this.sidebarDrawerBackdrop = backdrop; + } + this.mainContainer.appendChild(this.sidebarDrawerBackdrop); + + // Push a history entry so the Android back button dismisses the drawer. + // Must come before setSideBarHidden(false) so layoutMobileSidebar() sees + // the drawer state. + if (!this.mobileNavStack.has('sidebar')) { + this.mobileNavStack.push('sidebar'); + } + + // Show sidebar in grid — the actual drawer dimensions are applied by + // layoutMobileSidebar() from within layout(), which respects the + // "drawer" shape on phone (85% width, below the mobile top bar). + this.setSideBarHidden(false); + } + + private closeMobileSidebarDrawer(): void { + // Remove backdrop and dispose its listener. + this.sidebarDrawerBackdropDisposables.clear(); + this.sidebarDrawerBackdrop = undefined; + + // Hide sidebar in grid + this.setSideBarHidden(true); + + // Sync the navigation stack with the browser history: if there is a + // pending 'sidebar' entry (UI-initiated close), rewind history without + // firing onDidPop. If we're being called from the back-button path + // (onDidPop already fired), this is a no-op. + if (this.mobileNavStack.has('sidebar')) { + this.mobileNavStack.popSilently('sidebar'); + } + } + private createNotificationsHandlers( instantiationService: IInstantiationService, notificationService: NotificationService, @@ -742,6 +899,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.editorService = accessor.get(IEditorService); this.paneCompositeService = accessor.get(IPaneCompositePartService); this.viewDescriptorService = accessor.get(IViewDescriptorService); + this.sessionsManagementService = accessor.get(ISessionsManagementService); accessor.get(ITitleService); // Register layout listeners @@ -770,17 +928,15 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Initialize layout state (must be done before createWorkbenchLayout) this._mainContainerDimension = getClientArea(this.parent, new Dimension(800, 600)); + this.layoutPolicy.update(this._mainContainerDimension.width, this._mainContainerDimension.height); - // Default to list-detail on mobile web only. Desktop behavior stays unchanged, - // regardless of how narrow the window is resized. - if (isWeb && isMobile) { - this.partVisibility.sidebar = false; - this.partVisibility.auxiliaryBar = false; - } - } - - private isMobileWebLayout(): boolean { - return isWeb && isMobile; + // Update part visibility based on final viewport classification + const visDefaults = this.layoutPolicy.getPartVisibilityDefaults(); + this.partVisibility.sidebar = visDefaults.sidebar; + this.partVisibility.auxiliaryBar = visDefaults.auxiliaryBar; + this.partVisibility.panel = visDefaults.panel; + this.partVisibility.chatBar = visDefaults.chatBar; + this.partVisibility.editor = visDefaults.editor; } private areAllGroupsEmpty(): boolean { @@ -801,6 +957,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.layout(); } })); + + // Window resize — needed for device emulation and mobile viewport changes + const onWindowResize = () => this.layout(); + mainWindow.addEventListener('resize', onWindowResize); + this._register({ dispose: () => mainWindow.removeEventListener('resize', onWindowResize) }); } private updateFullscreenClass(): void { @@ -871,6 +1032,24 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); })); } + + // Wire up mobile nav stack: back-button pops close the corresponding part + this._register(this.mobileNavStack.onDidPop(layer => { + switch (layer) { + case 'sidebar': + this.closeMobileSidebarDrawer(); + break; + case 'panel': + this.setPanelHidden(true); + break; + case 'auxbar': + this.setAuxiliaryBarHidden(true); + break; + case 'editor': + // Editor modal close is handled by the editor service + break; + } + })); } createWorkbenchManagement(_instantiationService: IInstantiationService): void { @@ -890,25 +1069,43 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private createGridDescriptor(): ISerializedGrid { const { width, height } = this._mainContainerDimension; - // Default sizes - const sideBarSize = 300; + return this.createDesktopGridDescriptor(width, height); + } + + /** + * Standard multi-part layout for all viewport classes. + * On phone, the titlebar is hidden via CSS and a MobileTopBar + * is prepended before the grid. Sidebar/panel/auxbar are hidden + * in the grid via partVisibility defaults. + */ + private createDesktopGridDescriptor(width: number, height: number): ISerializedGrid { + + // Default sizes from layout policy + const sizes = this.layoutPolicy.getPartSizes(width, height); + // For hidden parts, still provide a reasonable cached size for when they're shown later + const sideBarSize = this.partVisibility.sidebar ? sizes.sideBarSize : Math.max(sizes.sideBarSize, 250); + const auxiliaryBarSize = this.partVisibility.auxiliaryBar ? sizes.auxiliaryBarSize : Math.max(sizes.auxiliaryBarSize, 300); + const panelSize = this.partVisibility.panel ? sizes.panelSize : Math.max(sizes.panelSize, 250); const editorSize = 600; - const auxiliaryBarSize = 340; - const panelSize = 300; const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; - // Calculate right section width and chat bar width - const rightSectionWidth = Math.max(0, width - sideBarSize); - const chatBarWidth = Math.max(0, rightSectionWidth - auxiliaryBarSize - editorSize); + // Calculate right section width — when sidebar is hidden it takes no space + const effectiveSideBarWidth = this.partVisibility.sidebar ? sideBarSize : 0; + const rightSectionWidth = Math.max(0, width - effectiveSideBarWidth); + const effectiveAuxBarWidth = this.partVisibility.auxiliaryBar ? auxiliaryBarSize : 0; + const effectiveEditorWidth = this.partVisibility.editor ? editorSize : 0; + const chatBarWidth = Math.max(0, rightSectionWidth - effectiveAuxBarWidth - effectiveEditorWidth); const contentHeight = Math.max(0, height - titleBarHeight); const topRightHeight = Math.max(0, contentHeight - panelSize); + const isPhone = this.layoutPolicy.viewportClass.get() === 'phone'; + const titleBarNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.TITLEBAR_PART }, size: titleBarHeight, - visible: true + visible: !isPhone }; const sideBarNode: ISerializedLeafNode = { @@ -988,16 +1185,78 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic //#region Layout Methods + private _previousViewportClass: string | undefined; + layout(): void { this._mainContainerDimension = getClientArea( this.mainWindowFullscreen ? mainWindow.document.body : this.parent ); + + // Update viewport classification and toggle mobile CSS classes + const previousClass = this._previousViewportClass; + this.layoutPolicy.update(this._mainContainerDimension.width, this._mainContainerDimension.height); + const currentClass = this.layoutPolicy.viewportClass.get(); + this.mainContainer.classList.toggle(LayoutClasses.PHONE_LAYOUT, currentClass === 'phone'); + + // When viewport class changes at runtime (e.g., device emulation toggle), + // update part visibility and create/destroy mobile components + if (previousClass !== undefined && previousClass !== currentClass) { + if (currentClass === 'phone' && !this.mobileTopBarElement) { + this.createMobileTopBar(); + // Hide titlebar in grid on phone (replaced by MobileTopBar) + this.workbenchGrid.setViewVisible(this.titleBarPartView, false); + // On phone, only chat is visible — hide everything else first + const defaults = this.layoutPolicy.getPartVisibilityDefaults(); + if (this.partVisibility.sidebar !== defaults.sidebar) { + this.setSideBarHidden(!defaults.sidebar); + } + if (this.partVisibility.auxiliaryBar !== defaults.auxiliaryBar) { + this.setAuxiliaryBarHidden(!defaults.auxiliaryBar); + } + if (this.partVisibility.panel !== defaults.panel) { + this.setPanelHidden(!defaults.panel); + } + } else if (currentClass !== 'phone' && this.mobileTopBarElement) { + // Remove mobile components when leaving phone layout + this.mobileTopBarDisposables.clear(); + this.mobileTopBarElement = undefined; + // Restore titlebar in grid + this.workbenchGrid.setViewVisible(this.titleBarPartView, true); + // Restore desktop part visibility + const defaults = this.layoutPolicy.getPartVisibilityDefaults(); + if (this.partVisibility.sidebar !== defaults.sidebar) { + this.setSideBarHidden(!defaults.sidebar); + } + if (this.partVisibility.chatBar !== defaults.chatBar) { + this.setChatBarHidden(!defaults.chatBar); + } + if (this.partVisibility.auxiliaryBar !== defaults.auxiliaryBar) { + this.setAuxiliaryBarHidden(!defaults.auxiliaryBar); + } + if (this.partVisibility.panel !== defaults.panel) { + this.setPanelHidden(!defaults.panel); + } + } + + // Re-run updateStyles() on pane composite parts so that + // mobile Part subclasses can re-apply or clear card-chrome + // inline styles based on the new `.phone-layout` class. + for (const partId of [Parts.CHATBAR_PART, Parts.SIDEBAR_PART, Parts.AUXILIARYBAR_PART, Parts.PANEL_PART]) { + this.parts.get(partId)?.updateStyles(); + } + } + this._previousViewportClass = currentClass; + this.logService.trace(`Workbench#layout, height: ${this._mainContainerDimension.height}, width: ${this._mainContainerDimension.width}`); size(this.mainContainer, this._mainContainerDimension.width, this._mainContainerDimension.height); + // On phone, subtract the mobile top bar height from the grid + const mobileTopBarHeight = this.mobileTopBarElement?.offsetHeight ?? 0; + const gridHeight = this._mainContainerDimension.height - mobileTopBarHeight; + // Layout the grid widget - this.workbenchGrid.layout(this._mainContainerDimension.width, this._mainContainerDimension.height); + this.workbenchGrid.layout(this._mainContainerDimension.width, gridHeight); this.layoutMobileSidebar(); // Emit as event @@ -1011,7 +1270,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return; } - if (!this.isMobileWebLayout() || !this.partVisibility.sidebar) { + // Only phone uses the overlay drawer shape. Tablet/desktop let the + // grid position the sidebar normally, so clear any inline styles. + const isPhone = this.layoutPolicy.viewportClass.get() === 'phone'; + if (!isPhone || !this.partVisibility.sidebar) { sidebarContainer.classList.remove('mobile-overlay-sidebar'); sidebarContainer.style.position = ''; sidebarContainer.style.top = ''; @@ -1022,17 +1284,19 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return; } - const titleBarHeight = this.workbenchGrid.getViewSize(this.titleBarPartView).height; - const mobileWidth = this._mainContainerDimension.width; - const mobileHeight = Math.max(0, this._mainContainerDimension.height - titleBarHeight); + // Phone drawer: 85% width (capped at 360px), positioned below the + // mobile top bar (the grid titlebar is hidden on phone). + const topBarHeight = this.mobileTopBarElement?.offsetHeight ?? 48; + const drawerWidth = Math.min(Math.floor(this._mainContainerDimension.width * 0.85), 360); + const drawerHeight = Math.max(0, this._mainContainerDimension.height - topBarHeight); sidebarContainer.classList.add('mobile-overlay-sidebar'); sidebarContainer.style.position = 'fixed'; - sidebarContainer.style.top = `${titleBarHeight}px`; + sidebarContainer.style.top = `${topBarHeight}px`; sidebarContainer.style.left = '0'; - sidebarContainer.style.width = `${mobileWidth}px`; - sidebarContainer.style.height = `${mobileHeight}px`; + sidebarContainer.style.width = `${drawerWidth}px`; + sidebarContainer.style.height = `${drawerHeight}px`; sidebarContainer.style.zIndex = '30'; - sidebarPart.layout(mobileWidth, mobileHeight, titleBarHeight, 0); + sidebarPart.layout(drawerWidth, drawerHeight, topBarHeight, 0); } private handleContainerDidLayout(container: HTMLElement, dimension: IDimension): void { @@ -1053,7 +1317,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, !this.partVisibility.chatBar ? LayoutClasses.CHATBAR_HIDDEN : undefined, LayoutClasses.STATUSBAR_HIDDEN, // agents window never has a status bar - this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined + this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, + this.layoutPolicy.viewportClass.get() === 'phone' ? LayoutClasses.PHONE_LAYOUT : undefined, ]); } @@ -1163,7 +1428,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic isVisible(part: Parts, targetWindow?: Window): boolean { switch (part) { case Parts.TITLEBAR_PART: - return true; // Always visible + // On phone layout the grid titlebar is hidden (replaced by MobileTopBar) + return this.layoutPolicy.viewportClass.get() !== 'phone'; case Parts.SIDEBAR_PART: return this.partVisibility.sidebar; case Parts.AUXILIARYBAR_PART: diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 58862cd264b0f..b1bdfe7fef46c 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -68,8 +68,10 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { clearSessionConfig(sessionId: string): void; } -const LOCAL_AGENT_HOST_PROVIDER_ID = 'local-agent-host'; -const REMOTE_AGENT_HOST_PROVIDER_PREFIX = 'agenthost-'; +export const LOCAL_AGENT_HOST_PROVIDER_ID = 'local-agent-host'; +export const REMOTE_AGENT_HOST_PROVIDER_PREFIX = 'agenthost-'; +export const REMOTE_AGENT_HOST_PROVIDER_RE = /^agenthost-/; +export const ANY_AGENT_HOST_PROVIDER_RE = /^(local-agent-host|agenthost-)/; /** * Checks whether a provider is an agent host provider based on its diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 385069bd358d7..a42408dbc50da 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -38,3 +38,10 @@ export const SessionsWelcomeVisibleContext = new RawContextKey('session export const EditorMaximizedContext = new RawContextKey('editorMaximized', false, localize('editorMaximized', "Whether the editor area is maximized")); //#endregion + +//#region < --- Mobile Layout --- > + +export const IsPhoneLayoutContext = new RawContextKey('sessionsIsPhoneLayout', false, localize('sessionsIsPhoneLayout', "Whether the current layout is the phone layout")); +export const KeyboardVisibleContext = new RawContextKey('sessionsKeyboardVisible', false, localize('sessionsKeyboardVisible', "Whether the virtual keyboard is visible")); + +//#endregion diff --git a/src/vs/sessions/common/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts index ed404d3272de3..04926331fc9b4 100644 --- a/src/vs/sessions/common/sessionsTelemetry.ts +++ b/src/vs/sessions/common/sessionsTelemetry.ts @@ -138,10 +138,10 @@ type TunnelConnectAttemptEvent = { type TunnelConnectAttemptClassification = { owner: 'osortega'; comment: 'Tracks individual agent-host tunnel connect attempts for performance and reliability.'; - isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this attempt was part of a reconnect cycle (true) or an initial connect (false).' }; + isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether this attempt was part of a reconnect cycle (true) or an initial connect (false).' }; attempt: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Attempt number within the current connect session (1-based).' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Duration of this individual attempt in milliseconds.' }; - success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether this individual attempt succeeded.' }; + success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether this individual attempt succeeded.' }; errorCategory: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Category of error when the attempt failed (relayConnectionFailed, auth, authExpired, network, other); empty on success.' }; }; @@ -166,10 +166,10 @@ type TunnelConnectResolvedEvent = { type TunnelConnectResolvedClassification = { owner: 'osortega'; comment: 'Tracks overall agent-host tunnel connect session outcomes for reliability.'; - isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the resolved session was a reconnect cycle (true) or an initial connect (false).' }; + isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the resolved session was a reconnect cycle (true) or an initial connect (false).' }; totalAttempts: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of attempts made before resolution.' }; totalDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total elapsed time from session start to resolution in milliseconds.' }; - success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the connect session ultimately succeeded.' }; + success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the connect session ultimately succeeded.' }; failureReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Reason the session terminated without connecting (hostOffline, maxAttemptsReached, auth, authExpired); empty on success.' }; }; @@ -182,3 +182,98 @@ export function logTunnelConnectResolved(telemetryService: ITelemetryService, da failureReason: data.failureReason ?? '', }); } + +// --- Socket lifecycle telemetry --- + +export type SocketCloseTrigger = + | 'server' + | 'sendOnDeadSocket' + | 'visibility' + | 'offline' + | 'malformedFrames' + | 'disposed' + | 'error'; + +type SocketCloseEvent = { + closeCode: number; + wasClean: boolean; + lifetimeMs: number; + messagesSent: number; + messagesReceived: number; + messagesDropped: number; + trigger: string; +}; + +type SocketCloseClassification = { + owner: 'osortega'; + comment: 'Tracks WebSocket close events for agent host connections to measure connection reliability.'; + closeCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'WebSocket close code.' }; + wasClean: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the close was clean.' }; + lifetimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the socket was alive in milliseconds.' }; + messagesSent: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages sent.' }; + messagesReceived: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages received.' }; + messagesDropped: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages dropped due to non-OPEN socket.' }; + trigger: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'What triggered the close (server, sendOnDeadSocket, visibility, offline, malformedFrames, disposed, error).' }; +}; + +export function logSocketClose(telemetryService: ITelemetryService, data: { closeCode: number; wasClean: boolean; lifetimeMs: number; messagesSent: number; messagesReceived: number; messagesDropped: number; trigger: SocketCloseTrigger }): void { + telemetryService.publicLog2('vscodeAgents.socket/close', data); +} + +// --- Send dropped telemetry --- + +type SendDroppedEvent = { + readyState: number; + timeSinceLastReceiveMs: number; + timeSinceLastSendMs: number; +}; + +type SendDroppedClassification = { + owner: 'osortega'; + comment: 'Tracks when a message is silently dropped due to a non-OPEN WebSocket, indicating a zombie socket.'; + readyState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'WebSocket readyState at drop time (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED).' }; + timeSinceLastReceiveMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds since last received message.' }; + timeSinceLastSendMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds since last sent message.' }; +}; + +export function logSendDropped(telemetryService: ITelemetryService, data: { readyState: number; timeSinceLastReceiveMs: number; timeSinceLastSendMs: number }): void { + telemetryService.publicLog2('vscodeAgents.socket/sendDropped', data); +} + +// --- Visibility resumed telemetry --- + +type VisibilityResumedEvent = { + hiddenDurationMs: number; + socketAlive: boolean; + forceClosed: boolean; +}; + +type VisibilityResumedClassification = { + owner: 'osortega'; + comment: 'Tracks tab visibility resume events to measure zombie socket detection effectiveness.'; + hiddenDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the tab was hidden in milliseconds.' }; + socketAlive: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the socket was alive after zombie detection check.' }; + forceClosed: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the socket was force-closed on resume.' }; +}; + +export function logVisibilityResumed(telemetryService: ITelemetryService, data: { hiddenDurationMs: number; socketAlive: boolean; forceClosed: boolean }): void { + telemetryService.publicLog2('vscodeAgents.socket/visibilityResumed', data); +} + +// --- Terminal recovery telemetry --- + +type TerminalRecoveryEvent = { + recoveredCount: number; + totalCount: number; +}; + +type TerminalRecoveryClassification = { + owner: 'osortega'; + comment: 'Tracks terminal reconnection outcomes after agent host disconnect.'; + recoveredCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of terminals successfully reconnected.' }; + totalCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of active terminals at reconnect time.' }; +}; + +export function logTerminalRecovery(telemetryService: ITelemetryService, data: { recoveredCount: number; totalCount: number }): void { + telemetryService.publicLog2('vscodeAgents.terminal/recovery', data); +} diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts index 657595bed09d1..dc712d1b4ff2e 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts @@ -16,6 +16,7 @@ import { ChatSessionProviderIdContext } from '../../../common/contextkeys.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; import { agentSessionSettingsUri, AGENT_SESSION_SETTINGS_SCHEME, AgentSessionSettingsFileSystemProvider, AgentSessionSettingsSchemaRegistrar } from './agentSessionSettingsFileSystemProvider.js'; +import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; /** * Registers the {@link AgentSessionSettingsFileSystemProvider} with the @@ -57,7 +58,7 @@ registerAction2(class OpenSessionSettingsAction extends Action2 { id: SessionItemContextMenuId, group: '2_settings', order: 1, - when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, /^(local-agent-host|agenthost-)/), + when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), }] }); } diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts index 2f9b0f901fb43..cd0caf3c3ace8 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts @@ -350,7 +350,7 @@ export function buildSessionSettingsJsonSchema(config: ResolveSessionConfigResul const result: IJSONSchema = { type: 'object', properties, - additionalProperties: false, + additionalProperties: true, }; if (required.length > 0) { result.required = required; @@ -493,7 +493,7 @@ export class AgentSessionSettingsSchemaRegistrar extends Disposable { // client only knows how to fetch schema content for that scheme. // The settings-file URI is used as the fileMatch glob so the schema // is applied to the actual editor document. - const schemaId = `vscode://schemas/agent-session-settings/${session.providerId}${session.resource.scheme}${session.resource.path}.jsonc`; + const schemaId = `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`; const identity = config.schema; if (this._lastSchemaIdentity.get(settingsUri) === identity) { return; @@ -520,3 +520,4 @@ export class AgentSessionSettingsSchemaRegistrar extends Disposable { this._sessionSchemas.deleteAndDispose(schemaUri); } } + diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts index 587aa5c248fb0..f492f082af181 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts @@ -22,8 +22,8 @@ import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.j import { buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; import { ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; import { toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; -const LOCAL_PROVIDER_ID = 'local-agent-host'; const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; /** @@ -36,7 +36,7 @@ const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; */ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvider { - readonly id = LOCAL_PROVIDER_ID; + readonly id = LOCAL_AGENT_HOST_PROVIDER_ID; readonly label: string; readonly icon: ThemeIcon = Codicon.vm; readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; diff --git a/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts index 4f52eef17bf55..87e37a368536f 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts @@ -230,7 +230,7 @@ suite('AgentSessionSettingsFileSystemProvider', () => { const schemaRegistry = Registry.as(JSONExtensions.JSONContribution); function expectedSchemaId(session: ISession): string { - return `vscode://schemas/agent-session-settings/${session.providerId}${session.resource.scheme}${session.resource.path}.jsonc`; + return `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`; } test('readFile lazily registers a schema + association for the session', async () => { diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index dfbd37604e9d0..fc2d4f6f6ef0a 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -13,6 +13,7 @@ import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensi import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID } from '../common/changes.js'; import { ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import { ChangesTitleBarContribution } from './changesTitleBarWidget.js'; +import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import './changesViewActions.js'; import './checksActions.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; @@ -26,7 +27,7 @@ const changesViewContainer = viewContainersRegistry.registerViewContainer({ title: localize2('changes', 'Changes'), icon: changesViewIcon, order: 10, - ctorDescriptor: new SyncDescriptor(ChangesViewPaneContainer, [CHANGES_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), + ctorDescriptor: new SyncDescriptor(ChangesViewPaneContainer), storageId: CHANGES_VIEW_CONTAINER_ID, hideIfEmpty: false, openCommandActionDescriptor: { @@ -54,6 +55,7 @@ viewsRegistry.registerViews([{ canMoveView: false, weight: 100, order: 1, + when: IsPhoneLayoutContext.negate(), windowEnablement: WindowEnablement.Sessions, }], changesViewContainer); diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts index 38c6a51d4782d..76b080d849f62 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts @@ -21,10 +21,11 @@ import { type ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { Menus } from '../../../../browser/menus.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js'; const IsActiveSessionAgentHost = ContextKeyExpr.or( - ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host'), - ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, /^agenthost-/), + ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, LOCAL_AGENT_HOST_PROVIDER_ID), + ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, REMOTE_AGENT_HOST_PROVIDER_RE), ); // -- Agent Host Model Picker Action -- diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts index 393924b897602..813acaef0feef 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; import { derived, IObservable, IReader, observableSignal } from '../../../../../base/common/observable.js'; +import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { IPermissionPickerDelegate } from '../../../../contrib/copilotChatSessions/browser/permissionPicker.js'; @@ -13,25 +14,6 @@ import { ISessionsProvider } from '../../../../services/sessions/common/sessions import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -/** - * The well-known session-config property name for tool auto-approval. The - * Agent Host Protocol's session-config schema is intentionally generic — only - * this property *name* (and the enum values below) is a convention shared - * across implementations that want to opt into VS Code's unified - * permission-picker UI. Agents that don't advertise this exact shape fall - * back to the generic per-property picker. - */ -export const AUTO_APPROVE_PROPERTY = 'autoApprove'; - -/** - * The set of enum values the unified permission picker understands for the - * `autoApprove` property. Mirrors `ChatPermissionLevel` in - * `vs/workbench/contrib/chat/common/constants.ts`. - * - * `autopilot` is optional (an agent may choose not to advertise it). - * `default` is required as the baseline level. - */ -const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet = new Set(['default', 'autoApprove', 'autopilot']); const REQUIRED_AUTO_APPROVE_VALUE = 'default'; /** @@ -107,7 +89,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe if (!provider) { return; } - provider.setSessionConfigValue(session.sessionId, AUTO_APPROVE_PROPERTY, level) + provider.setSessionConfigValue(session.sessionId, SessionConfigKey.AutoApprove, level) .catch(() => { /* best-effort */ }); } @@ -121,7 +103,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe if (!provider) { return ChatPermissionLevel.Default; } - const value = provider.getSessionConfig(session.sessionId)?.values[AUTO_APPROVE_PROPERTY]; + const value = provider.getSessionConfig(session.sessionId)?.values[SessionConfigKey.AutoApprove]; return isChatPermissionLevel(value) ? value : ChatPermissionLevel.Default; } @@ -135,7 +117,7 @@ export class AgentHostPermissionPickerDelegate extends Disposable implements IPe if (!provider) { return false; } - const schema = provider.getSessionConfig(session.sessionId)?.schema.properties[AUTO_APPROVE_PROPERTY]; + const schema = provider.getSessionConfig(session.sessionId)?.schema.properties[SessionConfigKey.AutoApprove]; return !!schema && isWellKnownAutoApproveSchema(schema); } diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts index 0d5ef522787c1..7917a88c4ac7d 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts @@ -23,7 +23,6 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { AgentHostSessionConfigBranchNameHintKey } from '../../../../../platform/agentHost/common/agentService.js'; import type { SessionConfigPropertySchema, SessionConfigValueItem } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; import { ChatContextKeyExprs } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; @@ -34,13 +33,14 @@ import { ActiveSessionProviderIdContext } from '../../../../common/contextkeys.j import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; -import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; +import { type IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js'; import { PermissionPicker } from '../../../copilotChatSessions/browser/permissionPicker.js'; import { AgentHostPermissionPickerActionItem } from './agentHostPermissionPickerActionItem.js'; -import { AgentHostPermissionPickerDelegate, AUTO_APPROVE_PROPERTY, isWellKnownAutoApproveSchema } from './agentHostPermissionPickerDelegate.js'; +import { AgentHostPermissionPickerDelegate, isWellKnownAutoApproveSchema } from './agentHostPermissionPickerDelegate.js'; +import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; -const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, /^agenthost-/); -const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host'); +const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, REMOTE_AGENT_HOST_PROVIDER_RE); +const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, LOCAL_AGENT_HOST_PROVIDER_ID); registerAction2(class extends Action2 { constructor() { @@ -149,7 +149,7 @@ function applyAutoApproveFiltering( property: string, configurationService: IConfigurationService, ): { readonly items: readonly IConfigPickerItem[]; readonly policyRestricted: boolean } { - if (property !== AUTO_APPROVE_PROPERTY) { + if (property !== SessionConfigKey.AutoApprove) { return { items, policyRestricted: false }; } const isAutopilotEnabled = configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; @@ -213,7 +213,7 @@ async function confirmAutoApproveLevel(value: string, dialogService: IDialogServ * Applies warning/info CSS classes to a trigger element for auto-approve levels. */ function applyAutoApproveTriggerStyles(trigger: HTMLElement, property: string | undefined, value: unknown | undefined): void { - if (property === AUTO_APPROVE_PROPERTY) { + if (property === SessionConfigKey.AutoApprove) { trigger.classList.toggle('warning', value === 'autopilot'); trigger.classList.toggle('info', value === 'autoApprove'); } @@ -291,7 +291,7 @@ class AgentHostSessionConfigPicker extends Disposable { const isNewSession = provider.getCreateSessionConfig(session.sessionId) !== undefined; for (const [property, schema] of Object.entries(resolvedConfig.schema.properties)) { - if (property === AgentHostSessionConfigBranchNameHintKey) { + if (property === SessionConfigKey.BranchNameHint) { continue; } // Only render pickers for properties we know how to present. Today @@ -311,7 +311,7 @@ class AgentHostSessionConfigPicker extends Disposable { // `Menus.NewSessionControl`) handles it — skip it here to avoid // double-rendering. Non-conforming schemas still fall through to // the generic per-property picker below. - if (property === AUTO_APPROVE_PROPERTY && isWellKnownAutoApproveSchema(schema)) { + if (property === SessionConfigKey.AutoApprove && isWellKnownAutoApproveSchema(schema)) { continue; } const value = resolvedConfig.values[property] ?? schema.default; @@ -349,7 +349,7 @@ class AgentHostSessionConfigPicker extends Disposable { return; } - const isAutoApproveProperty = property === AUTO_APPROVE_PROPERTY; + const isAutoApproveProperty = property === SessionConfigKey.AutoApprove; const currentValue = provider.getSessionConfig(sessionId)?.values[property]; const actionItems = toActionItems(property, items, currentValue, policyRestricted); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index c00f663b3c61d..a4fe043454555 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -37,6 +37,7 @@ import { ChatViewPane } from '../../../../workbench/contrib/chat/browser/widgetH import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { SessionsChatAccessibilityHelp } from './sessionsChatAccessibilityHelp.js'; +import { SessionsOpenerParticipantContribution } from './sessionsOpenerParticipant.js'; class NewChatInSessionsWindowAction extends Action2 { @@ -147,6 +148,7 @@ registerAction2(BranchChatSessionAction); // register workbench contributions registerWorkbenchContribution2(RegisterChatViewContainerContribution.ID, RegisterChatViewContainerContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(SessionsOpenerParticipantContribution.ID, SessionsOpenerParticipantContribution, WorkbenchPhase.BlockStartup); // register services registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts index ad6bc69c5bfe8..a6ec791d253a7 100644 --- a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts +++ b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts @@ -3,9 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - CustomizationHarnessServiceBase, -} from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { CustomizationHarnessServiceBase } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; /** * Sessions-window override of the customization harness service. @@ -15,7 +14,9 @@ import { * and AHP remote servers register directly via `registerExternalHarness()`. */ export class SessionsCustomizationHarnessService extends CustomizationHarnessServiceBase { - constructor() { - super([], ''); + constructor( + @IPromptsService promptsService: IPromptsService + ) { + super([], '', promptsService); } } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 567bc815b1bfb..3d8f79b24cbcd 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -88,9 +88,11 @@ class NewChatWidget extends Disposable { this._newChatInput.render(chatWidgetContent, parent); - // Create initial session — wait for providers if none registered yet + // Create initial session — wait for providers if none registered yet. + // Skip if an active session already exists (restored by openNewSessionView + // from a pending new session when navigating back from another session). const restoredProject = this._workspacePicker.selectedProject; - if (restoredProject) { + if (!this._syncWorkspacePickerFromActiveSession() && restoredProject) { if (this.sessionsProvidersService.getProviders().length > 0) { this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType); } else { @@ -106,6 +108,33 @@ class NewChatWidget extends Disposable { chatWidgetContainer.classList.add('revealed'); } + /** + * If a pending session was restored by {@link openNewSessionView}, sync + * the workspace picker to match the session's workspace. The picker may + * have restored a workspace from a different provider (e.g. remote vs + * local), so overwrite it with the session's actual workspace without + * firing the event (which would trigger {@link _onWorkspaceSelected} and + * create a new session). + * + * @returns `true` if an active session was found and the picker was synced. + */ + private _syncWorkspacePickerFromActiveSession(): boolean { + const activeSession = this.sessionsManagementService.activeSession.get(); + if (!activeSession) { + return false; + } + + const sessionWorkspace = activeSession.workspace.get(); + if (sessionWorkspace) { + this._workspacePicker.setSelectedWorkspace( + { providerId: activeSession.providerId, workspace: sessionWorkspace }, + /* fireEvent */ false, + ); + } + + return true; + } + private _createNewSession(selection: IWorkspaceSelection, sessionTypeId: string | undefined): void { this.sessionsManagementService.createNewSession(selection.providerId, selection.workspace.repositories[0].uri, sessionTypeId); } diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts index 022b52b7c64d8..0dd1cb31396db 100644 --- a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts @@ -16,7 +16,7 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; @@ -42,7 +42,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 9, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index b95ca7d4746fc..db8fe377fde78 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; -import { Schemas } from '../../../../base/common/network.js'; +import { basename } from '../../../../base/common/resources.js'; import { isNative } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; @@ -229,8 +229,8 @@ export class WorkspacePicker extends Disposable { }; const listOptions = showFilter - ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true } - : { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true }; + ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, fixedWidth: 600 } + : { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, fixedWidth: 600 }; triggerElement.setAttribute('aria-expanded', 'true'); this.actionWidgetService.show( @@ -329,6 +329,16 @@ export class WorkspacePicker extends Disposable { return this.sessionsProvidersService.getProviders().flatMap(p => p.browseActions); } + /** + * Builds the picker items list from recent workspaces. + * + * Ordering: + * 1. Own recents (from sessions picker storage) come first, followed by + * VS Code recent folders — both retain their original storage order. + * 2. Items are grouped by provider/group title. Groups are sorted by + * first-appearance index so the first group encountered stays on top. + * 3. Within each group the original insertion order is preserved (stable sort). + */ protected _buildItems(): IActionListItem[] { const items: IActionListItem[] = []; @@ -363,13 +373,15 @@ export class WorkspacePicker extends Disposable { } } - // Sort by group name, then by label within each group - workspaceEntries.sort((a, b) => { - const groupCmp = a.groupTitle.localeCompare(b.groupTitle); - if (groupCmp !== 0) { - return groupCmp; + // Group entries by groupTitle, preserving the original order within each group + const groupOrder = new Map(); + workspaceEntries.forEach((entry, index) => { + if (!groupOrder.has(entry.groupTitle)) { + groupOrder.set(entry.groupTitle, index); } - return a.workspace.label.localeCompare(b.workspace.label); + }); + workspaceEntries.sort((a, b) => { + return (groupOrder.get(a.groupTitle) ?? 0) - (groupOrder.get(b.groupTitle) ?? 0); }); // Add items with separators between groups @@ -849,16 +861,7 @@ export class WorkspacePicker extends Disposable { } return { providerId: stored.providerId, workspace }; }) - .filter((w): w is { providerId: string; workspace: ISessionWorkspace } => w !== undefined) - .sort((a, b) => { - // Local folders first, then remote repositories, alphabetical within each group - const aIsLocal = a.workspace.repositories[0]?.uri.scheme === Schemas.file; - const bIsLocal = b.workspace.repositories[0]?.uri.scheme === Schemas.file; - if (aIsLocal !== bIsLocal) { - return aIsLocal ? -1 : 1; - } - return a.workspace.label.localeCompare(b.workspace.label); - }); + .filter((w): w is { providerId: string; workspace: ISessionWorkspace } => w !== undefined); } protected _removeRecentWorkspace(selection: IWorkspaceSelection): void { @@ -915,7 +918,17 @@ export class WorkspacePicker extends Disposable { const recentlyOpened = await this.workspacesService.getRecentlyOpened(); this._vsCodeRecentFolderUris = recentlyOpened.workspaces .filter(isRecentFolder) - .map(f => f.folderUri); + .map(f => f.folderUri) + .filter(uri => !this._isCopilotWorktree(uri)) + .slice(0, 10); + } + + /** + * Returns whether the given URI points to a copilot-managed folder + * (a folder whose name starts with `copilot-`). + */ + private _isCopilotWorktree(uri: URI): boolean { + return basename(uri).startsWith('copilot-'); } /** @@ -948,6 +961,9 @@ export class WorkspacePicker extends Disposable { result.push({ providerId: provider.id, workspace }); } } + if (result.length >= 10) { + break; + } } return result; diff --git a/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts b/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts new file mode 100644 index 0000000000000..e9ebfd1820008 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ISessionOpenerParticipant, ISessionOpenOptions, sessionOpenerRegistry } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +/** + * Routes session open requests in the Agents window through the + * {@link ISessionsManagementService} so that the active session/chat state is + * properly updated. Without this, the default opener tries to load the chat + * directly into the `ChatViewId` view, which is hidden behind a `when` clause + * tied to the new-chat context keys and may simply do nothing. + */ +class SessionsOpenerParticipant implements ISessionOpenerParticipant { + + async handleOpenSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: ISessionOpenOptions): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const target = sessionsManagementService.getSession(session.resource); + if (!target) { + return false; + } + + await sessionsManagementService.openSession(session.resource, { preserveFocus: openOptions?.editorOptions?.preserveFocus }); + return true; + } +} + +export class SessionsOpenerParticipantContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.sessionOpenerParticipant'; + + constructor() { + super(); + this._register(sessionOpenerRegistry.registerParticipant(new SessionsOpenerParticipant())); + } +} diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts index 067188f31e1c8..85ed0bbdbf6cf 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts @@ -16,7 +16,7 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; @@ -44,7 +44,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 9, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts index cb943ea95b252..6653b98cd8c86 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -14,6 +14,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -52,6 +53,7 @@ function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disp when: ContextKeyExpr.and( IsSessionsWindowContext, ChatContextKeys.agentSessionType.notEqualsTo(CopilotCloudSessionType.id), + IsPhoneLayoutContext.negate(), ), }, ], diff --git a/src/vs/sessions/contrib/files/browser/files.contribution.ts b/src/vs/sessions/contrib/files/browser/files.contribution.ts index 28b17329f7b9c..9277f6924b36e 100644 --- a/src/vs/sessions/contrib/files/browser/files.contribution.ts +++ b/src/vs/sessions/contrib/files/browser/files.contribution.ts @@ -19,6 +19,7 @@ import { IViewsService } from '../../../../workbench/services/views/common/views import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; import { SESSIONS_FILES_EMPTY_VIEW_ID, SESSIONS_FILES_VIEW_ID, SessionsExplorerEmptyView, SessionsExplorerView } from './filesView.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; export const SESSIONS_FILES_CONTAINER_ID = 'workbench.sessions.auxiliaryBar.filesContainer'; @@ -60,7 +61,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(SessionsExplorerView), canToggleVisibility: false, canMoveView: false, - when: WorkspaceFolderCountContext.notEqualsTo('0'), + when: ContextKeyExpr.and(WorkspaceFolderCountContext.notEqualsTo('0'), IsPhoneLayoutContext.negate()), windowEnablement: WindowEnablement.Sessions, }], filesViewContainer); @@ -72,7 +73,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(SessionsExplorerEmptyView), canToggleVisibility: false, canMoveView: false, - when: WorkspaceFolderCountContext.isEqualTo('0'), + when: ContextKeyExpr.and(WorkspaceFolderCountContext.isEqualTo('0'), IsPhoneLayoutContext.negate()), windowEnablement: WindowEnablement.Sessions, }], filesViewContainer); } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 408fe16327d15..6cbf7a107ff17 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -41,6 +41,9 @@ import { createRemoteAgentHarnessDescriptor, RemoteAgentCustomizationItemProvide import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; import { ISSHRemoteAgentHostService } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js'; +import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logTerminalRecovery } from '../../../common/sessionsTelemetry.js'; /** Per-connection state bundle, disposed when a connection is removed. */ class ConnectionState extends Disposable { @@ -99,6 +102,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, @IStorageService private readonly _storageService: IStorageService, @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); @@ -260,11 +265,34 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } const existing = this._connections.get(connectionInfo.address); if (existing) { + const nameChanged = existing.name !== connectionInfo.name; + const clientIdChanged = existing.loggedConnection.clientId !== connectionInfo.clientId; + // If the name or clientId changed, tear down and re-register - if (existing.name !== connectionInfo.name || existing.loggedConnection.clientId !== connectionInfo.clientId) { - this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}: oldClientId=${existing.loggedConnection.clientId}, newClientId=${connectionInfo.clientId}, nameChanged=${existing.name !== connectionInfo.name}`); + if (nameChanged || clientIdChanged) { + this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}: oldClientId=${existing.loggedConnection.clientId}, newClientId=${connectionInfo.clientId}, nameChanged=${nameChanged}`); + const oldClientId = existing.loggedConnection.clientId; this._connections.deleteAndDispose(connectionInfo.address); this._setupConnection(connectionInfo); + + // Reconnect active terminals only when the backing + // client changed. Name-only updates don't invalidate + // subscriptions and would cause unnecessary buffer + // clear/replay flicker. + if (clientIdChanged) { + const newConnection = this._remoteAgentHostService.getConnection(connectionInfo.address); + if (newConnection) { + this._agentHostTerminalService.reconnectTerminals(newConnection, oldClientId).then( + ({ recovered, total }) => { + if (total > 0) { + this._logService.info(`[RemoteAgentHost] Terminal reconnection: ${recovered}/${total} recovered`); + logTerminalRecovery(this._telemetryService, { recoveredCount: recovered, totalCount: total }); + } + }, + err => this._logService.warn('[RemoteAgentHost] Terminal reconnection failed', err) + ); + } + } } } else { this._setupConnection(connectionInfo); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 884c9db5f6f87..e6236ef89b20f 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -225,10 +225,11 @@ display: none; gap: 8px; margin-top: 4px; - padding: 4px 8px; + margin-left: -6px; + padding: 4px 4px 4px 6px; box-sizing: border-box; border: 1px solid var(--vscode-contrastBorder, var(--vscode-widget-border, transparent)); - border-radius: 4px; + border-radius: 8px; background-color: var(--vscode-editor-background); color: var(--vscode-editor-foreground); align-items: center; @@ -403,3 +404,23 @@ background-position: -120% 0; } } + +/* ---- Mobile Layout: Touch Adaptations ---- */ + +/* Always show inline toolbar on mobile (no hover dependency) */ +.agent-sessions-workbench.phone-layout .sessions-list .monaco-list-row .actions { + display: flex !important; + visibility: visible !important; + opacity: 1 !important; +} + +/* Touch feedback on session list items */ +.agent-sessions-workbench.phone-layout .sessions-list .monaco-list-row:active { + background-color: var(--vscode-list-hoverBackground) !important; +} + +/* Disable webkit touch callout on list items */ +.agent-sessions-workbench.phone-layout .sessions-list .monaco-list-row { + -webkit-touch-callout: none; + touch-action: manipulation; +} diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index a30cab968356b..43646737f04b7 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -15,7 +15,7 @@ import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabe import { createMatches, FuzzyScore, IMatch } from '../../../../../base/common/filters.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { IReader, autorun } from '../../../../../base/common/observable.js'; +import { IReader, autorun, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { fromNow } from '../../../../../base/common/date.js'; @@ -42,6 +42,7 @@ import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { ISessionsListModelService } from './sessionsListModelService.js'; import { IAgentHostFilterService } from '../../../remoteAgentHost/common/agentHostFilter.js'; @@ -149,6 +150,8 @@ interface ISessionItemTemplate { readonly contextKeyService: IContextKeyService; readonly disposables: DisposableStore; readonly elementDisposables: DisposableStore; + /** Tracks the current icon CSS selector to avoid rebuilding the DOM (and restarting CSS animations) when the icon hasn't changed. */ + currentIconSelector: string | undefined; } class SessionItemRenderer implements ITreeRenderer { @@ -167,6 +170,8 @@ class SessionItemRenderer implements ITreeRenderer(); readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; + private readonly _motionReducedSignal; + constructor( private readonly options: { grouping: () => SessionsGrouping; sorting: () => SessionsSorting; isPinned: (session: ISession) => boolean; isRead: (session: ISession) => boolean }, private readonly approvalModel: AgentSessionApprovalModel | undefined, @@ -175,7 +180,10 @@ class SessionItemRenderer implements ITreeRenderer, _index: number, template: ISessionItemTemplate): void { @@ -238,16 +246,34 @@ class SessionItemRenderer implements ITreeRenderer { const sessionStatus = element.status.read(reader); const isRead = this.options.isRead(element); const isArchived = element.isArchived.read(reader); const gitHubInfo = element.gitHubInfo.read(reader); - DOM.clearNode(template.iconContainer); + this._motionReducedSignal.read(reader); const icon = this.getStatusIcon(sessionStatus, isRead, isArchived, gitHubInfo?.pullRequest?.icon); - const iconSpan = DOM.append(template.iconContainer, $(`span${ThemeIcon.asCSSSelector(icon)}`)); - iconSpan.style.color = icon.color ? asCssVariable(icon.color.id) : ''; + const iconSelector = ThemeIcon.asCSSSelector(icon); + const iconColor = icon.color ? asCssVariable(icon.color.id) : ''; + + if (iconSelector !== template.currentIconSelector) { + template.currentIconSelector = iconSelector; + DOM.clearNode(template.iconContainer); + const iconSpan = DOM.append(template.iconContainer, $(`span${iconSelector}`)); + iconSpan.style.color = iconColor; + } else { + const iconSpan = template.iconContainer.firstElementChild as HTMLElement | null; + if (iconSpan) { + iconSpan.style.color = iconColor; + } + } template.iconContainer.classList.toggle('session-icon-pulse', sessionStatus === SessionStatus.NeedsInput); template.container.classList.toggle('in-progress', sessionStatus === SessionStatus.InProgress); })); @@ -466,7 +492,11 @@ class SessionItemRenderer implements ITreeRenderer accessor.get(IMarkdownRendererService)); const hoverService = instantiationService.invokeFunction(accessor => accessor.get(IHoverService)); const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); + const accessibilityService = instantiationService.invokeFunction(accessor => accessor.get(IAccessibilityService)); const sessionRenderer = new SessionItemRenderer( { grouping: this.options.grouping, sorting: this.options.sorting, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s) }, approvalModel, @@ -739,6 +770,7 @@ export class SessionsList extends Disposable implements ISessionsList { markdownRendererService, hoverService, agentSessionsService, + accessibilityService, ); const showMoreRenderer = new SessionShowMoreRenderer(); diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 11dfd1e6446e1..87032a4efda17 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -18,10 +18,10 @@ import { ITerminalInstance, ITerminalService } from '../../../../workbench/contr import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; -import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; +import { SessionsWelcomeVisibleContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { CopilotCLISessionType, ISession } from '../../../services/sessions/common/session.js'; +import { isWorkspaceAgentSessionType, ISession } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; @@ -42,11 +42,11 @@ interface ISessionTerminalInfo { /** * Returns terminal info for the given session: worktree or repository path for - * background sessions only. Returns `undefined` for non-background sessions - * (Cloud, Local, etc.) which have no local worktree, or when no path is available. + * workspace-backed agent sessions. Returns `undefined` for sessions without a + * workspace (e.g. Cloud), or when no path is available. */ function getSessionTerminalInfo(session: ISession | undefined): ISessionTerminalInfo | undefined { - if (session?.sessionType !== CopilotCLISessionType.id) { + if (!session || !isWorkspaceAgentSessionType(session.sessionType)) { return undefined; } const repo = session.workspace.get()?.repositories[0]; @@ -86,13 +86,17 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben super(); const profileOverride = derived(reader => { - const profiles = this._agentHostTerminalService.profiles.read(reader); const session = this._sessionsManagementService.activeSession.read(reader); + if (!session || session.providerId === LOCAL_AGENT_HOST_PROVIDER_ID) { + return; // no need to override local default profiles with the local AH + } + const address = this._getSessionAgentHostAddress(session); if (!address) { return; } + const profiles = this._agentHostTerminalService.profiles.read(reader); return profiles.find(p => p.address === address) ?? this._agentHostTerminalService.getProfileForConnection(address); }); @@ -148,12 +152,12 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } })); - // When a session is archived or removed, close all terminals for its worktree + // When a session is archived or removed, close all terminals for its cwd this._register(this._sessionsManagementService.onDidChangeSessions(e => { for (const session of [...e.removed, ...e.changed.filter(s => s.isArchived.get())]) { - const worktreeUri = session.workspace.get()?.repositories[0]?.workingDirectory; - if (worktreeUri) { - this._closeTerminalsForPath(worktreeUri.fsPath); + const info = getSessionTerminalInfo(session); + if (info) { + this._closeTerminalsForPath(info.cwd.fsPath); } } })); @@ -392,7 +396,7 @@ class OpenSessionInTerminalAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 10, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index b188df4dee4b6..ed396d300ee11 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -328,6 +328,28 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); }); + // --- Claude provider: also uses worktree/repository path --- + + test('creates a terminal at the worktree for a Claude session', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ worktree: worktreeUri, repository: URI.file('/repo'), providerType: AgentSessionProviders.Claude }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath); + }); + + test('falls back to repository when worktree is undefined for a Claude session', async () => { + const repoUri = URI.file('/repo'); + const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Claude }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); + }); + // --- Non-background providers: use home directory --- test('uses home directory for a cloud agent session', async () => { @@ -471,6 +493,7 @@ suite('SessionsTerminalContribution', () => { const session = makeAgentSession({ isArchived: true, worktree: worktreeUri, + providerType: AgentSessionProviders.Background, }); onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); await tick(); @@ -503,13 +526,29 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(disposedInstances.length, 0); }); + test('closes terminals when archived session has only a repository (no worktree)', async () => { + const repoUri = URI.file('/repo'); + const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background, isArchived: false }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); + + const archivedSession = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background, isArchived: true }); + onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 1); + }); + test('closes terminals when session is removed', async () => { const worktreeUri = URI.file('/worktree'); await contribution.ensureTerminal(worktreeUri, false); assert.strictEqual(createdTerminals.length, 1); - const session = makeAgentSession({ worktree: worktreeUri }); + const session = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Background }); onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); await tick(); diff --git a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts b/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts index d1500e1379af8..69fbe51444247 100644 --- a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts +++ b/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts @@ -16,7 +16,6 @@ import { INotificationService, Severity } from '../../../../platform/notificatio import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IOutputService } from '../../../../workbench/services/output/common/output.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; import { ITunnelHostService } from '../common/tunnelHost.js'; @@ -84,7 +83,7 @@ registerAction2(class ToggleRemoteConnectionsAction extends Action2 { ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.AgentHostCopilot), + ContextKeyExpr.regex(ChatContextKeys.lockedCodingAgentId.key, /^agent-host-/), ) } }); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index f362a407405d1..b2b5193c2257d 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -18,7 +18,7 @@ import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessi import { ActiveSessionSupportsMultiChatContext, IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../common/sessionsProvider.js'; -import { COPILOT_CLI_SESSION_TYPE, IChat, ISession, SessionStatus, ISessionType } from '../common/session.js'; +import { IChat, ISession, isWorkspaceAgentSessionType, SessionStatus, ISessionType } from '../common/session.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; const ACTIVE_SESSION_STATES_KEY = 'agentSessions.activeSessionStates'; @@ -54,11 +54,14 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen readonly activeSession: IObservable = this._activeSession; private readonly _activeProviderId = observableValue(this, undefined); readonly activeProviderId: IObservable = this._activeProviderId; + + /** Tracks the pending new session so it can be restored by {@link openNewSessionView}. */ + private _pendingNewSession: ISession | undefined; private readonly isNewChatSessionContext: IContextKey; private readonly _isNewChatInSessionContext: IContextKey; private readonly _activeSessionProviderId: IContextKey; private readonly _activeSessionType: IContextKey; - private readonly _isBackgroundProvider: IContextKey; + private readonly _isWorkspaceAgent: IContextKey; private readonly _isActiveSessionArchived: IContextKey; private readonly _supportsMultiChat: IContextKey; private _activeChatObservable: ISettableObservable | undefined; @@ -83,7 +86,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._isNewChatInSessionContext = IsNewChatInSessionContext.bindTo(contextKeyService); this._activeSessionProviderId = ActiveSessionProviderIdContext.bindTo(contextKeyService); this._activeSessionType = ActiveSessionTypeContext.bindTo(contextKeyService); - this._isBackgroundProvider = IsActiveSessionBackgroundProviderContext.bindTo(contextKeyService); + this._isWorkspaceAgent = IsActiveSessionBackgroundProviderContext.bindTo(contextKeyService); this._isActiveSessionArchived = IsActiveSessionArchivedContext.bindTo(contextKeyService); this._supportsMultiChat = ActiveSessionSupportsMultiChatContext.bindTo(contextKeyService); @@ -169,6 +172,13 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._onDidChangeSessions.fire(e); const currentActive = this._activeSession.get(); + // Clear stale pending session if the provider removed it + if (e.removed.length && this._pendingNewSession) { + if (e.removed.some(r => r.sessionId === this._pendingNewSession!.sessionId)) { + this._pendingNewSession = undefined; + } + } + if (!currentActive) { return; } @@ -268,6 +278,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } unsetNewSession(): void { + this._pendingNewSession = undefined; this.setActiveSession(undefined); } @@ -288,11 +299,13 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } } const session = provider.createNewSession(repositoryUri, sessionTypeId); + this._pendingNewSession = session; this.setActiveSession(session); return session; } async sendAndCreateChat(session: ISession, options: ISendRequestOptions): Promise { + this._pendingNewSession = undefined; this.isNewChatSessionContext.set(false); this._isNewChatInSessionContext.set(false); @@ -330,6 +343,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } async sendRequest(session: ISession, chat: IChat, options: ISendRequestOptions): Promise { + this._pendingNewSession = undefined; this.isNewChatSessionContext.set(false); this._isNewChatInSessionContext.set(false); @@ -355,7 +369,10 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen if (this.isNewChatSessionContext.get()) { return; } - this.setActiveSession(undefined); + // Restore the pending new session if one exists, so pickers + // re-derive their state from the still-alive session object. + // Otherwise clear active session (first time / after send). + this.setActiveSession(this._pendingNewSession ?? undefined); this.isNewChatSessionContext.set(true); this._isNewChatInSessionContext.set(false); } @@ -390,7 +407,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen // Update context keys from session data this._activeSessionProviderId.set(session?.providerId ?? ''); this._activeSessionType.set(session?.sessionType ?? ''); - this._isBackgroundProvider.set(session?.sessionType === COPILOT_CLI_SESSION_TYPE); + this._isWorkspaceAgent.set(isWorkspaceAgentSessionType(session?.sessionType)); this._isActiveSessionArchived.set(session?.isArchived.get() ?? false); this._supportsMultiChat.set(session?.capabilities.supportsMultipleChats ?? false); diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 152e4f0274d95..101fda93509de 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -50,6 +50,16 @@ export const ClaudeCodeSessionType: ISessionType = { icon: Codicon.claude, }; +/** + * Returns whether the given session type represents a workspace-backed + * agent (e.g. Copilot CLI, Claude Code) that operates on a worktree or + * repository — regardless of whether the agent runs locally or remotely. + * TODO: Somehow make this contributable so we don't have to hardcode session types here. + */ +export function isWorkspaceAgentSessionType(sessionType: string | undefined): boolean { + return sessionType === COPILOT_CLI_SESSION_TYPE || sessionType === CLAUDE_CODE_SESSION_TYPE; +} + export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; /** diff --git a/src/vs/workbench/api/browser/mainThreadBrowsers.ts b/src/vs/workbench/api/browser/mainThreadBrowsers.ts index 2c0e0b7875b15..e3debe8ddb0f0 100644 --- a/src/vs/workbench/api/browser/mainThreadBrowsers.ts +++ b/src/vs/workbench/api/browser/mainThreadBrowsers.ts @@ -7,7 +7,7 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } import { IEditorService } from '../../services/editor/common/editorService.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { BrowserTabDto, ExtHostBrowsersShape, ExtHostContext, MainContext, MainThreadBrowsersShape } from '../common/extHost.protocol.js'; -import { IBrowserViewCDPService } from '../../contrib/browserView/common/browserView.js'; +import { IBrowserViewCDPService, IBrowserViewWorkbenchService } from '../../contrib/browserView/common/browserView.js'; import { BrowserViewUri } from '../../../platform/browserView/common/browserViewUri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { EditorGroupColumn, columnToEditorGroup } from '../../services/editor/common/editorGroupColumn.js'; @@ -29,25 +29,24 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers extHostContext: IExtHostContext, @IEditorService private readonly editorService: IEditorService, @IBrowserViewCDPService private readonly cdpService: IBrowserViewCDPService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostBrowsers); - // Track open browser editors - this._register(this.editorService.onWillOpenEditor((e) => { - if (e.editor instanceof BrowserEditorInput) { - this._track(e.editor); + // Track open browser editors via the workbench service + this._register(this.browserViewService.onDidChangeBrowserViews(() => { + for (const editor of this.browserViewService.getKnownBrowserViews().values()) { + this._track(editor); } })); this._register(this.editorService.onDidActiveEditorChange(() => this._syncActiveBrowserTab())); // Initial sync - for (const input of this.editorService.editors) { - if (input instanceof BrowserEditorInput) { - this._track(input); - } + for (const editor of this.browserViewService.getKnownBrowserViews().values()) { + this._track(editor); } this._syncActiveBrowserTab(); } diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index a254d1c97be74..ae3f56cec6644 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -20,7 +20,7 @@ import { LogLevel } from '../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchMcpGatewayService } from '../../contrib/mcp/common/mcpGatewayService.js'; import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js'; -import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js'; +import { extensionPrefixedIdentifier, McpCollectionDefinition, McpCollectionSortOrder, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js'; import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js'; import { IAuthenticationMcpAccessService } from '../../services/authentication/browser/authenticationMcpAccessService.js'; import { IAuthenticationMcpService } from '../../services/authentication/browser/authenticationMcpService.js'; @@ -148,6 +148,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { handle.value ??= this._mcpRegistry.registerCollection({ ...collection, source: extensionId, + order: McpCollectionSortOrder.Extension, resolveServerLanch: collection.canResolveLaunch ? (async def => { const r = await this._proxy.$resolveMcpLaunch(collection.id, def.label); return r ? McpServerLaunch.fromSerialized(r) : undefined; diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts index 75bd4a21022c3..b1df33f2523fc 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarActions.ts @@ -16,6 +16,14 @@ import { IsMainWindowFullscreenContext, IsCompactTitleBarContext, TitleBarStyleC import { CustomTitleBarVisibility, TitleBarSetting, TitlebarStyle } from '../../../../platform/window/common/window.js'; import { NotificationsPosition, NotificationsSettings } from '../../../common/notifications.js'; +/** + * Menu group for actions contributed to {@link MenuId.TitleBar} that should render + * **before** the layout controls (instead of trailing them like the default group). + * Use this group to surface a leading affordance that should remain visible even + * when layout controls are toggled off. + */ +export const TitleBarLeadingActionsGroup = '0_leading'; + // --- Context Menu Actions --- // export class ToggleTitleBarConfigAction extends Action2 { diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index fb96d03d3a243..76a523f257e8c 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -32,6 +32,7 @@ import { WindowTitle } from './windowTitle.js'; import { CommandCenterControl } from './commandCenterControl.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { ACCOUNTS_ACTIVITY_ID, GLOBAL_ACTIVITY_ID } from '../../../common/activity.js'; import { AccountsActivityActionViewItem, isAccountsActionVisible, SimpleAccountActivityActionViewItem, SimpleGlobalActivityActionViewItem } from '../globalCompositeBar.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -47,7 +48,7 @@ import { ResolvedKeybinding } from '../../../../base/common/keybindings.js'; import { EditorCommandsContextActionRunner } from '../editor/editorTabsControl.js'; import { IEditorCommandsContext, IEditorPartOptionsChangeEvent, IToolbarActions } from '../../../common/editor.js'; import { CodeWindow, mainWindow } from '../../../../base/browser/window.js'; -import { ACCOUNTS_ACTIVITY_TILE_ACTION, GLOBAL_ACTIVITY_TITLE_ACTION } from './titlebarActions.js'; +import { ACCOUNTS_ACTIVITY_TILE_ACTION, GLOBAL_ACTIVITY_TITLE_ACTION, TitleBarLeadingActionsGroup } from './titlebarActions.js'; import { IView } from '../../../../base/browser/ui/grid/grid.js'; import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -310,7 +311,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { @IHostService private readonly hostService: IHostService, @IEditorService editorService: IEditorService, @IMenuService private readonly menuService: IMenuService, - @IKeybindingService private readonly keybindingService: IKeybindingService + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IActionViewItemService private readonly actionViewItemService: IActionViewItemService ) { super(id, { hasTitle: false }, themeService, storageService, layoutService); @@ -607,6 +609,17 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private actionViewItemProvider(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { + // --- Custom view items registered via IActionViewItemService + for (const menuId of [MenuId.TitleBar, MenuId.LayoutControlMenu]) { + const customViewItem = this.actionViewItemService.lookUp(menuId, action.id); + if (customViewItem) { + const result = customViewItem(action, options, this.instantiationService, getWindowId(this.element ? getWindow(this.element) : mainWindow)); + if (result) { + return result; + } + } + } + // --- Activity Actions if (!this.isAuxiliary) { if (action.id === GLOBAL_ACTIVITY_ID) { @@ -685,6 +698,15 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } } + // --- Leading Global Actions (rendered before layout controls; opt-in via TitleBarLeadingActionsGroup) + if (this.globalToolbarMenu) { + fillInActionBarActions( + this.globalToolbarMenu.getActions(), + actions, + actionGroup => actionGroup === TitleBarLeadingActionsGroup + ); + } + // --- Layout Actions if (this.layoutToolbarMenu) { fillInActionBarActions( @@ -698,7 +720,8 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { if (this.globalToolbarMenu) { fillInActionBarActions( this.globalToolbarMenu.getActions(), - actions + actions, + actionGroup => actionGroup !== TitleBarLeadingActionsGroup // already rendered before layout controls ); } @@ -931,8 +954,9 @@ export class MainBrowserTitlebarPart extends BrowserTitlebarPart { @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, @IKeybindingService keybindingService: IKeybindingService, + @IActionViewItemService actionViewItemService: IActionViewItemService, ) { - super(Parts.TITLEBAR_PART, mainWindow, editorGroupService.mainPart, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService); + super(Parts.TITLEBAR_PART, mainWindow, editorGroupService.mainPart, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService, actionViewItemService); } } @@ -966,9 +990,10 @@ export class AuxiliaryBrowserTitlebarPart extends BrowserTitlebarPart implements @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, @IKeybindingService keybindingService: IKeybindingService, + @IActionViewItemService actionViewItemService: IActionViewItemService, ) { const id = AuxiliaryBrowserTitlebarPart.COUNTER++; - super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService); + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService, actionViewItemService); } override get preventZoom(): boolean { diff --git a/src/vs/workbench/browser/window.ts b/src/vs/workbench/browser/window.ts index bff3f2cf1b0e9..09cddff295647 100644 --- a/src/vs/workbench/browser/window.ts +++ b/src/vs/workbench/browser/window.ts @@ -154,6 +154,8 @@ export abstract class BaseWindow extends Disposable { didClear = true; (window as { vscodeOriginalClearTimeout?: typeof window.clearTimeout }).vscodeOriginalClearTimeout?.apply(this, [handle]); timeoutDisposables.delete(timeoutDisposable); + // Remove from the window's DisposableStore without re-disposing (we're already inside dispose) + disposables.deleteAndLeak(timeoutDisposable); }); disposables.add(timeoutDisposable); diff --git a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts index a16c153f57eda..51fa4d8732953 100644 --- a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts @@ -7,18 +7,28 @@ import { registerSingleton, InstantiationType } from '../../../../platform/insta import { IBrowserViewWorkbenchService, IBrowserViewCDPService, IBrowserViewModel } from '../common/browserView.js'; import { Event } from '../../../../base/common/event.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; +import { IBrowserViewState } from '../../../../platform/browserView/common/browserView.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { declare readonly _serviceBrand: undefined; - async getOrCreateBrowserViewModel(_id: string): Promise { - throw new Error('Integrated Browser is not available in web.'); + readonly onDidChangeBrowserViews = Event.None; + + private readonly _known = new Map(); + + getKnownBrowserViews(): Map { + return this._known; } - async getBrowserViewModel(_id: string): Promise { + getOrCreateLazy(_id: string, _state: IBrowserViewState): BrowserEditorInput { throw new Error('Integrated Browser is not available in web.'); } + getBrowserViewModel(_id: string): IBrowserViewModel | undefined { + return undefined; + } + async clearGlobalStorage(): Promise { } async clearWorkspaceStorage(): Promise { } } diff --git a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 5ab9c4265f68d..77cc549c182a7 100644 --- a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts @@ -9,19 +9,19 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { IBrowserEditorViewState } from './browserView.js'; +import { IBrowserEditorViewState, IBrowserViewWorkbenchService } from './browserView.js'; import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { TAB_ACTIVE_FOREGROUND } from '../../../common/theme.js'; import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IBrowserViewWorkbenchService, IBrowserViewModel } from '../common/browserView.js'; +import { IBrowserViewModel } from '../common/browserView.js'; import { hasKey } from '../../../../base/common/types.js'; -import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { LRUCachedFunction } from '../../../../base/common/cache.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; const LOADING_SPINNER_SVG = (color: string | undefined) => ` @@ -51,32 +51,53 @@ export class BrowserEditorInput extends EditorInput { private readonly _id: string; private _initialData: IBrowserEditorInputData; + private _model: IBrowserViewModel | undefined; private _modelPromise: Promise | undefined; + private _modelStore = this._register(new DisposableStore()); constructor( options: IBrowserEditorInputData, + private _resolveModel: () => Promise, @IThemeService private readonly themeService: IThemeService, - @IBrowserViewWorkbenchService private readonly browserViewWorkbenchService: IBrowserViewWorkbenchService, - @ILifecycleService private readonly lifecycleService: ILifecycleService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITelemetryService private readonly telemetryService: ITelemetryService ) { super(); this._id = options.id; this._initialData = options; + } - this._register(this.lifecycleService.onWillShutdown((e) => { - if (this._model) { - // For reloads, we simply hide / re-show the view. - if (e.reason === ShutdownReason.RELOAD) { - void this._model.setVisible(false); - } else { - this._model.dispose(); - this._model = undefined; - } - } + get model(): IBrowserViewModel | undefined { + return this._model; + } + + set model(model: IBrowserViewModel) { + if (this._model === model) { + return; + } + + this._modelStore.clear(); + this._model = model; + + // Set up cleanup when the model is disposed + this._modelStore.add(this._model.onWillDispose(() => { + this._modelStore.clear(); + this._model = undefined; })); + + // Auto-close editor when webcontents closes + this._modelStore.add(this._model.onDidClose(() => { + this.dispose(); + })); + + // Listen for label-relevant changes to fire onDidChangeLabel + this._modelStore.add(this._model.onDidChangeTitle(() => this._onDidChangeLabel.fire())); + this._modelStore.add(this._model.onDidChangeFavicon(() => this._onDidChangeLabel.fire())); + this._modelStore.add(this._model.onDidChangeLoadingState(() => this._onDidChangeLabel.fire())); + this._modelStore.add(this._model.onDidNavigate(() => this._onDidChangeLabel.fire())); + + this._onDidChangeLabel.fire(); } get id() { @@ -114,32 +135,9 @@ export class BrowserEditorInput extends EditorInput { override async resolve(): Promise { if (!this._model && !this._modelPromise) { this._modelPromise = (async () => { - this._model = await this.browserViewWorkbenchService.getOrCreateBrowserViewModel(this._id); + this._model = await this._resolveModel(); this._modelPromise = undefined; - // Set up cleanup when the model is disposed - this._register(this._model.onWillDispose(() => { - this._model = undefined; - })); - - // Auto-close editor when webcontents closes - this._register(this._model.onDidClose(() => { - this.dispose(); - })); - - // Listen for label-relevant changes to fire onDidChangeLabel - this._register(this._model.onDidChangeTitle(() => this._onDidChangeLabel.fire())); - this._register(this._model.onDidChangeFavicon(() => this._onDidChangeLabel.fire())); - this._register(this._model.onDidChangeLoadingState(() => this._onDidChangeLabel.fire())); - this._register(this._model.onDidNavigate(() => this._onDidChangeLabel.fire())); - - // Navigate to initial URL if provided - if (this._initialData.url) { - this._model.setInitialURL(this._initialData.url, this._initialData.title, this._initialData.favicon); - } - - this._onDidChangeLabel.fire(); - return this._model; })(); } @@ -263,11 +261,13 @@ export class BrowserEditorInput extends EditorInput { override copy(): EditorInput { logBrowserOpen(this.telemetryService, 'copyToNewWindow'); - return this.instantiationService.createInstance(BrowserEditorInput, { - id: generateUuid(), - url: this.url, - title: this.title, - favicon: this.favicon + return this.instantiationService.invokeFunction((accessor) => { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + return browserViewWorkbenchService.getOrCreateLazy(generateUuid(), { + url: this.url, + title: this.title, + favicon: this.favicon + }); }); } @@ -327,7 +327,10 @@ export class BrowserEditorSerializer implements IEditorSerializer { deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined { try { const data: IBrowserEditorInputData = JSON.parse(serializedEditor); - return instantiationService.createInstance(BrowserEditorInput, data); + return instantiationService.invokeFunction((accessor) => { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + return browserViewWorkbenchService.getOrCreateLazy(data.id, data); + }); } catch { return undefined; } diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 849efe3775c19..fcf544b9db45d 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -14,6 +14,7 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { localize } from '../../../../nls.js'; import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; +import type { BrowserEditorInput } from './browserEditorInput.js'; import { IBrowserViewBounds, IBrowserViewNavigationEvent, @@ -23,7 +24,6 @@ import { IBrowserViewKeyDownEvent, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, - IBrowserViewNewPageRequest, IBrowserViewDevToolsStateEvent, IBrowserViewService, BrowserViewStorageScope, @@ -33,15 +33,15 @@ import { IBrowserViewVisibilityEvent, IBrowserViewCertificateError, IElementData, + IBrowserViewOwner, browserZoomDefaultIndex, - browserZoomFactors + browserZoomFactors, + IBrowserViewState } from '../../../../platform/browserView/common/browserView.js'; -import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IBrowserZoomService } from './browserZoomService.js'; /** Extracts the host from a URL string for zoom tracking purposes. */ @@ -97,19 +97,20 @@ export interface IBrowserViewWorkbenchService { readonly _serviceBrand: undefined; /** - * Get or create a browser view model for the given ID - * @param id The browser view identifier - * @returns A browser view model that proxies to the main process + * Fires when the set of known browser views changes. */ - getOrCreateBrowserViewModel(id: string): Promise; + readonly onDidChangeBrowserViews: Event; /** - * Get an existing browser view model for the given ID - * @param id The browser view identifier - * @returns A browser view model that proxies to the main process - * @throws If no browser view exists for the given ID + * Get all known browser views. */ - getBrowserViewModel(id: string): Promise; + getKnownBrowserViews(): Map; + + /** + * Get an existing browser view for the given ID, or create a new one if it doesn't exist. + * The underlying browser view is not created until the editor is opened or the model is resolved. + */ + getOrCreateLazy(id: string, initialState?: IBrowserEditorViewState): BrowserEditorInput; /** * Clear all storage data for the global browser session @@ -159,6 +160,7 @@ export interface IBrowserViewCDPService { */ export interface IBrowserViewModel extends IDisposable { readonly id: string; + readonly owner: IBrowserViewOwner; readonly url: string; readonly title: string; readonly favicon: string | undefined; @@ -186,15 +188,11 @@ export interface IBrowserViewModel extends IDisposable { readonly onDidKeyCommand: Event; readonly onDidChangeTitle: Event; readonly onDidChangeFavicon: Event; - readonly onDidRequestNewPage: Event; readonly onDidFindInPage: Event; readonly onDidChangeVisibility: Event; readonly onDidClose: Event; readonly onWillDispose: Event; - initialize(create: boolean): Promise; - setInitialURL(url: string, title?: string, favicon?: string): void; - layout(bounds: IBrowserViewBounds): Promise; setVisible(visible: boolean): Promise; loadURL(url: string): Promise; @@ -249,129 +247,49 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { constructor( readonly id: string, + readonly owner: IBrowserViewOwner, + initialState: IBrowserViewState, private readonly browserViewService: IBrowserViewService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IConfigurationService private readonly configurationService: IConfigurationService, @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IDialogService private readonly dialogService: IDialogService, @IStorageService private readonly storageService: IStorageService, @IBrowserZoomService private readonly zoomService: IBrowserZoomService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, + @ILogService private readonly logService: ILogService, ) { super(); - } - - get url(): string { return this._url; } - get title(): string { return this._title; } - get favicon(): string | undefined { return this._favicon; } - get loading(): boolean { return this._loading; } - get focused(): boolean { return this._focused; } - get visible(): boolean { return this._visible; } - get isDevToolsOpen(): boolean { return this._isDevToolsOpen; } - get canGoBack(): boolean { return this._canGoBack; } - get canGoForward(): boolean { return this._canGoForward; } - get screenshot(): VSBuffer | undefined { return this._screenshot; } - get error(): IBrowserViewLoadError | undefined { return this._error; } - get certificateError(): IBrowserViewCertificateError | undefined { return this._certificateError; } - get storageScope(): BrowserViewStorageScope { return this._storageScope; } - get sharedWithAgent(): boolean { return this._sharedWithAgent; } - get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; } - get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; } - get canZoomOut(): boolean { return this._browserZoomIndex > 0; } - - get onDidNavigate(): Event { - return this.browserViewService.onDynamicDidNavigate(this.id); - } - - get onDidChangeLoadingState(): Event { - return this.browserViewService.onDynamicDidChangeLoadingState(this.id); - } - - get onDidChangeFocus(): Event { - return this.browserViewService.onDynamicDidChangeFocus(this.id); - } - - get onDidChangeDevToolsState(): Event { - return this.browserViewService.onDynamicDidChangeDevToolsState(this.id); - } - - get onDidKeyCommand(): Event { - return this.browserViewService.onDynamicDidKeyCommand(this.id); - } - - get onDidChangeTitle(): Event { - return this.browserViewService.onDynamicDidChangeTitle(this.id); - } - - get onDidChangeFavicon(): Event { - return this.browserViewService.onDynamicDidChangeFavicon(this.id); - } - - get onDidRequestNewPage(): Event { - return this.browserViewService.onDynamicDidRequestNewPage(this.id); - } - - get onDidFindInPage(): Event { - return this.browserViewService.onDynamicDidFindInPage(this.id); - } - - get onDidChangeVisibility(): Event { - return this.browserViewService.onDynamicDidChangeVisibility(this.id); - } - - get onDidClose(): Event { - return this.browserViewService.onDynamicDidClose(this.id); - } - - /** - * Initialize the model with the current state from the main process. - * @param create Whether to create the browser view if it doesn't already exist. - * @throws If the browser view doesn't exist and `create` is false, or if initialization fails - */ - async initialize(create: boolean): Promise { - const dataStorageSetting = this.configurationService.getValue( - 'workbench.browser.dataStorage' - ) ?? BrowserViewStorageScope.Global; - - // Wait for trust initialization before determining storage scope - await this.workspaceTrustManagementService.workspaceTrustInitialized; - const isWorkspaceUntrusted = - this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY && - !this.workspaceTrustManagementService.isWorkspaceTrusted(); - - // Always use ephemeral sessions for untrusted workspaces - const dataStorage = isWorkspaceUntrusted ? BrowserViewStorageScope.Ephemeral : dataStorageSetting; - - const workspaceId = this.workspaceContextService.getWorkspace().id; - const state = create - ? await this.browserViewService.getOrCreateBrowserView(this.id, dataStorage, workspaceId) - : await this.browserViewService.getState(this.id); - - this._url = state.url; - this._title = state.title; - this._loading = state.loading; - this._focused = state.focused; - this._visible = state.visible; - this._isDevToolsOpen = state.isDevToolsOpen; - this._canGoBack = state.canGoBack; - this._canGoForward = state.canGoForward; - this._screenshot = state.lastScreenshot; - this._favicon = state.lastFavicon; - this._error = state.lastError; - this._certificateError = state.certificateError; - this._storageScope = state.storageScope; - this._sharedWithAgent = await this.playwrightService.isPageTracked(this.id); - this._browserZoomIndex = state.browserZoomIndex; + // Initialize state + this._url = initialState.url; + this._title = initialState.title; + this._loading = initialState.loading; + this._focused = initialState.focused; + this._visible = initialState.visible; + this._isDevToolsOpen = initialState.isDevToolsOpen; + this._canGoBack = initialState.canGoBack; + this._canGoForward = initialState.canGoForward; + this._screenshot = initialState.lastScreenshot; + this._favicon = initialState.lastFavicon; + this._error = initialState.lastError; + this._certificateError = initialState.certificateError; + this._storageScope = initialState.storageScope; + this._browserZoomIndex = initialState.browserZoomIndex; this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral; this._zoomHost = parseZoomHost(this._url); + // Sync initial zoom and sharing state (async, but emits events) const effectiveZoomIndex = this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral); if (effectiveZoomIndex !== this._browserZoomIndex) { - await this.setBrowserZoomIndex(effectiveZoomIndex); + void this.setBrowserZoomIndex(effectiveZoomIndex).catch(e => { + this.logService.warn(`[BrowserViewModel] Failed to set initial zoom:`, e); + }); } + void this.playwrightService.isPageTracked(this.id).then(shared => this._setSharedWithAgent(shared)).catch(e => { + this.logService.warn(`[BrowserViewModel] Failed to check initial page tracking:`, e); + }); + + // Set up state synchronization this._register(this.zoomService.onDidChangeZoom(({ host, isEphemeralChange }) => { if (isEphemeralChange && !this._isEphemeral) { @@ -380,12 +298,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { if (host === undefined || host === this._zoomHost) { void this.setBrowserZoomIndex( this.zoomService.getEffectiveZoomIndex(this._zoomHost, this._isEphemeral) - ); + ).catch(() => { }); } })); - // Set up state synchronization - this._register(this.onDidNavigate(e => { // Clear favicon on navigation to a different host if (URL.parse(e.url)?.host !== URL.parse(this._url)?.host) { @@ -437,17 +353,62 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { })); } - setInitialURL(url: string, title?: string, favicon?: string): void { - if (this._url !== url) { - this._url = url; - this._title = title || ''; - this._favicon = favicon; - this._loading = true; - this._error = undefined; - this._certificateError = undefined; + get url(): string { return this._url; } + get title(): string { return this._title; } + get favicon(): string | undefined { return this._favicon; } + get loading(): boolean { return this._loading; } + get focused(): boolean { return this._focused; } + get visible(): boolean { return this._visible; } + get isDevToolsOpen(): boolean { return this._isDevToolsOpen; } + get canGoBack(): boolean { return this._canGoBack; } + get canGoForward(): boolean { return this._canGoForward; } + get screenshot(): VSBuffer | undefined { return this._screenshot; } + get error(): IBrowserViewLoadError | undefined { return this._error; } + get certificateError(): IBrowserViewCertificateError | undefined { return this._certificateError; } + get storageScope(): BrowserViewStorageScope { return this._storageScope; } + get sharedWithAgent(): boolean { return this._sharedWithAgent; } + get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; } + get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; } + get canZoomOut(): boolean { return this._browserZoomIndex > 0; } - void this.loadURL(url); // Non-blocking - } + get onDidNavigate(): Event { + return this.browserViewService.onDynamicDidNavigate(this.id); + } + + get onDidChangeLoadingState(): Event { + return this.browserViewService.onDynamicDidChangeLoadingState(this.id); + } + + get onDidChangeFocus(): Event { + return this.browserViewService.onDynamicDidChangeFocus(this.id); + } + + get onDidChangeDevToolsState(): Event { + return this.browserViewService.onDynamicDidChangeDevToolsState(this.id); + } + + get onDidKeyCommand(): Event { + return this.browserViewService.onDynamicDidKeyCommand(this.id); + } + + get onDidChangeTitle(): Event { + return this.browserViewService.onDynamicDidChangeTitle(this.id); + } + + get onDidChangeFavicon(): Event { + return this.browserViewService.onDynamicDidChangeFavicon(this.id); + } + + get onDidFindInPage(): Event { + return this.browserViewService.onDynamicDidFindInPage(this.id); + } + + get onDidChangeVisibility(): Event { + return this.browserViewService.onDynamicDidChangeVisibility(this.id); + } + + get onDidClose(): Event { + return this.browserViewService.onDynamicDidClose(this.id); } async layout(bounds: IBrowserViewBounds): Promise { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 4d4af6bf91e40..4b7789a3dee78 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -14,15 +14,14 @@ import { RawContextKey, IContextKey, IContextKeyService } from '../../../../plat import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IInstantiationService, IConstructorSignature, BrandedService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { IBrowserEditorViewState, IBrowserViewModel } from '../../browserView/common/browserView.js'; +import { IBrowserViewModel } from '../../browserView/common/browserView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; -import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError, BrowserNewPageLocation } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError } from '../../../../platform/browserView/common/browserView.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -39,10 +38,9 @@ import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { SiteInfoWidget } from './siteInfoWidget.js'; -import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; -import { URI } from '../../../../base/common/uri.js'; import { Emitter } from '../../../../base/common/event.js'; import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); @@ -376,10 +374,17 @@ export class BrowserEditor extends EditorPane { @ILogService private readonly logService: ILogService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IEditorService private readonly editorService: IEditorService, @ILayoutService private readonly layoutService: ILayoutService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, ) { super(BrowserEditorInput.EDITOR_ID, group, telemetryService, themeService, storageService); + + // Be sure to hide the view when the workbench is reloading, as `clearInput()` may not be called. + this._register(this.lifecycleService.onWillShutdown((e) => { + if (e.reason === ShutdownReason.RELOAD) { + this._model?.setVisible(false); + } + })); } protected override createEditor(parent: HTMLElement): void { @@ -598,31 +603,6 @@ export class BrowserEditor extends EditorPane { } })); - this._inputDisposables.add(this._model.onDidRequestNewPage(({ resource, url, location, position }) => { - logBrowserOpen(this.telemetryService, (() => { - switch (location) { - case BrowserNewPageLocation.Background: return 'browserLinkBackground'; - case BrowserNewPageLocation.Foreground: return 'browserLinkForeground'; - case BrowserNewPageLocation.NewWindow: return 'browserLinkNewWindow'; - } - })()); - - const targetGroup = location === BrowserNewPageLocation.NewWindow ? AUX_WINDOW_GROUP : this.group; - const viewState: IBrowserEditorViewState = { url }; - this.editorService.openEditor({ - resource: URI.revive(resource), - options: { - pinned: true, - inactive: location === BrowserNewPageLocation.Background, - auxiliary: { - bounds: position, - compact: true - }, - viewState - } - }, targetGroup); - })); - this._inputDisposables.add(this.overlayManager!.onDidChangeOverlayState(() => { this.checkOverlays(); })); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index be1e6c7d1cb58..e8fc1087cc783 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -11,7 +11,6 @@ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor import { BrowserEditor } from './browserEditor.js'; import { BrowserEditorInput, BrowserEditorSerializer } from '../common/browserEditorInput.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; @@ -50,7 +49,7 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { constructor( @IEditorResolverService editorResolverService: IEditorResolverService, - @IInstantiationService instantiationService: IInstantiationService + @IBrowserViewWorkbenchService browserViewWorkbenchService: IBrowserViewWorkbenchService, ) { editorResolverService.registerEditor( `${Schemas.vscodeBrowser}:/**`, @@ -70,10 +69,7 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { throw new Error(`Invalid browser view resource: ${resource.toString()}`); } - const browserInput = instantiationService.createInstance(BrowserEditorInput, { - ...options?.viewState, - id: parsed.id - }); + const browserInput = browserViewWorkbenchService.getOrCreateLazy(parsed.id, options?.viewState); // Start resolving the input right away. This will create the browser view. // This allows browser views to be loaded in the background. @@ -82,8 +78,8 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { return { editor: browserInput, options: { - ...options, - pinned: !!browserInput.url // pin if navigated + pinned: !!browserInput.url, // pin if navigated + ...options } }; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index d189013b3adb7..4645abef4945e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -3,15 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserViewCommandId, IBrowserViewService, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; -import { IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel } from '../common/browserView.js'; +import { BrowserViewCommandId, BrowserViewStorageScope, IBrowserViewOpenOptions, IBrowserViewOwner, IBrowserViewService, IBrowserViewState, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel, IBrowserEditorViewState } from '../common/browserView.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { Event } from '../../../../base/common/event.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; /** Command IDs whose accelerators are shown in browser view context menus. */ const browserViewContextMenuCommands = [ @@ -24,28 +31,85 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV declare readonly _serviceBrand: undefined; private readonly _browserViewService: IBrowserViewService; - private readonly _models = new Map(); + private readonly _known = new Map(); + private readonly _mainWindowId: number; + + private readonly _onDidChangeBrowserViews = this._register(new Emitter()); + readonly onDidChangeBrowserViews: Event = this._onDidChangeBrowserViews.event; constructor( @IMainProcessService mainProcessService: IMainProcessService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IKeybindingService private readonly keybindingService: IKeybindingService + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + @ILogService private readonly logService: ILogService ) { super(); const channel = mainProcessService.getChannel(ipcBrowserViewChannelName); this._browserViewService = ProxyChannel.toService(channel); + this._mainWindowId = mainWindow.vscodeWindowId; this.sendKeybindings(); this._register(this.keybindingService.onDidUpdateKeybindings(() => this.sendKeybindings())); + + // Start asynchronously creating models for all views we already own. + void this._initializeExistingViews().catch(e => { + this.logService.error('[BrowserViewWorkbenchService] Failed to initialize existing browser views.', e); + }); + + // Listen for new browser views + this._register(this._browserViewService.onDidCreateBrowserView(e => { + if (e.info.owner.mainWindowId !== this._mainWindowId) { + return; // Not for this window + } + + // Eagerly create the model from the state we already have + this._createModel(e.info.id, e.info.owner, e.info.state); + + const editor = this._known.get(e.info.id); + if (editor) { + this._openEditorForCreatedView(editor, e.openOptions); + } + })); } - async getOrCreateBrowserViewModel(id: string): Promise { - return this._getBrowserViewModel(id, true); + getKnownBrowserViews(): Map { + return this._known; } - async getBrowserViewModel(id: string): Promise { - return this._getBrowserViewModel(id, false); + getOrCreateLazy(id: string, initialState?: IBrowserEditorViewState, model?: IBrowserViewModel): BrowserEditorInput { + if (!this._known.has(id)) { + const input = this.instantiationService.createInstance(BrowserEditorInput, { id, ...initialState }, async () => { + const state = await this._browserViewService.getOrCreateBrowserView( + id, + { + owner: this._getDefaultOwner(), + scope: await this._resolveStorageScope(), + initialState: { + url: initialState?.url, + title: initialState?.title, + lastFavicon: initialState?.favicon + } + } + ); + return this._createModel(id, this._getDefaultOwner(), state); + }); + input.onWillDispose(() => { + this._known.delete(id); + this._onDidChangeBrowserViews.fire(); + }); + if (model) { + input.model = model; + } + this._known.set(id, input); + this._onDidChangeBrowserViews.fire(); + } + + return this._known.get(id)!; } async clearGlobalStorage(): Promise { @@ -57,31 +121,91 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV return this._browserViewService.clearWorkspaceStorage(workspaceId); } - private async _getBrowserViewModel(id: string, create: boolean): Promise { - let model = this._models.get(id); - if (model) { - return model; - } + private _getDefaultOwner(): IBrowserViewOwner { + return { mainWindowId: this._mainWindowId }; + } + + private async _resolveStorageScope(): Promise { + const dataStorageSetting = this.configurationService.getValue( + 'workbench.browser.dataStorage' + ) ?? BrowserViewStorageScope.Global; - model = this.instantiationService.createInstance(BrowserViewModel, id, this._browserViewService); - this._models.set(id, model); + await this.workspaceTrustManagementService.workspaceTrustInitialized; - // Initialize the model with current state - try { - await model.initialize(create); - } catch (e) { - this._models.delete(id); - throw e; + const isWorkspaceUntrusted = + this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY && + !this.workspaceTrustManagementService.isWorkspaceTrusted(); + + return isWorkspaceUntrusted ? BrowserViewStorageScope.Ephemeral : dataStorageSetting; + } + + /** + * Fetch all views owned by this window from the main service and create + * models for them so they are available synchronously. + */ + private async _initializeExistingViews(): Promise { + const views = await this._browserViewService.getBrowserViews(this._mainWindowId); + for (const info of views) { + if (!this._known.has(info.id)) { + this._createModel(info.id, info.owner, info.state); + } } + } - // Clean up model when disposed - Event.once(model.onWillDispose)(() => { - this._models.delete(id); - }); + private _createModel(id: string, owner: IBrowserViewOwner, state: IBrowserViewState): IBrowserViewModel { + // Don't double-create + const existing = this._known.get(id)?.model; + if (existing) { + return existing; + } + + const model = this.instantiationService.createInstance(BrowserViewModel, id, owner, state, this._browserViewService); + + // Sanity: both pass and assign the model to be sure. It will no-op if already set. + this.getOrCreateLazy(id, {}, model).model = model; return model; } + /** + * Open an editor tab for a newly created browser view. + */ + private _openEditorForCreatedView(view: BrowserEditorInput, openOptions: IBrowserViewOpenOptions): void { + const opts = openOptions; + + // Resolve target group: auxiliary window, parent's group, or default + let targetGroup: number | typeof AUX_WINDOW_GROUP | undefined; + if (opts.auxiliaryWindow) { + targetGroup = AUX_WINDOW_GROUP; + } else if (opts.parentViewId) { + targetGroup = this._findEditorGroupForView(opts.parentViewId); + } + + void this.editorService.openEditor(view, { + inactive: opts.background, + preserveFocus: opts.preserveFocus, + pinned: opts.pinned, + auxiliary: opts.auxiliaryWindow + ? { bounds: opts.auxiliaryWindow, compact: true } + : undefined, + }, targetGroup); + } + + /** + * Find the editor group that currently contains a browser view with the + * given ID, or undefined if not open in any group. + */ + private _findEditorGroupForView(viewId: string): number | undefined { + for (const group of this.editorGroupsService.groups) { + for (const editor of group.editors) { + if (editor instanceof BrowserEditorInput && editor.id === viewId) { + return group.id; + } + } + } + return undefined; + } + private sendKeybindings(): void { const keybindings: { [commandId: string]: string } = Object.create(null); for (const commandId of browserViewContextMenuCommands) { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index 2f0c1db0f6c0c..e51217a53b5a6 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -25,6 +25,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { IExternalOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js'; @@ -279,6 +280,7 @@ class OpenIntegratedBrowserAction extends Action2 { async run(accessor: ServicesAccessor, urlOrOptions?: string | IOpenBrowserOptions): Promise { const editorService = accessor.get(IEditorService); const telemetryService = accessor.get(ITelemetryService); + const browserViewService = accessor.get(IBrowserViewWorkbenchService); // Parse arguments const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {}); @@ -287,11 +289,7 @@ class OpenIntegratedBrowserAction extends Action2 { if (options.reuseUrlFilter) { const filterUri = URI.parse(options.reuseUrlFilter); - const matchingEditor = editorService.editors.find((e): e is BrowserEditorInput => { - if (!(e instanceof BrowserEditorInput)) { - return false; - } - + const matchingEditor = [...browserViewService.getKnownBrowserViews().values()].find((e) => { const editorUri = URI.parse(e.url || ''); // URIs default to putting "file" scheme. Check that the scheme is really in the filter. if (filterUri.scheme && options.reuseUrlFilter!.startsWith(`${filterUri.scheme}:`) && filterUri.scheme !== editorUri.scheme) { @@ -428,10 +426,10 @@ class OpenBrowserFromViewMenuAction extends Action2 { } async run(accessor: ServicesAccessor): Promise { - const editorService = accessor.get(IEditorService); + const browserViewService = accessor.get(IBrowserViewWorkbenchService); const commandService = accessor.get(ICommandService); - const hasOpenBrowserEditor = editorService.editors.some(editor => editor instanceof BrowserEditorInput); + const hasOpenBrowserEditor = browserViewService.getKnownBrowserViews().size > 0; if (hasOpenBrowserEditor) { await commandService.executeCommand(BrowserViewCommandId.QuickOpen); @@ -477,25 +475,15 @@ class BrowserEditorOpenContextKeyContribution extends Disposable implements IWor constructor( @IContextKeyService contextKeyService: IContextKeyService, - @IEditorService editorService: IEditorService, + @IBrowserViewWorkbenchService browserViewService: IBrowserViewWorkbenchService, ) { super(); const contextKey = CONTEXT_BROWSER_EDITOR_OPEN.bindTo(contextKeyService); - const update = () => contextKey.set(editorService.editors.some(e => e instanceof BrowserEditorInput)); + const update = () => contextKey.set(browserViewService.getKnownBrowserViews().size > 0); update(); - - this._register(editorService.onWillOpenEditor(e => { - if (e.editor instanceof BrowserEditorInput) { - contextKey.set(true); - } - })); - this._register(editorService.onDidCloseEditor(e => { - if (e.editor instanceof BrowserEditorInput) { - update(); - } - })); + this._register(browserViewService.onDidChangeBrowserViews(() => update())); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index 44a84be163943..641cd8bb0dc37 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -12,6 +12,7 @@ import { IAgentNetworkFilterService } from '../../../../../platform/networkFilte import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IToolResult } from '../../../chat/common/tools/languageModelToolsService.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; // eslint-disable-next-line local/code-import-patterns import type { Page } from 'playwright-core'; @@ -145,10 +146,10 @@ export function errorResult(message: string): IToolResult { * exists. When {@link playwrightService} is provided, only pages tracked by Playwright * (i.e. shared with the agent) are considered. * - * @returns The first matching {@link BrowserEditorInput}, or `undefined` if none was found. + * @returns All matching {@link BrowserEditorInput}s. */ async function findExistingPagesByHost( - editorService: IEditorService, + browserViewService: IBrowserViewWorkbenchService, playwrightService: IPlaywrightService | undefined, url: string, ): Promise { @@ -162,7 +163,7 @@ async function findExistingPagesByHost( : undefined; const results: BrowserEditorInput[] = []; - for (const editor of editorService.editors) { + for (const editor of browserViewService.getKnownBrowserViews().values()) { if (!(editor instanceof BrowserEditorInput)) { continue; } @@ -197,11 +198,12 @@ async function findExistingPagesByHost( */ export async function getExistingPagesResult( editorService: IEditorService, + browserViewService: IBrowserViewWorkbenchService, playwrightService: IPlaywrightService | undefined, url: string, formatOptions?: FormatBrowserEditorLinesOptions ): Promise { - const existing = await findExistingPagesByHost(editorService, playwrightService, url); + const existing = await findExistingPagesByHost(browserViewService, playwrightService, url); if (existing.length === 0) { return undefined; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index b1097a0d1f725..4dad58cb16fff 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -14,7 +14,7 @@ import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribu import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; import { ILanguageModelToolsService, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; -import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { formatBrowserEditorList } from './browserToolHelpers.js'; import { ClickBrowserTool, ClickBrowserToolData } from './clickBrowserTool.js'; import { DragElementTool, DragElementToolData } from './dragElementTool.js'; @@ -45,6 +45,7 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IChatContextService private readonly chatContextService: IChatContextService, @IEditorService private readonly editorService: IEditorService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { super(); @@ -111,24 +112,20 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench this._trackedIds = new Set(ids); this._updateBrowserContext(); })); - this._toolsStore.add(this.editorService.onDidEditorsChange(() => this._updateBrowserContext())); + this._toolsStore.add(this.browserViewService.onDidChangeBrowserViews(() => this._updateBrowserContext())); this._toolsStore.add(this.agentNetworkFilterService.onDidChange(() => this._updateBrowserContext())); } private _updateBrowserContext(): void { - const trackedEditors: BrowserEditorInput[] = []; - for (const editor of this.editorService.editors) { - if (editor instanceof BrowserEditorInput && this._trackedIds.has(editor.id)) { - trackedEditors.push(editor); - } - } + const trackedBrowsers = [...this.browserViewService.getKnownBrowserViews().values()] + .filter(entry => this._trackedIds.has(entry.id)); - if (trackedEditors.length === 0) { + if (trackedBrowsers.length === 0) { this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, []); return; } - const list = formatBrowserEditorList(this.editorService, trackedEditors, { agentNetworkFilterService: this.agentNetworkFilterService }); + const list = formatBrowserEditorList(this.editorService, trackedBrowsers, { agentNetworkFilterService: this.agentNetworkFilterService }); this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, [{ handle: 0, label: localize('browserContext.label', "Browser Pages"), diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index 00fd405d9df6f..89b5d2159b023 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -13,6 +13,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; import { createBrowserPageLink, getExistingPagesResult } from './browserToolHelpers.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; export const OpenPageToolId = 'open_browser_page'; @@ -49,6 +50,7 @@ export class OpenBrowserTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IEditorService private readonly editorService: IEditorService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, ) { } @@ -84,7 +86,7 @@ export class OpenBrowserTool implements IToolImpl { const params = invocation.parameters as IOpenBrowserToolParams; if (!params.forceNew) { - const existingResult = await getExistingPagesResult(this.editorService, this.playwrightService, params.url, { agentNetworkFilterService: this.agentNetworkFilterService }); + const existingResult = await getExistingPagesResult(this.editorService, this.browserViewService, this.playwrightService, params.url, { agentNetworkFilterService: this.agentNetworkFilterService }); if (existingResult) { return existingResult; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts index 12ca0b468f71e..149fdd44c796a 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts @@ -14,6 +14,7 @@ import { type CountTokensCallback, type IPreparedToolInvocation, type IToolData, import { IOpenBrowserToolParams, OpenBrowserToolData } from './openBrowserTool.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { createBrowserPageLink, getExistingPagesResult } from './browserToolHelpers.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; export const OpenBrowserToolNonAgenticData: IToolData = { ...OpenBrowserToolData, @@ -24,6 +25,7 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { constructor( @ITelemetryService private readonly telemetryService: ITelemetryService, @IEditorService private readonly editorService: IEditorService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, ) { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -52,7 +54,7 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { const params = invocation.parameters as IOpenBrowserToolParams; if (!params.forceNew) { - const existingResult = await getExistingPagesResult(this.editorService, undefined, params.url, { excludeIds: true }); + const existingResult = await getExistingPagesResult(this.editorService, this.browserViewService, undefined, params.url, { excludeIds: true }); if (existingResult) { return existingResult; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts index 11a1b91b22cdb..a0f6fc6925773 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -96,7 +96,11 @@ export class ScreenshotBrowserTool implements IToolImpl { // Note that we don't use Playwright's screenshot methods because they cause brief flashing on the page, // and also doesn't handle zooming well. - const browserViewModel = await this.browserViewWorkbenchService.getBrowserViewModel(params.pageId); // Throws if the given pageId doesn't exist + const browserViewModel = await this.browserViewWorkbenchService.getKnownBrowserViews().get(params.pageId)?.resolve(); + if (!browserViewModel) { + return errorResult(`No browser page found with ID ${params.pageId}`); + } + const bounds = selector && await playwrightInvokeRaw(this.playwrightService, params.pageId, async (page, selector, scrollIntoViewIfNeeded) => { const locator = page.locator(selector); if (scrollIntoViewIfNeeded) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 765f80e7299f9..0c3d168f16829 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -457,7 +457,11 @@ export class OpenPermissionPickerAction extends Action2 { ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask), ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.lockedCodingAgentId.notEqualsTo(AgentSessionProviders.Cloud), + ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.Background), + ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.Claude), + ), ) } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts b/src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts index 03e96021ee1a4..e7d0cbaf2e9ef 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/createPluginAction.ts @@ -66,7 +66,7 @@ function isUserDefined(storage: PromptsStorage): boolean { } function isUserDefinedMcpCollection(collection: McpCollectionDefinition): boolean { - const order = collection.presentation?.order; + const order = collection.order; return order === McpCollectionSortOrder.User || order === McpCollectionSortOrder.WorkspaceFolder || order === McpCollectionSortOrder.Workspace; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index eaf8529f0a4d4..44c7036efed6b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -15,7 +15,8 @@ import { autorun, derived, IObservable, observableValue, transaction } from '../ import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; -import { AgentHostSessionConfigBranchNameHintKey, AgentProvider, AgentSession, IAgentAttachment, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentProvider, AgentSession, IAgentAttachment, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { ConfirmationOptionKind, CustomizationRef, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -2259,7 +2260,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** Creates a new backend session and subscribes to its state. */ private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, fork?: { session: URI; turnIndex: number; turnId: string }, sessionConfig?: Record, branchNameHint?: string): Promise { - const config = branchNameHint ? { ...sessionConfig, [AgentHostSessionConfigBranchNameHintKey]: branchNameHint } : sessionConfig; + const config = branchNameHint ? { ...sessionConfig, [SessionConfigKey.BranchNameHint]: branchNameHint } : sessionConfig; const workingDirectory = this._config.resolveWorkingDirectory?.(sessionResource) ?? this._workingDirectoryResolver.resolve(sessionResource) ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index d04feb219aa01..d9d3931bdb489 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -12,7 +12,7 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { ISessionOpenOptions, openSession } from './agentSessionsOpener.js'; -import { IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js'; +import { AgentSessionStatus, IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js'; import { IAgentSessionsService } from './agentSessionsService.js'; import { AgentSessionsSorter, groupAgentSessionsByDate, sessionDateFromNow } from './agentSessionsViewer.js'; import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID } from './agentSessions.js'; @@ -141,7 +141,7 @@ export class AgentSessionsPicker { private createPickerItems(filter: AgentSessionsFilter): (ISessionPickItem | IQuickPickSeparator)[] { const sessions = this.agentSessionsService.model.sessions - .filter(session => !filter.exclude(session)) + .filter(session => session.status !== AgentSessionStatus.Completed && !filter.exclude(session)) .sort(this.sorter.compare.bind(this.sorter)); const items: (ISessionPickItem | IQuickPickSeparator)[] = []; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 3514479e362ca..8f70afe369c7e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -526,6 +526,7 @@ export class AICustomizationListWidget extends Disposable { private displayEntries: IListEntry[] = []; private searchQuery: string = ''; private readonly collapsedGroups = new Set(); + private _layoutDeferred = false; private readonly dropdownActionDisposables = this._register(new DisposableStore()); private _loadItemsSeq = 0; @@ -1496,14 +1497,20 @@ export class AICustomizationListWidget extends Disposable { this.searchInput.layout(); // Measure sibling elements to calculate the remaining space for the list. - // When offsetHeight returns 0 the container just became visible - // after display:none and the browser hasn't reflowed yet — defer - // layout to the next frame so measurements are accurate. - // Skip the retry when the element is hidden (display:none parent) - // since rAF will never produce a non-zero measurement. + // When offsetHeight returns 0 the container may have just become visible + // after display:none and the browser hasn't reflowed yet — defer layout + // once so measurements are accurate. Only retry once to avoid an endless + // loop when the widget is created while permanently hidden. const searchBarHeight = this.searchAndButtonContainer.offsetHeight; - if (searchBarHeight === 0 && this.element.offsetParent !== null) { - DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(height, width)); + if (searchBarHeight === 0 && !this._layoutDeferred) { + this._layoutDeferred = true; + DOM.getWindow(this.element).requestAnimationFrame(() => { + try { + this.layout(height, width); + } finally { + this._layoutDeferred = false; + } + }); return; } const footerHeight = this.sectionHeader.offsetHeight; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index e99c916b65a80..2f602be4ac8c8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -32,8 +32,7 @@ import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { localChatSessionType } from '../../common/chatSessionsService.js'; -import { CustomizationHarness, ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; @@ -763,12 +762,9 @@ class AICustomizationManagementActionsContribution extends Disposable implements const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; if (sessionResource) { const sessionType = getChatSessionType(sessionResource); - const harnessId = sessionType === localChatSessionType - ? CustomizationHarness.VSCode - : sessionType; - const available = harnessService.availableHarnesses.get(); - if (available.some(h => h.id === harnessId)) { - harnessService.setActiveHarness(harnessId); + const harness = harnessService.findHarnessById(sessionType); + if (harness) { + harnessService.setActiveHarness(sessionType); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 2fa8262672846..877bc8e99c5d7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -83,10 +83,11 @@ import { IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; import { AgentPluginEditor } from '../agentPluginEditor/agentPluginEditor.js'; import { AgentPluginEditorInput } from '../agentPluginEditor/agentPluginEditorInput.js'; import { IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; -import { ICustomizationHarnessService, CustomizationHarness, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage } from './aiCustomizationWelcomePage.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { SessionType } from '../../common/chatSessionsService.js'; const $ = DOM.$; @@ -499,7 +500,7 @@ export class AICustomizationManagementEditor extends EditorPane { let hidden: Set; if (this.isHarnessSelectorEnabled) { const activeId = this.harnessService.activeHarness.get(); - const descriptor = this.harnessService.availableHarnesses.get().find(h => h.id === activeId); + const descriptor = this.harnessService.findHarnessById(activeId); hidden = new Set(descriptor?.hiddenSections ?? []); } else { hidden = new Set(); // Local harness has no hidden sections @@ -605,12 +606,12 @@ export class AICustomizationManagementEditor extends EditorPane { // setActiveHarness(VSCode) is a safe no-op since the CLI harness // remains active — filtering stays correct for that window. if (!this.isHarnessSelectorEnabled) { - this.harnessService.setActiveHarness(CustomizationHarness.VSCode); + this.harnessService.setActiveHarness(SessionType.Local); } this.editorDisposables.add(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ChatConfiguration.ChatCustomizationHarnessSelectorEnabled)) { if (!this.isHarnessSelectorEnabled) { - this.harnessService.setActiveHarness(CustomizationHarness.VSCode); + this.harnessService.setActiveHarness(SessionType.Local); } } })); @@ -649,7 +650,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.harnessDropdownButton.setAttribute('aria-label', localize('selectHarness', "Select customization target")); this.harnessDropdownButton.setAttribute('aria-haspopup', 'listbox'); this.editorDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this.harnessDropdownButton, () => { - const descriptor = this.harnessService.availableHarnesses.get().find(h => h.id === this.harnessService.activeHarness.get()); + const descriptor = this.harnessService.findHarnessById(this.harnessService.activeHarness.get()); return descriptor?.label ?? ''; })); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index 198a8c52c6fad..519a41f869a18 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -5,13 +5,13 @@ import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { - CustomizationHarness, CustomizationHarnessServiceBase, ICustomizationHarnessService, createVSCodeHarnessDescriptor, } from '../../common/customizationHarnessService.js'; -import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; +import { SessionType } from '../../common/chatSessionsService.js'; /** * Core implementation of the customization harness service. @@ -20,11 +20,14 @@ import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js * (e.g. Copilot CLI) are contributed by extensions via the provider API. */ class CustomizationHarnessService extends CustomizationHarnessServiceBase { - constructor() { + constructor( + @IPromptsService promptsService: IPromptsService + ) { const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; super( [createVSCodeHarnessDescriptor(localExtras)], - CustomizationHarness.VSCode, + SessionType.Local, + promptsService, ); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 5a23afd396ae5..a97d31543aaa8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -40,9 +40,10 @@ import { formatDisplayName, truncateToFirstLine } from './aiCustomizationListWid import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationHarnessService, CustomizationHarness } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; +import { SessionType } from '../../common/chatSessionsService.js'; const $ = DOM.$; @@ -164,7 +165,7 @@ class McpServerItemRenderer implements IListRenderer { const activeId = this.harnessService.activeHarness.read(reader); - templateData.bridgedBadge.style.display = activeId !== CustomizationHarness.VSCode ? '' : 'none'; + templateData.bridgedBadge.style.display = activeId !== SessionType.Local ? '' : 'none'; })); templateData.disposables.add(this.hoverService.setupManagedHover( getDefaultHoverDelegate('mouse'), @@ -388,6 +389,7 @@ export class McpListWidget extends Disposable { private browseMode: boolean = false; private lastHeight: number = 0; private lastWidth: number = 0; + private _layoutDeferred = false; private readonly collapsedGroups = new Set(); private galleryCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -953,14 +955,20 @@ export class McpListWidget extends Disposable { this.element.style.height = `${height}px`; // Measure sibling elements to calculate the list height. - // When offsetHeight returns 0 the container just became visible - // after display:none and the browser hasn't reflowed yet — defer - // layout to the next frame so measurements are accurate. - // Skip the retry when the element is hidden (display:none parent) - // since rAF will never produce a non-zero measurement. + // When offsetHeight returns 0 the container may have just become visible + // after display:none and the browser hasn't reflowed yet — defer layout + // once so measurements are accurate. Only retry once to avoid an endless + // loop when the widget is created while permanently hidden. const searchBarHeight = this.searchAndButtonContainer.offsetHeight; - if (searchBarHeight === 0 && this.element.offsetParent !== null) { - DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(this.lastHeight, this.lastWidth)); + if (searchBarHeight === 0 && !this._layoutDeferred) { + this._layoutDeferred = true; + DOM.getWindow(this.element).requestAnimationFrame(() => { + try { + this.layout(this.lastHeight, this.lastWidth); + } finally { + this._layoutDeferred = false; + } + }); return; } const footerHeight = this.sectionHeader.offsetHeight; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index f82a81076993c..4556b8c5c9f56 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -353,6 +353,7 @@ export class PluginListWidget extends Disposable { private browseMode: boolean = false; private lastHeight: number = 0; private lastWidth: number = 0; + private _layoutDeferred = false; private readonly collapsedGroups = new Set(); private marketplaceCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -846,14 +847,20 @@ export class PluginListWidget extends Disposable { this.element.style.height = `${height}px`; // Measure sibling elements to calculate the list height. - // When offsetHeight returns 0 the container just became visible - // after display:none and the browser hasn't reflowed yet — defer - // layout to the next frame so measurements are accurate. - // Skip the retry when the element is hidden (display:none parent) - // since rAF will never produce a non-zero measurement. + // When offsetHeight returns 0 the container may have just become visible + // after display:none and the browser hasn't reflowed yet — defer layout + // once so measurements are accurate. Only retry once to avoid an endless + // loop when the widget is created while permanently hidden. const searchBarHeight = this.searchAndButtonContainer.offsetHeight; - if (searchBarHeight === 0 && this.element.offsetParent !== null) { - DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(this.lastHeight, this.lastWidth)); + if (searchBarHeight === 0 && !this._layoutDeferred) { + this._layoutDeferred = true; + DOM.getWindow(this.element).requestAnimationFrame(() => { + try { + this.layout(this.lastHeight, this.lastWidth); + } finally { + this._layoutDeferred = false; + } + }); return; } const footerHeight = this.sectionHeader.offsetHeight; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index af8c8ce3c7c56..25d20c456ffcb 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -36,7 +36,7 @@ import { IEditorResolverService, RegisteredEditorPriority } from '../../../servi import { IPathService } from '../../../services/path/common/pathService.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 { allDiscoverySources, discoverySourceSettingsLabel, McpCollisionBehavior, mcpDiscoverySection, mcpServerCollisionBehaviorSection, mcpServerSamplingSection } from '../../mcp/common/mcpConfiguration.js'; import { ChatAgentNameService, ChatAgentService, IChatAgentNameService, IChatAgentService } from '../common/participants/chatAgents.js'; import { CodeMapperService, ICodeMapperService } from '../common/editing/chatCodeMapperService.js'; import '../common/widget/chatColors.js'; @@ -736,6 +736,19 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['experimental'], }, + [mcpServerCollisionBehaviorSection]: { + type: 'string', + description: nls.localize('chat.mcp.collisionBehavior', "Controls behavior when multiple MCP servers are discovered with the same name. 'disable' disables lower-priority duplicates. 'suffix' appends numeric suffixes to disambiguate."), + enum: [ + McpCollisionBehavior.Disable, + McpCollisionBehavior.Suffix, + ], + enumDescriptions: [ + nls.localize('chat.mcp.collisionBehavior.disable', "Disable lower-priority servers with duplicate names."), + nls.localize('chat.mcp.collisionBehavior.suffix', "Append numeric suffixes to servers with duplicate names."), + ], + default: McpCollisionBehavior.Disable, + }, [mcpServerSamplingSection]: { type: 'object', description: nls.localize('chat.mcp.serverSampling', "Configures which models are exposed to MCP servers for sampling (making model requests in the background). This setting can be edited in a graphical way under the `{0}` command.", 'MCP: ' + nls.localize('mcp.list', 'List Servers')), @@ -745,7 +758,7 @@ configurationRegistry.registerConfiguration({ properties: { allowedDuringChat: { type: 'boolean', - description: nls.localize('chat.mcp.serverSampling.allowedDuringChat', "Whether this server is make sampling requests during its tool calls in a chat session."), + description: nls.localize('chat.mcp.serverSampling.allowedDuringChat', "Whether this server is allowed to make sampling requests during its tool calls in a chat session."), default: true, }, allowedOutsideChat: { diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 0c326d98dca0a..e6cbe34da226f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -49,10 +49,10 @@ import { assertNever } from '../../../../../base/common/assert.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; import { slashReg } from '../../common/requestParser/chatRequestParser.js'; -import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', @@ -557,7 +557,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ async run(accessor: ServicesAccessor, chatOptions?: { resource: UriComponents; prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { const chatService = accessor.get(IChatService); - const promptsService = accessor.get(IPromptsService); + const customizationHarnessService = accessor.get(ICustomizationHarnessService); const toolsService = accessor.get(ILanguageModelToolsService); const { type } = contribution; @@ -567,7 +567,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const resource = URI.revive(chatOptions.resource); const ref = await chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatSessionsContribution#sendPrompt'); try { - const promptFile = await resolvePromptSlashCommand(chatOptions.prompt, contribution.type, promptsService, toolsService); + const promptFile = await resolvePromptSlashCommand(chatOptions.prompt, contribution.type, customizationHarnessService, toolsService); if (promptFile) { attachedContext = [promptFile, ...(attachedContext ?? [])]; } @@ -1318,7 +1318,7 @@ async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatS const logService = accessor.get(ILogService); const editorGroupService = accessor.get(IEditorGroupsService); const editorService = accessor.get(IEditorService); - const promptsService = accessor.get(IPromptsService); + const customizationHarnessService = accessor.get(ICustomizationHarnessService); const toolsService = accessor.get(ILanguageModelToolsService); // Determine resource to open @@ -1374,7 +1374,7 @@ async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatS } let attachedContext = chatSendOptions.attachedContext; - const promptFile = await resolvePromptSlashCommand(chatSendOptions.prompt, openOptions.type, promptsService, toolsService); + const promptFile = await resolvePromptSlashCommand(chatSendOptions.prompt, openOptions.type, customizationHarnessService, toolsService); if (promptFile) { attachedContext = [promptFile, ...(attachedContext ?? [])]; } @@ -1410,12 +1410,12 @@ function normalizeSessionOptions(options: ReadonlyChatSessionOptionsMap | Readon /** * Returns the variable entry for a slash command if the prompt starts with a slash command that can be resolved to a prompt file, otherwise returns undefined. */ -async function resolvePromptSlashCommand(prompt: string, sessionType: string, promptsService: IPromptsService, toolsService: ILanguageModelToolsService): Promise { +async function resolvePromptSlashCommand(prompt: string, sessionType: string, customizationHarnessService: ICustomizationHarnessService, toolsService: ILanguageModelToolsService): Promise { const slashMatch = prompt.match(slashReg); // starts with a slash command, add the corresponding prompt file to the context if it exists if (slashMatch) { // need to resolve the slash command to get the prompt file - const slashCommand = await promptsService.resolvePromptSlashCommand(slashMatch[1], sessionType, CancellationToken.None); + const slashCommand = await customizationHarnessService.resolvePromptSlashCommand(slashMatch[1], sessionType, CancellationToken.None); if (slashCommand) { const parseResult = slashCommand.parsedPromptFile; // add the prompt file to the context diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts index dc664a1ffb0b6..ea8724e0e58b8 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts @@ -60,8 +60,8 @@ export async function askForPromptSourceFolder( // create list of source folder locations const foldersList = resolvedFolders.map(resolved => { - const folderUri = resolved.parent; - const isDefault = defaultFolder && isEqual(folderUri, defaultFolder.parent); + const folderUri = resolved.searchRoot; + const isDefault = defaultFolder && isEqual(folderUri, defaultFolder.searchRoot); const sourceDescription = getSourceDescription(resolved.source); const detail = (existingFolder && isEqual(folderUri, existingFolder)) ? localize('current.folder', "Current Location") : undefined; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index e941918d96dc7..66535a8f2927d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -62,12 +62,11 @@ display: flex; flex-direction: column; flex-shrink: 0; - overflow: hidden; .chat-question-title-row { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; gap: 8px; min-width: 0; padding: 8px 8px 8px 16px; @@ -77,8 +76,9 @@ .chat-question-title { flex: 1; min-width: 0; - word-wrap: break-word; - overflow-wrap: break-word; + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; font-weight: 500; font-size: var(--vscode-chat-font-size-body-s); margin: 0; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts index e27cec0900017..243492253495b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts @@ -195,6 +195,7 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca return buttons; } + protected additionalPrimaryActions(): AbstractToolPrimaryAction[] { return []; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index c10c8d9bc7170..494623317332d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -85,6 +85,7 @@ import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; import { IChatDebugService } from '../../common/chatDebugService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; const $ = dom.$; @@ -404,6 +405,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IChatEditingService chatEditingService: IChatEditingService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IPromptsService private readonly promptsService: IPromptsService, + @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IChatModeService private readonly chatModeService: IChatModeService, @IChatLayoutService private readonly chatLayoutService: IChatLayoutService, @@ -2291,7 +2293,7 @@ export class ChatWidget extends Disposable implements IChatWidget { }); } - private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions): Promise { + private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions, sessionResource: URI): Promise { // first check if the input has a prompt slash command const agentSlashPromptPart = this.parsedInput.parts.find((r): r is ChatRequestSlashPromptPart => r instanceof ChatRequestSlashPromptPart); if (!agentSlashPromptPart) { @@ -2302,10 +2304,10 @@ export class ChatWidget extends Disposable implements IChatWidget { // Track them now so tip exclusions still update for commands like /init. this.chatTipService.recordSlashCommandUsage(agentSlashPromptPart.name); - const sessionType = this.viewModel ? getChatSessionType(this.viewModel.model.sessionResource) : undefined; + const sessionType = getChatSessionType(sessionResource); // need to resolve the slash command to get the prompt file - const slashCommand = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.name, sessionType, CancellationToken.None); + const slashCommand = await this.customizationHarnessService.resolvePromptSlashCommand(agentSlashPromptPart.name, sessionType, CancellationToken.None); if (!slashCommand) { return; } @@ -2417,7 +2419,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } // process the prompt command - await this._applyPromptFileIfSet(requestInputs); + await this._applyPromptFileIfSet(requestInputs, this.viewModel.sessionResource); if (this.viewOptions.enableWorkingSet !== undefined && this.input.currentModeKind === ChatModeKind.Edit) { const uniqueWorkingSetEntries = new ResourceSet(); // NOTE: this is used for bookkeeping so the UI can avoid rendering references in the UI that are already shown in the working set 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 61885a725c0b4..763d5df58a5a8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1259,7 +1259,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } const tryMatch = () => { + // wait for at least 2 models to load, + // Otherwise matching is not useful and may be inaccurate due to the fact that we have auto const models = this.getModels(); + if (models.length === 0) { + return; + } + if (models.length === 1 && models[0].metadata.id.toLocaleLowerCase() === 'auto') { + return; + } // Try exact identifier match first (e.g. "copilot/gpt-4o") let match = models.find(m => m.identifier === lastModelId); if (!match) { 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 ee9c7f4a7eb15..35c64cfda5c22 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -92,6 +92,7 @@ function createModelItem( action: IActionWidgetDropdownAction & { section?: string }, model?: ILanguageModelChatMetadataAndIdentifier, ): IActionListItem { + const hoverContent = model ? getModelHoverContent(model) : undefined; return { item: action, kind: ActionListItemKind.Action, @@ -100,8 +101,9 @@ function createModelItem( group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, hideIcon: false, section: action.section, - hover: model ? { content: getModelHoverContent(model) } : undefined, - submenuActions: action.toolbarActions, + hover: hoverContent ? { content: hoverContent } : undefined, + tooltip: action.tooltip, + submenuActions: action.toolbarActions?.length ? action.toolbarActions : undefined, }; } @@ -793,27 +795,30 @@ export class ModelPickerWidget extends Disposable { } -function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): MarkdownString { +function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): MarkdownString | undefined { const isAuto = isAutoModel(model); const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - markdown.appendMarkdown(`**${model.metadata.name}**`); + let hasContent = false; if (model.metadata.tooltip) { - markdown.appendMarkdown(`\n\n`); if (model.metadata.statusIcon) { markdown.appendMarkdown(`$(${model.metadata.statusIcon.id}) `); } markdown.appendMarkdown(`${model.metadata.tooltip}`); + hasContent = true; } if (!isAuto && (model.metadata.maxInputTokens || model.metadata.maxOutputTokens)) { - markdown.appendMarkdown(`\n\n`); + if (hasContent) { + markdown.appendMarkdown(`\n\n`); + } const totalTokens = (model.metadata.maxInputTokens ?? 0) + (model.metadata.maxOutputTokens ?? 0); markdown.appendMarkdown(`${localize('models.contextSize', 'Context Size')}: `); markdown.appendMarkdown(`${formatTokenCount(totalTokens)}`); + hasContent = true; } - return markdown; + return hasContent ? markdown : undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index b128fe75fe673..702c7bc935b55 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -54,8 +54,8 @@ import { IDynamicVariable } from '../../../../common/attachments/chatVariables.j import { ChatAgentLocation, ChatModeKind, isSupportedChatFileScheme } from '../../../../common/constants.js'; import { isToolSet } from '../../../../common/tools/languageModelToolsService.js'; import { IChatSessionsService } from '../../../../common/chatSessionsService.js'; -import { ICustomizationHarnessService, getActiveHarnessSlashCommands } from '../../../../common/customizationHarnessService.js'; -import { IPromptsService, matchesSessionType } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomizationHarnessService } from '../../../../common/customizationHarnessService.js'; +import { matchesSessionType } from '../../../../common/promptSyntax/service/promptsService.js'; import { ChatSubmitAction, IChatExecuteActionContext } from '../../../actions/chatExecuteActions.js'; import { IChatWidget, IChatWidgetService } from '../../../chat.js'; import { resizeImage } from '../../../chatImageUtils.js'; @@ -81,7 +81,6 @@ class SlashCommandCompletions extends Disposable { @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, - @IPromptsService private readonly promptsService: IPromptsService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IChatService chatService: IChatService, @IChatSessionsService chatSessionsService: IChatSessionsService, @@ -236,7 +235,8 @@ class SlashCommandCompletions extends Disposable { return; } - const promptCommands = await getActiveHarnessSlashCommands(this.harnessService, this.promptsService, token); + const currentSessionType = getChatSessionType(widget.viewModel.model.sessionResource); + const promptCommands = await this.harnessService.getSlashCommands(currentSessionType, token); if (promptCommands.length === 0) { return null; } @@ -245,7 +245,6 @@ class SlashCommandCompletions extends Disposable { return null; } - const currentSessionType = getChatSessionType(widget.viewModel.model.sessionResource); const userInvocableCommands = promptCommands .filter(c => c.userInvocable) .filter(c => matchesSessionType(c.sessionTypes, currentSessionType)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts index 4c7de690f60de..4d2b991c20c4f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts @@ -22,7 +22,6 @@ import { localize } from '../../../../../../../nls.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../common/widget/chatColors.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader } from '../../../../common/requestParser/chatParserTypes.js'; import { agentReg, slashReg, variableReg } from '../../../../common/requestParser/chatRequestParser.js'; -import { IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; import { IChatWidget } from '../../../chat.js'; import { ChatWidget } from '../../chatWidget.js'; import { dynamicVariableDecorationType } from '../../../attachments/chatDynamicVariables.js'; @@ -32,6 +31,7 @@ import { CancellationToken } from '../../../../../../../base/common/cancellation import { ThrottledDelayer } from '../../../../../../../base/common/async.js'; import { IEditorService } from '../../../../../../services/editor/common/editorService.js'; import { getChatSessionType } from '../../../../common/model/chatUri.js'; +import { ICustomizationHarnessService } from '../../../../common/customizationHarnessService.js'; const decorationDescription = 'chat'; const placeholderDecorationType = 'chat-session-detail'; @@ -87,7 +87,7 @@ class InputEditorDecorations extends Disposable { @IThemeService private readonly themeService: IThemeService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @ILabelService private readonly labelService: ILabelService, - @IPromptsService private readonly promptsService: IPromptsService, + @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, @IEditorService private readonly editorService: IEditorService, ) { super(); @@ -137,7 +137,12 @@ class InputEditorDecorations extends Disposable { void this.editorService.openEditor({ resource: mouseDownPromptSlashCommand.uri }); })); this._register(this.chatAgentService.onDidChangeAgents(() => this.triggerInputEditorDecorationsUpdate())); - this._register(this.promptsService.onDidChangeSlashCommands(() => this.triggerInputEditorDecorationsUpdate())); + this._register(this.customizationHarnessService.onDidChangeSlashCommands((e) => { + const sessionResource = this.widget.viewModel?.sessionResource; + if (sessionResource && e.sessionType === getChatSessionType(sessionResource)) { + this.triggerInputEditorDecorationsUpdate(); + } + })); this._register(autorun(reader => { // Watch for changes to the current mode and its properties const currentMode = this.widget.input.currentModeObs.read(reader); @@ -313,7 +318,7 @@ class InputEditorDecorations extends Disposable { const slashPromptPart = parsedRequest.find((p): p is ChatRequestSlashPromptPart => p instanceof ChatRequestSlashPromptPart); // first, fetch all async context - const promptSlashCommand = slashPromptPart ? await this.promptsService.resolvePromptSlashCommand(slashPromptPart.name, getChatSessionType(viewModel.sessionResource), token) : undefined; + const promptSlashCommand = slashPromptPart ? await this.customizationHarnessService.resolvePromptSlashCommand(slashPromptPart.name, getChatSessionType(viewModel.sessionResource), token) : undefined; if (token.isCancellationRequested) { // a new update came in while we were waiting return; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index f7f2ad013775a..9fe7079c924a7 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1403,7 +1403,9 @@ export class ChatService extends Disposable implements IChatService { this.chatEntitlementService.markAnonymousRateLimited(); } - shouldProcessPending = !rawResult.errorDetails && !token.isCancellationRequested; + shouldProcessPending = !rawResult.errorDetails + && !token.isCancellationRequested + && !request.response?.response.value.some(v => v.kind === 'confirmation' && !v.isUsed); request.response?.complete(); if (agentOrCommandFollowups) { diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 4971dae55ce5e..a7fa2f855fffb 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -6,7 +6,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { Event } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { joinPath } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -15,8 +15,9 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { AICustomizationManagementSection, IStorageSourceFilter } from './aiCustomizationWorkspaceService.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; import { AGENT_MD_FILENAME } from './promptSyntax/config/promptFileLocations.js'; -import { IChatPromptSlashCommand, IPromptsService, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { IChatPromptSlashCommand, IPromptsService, IResolvedChatPromptSlashCommand, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { SessionType } from './chatSessionsService.js'; export const ICustomizationHarnessService = createDecorator('customizationHarnessService'); @@ -54,20 +55,13 @@ export interface ISectionOverride { readonly fileExtension?: string; } -/** - * Identifies the AI harness (execution environment) that customizations - * are filtered for. Storage answers "where did this come from?"; harness - * answers "who consumes it?". - */ -export enum CustomizationHarness { - VSCode = 'vscode', - CLI = 'cli', -} - /** * Describes a single harness option for the UI toggle. */ export interface IHarnessDescriptor { + /** + * The harness/session-type identifier. + */ readonly id: string; readonly label: string; readonly icon: ThemeIcon; @@ -235,11 +229,17 @@ export interface ICustomizationHarnessService { */ readonly availableHarnesses: IObservable; + /** + * Finds the descriptor of the harness with the given id, or `undefined` if no such harness exists. + * @param sessionType The harness id (sessionType) + */ + findHarnessById(sessionType: string): IHarnessDescriptor | undefined; + /** * Changes the active harness. The new id must be present in * `availableHarnesses`. */ - setActiveHarness(id: string): void; + setActiveHarness(sessionType: string): void; /** * Convenience: returns the storage source filter for the active harness @@ -258,6 +258,25 @@ export interface ICustomizationHarnessService { * Returns a disposable that removes the harness when disposed. */ registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable; + + + /** + * Fires when one of the provided slash commands changes. + */ + readonly onDidChangeSlashCommands: Event<{ readonly sessionType: string }>; + + /** + * Returns the prompt and skill slash commands for the given session type. + * Provider-backed harnesses contribute their own items directly; the default + * VS Code harness falls back to the core prompts service. + */ + getSlashCommands(sessionType: string, token: CancellationToken): Promise; + + /** + * Resolves a slash command to its full metadata, including the parsed prompt file for prompt commands. + * Provider-backed harnesses resolve their own items directly; the default VS Code harness falls back to the core prompts service. + */ + resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise; } /** @@ -272,42 +291,6 @@ export interface ICustomizationSlashCommand { readonly sessionTypes?: readonly string[]; } -/** - * Returns the prompt and skill slash commands for the currently active harness. - * Provider-backed harnesses contribute their own items directly; the default - * VS Code harness falls back to the core prompts service. - */ -export async function getActiveHarnessSlashCommands( - harnessService: ICustomizationHarnessService, - promptsService: Pick, - token: CancellationToken, -): Promise { - const itemProvider = harnessService.getActiveDescriptor().itemProvider; - if (!itemProvider) { - return await promptsService.getPromptSlashCommands(token); - } - - const items = await itemProvider.provideChatSessionCustomizations(token); - if (!items) { - return []; - } - const result = []; - for (const item of items) { - if ((item.enabled !== false) && (item.type === PromptsType.prompt || item.type === PromptsType.skill)) { - result.push({ - uri: item.uri, - type: item.type as PromptsType.prompt | PromptsType.skill, - name: item.name, - description: item.description, - userInvocable: true, - storage: item.storage ?? PromptsStorage.local, - when: undefined - }); - } - } - return result; -} - // #region Shared filter constants /** @@ -372,7 +355,7 @@ function buildAllSources(extras: readonly string[]): readonly string[] { export function createVSCodeHarnessDescriptor(extras: readonly string[]): IHarnessDescriptor { const filter: IStorageSourceFilter = { sources: buildAllSources(extras) }; return { - id: CustomizationHarness.VSCode, + id: SessionType.Local, label: localize('harness.local', "Local"), icon: ThemeIcon.fromId(Codicon.vm.id), supportsTroubleshoot: true, @@ -437,7 +420,7 @@ function createRestrictedHarnessDescriptor( */ export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly string[]): IHarnessDescriptor { return createRestrictedHarnessDescriptor( - CustomizationHarness.CLI, + SessionType.CopilotCLI, localize('harness.cli', "Copilot CLI"), ThemeIcon.fromId(Codicon.copilot.id), cliUserRoots, @@ -496,6 +479,9 @@ export function matchesInstructionFileFilter(filePath: string, filters: readonly */ export class CustomizationHarnessServiceBase implements ICustomizationHarnessService { declare readonly _serviceBrand: undefined; + private readonly _onDidChangeSlashCommands = new Emitter<{ readonly sessionType: string }>(); + readonly onDidChangeSlashCommands = this._onDidChangeSlashCommands.event; + private readonly _providerListeners: IDisposable[] = []; private readonly _activeHarness: ISettableObservable; readonly activeHarness: IObservable; @@ -508,12 +494,15 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer constructor( staticHarnesses: readonly IHarnessDescriptor[], defaultHarness: string, + private readonly promptsService: IPromptsService, ) { this._staticHarnesses = staticHarnesses; + this.promptsService = promptsService; this._activeHarness = observableValue(this, defaultHarness); this.activeHarness = this._activeHarness; this._availableHarnesses = observableValue(this, [...this._staticHarnesses]); this.availableHarnesses = this._availableHarnesses; + this._rebindProviderListeners(); } private _getAllHarnesses(): readonly IHarnessDescriptor[] { @@ -528,6 +517,30 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer private _refreshAvailableHarnesses(): void { this._availableHarnesses.set(this._getAllHarnesses(), undefined); + this._rebindProviderListeners(); + } + + private _rebindProviderListeners(): void { + for (const listener of this._providerListeners) { + listener.dispose(); + } + this._providerListeners.length = 0; + for (const harness of this._getAllHarnesses()) { + const provider = harness.itemProvider; + if (!provider) { + this._providerListeners.push(this.promptsService.onDidChangeSlashCommands(() => this._onDidChangeSlashCommands.fire({ sessionType: harness.id }))); + } else { + this._providerListeners.push(provider.onDidChange(() => this._onDidChangeSlashCommands.fire({ sessionType: harness.id }))); + } + } + } + + dispose(): void { + for (const listener of this._providerListeners) { + listener.dispose(); + } + this._providerListeners.length = 0; + this._onDidChangeSlashCommands.dispose(); } registerExternalHarness(descriptor: IHarnessDescriptor): IDisposable { @@ -552,8 +565,13 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer }; } + findHarnessById(id: string): IHarnessDescriptor | undefined { + return this._getAllHarnesses().find(h => h.id === id); + } + setActiveHarness(id: string): void { - if (this._getAllHarnesses().some(h => h.id === id)) { + const harness = this.findHarnessById(id); + if (harness) { this._activeHarness.set(id, undefined); } } @@ -576,6 +594,60 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer } return all.find(h => h.id === activeId) ?? all[0]; } + + async getSlashCommands(sessionType: string, token: CancellationToken): Promise { + const harness = this.findHarnessById(sessionType); + if (!harness || !harness.itemProvider) { + const commands = await this.promptsService.getPromptSlashCommands(token); + return commands.filter(command => matchesSessionType(command.sessionTypes, sessionType)); + } + + const items = await harness.itemProvider.provideChatSessionCustomizations(token); + if (!items) { + return []; + } + const result = []; + for (const item of items) { + if ((item.enabled !== false) && (item.type === PromptsType.prompt || item.type === PromptsType.skill)) { + result.push({ + uri: item.uri, + type: item.type as PromptsType.prompt | PromptsType.skill, + name: item.name, + description: item.description, + userInvocable: true, // todo we need a way for providers to specify this if some items aren't user-invocable` + storage: item.storage ?? PromptsStorage.local, + when: undefined, + sessionTypes: [sessionType], + }); + } + } + return result; + } + + public async resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise { + const harness = this.findHarnessById(sessionType); + if (!harness || !harness.itemProvider) { + return this.promptsService.resolvePromptSlashCommand(name, sessionType, token); + } + + const items = await harness.itemProvider.provideChatSessionCustomizations(token); + const item = items?.find(cmd => cmd.name === name); + if (item) { + const parsedPromptFile = await this.promptsService.parseNew(item.uri, token); + return { + uri: item.uri, + type: item.type as PromptsType.prompt | PromptsType.skill, + name: item.name, + description: item.description, + userInvocable: parsedPromptFile.header?.userInvocable ?? true, + storage: item.storage ?? PromptsStorage.local, + when: undefined, + sessionTypes: [sessionType], + parsedPromptFile, + }; + } + return undefined; + } } // #endregion diff --git a/src/vs/workbench/contrib/chat/common/enablement.ts b/src/vs/workbench/contrib/chat/common/enablement.ts index a6e3b90ff7541..799e663b2a6a5 100644 --- a/src/vs/workbench/contrib/chat/common/enablement.ts +++ b/src/vs/workbench/contrib/chat/common/enablement.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IReader } from '../../../../base/common/observable.js'; +import { IReader, ITransaction } from '../../../../base/common/observable.js'; import { ObservableMemento, observableMemento } from '../../../../platform/observable/common/observableMemento.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -25,7 +25,7 @@ export function isContributionDisabled(state: ContributionEnablementState): bool export interface IEnablementModel { readEnabled(key: string, reader?: IReader): ContributionEnablementState; - setEnabled(key: string, state: ContributionEnablementState): void; + setEnabled(key: string, state: ContributionEnablementState, tx?: ITransaction): void; remove(key: string): void; } @@ -93,29 +93,29 @@ export class EnablementModel extends Disposable implements IEnablementModel { return ContributionEnablementState.EnabledProfile; } - setEnabled(key: string, state: ContributionEnablementState): void { + setEnabled(key: string, state: ContributionEnablementState, tx?: ITransaction): void { switch (state) { case ContributionEnablementState.EnabledProfile: { // Enabled-profile is the default: remove key from profile state, // and also remove any workspace override. - this._deleteFromMap(this._profileState, key); - this._deleteFromMap(this._workspaceState, key); + this._deleteFromMap(this._profileState, key, tx); + this._deleteFromMap(this._workspaceState, key, tx); break; } case ContributionEnablementState.DisabledProfile: { // Store disabled in profile, remove workspace override. - this._setInMap(this._profileState, key, false); - this._deleteFromMap(this._workspaceState, key); + this._setInMap(this._profileState, key, false, tx); + this._deleteFromMap(this._workspaceState, key, tx); break; } case ContributionEnablementState.EnabledWorkspace: { // Workspace override: always store explicitly. - this._setInMap(this._workspaceState, key, true); + this._setInMap(this._workspaceState, key, true, tx); break; } case ContributionEnablementState.DisabledWorkspace: { // Workspace override: always store explicitly. - this._setInMap(this._workspaceState, key, false); + this._setInMap(this._workspaceState, key, false, tx); break; } } @@ -126,23 +126,23 @@ export class EnablementModel extends Disposable implements IEnablementModel { this._deleteFromMap(this._workspaceState, key); } - private _setInMap(memento: ObservableMemento, key: string, value: boolean): void { + private _setInMap(memento: ObservableMemento, key: string, value: boolean, tx?: ITransaction): void { const current = memento.get(); if (current.get(key) === value) { return; } const next = new Map(current); next.set(key, value); - memento.set(next, undefined); + memento.set(next, tx); } - private _deleteFromMap(memento: ObservableMemento, key: string): void { + private _deleteFromMap(memento: ObservableMemento, key: string, tx?: ITransaction): void { const current = memento.get(); if (!current.has(key)) { return; } const next = new Map(current); next.delete(key); - memento.set(next, undefined); + memento.set(next, tx); } } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index bc52f6a253269..35b78139444a9 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -66,6 +66,13 @@ export function getCanonicalPluginCommandId(plugin: { readonly uri: URI }, comma return normalizedCommand; } + // When the skill name matches the plugin name, use just the plugin + // name so the user can invoke `/plugin-name` instead of the redundant + // `/plugin-name:plugin-name`. + if (prefix === normalizedCommand) { + return prefix; + } + return `${prefix}:${normalizedCommand}`; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 8a7e9b3012c90..0fa39870f848b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -128,7 +128,7 @@ export interface IPromptSourceFolder { */ export interface IResolvedPromptSourceFolder { readonly uri: URI; - readonly parent: URI; // matches the URI when no glob pattern is used + readonly searchRoot: URI; // matches the URI when no glob pattern is used readonly filePattern: string | undefined; // the part of the path with the glob pattern, or undefined if no glob pattern is used readonly source: PromptFolderSource; readonly storage: PromptsStorage.local | PromptsStorage.user; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 1b9e2005c6867..ed80727c467e8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -9,7 +9,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import * as nls from '../../../../../../nls.js'; import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js'; -import { basename, dirname, isEqual, isEqualOrParent, joinPath, extname } from '../../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; @@ -61,7 +61,7 @@ export class PromptFilesLocator { const userDataPromptsHome = this.userDataService.currentProfile.promptsHome; this.userDataFolder = { uri: userDataPromptsHome, - parent: userDataPromptsHome, + searchRoot: userDataPromptsHome, filePattern: undefined, source: PromptFileSource.UserData, storage: PromptsStorage.user, @@ -183,10 +183,10 @@ export class PromptFilesLocator { const paths = new ResourceSet(); - for (const { parent, filePattern } of absoluteLocations) { + for (const { searchRoot, filePattern } of absoluteLocations) { const files = (filePattern === undefined) - ? await this.resolveFilesAtLocation(parent, type, token) // if the location does not contain a glob pattern, resolve the location directly - : await this.searchFilesInLocation(parent, filePattern, token); + ? await this.resolveFilesAtLocation(searchRoot, type, token) // if the location does not contain a glob pattern, resolve the location directly + : await this.searchFilesInLocation(searchRoot, filePattern, token); for (const file of files) { if (getPromptFileType(file) === type) { paths.add(file); @@ -213,10 +213,10 @@ export class PromptFilesLocator { const updateExternalFolderWatchers = () => { externalFolderWatchers.clear(); for (const folder of parentFolders) { - if (!this.getWorkspaceFolder(folder.parent)) { + if (!this.getWorkspaceFolder(folder.searchRoot)) { // if the folder is not part of the workspace, we need to watch it const recursive = folder.filePattern !== undefined || type === PromptsType.instructions; // instructions can be in subfolders, so watch recursively - externalFolderWatchers.add(this.fileService.watch(folder.parent, { recursive, excludes: [] })); + externalFolderWatchers.add(this.fileService.watch(folder.searchRoot, { recursive, excludes: [] })); } } }; @@ -253,7 +253,7 @@ export class PromptFilesLocator { eventEmitter.fire(); return; } - if (parentFolders.some(folder => e.affects(folder.parent))) { + if (parentFolders.some(folder => e.affects(folder.searchRoot))) { eventEmitter.fire(); return; } @@ -281,16 +281,16 @@ export class PromptFilesLocator { // Convert to absolute locations with metadata const absoluteLocations = await this.toAbsoluteLocations(PromptsType.hook, allowedHookFolders); - // Deduplicate by parent URI, keeping the first occurrence + // Deduplicate by search root, keeping the first occurrence const seen = new ResourceSet(); const result: IResolvedPromptSourceFolder[] = []; for (const location of absoluteLocations) { // For hook configs, entries are directories unless the path ends with .json (specific file) // Default entries have filePattern, user entries don't but are still directories - // location.parent points to the directory in both cases, so we can just use that - if (!seen.has(location.parent)) { - seen.add(location.parent); - result.push({ ...location, uri: location.parent, filePattern: undefined }); + // searchRoot already points to the correct directory or specific file to use in both cases + if (!seen.has(location.searchRoot)) { + seen.add(location.searchRoot); + result.push({ ...location, uri: location.searchRoot, filePattern: undefined }); } } @@ -470,8 +470,8 @@ export class PromptFilesLocator { const uri = joinPath(userHome, configuredLocation.substring(2)); if (!seen.has(uri)) { seen.add(uri); - const { parent, filePattern } = getParentFolder(type, uri); - result.push({ uri, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); + const { searchRoot, filePattern } = resolveSearchLocation(type, uri); + result.push({ uri, searchRoot: searchRoot, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); } continue; } @@ -486,16 +486,16 @@ export class PromptFilesLocator { } if (!seen.has(uri)) { seen.add(uri); - const { parent, filePattern } = getParentFolder(type, uri); - result.push({ uri, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); + const { searchRoot, filePattern } = resolveSearchLocation(type, uri); + result.push({ uri, searchRoot: searchRoot, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); } } else { for (const folder of rootFolders) { const absolutePath = joinPath(folder, configuredLocation); if (!seen.has(absolutePath)) { seen.add(absolutePath); - const { parent, filePattern } = getParentFolder(type, absolutePath); - result.push({ uri: absolutePath, parent, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); + const { searchRoot, filePattern } = resolveSearchLocation(type, absolutePath); + result.push({ uri: absolutePath, searchRoot: searchRoot, filePattern, source: sourceFolder.source, storage: sourceFolder.storage, displayPath: configuredLocation, isDefault }); } } } @@ -852,33 +852,31 @@ export function isValidGlob(pattern: string): boolean { return false; } -interface IParentFolderResult { - readonly parent: URI; +interface ISearchLocationResult { + readonly searchRoot: URI; readonly filePattern?: string; } /** - * Finds the first parent of the provided location that does not contain a `glob pattern`. + * Resolves the search root and optional file pattern for the provided location. + * For paths with glob patterns, finds the deepest non-glob ancestor directory. * - * Asumes that the location that is provided has a valid path (is abstract) + * Assumes that the location that is provided has a valid path (is abstract) * * ## Examples * * ```typescript * assert.strictDeepEqual( - * getParentFolder(PromptsType.prompt, URI.file('/home/user/{folder1,folder2}/file.md')), - * { parent: URI.file('/home/user'), filePattern: '{folder1,folder2}/file.md' }, - * 'Must find correct non-glob parent dirname.', + * resolveSearchLocation(PromptsType.prompt, URI.file('/home/user/{folder1,folder2}/file.md')), + * { searchRoot: URI.file('/home/user'), filePattern: '{folder1,folder2}/file.md' }, + * 'Must find correct non-glob search root.', * ); * ``` */ -function getParentFolder(type: PromptsType, location: URI): IParentFolderResult { - if (type === PromptsType.hook && extname(location) === '.json') { - location = dirname(location); - } +function resolveSearchLocation(type: PromptsType, location: URI): ISearchLocationResult { if (type !== PromptsType.instructions && type !== PromptsType.prompt) { // only instructions and prompts support glob patterns, so we can return the location as is - return { parent: location }; + return { searchRoot: location }; } const segments = location.path.split('/'); @@ -889,16 +887,16 @@ function getParentFolder(type: PromptsType, location: URI): IParentFolderResult if (i === segments.length) { // the path does not contain a glob pattern, so we can // just find all prompt files in the provided location - return { parent: location }; + return { searchRoot: location }; } const parent = location.with({ path: segments.slice(0, i).join('/') }); if (i === segments.length - 1 && segments[i] === '*' || segments[i] === ``) { - return { parent }; + return { searchRoot: parent }; } // the path contains a glob pattern, so we search in last folder that does not contain a glob pattern return { - parent, + searchRoot: parent, filePattern: segments.slice(i).join('/') }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/createPluginAction.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/createPluginAction.test.ts index 8bdbac583fbb6..df1f780d155c1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/createPluginAction.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/createPluginAction.test.ts @@ -473,7 +473,7 @@ suite('writePluginToDisk', () => { collection: { id: 'col1', label: 'Test Collection', - presentation: { order: McpCollectionSortOrder.User }, + order: McpCollectionSortOrder.User, } as IResourceTreeItem['mcpServer'] extends undefined ? never : NonNullable['collection'], definition: { id: 'def1', diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 68e7f362bf8e8..e13bdbb7cb532 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -15,7 +15,7 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { timeout } from '../../../../../../base/common/async.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { AgentHostSessionConfigBranchNameHintKey, IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import { isSessionAction, type ActionEnvelope, type INotification, type SessionAction, type TerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { CustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -50,6 +50,7 @@ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; // ---- Mock agent host service ------------------------------------------------ @@ -1839,7 +1840,7 @@ suite('AgentHostChatContribution', () => { await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.deepStrictEqual(agentHostService.createSessionCalls[0].config, { ...config, [AgentHostSessionConfigBranchNameHintKey]: 'add-agent-host-session-configuration-flow' }); + assert.deepStrictEqual(agentHostService.createSessionCalls[0].config, { ...config, [SessionConfigKey.BranchNameHint]: 'add-agent-host-session-configuration-flow' }); })); test('handler derives deterministic branch name hints from first request text', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index 19c4aac1883a9..fb38770250231 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -198,6 +198,7 @@ suite('aiCustomizationListWidget', () => { setActiveHarness: () => { }, getStorageSourceFilter: () => ({ sources: [] }), getActiveDescriptor: () => descriptor, + findHarnessById: (id) => id === descriptor.id ? descriptor : undefined, registerExternalHarness: () => ({ dispose() { } }), }); diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index fc3fcd88f280d..4eb4cdffc88f3 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -8,10 +8,12 @@ import { Emitter } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CustomizationHarness, CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, getActiveHarnessSlashCommands, ICustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, ICustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { SessionType } from '../../common/chatSessionsService.js'; +import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; suite('CustomizationHarnessService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -20,10 +22,39 @@ suite('CustomizationHarnessService', () => { if (harnesses.length === 0) { harnesses = [createVSCodeHarnessDescriptor([PromptsStorage.extension])]; } - return new CustomizationHarnessServiceBase(harnesses, harnesses[0].id); + const promptsService: IPromptsService = new MockPromptsService(); + const service = new CustomizationHarnessServiceBase(harnesses, harnesses[0].id, promptsService); + store.add(service); + return service; } suite('registerExternalHarness', () => { + test('forwards item provider changes via onDidChangeSlashCommands with sessionType', () => { + const service = createService(); + const emitter = new Emitter(); + store.add(emitter); + const harnessId = 'test-harness'; + const externalDescriptor: IHarnessDescriptor = { + id: harnessId, + label: 'Test Harness', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + store.add(service.registerExternalHarness(externalDescriptor)); + + let firedSessionType: string | undefined; + const listener = store.add(service.onDidChangeSlashCommands(e => firedSessionType = e.sessionType)); + store.add(listener); + + emitter.fire(); + assert.strictEqual(firedSessionType, harnessId); + }); + test('adds harness to available list', () => { const service = createService(); assert.strictEqual(service.availableHarnesses.get().length, 1); @@ -90,7 +121,7 @@ suite('CustomizationHarnessService', () => { assert.strictEqual(service.activeHarness.get(), 'test-ext'); reg.dispose(); - assert.strictEqual(service.activeHarness.get(), CustomizationHarness.VSCode); + assert.strictEqual(service.activeHarness.get(), SessionType.Local); }); test('allows switching to external harness', () => { @@ -299,12 +330,16 @@ suite('CustomizationHarnessService', () => { }); }); - suite('getActiveHarnessSlashCommands', () => { + suite('getSlashCommands', () => { test('uses the active harness provider for prompt and skill items', async () => { + + + const testSessionType = 'test-session-type'; + const emitter = new Emitter(); store.add(emitter); const service = createService({ - id: 'test-ext', + id: testSessionType, label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), @@ -319,14 +354,7 @@ suite('CustomizationHarnessService', () => { }, }); - const promptsService: Pick = { - getPromptSlashCommands: async () => { - assert.fail('expected harness itemProvider to be used'); - }, - isValidSlashCommandName: name => name !== 'disabled', - }; - - const commands = await getActiveHarnessSlashCommands(service, promptsService, CancellationToken.None); + const commands = await service.getSlashCommands(testSessionType, CancellationToken.None); assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type })), [ { name: 'fix', type: PromptsType.prompt }, { name: 'lint', type: PromptsType.skill }, @@ -334,20 +362,32 @@ suite('CustomizationHarnessService', () => { }); test('falls back to promptsService when the active harness has no provider', async () => { - const service = createService(); - const promptsService: Pick = { - getPromptSlashCommands: async () => ([ - { uri: URI.parse('file:///workspace/.github/prompts/explain.prompt.md'), name: 'explain', type: PromptsType.prompt, storage: PromptsStorage.local, userInvocable: false, when: undefined, sessionTypes: ['chat'] }, - { uri: URI.parse('file:///workspace/.github/skills/review/SKILL.md'), name: 'review', type: PromptsType.skill, storage: PromptsStorage.user, userInvocable: true, when: undefined }, - ]), - isValidSlashCommandName: () => true, - }; - const commands = await getActiveHarnessSlashCommands(service, promptsService, CancellationToken.None); - assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [ - { name: 'explain', type: PromptsType.prompt, userInvocable: false, sessionTypes: ['chat'] }, - { name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined }, - ]); + const testSessionType = 'test-session-type'; + const promptsService = new class extends MockPromptsService { + override async getPromptSlashCommands() { + return [ + { uri: URI.parse('file:///workspace/.github/prompts/explain.prompt.md'), name: 'explain', type: PromptsType.prompt, storage: PromptsStorage.local, userInvocable: false, when: undefined, sessionTypes: [testSessionType] }, + { uri: URI.parse('file:///workspace/.github/skills/review/SKILL.md'), name: 'review', type: PromptsType.skill, storage: PromptsStorage.user, userInvocable: true, when: undefined }, + ]; + } + override isValidSlashCommandName() { return true; } + }; + const service = new CustomizationHarnessServiceBase([createVSCodeHarnessDescriptor([PromptsStorage.extension])], SessionType.Local, promptsService); + store.add(service); + { + const commands = await service.getSlashCommands(testSessionType, CancellationToken.None); + assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [ + { name: 'explain', type: PromptsType.prompt, userInvocable: false, sessionTypes: [testSessionType] }, + { name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined }, + ]); + } + { + const commands = await service.getSlashCommands(SessionType.Local, CancellationToken.None); + assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [ + { name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined }, + ]); + } }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index e5c41a4623d85..c4e34dcec8916 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -45,7 +45,6 @@ export class MockPromptsService implements IPromptsService { isValidSlashCommandName(_command: string): boolean { return false; } // eslint-disable-next-line @typescript-eslint/no-explicit-any resolvePromptSlashCommand(command: string, _sessionType: string | undefined, _token: CancellationToken): Promise { throw new Error('Not implemented'); } - get onDidChangeSlashCommands(): Event { throw new Error('Not implemented'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any getPromptSlashCommands(_token: CancellationToken): Promise { throw new Error('Not implemented'); } getPromptSlashCommandName(uri: URI, _token: CancellationToken): Promise { throw new Error('Not implemented'); } @@ -72,4 +71,5 @@ export class MockPromptsService implements IPromptsService { onDidChangePromptFiles: Event = Event.None; onDidChangeSkills: Event = Event.None; onDidChangeHooks: Event = Event.None; + onDidChangeSlashCommands: Event = Event.None; } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts index deb5e1dc13916..ad91e945248d4 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts @@ -2871,6 +2871,67 @@ suite('PromptFilesLocator', () => { }); }); + suite('listFiles with PromptsType.hook', () => { + testT('only returns targeted json files, not sibling json files', async () => { + configValues[PromptsConfig.HOOKS_LOCATION_KEY] = { + '.claude/settings.json': true, + '.claude/settings.local.json': true, + '~/.claude/settings.json': true, + '.github/hooks': true, + '~/.copilot/hooks': true, + }; + setWorkspaceFolders(['/Users/legomushroom/repos/vscode']); + await mockFiles(fileService, [ + // targeted files that should be found + { path: '/Users/legomushroom/repos/vscode/.claude/settings.json', contents: ['{}'] }, + { path: '/Users/legomushroom/repos/vscode/.claude/settings.local.json', contents: ['{}'] }, + // sibling files in .claude/ that should NOT be found + { path: '/Users/legomushroom/repos/vscode/.claude/config.json', contents: ['{}'] }, + { path: '/Users/legomushroom/repos/vscode/.claude/stats-cache.json', contents: ['{}'] }, + // hook directory files that should be found + { path: '/Users/legomushroom/repos/vscode/.github/hooks/pre-commit.json', contents: ['{}'] }, + ]); + const locator = instantiationService.createInstance(PromptFilesLocator); + + const files = await locator.listFiles(PromptsType.hook, PromptsStorage.local, CancellationToken.None); + assert.deepStrictEqual( + files.map(f => f.path).sort(), + [ + '/Users/legomushroom/repos/vscode/.claude/settings.json', + '/Users/legomushroom/repos/vscode/.claude/settings.local.json', + '/Users/legomushroom/repos/vscode/.github/hooks/pre-commit.json', + ], + ); + }); + + testT('returns hook files from user home specific json paths', async () => { + configValues[PromptsConfig.HOOKS_LOCATION_KEY] = { + '~/.claude/settings.json': true, + '~/.copilot/hooks': true, + }; + setWorkspaceFolders(['/Users/legomushroom/repos/vscode']); + await mockFiles(fileService, [ + // targeted user file + { path: '/Users/legomushroom/.claude/settings.json', contents: ['{}'] }, + // sibling files that should NOT be found + { path: '/Users/legomushroom/.claude/config.json', contents: ['{}'] }, + { path: '/Users/legomushroom/.claude/stats-cache.json', contents: ['{}'] }, + // hook directory files + { path: '/Users/legomushroom/.copilot/hooks/my-hook.json', contents: ['{}'] }, + ]); + const locator = instantiationService.createInstance(PromptFilesLocator); + + const files = await locator.listFiles(PromptsType.hook, PromptsStorage.user, CancellationToken.None); + assert.deepStrictEqual( + files.map(f => f.path).sort(), + [ + '/Users/legomushroom/.claude/settings.json', + '/Users/legomushroom/.copilot/hooks/my-hook.json', + ], + ); + }); + }); + suite('getSourceDescription', () => { test('returns descriptions for all known folder sources', () => { const folderSources: PromptFileSource[] = [ diff --git a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts index ca5f2458fb0b6..06e56c89f9b12 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts @@ -23,6 +23,7 @@ import { mcpDiscoveryRegistry } from '../common/discovery/mcpDiscovery.js'; import { RemoteNativeMpcDiscovery } from '../common/discovery/nativeMcpRemoteDiscovery.js'; import { PluginMcpDiscovery } from '../common/discovery/pluginMcpDiscovery.js'; import { CursorWorkspaceMcpDiscoveryAdapter } from '../common/discovery/workspaceMcpDiscoveryAdapter.js'; +import { WorkspaceDotMcpDiscovery } from '../common/discovery/workspaceDotMcpDiscovery.js'; import { McpCommandIds } from '../common/mcpCommandIds.js'; import { mcpServerSchema } from '../common/mcpConfiguration.js'; import { McpContextKeysController } from '../common/mcpContextKeys.js'; @@ -62,6 +63,7 @@ mcpDiscoveryRegistry.register(new SyncDescriptor(RemoteNativeMpcDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(InstalledMcpServersDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(ExtensionMcpDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(CursorWorkspaceMcpDiscoveryAdapter)); +mcpDiscoveryRegistry.register(new SyncDescriptor(WorkspaceDotMcpDiscovery)); mcpDiscoveryRegistry.register(new SyncDescriptor(PluginMcpDiscovery)); registerWorkbenchContribution2('mcpDiscovery', McpDiscovery, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index cd1acdfbb555f..cd430359345fc 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -53,6 +53,7 @@ import { ChatViewId, IChatWidgetService } from '../../chat/browser/chat.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { IChatElicitationRequest, IChatToolInvocation } from '../../chat/common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../../chat/common/constants.js'; +import { ContributionEnablementState, isContributionDisabled } from '../../chat/common/enablement.js'; import { ILanguageModelsService } from '../../chat/common/languageModels.js'; import { ILanguageModelToolsService } from '../../chat/common/tools/languageModelToolsService.js'; import { extensionsFilterSubMenu, IExtensionsWorkbenchService, VIEWLET_ID } from '../../extensions/common/extensions.js'; @@ -60,11 +61,10 @@ import { TEXT_FILE_EDITOR_ID } from '../../files/common/files.js'; import { McpCommandIds } from '../common/mcpCommandIds.js'; import { McpContextKeys } from '../common/mcpContextKeys.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; -import { HasInstalledMcpServersContext, IMcpSamplingService, IMcpServer, IMcpServerStartOpts, IMcpService, IMcpWorkbenchService, InstalledMcpServersViewId, LazyCollectionState, McpCapability, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, mcpPromptPrefix, McpServerCacheState, McpStartServerInteraction } from '../common/mcpTypes.js'; +import { HasInstalledMcpServersContext, IMcpSamplingService, IMcpServer, IMcpServerStartOpts, IMcpService, InstalledMcpServersViewId, LazyCollectionState, McpCapability, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, mcpPromptPrefix, McpServerCacheState, McpStartServerInteraction } from '../common/mcpTypes.js'; +import { startServerAndWaitForLiveTools } from '../common/mcpTypesUtils.js'; import { McpAddConfigurationCommand, McpInstallFromManifestCommand } from './mcpCommandsAddConfiguration.js'; import { McpResourceQuickAccess, McpResourceQuickPick } from './mcpResourceQuickAccess.js'; -import { startServerAndWaitForLiveTools } from '../common/mcpTypesUtils.js'; -import { isContributionDisabled } from '../../chat/common/enablement.js'; import './media/mcpServerAction.css'; import { openPanelChatAndGetWidget } from './openPanelChatAndGetWidget.js'; @@ -104,7 +104,6 @@ export class ListMcpServerCommand extends Action2 { const mcpService = accessor.get(IMcpService); const commandService = accessor.get(ICommandService); const quickInput = accessor.get(IQuickInputService); - const mcpWorkbenchService = accessor.get(IMcpWorkbenchService); type ItemType = { id: string } & IQuickPickItem; @@ -117,7 +116,7 @@ export class ListMcpServerCommand extends Action2 { store.add(pick); store.add(autorun(reader => { - const servers = groupBy(mcpService.servers.read(reader).slice().sort((a, b) => (a.collection.presentation?.order || 0) - (b.collection.presentation?.order || 0)), s => s.collection.id); + const servers = groupBy(mcpService.servers.read(reader).slice().sort((a, b) => a.collection.order - b.collection.order), s => s.collection.id); const firstRun = pick.items.length === 0; pick.items = [ { id: '$add', label: localize('mcp.addServer', 'Add Server'), description: localize('mcp.addServer.description', 'Add a new server configuration'), alwaysShow: true, iconClass: ThemeIcon.asClassName(Codicon.add) }, @@ -159,21 +158,13 @@ export class ListMcpServerCommand extends Action2 { } else if (picked.id === '$add') { commandService.executeCommand(McpCommandIds.AddConfiguration); } else { - const server = mcpService.servers.get().find(s => s.definition.id === picked.id); - if (server && isContributionDisabled(server.enablement.get())) { - const workbenchServer = mcpWorkbenchService.local.find(s => s.id === picked.id); - if (workbenchServer) { - mcpWorkbenchService.open(workbenchServer); - } - } else { - commandService.executeCommand(McpCommandIds.ServerOptions, picked.id); - } + commandService.executeCommand(McpCommandIds.ServerOptions, picked.id); } } } interface ActionItem extends IQuickPickItem { - action: 'start' | 'stop' | 'restart' | 'showOutput' | 'config' | 'configSampling' | 'samplingLog' | 'resources'; + action: 'start' | 'stop' | 'restart' | 'showOutput' | 'config' | 'configSampling' | 'samplingLog' | 'resources' | 'enable'; } interface AuthActionItem extends IQuickPickItem { @@ -249,11 +240,17 @@ export class McpServerOptionsCommand extends Action2 { const items: (ActionItem | AuthActionItem | IQuickPickSeparator)[] = []; const serverState = server.connectionState.get(); + const disabled = isContributionDisabled(server.enablement.get()); items.push({ type: 'separator', label: localize('mcp.actions.status', 'Status') }); - // Only show start when server is stopped or in error state - if (McpConnectionState.canBeStarted(serverState.state)) { + if (disabled) { + items.push({ + label: localize('mcp.enableWorkspace', 'Enable Server (Workspace)'), + action: 'enable' + }); + } else if (McpConnectionState.canBeStarted(serverState.state)) { + // Only show start when server is stopped or in error state items.push({ label: localize('mcp.start', 'Start Server'), action: 'start' @@ -320,6 +317,9 @@ export class McpServerOptionsCommand extends Action2 { } switch (pick.action) { + case 'enable': + mcpService.enablementModel.setEnabled(server.definition.id, ContributionEnablementState.EnabledWorkspace); + break; case 'start': await server.start({ promptType: 'all-untrusted' }); server.showOutput(); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts index 285e27f037057..9848b1720ceee 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts @@ -15,19 +15,26 @@ import { CodeLens, CodeLensList, CodeLensProvider, InlayHint, InlayHintList } fr import { ITextModel } from '../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; import { localize } from '../../../../nls.js'; +import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; +import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js'; import { McpCommandIds } from '../common/mcpCommandIds.js'; import { mcpConfigurationSection } from '../common/mcpConfiguration.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; +import { isContributionDisabled } from '../../chat/common/enablement.js'; import { IMcpConfigPath, IMcpServerStartOpts, IMcpService, IMcpWorkbenchService, McpConnectionState } from '../common/mcpTypes.js'; const diagnosticOwner = 'vscode.mcp'; +type ConfigDescriptor = Pick & { + serversKey?: string; +}; + export class McpLanguageFeatures extends Disposable implements IWorkbenchContribution { - private readonly _cachedMcpSection = this._register(new MutableDisposable<{ model: ITextModel; inConfig: IMcpConfigPath; tree: Node } & IDisposable>()); + private readonly _cachedMcpSection = this._register(new MutableDisposable<{ model: ITextModel; inConfig: ConfigDescriptor; tree: Node } & IDisposable>()); constructor( @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, @@ -41,6 +48,7 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib const patterns = [ { pattern: '**/mcp.json' }, + { pattern: '**/.mcp.json' }, { pattern: '**/workspace.json' }, ]; @@ -64,7 +72,9 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib } const uri = model.uri; - const inConfig = await this._mcpWorkbenchService.getMcpConfigPath(model.uri); + const inConfig: ConfigDescriptor | undefined = uri.path.endsWith('/.mcp.json') + ? { scope: StorageScope.WORKSPACE, target: ConfigurationTarget.WORKSPACE_FOLDER, serversKey: 'mcpServers' } + : await this._mcpWorkbenchService.getMcpConfigPath(model.uri); if (!inConfig) { return undefined; } @@ -88,8 +98,9 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib }; } - private _addDiagnostics(tm: ITextModel, value: string, tree: Node, inConfig: IMcpConfigPath) { - const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, 'servers'] : ['servers']); + private _addDiagnostics(tm: ITextModel, value: string, tree: Node, inConfig: ConfigDescriptor) { + const serversKey = inConfig.serversKey ?? 'servers'; + const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, serversKey] : [serversKey]); if (!serversNode) { return; } @@ -145,7 +156,8 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib } const { tree, inConfig } = parsed; - const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, 'servers'] : ['servers']); + const serversKey = inConfig.serversKey ?? 'servers'; + const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, serversKey] : [serversKey]); if (!serversNode) { return undefined; } @@ -176,6 +188,19 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib } const range = Range.fromPositions(model.getPositionAt(node.children[0].offset)); + + if (isContributionDisabled(read(server.enablement))) { + lenses.push({ + range, + command: { + id: McpCommandIds.ServerOptions, + title: '$(circle-slash) ' + localize('server.disabled', 'Disabled'), + arguments: [server.definition.id], + }, + }); + continue; + } + const canDebug = !!server.readDefinitions().get().server?.devMode?.debug; const state = read(server.connectionState).state; switch (state) { @@ -338,7 +363,7 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib const inputs = await this._mcpRegistry.getSavedInputs(inConfig.scope); const hints: InlayHint[] = []; - const serversNode = findNodeAtLocation(mcpSection, ['servers']); + const serversNode = findNodeAtLocation(mcpSection, [inConfig.serversKey ?? 'servers']); if (serversNode) { annotateServers(serversNode); } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts index 004919f456fb2..925edaf562f78 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/extensionMcpDiscovery.ts @@ -15,7 +15,7 @@ import { IExtensionService } from '../../../../services/extensions/common/extens import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; import { mcpActivationEvent, mcpContributionPoint } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { extensionPrefixedIdentifier, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; +import { extensionPrefixedIdentifier, McpCollectionSortOrder, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; const cacheKey = 'mcp.extCachedServers'; @@ -122,6 +122,7 @@ export class ExtensionMcpDiscovery extends Disposable implements IMcpDiscovery { trustBehavior: McpServerTrust.Kind.Trusted, scope: StorageScope.WORKSPACE, configTarget: ConfigurationTarget.USER, + order: McpCollectionSortOrder.Extension, serverDefinitions: observableValue(this, serverDefs?.map(McpServerDefinition.fromSerialized) || []), lazy: { isCached: !!serverDefs, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts index 3208a2541f82e..5731e1e3b0ec3 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts @@ -18,7 +18,7 @@ import { IWorkbenchLocalMcpServer } from '../../../../services/mcp/common/mcpWor import { getMcpServerMapping } from '../mcpConfigFileUtils.js'; import { mcpConfigurationSection } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { IMcpConfigPath, IMcpWorkbenchService, McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; +import { IMcpConfigPath, IMcpWorkbenchService, McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; interface CollectionState extends IDisposable { @@ -131,8 +131,8 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc const newCollection: McpCollectionDefinition = { id, label: mcpConfigPath?.label ?? '', + order: mcpConfigPath?.order ?? McpCollectionSortOrder.User, presentation: { - order: serverDefinitions[0]?.presentation?.order, origin: mcpConfigPath?.uri, }, remoteAuthority: mcpConfigPath?.remoteAuthority ?? null, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts index 79f0563403bd5..d17fa5e9b22c6 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts @@ -151,9 +151,9 @@ export abstract class NativeFilesystemMcpDiscovery extends FilesystemMcpDiscover scope: StorageScope.PROFILE, trustBehavior: McpServerTrust.Kind.TrustedOnNonce, serverDefinitions: observableValue(this, []), + order: adapter.order + (adapter.remoteAuthority ? McpCollectionSortOrder.RemoteBoost : 0), presentation: { origin: file, - order: adapter.order + (adapter.remoteAuthority ? McpCollectionSortOrder.RemoteBoost : 0), }, }; diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 522a1be3ec646..28ae133110ff7 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -77,9 +77,9 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { trustBehavior: McpServerTrust.Kind.Trusted, serverDefinitions: plugin.mcpServerDefinitions.map(defs => defs.map(d => this._toServerDefinition(collectionId, d)).filter(isDefined)), + order: McpCollectionSortOrder.Plugin, presentation: { origin: manifestURI, - order: McpCollectionSortOrder.Plugin, }, }); } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts new file mode 100644 index 0000000000000..d7e21890057c3 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/discovery/workspaceDotMcpDiscovery.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; +import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; +import { IMcpRegistry } from '../mcpRegistryTypes.js'; +import { McpCollectionSortOrder, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; +import { IMcpDiscovery } from './mcpDiscovery.js'; +import { claudeConfigToServerDefinition } from './nativeMcpDiscoveryAdapters.js'; + +/** + * Discovers MCP servers defined in `.mcp.json` files at workspace folder roots. + * Uses the Claude-style format: `{ "mcpServers": { ... } }`. + */ +export class WorkspaceDotMcpDiscovery extends Disposable implements IMcpDiscovery { + readonly fromGallery = false; + + private readonly _collections = this._register(new DisposableMap()); + + constructor( + @IFileService private readonly _fileService: IFileService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + ) { + super(); + } + + start(): void { + this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(e => { + for (const removed of e.removed) { + this._collections.deleteAndDispose(removed.uri.toString()); + } + for (const added of e.added) { + this._watchFolder(added); + } + })); + + for (const folder of this._workspaceContextService.getWorkspace().folders) { + this._watchFolder(folder); + } + } + + private _watchFolder(folder: IWorkspaceFolder) { + const configFile = joinPath(folder.uri, '.mcp.json'); + const collectionId = `workspace-dot-mcp.${folder.index}`; + const serverDefinitions = observableValue(this, []); + + const collection = { + id: collectionId, + label: `${folder.name}/.mcp.json`, + remoteAuthority: this._remoteAgentService.getConnection()?.remoteAuthority || null, + scope: StorageScope.WORKSPACE, + trustBehavior: McpServerTrust.Kind.TrustedOnNonce as const, + serverDefinitions, + configTarget: ConfigurationTarget.WORKSPACE_FOLDER, + order: McpCollectionSortOrder.WorkspaceFolder + 1, + presentation: { + origin: configFile, + }, + }; + + const store = new DisposableStore(); + const collectionRegistration = store.add(new MutableDisposable()); + + const updateFile = async () => { + let definitions: McpServerDefinition[] = []; + try { + const contents = await this._fileService.readFile(configFile); + const defs = await claudeConfigToServerDefinition(collectionId, contents.value, folder.uri); + if (defs) { + for (const d of defs) { + d.roots = [folder.uri]; + } + definitions = defs; + } + } catch { + // file doesn't exist or is malformed + } + + if (!definitions.length) { + collectionRegistration.clear(); + } else { + serverDefinitions.set(definitions, undefined); + if (!collectionRegistration.value) { + collectionRegistration.value = this._mcpRegistry.registerCollection(collection); + } + } + }; + + const throttler = store.add(new RunOnceScheduler(updateFile, 500)); + const watcher = store.add(this._fileService.createWatcher(configFile, { recursive: false, excludes: [] })); + store.add(watcher.onDidChange(() => throttler.schedule())); + updateFile(); + + this._collections.set(folder.uri.toString(), store); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts b/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts index b1ade8f9a866f..b6f97353a5569 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/workspaceMcpDiscoveryAdapter.ts @@ -57,9 +57,9 @@ export class CursorWorkspaceMcpDiscoveryAdapter extends FilesystemMcpDiscovery i trustBehavior: McpServerTrust.Kind.TrustedOnNonce, serverDefinitions: observableValue(this, []), configTarget: ConfigurationTarget.WORKSPACE_FOLDER, + order: McpCollectionSortOrder.WorkspaceFolder + 1, presentation: { origin: configFile, - order: McpCollectionSortOrder.WorkspaceFolder + 1, }, }; diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index 8b1559c8f8454..2261258525ebc 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -54,6 +54,12 @@ export const discoverySourceSettingsLabel: Record = { export const mcpConfigurationSection = 'mcp'; export const mcpDiscoverySection = 'chat.mcp.discovery.enabled'; export const mcpServerSamplingSection = 'chat.mcp.serverSampling'; +export const mcpServerCollisionBehaviorSection = 'chat.mcp.collisionBehavior'; + +export const enum McpCollisionBehavior { + Disable = 'disable', + Suffix = 'suffix', +} export interface IMcpServerSamplingConfiguration { allowedDuringChat?: boolean; diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index d9ff9dad84aa0..634e86531b605 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -120,7 +120,7 @@ export class McpRegistry extends Disposable implements IMcpRegistry { this._collections.set(currentCollections.map(c => c === toReplace ? collection : c), undefined); } else { this._collections.set([...currentCollections, collection] - .sort((a, b) => (a.presentation?.order || 0) - (b.presentation?.order || 0)), undefined); + .sort((a, b) => a.order - b.order), undefined); } return { diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts index 940704772ae02..4fa9f3430ab26 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -6,13 +6,15 @@ import { RunOnceScheduler } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { autorun, derived, IObservable, IReader, ISettableObservable, ITransaction, observableValue, transaction } from '../../../../base/common/observable.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { mcpAutoStartConfig, McpAutoStartValue } from '../../../../platform/mcp/common/mcpManagement.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; -import { EnablementModel, isContributionEnabled } from '../../chat/common/enablement.js'; +import { ContributionEnablementState, EnablementModel, IEnablementModel, isContributionEnabled } from '../../chat/common/enablement.js'; +import { McpCollisionBehavior, mcpServerCollisionBehaviorSection } from './mcpConfiguration.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; import { McpServer, McpServerMetadataCache } from './mcpServer.js'; import { IAutostartResult, IMcpServer, IMcpService, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, McpServerCacheState, McpServerDefinition, McpStartServerInteraction, McpToolName, UserInteractionRequiredError } from './mcpTypes.js'; @@ -30,7 +32,7 @@ export class McpService extends Disposable implements IMcpService { public get lazyCollectionState() { return this._mcpRegistry.lazyCollectionState; } - public readonly enablementModel: EnablementModel; + public readonly enablementModel: McpCollisionEnablementModel; protected readonly userCache: McpServerMetadataCache; protected readonly workspaceCache: McpServerMetadataCache; @@ -44,7 +46,9 @@ export class McpService extends Disposable implements IMcpService { ) { super(); - this.enablementModel = this._register(new EnablementModel('mcp.enablement', storageService)); + const baseEnablement = this._register(new EnablementModel('mcp.enablement', storageService)); + const collisionBehavior = observableConfigValue(mcpServerCollisionBehaviorSection, McpCollisionBehavior.Disable, configurationService); + this.enablementModel = new McpCollisionEnablementModel(baseEnablement, this._mcpRegistry, collisionBehavior); this.userCache = this._register(_instantiationService.createInstance(McpServerMetadataCache, StorageScope.PROFILE)); this.workspaceCache = this._register(_instantiationService.createInstance(McpServerMetadataCache, StorageScope.WORKSPACE)); @@ -247,3 +251,119 @@ class McpPrefixGenerator { return toolPrefix; } } + +/** + * Wraps an {@link EnablementModel} with collision-aware defaults and + * mutual-exclusion logic for MCP servers with the same label. + * + * When collision behavior is `disable`: + * - Servers whose label collides with a higher-priority server are disabled + * by default (unless the user has explicitly toggled them). + * - Enabling a colliding server disables all other servers with the same label. + * + * When collision behavior is `suffix`, delegates everything unchanged. + */ +export class McpCollisionEnablementModel implements IEnablementModel { + + /** + * For each server definition ID, the list of all definition IDs that share + * the same (case-insensitive) label, in priority order (lowest collection + * order first). Empty when collision behavior is `suffix`. + */ + private readonly _collisionGroups: IObservable>; + + constructor( + private readonly _base: EnablementModel, + registry: IMcpRegistry, + collisionBehavior: IObservable, + ) { + this._collisionGroups = derived(reader => { + if (collisionBehavior.read(reader) !== McpCollisionBehavior.Disable) { + return new Map(); + } + + const collections = registry.collections.read(reader); + // label → list of server definition IDs, in priority order + const labelToIds = new Map(); + for (const collection of collections) { + for (const server of collection.serverDefinitions.read(reader)) { + const key = server.label.toLowerCase(); + let ids = labelToIds.get(key); + if (!ids) { + ids = []; + labelToIds.set(key, ids); + } + ids.push(server.id); + } + } + + const groups = new Map(); + for (const ids of labelToIds.values()) { + if (ids.length < 2) { + continue; + } + for (const id of ids) { + groups.set(id, ids); + } + } + + return groups; + }); + } + + readEnabled(key: string, reader?: IReader): ContributionEnablementState { + const baseState = this._base.readEnabled(key, reader); + + if (!isContributionEnabled(baseState)) { + return baseState; + } + + const group = this._collisionGroups.read(reader).get(key); + if (!group) { + return baseState; + } + + // This server is enabled and in a collision group. Only allow it + // to stay enabled if no higher-priority server in the group is + // also enabled. + for (const otherId of group) { + if (otherId === key) { + return baseState; + } + if (isContributionEnabled(this._base.readEnabled(otherId, reader))) { + return ContributionEnablementState.DisabledProfile; + } + } + return baseState; + } + + setEnabled(key: string, state: ContributionEnablementState, tx?: ITransaction): void { + const isEnabling = state === ContributionEnablementState.EnabledProfile || state === ContributionEnablementState.EnabledWorkspace; + const group = isEnabling ? this._collisionGroups.get().get(key) : undefined; + + if (!group) { + this._base.setEnabled(key, state, tx); + return; + } + + // Enabling a colliding server: disable all others in the group atomically + const updateGroup = (innerTx: ITransaction) => { + this._base.setEnabled(key, state, innerTx); + for (const otherId of group) { + if (otherId !== key) { + this._base.setEnabled(otherId, ContributionEnablementState.DisabledWorkspace, innerTx); + } + } + }; + + if (tx) { + updateGroup(tx); + } else { + transaction(innerTx => updateGroup(innerTx)); + } + } + + remove(key: string): void { + this._base.remove(key); + } +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 8868b1de415a9..f4fa7a32d9673 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -81,9 +81,10 @@ export interface McpCollectionDefinition { readonly source?: IWorkbenchMcpServer | ExtensionIdentifier; + /** Sort order of the collection. Lower values have higher priority. */ + readonly order: number; + readonly presentation?: { - /** Sort order of the collection. */ - readonly order?: number; /** Place where this collection is configured, used in workspace trust prompts and "show config" */ readonly origin?: URI; }; @@ -279,6 +280,7 @@ export const IMcpService = createDecorator('IMcpService'); export interface McpCollectionReference { id: string; label: string; + order: number; presentation?: McpCollectionDefinition['presentation']; } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts index 6a41246aa967d..a655ef1bf1483 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts @@ -367,7 +367,7 @@ function createServer( let startCalls = 0; return { - collection: { id: collectionId, label: collectionId }, + collection: { id: collectionId, label: collectionId, order: 0 }, definition: { id: definitionId, label: definitionId }, connection: observableValue(owner, undefined), connectionState, @@ -406,7 +406,7 @@ function createNeverStartingServer( let startBehavior: 'hang' | 'succeed' = 'hang'; const result: IMcpServer & { startCalls: number; startBehavior: 'hang' | 'succeed'; cacheStateValue: ReturnType> } = { - collection: { id: collectionId, label: collectionId }, + collection: { id: collectionId, label: collectionId, order: 0 }, definition: { id: definitionId, label: definitionId }, connection: observableValue(owner, undefined), connectionState, 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 722a4ee2fe77f..1675a96a4668d 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -25,15 +25,19 @@ import { IProductService } from '../../../../../platform/product/common/productS import { ISecretStorageService } from '../../../../../platform/secrets/common/secrets.js'; import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { IWorkspaceFolderData } from '../../../../../platform/workspace/common/workspace.js'; import { IConfigurationResolverService } from '../../../../services/configurationResolver/common/configurationResolver.js'; import { ConfigurationResolverExpression, Replacement } from '../../../../services/configurationResolver/common/configurationResolverExpression.js'; import { IOutputService } from '../../../../services/output/common/output.js'; import { TestLoggerService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { ContributionEnablementState, EnablementModel, isContributionEnabled } from '../../../chat/common/enablement.js'; +import { McpCollisionBehavior, mcpServerCollisionBehaviorSection } from '../../common/mcpConfiguration.js'; import { McpRegistry } from '../../common/mcpRegistry.js'; import { IMcpHostDelegate, IMcpMessageTransport } from '../../common/mcpRegistryTypes.js'; import { IMcpSandboxService } from '../../common/mcpSandboxService.js'; import { McpServerConnection } from '../../common/mcpServerConnection.js'; +import { McpCollisionEnablementModel } from '../../common/mcpService.js'; import { McpTaskManager } from '../../common/mcpTaskManager.js'; import { IMcpPotentialSandboxBlock, LazyCollectionState, McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpServerTransportStdio, McpServerTransportType, McpServerTrust, McpStartServerInteraction } from '../../common/mcpTypes.js'; import { TestMcpMessageTransport } from './mcpRegistryTypes.js'; @@ -225,6 +229,7 @@ suite('Workbench - MCP - Registry', () => { trustBehavior: McpServerTrust.Kind.Trusted, scope: StorageScope.APPLICATION, configTarget: ConfigurationTarget.USER, + order: 0, }; // Create base definition that can be reused @@ -698,6 +703,276 @@ suite('Workbench - MCP - Registry', () => { }); }); + suite('Server Label Collision Enablement', () => { + let enablementModel: McpCollisionEnablementModel; + let baseEnablement: EnablementModel; + + function createCollectionWithServers( + id: string, + order: number, + servers: { id: string; label: string }[], + ): McpCollectionDefinition & { serverDefinitions: ISettableObservable } { + return { + id, + label: `Collection ${id}`, + remoteAuthority: null, + order, + serverDefinitions: observableValue('serverDefs', servers.map(s => ({ + ...baseDefinition, + id: s.id, + label: s.label, + }))), + trustBehavior: McpServerTrust.Kind.Trusted, + scope: StorageScope.APPLICATION, + configTarget: ConfigurationTarget.USER, + }; + } + + function setupModel() { + baseEnablement = store.add(new EnablementModel('mcp.enablement.test', testStorageService)); + const collisionBehavior = observableConfigValue(mcpServerCollisionBehaviorSection, McpCollisionBehavior.Disable, configurationService); + enablementModel = new McpCollisionEnablementModel(baseEnablement, registry, collisionBehavior); + } + + test('disables lower-priority servers with same label', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-a', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('does not disable servers with different labels', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'Server A' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-b', label: 'Server B' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-b'))); + }); + + test('label collision is case-insensitive', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-a', label: 'my server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('respects collection order for priority', () => { + const col2 = createCollectionWithServers('col-2', 200, [{ id: 'col-2.srv-a', label: 'My Server' }]); + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + store.add(registry.registerCollection(col2)); + store.add(registry.registerCollection(col1)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('enabling a colliding server disables others with same label', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-a', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Enable the lower-priority server explicitly + enablementModel.setEnabled('col-2.srv-a', ContributionEnablementState.EnabledWorkspace); + + // col-2 is now enabled, col-1 should be disabled (set to DisabledWorkspace) + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.strictEqual(enablementModel.readEnabled('col-1.srv-a'), ContributionEnablementState.DisabledWorkspace); + }); + + test('no collision effect when behavior is "suffix"', () => { + configurationService.setUserConfiguration('chat.mcp.collisionBehavior', McpCollisionBehavior.Suffix); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === 'chat.mcp.collisionBehavior', + } as unknown as IConfigurationChangeEvent); + + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-a', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Both should be enabled when collision behavior is "suffix" + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('non-winner becomes enabled when winner is explicitly disabled', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-a', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Explicitly disable the winner + enablementModel.setEnabled('col-1.srv-a', ContributionEnablementState.DisabledProfile); + + // col-1 is disabled, col-2 becomes the first enabled server in the group + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('updates when server definitions change', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'Server A' }]); + const col2: McpCollectionDefinition & { serverDefinitions: ISettableObservable } = { + ...createCollectionWithServers('col-2', 100, []), + }; + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Initially no collision — both enabled + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + + // Add a conflicting server to col2 + col2.serverDefinitions.set([{ ...baseDefinition, id: 'col-2.srv-a', label: 'Server A' }], undefined); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + }); + + test('three-way collision: only highest priority is enabled', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv', label: 'My Server' }]); + const col3 = createCollectionWithServers('col-3', 200, [{ id: 'col-3.srv', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + store.add(registry.registerCollection(col3)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-3.srv'))); + }); + + test('three-way collision: enabling lowest disables both others', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv', label: 'My Server' }]); + const col3 = createCollectionWithServers('col-3', 200, [{ id: 'col-3.srv', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + store.add(registry.registerCollection(col3)); + setupModel(); + + enablementModel.setEnabled('col-3.srv', ContributionEnablementState.EnabledWorkspace); + + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-3.srv'))); + }); + + test('disabling winner cascades to next in priority', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv', label: 'My Server' }]); + const col3 = createCollectionWithServers('col-3', 200, [{ id: 'col-3.srv', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + store.add(registry.registerCollection(col3)); + setupModel(); + + // Disable the winner — col-2 (next priority) becomes the active one + enablementModel.setEnabled('col-1.srv', ContributionEnablementState.DisabledProfile); + + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-3.srv'))); + }); + + test('both servers in same collection with same label: only first enabled', () => { + const col = createCollectionWithServers('col-1', 0, [ + { id: 'col-1.srv-a', label: 'My Server' }, + { id: 'col-1.srv-b', label: 'My Server' }, + ]); + store.add(registry.registerCollection(col)); + setupModel(); + + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv-b'))); + }); + + test('EnabledWorkspace non-winner still suppressed if winner also enabled', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Manually set both to EnabledWorkspace in the base model + baseEnablement.setEnabled('col-1.srv', ContributionEnablementState.EnabledWorkspace); + baseEnablement.setEnabled('col-2.srv', ContributionEnablementState.EnabledWorkspace); + + // Even though both are explicitly enabled, only the higher-priority one wins + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv'))); + }); + + test('remove clears collision override and restores default behavior', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv', label: 'My Server' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv', label: 'My Server' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Enable col-2, which disables col-1 via DisabledWorkspace + enablementModel.setEnabled('col-2.srv', ContributionEnablementState.EnabledWorkspace); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + + // Remove both overrides — restores default collision behavior + enablementModel.remove('col-1.srv'); + enablementModel.remove('col-2.srv'); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv'))); + }); + + test('non-colliding servers in same collection as colliding ones are unaffected', () => { + const col1 = createCollectionWithServers('col-1', 0, [ + { id: 'col-1.srv-a', label: 'My Server' }, + { id: 'col-1.srv-b', label: 'Unique Server' }, + ]); + const col2 = createCollectionWithServers('col-2', 100, [ + { id: 'col-2.srv-a', label: 'My Server' }, + { id: 'col-2.srv-c', label: 'Another Unique' }, + ]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + // Colliding servers: only col-1's wins + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(!isContributionEnabled(enablementModel.readEnabled('col-2.srv-a'))); + // Non-colliding servers: both enabled + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-b'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-c'))); + }); + + test('setEnabled with non-colliding server does not affect others', () => { + const col1 = createCollectionWithServers('col-1', 0, [{ id: 'col-1.srv-a', label: 'Server A' }]); + const col2 = createCollectionWithServers('col-2', 100, [{ id: 'col-2.srv-b', label: 'Server B' }]); + store.add(registry.registerCollection(col1)); + store.add(registry.registerCollection(col2)); + setupModel(); + + enablementModel.setEnabled('col-2.srv-b', ContributionEnablementState.EnabledWorkspace); + + // No collision group — col-1 should be unaffected + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-1.srv-a'))); + assert.ok(isContributionEnabled(enablementModel.readEnabled('col-2.srv-b'))); + }); + }); + suite('Trust Flow', () => { /** * Helper to create a test MCP collection with a specific trust behavior @@ -711,6 +986,7 @@ suite('Workbench - MCP - Registry', () => { trustBehavior, scope: StorageScope.APPLICATION, configTarget: ConfigurationTarget.USER, + order: 0, }; } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts index 69b07fdf55f6f..4360cbdf62d8c 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts @@ -168,6 +168,7 @@ export class TestMcpRegistry implements IMcpRegistry { remoteAuthority: null, label: 'Test Collection', configTarget: ConfigurationTarget.USER, + order: 0, serverDefinitions: observableValue(this, [{ id: 'test-server', label: 'Test Server', diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts index 518481fb12320..8e1c2c2d8c615 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts @@ -95,6 +95,7 @@ suite('Workbench - MCP - ServerConnection', () => { trustBehavior: McpServerTrust.Kind.Trusted, scope: StorageScope.APPLICATION, configTarget: ConfigurationTarget.USER, + order: 0, }; // Create server definition diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index 30c9e0cee5c3f..0daf8a902e1db 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -120,7 +120,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { constructor( id: number, - private readonly _connection: IAgentConnection, + private _connection: IAgentConnection, private readonly _terminalUri: URI, private readonly _options?: IAgentHostPtyOptions, ) { @@ -378,6 +378,84 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { // Not applicable } + /** + * Reconnect this pty to a new agent host connection. Tears down the + * old subscription and re-subscribes with the new connection, replaying + * content from the server-side snapshot. Terminal output during the + * disconnect gap is a stream (not state), so some loss is expected. + * + * @returns `true` if reconnection succeeded, `false` otherwise. + */ + async reconnect(newConnection: IAgentConnection): Promise { + // Clean up old subscription + this._subscriptionDisposables.clear(); + this._subscriptionRef?.dispose(); + this._subscriptionRef = undefined; + + // Swap connection + this._connection = newConnection; + + try { + // Re-subscribe to the terminal state + this._subscriptionRef = this._connection.getSubscription(StateComponents.Terminal, this._terminalUri); + const subscription = this._subscriptionRef.object; + + // Wait for hydration with a timeout — the terminal may no longer + // exist on the server (e.g. agent process restarted). + if (subscription.value === undefined) { + const RECONNECT_HYDRATE_TIMEOUT_MS = 10_000; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + listener.dispose(); + reject(new Error('Reconnect hydration timed out')); + }, RECONNECT_HYDRATE_TIMEOUT_MS); + const listener = subscription.onDidChange(() => { + clearTimeout(timer); + listener.dispose(); + resolve(); + }); + this._subscriptionDisposables.add(listener); + }); + } + + const state = subscription.value as TerminalState; + + if (state.supportsCommandDetection && !this._supportsCommandDetection) { + this._supportsCommandDetection = true; + this._onSupportsCommandDetection.fire(); + } + + // Clear the terminal buffer before replaying to avoid duplicate + // content. ESC[2J clears the screen, ESC[3J clears scrollback, + // ESC[H moves cursor to home position. + this.handleData('\x1b[2J\x1b[3J\x1b[H'); + this._replayContent(state.content); + + // Update cwd/title if they changed + if (state.cwd) { + this._properties.cwd = state.cwd.toString(); + } + if (state.title) { + this._properties.title = state.title; + } + + // Wire up action listener for streaming updates + this._subscriptionDisposables.add(subscription.onDidApplyAction(envelope => { + this._handleAction(envelope); + })); + + return true; + } catch (err) { + console.warn('[AgentHostPty] Reconnection failed:', err instanceof Error ? err.message : String(err)); + return false; + } + } + + /** The terminal URI this pty is subscribed to. */ + get terminalUri(): URI { + return this._terminalUri; + } + override dispose(): void { this._subscriptionRef?.dispose(); this._subscriptionRef = undefined; diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts index 19a9fba7ab215..6de7432acc951 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts @@ -78,6 +78,13 @@ export interface IAgentHostTerminalService { */ createTerminalForEntry(address: string, options?: IAgentHostTerminalCreateOptions): Promise; + /** + * Reconnects all active terminals that belonged to {@link oldClientId} + * to a new agent host connection. Only terminals matching the old + * client are touched — terminals from other hosts are left alone. + */ + reconnectTerminals(newConnection: IAgentConnection, oldClientId: string): Promise<{ recovered: number; total: number }>; + /** * Attaches to an existing server-side terminal by subscribing to its * state without creating a new process. @@ -104,6 +111,11 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe /** Revived terminal instances, keyed by terminal URI string. */ private readonly _revivedInstances = new Map(); + /** + * Active AgentHostPty instances with their owning connection clientId, + * keyed by terminal URI string. Used for reconnection scoping. + */ + private readonly _activePtys = new Map(); private readonly _reviveSequencer = new SequencerByKey(); constructor( @@ -279,8 +291,9 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe async createTerminal(connection: IAgentConnection, options?: IAgentHostTerminalCreateOptions): Promise { const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: `/${generateUuid()}` }); const name = options?.name ?? localize('agentHostTerminal.default', "Agent Host Terminal"); + const key = terminalUri.toString(); - return this._terminalService.createTerminal({ + const instance = await this._terminalService.createTerminal({ config: { customPtyImplementation: (id, cols, rows) => { const pty = new AgentHostPty(id, connection, terminalUri, { @@ -290,6 +303,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe if (cols > 0 && rows > 0) { pty.resize(cols, rows); } + this._activePtys.set(key, { pty, clientId: connection.clientId }); return pty; }, name, @@ -298,6 +312,12 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe }, location: options?.location, }); + + this._register(instance.onDisposed(() => { + this._activePtys.delete(key); + })); + + return instance; } async reviveTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): Promise { @@ -328,6 +348,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe commandSource.connect(instance, pty); } + this._activePtys.set(key, { pty, clientId: connection.clientId }); return pty; }, name: localize('agentHostTerminal.tool', "Agent Host Terminal"), @@ -341,8 +362,36 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe instance.store.add(store); this._register(instance.onDisposed(() => { this._revivedInstances.delete(key); + this._activePtys.delete(key); })); return instance; } + + async reconnectTerminals(newConnection: IAgentConnection, oldClientId: string): Promise<{ recovered: number; total: number }> { + // Only reconnect terminals that belonged to the old connection + // identified by oldClientId. In multi-host setups, other hosts' + // terminals are left untouched. + const entries = [...this._activePtys.entries()].filter( + ([, entry]) => entry.clientId === oldClientId + ); + const total = entries.length; + let recovered = 0; + const promises: Promise[] = []; + for (const [key, entry] of entries) { + promises.push( + entry.pty.reconnect(newConnection).then(success => { + if (success) { + recovered++; + // Update the clientId to the new connection + entry.clientId = newConnection.clientId; + } else { + console.warn(`[AgentHostTerminalService] Failed to reconnect terminal: ${key}`); + } + }) + ); + } + await Promise.all(promises); + return { recovered, total }; + } } diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index e474f44d569ca..18370f0de2fe7 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -341,4 +341,123 @@ suite('AgentHostPty', () => { const cwd = await pty.getInitialCwd(); assert.strictEqual(cwd, '/home/user'); }); + + test('reconnect() re-subscribes with new connection and replays content', async () => { + const conn1 = new MockAgentConnection({ content: [{ type: 'unclassified', value: 'old output\n' }] }); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + + await pty.start(); + + // Create a new connection with different content (simulating server-side changes during disconnect) + const conn2 = new MockAgentConnection({ + content: [{ type: 'unclassified', value: 'old output\nnew output after reconnect\n' }], + cwd: '/home/reconnected', + title: 'Reconnected Terminal', + }); + disposables.add(conn2); + + const dataReceived: string[] = []; + disposables.add(pty.onProcessData!(e => { + dataReceived.push(typeof e === 'string' ? e : e.data); + })); + + const result = await pty.reconnect(conn2); + + assert.strictEqual(result, true, 'reconnect() should succeed'); + // Should have clear sequence + replayed content + assert.ok(dataReceived.some(d => d.includes('\x1b[2J')), 'should clear buffer before replay'); + assert.ok(dataReceived.some(d => d.includes('new output after reconnect')), 'should replay new content'); + + const cwd = await pty.getCwd(); + assert.strictEqual(cwd, '/home/reconnected'); + }); + + test('reconnect() streams new actions from new connection', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + await pty.start(); + + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + + const dataReceived: string[] = []; + disposables.add(pty.onProcessData!(e => { + dataReceived.push(typeof e === 'string' ? e : e.data); + })); + + await pty.reconnect(conn2); + dataReceived.length = 0; // clear replay data + + // New actions from conn2 should be received + conn2.fireAction({ type: ActionType.TerminalData, terminal: terminalUri.toString(), data: 'post-reconnect data' }); + + assert.deepStrictEqual(dataReceived, ['post-reconnect data']); + + // Old connection actions should NOT be received + conn1.fireAction({ type: ActionType.TerminalData, terminal: terminalUri.toString(), data: 'stale data' }); + assert.deepStrictEqual(dataReceived, ['post-reconnect data']); + }); + + test('reconnect() times out when subscription never hydrates', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + await pty.start(); + + // Create a connection whose subscription never fires onDidChange + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + // Override getSubscription to return a subscription that never hydrates + conn2.getSubscription = (_kind: StateComponents, _resource: URI): IReference> => { + const onDidChange = new Emitter(); + const onDidApplyAction = new Emitter(); + disposables.add(onDidChange); + disposables.add(onDidApplyAction); + const sub: IAgentSubscription = { + value: undefined, // never hydrated + verifiedValue: undefined, + onDidChange: onDidChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: onDidApplyAction.event, + }; + return { + object: sub as IAgentSubscription, + dispose: () => { onDidChange.dispose(); onDidApplyAction.dispose(); }, + }; + }; + + // Suppress the expected console.warn from reconnect failure + const origWarn = console.warn; + console.warn = () => { }; + try { + const result = await pty.reconnect(conn2); + assert.strictEqual(result, false, 'reconnect() should fail on timeout'); + } finally { + console.warn = origWarn; + } + }).timeout(15000); // Allow for the 10s hydration timeout + + test('reconnect() dispatches input to new connection', async () => { + const conn1 = new MockAgentConnection(); + disposables.add(conn1); + const pty = disposables.add(new AgentHostPty(1, conn1, terminalUri)); + await pty.start(); + + const conn2 = new MockAgentConnection(); + disposables.add(conn2); + await pty.reconnect(conn2); + + pty.input('after reconnect'); + await new Promise(resolve => setTimeout(resolve, 10)); + + const inputActions = conn2.dispatchedActions.filter(a => a.type === ActionType.TerminalInput); + assert.strictEqual(inputActions.length, 1); + assert.strictEqual((inputActions[0] as { data: string }).data, 'after reconnect'); + + // conn1 should not have received the input + const oldInputActions = conn1.dispatchedActions.filter(a => a.type === ActionType.TerminalInput); + assert.strictEqual(oldInputActions.length, 0); + }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts index 842fe8807f423..4f766dc5d2587 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineBackgroundDetachRewriter.ts @@ -7,7 +7,7 @@ import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { OperatingSystem } from '../../../../../../../base/common/platform.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js'; -import { isPowerShell } from '../../runInTerminalHelpers.js'; +import { isFish, isPowerShell } from '../../runInTerminalHelpers.js'; import type { ICommandLineRewriter, ICommandLineRewriterOptions, ICommandLineRewriterResult } from './commandLineRewriter.js'; /** @@ -46,13 +46,31 @@ export class CommandLineBackgroundDetachRewriter extends Disposable implements I } private _rewriteForPosix(options: ICommandLineRewriterOptions): ICommandLineRewriterResult { + const trimmed = options.commandLine.trimEnd(); + + // nohup only accepts a simple external command as its argument — it cannot exec + // compound statements (for/while/if/case) or shell builtins (eval/set/export/source). + // Wrap those in ` -c '...'` so the whole construct runs as a single executable. + let commandToWrap = trimmed; + if (this._needsShellCWrapper(trimmed)) { + if (isFish(options.shell, options.os)) { + // Fish does not support the POSIX '\'' escape inside single-quoted strings. + // Use a double-quoted string and escape backslash and double-quote instead. + const escaped = trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + commandToWrap = `${options.shell} -c "${escaped}"`; + } else { + // bash/zsh: escape single quotes for use inside a single-quoted shell -c '...' string. + const escaped = trimmed.replace(/'/g, `'\\''`); + commandToWrap = `${options.shell} -c '${escaped}'`; + } + } + // If the command already ends with a single trailing `&` (background operator, // as opposed to `&&` for command chaining), don't append another one. - const trimmed = options.commandLine.trimEnd(); - const endsWithBackgroundAmp = /(?:^|[^&])&$/.test(trimmed); + const endsWithBackgroundAmp = /(?:^|[^&])&$/.test(commandToWrap); const rewritten = endsWithBackgroundAmp - ? `nohup ${trimmed}` - : `nohup ${options.commandLine} &`; + ? `nohup ${commandToWrap}` + : `nohup ${commandToWrap} &`; return { rewritten, reasoning: 'Wrapped background command with nohup to survive terminal shutdown', @@ -60,6 +78,27 @@ export class CommandLineBackgroundDetachRewriter extends Disposable implements I }; } + /** + * Returns true when the command uses shell compound constructs or builtins that + * `nohup` cannot exec directly. Such commands must be wrapped in ` -c '...'` before + * being passed to nohup. + */ + private _needsShellCWrapper(commandLine: string): boolean { + const trimmed = commandLine.trimStart(); + return ( + // Bash compound command keywords — syntax constructs that are not executables. + /^(for|while|until|if|case|select|function)\b/.test(trimmed) || + // Shell builtins — these only run meaningfully inside the current shell; nohup + // cannot exec them (eval, set, export, source, unset, declare, etc.). + /^(eval|set|export|source|unset|declare|typeset|local|readonly|alias)\b/.test(trimmed) || + // `. file` (dot-source builtin). Exclude `./script` (relative path) by requiring + // whitespace after the dot. + /^\.\s/.test(trimmed) || + // Compound groupings: subshell `( ... )` or brace group `{ ...; }`. + /^[{(]/.test(trimmed) + ); + } + private _rewriteForPowerShell(options: ICommandLineRewriterOptions): ICommandLineRewriterResult | undefined { if (!isPowerShell(options.shell, options.os)) { return undefined; 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 6b1f790bd7434..2f3560955802b 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 @@ -99,12 +99,32 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._command = command; this._invocationContext = invocationContext; - this._register(toDisposable(() => this._currentMonitoringCts?.dispose())); - // Start async to ensure listeners are set up + // Create the CTS synchronously so it is available for cancellation if the + // OutputMonitor is disposed before the deferred _startMonitoring fires. + // The registered disposable must cancel (not just dispose) the CTS so that + // the async monitoring loop's token becomes isCancellationRequested=true and + // the loop exits promptly — CancellationTokenSource.dispose() alone does + // not set isCancellationRequested. + const cts = new CancellationTokenSource(token); + this._currentMonitoringCts = cts; + this._register(toDisposable(() => { + this._currentMonitoringCts?.cancel(); + this._currentMonitoringCts?.dispose(); + })); + + // Start async to ensure listeners are set up. + // Capture `cts` locally so that if continueMonitoringAsync replaces + // _currentMonitoringCts before this fires, we detect the replacement + // and avoid starting a duplicate monitoring loop. _startMonitoring + // handles a cancelled token correctly by firing onDidFinishCommand in + // its finally block, so we always call it when we're still the current + // CTS (even if the token has since been cancelled). timeout(0).then(() => { - this._currentMonitoringCts = new CancellationTokenSource(token); - this._startMonitoring(command, invocationContext, this._currentMonitoringCts.token); + if (this._currentMonitoringCts !== cts) { + return; + } + this._startMonitoring(command, invocationContext, cts.token); }); } @@ -488,18 +508,25 @@ export function detectsInputRequiredPattern(cursorLine: string): boolean { // Same as above but allows a preceding '?' or ':' and optional wrappers e.g. // "Continue? (y/n)" or "Overwrite: [yes/no]" /[?:]\s*(?:\(|\[)?\s*y(?:es)?\s*\/\s*n(?:o)?\s*(?:\]|\))?\s+$/i, - // Confirmation prompts ending with (y) e.g. "Ok to proceed? (y)" - /\(y\)\s*$/i, - // Line ends with ':' - /:\s*$/, + // Confirmation prompts ending with (y) followed by trailing space, e.g. "Ok to proceed? (y) " + // The trailing space indicates the cursor is positioned after the prompt awaiting input, as + // opposed to normal command output that happens to contain "(y)" followed by a newline. + /\(y\) +$/i, + // Line ends with ':' followed by at least one space. The trailing space indicates a + // waiting prompt (cursor positioned after the colon). A bare ':\n' at end of buffer is + // usually non-prompt output (e.g. a header or log line) and must not match. + /: +$/, // Prompt with parenthesized default value e.g. "package name: (test) " or "version: (1.0.0) " - /:\s*\([^)]*\)\s*$/, + /:\s*\([^)]*\) +$/, // Line contains (END) which is common in pagers /\(END\)$/, - // Password prompt - /password[:]?$/i, - // Line ends with '?' - /\?\s*(?:\([a-z\s]+\))?$/i, + // Password prompt (must be followed by optional colon and trailing space to indicate + // an active prompt; otherwise normal output containing the word "password" would match). + /password:? +$/i, + // Line ends with '?' followed by at least one space (optionally followed by a + // parenthesized hint like "Continue? (yes/no) "). Requiring trailing space avoids + // matching arbitrary command output where a line happens to end with '?'. + /\? *(?:\([a-z\s]+\))? +$/i, // "Press a key" or "Press any key" /press a(?:ny)? key/i, ].some(e => e.test(cursorLine)); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 0430a4d4d1111..3bad6b3848110 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -284,6 +284,13 @@ suite('OutputMonitor', () => { assert.strictEqual(detectsInputRequiredPattern('Enter your name: '), true); assert.strictEqual(detectsInputRequiredPattern('Password: '), true); assert.strictEqual(detectsInputRequiredPattern('File to overwrite: '), true); + + // Non-prompts: a trailing colon without a following space is typical of normal + // command output (headers, log lines ending with ':' before a newline) and must + // not be treated as an input prompt. + assert.strictEqual(detectsInputRequiredPattern('Running tests:'), false); + assert.strictEqual(detectsInputRequiredPattern('Results:\n'), false); + assert.strictEqual(detectsInputRequiredPattern('Summary:'), false); }); test('detects prompts with parenthesized default values', () => { @@ -294,9 +301,16 @@ suite('OutputMonitor', () => { }); test('detects trailing questions', () => { - assert.strictEqual(detectsInputRequiredPattern('Continue?'), true); + assert.strictEqual(detectsInputRequiredPattern('Continue? '), true); assert.strictEqual(detectsInputRequiredPattern('Proceed? '), true); - assert.strictEqual(detectsInputRequiredPattern('Are you sure?'), true); + assert.strictEqual(detectsInputRequiredPattern('Are you sure? '), true); + + // Non-prompts: a trailing '?' without a following space is typical of + // normal command output (log lines, error messages) and must not be + // treated as an input prompt. + assert.strictEqual(detectsInputRequiredPattern('Continue?'), false); + assert.strictEqual(detectsInputRequiredPattern('Are you sure?\n'), false); + assert.strictEqual(detectsInputRequiredPattern('What happened?'), false); }); test('detects press any key prompts', () => { @@ -375,6 +389,34 @@ suite('OutputMonitor', () => { }); }); + suite('disposable leak regression', () => { + test('disposing before timeout(0) fires does not leak idle input listener', async () => { + // Regression: disposing immediately (before the deferred _startMonitoring fires) + // must not leak the FunctionDisposable created by onDidInputData. + // The CTS must be cancelled synchronously so that when timeout(0) fires and + // _setupIdleInputListener runs, isCancellationRequested is already true. + return runWithFakedTimers({}, async () => { + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), cts.token, 'test command')); + // Dispose immediately, before the deferred _startMonitoring callback fires. + monitor.dispose(); + await new Promise(resolve => setTimeout(resolve, 0)); + // ensureNoDisposablesAreLeakedInTestSuite will catch any leaked disposable. + }); + }); + + test('disposing after monitoring completes does not leak idle input listener', async () => { + // Verifies the finally block in _startMonitoring clears _userInputListener before + // firing onDidFinishCommand. Any undisposed FunctionDisposable from onDidInputData + // would be caught by ensureNoDisposablesAreLeakedInTestSuite. + return runWithFakedTimers({}, async () => { + execution.isActive = async () => false; + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), cts.token, 'test command')); + await Event.toPromise(monitor.onDidFinishCommand); + monitor.dispose(); + }); + }); + }); + suite('detectsGenericPressAnyKeyPattern', () => { test('detects generic press any key prompts from scripts', () => { assert.strictEqual(detectsGenericPressAnyKeyPattern('Press any key to continue...'), true); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts index cddcc474d7195..0dbd2ca5e5e45 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineBackgroundDetachRewriter.test.ts @@ -94,6 +94,96 @@ suite('CommandLineBackgroundDetachRewriter', () => { }); }); + suite('POSIX shell -c wrapping for compound commands and builtins', () => { + test('for loop should be wrapped using bash shell path', () => { + deepStrictEqual(rewriter.rewrite(createOptions('for i in $(seq 1 90); do echo $i; sleep 1; done', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'for i in $(seq 1 90); do echo $i; sleep 1; done' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'for i in $(seq 1 90); do echo $i; sleep 1; done', + }); + }); + + test('while loop should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('while true; do sleep 1; done', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'while true; do sleep 1; done' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'while true; do sleep 1; done', + }); + }); + + test('if statement should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('if [ -f file ]; then cat file; fi', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'if [ -f file ]; then cat file; fi' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'if [ -f file ]; then cat file; fi', + }); + }); + + test('eval builtin should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('eval $SETUP_ENV && opam install coq --yes', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'eval $SETUP_ENV && opam install coq --yes' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'eval $SETUP_ENV && opam install coq --yes', + }); + }); + + test('set builtin should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('set -e; cmd1; cmd2', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'set -e; cmd1; cmd2' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'set -e; cmd1; cmd2', + }); + }); + + test('export builtin should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('export PATH="/usr/local/bin:$PATH"; myapp', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'export PATH="/usr/local/bin:$PATH"; myapp' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'export PATH="/usr/local/bin:$PATH"; myapp', + }); + }); + + test('dot-source builtin should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('. /etc/profile; myapp', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c '. /etc/profile; myapp' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: '. /etc/profile; myapp', + }); + }); + + test('relative path ./script should NOT be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('./start.sh', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup ./start.sh &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: './start.sh', + }); + }); + + test('brace group should be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('{ cmd1; cmd2; }', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c '{ cmd1; cmd2; }' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: '{ cmd1; cmd2; }', + }); + }); + + test('single quotes in command should be properly escaped', () => { + deepStrictEqual(rewriter.rewrite(createOptions(`for f in *.txt; do echo 'file:' $f; done`, '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/bash -c 'for f in *.txt; do echo '\\''file:'\\'' $f; done' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: `for f in *.txt; do echo 'file:' $f; done`, + }); + }); + + test('simple external command should NOT be wrapped in shell -c', () => { + deepStrictEqual(rewriter.rewrite(createOptions('python3 app.py', '/bin/bash', OperatingSystem.Linux, true)), { + rewritten: 'nohup python3 app.py &', + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'python3 app.py', + }); + }); + }); + suite('POSIX (zsh)', () => { test('should wrap with nohup', () => { deepStrictEqual(rewriter.rewrite(createOptions('node server.js', '/bin/zsh', OperatingSystem.Linux, true)), { @@ -102,6 +192,14 @@ suite('CommandLineBackgroundDetachRewriter', () => { forDisplay: 'node server.js', }); }); + + test('for loop should be wrapped using zsh shell path', () => { + deepStrictEqual(rewriter.rewrite(createOptions('for i in $(seq 1 10); do echo $i; done', '/bin/zsh', OperatingSystem.Linux, true)), { + rewritten: `nohup /bin/zsh -c 'for i in $(seq 1 10); do echo $i; done' &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'for i in $(seq 1 10); do echo $i; done', + }); + }); }); suite('POSIX (fish)', () => { @@ -112,6 +210,22 @@ suite('CommandLineBackgroundDetachRewriter', () => { forDisplay: 'ruby app.rb', }); }); + + test('for loop should be wrapped using fish shell path with double-quote escaping', () => { + deepStrictEqual(rewriter.rewrite(createOptions('for i in (seq 1 10); echo $i; end', '/usr/bin/fish', OperatingSystem.Linux, true)), { + rewritten: `nohup /usr/bin/fish -c "for i in (seq 1 10); echo $i; end" &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'for i in (seq 1 10); echo $i; end', + }); + }); + + test('compound command with double quotes should be escaped for fish', () => { + deepStrictEqual(rewriter.rewrite(createOptions('for f in *.txt; echo "file: $f"; end', '/usr/bin/fish', OperatingSystem.Linux, true)), { + rewritten: `nohup /usr/bin/fish -c "for f in *.txt; echo \\"file: $f\\"; end" &`, + reasoning: 'Wrapped background command with nohup to survive terminal shutdown', + forDisplay: 'for f in *.txt; echo "file: $f"; end', + }); + }); }); suite('Windows (PowerShell)', () => { diff --git a/src/vs/workbench/electron-browser/actions/media/openInAgents.css b/src/vs/workbench/electron-browser/actions/media/openInAgents.css new file mode 100644 index 0000000000000..c457308436116 --- /dev/null +++ b/src/vs/workbench/electron-browser/actions/media/openInAgents.css @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* "Open in Agents" titlebar widget — icon-only at rest, expands on hover/focus. */ +.monaco-workbench .open-in-agents-titlebar-widget { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 4px; + margin: 0 10px 0 2px; + border-radius: 5px; + cursor: pointer; + color: var(--vscode-titleBar-activeForeground); + -webkit-app-region: no-drag; + white-space: nowrap; + position: relative; +} + +/* Vertical separator drawn as an absolutely positioned pseudo-element so it isn't clipped by any ancestor `overflow: hidden`. */ +.monaco-workbench .open-in-agents-titlebar-widget::after { + content: ''; + position: absolute; + right: -6px; + top: 4px; + bottom: 4px; + width: 1px; + background-color: var(--vscode-widget-border, rgba(128, 128, 128, 0.5)); + pointer-events: none; +} + +.monaco-workbench .open-in-agents-titlebar-widget > .open-in-agents-titlebar-widget-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; + background-image: url('../../../../sessions/browser/media/sessions-icon.svg'); + background-repeat: no-repeat; + background-position: center center; + background-size: contain; + /* Desaturated at rest; full color on hover/focus. */ + filter: grayscale(1) opacity(0.75); + transition: filter 150ms ease; +} + +.monaco-workbench .open-in-agents-titlebar-widget:hover > .open-in-agents-titlebar-widget-icon, +.monaco-workbench .open-in-agents-titlebar-widget:focus-visible > .open-in-agents-titlebar-widget-icon { + filter: none; +} + +.monaco-workbench .open-in-agents-titlebar-widget > .open-in-agents-titlebar-widget-label { + display: inline-block; + max-width: 0; + opacity: 0; + margin-left: 0; + color: var(--vscode-foreground); + font: inherit; + overflow: hidden; + white-space: nowrap; + transition: max-width 150ms ease, opacity 150ms ease, margin-left 150ms ease; +} + +.monaco-workbench .open-in-agents-titlebar-widget:hover, +.monaco-workbench .open-in-agents-titlebar-widget:focus-visible { + background-color: var(--vscode-toolbar-hoverBackground); + outline: none; +} + +/* Quality-tinted hover/focus background — blue (stable), green (insider), orange (exploration). */ +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:hover, +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="stable"]:focus-visible { + background-color: rgba(0, 122, 204, 0.18); +} + +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:hover, +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="insider"]:focus-visible { + background-color: rgba(36, 187, 26, 0.20); +} + +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:hover, +.monaco-workbench .open-in-agents-titlebar-widget[data-product-quality="exploration"]:focus-visible { + background-color: rgba(255, 140, 0, 0.22); +} + +.monaco-workbench .open-in-agents-titlebar-widget:hover > .open-in-agents-titlebar-widget-label, +.monaco-workbench .open-in-agents-titlebar-widget:focus-visible > .open-in-agents-titlebar-widget-label { + max-width: 200px; + opacity: 1; + margin-left: 6px; +} + +.monaco-workbench .open-in-agents-titlebar-widget:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts new file mode 100644 index 0000000000000..492281c57b03b --- /dev/null +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/openInAgents.css'; +import { $, append } from '../../../base/browser/dom.js'; +import { getDefaultHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../base/common/actions.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../platform/actions/browser/actionViewItemService.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../platform/configuration/common/configurationRegistry.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { IInstantiationService, ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; +import { INativeHostService } from '../../../platform/native/common/native.js'; +import { IProductService } from '../../../platform/product/common/productService.js'; +import { Registry } from '../../../platform/registry/common/platform.js'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../platform/workspace/common/workspace.js'; +import { ToggleTitleBarConfigAction, TitleBarLeadingActionsGroup } from '../../browser/parts/titlebar/titlebarActions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../common/contributions.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../common/contextkeys.js'; +import { workbenchConfigurationNodeBase } from '../../common/configuration.js'; +import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; +import { ChatEntitlementContextKeys } from '../../services/chat/common/chatEntitlementService.js'; + +const OpenInAgentsActionId = 'workbench.action.openInAgents'; +const OpenInAgentsEnabledSetting = 'workbench.openInAgents.enabled'; + +// Context key tracking the current product quality so we can hide the +// "Open in Agents" entry in stable builds for now. +const OpenInAgentsProductQualityContext = new RawContextKey('openInAgentsProductQuality', ''); + +type OpenInAgentsMode = 'siblingApp' | 'newWindow'; + +type OpenInAgentsEvent = { mode: OpenInAgentsMode }; +type OpenInAgentsClassification = { + owner: 'osortega'; + comment: 'Tracks when the user opens the Agents application from the VS Code titlebar.'; + mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the Agents app was opened: siblingApp (launched separate Agents app) or newWindow (in-process agents window).' }; +}; + +const OpenInAgentsVisibility = ContextKeyExpr.and( + ContextKeyExpr.equals(`config.${OpenInAgentsEnabledSetting}`, true), + IsSessionsWindowContext.toNegated(), + IsAuxiliaryWindowContext.toNegated(), + // Hide whenever the user has signaled (or policy/workspace trust dictates) + // that AI features should not be shown in this window/workspace. + ChatEntitlementContextKeys.Setup.hidden.negate(), + ChatEntitlementContextKeys.Setup.disabled.negate(), + ChatEntitlementContextKeys.Setup.disabledInWorkspace.negate(), + ChatEntitlementContextKeys.Setup.untrusted.negate(), + // Hide in stable builds for now (insider, exploration and OSS dev are allowed). + ContextKeyExpr.notEquals(OpenInAgentsProductQualityContext.key, 'stable'), +); + +/** + * Action that opens the Agents application for the current workspace. + * + * In built builds where a sibling Agents app is registered (`darwinSiblingBundleIdentifier` + * / `win32SiblingExeBasename`), launches it via {@link INativeHostService.launchSiblingApp} + * with `--agents` and the current workspace folder/file. Otherwise falls back to opening + * a new in-process Agents window via {@link INativeHostService.openAgentsWindow}. + */ +class OpenInAgentsAction extends Action2 { + + constructor() { + super({ + id: OpenInAgentsActionId, + title: localize2('openInAgents', "Open in Agents"), + f1: true, + precondition: OpenInAgentsVisibility, + menu: [{ + // Render in the global titlebar tool bar in the dedicated leading + // slot so we appear before the layout controls (and stay visible + // when layout controls are toggled off). + id: MenuId.TitleBar, + group: TitleBarLeadingActionsGroup, + order: -1000, + when: OpenInAgentsVisibility, + }, { + // Also surface inside the "Customize Layout..." submenu so users + // can toggle the entry on/off from the layout customization UI. + id: MenuId.LayoutControlMenuSubmenu, + group: '0_workbench_layout', + order: -1000, + when: OpenInAgentsVisibility, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const nativeHostService = accessor.get(INativeHostService); + const productService = accessor.get(IProductService); + const environmentService = accessor.get(IWorkbenchEnvironmentService); + const workspaceContextService = accessor.get(IWorkspaceContextService); + const telemetryService = accessor.get(ITelemetryService); + + const args: string[] = ['--new-window']; + + const workspace = workspaceContextService.getWorkspace(); + switch (workspaceContextService.getWorkbenchState()) { + case WorkbenchState.FOLDER: + if (workspace.folders.length > 0) { + args.push('--folder-uri', workspace.folders[0].uri.toString()); + } + break; + case WorkbenchState.WORKSPACE: + if (workspace.configuration) { + args.push('--file-uri', workspace.configuration.toString()); + } + break; + } + + const hasSibling = !!( + productService.darwinSiblingBundleIdentifier || + productService.win32SiblingExeBasename + ); + + // In built builds with a sibling Agents app available, launch it. + // Otherwise (dev / OSS / no sibling), open a new agents window of + // the current Electron app. + const mode: OpenInAgentsMode = environmentService.isBuilt && hasSibling ? 'siblingApp' : 'newWindow'; + telemetryService.publicLog2('vscode.openInAgents', { mode }); + + if (mode === 'siblingApp') { + await nativeHostService.launchSiblingApp(args); + } else { + await nativeHostService.openAgentsWindow({ forceNewWindow: true }); + } + } +} + +/** + * Renders the "Open in Agents" titlebar entry as an icon-only button that + * expands to reveal a label on hover / keyboard focus. + */ +class OpenInAgentsTitleBarWidget extends BaseActionViewItem { + + constructor( + action: IAction, + options: IBaseActionViewItemOptions | undefined, + @IProductService private readonly productService: IProductService, + @IHoverService private readonly hoverService: IHoverService, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + + container.classList.add('open-in-agents-titlebar-widget'); + container.setAttribute('role', 'button'); + container.setAttribute('data-product-quality', this.productService.quality ?? 'stable'); + + const label = this.action.label || localize('openInAgentsLabel', "Open in Agents"); + container.setAttribute('aria-label', label); + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), container, label)); + + const icon = append(container, $('span.open-in-agents-titlebar-widget-icon')); + icon.setAttribute('aria-hidden', 'true'); + + const labelEl = append(container, $('span.open-in-agents-titlebar-widget-label')); + labelEl.textContent = label; + } +} + +class OpenInAgentsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.openInAgents.desktop'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IProductService productService: IProductService, + ) { + super(); + OpenInAgentsProductQualityContext.bindTo(contextKeyService).set(productService.quality ?? ''); + this._register(actionViewItemService.register(MenuId.TitleBar, OpenInAgentsActionId, (action, options) => { + return instantiationService.createInstance(OpenInAgentsTitleBarWidget, action, options); + }, undefined)); + } +} + +registerAction2(OpenInAgentsAction); +registerWorkbenchContribution2(OpenInAgentsContribution.ID, OpenInAgentsContribution, WorkbenchPhase.BlockRestore); + +// Toggle entry in titlebar context menu (right-click on titlebar) +registerAction2(class ToggleOpenInAgents extends ToggleTitleBarConfigAction { + constructor() { + super( + OpenInAgentsEnabledSetting, + localize('toggle.openInAgents', 'Open in Agents'), + localize('toggle.openInAgentsDescription', "Toggle visibility of the Open in Agents button in title bar"), + 6, + ); + } +}); + +// Configuration setting backing the toggle. +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + [OpenInAgentsEnabledSetting]: { + type: 'boolean', + default: true, + markdownDescription: localize('openInAgentsEnabled', "Controls whether the Open in Agents button is shown in the title bar."), + } + } +}); diff --git a/src/vs/workbench/electron-browser/desktop.contribution.ts b/src/vs/workbench/electron-browser/desktop.contribution.ts index 544300939e9d1..3226ccc6ef83e 100644 --- a/src/vs/workbench/electron-browser/desktop.contribution.ts +++ b/src/vs/workbench/electron-browser/desktop.contribution.ts @@ -11,6 +11,7 @@ import { KeyMod, KeyCode } from '../../base/common/keyCodes.js'; import { isLinux, isMacintosh, isWindows } from '../../base/common/platform.js'; import { ConfigureRuntimeArgumentsAction, ToggleDevToolsAction, ReloadWindowWithExtensionsDisabledAction, OpenUserDataFolderAction, ShowGPUInfoAction, ShowContentTracingAction, StopTracing, StartTracing } from './actions/developerActions.js'; import { ZoomResetAction, ZoomOutAction, ZoomInAction, CloseWindowAction, SwitchWindowAction, QuickSwitchWindowAction, SwitchToMainWindowAction, NewWindowTabHandler, ShowPreviousWindowTabHandler, ShowNextWindowTabHandler, MoveWindowTabToNewWindowHandler, MergeWindowTabsHandlerHandler, ToggleWindowTabsBarHandler, ToggleWindowAlwaysOnTopAction, DisableWindowAlwaysOnTopAction, EnableWindowAlwaysOnTopAction, CloseOtherWindowsAction } from './actions/windowActions.js'; +import './actions/openInAgentsAction.js'; import { ContextKeyExpr } from '../../platform/contextkey/common/contextkey.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../platform/keybinding/common/keybindingsRegistry.js'; import { CommandsRegistry } from '../../platform/commands/common/commands.js'; diff --git a/src/vs/workbench/electron-browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/electron-browser/parts/titlebar/titlebarPart.ts index 6fe575a12dda6..9fd237004231f 100644 --- a/src/vs/workbench/electron-browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/electron-browser/parts/titlebar/titlebarPart.ts @@ -13,6 +13,7 @@ import { INativeWorkbenchEnvironmentService } from '../../../services/environmen import { IHostService } from '../../../services/host/browser/host.js'; import { isMacintosh, isWindows, isLinux, isTahoeOrNewer } from '../../../../base/common/platform.js'; import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { BrowserTitlebarPart, BrowserTitleService, IAuxiliaryTitlebarPart } from '../../../browser/parts/titlebar/titlebarPart.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; @@ -76,9 +77,10 @@ export class NativeTitlebarPart extends BrowserTitlebarPart { @IEditorGroupsService editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, - @IKeybindingService keybindingService: IKeybindingService + @IKeybindingService keybindingService: IKeybindingService, + @IActionViewItemService actionViewItemService: IActionViewItemService ) { - super(id, targetWindow, editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService); + super(id, targetWindow, editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, editorService, menuService, keybindingService, actionViewItemService); this.tahoeOrNewer = isTahoeOrNewer(environmentService.os.release); @@ -300,9 +302,10 @@ export class MainNativeTitlebarPart extends NativeTitlebarPart { @IEditorGroupsService editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, - @IKeybindingService keybindingService: IKeybindingService + @IKeybindingService keybindingService: IKeybindingService, + @IActionViewItemService actionViewItemService: IActionViewItemService ) { - super(Parts.TITLEBAR_PART, mainWindow, editorGroupService.mainPart, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService, editorGroupService, editorService, menuService, keybindingService); + super(Parts.TITLEBAR_PART, mainWindow, editorGroupService.mainPart, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService, editorGroupService, editorService, menuService, keybindingService, actionViewItemService); } } @@ -329,10 +332,11 @@ export class AuxiliaryNativeTitlebarPart extends NativeTitlebarPart implements I @IEditorGroupsService editorGroupService: IEditorGroupsService, @IEditorService editorService: IEditorService, @IMenuService menuService: IMenuService, - @IKeybindingService keybindingService: IKeybindingService + @IKeybindingService keybindingService: IKeybindingService, + @IActionViewItemService actionViewItemService: IActionViewItemService ) { const id = AuxiliaryNativeTitlebarPart.COUNTER++; - super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService, editorGroupService, editorService, menuService, keybindingService); + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), editorGroupsContainer, contextMenuService, configurationService, environmentService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService, editorGroupService, editorService, menuService, keybindingService, actionViewItemService); } override get preventZoom(): boolean { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts index ce66bde954d78..d668b9522f483 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts @@ -13,9 +13,9 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../../contrib/chat/common/customizationHarnessService.js'; +import { ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../../contrib/chat/common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../../contrib/chat/common/plugins/agentPluginService.js'; -import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionsService, SessionType } from '../../../../contrib/chat/common/chatSessionsService.js'; import { PromptsType } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, AgentInstructionFileType, PromptsStorage, IPromptPath, IAgentInstructionFile } from '../../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { AICustomizationManagementSection } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; @@ -114,7 +114,7 @@ function createMockWorkspaceService(): IAICustomizationWorkspaceService { function createMockHarnessService(): ICustomizationHarnessService { const descriptor = createVSCodeHarnessDescriptor([PromptsStorage.extension]); return new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly activeHarness = observableValue('activeHarness', SessionType.Local); override readonly availableHarnesses = observableValue('harnesses', [descriptor]); override getStorageSourceFilter() { return defaultFilter; } override getActiveDescriptor() { return descriptor; } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 0e26f3d707678..0f1e585f29aab 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -31,8 +31,8 @@ import { IPathService } from '../../../../services/path/common/pathService.js'; import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { IWebviewService } from '../../../../contrib/webview/browser/webview.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots } from '../../../../contrib/chat/common/customizationHarnessService.js'; -import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots } from '../../../../contrib/chat/common/customizationHarnessService.js'; +import { IChatSessionsService, SessionType } from '../../../../contrib/chat/common/chatSessionsService.js'; import { PromptsType } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, AgentInstructionFileType, PromptsStorage, IAgentSkill, IChatPromptSlashCommand, IAgentInstructionFile } from '../../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { ParsedPromptFile } from '../../../../contrib/chat/common/promptSyntax/promptFileParser.js'; @@ -183,8 +183,8 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IAge }(); } -function createMockHarnessService(activeHarness: CustomizationHarness, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService { - const active = observableValue('activeHarness', activeHarness); +function createMockHarnessService(activeHarnessId: string, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService { + const active = observableValue('activeHarness', activeHarnessId); return new class extends mock() { override readonly activeHarness = active; override readonly availableHarnesses = constObservable(descriptors); @@ -346,7 +346,7 @@ const mcpRuntimeServers = [ ]; interface IRenderEditorOptions { - readonly harness: CustomizationHarness; + readonly harnessId: string; readonly isSessionsWindow?: boolean; readonly managementSections?: readonly AICustomizationManagementSection[]; readonly availableHarnesses?: readonly IHarnessDescriptor[]; @@ -447,7 +447,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor const instantiationService = createEditorServices(ctx.disposableStore, { colorTheme: ctx.theme, additionalServices: (reg) => { - const harnessService = createMockHarnessService(options.harness, availableHarnesses); + const harnessService = createMockHarnessService(options.harnessId, availableHarnesses); const agentFeedbackService = createMockAgentFeedbackService(); const codeReviewService = createMockCodeReviewService(); registerWorkbenchServices(reg); @@ -675,7 +675,7 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise } }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly activeHarness = observableValue('activeHarness', SessionType.Local); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -783,7 +783,7 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly activeHarness = observableValue('activeHarness', SessionType.Local); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -891,7 +891,7 @@ function renderMcpDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): voi } }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly activeHarness = observableValue('activeHarness', SessionType.Local); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -916,7 +916,7 @@ function renderPluginDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): reg.define(IListService, ListService); reg.defineInstance(IConfigurationService, createDisabledConfigService(ChatConfiguration.PluginsEnabled, false, byPolicy)); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly activeHarness = observableValue('activeHarness', SessionType.Local); override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } override registerExternalHarness() { return { dispose() { } }; } }()); @@ -947,21 +947,21 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // Welcome page — default state with no section selected WelcomePage: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode }), + render: ctx => renderEditor(ctx, { harnessId: SessionType.Local }), }), // Full editor with Local (VS Code) harness — all sections visible, harness dropdown, // Generate buttons, AGENTS.md shortcut, all storage groups LocalHarness: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode, selectedSection: AICustomizationManagementSection.Agents }), + render: ctx => renderEditor(ctx, { harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Agents }), }), // Full editor with Copilot CLI harness — no prompts section, CLI-specific // root files and instruction filtering under .github/.copilot paths. CliHarness: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harness: CustomizationHarness.CLI, selectedSection: AICustomizationManagementSection.Agents }), + render: ctx => renderEditor(ctx, { harnessId: SessionType.CopilotCLI, selectedSection: AICustomizationManagementSection.Agents }), }), // Sessions-window variant of the full editor with workspace override UX @@ -969,7 +969,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { Sessions: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.CLI, + harnessId: SessionType.CopilotCLI, isSessionsWindow: true, selectedSection: AICustomizationManagementSection.Agents, availableHarnesses: [ @@ -991,7 +991,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { SessionsSkillsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.CLI, + harnessId: SessionType.CopilotCLI, isSessionsWindow: true, selectedSection: AICustomizationManagementSection.Skills, availableHarnesses: [ @@ -1017,7 +1017,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.McpServers, }), }), @@ -1026,7 +1026,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { AgentsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Agents, }), }), @@ -1035,7 +1035,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { SkillsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Skills, }), }), @@ -1044,7 +1044,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { InstructionsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Instructions, }), }), @@ -1053,7 +1053,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { HooksTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Hooks, }), }), @@ -1062,7 +1062,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PromptsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Prompts, }), }), @@ -1071,7 +1071,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginsTab: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Plugins, }), }), @@ -1117,7 +1117,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PromptsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Prompts, scrollToBottom: true, }), @@ -1126,7 +1126,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.McpServers, scrollToBottom: true, }), @@ -1135,7 +1135,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Plugins, scrollToBottom: true, }), @@ -1145,7 +1145,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServersTabNarrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.McpServers, width: 550, height: 400, @@ -1155,7 +1155,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { AgentsTabNarrow: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Agents, width: 550, height: 400, @@ -1167,7 +1167,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { AgentsItemEditor: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Agents, openFirstItem: true, }), @@ -1177,7 +1177,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { McpServerDetail: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.McpServers, openFirstItem: true, }), @@ -1187,7 +1187,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { PluginDetail: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, + harnessId: SessionType.Local, selectedSection: AICustomizationManagementSection.Plugins, openFirstItem: true, }),