diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index af0becdc630..a100345f459 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -137,3 +137,6 @@ function f(x: number, y: string): void { } - When adding file watching, prefer correlated file watchers (via fileService.createWatcher) to shared ones. - When adding tooltips to UI elements, prefer the use of IHoverService service. - Do not duplicate code. Always look for existing utility functions, helpers, or patterns in the codebase before implementing new functionality. Reuse and extend existing code whenever possible. + +## Learnings +- Minimize the amount of assertions in tests. Prefer one snapshot-style `assert.deepStrictEqual` over multiple precise assertions, as they are much more difficult to understand and to update. diff --git a/.github/instructions/learnings.instructions.md b/.github/instructions/learnings.instructions.md index 78a9f52a06e..9358a943e3d 100644 --- a/.github/instructions/learnings.instructions.md +++ b/.github/instructions/learnings.instructions.md @@ -8,14 +8,13 @@ It is a meta-instruction file. Structure of learnings: * Each instruction file has a "Learnings" section. -* Each learning has a counter that indicates how often that learning was useful (initially 1). * Each learning has a 1-4 sentences description of the learning. Example: ```markdown ## Learnings -* Prefer `const` over `let` whenever possible (1) -* Avoid `any` type (3) +* Prefer `const` over `let` whenever possible +* Avoid `any` type ``` When the user tells you "learn!", you should: @@ -23,10 +22,7 @@ When the user tells you "learn!", you should: * identify the problem that you created * identify why it was a problem * identify how you were told to fix it/how the user fixed it + * reflect over it, maybe it can be generalized? Avoid too specific learnings. * create a learning (1-4 sentences) from that * Write this out to the user and reflect over these sentences * then, add the reflected learning to the "Learnings" section of the most appropriate instruction file - - - Important: Whenever a learning was really useful, increase the counter!! - When a learning was not useful and just caused more problems, decrease the counter. diff --git a/.vscode/notebooks/api.github-issues b/.vscode/notebooks/api.github-issues index d466fa1b04b..aca29690dc2 100644 --- a/.vscode/notebooks/api.github-issues +++ b/.vscode/notebooks/api.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"October 2025\"" + "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"January 2026\"" }, { "kind": 1, diff --git a/build/azure-pipelines/common/sanity-tests.yml b/build/azure-pipelines/common/sanity-tests.yml index 4bb3b7e44a2..d6e806594a5 100644 --- a/build/azure-pipelines/common/sanity-tests.yml +++ b/build/azure-pipelines/common/sanity-tests.yml @@ -1,33 +1,37 @@ parameters: - - name: commit + - name: name type: string - - name: quality + - name: displayName type: string - name: poolName type: string - name: os type: string + - name: args + type: string + default: "" jobs: - - job: ${{ parameters.os }} - displayName: ${{ parameters.os }} Sanity Tests + - job: ${{ parameters.name }} + displayName: ${{ parameters.displayName }} pool: name: ${{ parameters.poolName }} os: ${{ parameters.os }} timeoutInMinutes: 30 variables: SANITY_TEST_LOGS: $(Build.SourcesDirectory)/.build/sanity-test-logs + LOG_FILE: $(SANITY_TEST_LOGS)/results.xml templateContext: outputs: - output: pipelineArtifact targetPath: $(SANITY_TEST_LOGS) - artifactName: sanity-test-logs-${{ lower(parameters.os) }}-$(System.JobAttempt) - displayName: Publish Sanity Test Logs + artifactName: sanity-test-logs-${{ parameters.name }}-$(System.JobAttempt) + displayName: Sanity Tests Logs sbomEnabled: false isProduction: false condition: succeededOrFailed() steps: - - checkout: self + - template: ./checkout.yml@self - task: NodeTool@0 inputs: @@ -35,27 +39,34 @@ jobs: versionFilePath: .nvmrc displayName: Install Node.js - - ${{ if eq(parameters.os, 'windows') }}: - - script: | - mkdir "$(SANITY_TEST_LOGS)" - displayName: Create Logs Directory + - bash: | + npm config set registry "$(NPM_REGISTRY)" + echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" + condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) + displayName: Configure NPM Registry - - ${{ else }}: - - script: | - mkdir -p "$(SANITY_TEST_LOGS)" - displayName: Create Logs Directory + - task: npmAuthenticate@0 + inputs: + workingFile: $(NPMRC_PATH) + condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) + displayName: Authenticate with NPM Registry - - script: npm install + - script: npm ci + workingDirectory: ./test/sanity displayName: Install Dependencies - workingDirectory: $(Build.SourcesDirectory)/test/sanity - - script: npm run sanity-test -- --commit ${{ parameters.commit }} --quality ${{ parameters.quality }} --verbose --test-results $(SANITY_TEST_LOGS)/sanity-test.xml + - script: npm run compile + workingDirectory: ./test/sanity + displayName: Compile Sanity Tests + + - script: npm run start -- -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -v ${{ parameters.args }} + workingDirectory: ./test/sanity displayName: Run Sanity Tests - task: PublishTestResults@2 inputs: testResultsFormat: JUnit - testResultsFiles: $(SANITY_TEST_LOGS)/sanity-test.xml - testRunTitle: ${{ parameters.os }} Sanity Tests + testResultsFiles: $(LOG_FILE) + testRunTitle: ${{ parameters.displayName }} condition: succeededOrFailed() displayName: Publish Test Results diff --git a/build/azure-pipelines/product-sanity-tests.yml b/build/azure-pipelines/product-sanity-tests.yml index 79406964f37..f9000fcf457 100644 --- a/build/azure-pipelines/product-sanity-tests.yml +++ b/build/azure-pipelines/product-sanity-tests.yml @@ -3,11 +3,12 @@ pr: none trigger: none parameters: - - name: commit - displayName: Commit + - name: BUILD_COMMIT + displayName: Published Build Commit type: string - - name: quality - displayName: Quality + + - name: BUILD_QUALITY + displayName: Published Build Quality type: string default: insider values: @@ -15,13 +16,24 @@ parameters: - insider - stable + - name: NPM_REGISTRY + displayName: Custom NPM Registry URL + type: string + default: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/npm/registry/" + variables: - name: skipComponentGovernanceDetection value: true - name: Codeql.SkipTaskAutoInjection value: true + - name: BUILD_COMMIT + value: ${{ parameters.BUILD_COMMIT }} + - name: BUILD_QUALITY + value: ${{ parameters.BUILD_QUALITY }} + - name: NPM_REGISTRY + value: ${{ parameters.NPM_REGISTRY }} -name: "$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.quality }})" +name: "$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.BUILD_QUALITY }} ${{ parameters.BUILD_COMMIT }})" resources: repositories: @@ -47,25 +59,35 @@ extends: sourceAnalysisPool: 1es-windows-2022-x64 createAdoIssuesForJustificationsForDisablement: false stages: - - stage: SanityTests + - stage: sanity_tests + displayName: Run Sanity Tests jobs: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - commit: ${{ parameters.commit }} - quality: ${{ parameters.quality }} + name: Windows_x64 + displayName: Windows x64 Sanity Tests poolName: 1es-windows-2022-x64 os: windows - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - commit: ${{ parameters.commit }} - quality: ${{ parameters.quality }} - poolName: 1es-ubuntu-22.04-x64 - os: linux + name: Windows_arm64 + displayName: Windows arm64 Sanity Tests (no runtime) + poolName: 1es-windows-2022-x64 + os: windows + args: --no-runtime-check --grep "win32-arm64" - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - commit: ${{ parameters.commit }} - quality: ${{ parameters.quality }} + name: macOS_x64 + displayName: MacOS x64 Sanity Tests (no runtime) + poolName: AcesShared + os: macOS + args: --no-runtime-check --grep "darwin-x64" + + - template: build/azure-pipelines/common/sanity-tests.yml@self + parameters: + name: macOS_arm64 + displayName: MacOS arm64 Sanity Tests poolName: AcesShared os: macOS diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index dea532739cb..0f2e02380f8 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -899,6 +899,7 @@ "--vscode-window-inactiveBorder" ], "others": [ + "--editor-font-size", "--background-dark", "--background-light", "--chat-editing-last-edit-shift", diff --git a/build/linux/debian/dep-lists.ts b/build/linux/debian/dep-lists.ts index d00eb59e3a2..941501b532c 100644 --- a/build/linux/debian/dep-lists.ts +++ b/build/linux/debian/dep-lists.ts @@ -64,7 +64,6 @@ export const referenceGeneratedDepsByArch = { 'libatk-bridge2.0-0 (>= 2.5.3)', 'libatk1.0-0 (>= 2.11.90)', 'libatspi2.0-0 (>= 2.9.90)', - 'libc6 (>= 2.15)', 'libc6 (>= 2.16)', 'libc6 (>= 2.17)', 'libc6 (>= 2.25)', diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index d8ae8777166..91d4cbc16d5 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -7,7 +7,7 @@ import { Model } from '../model'; import { Repository as BaseRepository, Resource } from '../repository'; -import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, GitErrorCodes, CloneOptions, CommitShortStat, DiffChange, Worktree } from './git'; +import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, GitErrorCodes, CloneOptions, CommitShortStat, DiffChange, Worktree, RepositoryKind } from './git'; import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands, CancellationToken } from 'vscode'; import { combinedDisposable, filterEvent, mapEvent } from '../util'; import { toGitUri } from '../uri'; @@ -78,6 +78,7 @@ export class ApiRepository implements Repository { readonly rootUri: Uri; readonly inputBox: InputBox; + readonly kind: RepositoryKind; readonly state: RepositoryState; readonly ui: RepositoryUIState; @@ -87,6 +88,7 @@ export class ApiRepository implements Repository { constructor(repository: BaseRepository) { this.#repository = repository; + this.kind = this.#repository.kind; this.rootUri = Uri.file(this.#repository.root); this.inputBox = new ApiInputBox(this.#repository.inputBox); this.state = new ApiRepositoryState(this.#repository); diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 18b49fcb268..1e3009499f4 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -126,6 +126,8 @@ export interface DiffChange extends Change { readonly deletions: number; } +export type RepositoryKind = 'repository' | 'submodule' | 'worktree'; + export interface RepositoryState { readonly HEAD: Branch | undefined; readonly refs: Ref[]; diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 8480e6d3617..b528a89cff0 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -11,7 +11,7 @@ import picomatch from 'picomatch'; import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, Disposable, Event, EventEmitter, ExcludeSettingOptions, FileDecoration, FileType, l10n, LogLevel, LogOutputChannel, Memento, ProgressLocation, ProgressOptions, QuickDiffProvider, RelativePattern, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit } from 'vscode'; import { ActionButton } from './actionButton'; import { ApiRepository } from './api/api1'; -import { Branch, BranchQuery, Change, CommitOptions, DiffChange, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status } from './api/git'; +import { Branch, BranchQuery, Change, CommitOptions, DiffChange, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, RepositoryKind, Status } from './api/git'; import { AutoFetcher } from './autofetch'; import { GitBranchProtectionProvider, IBranchProtectionProviderRegistry } from './branchProtection'; import { debounce, memoize, sequentialize, throttle } from './decorators'; @@ -870,7 +870,7 @@ export class Repository implements Disposable { return this.repository.dotGit; } - get kind(): 'repository' | 'submodule' | 'worktree' { + get kind(): RepositoryKind { return this.repository.kind; } diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index 6d832e6c159..95d0a131b7c 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -7,9 +7,9 @@ export type JSONLanguageStatus = { schemas: string[] }; import { workspace, window, languages, commands, LogOutputChannel, ExtensionContext, extensions, Uri, ColorInformation, - Diagnostic, StatusBarAlignment, TextEditor, TextDocument, FormattingOptions, CancellationToken, FoldingRange, + Diagnostic, StatusBarAlignment, TextDocument, FormattingOptions, CancellationToken, FoldingRange, ProviderResult, TextEdit, Range, Position, Disposable, CompletionItem, CompletionList, CompletionContext, Hover, MarkdownString, FoldingContext, DocumentSymbol, SymbolInformation, l10n, - RelativePattern + RelativePattern, CodeAction, CodeActionKind, CodeActionContext } from 'vscode'; import { LanguageClientOptions, RequestType, NotificationType, FormattingOptions as LSPFormattingOptions, DocumentDiagnosticReportKind, @@ -20,8 +20,9 @@ import { import { hash } from './utils/hash'; -import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem } from './languageStatus'; +import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem, createSchemaLoadIssueItem, createSchemaLoadStatusItem } from './languageStatus'; import { getLanguageParticipants, LanguageParticipants } from './languageParticipants'; +import { matchesUrlPattern } from './utils/urlMatch'; namespace VSCodeContentRequest { export const type: RequestType = new RequestType('vscode/content'); @@ -42,6 +43,7 @@ namespace LanguageStatusRequest { namespace ValidateContentRequest { export const type: RequestType<{ schemaUri: string; content: string }, LSPDiagnostic[], any> = new RequestType('json/validateContent'); } + interface SortOptions extends LSPFormattingOptions { } @@ -110,6 +112,7 @@ export namespace SettingIds { export const enableKeepLines = 'json.format.keepLines'; export const enableValidation = 'json.validate.enable'; export const enableSchemaDownload = 'json.schemaDownload.enable'; + export const trustedDomains = 'json.schemaDownload.trustedDomains'; export const maxItemsComputed = 'json.maxItemsComputed'; export const editorFoldingMaximumRegions = 'editor.foldingMaximumRegions'; export const editorColorDecoratorsLimit = 'editor.colorDecoratorsLimit'; @@ -119,6 +122,17 @@ export namespace SettingIds { export const colorDecoratorsLimit = 'colorDecoratorsLimit'; } +export namespace CommandIds { + export const workbenchActionOpenSettings = 'workbench.action.openSettings'; + export const workbenchTrustManage = 'workbench.trust.manage'; + export const retryResolveSchemaCommandId = '_json.retryResolveSchema'; + export const configureTrustedDomainsCommandId = '_json.configureTrustedDomains'; + export const showAssociatedSchemaList = '_json.showAssociatedSchemaList'; + export const clearCacheCommandId = 'json.clearCache'; + export const validateCommandId = 'json.validate'; + export const sortCommandId = 'json.sort'; +} + export interface TelemetryReporter { sendTelemetryEvent(eventName: string, properties?: { [key: string]: string; @@ -143,6 +157,16 @@ export interface SchemaRequestService { clearCache?(): Promise; } +export enum SchemaRequestServiceErrors { + UntrustedWorkspaceError = 1, + UntrustedSchemaError = 2, + OpenTextDocumentAccessError = 3, + HTTPDisabledError = 4, + HTTPError = 5, + VSCodeAccessError = 6, + UntitledAccessError = 7, +} + export const languageServerDescription = l10n.t('JSON Language Server'); let resultLimit = 5000; @@ -191,6 +215,8 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const toDispose: Disposable[] = []; let rangeFormatting: Disposable | undefined = undefined; + let settingsCache: Settings | undefined = undefined; + let schemaAssociationsCache: Promise | undefined = undefined; const documentSelector = languageParticipants.documentSelector; @@ -200,14 +226,18 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP toDispose.push(schemaResolutionErrorStatusBarItem); const fileSchemaErrors = new Map(); - let schemaDownloadEnabled = true; + let schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload); + let trustedDomains = workspace.getConfiguration().get>(SettingIds.trustedDomains, {}); let isClientReady = false; const documentSymbolsLimitStatusbarItem = createLimitStatusItem((limit: number) => createDocumentSymbolsLimitItem(documentSelector, SettingIds.maxItemsComputed, limit)); toDispose.push(documentSymbolsLimitStatusbarItem); - toDispose.push(commands.registerCommand('json.clearCache', async () => { + const schemaLoadStatusItem = createSchemaLoadStatusItem((diagnostic: Diagnostic) => createSchemaLoadIssueItem(documentSelector, schemaDownloadEnabled, diagnostic)); + toDispose.push(schemaLoadStatusItem); + + toDispose.push(commands.registerCommand(CommandIds.clearCacheCommandId, async () => { if (isClientReady && runtime.schemaRequests.clearCache) { const cachedSchemas = await runtime.schemaRequests.clearCache(); await client.sendNotification(SchemaContentChangeNotification.type, cachedSchemas); @@ -215,12 +245,12 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP window.showInformationMessage(l10n.t('JSON schema cache cleared.')); })); - toDispose.push(commands.registerCommand('json.validate', async (schemaUri: Uri, content: string) => { + toDispose.push(commands.registerCommand(CommandIds.validateCommandId, async (schemaUri: Uri, content: string) => { const diagnostics: LSPDiagnostic[] = await client.sendRequest(ValidateContentRequest.type, { schemaUri: schemaUri.toString(), content }); return diagnostics.map(client.protocol2CodeConverter.asDiagnostic); })); - toDispose.push(commands.registerCommand('json.sort', async () => { + toDispose.push(commands.registerCommand(CommandIds.sortCommandId, async () => { if (isClientReady) { const textEditor = window.activeTextEditor; @@ -239,17 +269,10 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP } })); - function filterSchemaErrorDiagnostics(uri: Uri, diagnostics: Diagnostic[]): Diagnostic[] { - const schemaErrorIndex = diagnostics.findIndex(isSchemaResolveError); - if (schemaErrorIndex !== -1) { - const schemaResolveDiagnostic = diagnostics[schemaErrorIndex]; - fileSchemaErrors.set(uri.toString(), schemaResolveDiagnostic.message); - if (!schemaDownloadEnabled) { - diagnostics = diagnostics.filter(d => !isSchemaResolveError(d)); - } - if (window.activeTextEditor && window.activeTextEditor.document.uri.toString() === uri.toString()) { - schemaResolutionErrorStatusBarItem.show(); - } + function handleSchemaErrorDiagnostics(uri: Uri, diagnostics: Diagnostic[]): Diagnostic[] { + schemaLoadStatusItem.update(uri, diagnostics); + if (!schemaDownloadEnabled) { + return diagnostics.filter(d => !isSchemaResolveError(d)); } return diagnostics; } @@ -270,18 +293,18 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP }, middleware: { workspace: { - didChangeConfiguration: () => client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings() }) + didChangeConfiguration: () => client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings(true) }) }, provideDiagnostics: async (uriOrDoc, previousResolutId, token, next) => { const diagnostics = await next(uriOrDoc, previousResolutId, token); if (diagnostics && diagnostics.kind === DocumentDiagnosticReportKind.Full) { const uri = uriOrDoc instanceof Uri ? uriOrDoc : uriOrDoc.uri; - diagnostics.items = filterSchemaErrorDiagnostics(uri, diagnostics.items); + diagnostics.items = handleSchemaErrorDiagnostics(uri, diagnostics.items); } return diagnostics; }, handleDiagnostics: (uri: Uri, diagnostics: Diagnostic[], next: HandleDiagnosticsSignature) => { - diagnostics = filterSchemaErrorDiagnostics(uri, diagnostics); + diagnostics = handleSchemaErrorDiagnostics(uri, diagnostics); next(uri, diagnostics); }, // testing the replace / insert mode @@ -373,7 +396,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const uri = Uri.parse(uriPath); const uriString = uri.toString(true); if (uri.scheme === 'untitled') { - throw new ResponseError(3, l10n.t('Unable to load {0}', uriString)); + throw new ResponseError(SchemaRequestServiceErrors.UntitledAccessError, l10n.t('Unable to load {0}', uriString)); } if (uri.scheme === 'vscode') { try { @@ -382,7 +405,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const content = await workspace.fs.readFile(uri); return new TextDecoder().decode(content); } catch (e) { - throw new ResponseError(5, e.toString(), e); + throw new ResponseError(SchemaRequestServiceErrors.VSCodeAccessError, e.toString(), e); } } else if (uri.scheme !== 'http' && uri.scheme !== 'https') { try { @@ -390,9 +413,15 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP schemaDocuments[uriString] = true; return document.getText(); } catch (e) { - throw new ResponseError(2, e.toString(), e); + throw new ResponseError(SchemaRequestServiceErrors.OpenTextDocumentAccessError, e.toString(), e); + } + } else if (schemaDownloadEnabled) { + if (!workspace.isTrusted) { + throw new ResponseError(SchemaRequestServiceErrors.UntrustedWorkspaceError, l10n.t('Downloading schemas is disabled in untrusted workspaces')); + } + if (!await isTrusted(uri)) { + throw new ResponseError(SchemaRequestServiceErrors.UntrustedSchemaError, l10n.t('Location {0} is untrusted', uriString)); } - } else if (schemaDownloadEnabled && workspace.isTrusted) { if (runtime.telemetry && uri.authority === 'schema.management.azure.com') { /* __GDPR__ "json.schema" : { @@ -406,13 +435,10 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP try { return await runtime.schemaRequests.getContent(uriString); } catch (e) { - throw new ResponseError(4, e.toString()); + throw new ResponseError(SchemaRequestServiceErrors.HTTPError, e.toString(), e); } } else { - if (!workspace.isTrusted) { - throw new ResponseError(1, l10n.t('Downloading schemas is disabled in untrusted workspaces')); - } - throw new ResponseError(1, l10n.t('Downloading schemas is disabled through setting \'{0}\'', SettingIds.enableSchemaDownload)); + throw new ResponseError(SchemaRequestServiceErrors.HTTPDisabledError, l10n.t('Downloading schemas is disabled through setting \'{0}\'', SettingIds.enableSchemaDownload)); } }); @@ -427,19 +453,6 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP } return false; }; - const handleActiveEditorChange = (activeEditor?: TextEditor) => { - if (!activeEditor) { - return; - } - - const activeDocUri = activeEditor.document.uri.toString(); - - if (activeDocUri && fileSchemaErrors.has(activeDocUri)) { - schemaResolutionErrorStatusBarItem.show(); - } else { - schemaResolutionErrorStatusBarItem.hide(); - } - }; const handleContentClosed = (uriString: string) => { if (handleContentChange(uriString)) { delete schemaDocuments[uriString]; @@ -484,59 +497,81 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP toDispose.push(workspace.onDidChangeTextDocument(e => handleContentChange(e.document.uri.toString()))); toDispose.push(workspace.onDidCloseTextDocument(d => handleContentClosed(d.uri.toString()))); - toDispose.push(window.onDidChangeActiveTextEditor(handleActiveEditorChange)); + toDispose.push(commands.registerCommand(CommandIds.retryResolveSchemaCommandId, triggerValidation)); - const handleRetryResolveSchemaCommand = () => { - if (window.activeTextEditor) { - schemaResolutionErrorStatusBarItem.text = '$(watch)'; - const activeDocUri = window.activeTextEditor.document.uri.toString(); - client.sendRequest(ForceValidateRequest.type, activeDocUri).then((diagnostics) => { - const schemaErrorIndex = diagnostics.findIndex(isSchemaResolveError); - if (schemaErrorIndex !== -1) { - // Show schema resolution errors in status bar only; ref: #51032 - const schemaResolveDiagnostic = diagnostics[schemaErrorIndex]; - fileSchemaErrors.set(activeDocUri, schemaResolveDiagnostic.message); - } else { - schemaResolutionErrorStatusBarItem.hide(); + toDispose.push(commands.registerCommand(CommandIds.configureTrustedDomainsCommandId, configureTrustedDomains)); + + toDispose.push(languages.registerCodeActionsProvider(documentSelector, { + provideCodeActions(_document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] { + const codeActions: CodeAction[] = []; + + for (const diagnostic of context.diagnostics) { + if (typeof diagnostic.code !== 'number') { + continue; } - schemaResolutionErrorStatusBarItem.text = '$(alert)'; - }); + switch (diagnostic.code) { + case ErrorCodes.UntrustedSchemaError: { + const title = l10n.t('Configure Trusted Domains...'); + const action = new CodeAction(title, CodeActionKind.QuickFix); + const schemaUri = diagnostic.relatedInformation?.[0]?.location.uri; + if (schemaUri) { + action.command = { command: CommandIds.configureTrustedDomainsCommandId, arguments: [schemaUri.toString()], title }; + } else { + action.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.trustedDomains], title }; + } + action.diagnostics = [diagnostic]; + action.isPreferred = true; + codeActions.push(action); + } + break; + case ErrorCodes.HTTPDisabledError: { + const title = l10n.t('Enable Schema Downloading...'); + const action = new CodeAction(title, CodeActionKind.QuickFix); + action.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.enableSchemaDownload], title }; + action.diagnostics = [diagnostic]; + action.isPreferred = true; + codeActions.push(action); + } + break; + } + } + + return codeActions; } - }; - - toDispose.push(commands.registerCommand('_json.retryResolveSchema', handleRetryResolveSchemaCommand)); - - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); - - toDispose.push(extensions.onDidChange(async _ => { - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); + }, { + providedCodeActionKinds: [CodeActionKind.QuickFix] })); - const associationWatcher = workspace.createFileSystemWatcher(new RelativePattern( - Uri.parse(`vscode://schemas-associations/`), - '**/schemas-associations.json') - ); + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(false)); + + toDispose.push(extensions.onDidChange(async _ => { + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true)); + })); + + const associationWatcher = workspace.createFileSystemWatcher(new RelativePattern(Uri.parse(`vscode://schemas-associations/`), '**/schemas-associations.json')); toDispose.push(associationWatcher); toDispose.push(associationWatcher.onDidChange(async _e => { - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true)); })); // manually register / deregister format provider based on the `json.format.enable` setting avoiding issues with late registration. See #71652. updateFormatterRegistration(); toDispose.push({ dispose: () => rangeFormatting && rangeFormatting.dispose() }); - updateSchemaDownloadSetting(); - toDispose.push(workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration(SettingIds.enableFormatter)) { updateFormatterRegistration(); } else if (e.affectsConfiguration(SettingIds.enableSchemaDownload)) { - updateSchemaDownloadSetting(); + schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload); + triggerValidation(); } else if (e.affectsConfiguration(SettingIds.editorFoldingMaximumRegions) || e.affectsConfiguration(SettingIds.editorColorDecoratorsLimit)) { - client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings() }); + client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings(true) }); + } else if (e.affectsConfiguration(SettingIds.trustedDomains)) { + trustedDomains = workspace.getConfiguration().get>(SettingIds.trustedDomains, {}); + triggerValidation(); } })); - toDispose.push(workspace.onDidGrantWorkspaceTrust(updateSchemaDownloadSetting)); + toDispose.push(workspace.onDidGrantWorkspaceTrust(() => triggerValidation())); toDispose.push(createLanguageStatusItem(documentSelector, (uri: string) => client.sendRequest(LanguageStatusRequest.type, uri))); @@ -572,20 +607,13 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP } } - function updateSchemaDownloadSetting() { - if (!workspace.isTrusted) { - schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Unable to download schemas in untrusted workspaces.'); - schemaResolutionErrorStatusBarItem.command = 'workbench.trust.manage'; - return; - } - schemaDownloadEnabled = workspace.getConfiguration().get(SettingIds.enableSchemaDownload) !== false; - if (schemaDownloadEnabled) { - schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Unable to resolve schema. Click to retry.'); - schemaResolutionErrorStatusBarItem.command = '_json.retryResolveSchema'; - handleRetryResolveSchemaCommand(); - } else { - schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Downloading schemas is disabled. Click to configure.'); - schemaResolutionErrorStatusBarItem.command = { command: 'workbench.action.openSettings', arguments: [SettingIds.enableSchemaDownload], title: '' }; + async function triggerValidation() { + const activeTextEditor = window.activeTextEditor; + if (activeTextEditor && languageParticipants.hasLanguage(activeTextEditor.document.languageId)) { + schemaResolutionErrorStatusBarItem.text = '$(watch)'; + schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Validating...'); + const activeDocUri = activeTextEditor.document.uri.toString(); + await client.sendRequest(ForceValidateRequest.type, activeDocUri); } } @@ -612,6 +640,113 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP }); } + function getSettings(forceRefresh: boolean): Settings { + if (!settingsCache || forceRefresh) { + settingsCache = computeSettings(); + } + return settingsCache; + } + + async function getSchemaAssociations(forceRefresh: boolean): Promise { + if (!schemaAssociationsCache || forceRefresh) { + schemaAssociationsCache = computeSchemaAssociations(); + runtime.logOutputChannel.info(`Computed schema associations: ${(await schemaAssociationsCache).map(a => `${a.uri} -> [${a.fileMatch.join(', ')}]`).join('\n')}`); + + } + return schemaAssociationsCache; + } + + async function isTrusted(uri: Uri): Promise { + if (uri.scheme !== 'http' && uri.scheme !== 'https') { + return true; + } + const uriString = uri.toString(true); + + // Check against trustedDomains setting + if (matchesUrlPattern(uri, trustedDomains)) { + return true; + } + + const knownAssociations = await getSchemaAssociations(false); + for (const association of knownAssociations) { + if (association.uri === uriString) { + return true; + } + } + const settingsCache = getSettings(false); + if (settingsCache.json && settingsCache.json.schemas) { + for (const schemaSetting of settingsCache.json.schemas) { + const schemaUri = schemaSetting.url; + if (schemaUri === uriString) { + return true; + } + } + } + return false; + } + + async function configureTrustedDomains(schemaUri: string): Promise { + interface QuickPickItemWithAction { + label: string; + description?: string; + execute: () => Promise; + } + + const items: QuickPickItemWithAction[] = []; + + try { + const uri = Uri.parse(schemaUri); + const domain = `${uri.scheme}://${uri.authority}`; + + // Add "Trust domain" option + items.push({ + label: l10n.t('Trust Domain: {0}', domain), + description: l10n.t('Allow all schemas from this domain'), + execute: async () => { + const config = workspace.getConfiguration(); + const currentDomains = config.get>(SettingIds.trustedDomains, {}); + currentDomains[domain] = true; + await config.update(SettingIds.trustedDomains, currentDomains, true); + await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); + } + }); + + // Add "Trust URI" option + items.push({ + label: l10n.t('Trust URI: {0}', schemaUri), + description: l10n.t('Allow only this specific schema'), + execute: async () => { + const config = workspace.getConfiguration(); + const currentDomains = config.get>(SettingIds.trustedDomains, {}); + currentDomains[schemaUri] = true; + await config.update(SettingIds.trustedDomains, currentDomains, true); + await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); + } + }); + } catch (e) { + runtime.logOutputChannel.error(`Failed to parse schema URI: ${schemaUri}`); + } + + + // Always add "Configure setting" option + items.push({ + label: l10n.t('Configure Setting'), + description: l10n.t('Open settings editor'), + execute: async () => { + await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); + } + }); + + const selected = await window.showQuickPick(items, { + placeHolder: l10n.t('Select how to configure trusted schema domains') + }); + + if (selected) { + await selected.execute(); + } + } + + return { dispose: async () => { await client.stop(); @@ -621,9 +756,9 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP }; } -async function getSchemaAssociations(): Promise { - return getSchemaExtensionAssociations() - .concat(await getDynamicSchemaAssociations()); +async function computeSchemaAssociations(): Promise { + const extensionAssociations = getSchemaExtensionAssociations(); + return extensionAssociations.concat(await getDynamicSchemaAssociations()); } function getSchemaExtensionAssociations(): ISchemaAssociation[] { @@ -680,7 +815,9 @@ async function getDynamicSchemaAssociations(): Promise { return result; } -function getSettings(): Settings { + + +function computeSettings(): Settings { const configuration = workspace.getConfiguration(); const httpSettings = workspace.getConfiguration('http'); @@ -781,8 +918,14 @@ function updateMarkdownString(h: MarkdownString): MarkdownString { return n; } -function isSchemaResolveError(d: Diagnostic) { - return d.code === /* SchemaResolveError */ 0x300; +export namespace ErrorCodes { + export const SchemaResolveError = 0x10000; + export const UntrustedSchemaError = SchemaResolveError + SchemaRequestServiceErrors.UntrustedSchemaError; + export const HTTPDisabledError = SchemaResolveError + SchemaRequestServiceErrors.HTTPDisabledError; +} + +export function isSchemaResolveError(d: Diagnostic) { + return typeof d.code === 'number' && d.code >= ErrorCodes.SchemaResolveError; } diff --git a/extensions/json-language-features/client/src/languageStatus.ts b/extensions/json-language-features/client/src/languageStatus.ts index 1064a0b5956..a608b4be7ca 100644 --- a/extensions/json-language-features/client/src/languageStatus.ts +++ b/extensions/json-language-features/client/src/languageStatus.ts @@ -6,9 +6,9 @@ import { window, languages, Uri, Disposable, commands, QuickPickItem, extensions, workspace, Extension, WorkspaceFolder, QuickPickItemKind, - ThemeIcon, TextDocument, LanguageStatusSeverity, l10n, DocumentSelector + ThemeIcon, TextDocument, LanguageStatusSeverity, l10n, DocumentSelector, Diagnostic } from 'vscode'; -import { JSONLanguageStatus, JSONSchemaSettings } from './jsonClient'; +import { CommandIds, ErrorCodes, isSchemaResolveError, JSONLanguageStatus, JSONSchemaSettings, SettingIds } from './jsonClient'; type ShowSchemasInput = { schemas: string[]; @@ -168,7 +168,7 @@ export function createLanguageStatusItem(documentSelector: DocumentSelector, sta statusItem.name = l10n.t('JSON Validation Status'); statusItem.severity = LanguageStatusSeverity.Information; - const showSchemasCommand = commands.registerCommand('_json.showAssociatedSchemaList', showSchemaList); + const showSchemasCommand = commands.registerCommand(CommandIds.showAssociatedSchemaList, showSchemaList); const activeEditorListener = window.onDidChangeActiveTextEditor(() => { updateLanguageStatus(); @@ -195,7 +195,7 @@ export function createLanguageStatusItem(documentSelector: DocumentSelector, sta statusItem.detail = l10n.t('multiple JSON schemas configured'); } statusItem.command = { - command: '_json.showAssociatedSchemaList', + command: CommandIds.showAssociatedSchemaList, title: l10n.t('Show Schemas'), arguments: [{ schemas, uri: document.uri.toString() } satisfies ShowSchemasInput] }; @@ -279,3 +279,86 @@ export function createDocumentSymbolsLimitItem(documentSelector: DocumentSelecto } +export function createSchemaLoadStatusItem(newItem: (fileSchemaError: Diagnostic) => Disposable) { + let statusItem: Disposable | undefined; + const fileSchemaErrors: Map = new Map(); + + const toDispose: Disposable[] = []; + toDispose.push(window.onDidChangeActiveTextEditor(textEditor => { + statusItem?.dispose(); + statusItem = undefined; + const doc = textEditor?.document; + if (doc) { + const fileSchemaError = fileSchemaErrors.get(doc.uri.toString()); + if (fileSchemaError !== undefined) { + statusItem = newItem(fileSchemaError); + } + } + })); + toDispose.push(workspace.onDidCloseTextDocument(document => { + fileSchemaErrors.delete(document.uri.toString()); + })); + + function update(uri: Uri, diagnostics: Diagnostic[]) { + const fileSchemaError = diagnostics.find(isSchemaResolveError); + const uriString = uri.toString(); + + if (fileSchemaError === undefined) { + fileSchemaErrors.delete(uriString); + if (statusItem && uriString === window.activeTextEditor?.document.uri.toString()) { + statusItem.dispose(); + statusItem = undefined; + } + } else { + const current = fileSchemaErrors.get(uriString); + if (current?.message === fileSchemaError.message) { + return; + } + fileSchemaErrors.set(uriString, fileSchemaError); + if (uriString === window.activeTextEditor?.document.uri.toString()) { + statusItem?.dispose(); + statusItem = newItem(fileSchemaError); + } + } + } + return { + update, + dispose() { + statusItem?.dispose(); + toDispose.forEach(d => d.dispose()); + toDispose.length = 0; + statusItem = undefined; + fileSchemaErrors.clear(); + } + }; +} + + + +export function createSchemaLoadIssueItem(documentSelector: DocumentSelector, schemaDownloadEnabled: boolean | undefined, diagnostic: Diagnostic): Disposable { + const statusItem = languages.createLanguageStatusItem('json.documentSymbolsStatus', documentSelector); + statusItem.name = l10n.t('JSON Outline Status'); + statusItem.severity = LanguageStatusSeverity.Error; + statusItem.text = 'Schema download issue'; + if (!workspace.isTrusted) { + statusItem.detail = l10n.t('Workspace untrusted'); + statusItem.command = { command: CommandIds.workbenchTrustManage, title: 'Configure Trust' }; + } else if (!schemaDownloadEnabled) { + statusItem.detail = l10n.t('Download disabled'); + statusItem.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.enableSchemaDownload], title: 'Configure' }; + } else if (typeof diagnostic.code === 'number' && diagnostic.code === ErrorCodes.UntrustedSchemaError) { + statusItem.detail = l10n.t('Location untrusted'); + const schemaUri = diagnostic.relatedInformation?.[0]?.location.uri; + if (schemaUri) { + statusItem.command = { command: CommandIds.configureTrustedDomainsCommandId, arguments: [schemaUri.toString()], title: 'Configure Trusted Domains' }; + } else { + statusItem.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.trustedDomains], title: 'Configure Trusted Domains' }; + } + } else { + statusItem.detail = l10n.t('Unable to resolve schema'); + statusItem.command = { command: CommandIds.retryResolveSchemaCommandId, title: 'Retry' }; + } + return Disposable.from(statusItem); +} + + diff --git a/extensions/json-language-features/client/src/utils/urlMatch.ts b/extensions/json-language-features/client/src/utils/urlMatch.ts new file mode 100644 index 00000000000..a870c2d0726 --- /dev/null +++ b/extensions/json-language-features/client/src/utils/urlMatch.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 { Uri } from 'vscode'; + +/** + * Check whether a URL matches the list of trusted domains or URIs. + * + * trustedDomains is an object where: + * - Keys are full domains (https://www.microsoft.com) or full URIs (https://www.test.com/schemas/mySchema.json) + * - Keys can include wildcards (https://*.microsoft.com) or glob patterns + * - Values are booleans indicating if the domain/URI is trusted (true) or blocked (false) + * + * @param url The URL to check + * @param trustedDomains Object mapping domain patterns to boolean trust values + */ +export function matchesUrlPattern(url: Uri, trustedDomains: Record): boolean { + // Check localhost + if (isLocalhostAuthority(url.authority)) { + return true; + } + + for (const [pattern, isTrusted] of Object.entries(trustedDomains)) { + if (typeof pattern !== 'string' || pattern.trim() === '') { + continue; + } + + // Wildcard matches everything + if (pattern === '*') { + return isTrusted; + } + + try { + const patternUri = Uri.parse(pattern); + + // Scheme must match + if (url.scheme !== patternUri.scheme) { + continue; + } + + // Check authority (host:port) + if (!matchesAuthority(url.authority, patternUri.authority)) { + continue; + } + + // Check path + if (!matchesPath(url.path, patternUri.path)) { + continue; + } + + return isTrusted; + } catch { + // Invalid pattern, skip + continue; + } + } + + return false; +} + +function matchesAuthority(urlAuthority: string, patternAuthority: string): boolean { + urlAuthority = urlAuthority.toLowerCase(); + patternAuthority = patternAuthority.toLowerCase(); + + if (patternAuthority === urlAuthority) { + return true; + } + // Handle wildcard subdomains (e.g., *.github.com) + if (patternAuthority.startsWith('*.')) { + const patternDomain = patternAuthority.substring(2); + // Exact match or subdomain match + return urlAuthority === patternDomain || urlAuthority.endsWith('.' + patternDomain); + } + + return false; +} + +function matchesPath(urlPath: string, patternPath: string): boolean { + // Empty pattern path or just "/" matches any path + if (!patternPath || patternPath === '/') { + return true; + } + + // Exact match + if (urlPath === patternPath) { + return true; + } + + // If pattern ends with '/', it matches any path starting with it + if (patternPath.endsWith('/')) { + return urlPath.startsWith(patternPath); + } + + // Otherwise, pattern must be a prefix + return urlPath.startsWith(patternPath + '/') || urlPath === patternPath; +} + + +const rLocalhost = /^(.+\.)?localhost(:\d+)?$/i; +const r127 = /^127\.0\.0\.1(:\d+)?$/; +const rIPv6Localhost = /^\[::1\](:\d+)?$/; + +function isLocalhostAuthority(authority: string): boolean { + return rLocalhost.test(authority) || r127.test(authority) || rIPv6Localhost.test(authority); +} diff --git a/extensions/json-language-features/package.json b/extensions/json-language-features/package.json index 50da0468e48..429e051159e 100644 --- a/extensions/json-language-features/package.json +++ b/extensions/json-language-features/package.json @@ -126,6 +126,22 @@ "tags": [ "usesOnlineServices" ] + }, + "json.schemaDownload.trustedDomains": { + "type": "object", + "default": { + "https://schemastore.azurewebsites.net/": true, + "https://raw.githubusercontent.com/": true, + "https://www.schemastore.org/": true, + "https://json-schema.org/": true + }, + "additionalProperties": { + "type": "boolean" + }, + "description": "%json.schemaDownload.trustedDomains.desc%", + "tags": [ + "usesOnlineServices" + ] } } }, diff --git a/extensions/json-language-features/package.nls.json b/extensions/json-language-features/package.nls.json index abc07c993dc..9052d3781c9 100644 --- a/extensions/json-language-features/package.nls.json +++ b/extensions/json-language-features/package.nls.json @@ -19,6 +19,6 @@ "json.enableSchemaDownload.desc": "When enabled, JSON schemas can be fetched from http and https locations.", "json.command.clearCache": "Clear Schema Cache", "json.command.sort": "Sort Document", - "json.workspaceTrust": "The extension requires workspace trust to load schemas from http and https." - + "json.workspaceTrust": "The extension requires workspace trust to load schemas from http and https.", + "json.schemaDownload.trustedDomains.desc": "List of trusted domains for downloading JSON schemas over http(s). Use '*' to trust all domains. '*' can also be used as a wildcard in domain names." } diff --git a/extensions/json-language-features/server/package-lock.json b/extensions/json-language-features/server/package-lock.json index fc31206a0cd..4761136e1bf 100644 --- a/extensions/json-language-features/server/package-lock.json +++ b/extensions/json-language-features/server/package-lock.json @@ -12,7 +12,7 @@ "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "request-light": "^0.8.0", - "vscode-json-languageservice": "^5.6.4", + "vscode-json-languageservice": "^5.7.1", "vscode-languageserver": "^10.0.0-next.15", "vscode-uri": "^3.1.0" }, @@ -67,9 +67,9 @@ "license": "MIT" }, "node_modules/vscode-json-languageservice": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.6.4.tgz", - "integrity": "sha512-i0MhkFmnQAbYr+PiE6Th067qa3rwvvAErCEUo0ql+ghFXHvxbwG3kLbwMaIUrrbCLUDEeULiLgROJjtuyYoIsA==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.1.tgz", + "integrity": "sha512-sMK2F8p7St0lJCr/4IfbQRoEUDUZRR7Ud0IiSl8I/JtN+m9Gv+FJlNkSAYns2R7Ebm/PKxqUuWYOfBej/rAdBQ==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", diff --git a/extensions/json-language-features/server/package.json b/extensions/json-language-features/server/package.json index 00fff97cbe7..6534e6f0eca 100644 --- a/extensions/json-language-features/server/package.json +++ b/extensions/json-language-features/server/package.json @@ -15,7 +15,7 @@ "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "request-light": "^0.8.0", - "vscode-json-languageservice": "^5.6.4", + "vscode-json-languageservice": "^5.7.1", "vscode-languageserver": "^10.0.0-next.15", "vscode-uri": "^3.1.0" }, diff --git a/extensions/json-language-features/server/src/jsonServer.ts b/extensions/json-language-features/server/src/jsonServer.ts index cbe1e7d02b4..811cbcd2e91 100644 --- a/extensions/json-language-features/server/src/jsonServer.ts +++ b/extensions/json-language-features/server/src/jsonServer.ts @@ -5,7 +5,7 @@ import { Connection, - TextDocuments, InitializeParams, InitializeResult, NotificationType, RequestType, + TextDocuments, InitializeParams, InitializeResult, NotificationType, RequestType, ResponseError, DocumentRangeFormattingRequest, Disposable, ServerCapabilities, TextDocumentSyncKind, TextEdit, DocumentFormattingRequest, TextDocumentIdentifier, FormattingOptions, Diagnostic, CodeAction, CodeActionKind } from 'vscode-languageserver'; @@ -36,6 +36,10 @@ namespace ForceValidateRequest { export const type: RequestType = new RequestType('json/validate'); } +namespace ForceValidateAllRequest { + export const type: RequestType = new RequestType('json/validateAll'); +} + namespace LanguageStatusRequest { export const type: RequestType = new RequestType('json/languageStatus'); } @@ -102,8 +106,8 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) } return connection.sendRequest(VSCodeContentRequest.type, uri).then(responseText => { return responseText; - }, error => { - return Promise.reject(error.message); + }, (error: ResponseError) => { + return Promise.reject(error); }); }; } @@ -298,6 +302,10 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) }); // Retry schema validation on all open documents + connection.onRequest(ForceValidateAllRequest.type, async () => { + diagnosticsSupport?.requestRefresh(); + }); + connection.onRequest(ForceValidateRequest.type, async uri => { const document = documents.get(uri); if (document) { @@ -387,11 +395,11 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) connection.onDidChangeWatchedFiles((change) => { // Monitored files have changed in VSCode let hasChanges = false; - change.changes.forEach(c => { + for (const c of change.changes) { if (languageService.resetSchema(c.uri)) { hasChanges = true; } - }); + } if (hasChanges) { diagnosticsSupport?.requestRefresh(); } diff --git a/extensions/theme-defaults/themes/dark_modern.json b/extensions/theme-defaults/themes/dark_modern.json index 51e0f371c27..574d89f9c4a 100644 --- a/extensions/theme-defaults/themes/dark_modern.json +++ b/extensions/theme-defaults/themes/dark_modern.json @@ -13,12 +13,12 @@ "badge.background": "#616161", "badge.foreground": "#F8F8F8", "button.background": "#0078D4", - "button.border": "#FFFFFF12", + "button.border": "#ffffff1a", "button.foreground": "#FFFFFF", "button.hoverBackground": "#026EC1", - "button.secondaryBackground": "#313131", + "button.secondaryBackground": "#00000000", "button.secondaryForeground": "#CCCCCC", - "button.secondaryHoverBackground": "#3C3C3C", + "button.secondaryHoverBackground": "#2B2B2B", "chat.slashCommandBackground": "#26477866", "chat.slashCommandForeground": "#85B6FF", "chat.editedFileForeground": "#E2C08D", diff --git a/package-lock.json b/package-lock.json index c5874a4622b..b14a6d39f8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@vscode/proxy-agent": "^0.36.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.2", - "@vscode/sqlite3": "5.1.10-vscode", + "@vscode/sqlite3": "5.1.11-vscode", "@vscode/sudo-prompt": "9.3.2", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", @@ -46,14 +46,14 @@ "native-is-elevated": "0.8.0", "native-keymap": "^3.3.5", "native-watchdog": "^1.4.1", - "node-pty": "^1.1.0-beta43", + "node-pty": "^1.2.0-beta.6", "open": "^10.1.2", "tas-client": "0.3.1", "undici": "^7.9.0", "v8-inspect-profiler": "^0.1.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", - "vscode-textmate": "^9.3.0", + "vscode-textmate": "^9.3.1", "yauzl": "^3.0.0", "yazl": "^2.4.3" }, @@ -2875,10 +2875,11 @@ ] }, "node_modules/@vscode/deviceid": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.1.tgz", - "integrity": "sha512-ErpoMeKKNYAkR1IT3zxB5RtiTqEECdh8fxggupWvzuxpTAX77hwOI2NdJ7um+vupnXRBZVx4ugo0+dVHJWUkag==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.4.tgz", + "integrity": "sha512-3u705VptsQhKMcHvUMJzaOn9fBrKEQHsl7iibRRVQ8kUNV+cptki7bQXACPNsGtJ5Dh4/7A7W1uKtP3z39GUQg==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "fs-extra": "^11.2.0", "uuid": "^9.0.1" @@ -3258,9 +3259,9 @@ } }, "node_modules/@vscode/policy-watcher": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.3.5.tgz", - "integrity": "sha512-k1n9gaDBjyVRy5yJLABbZCnyFwgQ8OA4sR3vXmXnmB+mO9JA0nsl/XOXQfVCoLasBu3UHCOfAnDWGn2sRzCR+A==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.3.7.tgz", + "integrity": "sha512-OvIczTbtGLZs7YU0ResbjM0KEB2ORBnlJ4ICxaB9fKHNVBwNVp4i2qIkDQGp3UBGtu7P8/+eg4/ZKk2oJGFcug==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3320,9 +3321,9 @@ } }, "node_modules/@vscode/spdlog": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/@vscode/spdlog/-/spdlog-0.15.4.tgz", - "integrity": "sha512-NmFasVWjn/6BjHMAjqalsbG2srQCt8yfC0EczP5wzNQFawv74rhvuarhWi44x3St9LB8bZBxrpbT7igPaTJwcw==", + "version": "0.15.6", + "resolved": "https://registry.npmjs.org/@vscode/spdlog/-/spdlog-0.15.6.tgz", + "integrity": "sha512-s0ei7I0rLrNlsGTa8EVoAXe4qvbsfXrHebQ5dNbu7dc1Zs/DbnJNSADpHUy8vtNvTJukBWjOXFhAYUfXxGk+Bg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3332,9 +3333,9 @@ } }, "node_modules/@vscode/sqlite3": { - "version": "5.1.10-vscode", - "resolved": "https://registry.npmjs.org/@vscode/sqlite3/-/sqlite3-5.1.10-vscode.tgz", - "integrity": "sha512-sCJozBr1jItK4eCtbibX3Vi8BXfNyDsPCplojm89OuydoSxwP+Z3gSgzsTXWD5qYyXpTvVaT3LtHLoH2Byv8oA==", + "version": "5.1.11-vscode", + "resolved": "https://registry.npmjs.org/@vscode/sqlite3/-/sqlite3-5.1.11-vscode.tgz", + "integrity": "sha512-x2vBjFRZj/34Ji46lrxotjUtgljistPZU3cbxpckml3bMwF+Z0zbJYiplIeskHLo2g0Kj3kvR8MRRJ+o2nxNug==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -3559,9 +3560,9 @@ } }, "node_modules/@vscode/windows-mutex": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@vscode/windows-mutex/-/windows-mutex-0.5.2.tgz", - "integrity": "sha512-O9CNYVl2GmFVbiHiz7tyFrKIdXVs3qf8HnyWlfxyuMaKzXd1L35jSTNCC1oAVwr8F0O2P4o3C/jOSIXulUCJ7w==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@vscode/windows-mutex/-/windows-mutex-0.5.3.tgz", + "integrity": "sha512-hWNmD+AzINR57jWuc/iW53kA+BghI4iOuicxhAEeeJLPOeMm9X5IUD0ttDwJFEib+D8H/2T9pT/8FeB/xcqbRw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3580,9 +3581,9 @@ } }, "node_modules/@vscode/windows-registry": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.1.2.tgz", - "integrity": "sha512-/eDRmGNe6g11wHckOyiVLvK/mEE5UBZFeoRlBosIL343LDrSKUL5JDAcFeAZqOXnlTtZ3UZtj5yezKiAz99NcA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.1.3.tgz", + "integrity": "sha512-si8+b+2Wh0x2X6W2+kgDyLJD9hyGIrjUo1X/7RWlvsxyI5+Pg+bpdHJrVYtIW4cHOPVB0FYFaN1UZndbUbU5lQ==", "hasInstallScript": true, "license": "MIT" }, @@ -12818,9 +12819,9 @@ "license": "MIT" }, "node_modules/native-keymap": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/native-keymap/-/native-keymap-3.3.7.tgz", - "integrity": "sha512-07n5kF0L9ERC9pilqEFucnhs1XG4WttbHAMWhhOSqQYXhB8mMNTSCzP4psTaVgDSp6si2HbIPhTIHuxSia6NPQ==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/native-keymap/-/native-keymap-3.3.9.tgz", + "integrity": "sha512-d/ydQ5x+GM5W0dyAjFPwexhtc9CDH1g/xWZESS5CXk16ThyFzSBLvlBJq1+FyzUIFf/F2g1MaHdOpa6G9150YQ==", "hasInstallScript": true, "license": "MIT" }, @@ -12949,9 +12950,9 @@ } }, "node_modules/node-pty": { - "version": "1.1.0-beta43", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta43.tgz", - "integrity": "sha512-CYyIQogRs97Rfjo0WKyku8V56Bm4WyWUijrbWDs5LJ+ZmsUW2gqbVAEpD+1gtA7dEZ6v1A08GzfqsDuIl/eRqw==", + "version": "1.2.0-beta.6", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.6.tgz", + "integrity": "sha512-0ArHUpsE5y6nSRSkbY36l+bjyuZNMjww0pdsBKCbiw/HTFCikJlsbUuyZc60KPdgH/9YhAiqD2BM8a0AOUVrsw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -17695,9 +17696,9 @@ } }, "node_modules/vscode-textmate": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.0.tgz", - "integrity": "sha512-zHiZZOdb9xqj5/X1C4a29sbgT2HngdWxPLSl3PyHRQF+5visI4uNM020OHiLJjsMxUssyk/pGVAg/9LCIobrVg==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.1.tgz", + "integrity": "sha512-U19nFkCraZF9/bkQKQYsb9mRqM9NwpToQQFl40nGiioZTH9gRtdtCHwp48cubayVfreX3ivnoxgxQgNwrTVmQg==", "license": "MIT" }, "node_modules/vscode-uri": { diff --git a/package.json b/package.json index 6f63917b150..56fe0197794 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.109.0", - "distro": "39ff23997789155762a80ca3f2d965b764339c86", + "distro": "ce89ce05183635114ccfc46870d71ec520727c8e", "author": { "name": "Microsoft Corporation" }, @@ -83,7 +83,7 @@ "@vscode/proxy-agent": "^0.36.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.2", - "@vscode/sqlite3": "5.1.10-vscode", + "@vscode/sqlite3": "5.1.11-vscode", "@vscode/sudo-prompt": "9.3.2", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", @@ -109,14 +109,14 @@ "native-is-elevated": "0.8.0", "native-keymap": "^3.3.5", "native-watchdog": "^1.4.1", - "node-pty": "^1.1.0-beta43", + "node-pty": "^1.2.0-beta.6", "open": "^10.1.2", "tas-client": "0.3.1", "undici": "^7.9.0", "v8-inspect-profiler": "^0.1.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", - "vscode-textmate": "^9.3.0", + "vscode-textmate": "^9.3.1", "yauzl": "^3.0.0", "yazl": "^2.4.3" }, @@ -240,4 +240,4 @@ "optionalDependencies": { "windows-foreground-love": "0.5.0" } -} \ No newline at end of file +} diff --git a/remote/package-lock.json b/remote/package-lock.json index 44adad2a826..fd2b8a14bee 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -38,11 +38,11 @@ "kerberos": "2.1.1", "minimist": "^1.2.8", "native-watchdog": "^1.4.1", - "node-pty": "^1.1.0-beta43", + "node-pty": "^1.2.0-beta.6", "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", - "vscode-textmate": "^9.3.0", + "vscode-textmate": "^9.3.1", "yauzl": "^3.0.0", "yazl": "^2.4.3" } @@ -393,10 +393,11 @@ } }, "node_modules/@vscode/deviceid": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.1.tgz", - "integrity": "sha512-ErpoMeKKNYAkR1IT3zxB5RtiTqEECdh8fxggupWvzuxpTAX77hwOI2NdJ7um+vupnXRBZVx4ugo0+dVHJWUkag==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.4.tgz", + "integrity": "sha512-3u705VptsQhKMcHvUMJzaOn9fBrKEQHsl7iibRRVQ8kUNV+cptki7bQXACPNsGtJ5Dh4/7A7W1uKtP3z39GUQg==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "fs-extra": "^11.2.0", "uuid": "^9.0.1" @@ -451,9 +452,9 @@ } }, "node_modules/@vscode/spdlog": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/@vscode/spdlog/-/spdlog-0.15.4.tgz", - "integrity": "sha512-NmFasVWjn/6BjHMAjqalsbG2srQCt8yfC0EczP5wzNQFawv74rhvuarhWi44x3St9LB8bZBxrpbT7igPaTJwcw==", + "version": "0.15.6", + "resolved": "https://registry.npmjs.org/@vscode/spdlog/-/spdlog-0.15.6.tgz", + "integrity": "sha512-s0ei7I0rLrNlsGTa8EVoAXe4qvbsfXrHebQ5dNbu7dc1Zs/DbnJNSADpHUy8vtNvTJukBWjOXFhAYUfXxGk+Bg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -501,9 +502,9 @@ } }, "node_modules/@vscode/windows-process-tree": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.6.2.tgz", - "integrity": "sha512-uzyUuQ93m7K1jSPrB/72m4IspOyeGpvvghNwFCay/McZ+y4Hk2BnLdZPb6EJ8HLRa3GwCvYjH/MQZzcnLOVnaQ==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.6.3.tgz", + "integrity": "sha512-mjirLbtgjv7P6fwD8gx7iaY961EfGqUExGvfzsKl3spLfScg57ejlMi+7O1jfJqpM2Zly9DTSxyY4cFsDN6c9Q==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -511,9 +512,9 @@ } }, "node_modules/@vscode/windows-registry": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.1.2.tgz", - "integrity": "sha512-/eDRmGNe6g11wHckOyiVLvK/mEE5UBZFeoRlBosIL343LDrSKUL5JDAcFeAZqOXnlTtZ3UZtj5yezKiAz99NcA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@vscode/windows-registry/-/windows-registry-1.1.3.tgz", + "integrity": "sha512-si8+b+2Wh0x2X6W2+kgDyLJD9hyGIrjUo1X/7RWlvsxyI5+Pg+bpdHJrVYtIW4cHOPVB0FYFaN1UZndbUbU5lQ==", "hasInstallScript": true, "license": "MIT" }, @@ -1051,9 +1052,9 @@ } }, "node_modules/node-pty": { - "version": "1.1.0-beta43", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta43.tgz", - "integrity": "sha512-CYyIQogRs97Rfjo0WKyku8V56Bm4WyWUijrbWDs5LJ+ZmsUW2gqbVAEpD+1gtA7dEZ6v1A08GzfqsDuIl/eRqw==", + "version": "1.2.0-beta.6", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.6.tgz", + "integrity": "sha512-0ArHUpsE5y6nSRSkbY36l+bjyuZNMjww0pdsBKCbiw/HTFCikJlsbUuyZc60KPdgH/9YhAiqD2BM8a0AOUVrsw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1399,9 +1400,9 @@ } }, "node_modules/vscode-textmate": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.0.tgz", - "integrity": "sha512-zHiZZOdb9xqj5/X1C4a29sbgT2HngdWxPLSl3PyHRQF+5visI4uNM020OHiLJjsMxUssyk/pGVAg/9LCIobrVg==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.1.tgz", + "integrity": "sha512-U19nFkCraZF9/bkQKQYsb9mRqM9NwpToQQFl40nGiioZTH9gRtdtCHwp48cubayVfreX3ivnoxgxQgNwrTVmQg==", "license": "MIT" }, "node_modules/wrappy": { diff --git a/remote/package.json b/remote/package.json index 479adcd5410..f506788e938 100644 --- a/remote/package.json +++ b/remote/package.json @@ -33,11 +33,11 @@ "kerberos": "2.1.1", "minimist": "^1.2.8", "native-watchdog": "^1.4.1", - "node-pty": "^1.1.0-beta43", + "node-pty": "^1.2.0-beta.6", "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", - "vscode-textmate": "^9.3.0", + "vscode-textmate": "^9.3.1", "yauzl": "^3.0.0", "yazl": "^2.4.3" }, diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 6fef77cf22c..fcdd633aa25 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -26,7 +26,7 @@ "katex": "^0.16.22", "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", - "vscode-textmate": "^9.3.0" + "vscode-textmate": "^9.3.1" } }, "node_modules/@microsoft/1ds-core-js": { @@ -266,9 +266,9 @@ "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==" }, "node_modules/vscode-textmate": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.0.tgz", - "integrity": "sha512-zHiZZOdb9xqj5/X1C4a29sbgT2HngdWxPLSl3PyHRQF+5visI4uNM020OHiLJjsMxUssyk/pGVAg/9LCIobrVg==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.1.tgz", + "integrity": "sha512-U19nFkCraZF9/bkQKQYsb9mRqM9NwpToQQFl40nGiioZTH9gRtdtCHwp48cubayVfreX3ivnoxgxQgNwrTVmQg==", "license": "MIT" }, "node_modules/yallist": { diff --git a/remote/web/package.json b/remote/web/package.json index a90d2e5b957..20b48882695 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -21,6 +21,6 @@ "katex": "^0.16.22", "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", - "vscode-textmate": "^9.3.0" + "vscode-textmate": "^9.3.1" } } diff --git a/src/vs/base/browser/domStylesheets.ts b/src/vs/base/browser/domStylesheets.ts index 1e34173680e..c338502d541 100644 --- a/src/vs/base/browser/domStylesheets.ts +++ b/src/vs/base/browser/domStylesheets.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DisposableStore, toDisposable, IDisposable } from '../common/lifecycle.js'; +import { DisposableStore, toDisposable, IDisposable, Disposable } from '../common/lifecycle.js'; import { autorun, IObservable } from '../common/observable.js'; import { isFirefox } from './browser.js'; import { getWindows, sharedMutationObserver } from './dom.js'; @@ -15,35 +15,27 @@ export function isGlobalStylesheet(node: Node): boolean { return globalStylesheets.has(node as HTMLStyleElement); } -/** - * A version of createStyleSheet which has a unified API to initialize/set the style content. - */ -export function createStyleSheet2(): WrappedStyleElement { - return new WrappedStyleElement(); -} - -class WrappedStyleElement { +class WrappedStyleElement extends Disposable { private _currentCssStyle = ''; private _styleSheet: HTMLStyleElement | undefined = undefined; - public setStyle(cssStyle: string): void { + setStyle(cssStyle: string): void { if (cssStyle === this._currentCssStyle) { return; } this._currentCssStyle = cssStyle; if (!this._styleSheet) { - this._styleSheet = createStyleSheet(mainWindow.document.head, (s) => s.textContent = cssStyle); + this._styleSheet = createStyleSheet(mainWindow.document.head, s => s.textContent = cssStyle, this._store); } else { this._styleSheet.textContent = cssStyle; } } - public dispose(): void { - if (this._styleSheet) { - this._styleSheet.remove(); - this._styleSheet = undefined; - } + override dispose(): void { + super.dispose(); + + this._styleSheet = undefined; } } @@ -121,12 +113,10 @@ function getSharedStyleSheet(): HTMLStyleElement { function getDynamicStyleSheetRules(style: HTMLStyleElement) { if (style?.sheet?.rules) { - // Chrome, IE - return style.sheet.rules; + return style.sheet.rules; // Chrome, IE } if (style?.sheet?.cssRules) { - // FF - return style.sheet.cssRules; + return style.sheet.cssRules; // FF } return []; } @@ -174,7 +164,7 @@ function isCSSStyleRule(rule: CSSRule): rule is CSSStyleRule { export function createStyleSheetFromObservable(css: IObservable): IDisposable { const store = new DisposableStore(); - const w = store.add(createStyleSheet2()); + const w = store.add(new WrappedStyleElement()); store.add(autorun(reader => { w.setStyle(css.read(reader)); })); diff --git a/src/vs/base/browser/ui/button/button.css b/src/vs/base/browser/ui/button/button.css index 2517cd3571c..da2318ec8b6 100644 --- a/src/vs/base/browser/ui/button/button.css +++ b/src/vs/base/browser/ui/button/button.css @@ -7,14 +7,21 @@ box-sizing: border-box; display: flex; width: 100%; - padding: 4px; - border-radius: 2px; + padding: 4px 8px; + border-radius: 4px; text-align: center; cursor: pointer; justify-content: center; align-items: center; border: 1px solid var(--vscode-button-border, transparent); - line-height: 18px; + line-height: 16px; + font-size: 12px; +} + +.monaco-text-button.small { + line-height: 14px; + font-size: 11px; + padding: 3px 6px; } .monaco-text-button:focus { @@ -39,9 +46,7 @@ .monaco-text-button.monaco-text-button-with-short-label { flex-direction: row; flex-wrap: wrap; - padding: 0 4px; overflow: hidden; - height: 28px; } .monaco-text-button.monaco-text-button-with-short-label > .monaco-button-label { @@ -61,7 +66,6 @@ align-items: center; font-weight: normal; font-style: inherit; - padding: 4px 0; } .monaco-button-dropdown { @@ -100,13 +104,13 @@ .monaco-button-dropdown > .monaco-button.monaco-dropdown-button { border: 1px solid var(--vscode-button-border, transparent); border-left-width: 0 !important; - border-radius: 0 2px 2px 0; + border-radius: 0 4px 4px 0; display: flex; align-items: center; } .monaco-button-dropdown > .monaco-button.monaco-text-button { - border-radius: 2px 0 0 2px; + border-radius: 4px 0 0 4px; } .monaco-description-button { diff --git a/src/vs/base/browser/ui/button/button.ts b/src/vs/base/browser/ui/button/button.ts index 9b66a126cb9..fa1fa93d545 100644 --- a/src/vs/base/browser/ui/button/button.ts +++ b/src/vs/base/browser/ui/button/button.ts @@ -35,6 +35,7 @@ export interface IButtonOptions extends Partial { readonly supportIcons?: boolean; readonly supportShortLabel?: boolean; readonly secondary?: boolean; + readonly small?: boolean; readonly hoverDelegate?: IHoverDelegate; readonly disabled?: boolean; } @@ -116,6 +117,7 @@ export class Button extends Disposable implements IButton { this._element.setAttribute('role', 'button'); this._element.classList.toggle('secondary', !!options.secondary); + this._element.classList.toggle('small', !!options.small); const background = options.secondary ? options.buttonSecondaryBackground : options.buttonBackground; const foreground = options.secondary ? options.buttonSecondaryForeground : options.buttonForeground; diff --git a/src/vs/base/browser/ui/dialog/dialog.css b/src/vs/base/browser/ui/dialog/dialog.css index fe18c9a447b..c484fa86dbd 100644 --- a/src/vs/base/browser/ui/dialog/dialog.css +++ b/src/vs/base/browser/ui/dialog/dialog.css @@ -194,7 +194,6 @@ } .monaco-dialog-box > .dialog-buttons-row > .dialog-buttons > .monaco-button { - padding: 4px 10px; overflow: hidden; text-overflow: ellipsis; margin: 4px 5px; /* allows button focus outline to be visible */ @@ -228,19 +227,14 @@ outline-width: 1px; outline-style: solid; outline-color: var(--vscode-focusBorder); - border-radius: 2px; + border-radius: 4px; } -.monaco-dialog-box > .dialog-buttons-row > .dialog-buttons > .monaco-button-dropdown > .monaco-text-button { - padding-left: 10px; - padding-right: 10px; -} .monaco-dialog-box.align-vertical > .dialog-buttons-row > .dialog-buttons > .monaco-button-dropdown > .monaco-text-button { width: 100%; } .monaco-dialog-box > .dialog-buttons-row > .dialog-buttons > .monaco-button-dropdown > .monaco-dropdown-button { - padding-left: 5px; - padding-right: 5px; + padding: 0 4px; } diff --git a/src/vs/base/browser/ui/inputbox/inputBox.css b/src/vs/base/browser/ui/inputbox/inputBox.css index f6005a48f78..827a19f29b4 100644 --- a/src/vs/base/browser/ui/inputbox/inputBox.css +++ b/src/vs/base/browser/ui/inputbox/inputBox.css @@ -8,7 +8,7 @@ display: block; padding: 0; box-sizing: border-box; - border-radius: 2px; + border-radius: 4px; /* Customizable */ font-size: inherit; diff --git a/src/vs/base/browser/ui/selectBox/selectBox.css b/src/vs/base/browser/ui/selectBox/selectBox.css index 7242251e9b4..2b0011a842b 100644 --- a/src/vs/base/browser/ui/selectBox/selectBox.css +++ b/src/vs/base/browser/ui/selectBox/selectBox.css @@ -6,7 +6,7 @@ .monaco-select-box { width: 100%; cursor: pointer; - border-radius: 2px; + border-radius: 4px; } .monaco-select-box-dropdown-container { @@ -30,6 +30,6 @@ .mac .monaco-action-bar .action-item .monaco-select-box { font-size: 11px; - border-radius: 3px; + border-radius: 4px; min-height: 24px; } diff --git a/src/vs/base/browser/ui/selectBox/selectBoxCustom.css b/src/vs/base/browser/ui/selectBox/selectBoxCustom.css index 4d2fb516f20..2ca9a99a7bc 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxCustom.css +++ b/src/vs/base/browser/ui/selectBox/selectBoxCustom.css @@ -6,7 +6,7 @@ .monaco-select-box-dropdown-container { display: none; box-sizing: border-box; - border-radius: 5px; + border-radius: 4px; box-shadow: 0 2px 8px var(--vscode-widget-shadow); } diff --git a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts index 389aac8f113..753bd958113 100644 --- a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts @@ -286,6 +286,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE this._configuration = this._register(this._createConfiguration(codeEditorWidgetOptions.isSimpleWidget || false, codeEditorWidgetOptions.contextMenuId ?? (codeEditorWidgetOptions.isSimpleWidget ? MenuId.SimpleEditorContext : MenuId.EditorContext), options, accessibilityService)); + this._domElement.style?.setProperty('--editor-font-size', this._configuration.options.get(EditorOption.fontSize) + 'px'); this._register(this._configuration.onDidChange((e) => { this._onDidChangeConfiguration.fire(e); @@ -294,6 +295,9 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE const layoutInfo = options.get(EditorOption.layoutInfo); this._onDidLayoutChange.fire(layoutInfo); } + if (e.hasChanged(EditorOption.fontSize)) { + this._domElement.style.setProperty('--editor-font-size', options.get(EditorOption.fontSize) + 'px'); + } })); this._contextKeyService = this._register(contextKeyService.createScoped(this._domElement)); diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 83710866127..25724438958 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -72,8 +72,8 @@ export interface IFontToken { readonly startIndex: number; readonly endIndex: number; readonly fontFamily: string | null; - readonly fontSize: string | null; - readonly lineHeight: number | null; + readonly fontSizeMultiplier: number | null; + readonly lineHeightMultiplier: number | null; } /** @@ -753,6 +753,18 @@ export enum InlineCompletionTriggerKind { Explicit = 1, } +/** + * Arbitrary data that the provider can pass when firing {@link InlineCompletionsProvider.onDidChangeInlineCompletions}. + * This data is passed back to the provider in {@link InlineCompletionContext.changeHint}. + */ +export interface IInlineCompletionChangeHint { + /** + * Arbitrary data that the provider can use to identify what triggered the change. + * This data must be JSON serializable. + */ + readonly data?: unknown; +} + export interface InlineCompletionContext { /** @@ -775,6 +787,12 @@ export interface InlineCompletionContext { readonly includeInlineCompletions: boolean; readonly requestIssuedDateTime: number; readonly earliestShownDateTime: number; + + /** + * The change hint that was passed to {@link InlineCompletionsProvider.onDidChangeInlineCompletions}. + * Only set if this request was triggered by such an event. + */ + readonly changeHint?: IInlineCompletionChangeHint; } export interface IInlineCompletionModelInfo { @@ -946,7 +964,12 @@ export interface InlineCompletionsProvider; + /** + * Fired when the provider wants to trigger a new completion request. + * The event can pass a {@link IInlineCompletionChangeHint} which will be + * included in the {@link InlineCompletionContext} of the subsequent request. + */ + onDidChangeInlineCompletions?: Event; /** * Only used for {@link yieldsToGroupIds}. diff --git a/src/vs/editor/common/languages/supports/tokenization.ts b/src/vs/editor/common/languages/supports/tokenization.ts index 076b443f58f..0545b34945d 100644 --- a/src/vs/editor/common/languages/supports/tokenization.ts +++ b/src/vs/editor/common/languages/supports/tokenization.ts @@ -429,10 +429,10 @@ export function generateTokensCSSForFontMap(fontMap: readonly IFontTokenOptions[ const fonts = new Set(); for (let i = 1, len = fontMap.length; i < len; i++) { const font = fontMap[i]; - if (!font.fontFamily && !font.fontSize) { + if (!font.fontFamily && !font.fontSizeMultiplier) { continue; } - const className = classNameForFontTokenDecorations(font.fontFamily ?? '', font.fontSize ?? ''); + const className = classNameForFontTokenDecorations(font.fontFamily ?? '', font.fontSizeMultiplier ?? 0); if (fonts.has(className)) { continue; } @@ -441,8 +441,8 @@ export function generateTokensCSSForFontMap(fontMap: readonly IFontTokenOptions[ if (font.fontFamily) { rule += `font-family: ${font.fontFamily};`; } - if (font.fontSize) { - rule += `font-size: ${font.fontSize};`; + if (font.fontSizeMultiplier) { + rule += `font-size: calc(var(--editor-font-size)*${font.fontSizeMultiplier});`; } rule += `}`; rules.push(rule); @@ -450,6 +450,19 @@ export function generateTokensCSSForFontMap(fontMap: readonly IFontTokenOptions[ return rules.join('\n'); } -export function classNameForFontTokenDecorations(fontFamily: string, fontSize: string): string { - return `font-decoration-${fontFamily.toLowerCase()}-${fontSize.toLowerCase()}`; +export function classNameForFontTokenDecorations(fontFamily: string, fontSize: number): string { + const safeFontFamily = sanitizeFontFamilyForClassName(fontFamily); + return cleanClassName(`font-decoration-${safeFontFamily}-${fontSize}`); +} + +function sanitizeFontFamilyForClassName(fontFamily: string): string { + const normalized = fontFamily.toLowerCase().trim(); + if (!normalized) { + return 'default'; + } + return cleanClassName(normalized); +} + +function cleanClassName(className: string): string { + return className.replace(/[^a-z0-9_-]/gi, '-'); } diff --git a/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts b/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts index ccf4b297be3..0ab0c461ed0 100644 --- a/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts +++ b/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts @@ -75,8 +75,8 @@ export class TokenizationFontDecorationProvider extends Disposable implements De }; TokenizationFontDecorationProvider.DECORATION_COUNT++; - if (annotation.annotation.lineHeight) { - affectedLineHeights.add(new LineHeightChangingDecoration(0, decorationId, lineNumber, annotation.annotation.lineHeight)); + if (annotation.annotation.lineHeightMultiplier) { + affectedLineHeights.add(new LineHeightChangingDecoration(0, decorationId, lineNumber, annotation.annotation.lineHeightMultiplier)); } affectedLineFonts.add(new LineFontChangingDecoration(0, decorationId, lineNumber)); @@ -135,8 +135,8 @@ export class TokenizationFontDecorationProvider extends Disposable implements De const annotationEndPosition = this.textModel.getPositionAt(annotation.range.endExclusive); const range = Range.fromPositions(annotationStartPosition, annotationEndPosition); const anno = annotation.annotation; - const className = classNameForFontTokenDecorations(anno.fontToken.fontFamily ?? '', anno.fontToken.fontSize ?? ''); - const affectsFont = !!(anno.fontToken.fontFamily || anno.fontToken.fontSize); + const className = classNameForFontTokenDecorations(anno.fontToken.fontFamily ?? '', anno.fontToken.fontSizeMultiplier ?? 0); + const affectsFont = !!(anno.fontToken.fontFamily || anno.fontToken.fontSizeMultiplier); const id = anno.decorationId; decorations.push({ id: id, diff --git a/src/vs/editor/common/textModelEvents.ts b/src/vs/editor/common/textModelEvents.ts index cc142ebb8c5..b25c00aae8a 100644 --- a/src/vs/editor/common/textModelEvents.ts +++ b/src/vs/editor/common/textModelEvents.ts @@ -162,11 +162,11 @@ export interface IFontTokenOption { /** * Font size of the token. */ - readonly fontSize?: string; + readonly fontSizeMultiplier?: number; /** * Line height of the token. */ - readonly lineHeight?: number; + readonly lineHeightMultiplier?: number; } /** @@ -189,8 +189,8 @@ export function serializeFontTokenOptions(): (options: IFontTokenOption) => IFon return (annotation: IFontTokenOption) => { return { fontFamily: annotation.fontFamily ?? '', - fontSize: annotation.fontSize ?? '', - lineHeight: annotation.lineHeight ?? 0 + fontSizeMultiplier: annotation.fontSizeMultiplier ?? 0, + lineHeightMultiplier: annotation.lineHeightMultiplier ?? 0 }; }; } @@ -202,8 +202,8 @@ export function deserializeFontTokenOptions(): (options: IFontTokenOption) => IF return (annotation: IFontTokenOption) => { return { fontFamily: annotation.fontFamily ? String(annotation.fontFamily) : undefined, - fontSize: annotation.fontSize ? String(annotation.fontSize) : undefined, - lineHeight: annotation.lineHeight ? Number(annotation.lineHeight) : undefined + fontSizeMultiplier: annotation.fontSizeMultiplier ? Number(annotation.fontSizeMultiplier) : undefined, + lineHeightMultiplier: annotation.lineHeightMultiplier ? Number(annotation.lineHeightMultiplier) : undefined }; }; } @@ -348,13 +348,13 @@ export class ModelLineHeightChanged { /** * The line height on the line. */ - public readonly lineHeight: number | null; + public readonly lineHeightMultiplier: number | null; - constructor(ownerId: number, decorationId: string, lineNumber: number, lineHeight: number | null) { + constructor(ownerId: number, decorationId: string, lineNumber: number, lineHeightMultiplier: number | null) { this.ownerId = ownerId; this.decorationId = decorationId; this.lineNumber = lineNumber; - this.lineHeight = lineHeight; + this.lineHeightMultiplier = lineHeightMultiplier; } } diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 3fab2ddee2e..101b46af347 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -451,10 +451,10 @@ export class ViewModel extends Disposable implements IViewModel { this.viewLayout.changeSpecialLineHeights((accessor: ILineHeightChangeAccessor) => { for (const change of filteredChanges) { - const { decorationId, lineNumber, lineHeight } = change; + const { decorationId, lineNumber, lineHeightMultiplier } = change; const viewRange = this.coordinatesConverter.convertModelRangeToViewRange(new Range(lineNumber, 1, lineNumber, this.model.getLineMaxColumn(lineNumber))); - if (lineHeight !== null) { - accessor.insertOrChangeCustomLineHeight(decorationId, viewRange.startLineNumber, viewRange.endLineNumber, lineHeight); + if (lineHeightMultiplier !== null) { + accessor.insertOrChangeCustomLineHeight(decorationId, viewRange.startLineNumber, viewRange.endLineNumber, lineHeightMultiplier * this._configuration.options.get(EditorOption.lineHeight)); } else { accessor.removeCustomLineHeight(decorationId); } diff --git a/src/vs/editor/contrib/hover/browser/contentHoverController.ts b/src/vs/editor/contrib/hover/browser/contentHoverController.ts index 8d7421d9906..ba0e7d0b161 100644 --- a/src/vs/editor/contrib/hover/browser/contentHoverController.ts +++ b/src/vs/editor/contrib/hover/browser/contentHoverController.ts @@ -17,7 +17,7 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { ResultKind } from '../../../../platform/keybinding/common/keybindingResolver.js'; import { HoverVerbosityAction } from '../../../common/languages.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { isMousePositionWithinElement, shouldShowHover } from './hoverUtils.js'; +import { isMousePositionWithinElement, shouldShowHover, isTriggerModifierPressed } from './hoverUtils.js'; import { ContentHoverWidgetWrapper } from './contentHoverWidgetWrapper.js'; import './hover.css'; import { Emitter } from '../../../../base/common/event.js'; @@ -266,12 +266,19 @@ export class ContentHoverController extends Disposable implements IEditorContrib } private _onKeyDown(e: IKeyboardEvent): void { - if (this._ignoreMouseEvents) { + if (this._ignoreMouseEvents || !this._contentWidget) { return; } - if (!this._contentWidget) { + + if (this._hoverSettings.enabled === 'onKeyboardModifier' + && isTriggerModifierPressed(this._editor.getOption(EditorOption.multiCursorModifier), e) + && this._mouseMoveEvent) { + if (!this._contentWidget.isVisible) { + this._contentWidget.showsOrWillShow(this._mouseMoveEvent); + } return; } + const isPotentialKeyboardShortcut = this._isPotentialKeyboardShortcut(e); const isModifierKeyPressed = isModifierKey(e.keyCode); if (isPotentialKeyboardShortcut || isModifierKeyPressed) { diff --git a/src/vs/editor/contrib/hover/browser/glyphHoverController.ts b/src/vs/editor/contrib/hover/browser/glyphHoverController.ts index e26f82ccf57..c8dbfa9ec3d 100644 --- a/src/vs/editor/contrib/hover/browser/glyphHoverController.ts +++ b/src/vs/editor/contrib/hover/browser/glyphHoverController.ts @@ -12,7 +12,7 @@ import { IEditorContribution, IScrollEvent } from '../../../common/editorCommon. import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IHoverWidget } from './hoverTypes.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { isMousePositionWithinElement, shouldShowHover } from './hoverUtils.js'; +import { isMousePositionWithinElement, isTriggerModifierPressed, shouldShowHover } from './hoverUtils.js'; import './hover.css'; import { GlyphHoverWidget } from './glyphHoverWidget.js'; @@ -206,6 +206,14 @@ export class GlyphHoverController extends Disposable implements IEditorContribut if (!this._editor.hasModel()) { return; } + + if (this._hoverSettings.enabled === 'onKeyboardModifier' + && isTriggerModifierPressed(this._editor.getOption(EditorOption.multiCursorModifier), e) + && this._mouseMoveEvent) { + this._tryShowHoverWidget(this._mouseMoveEvent); + return; + } + if (isModifierKey(e.keyCode)) { // Do not hide hover when a modifier key is pressed return; diff --git a/src/vs/editor/contrib/hover/browser/hoverUtils.ts b/src/vs/editor/contrib/hover/browser/hoverUtils.ts index 669b36fbbb7..997d4512c1a 100644 --- a/src/vs/editor/contrib/hover/browser/hoverUtils.ts +++ b/src/vs/editor/contrib/hover/browser/hoverUtils.ts @@ -37,9 +37,19 @@ export function shouldShowHover( if (hoverEnabled === 'off') { return false; } - if (multiCursorModifier === 'altKey') { - return mouseEvent.event.ctrlKey || mouseEvent.event.metaKey; - } else { - return mouseEvent.event.altKey; - } + return isTriggerModifierPressed(multiCursorModifier, mouseEvent.event); +} + +/** + * Returns true if the trigger modifier (inverse of multi-cursor modifier) is pressed. + * This works with both mouse and keyboard events by relying only on the modifier flags. + */ +export function isTriggerModifierPressed( + multiCursorModifier: 'altKey' | 'ctrlKey' | 'metaKey', + event: { ctrlKey: boolean; metaKey: boolean; altKey: boolean } +): boolean { + if (multiCursorModifier === 'altKey') { + return event.ctrlKey || event.metaKey; + } + return event.altKey; // multiCursorModifier is ctrlKey or metaKey } diff --git a/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts b/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts index e491793d5d6..e40987aeefe 100644 --- a/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts +++ b/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { shouldShowHover } from '../../browser/hoverUtils.js'; +import { isMousePositionWithinElement, isTriggerModifierPressed, shouldShowHover } from '../../browser/hoverUtils.js'; import { IEditorMouseEvent } from '../../../../browser/editorBrowser.js'; suite('Hover Utils', () => { @@ -85,4 +85,140 @@ suite('Hover Utils', () => { assert.strictEqual(result, false); }); }); + + suite('isMousePositionWithinElement', () => { + + function createMockElement(left: number, top: number, width: number, height: number): HTMLElement { + const element = document.createElement('div'); + // Mock getDomNodePagePosition by setting up the element's bounding rect + element.getBoundingClientRect = () => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => { } + }); + return element; + } + + test('returns true when mouse is inside element bounds', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 150, 150), true); + assert.strictEqual(isMousePositionWithinElement(element, 200, 150), true); + assert.strictEqual(isMousePositionWithinElement(element, 250, 180), true); + }); + + test('returns true when mouse is on element edges', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); // top-left corner + assert.strictEqual(isMousePositionWithinElement(element, 300, 100), true); // top-right corner + assert.strictEqual(isMousePositionWithinElement(element, 100, 200), true); // bottom-left corner + assert.strictEqual(isMousePositionWithinElement(element, 300, 200), true); // bottom-right corner + }); + + test('returns false when mouse is left of element', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 99, 150), false); + assert.strictEqual(isMousePositionWithinElement(element, 50, 150), false); + }); + + test('returns false when mouse is right of element', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 301, 150), false); + assert.strictEqual(isMousePositionWithinElement(element, 400, 150), false); + }); + + test('returns false when mouse is above element', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 200, 99), false); + assert.strictEqual(isMousePositionWithinElement(element, 200, 50), false); + }); + + test('returns false when mouse is below element', () => { + const element = createMockElement(100, 100, 200, 100); + assert.strictEqual(isMousePositionWithinElement(element, 200, 201), false); + assert.strictEqual(isMousePositionWithinElement(element, 200, 300), false); + }); + + test('handles element at origin (0,0)', () => { + const element = createMockElement(0, 0, 100, 100); + assert.strictEqual(isMousePositionWithinElement(element, 0, 0), true); + assert.strictEqual(isMousePositionWithinElement(element, 50, 50), true); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); + assert.strictEqual(isMousePositionWithinElement(element, 101, 101), false); + }); + + test('handles small elements (1x1)', () => { + const element = createMockElement(100, 100, 1, 1); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); + assert.strictEqual(isMousePositionWithinElement(element, 101, 101), true); + assert.strictEqual(isMousePositionWithinElement(element, 102, 102), false); + }); + }); + + suite('isTriggerModifierPressed', () => { + + function createModifierEvent(ctrlKey: boolean, altKey: boolean, metaKey: boolean) { + return { ctrlKey, altKey, metaKey }; + } + + test('returns true with ctrl pressed when multiCursorModifier is altKey', () => { + const event = createModifierEvent(true, false, false); + assert.strictEqual(isTriggerModifierPressed('altKey', event), true); + }); + + test('returns true with metaKey pressed when multiCursorModifier is altKey', () => { + const event = createModifierEvent(false, false, true); + assert.strictEqual(isTriggerModifierPressed('altKey', event), true); + }); + + test('returns true with both ctrl and metaKey pressed when multiCursorModifier is altKey', () => { + const event = createModifierEvent(true, false, true); + assert.strictEqual(isTriggerModifierPressed('altKey', event), true); + }); + + test('returns false without ctrl or metaKey when multiCursorModifier is altKey', () => { + const event = createModifierEvent(false, false, false); + assert.strictEqual(isTriggerModifierPressed('altKey', event), false); + }); + + test('returns false with alt pressed when multiCursorModifier is altKey', () => { + const event = createModifierEvent(false, true, false); + assert.strictEqual(isTriggerModifierPressed('altKey', event), false); + }); + + test('returns true with alt pressed when multiCursorModifier is ctrlKey', () => { + const event = createModifierEvent(false, true, false); + assert.strictEqual(isTriggerModifierPressed('ctrlKey', event), true); + }); + + test('returns false without alt pressed when multiCursorModifier is ctrlKey', () => { + const event = createModifierEvent(false, false, false); + assert.strictEqual(isTriggerModifierPressed('ctrlKey', event), false); + }); + + test('returns false with ctrl pressed when multiCursorModifier is ctrlKey', () => { + const event = createModifierEvent(true, false, false); + assert.strictEqual(isTriggerModifierPressed('ctrlKey', event), false); + }); + + test('returns true with alt pressed when multiCursorModifier is metaKey', () => { + const event = createModifierEvent(false, true, false); + assert.strictEqual(isTriggerModifierPressed('metaKey', event), true); + }); + + test('returns false without alt pressed when multiCursorModifier is metaKey', () => { + const event = createModifierEvent(false, false, false); + assert.strictEqual(isTriggerModifierPressed('metaKey', event), false); + }); + + test('returns false with metaKey pressed when multiCursorModifier is metaKey', () => { + const event = createModifierEvent(false, false, true); + assert.strictEqual(isTriggerModifierPressed('metaKey', event), false); + }); + }); }); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts index 8c3e791e089..97392ce8d7e 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/commands.ts @@ -6,7 +6,7 @@ import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { asyncTransaction, transaction } from '../../../../../base/common/observable.js'; import { splitLines } from '../../../../../base/common/strings.js'; -import { vBoolean, vObj, vOptionalProp, vString, vUndefined, vUnion, vWithJsonSchemaRef } from '../../../../../base/common/validation.js'; +import { vBoolean, vObj, vOptionalProp, vString, vUnchecked, vUndefined, vUnion, vWithJsonSchemaRef } from '../../../../../base/common/validation.js'; import * as nls from '../../../../../nls.js'; import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../../platform/accessibility/common/accessibility.js'; import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; @@ -80,6 +80,7 @@ const argsValidator = vUnion(vObj({ showNoResultNotification: vOptionalProp(vBoolean()), providerId: vOptionalProp(vWithJsonSchemaRef(providerIdSchemaUri, vString())), explicit: vOptionalProp(vBoolean()), + changeHintData: vOptionalProp(vUnchecked()), }), vUndefined()); export class TriggerInlineSuggestionAction extends EditorAction { @@ -118,6 +119,7 @@ export class TriggerInlineSuggestionAction extends EditorAction { await controller?.model.get()?.trigger(tx, { provider: provider, explicit: validatedArgs?.explicit ?? true, + changeHint: validatedArgs?.changeHintData ? { data: validatedArgs.changeHintData } : undefined, }); controller?.playAccessibilitySignal(tx); }); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts index 87928882cf5..222e76c5e51 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -25,7 +25,7 @@ import { Selection } from '../../../../common/core/selection.js'; import { TextReplacement, TextEdit } from '../../../../common/core/edits/textEdit.js'; import { TextLength } from '../../../../common/core/text/textLength.js'; import { ScrollType } from '../../../../common/editorCommon.js'; -import { InlineCompletionEndOfLifeReasonKind, InlineCompletion, InlineCompletionTriggerKind, PartialAcceptTriggerKind, InlineCompletionsProvider, InlineCompletionCommand } from '../../../../common/languages.js'; +import { IInlineCompletionChangeHint, InlineCompletionEndOfLifeReasonKind, InlineCompletion, InlineCompletionTriggerKind, PartialAcceptTriggerKind, InlineCompletionsProvider, InlineCompletionCommand } from '../../../../common/languages.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; import { EndOfLinePreference, IModelDeltaDecoration, ITextModel } from '../../../../common/model.js'; import { TextModelText } from '../../../../common/model/textModelText.js'; @@ -61,7 +61,7 @@ export class InlineCompletionsModel extends Disposable { private readonly _forceUpdateExplicitlySignal = observableSignal(this); private readonly _noDelaySignal = observableSignal(this); - private readonly _fetchSpecificProviderSignal = observableSignal(this); + private readonly _fetchSpecificProviderSignal = observableSignal<{ provider: InlineCompletionsProvider; changeHint?: IInlineCompletionChangeHint } | undefined>(this); // We use a semantic id to keep the same inline completion selected even if the provider reorders the completions. private readonly _selectedInlineCompletionId = observableValue(this, undefined); @@ -215,7 +215,7 @@ export class InlineCompletionsModel extends Disposable { return; } - store.add(provider.onDidChangeInlineCompletions(() => { + store.add(provider.onDidChangeInlineCompletions(changeHint => { if (!this._enabled.get()) { return; } @@ -240,7 +240,7 @@ export class InlineCompletionsModel extends Disposable { } transaction(tx => { - this._fetchSpecificProviderSignal.trigger(tx, provider); + this._fetchSpecificProviderSignal.trigger(tx, { provider, changeHint: changeHint ?? undefined }); this.trigger(tx); }); @@ -334,6 +334,7 @@ export class InlineCompletionsModel extends Disposable { onlyRequestInlineEdits: false, shouldDebounce: true, provider: undefined as InlineCompletionsProvider | undefined, + changeHint: undefined as IInlineCompletionChangeHint | undefined, textChange: false, changeReason: '', }), @@ -354,7 +355,8 @@ export class InlineCompletionsModel extends Disposable { } else if (ctx.didChange(this._onlyRequestInlineEditsSignal)) { changeSummary.onlyRequestInlineEdits = true; } else if (ctx.didChange(this._fetchSpecificProviderSignal)) { - changeSummary.provider = ctx.change; + changeSummary.provider = ctx.change?.provider; + changeSummary.changeHint = ctx.change?.changeHint; } return true; }, @@ -424,6 +426,7 @@ export class InlineCompletionsModel extends Disposable { includeInlineEdits: this._inlineEditsEnabled.read(reader), requestIssuedDateTime: requestInfo.startTime, earliestShownDateTime: requestInfo.startTime + (changeSummary.inlineCompletionTriggerKind === InlineCompletionTriggerKind.Explicit || this.inAcceptFlow.read(undefined) ? 0 : this._minShowDelay.read(undefined)), + changeHint: changeSummary.changeHint, }; if (context.triggerKind === InlineCompletionTriggerKind.Automatic && changeSummary.textChange) { @@ -474,7 +477,7 @@ export class InlineCompletionsModel extends Disposable { return availableProviders; } - public async trigger(tx?: ITransaction, options: { onlyFetchInlineEdits?: boolean; noDelay?: boolean; provider?: InlineCompletionsProvider; explicit?: boolean } = {}): Promise { + public async trigger(tx?: ITransaction, options: { onlyFetchInlineEdits?: boolean; noDelay?: boolean; provider?: InlineCompletionsProvider; explicit?: boolean; changeHint?: IInlineCompletionChangeHint } = {}): Promise { subtransaction(tx, tx => { if (options.onlyFetchInlineEdits) { this._onlyRequestInlineEditsSignal.trigger(tx); @@ -489,7 +492,7 @@ export class InlineCompletionsModel extends Disposable { this._forceUpdateExplicitlySignal.trigger(tx); } if (options.provider) { - this._fetchSpecificProviderSignal.trigger(tx, options.provider); + this._fetchSpecificProviderSignal.trigger(tx, { provider: options.provider, changeHint: options.changeHint }); } }); await this._fetchInlineCompletionsPromise.get(); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts index 0f26af1bbb8..c9909d45346 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletions.test.ts @@ -761,4 +761,80 @@ suite('Multi Cursor Support', () => { } ); }); + + test('Change hint is passed from onDidChange to provideInlineCompletions', async function () { + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider, inlineSuggest: { enabled: true } }, + async ({ editor, editorViewModel, model, context }) => { + context.keyboardType('foo'); + provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) }); + model.triggerExplicitly(); + await timeout(1000); + + const firstCallHistory = provider.getAndClearCallHistory(); + assert.strictEqual(firstCallHistory.length, 1); + assert.strictEqual((firstCallHistory[0] as { changeHint?: unknown }).changeHint, undefined); + + // Change cursor position to avoid cache hit + editor.setPosition({ lineNumber: 1, column: 3 }); + + + const changeHintData = { reason: 'modelUpdated', version: 42 }; + provider.setReturnValue({ insertText: 'foobaz', range: new Range(1, 1, 1, 4) }); + provider.fireOnDidChange({ data: changeHintData }); + await timeout(1000); + + const secondCallHistory = provider.getAndClearCallHistory(); + + assert.deepStrictEqual( + secondCallHistory, + [{ + changeHint: { + data: { + reason: 'modelUpdated', + version: 42, + } + }, + position: '(1,3)', + text: 'foo', + triggerKind: 0 + }] + ); + } + ); + }); + + test('Change hint is undefined when onDidChange fires without hint', async function () { + const provider = new MockInlineCompletionsProvider(); + await withAsyncTestCodeEditorAndInlineCompletionsModel('', + { fakeClock: true, provider, inlineSuggest: { enabled: true } }, + async ({ editor, editorViewModel, model, context }) => { + context.keyboardType('foo'); + provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) }); + model.triggerExplicitly(); + await timeout(1000); + + provider.getAndClearCallHistory(); + + // Change cursor position to avoid cache hit + editor.setPosition({ lineNumber: 1, column: 3 }); + + provider.setReturnValue({ insertText: 'foobaz', range: new Range(1, 1, 1, 4) }); + provider.fireOnDidChange(); + await timeout(1000); + + const callHistory = provider.getAndClearCallHistory(); + + assert.deepStrictEqual( + callHistory, + [{ + position: '(1,3)', + text: 'foo', + triggerKind: 0 + }] + ); + } + ); + }); }); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 2d9a1a5e2f5..bbd453dcaf5 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -9,7 +9,7 @@ import { Disposable, DisposableStore } from '../../../../../base/common/lifecycl import { CoreEditingCommands, CoreNavigationCommands } from '../../../../browser/coreCommands.js'; import { Position } from '../../../../common/core/position.js'; import { ITextModel } from '../../../../common/model.js'; -import { InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider } from '../../../../common/languages.js'; +import { IInlineCompletionChangeHint, InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider } from '../../../../common/languages.js'; import { ITestCodeEditor, TestCodeEditorInstantiationOptions, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; import { InlineCompletionsModel } from '../../browser/model/inlineCompletionsModel.js'; import { autorun, derived } from '../../../../../base/common/observable.js'; @@ -27,7 +27,7 @@ import { PositionOffsetTransformer } from '../../../../common/core/text/position import { InlineSuggestionsView } from '../../browser/view/inlineSuggestionsView.js'; import { IBulkEditService } from '../../../../browser/services/bulkEditService.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; export class MockInlineCompletionsProvider implements InlineCompletionsProvider { private returnValue: InlineCompletion[] = []; @@ -36,6 +36,9 @@ export class MockInlineCompletionsProvider implements InlineCompletionsProvider private callHistory = new Array(); private calledTwiceIn50Ms = false; + private readonly _onDidChangeEmitter = new Emitter(); + public readonly onDidChangeInlineCompletions: Event = this._onDidChangeEmitter.event; + constructor( public readonly enableForwardStability = false, ) { } @@ -62,6 +65,13 @@ export class MockInlineCompletionsProvider implements InlineCompletionsProvider } } + /** + * Fire an onDidChange event with an optional change hint. + */ + public fireOnDidChange(changeHint?: IInlineCompletionChangeHint): void { + this._onDidChangeEmitter.fire(changeHint); + } + private lastTimeMs: number | undefined = undefined; async provideInlineCompletions(model: ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken): Promise { @@ -74,7 +84,8 @@ export class MockInlineCompletionsProvider implements InlineCompletionsProvider this.callHistory.push({ position: position.toString(), triggerKind: context.triggerKind, - text: model.getValue() + text: model.getValue(), + ...(context.changeHint !== undefined ? { changeHint: context.changeHint } : {}), }); const result = new Array(); for (const v of this.returnValue) { diff --git a/src/vs/editor/contrib/rename/browser/renameWidget.css b/src/vs/editor/contrib/rename/browser/renameWidget.css index 66f241efd1c..acd375f2afb 100644 --- a/src/vs/editor/contrib/rename/browser/renameWidget.css +++ b/src/vs/editor/contrib/rename/browser/renameWidget.css @@ -15,7 +15,7 @@ .monaco-editor .rename-box .rename-input-with-button { padding: 3px; - border-radius: 2px; + border-radius: 4px; width: calc(100% - 8px); /* 4px padding on each side */ } diff --git a/src/vs/editor/test/browser/editorTestServices.ts b/src/vs/editor/test/browser/editorTestServices.ts index 4567ca51837..38594483bac 100644 --- a/src/vs/editor/test/browser/editorTestServices.ts +++ b/src/vs/editor/test/browser/editorTestServices.ts @@ -19,7 +19,8 @@ export class TestCodeEditorService extends AbstractCodeEditorService { } getActiveCodeEditor(): ICodeEditor | null { - return null; + const editors = this.listCodeEditors(); + return editors.length > 0 ? editors[editors.length - 1] : null; } public lastInput?: IResourceEditorInput; override openCodeEditor(input: IResourceEditorInput, source: ICodeEditor | null, sideBySide?: boolean): Promise { diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index bdd5231f202..a85f63bf973 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -7494,6 +7494,18 @@ declare namespace monaco.languages { Explicit = 1 } + /** + * Arbitrary data that the provider can pass when firing {@link InlineCompletionsProvider.onDidChangeInlineCompletions}. + * This data is passed back to the provider in {@link InlineCompletionContext.changeHint}. + */ + export interface IInlineCompletionChangeHint { + /** + * Arbitrary data that the provider can use to identify what triggered the change. + * This data must be JSON serializable. + */ + readonly data?: unknown; + } + export interface InlineCompletionContext { /** * How the completion was triggered. @@ -7504,6 +7516,11 @@ declare namespace monaco.languages { readonly includeInlineCompletions: boolean; readonly requestIssuedDateTime: number; readonly earliestShownDateTime: number; + /** + * The change hint that was passed to {@link InlineCompletionsProvider.onDidChangeInlineCompletions}. + * Only set if this request was triggered by such an event. + */ + readonly changeHint?: IInlineCompletionChangeHint; } export interface IInlineCompletionModelInfo { @@ -7648,7 +7665,12 @@ declare namespace monaco.languages { * Will be called when a completions list is no longer in use and can be garbage-collected. */ disposeInlineCompletions(completions: T, reason: InlineCompletionsDisposeReason): void; - onDidChangeInlineCompletions?: IEvent; + /** + * Fired when the provider wants to trigger a new completion request. + * The event can pass a {@link IInlineCompletionChangeHint} which will be + * included in the {@link InlineCompletionContext} of the subsequent request. + */ + onDidChangeInlineCompletions?: IEvent; /** * Only used for {@link yieldsToGroupIds}. * Multiple providers can have the same group id. diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index adcf1f0e5e2..764c4ff0a6c 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -21,6 +21,7 @@ export const enum AccessibleViewProviderId { MergeEditor = 'mergeEditor', PanelChat = 'panelChat', ChatTerminalOutput = 'chatTerminalOutput', + ChatThinking = 'chatThinking', InlineChat = 'inlineChat', AgentChat = 'agentChat', QuickChat = 'quickChat', diff --git a/src/vs/platform/actions/browser/buttonbar.ts b/src/vs/platform/actions/browser/buttonbar.ts index 45778e15a54..f6488250bba 100644 --- a/src/vs/platform/actions/browser/buttonbar.ts +++ b/src/vs/platform/actions/browser/buttonbar.ts @@ -29,6 +29,7 @@ export type IButtonConfigProvider = (action: IAction, index: number) => { export interface IWorkbenchButtonBarOptions { telemetrySource?: string; buttonConfigProvider?: IButtonConfigProvider; + small?: boolean; } export class WorkbenchButtonBar extends ButtonBar { @@ -99,6 +100,7 @@ export class WorkbenchButtonBar extends ButtonBar { contextMenuProvider: this._contextMenuService, ariaLabel: tooltip, supportIcons: true, + small: this._options?.small, }); } else { action = actionOrSubmenu; @@ -106,6 +108,7 @@ export class WorkbenchButtonBar extends ButtonBar { secondary: conifgProvider(action, i)?.isSecondary ?? secondary, ariaLabel: tooltip, supportIcons: true, + small: this._options?.small, }); } @@ -142,7 +145,8 @@ export class WorkbenchButtonBar extends ButtonBar { const btn = this.addButton({ secondary: true, - ariaLabel: localize('moreActions', "More Actions") + ariaLabel: localize('moreActions', "More Actions"), + small: this._options?.small, }); btn.icon = Codicon.dropDownButton; diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index b82fea31417..530b0e30433 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -291,6 +291,7 @@ export class MenuId { static readonly AgentSessionsToolbar = new MenuId('AgentSessionsToolbar'); static readonly AgentSessionItemToolbar = new MenuId('AgentSessionItemToolbar'); static readonly AgentSessionSectionToolbar = new MenuId('AgentSessionSectionToolbar'); + static readonly AgentsControlMenu = new MenuId('AgentsControlMenu'); static readonly ChatViewSessionTitleNavigationToolbar = new MenuId('ChatViewSessionTitleNavigationToolbar'); static readonly ChatViewSessionTitleToolbar = new MenuId('ChatViewSessionTitleToolbar'); diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 75a302bd0e9..695a42bb817 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -230,6 +230,15 @@ export interface ICommonNativeHostService { // Registry (Windows only) windowsGetStringRegKey(hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise; + + // Zip + /** + * Creates a zip file at the specified path containing the provided files. + * + * @param zipPath The URI where the zip file should be created. + * @param files An array of file entries to include in the zip, each with a relative path and string contents. + */ + createZipFile(zipPath: URI, files: { path: string; contents: string }[]): Promise; } export const INativeHostService = createDecorator('nativeHostService'); diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 2c3b710261b..ee61af05310 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -43,6 +43,7 @@ import { IV8Profile } from '../../profiling/common/profiling.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { CancellationError } from '../../../base/common/errors.js'; +import { zip } from '../../../base/node/zip.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IProxyAuthService } from './auth.js'; import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js'; @@ -1168,6 +1169,14 @@ export class NativeHostMainService extends Disposable implements INativeHostMain //#endregion + //#region Zip + + async createZipFile(windowId: number | undefined, zipPath: URI, files: { path: string; contents: string }[]): Promise { + await zip(zipPath.fsPath, files); + } + + //#endregion + private windowById(windowId: number | undefined, fallbackCodeWindowId?: number): ICodeWindow | IAuxiliaryWindow | undefined { return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId) ?? this.codeWindowById(fallbackCodeWindowId); } diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 0b0856c6411..0636687742d 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -9,7 +9,7 @@ z-index: 2550; left: 50%; -webkit-app-region: no-drag; - border-radius: 6px; + border-radius: 8px; } .quick-input-titlebar { @@ -89,7 +89,7 @@ .quick-input-header { cursor: grab; display: flex; - padding: 6px 6px 2px 6px; + padding: 6px 6px 4px 6px; } .quick-input-widget.hidden-input .quick-input-header { @@ -155,14 +155,6 @@ margin-left: 6px; } -.quick-input-action .monaco-text-button { - font-size: 11px; - padding: 0 6px; - display: flex; - height: 25px; - align-items: center; -} - .quick-input-message { margin-top: -1px; padding: 5px; @@ -196,7 +188,7 @@ .quick-input-list .monaco-list { overflow: hidden; max-height: calc(20 * 22px); - padding-bottom: 5px; + padding-bottom: 7px; } .quick-input-list .monaco-scrollable-element { diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index 328f5f941ef..a9a3f6a90d7 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -113,6 +113,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { onCommandStart: Event, onCommandStartChanged: Event, onCommandExecuted: Event, + onCommandFinished: Event, @ILogService private readonly _logService: ILogService ) { super(); @@ -127,6 +128,7 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._register(onCommandStart(e => this._handleCommandStart(e as { marker: IMarker }))); this._register(onCommandStartChanged(() => this._handleCommandStartChanged())); this._register(onCommandExecuted(() => this._handleCommandExecuted())); + this._register(onCommandFinished(() => this._handleCommandFinished())); this._register(this.onDidStartInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidStartInput'))); this._register(this.onDidChangeInput(() => this._logCombinedStringIfTrace('PromptInputModel#onDidChangeInput'))); @@ -261,6 +263,13 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._onDidChangeInput.fire(event); } + private _handleCommandFinished() { + // Clear the prompt input value when command finishes to prepare for the next command + // This prevents runCommand from detecting leftover text and sending ^C unnecessarily + this._value = ''; + this._onDidChangeInput.fire(this._createStateObject()); + } + @throttle(0) private _sync() { try { diff --git a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts index e52d286d20e..259fc6ef00c 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts @@ -84,7 +84,7 @@ export class CommandDetectionCapability extends Disposable implements ICommandDe ) { super(); this._currentCommand = new PartialTerminalCommand(this._terminal); - this._promptInputModel = this._register(new PromptInputModel(this._terminal, this.onCommandStarted, this.onCommandStartChanged, this.onCommandExecuted, this._logService)); + this._promptInputModel = this._register(new PromptInputModel(this._terminal, this.onCommandStarted, this.onCommandStartChanged, this.onCommandExecuted, this.onCommandFinished, this._logService)); // Pull command line from the buffer if it was not set explicitly this._register(this.onCommandExecuted(command => { diff --git a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts index 64fe94b7ab9..e625ae66a9b 100644 --- a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts +++ b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts @@ -24,6 +24,7 @@ suite('PromptInputModel', () => { let onCommandStart: Emitter; let onCommandStartChanged: Emitter; let onCommandExecuted: Emitter; + let onCommandFinished: Emitter; async function writePromise(data: string) { await new Promise(r => xterm.write(data, r)); @@ -37,6 +38,10 @@ suite('PromptInputModel', () => { onCommandExecuted.fire(null!); } + function fireCommandFinished() { + onCommandFinished.fire(null!); + } + function setContinuationPrompt(prompt: string) { promptInputModel.setContinuationPrompt(prompt); } @@ -68,7 +73,8 @@ suite('PromptInputModel', () => { onCommandStart = store.add(new Emitter()); onCommandStartChanged = store.add(new Emitter()); onCommandExecuted = store.add(new Emitter()); - promptInputModel = store.add(new PromptInputModel(xterm, onCommandStart.event, onCommandStartChanged.event, onCommandExecuted.event, new NullLogService)); + onCommandFinished = store.add(new Emitter()); + promptInputModel = store.add(new PromptInputModel(xterm, onCommandStart.event, onCommandStartChanged.event, onCommandExecuted.event, onCommandFinished.event, new NullLogService)); }); test('basic input and execute', async () => { @@ -138,6 +144,21 @@ suite('PromptInputModel', () => { }); }); + test('should clear value when command finishes', async () => { + await writePromise('$ '); + fireCommandStart(); + await assertPromptInput('|'); + + await writePromise('echo hello'); + await assertPromptInput('echo hello|'); + + fireCommandExecuted(); + strictEqual(promptInputModel.value, 'echo hello'); + + fireCommandFinished(); + strictEqual(promptInputModel.value, ''); + }); + test('cursor navigation', async () => { await writePromise('$ '); fireCommandStart(); diff --git a/src/vs/platform/theme/common/themeService.ts b/src/vs/platform/theme/common/themeService.ts index 9a4657d9a7a..33fbf67cde3 100644 --- a/src/vs/platform/theme/common/themeService.ts +++ b/src/vs/platform/theme/common/themeService.ts @@ -83,8 +83,8 @@ export interface IColorTheme { export class IFontTokenOptions { fontFamily?: string; - fontSize?: string; - lineHeight?: number; + fontSizeMultiplier?: number; + lineHeightMultiplier?: number; } export interface IFileIconTheme { diff --git a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts index 8a31973e9d1..1ebcf272816 100644 --- a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts +++ b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts @@ -15,7 +15,7 @@ import { ThemeTypeSelector } from '../common/theme.js'; import { ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { coalesce } from '../../../base/common/arrays.js'; import { getAllWindowsExcludingOffscreen } from '../../windows/electron-main/windows.js'; -import { ILogService } from '../../log/common/log.js'; +import { ILogService, LogLevel } from '../../log/common/log.js'; import { IThemeMainService } from './themeMainService.js'; // These default colors match our default themes @@ -31,12 +31,20 @@ const THEME_BG_STORAGE_KEY = 'themeBackground'; const THEME_WINDOW_SPLASH_KEY = 'windowSplash'; const THEME_WINDOW_SPLASH_OVERRIDE_KEY = 'windowSplashWorkspaceOverride'; -const AUXILIARYBAR_DEFAULT_VISIBILITY = 'workbench.secondarySideBar.defaultVisibility'; +class Setting { + constructor(public readonly key: string, public readonly defaultValue: T) { + } + getValue(configurationService: IConfigurationService): T { + return configurationService.getValue(this.key) ?? this.defaultValue; + } +} -namespace ThemeSettings { - export const DETECT_COLOR_SCHEME = 'window.autoDetectColorScheme'; - export const DETECT_HC = 'window.autoDetectHighContrast'; - export const SYSTEM_COLOR_THEME = 'window.systemColorTheme'; +// in the main process, defaults are not known to the configuration service, so we need to define them here +namespace Setting { + export const DETECT_COLOR_SCHEME = new Setting('window.autoDetectColorScheme', false); + export const DETECT_HC = new Setting('window.autoDetectHighContrast', true); + export const SYSTEM_COLOR_THEME = new Setting<'default' | 'auto' | 'light' | 'dark'>('window.systemColorTheme', 'default'); + export const AUXILIARYBAR_DEFAULT_VISIBILITY = new Setting<'hidden' | 'visibleInWorkspace' | 'visible' | 'maximizedInWorkspace' | 'maximized'>('workbench.secondarySideBar.defaultVisibility', 'visibleInWorkspace'); } interface IPartSplashOverrideWorkspaces { @@ -76,22 +84,38 @@ export class ThemeMainService extends Disposable implements IThemeMainService { // System Theme if (!isLinux) { this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ThemeSettings.SYSTEM_COLOR_THEME) || e.affectsConfiguration(ThemeSettings.DETECT_COLOR_SCHEME)) { + if (e.affectsConfiguration(Setting.SYSTEM_COLOR_THEME.key) || e.affectsConfiguration(Setting.DETECT_COLOR_SCHEME.key)) { this.updateSystemColorTheme(); + this.logThemeSettings(); } })); } this.updateSystemColorTheme(); + this.logThemeSettings(); // Color Scheme changes - this._register(Event.fromNodeEventEmitter(electron.nativeTheme, 'updated')(() => this._onDidChangeColorScheme.fire(this.getColorScheme()))); + this._register(Event.fromNodeEventEmitter(electron.nativeTheme, 'updated')(() => { + this.logThemeSettings(); + this._onDidChangeColorScheme.fire(this.getColorScheme()); + })); + } + + private logThemeSettings(): void { + if (this.logService.getLevel() >= LogLevel.Debug) { + const logSetting = (setting: Setting) => `${setting.key}=${setting.getValue(this.configurationService)}`; + this.logService.debug(`[theme main service] ${logSetting(Setting.DETECT_COLOR_SCHEME)}, ${logSetting(Setting.DETECT_HC)}, ${logSetting(Setting.SYSTEM_COLOR_THEME)}`); + + const logProperty = (property: keyof Electron.NativeTheme) => `${String(property)}=${electron.nativeTheme[property]}`; + this.logService.debug(`[theme main service] electron.nativeTheme: ${logProperty('themeSource')}, ${logProperty('shouldUseDarkColors')}, ${logProperty('shouldUseHighContrastColors')}, ${logProperty('shouldUseInvertedColorScheme')}, ${logProperty('shouldUseDarkColorsForSystemIntegratedUI')} `); + this.logService.debug(`[theme main service] New color scheme: ${JSON.stringify(this.getColorScheme())}`); + } } private updateSystemColorTheme(): void { - if (isLinux || this.configurationService.getValue(ThemeSettings.DETECT_COLOR_SCHEME)) { + if (isLinux || Setting.DETECT_COLOR_SCHEME.getValue(this.configurationService)) { electron.nativeTheme.themeSource = 'system'; // only with `system` we can detect the system color scheme } else { - switch (this.configurationService.getValue<'default' | 'auto' | 'light' | 'dark'>(ThemeSettings.SYSTEM_COLOR_THEME)) { + switch (Setting.SYSTEM_COLOR_THEME.getValue(this.configurationService)) { case 'dark': electron.nativeTheme.themeSource = 'dark'; break; @@ -145,11 +169,11 @@ export class ThemeMainService extends Disposable implements IThemeMainService { getPreferredBaseTheme(): ThemeTypeSelector | undefined { const colorScheme = this.getColorScheme(); - if (this.configurationService.getValue(ThemeSettings.DETECT_HC) && colorScheme.highContrast) { + if (Setting.DETECT_HC.getValue(this.configurationService) && colorScheme.highContrast) { return colorScheme.dark ? ThemeTypeSelector.HC_BLACK : ThemeTypeSelector.HC_LIGHT; } - if (this.configurationService.getValue(ThemeSettings.DETECT_COLOR_SCHEME)) { + if (Setting.DETECT_COLOR_SCHEME.getValue(this.configurationService)) { return colorScheme.dark ? ThemeTypeSelector.VS_DARK : ThemeTypeSelector.VS; } @@ -334,7 +358,7 @@ export class ThemeMainService extends Disposable implements IThemeMainService { } // Figure out auxiliary bar width based on workspace, configuration and overrides - const auxiliaryBarDefaultVisibility = this.configurationService.getValue(AUXILIARYBAR_DEFAULT_VISIBILITY) ?? 'visibleInWorkspace'; + const auxiliaryBarDefaultVisibility = Setting.AUXILIARYBAR_DEFAULT_VISIBILITY.getValue(this.configurationService); let auxiliaryBarWidth: number; if (workspace) { const auxiliaryBarVisible = override.layoutInfo.workspaces[workspace.id]?.auxiliaryBarVisible; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 6444ca9c12c..0d56d40bb74 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -35,6 +35,7 @@ import { ChatRequestParser } from '../../contrib/chat/common/requestParser/chatR import { IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatNotebookEdit, IChatProgress, IChatService, IChatTask, IChatTaskSerialized, IChatWarningMessage } from '../../contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../contrib/chat/common/constants.js'; +import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; @@ -120,6 +121,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA @IExtensionService private readonly _extensionService: IExtensionService, @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, @IPromptsService private readonly _promptsService: IPromptsService, + @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatAgents2); @@ -279,6 +281,23 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA continue; } + if (progress.kind === 'beginToolInvocation') { + // Begin a streaming tool invocation + this._languageModelToolsService.beginToolCall({ + toolCallId: progress.toolCallId, + toolId: progress.toolName, + chatRequestId: requestId, + sessionResource: chatSession?.sessionResource, + }); + continue; + } + + if (progress.kind === 'updateToolInvocation') { + // Update the streaming data for an existing tool invocation + this._languageModelToolsService.updateToolStream(progress.toolCallId, progress.streamData?.partialInput, CancellationToken.None); + continue; + } + const revivedProgress = progress.kind === 'notebookEdit' ? ChatNotebookEdit.fromChatEdit(progress) : revive(progress) as IChatProgress; diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 7173ddc8d70..6a18a39b05f 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -652,8 +652,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat if (options?.optionGroups && options.optionGroups.length) { const groupsWithCallbacks = options.optionGroups.map(group => ({ ...group, - onSearch: group.searchable ? async (token: CancellationToken) => { - return await this._proxy.$invokeOptionGroupSearch(handle, group.id, token); + onSearch: group.searchable ? async (query: string, token: CancellationToken) => { + return await this._proxy.$invokeOptionGroupSearch(handle, group.id, query, token); } : undefined, })); this._chatSessionsService.setOptionGroupsForSessionType(chatSessionScheme, handle, groupsWithCallbacks); diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 62db354cc8e..18430fbfffa 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -33,7 +33,7 @@ import * as callh from '../../contrib/callHierarchy/common/callHierarchy.js'; import * as search from '../../contrib/search/common/search.js'; import * as typeh from '../../contrib/typeHierarchy/common/typeHierarchy.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostContext, ExtHostLanguageFeaturesShape, HoverWithId, ICallHierarchyItemDto, ICodeActionDto, ICodeActionProviderMetadataDto, IdentifiableInlineCompletion, IdentifiableInlineCompletions, IDocumentDropEditDto, IDocumentDropEditProviderMetadata, IDocumentFilterDto, IIndentationRuleDto, IInlayHintDto, IInlineCompletionModelInfoDto, ILanguageConfigurationDto, ILanguageWordDefinitionDto, ILinkDto, ILocationDto, ILocationLinkDto, IOnEnterRuleDto, IPasteEditDto, IPasteEditProviderMetadataDto, IRegExpDto, ISignatureHelpProviderMetadataDto, ISuggestDataDto, ISuggestDataDtoField, ISuggestResultDtoField, ITypeHierarchyItemDto, IWorkspaceSymbolDto, MainContext, MainThreadLanguageFeaturesShape } from '../common/extHost.protocol.js'; +import { ExtHostContext, ExtHostLanguageFeaturesShape, HoverWithId, ICallHierarchyItemDto, ICodeActionDto, ICodeActionProviderMetadataDto, IdentifiableInlineCompletion, IdentifiableInlineCompletions, IDocumentDropEditDto, IDocumentDropEditProviderMetadata, IDocumentFilterDto, IIndentationRuleDto, IInlayHintDto, IInlineCompletionChangeHintDto, IInlineCompletionModelInfoDto, ILanguageConfigurationDto, ILanguageWordDefinitionDto, ILinkDto, ILocationDto, ILocationLinkDto, IOnEnterRuleDto, IPasteEditDto, IPasteEditProviderMetadataDto, IRegExpDto, ISignatureHelpProviderMetadataDto, ISuggestDataDto, ISuggestDataDtoField, ISuggestResultDtoField, ITypeHierarchyItemDto, IWorkspaceSymbolDto, MainContext, MainThreadLanguageFeaturesShape } from '../common/extHost.protocol.js'; import { InlineCompletionEndOfLifeReasonKind } from '../common/extHostTypes.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { DataChannelForwardingTelemetryService, forwardToChannelIf, isCopilotLikeExtension } from '../../../platform/dataChannel/browser/forwardingTelemetryService.js'; @@ -683,10 +683,10 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread this._registrations.set(handle, provider); } - $emitInlineCompletionsChange(handle: number): void { + $emitInlineCompletionsChange(handle: number, changeHint: IInlineCompletionChangeHintDto | undefined): void { const obj = this._registrations.get(handle); if (obj instanceof ExtensionBackedInlineCompletionsProvider) { - obj._emitDidChange(); + obj._emitDidChange(changeHint); } } @@ -1290,8 +1290,8 @@ export class MainThreadDocumentRangeSemanticTokensProvider implements languages. class ExtensionBackedInlineCompletionsProvider extends Disposable implements languages.InlineCompletionsProvider { public readonly setModelId: ((modelId: string) => Promise) | undefined; - public readonly _onDidChangeEmitter = new Emitter(); - public readonly onDidChangeInlineCompletions: Event | undefined; + public readonly _onDidChangeEmitter = new Emitter(); + public readonly onDidChangeInlineCompletions: Event | undefined; public readonly _onDidChangeModelInfoEmitter = new Emitter(); public readonly onDidChangeModelInfo: Event | undefined; @@ -1334,9 +1334,9 @@ class ExtensionBackedInlineCompletionsProvider extends Disposable implements lan } } - public _emitDidChange() { + public _emitDidChange(changeHint: IInlineCompletionChangeHintDto | undefined) { if (this._supportsOnDidChange) { - this._onDidChangeEmitter.fire(); + this._onDidChangeEmitter.fire(changeHint); } } diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts index 934fcab4942..ff25ae2c48e 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts @@ -140,6 +140,7 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre } }, prepareToolInvocation: (context, token) => this._proxy.$prepareToolInvocation(id, context, token), + handleToolStream: (context, token) => this._proxy.$handleToolStream(id, context, token), }); this._tools.set(id, disposable); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 0a7ba21b116..69162e12ea6 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1912,7 +1912,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatResponseExtensionsPart: extHostTypes.ChatResponseExtensionsPart, ChatResponseExternalEditPart: extHostTypes.ChatResponseExternalEditPart, ChatResponsePullRequestPart: extHostTypes.ChatResponsePullRequestPart, - ChatPrepareToolInvocationPart: extHostTypes.ChatPrepareToolInvocationPart, ChatResponseMultiDiffPart: extHostTypes.ChatResponseMultiDiffPart, ChatResponseReferencePartStatusKind: extHostTypes.ChatResponseReferencePartStatusKind, ChatResponseClearToPreviousToolInvocationReason: extHostTypes.ChatResponseClearToPreviousToolInvocationReason, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 3e45e3e4694..1d3d6debfbe 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -64,7 +64,7 @@ import { IChatSessionItem, IChatSessionProviderOptionGroup, IChatSessionProvider import { IChatRequestVariableValue } from '../../contrib/chat/common/attachments/chatVariables.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatSelector } from '../../contrib/chat/common/languageModels.js'; -import { IPreparedToolInvocation, IToolInvocation, IToolInvocationPreparationContext, IToolProgressStep, IToolResult, ToolDataSource } from '../../contrib/chat/common/tools/languageModelToolsService.js'; +import { IPreparedToolInvocation, IStreamedToolInvocation, IToolInvocation, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolProgressStep, IToolResult, ToolDataSource } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { IPromptFileContext, IPromptFileResource } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { DebugConfigurationProviderTriggerKind, IAdapterDescriptor, IConfig, IDebugSessionReplMode, IDebugTestRunReference, IDebugVisualization, IDebugVisualizationContext, IDebugVisualizationTreeItem, MainThreadDebugVisualization } from '../../contrib/debug/common/debug.js'; import { McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch } from '../../contrib/mcp/common/mcpTypes.js'; @@ -491,6 +491,10 @@ export interface IInlineCompletionModelInfoDto { readonly currentModelId: string; } +export interface IInlineCompletionChangeHintDto { + readonly data?: unknown; +} + export interface MainThreadLanguageFeaturesShape extends IDisposable { $unregister(handle: number): void; $registerDocumentSymbolProvider(handle: number, selector: IDocumentFilterDto[], label: string): void; @@ -537,7 +541,7 @@ export interface MainThreadLanguageFeaturesShape extends IDisposable { initialModelInfo: IInlineCompletionModelInfoDto | undefined, supportsOnDidChangeModelInfo: boolean, ): void; - $emitInlineCompletionsChange(handle: number): void; + $emitInlineCompletionsChange(handle: number, changeHint: IInlineCompletionChangeHintDto | undefined): void; $emitInlineCompletionModelInfoChange(handle: number, data: IInlineCompletionModelInfoDto | undefined): void; $registerSignatureHelpProvider(handle: number, selector: IDocumentFilterDto[], metadata: ISignatureHelpProviderMetadataDto): void; $registerInlayHintsProvider(handle: number, selector: IDocumentFilterDto[], supportsResolve: boolean, eventHandle: number | undefined, displayName: string | undefined): void; @@ -1517,6 +1521,8 @@ export interface ExtHostLanguageModelToolsShape { $onDidChangeTools(tools: IToolDataDto[]): void; $invokeTool(dto: Dto, token: CancellationToken): Promise | SerializableObjectWithBuffers>>; $countTokensForInvocation(callId: string, input: string, token: CancellationToken): Promise; + + $handleToolStream(toolId: string, context: IToolInvocationStreamContext, token: CancellationToken): Promise; $prepareToolInvocation(toolId: string, context: IToolInvocationPreparationContext, token: CancellationToken): Promise; } @@ -1543,7 +1549,9 @@ export type IChatProgressDto = | IChatTaskDto | IChatNotebookEditDto | IChatExternalEditsDto - | IChatResponseClearToPreviousToolInvocationDto; + | IChatResponseClearToPreviousToolInvocationDto + | IChatBeginToolInvocationDto + | IChatUpdateToolInvocationDto; export interface ExtHostUrlsShape { $handleExternalUri(handle: number, uri: UriComponents): Promise; @@ -2328,6 +2336,23 @@ export interface IChatResponseClearToPreviousToolInvocationDto { reason: ChatResponseClearToPreviousToolInvocationReason; } +export interface IChatBeginToolInvocationDto { + kind: 'beginToolInvocation'; + toolCallId: string; + toolName: string; + streamData?: { + partialInput?: unknown; + }; +} + +export interface IChatUpdateToolInvocationDto { + kind: 'updateToolInvocation'; + toolCallId: string; + streamData: { + partialInput?: unknown; + }; +} + export type ICellEditOperationDto = notebookCommon.ICellMetadataEdit | notebookCommon.IDocumentMetadataEdit @@ -3351,7 +3376,7 @@ export interface ExtHostChatSessionsShape { $disposeChatSessionContent(providerHandle: number, sessionResource: UriComponents): Promise; $invokeChatSessionRequestHandler(providerHandle: number, sessionResource: UriComponents, request: IChatAgentRequest, history: any[], token: CancellationToken): Promise; $provideChatSessionProviderOptions(providerHandle: number, token: CancellationToken): Promise; - $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, token: CancellationToken): Promise; + $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise; $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: ReadonlyArray, token: CancellationToken): Promise; } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index d9b6230da4e..785c6bf33ef 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -112,7 +112,7 @@ export class ChatAgentResponseStream { const _report = (progress: IChatProgressDto, task?: (progress: vscode.Progress) => Thenable) => { // Measure the time to the first progress update with real markdown content - if (typeof this._firstProgress === 'undefined' && (progress.kind === 'markdownContent' || progress.kind === 'markdownVuln' || progress.kind === 'prepareToolInvocation')) { + if (typeof this._firstProgress === 'undefined' && (progress.kind === 'markdownContent' || progress.kind === 'markdownVuln' || progress.kind === 'beginToolInvocation')) { this._firstProgress = this._stopWatch.elapsed(); } @@ -301,12 +301,32 @@ export class ChatAgentResponseStream { _report(dto); return this; }, - prepareToolInvocation(toolName) { - throwIfDone(this.prepareToolInvocation); + beginToolInvocation(toolCallId, toolName, streamData) { + throwIfDone(this.beginToolInvocation); checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); - const part = new extHostTypes.ChatPrepareToolInvocationPart(toolName); - const dto = typeConvert.ChatPrepareToolInvocationPart.from(part); + const dto: IChatProgressDto = { + kind: 'beginToolInvocation', + toolCallId, + toolName, + streamData: streamData ? { + partialInput: streamData.partialInput + } : undefined + }; + _report(dto); + return this; + }, + updateToolInvocation(toolCallId, streamData) { + throwIfDone(this.updateToolInvocation); + checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); + + const dto: IChatProgressDto = { + kind: 'updateToolInvocation', + toolCallId, + streamData: { + partialInput: streamData.partialInput + } + }; _report(dto); return this; }, @@ -357,11 +377,6 @@ export class ChatAgentResponseStream { that._sessionDisposables.add(toDisposable(() => cts.dispose(true))); } _report(dto); - } else if (part instanceof extHostTypes.ChatPrepareToolInvocationPart) { - checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); - const dto = typeConvert.ChatPrepareToolInvocationPart.from(part); - _report(dto); - return this; } else if (part instanceof extHostTypes.ChatResponseExternalEditPart) { const p = this.externalEdit(part.uris, part.callback); p.then((value) => part.didGetApplied(value)); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index b0ad15d2d49..bc7366256c1 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -465,7 +465,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } - async $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, token: CancellationToken): Promise { + async $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise { const optionGroups = this._providerOptionGroups.get(providerHandle); if (!optionGroups) { this._logService.warn(`No option groups found for provider handle ${providerHandle}`); @@ -479,7 +479,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } try { - const results = await group.onSearch(token); + const results = await group.onSearch(query, token); return results ?? []; } catch (error) { this._logService.error(`Error calling onSearch for option group ${optionGroupId}:`, error); diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index 098b7a0e5d4..4311937f5c2 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -2619,7 +2619,7 @@ export class ExtHostLanguageFeatures extends CoreDisposable implements extHostPr const supportsOnDidChange = isProposedApiEnabled(extension, 'inlineCompletionsAdditions') && typeof provider.onDidChange === 'function'; if (supportsOnDidChange) { - const subscription = provider.onDidChange!(_ => this._proxy.$emitInlineCompletionsChange(handle)); + const subscription = provider.onDidChange!(e => this._proxy.$emitInlineCompletionsChange(handle, e ? { data: e.data } : undefined)); result = Disposable.from(result, subscription); } diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 65073d77ea9..21815d734b5 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -12,7 +12,7 @@ import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { revive } from '../../../base/common/marshalling.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; -import { IPreparedToolInvocation, isToolInvocationContext, IToolInvocation, IToolInvocationContext, IToolInvocationPreparationContext, IToolResult, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; +import { IPreparedToolInvocation, IStreamedToolInvocation, isToolInvocationContext, IToolInvocation, IToolInvocationContext, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolResult, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { ExtensionEditToolId, InternalEditToolId } from '../../contrib/chat/common/tools/builtinTools/editFileTool.js'; import { InternalFetchWebPageToolId } from '../../contrib/chat/common/tools/builtinTools/tools.js'; import { SearchExtensionsToolId } from '../../contrib/extensions/common/searchExtensionsTool.js'; @@ -127,6 +127,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape chatRequestId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatRequestId : undefined, chatInteractionId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatInteractionId : undefined, fromSubAgent: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.fromSubAgent : undefined, + chatStreamToolCallId: isProposedApiEnabled(extension, 'chatParticipantAdditions') ? options.chatStreamToolCallId : undefined, }, token); const dto: Dto = result instanceof SerializableObjectWithBuffers ? result.value : result; @@ -192,6 +193,9 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape if (isProposedApiEnabled(item.extension, 'chatParticipantAdditions') && dto.modelId) { options.model = await this.getModel(dto.modelId, item.extension); } + if (isProposedApiEnabled(item.extension, 'chatParticipantAdditions') && dto.chatStreamToolCallId) { + options.chatStreamToolCallId = dto.chatStreamToolCallId; + } if (dto.tokenBudget !== undefined) { options.tokenizationOptions = { @@ -243,6 +247,37 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape return model; } + async $handleToolStream(toolId: string, context: IToolInvocationStreamContext, token: CancellationToken): Promise { + const item = this._registeredTools.get(toolId); + if (!item) { + throw new Error(`Unknown tool ${toolId}`); + } + + // Only call handleToolStream if it's defined on the tool + if (!item.tool.handleToolStream) { + return undefined; + } + + // Ensure the chatParticipantAdditions API is enabled + checkProposedApiEnabled(item.extension, 'chatParticipantAdditions'); + + const options: vscode.LanguageModelToolInvocationStreamOptions = { + rawInput: context.rawInput, + chatRequestId: context.chatRequestId, + chatSessionId: context.chatSessionId, + chatInteractionId: context.chatInteractionId + }; + + const result = await item.tool.handleToolStream(options, token); + if (!result) { + return undefined; + } + + return { + invocationMessage: typeConvert.MarkdownString.fromStrict(result.invocationMessage) + }; + } + async $prepareToolInvocation(toolId: string, context: IToolInvocationPreparationContext, token: CancellationToken): Promise { const item = this._registeredTools.get(toolId); if (!item) { diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 70028f88db4..bd1cdbd286f 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -42,7 +42,7 @@ import { IViewBadge } from '../../common/views.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { IChatRequestDraft } from '../../contrib/chat/common/editing/chatEditingService.js'; import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/chatModel.js'; -import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatPrepareToolInvocationPart, IChatProgressMessage, IChatPullRequestContent, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage } from '../../contrib/chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; @@ -2813,19 +2813,6 @@ export namespace ChatResponseMovePart { } } -export namespace ChatPrepareToolInvocationPart { - export function from(part: vscode.ChatPrepareToolInvocationPart): IChatPrepareToolInvocationPart { - return { - kind: 'prepareToolInvocation', - toolName: part.toolName, - }; - } - - export function to(part: IChatPrepareToolInvocationPart): vscode.ChatPrepareToolInvocationPart { - return new types.ChatPrepareToolInvocationPart(part.toolName); - } -} - export namespace ChatToolInvocationPart { export function from(part: vscode.ChatToolInvocationPart): IChatToolInvocationSerialized { // Convert extension API ChatToolInvocationPart to internal serialized format @@ -3098,8 +3085,6 @@ export namespace ChatResponsePart { return ChatResponseMovePart.from(part); } else if (part instanceof types.ChatResponseExtensionsPart) { return ChatResponseExtensionsPart.from(part); - } else if (part instanceof types.ChatPrepareToolInvocationPart) { - return ChatPrepareToolInvocationPart.from(part); } else if (part instanceof types.ChatResponsePullRequestPart) { return ChatResponsePullRequestPart.from(part); } else if (part instanceof types.ChatToolInvocationPart) { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 41cbfdd1738..6277175ffcd 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3340,17 +3340,6 @@ export class ChatResponseNotebookEditPart implements vscode.ChatResponseNotebook } } -export class ChatPrepareToolInvocationPart { - toolName: string; - /** - * @param toolName The name of the tool being prepared for invocation. - */ - constructor(toolName: string) { - this.toolName = toolName; - } -} - - export interface ChatTerminalToolInvocationData2 { commandLine: { original: string; diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index 4e81dd810ed..8ee357c7ee5 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -105,7 +105,14 @@ export function connectProxyResolver( extHostLogService.trace('ProxyResolver#loadAdditionalCertificates: Loading test certificates'); promises.push(Promise.resolve(https.globalAgent.testCertificates as string[])); } - return (await Promise.all(promises)).flat(); + const result = (await Promise.all(promises)).flat(); + mainThreadTelemetry.$publicLog2('additionalCertificates', { + count: result.length, + isRemote, + loadLocalCertificates, + useNodeSystemCerts, + }); + return result; }, env: process.env, }; @@ -257,6 +264,22 @@ function recordFetchFeatureUse(mainThreadTelemetry: MainThreadTelemetryShape, fe } } +type AdditionalCertificatesClassification = { + owner: 'chrmarti'; + comment: 'Tracks the number of additional certificates loaded for TLS connections'; + count: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of additional certificates loaded' }; + isRemote: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this is a remote extension host' }; + loadLocalCertificates: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether local certificates are loaded' }; + useNodeSystemCerts: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether Node.js system certificates are used' }; +}; + +type AdditionalCertificatesEvent = { + count: number; + isRemote: boolean; + loadLocalCertificates: boolean; + useNodeSystemCerts: boolean; +}; + type ProxyResolveStatsClassification = { owner: 'chrmarti'; comment: 'Performance statistics for proxy resolution'; diff --git a/src/vs/workbench/browser/parts/editor/media/editorplaceholder.css b/src/vs/workbench/browser/parts/editor/media/editorplaceholder.css index 4861d184353..b7c1b96fc9a 100644 --- a/src/vs/workbench/browser/parts/editor/media/editorplaceholder.css +++ b/src/vs/workbench/browser/parts/editor/media/editorplaceholder.css @@ -57,8 +57,6 @@ } .monaco-editor-pane-placeholder .editor-placeholder-buttons-container > .monaco-button { - font-size: 14px; width: fit-content; - padding: 6px 11px; outline-offset: 2px !important; } diff --git a/src/vs/workbench/browser/parts/notifications/media/notificationsList.css b/src/vs/workbench/browser/parts/notifications/media/notificationsList.css index e41d6f4824a..92da46b4dca 100644 --- a/src/vs/workbench/browser/parts/notifications/media/notificationsList.css +++ b/src/vs/workbench/browser/parts/notifications/media/notificationsList.css @@ -127,9 +127,7 @@ .monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container .monaco-text-button { width: fit-content; - padding: 4px 10px; display: inline-block; /* to enable ellipsis in text overflow */ - font-size: 12px; overflow: hidden; text-overflow: ellipsis; } diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControlRegistry.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControlRegistry.ts new file mode 100644 index 00000000000..20eeafacdb0 --- /dev/null +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControlRegistry.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 { IDisposable } from '../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; + +/** + * Interface for a command center control that can be registered with the titlebar. + */ +export interface ICommandCenterControl extends IDisposable { + readonly element: HTMLElement; +} + +/** + * A registration for a custom command center control. + */ +export interface ICommandCenterControlRegistration { + /** + * The context key that must be truthy for this control to be shown. + * When this context key is true, this control replaces the default command center. + */ + readonly contextKey: string; + + /** + * Priority for when multiple controls match. Higher priority wins. + */ + readonly priority: number; + + /** + * Factory function to create the control. + */ + create(instantiationService: IInstantiationService): ICommandCenterControl; +} + +class CommandCenterControlRegistryImpl { + private readonly registrations: ICommandCenterControlRegistration[] = []; + + /** + * Register a custom command center control. + */ + register(registration: ICommandCenterControlRegistration): IDisposable { + this.registrations.push(registration); + // Sort by priority descending + this.registrations.sort((a, b) => b.priority - a.priority); + + return { + dispose: () => { + const index = this.registrations.indexOf(registration); + if (index >= 0) { + this.registrations.splice(index, 1); + } + } + }; + } + + /** + * Get all registered command center controls. + */ + getRegistrations(): readonly ICommandCenterControlRegistration[] { + return this.registrations; + } +} + +/** + * Registry for custom command center controls. + * Contrib modules can register controls here, and the titlebar will use them + * when their context key conditions are met. + */ +export const CommandCenterControlRegistry = new CommandCenterControlRegistryImpl(); diff --git a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css index 0246cd2ad10..982f5a620df 100644 --- a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css +++ b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css @@ -168,10 +168,7 @@ border: 1px solid var(--vscode-commandCenter-border); overflow: hidden; margin: 0 6px; - border-top-left-radius: 6px; - border-bottom-left-radius: 6px; - border-top-right-radius: 6px; - border-bottom-right-radius: 6px; + border-radius: 4px; height: 22px; width: 38vw; max-width: 600px; diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 743f9e6ee8b..831c4be2380 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -30,6 +30,7 @@ import { IContextKey, IContextKeyService } from '../../../../platform/contextkey import { IHostService } from '../../../services/host/browser/host.js'; import { WindowTitle } from './windowTitle.js'; import { CommandCenterControl } from './commandCenterControl.js'; +import { CommandCenterControlRegistry } from './commandCenterControlRegistry.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { ACCOUNTS_ACTIVITY_ID, GLOBAL_ACTIVITY_ID } from '../../../common/activity.js'; @@ -328,6 +329,14 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { this._register(this.hostService.onDidChangeActiveWindow(windowId => windowId === targetWindowId ? this.onFocus() : this.onBlur())); this._register(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationChanged(e))); this._register(this.editorGroupsContainer.onDidChangeEditorPartOptions(e => this.onEditorPartConfigurationChange(e))); + + // Re-create title when any registered command center control's context key changes + this._register(this.contextKeyService.onDidChangeContext(e => { + const registeredContextKeys = new Set(CommandCenterControlRegistry.getRegistrations().map(r => r.contextKey)); + if (registeredContextKeys.size > 0 && e.affectsSome(registeredContextKeys)) { + this.createTitle(); + } + })); } private onBlur(): void { @@ -576,9 +585,24 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { // Menu Title else { - const commandCenter = this.instantiationService.createInstance(CommandCenterControl, this.windowTitle, this.hoverDelegate); - reset(this.title, commandCenter.element); - this.titleDisposables.add(commandCenter); + // Check if any registered command center control should be shown + let customControlShown = false; + for (const registration of CommandCenterControlRegistry.getRegistrations()) { + if (this.contextKeyService.getContextKeyValue(registration.contextKey)) { + const control = registration.create(this.instantiationService); + reset(this.title, control.element); + this.titleDisposables.add(control); + customControlShown = true; + break; + } + } + + if (!customControlShown) { + // Normal mode - show regular command center + const commandCenter = this.instantiationService.createInstance(CommandCenterControl, this.windowTitle, this.hoverDelegate); + reset(this.title, commandCenter.element); + this.titleDisposables.add(commandCenter); + } } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts index 6a43b52152a..57d42830dd2 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; +import { truncate } from '../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; @@ -27,6 +28,11 @@ const LOADING_SPINNER_SVG = (color: string | undefined) => ` `; +/** + * Maximum length for browser page titles before truncation + */ +const MAX_TITLE_LENGTH = 30; + /** * JSON-serializable type used during browser state serialization/deserialization */ @@ -148,6 +154,10 @@ export class BrowserEditorInput extends EditorInput { } override getName(): string { + return truncate(this.getTitle(), MAX_TITLE_LENGTH); + } + + override getTitle(): string { // Use model data if available, otherwise fall back to initial data if (this._model && this._model.url) { if (this._model.title) { diff --git a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEdit.css b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEdit.css index e113ad073ff..641c0d5e311 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEdit.css +++ b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEdit.css @@ -43,7 +43,6 @@ display: inline-flex; width: inherit; margin: 0 4px; - padding: 4px 8px; } .monaco-workbench .bulk-edit-panel .monaco-tl-contents { diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts index e468b666365..90397262b5d 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts @@ -41,7 +41,7 @@ export const getToolConfirmationAlert = (accessor: ServicesAccessor, toolInvocat }; } - if (!(v.confirmationMessages?.message && state.type === IChatToolInvocation.StateKind.WaitingForConfirmation)) { + if (!(state.type === IChatToolInvocation.StateKind.WaitingForConfirmation && state.confirmationMessages?.message)) { return; } @@ -56,7 +56,7 @@ export const getToolConfirmationAlert = (accessor: ServicesAccessor, toolInvocat input = JSON.stringify(v.toolSpecificData.rawInput); } } - const titleObj = v.confirmationMessages?.title; + const titleObj = state.confirmationMessages?.title; const title = typeof titleObj === 'string' ? titleObj : titleObj?.value || ''; return { title: (title + (input ? ': ' + input : '')).trim(), diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index f47ce1f0362..152b390c9a7 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -99,9 +99,9 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi const toolInvocations = item.response.value.filter(item => item.kind === 'toolInvocation'); for (const toolInvocation of toolInvocations) { const state = toolInvocation.state.get(); - if (toolInvocation.confirmationMessages?.title && state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { - const title = typeof toolInvocation.confirmationMessages.title === 'string' ? toolInvocation.confirmationMessages.title : toolInvocation.confirmationMessages.title.value; - const message = typeof toolInvocation.confirmationMessages.message === 'string' ? toolInvocation.confirmationMessages.message : stripIcons(renderAsPlaintext(toolInvocation.confirmationMessages.message!)); + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation && state.confirmationMessages?.title) { + const title = typeof state.confirmationMessages.title === 'string' ? state.confirmationMessages.title : state.confirmationMessages.title.value; + const message = typeof state.confirmationMessages.message === 'string' ? state.confirmationMessages.message : stripIcons(renderAsPlaintext(state.confirmationMessages.message!)); let input = ''; if (toolInvocation.toolSpecificData) { if (toolInvocation.toolSpecificData?.kind === 'terminal') { diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatThinkingAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatThinkingAccessibleView.ts new file mode 100644 index 00000000000..0c8e067e875 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatThinkingAccessibleView.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; +import { IChatWidgetService } from '../chat.js'; +import { IChatResponseViewModel, isResponseVM } from '../../common/model/chatViewModel.js'; + +export class ChatThinkingAccessibleView implements IAccessibleViewImplementation { + readonly priority = 105; + readonly name = 'chatThinking'; + readonly type = AccessibleViewType.View; + // Never match via the registry - this view is only opened via the explicit command (Alt+Shift+F2) + readonly when = ContextKeyExpr.false(); + + getProvider(accessor: ServicesAccessor) { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + if (!widget) { + return; + } + + const viewModel = widget.viewModel; + if (!viewModel) { + return; + } + + // Get the latest response from the chat + const items = viewModel.getItems(); + const latestResponse = [...items].reverse().find(item => isResponseVM(item)); + if (!latestResponse || !isResponseVM(latestResponse)) { + return; + } + + // Extract thinking content from the response + const thinkingContent = this._extractThinkingContent(latestResponse); + if (!thinkingContent) { + return; + } + + return new AccessibleContentProvider( + AccessibleViewProviderId.ChatThinking, + { type: AccessibleViewType.View, id: AccessibleViewProviderId.ChatThinking, language: 'markdown' }, + () => thinkingContent, + () => widget.focusInput(), + AccessibilityVerbositySettingId.Chat + ); + } + + private _extractThinkingContent(response: IChatResponseViewModel): string | undefined { + const thinkingParts: string[] = []; + for (const part of response.response.value) { + if (part.kind === 'thinking') { + const value = Array.isArray(part.value) ? part.value.join('') : (part.value || ''); + const trimmed = value.trim(); + if (trimmed) { + thinkingParts.push(trimmed); + } + } + } + + if (thinkingParts.length === 0) { + return undefined; + } + return thinkingParts.join('\n\n'); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts index f1ec099751c..51badfa9692 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts @@ -6,15 +6,19 @@ import { alert } from '../../../../../base/browser/ui/aria/aria.js'; import { localize } from '../../../../../nls.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { IChatWidgetService } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { isResponseVM } from '../../common/model/chatViewModel.js'; import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../../platform/accessibility/common/accessibility.js'; +import { IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { ChatThinkingAccessibleView } from '../accessibility/chatThinkingAccessibleView.js'; +import { CHAT_CATEGORY } from './chatActions.js'; export const ACTION_ID_FOCUS_CHAT_CONFIRMATION = 'workbench.action.chat.focusConfirmation'; +export const ACTION_ID_OPEN_THINKING_ACCESSIBLE_VIEW = 'workbench.action.chat.openThinkingAccessibleView'; class AnnounceChatConfirmationAction extends Action2 { constructor() { @@ -67,6 +71,39 @@ class AnnounceChatConfirmationAction extends Action2 { } } +class OpenThinkingAccessibleViewAction extends Action2 { + constructor() { + super({ + id: ACTION_ID_OPEN_THINKING_ACCESSIBLE_VIEW, + title: { value: localize('openThinkingAccessibleView', 'Open Thinking Accessible View'), original: 'Open Thinking Accessible View' }, + category: CHAT_CATEGORY, + precondition: ChatContextKeys.enabled, + f1: true, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.Alt | KeyMod.Shift | KeyCode.F3, + when: ChatContextKeys.inChatSession + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const accessibleViewService = accessor.get(IAccessibleViewService); + const instantiationService = accessor.get(IInstantiationService); + + const thinkingView = new ChatThinkingAccessibleView(); + const provider = instantiationService.invokeFunction(thinkingView.getProvider.bind(thinkingView)); + + if (!provider) { + alert(localize('noThinking', 'No thinking')); + return; + } + + accessibleViewService.show(provider); + } +} + export function registerChatAccessibilityActions(): void { registerAction2(AnnounceChatConfirmationAction); + registerAction2(OpenThinkingAccessibleViewAction); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 1a3a49fac76..9c42a79f6ef 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -17,6 +17,7 @@ import { TerminalContribCommandId } from '../../../terminal/terminalContribExpor import { ChatContextKeyExprs, ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; import { FocusAgentSessionsAction } from '../agentSessions/agentSessionsActions.js'; +import { ACTION_ID_OPEN_THINKING_ACCESSIBLE_VIEW } from './chatAccessibilityActions.js'; import { IChatWidgetService } from '../chat.js'; import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from '../chatEditing/chatEditingActions.js'; @@ -75,6 +76,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'age content.push(localize('chat.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use enter or the submit button to run a new request.')); content.push(localize('chat.attachments.removal', 'To remove attached contexts, focus an attachment and press Delete or Backspace.')); content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}.', '')); + content.push(localize('chat.openThinkingAccessibleView', 'To inspect thinking content from the latest response, invoke the Open Thinking Accessible View command{0}.', ``)); content.push(localize('workbench.action.chat.focus', 'To focus the chat request and response list, invoke the Focus Chat command{0}. This will move focus to the most recent response, which you can then navigate using the up and down arrow keys.', getChatFocusKeybindingLabel(keybindingService, type, 'last'))); content.push(localize('workbench.action.chat.focusLastFocusedItem', 'To return to the last chat response you focused, invoke the Focus Last Focused Chat Response command{0}.', getChatFocusKeybindingLabel(keybindingService, type, 'lastFocused'))); content.push(localize('workbench.action.chat.focusInput', 'To focus the input box for chat requests, invoke the Focus Chat Input command{0}.', getChatFocusKeybindingLabel(keybindingService, type, 'input'))); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 2eeef50c655..884cb31ad8f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -520,7 +520,7 @@ export function registerChatActions() { }, { id: MenuId.EditorTitle, group: 'navigation', - when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), ChatContextKeys.lockedToCodingAgent.negate()), + when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), order: 1 }], }); @@ -947,7 +947,8 @@ MenuRegistry.appendMenuItem(MenuId.CommandCenter, { ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabled.negate() ), - ContextKeyExpr.has('config.chat.commandCenter.enabled') + ContextKeyExpr.has('config.chat.commandCenter.enabled'), + ContextKeyExpr.has(`config.${ChatConfiguration.AgentSessionProjectionEnabled}`).negate() // Hide when agent controls are shown ), order: 10001 // to the right of command center }); @@ -1193,28 +1194,3 @@ registerAction2(class ToggleChatViewTitleAction extends Action2 { await configurationService.updateValue(ChatConfiguration.ChatViewTitleEnabled, !chatViewTitleEnabled); } }); - -registerAction2(class ToggleChatViewWelcomeAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.toggleChatViewWelcome', - title: localize2('chat.toggleChatViewWelcome.label', "Show Welcome"), - category: CHAT_CATEGORY, - precondition: ChatContextKeys.enabled, - toggled: ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewWelcomeEnabled}`, true), - menu: { - id: MenuId.ChatWelcomeContext, - group: '1_modify', - order: 3, - when: ChatContextKeys.inChatEditor.negate() - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const configurationService = accessor.get(IConfigurationService); - - const chatViewWelcomeEnabled = configurationService.getValue(ChatConfiguration.ChatViewWelcomeEnabled); - await configurationService.updateValue(ChatConfiguration.ChatViewWelcomeEnabled, !chatViewWelcomeEnabled); - } -}); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index dc52a099eb2..884cc180f4c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -156,6 +156,7 @@ export class ChatSubmitAction extends SubmitAction { const precondition = ContextKeyExpr.and( ChatContextKeys.inputHasText, whenNotInProgress, + ChatContextKeys.chatSessionOptionsValid, ); super({ @@ -346,9 +347,8 @@ class SwitchToNextModelAction extends Action2 { } } -export const ChatOpenModelPickerActionId = 'workbench.action.chat.openModelPicker'; -class OpenModelPickerAction extends Action2 { - static readonly ID = ChatOpenModelPickerActionId; +export class OpenModelPickerAction extends Action2 { + static readonly ID = 'workbench.action.chat.openModelPicker'; constructor() { super({ @@ -430,6 +430,41 @@ export class OpenModePickerAction extends Action2 { } } +export class OpenSessionTargetPickerAction extends Action2 { + static readonly ID = 'workbench.action.chat.openSessionTargetPicker'; + + constructor() { + super({ + id: OpenSessionTargetPickerAction.ID, + title: localize2('interactive.openSessionTargetPicker.label', "Open Session Target Picker"), + tooltip: localize('setSessionTarget', "Set Session Target"), + category: CHAT_CATEGORY, + f1: false, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.hasCanDelegateProviders, ChatContextKeys.chatSessionIsEmpty), + menu: [ + { + id: MenuId.ChatInput, + order: 0, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.inQuickChat.negate(), + ChatContextKeys.hasCanDelegateProviders), + group: 'navigation', + }, + ] + }); + } + + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + if (widget) { + widget.input.openSessionTargetPicker(); + } + } +} + export class ChatSessionPrimaryPickerAction extends Action2 { static readonly ID = 'workbench.action.chat.chatSessionPrimaryPicker'; constructor() { @@ -494,7 +529,8 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { const menuCondition = ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask); const precondition = ContextKeyExpr.and( ChatContextKeys.inputHasText, - whenNotInProgress + whenNotInProgress, + ChatContextKeys.chatSessionOptionsValid ); super({ @@ -756,6 +792,7 @@ export function registerChatExecuteActions() { registerAction2(SwitchToNextModelAction); registerAction2(OpenModelPickerAction); registerAction2(OpenModePickerAction); + registerAction2(OpenSessionTargetPickerAction); registerAction2(ChatSessionPrimaryPickerAction); registerAction2(ChangeChatModelAction); registerAction2(CancelEdit); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index 1476c1660a0..9daecba598b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -5,6 +5,8 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; @@ -14,16 +16,21 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatEditingSession } from '../../common/editing/chatEditingService.js'; import { IChatService } from '../../common/chatService/chatService.js'; +import { localChatSessionType } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; -import { ChatViewId, IChatWidgetService } from '../chat.js'; +import { getChatSessionType, LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { ChatViewId, IChatWidgetService, isIChatViewViewContext } from '../chat.js'; import { EditingSessionAction, getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; +import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { ACTION_ID_NEW_CHAT, ACTION_ID_NEW_EDIT_SESSION, CHAT_CATEGORY, handleCurrentEditingSession } from './chatActions.js'; import { clearChatEditor } from './chatClear.js'; import { AgentSessionsViewerOrientation } from '../agentSessions/agentSessions.js'; +import { IFocusViewService } from '../agentSessions/focusViewService.js'; export interface INewEditSessionActionContext { @@ -95,7 +102,7 @@ export function registerNewChatActions() { { id: MenuId.CompactWindowEditorTitle, group: 'navigation', - when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), ChatContextKeys.lockedToCodingAgent.negate()), + when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), order: 1 } ], @@ -114,6 +121,14 @@ export function registerNewChatActions() { async run(accessor: ServicesAccessor, ...args: unknown[]) { const accessibilityService = accessor.get(IAccessibilityService); + const focusViewService = accessor.get(IFocusViewService); + + // Exit focus view mode if active (back button behavior) + if (focusViewService.isActive) { + await focusViewService.exitFocusView(); + return; + } + const viewsService = accessor.get(IViewsService); const executeCommandContext = args[0] as INewEditSessionActionContext | undefined; @@ -132,7 +147,20 @@ export function registerNewChatActions() { } await editingSession?.stop(); - await widget.clear(); + + // Create a new session with the same type as the current session + if (isIChatViewViewContext(widget.viewContext)) { + // For the sidebar, we need to explicitly load a session with the same type + const currentResource = widget.viewModel?.model.sessionResource; + const sessionType = currentResource ? getChatSessionType(currentResource) : localChatSessionType; + const newResource = getResourceForNewChatSession(sessionType); + const view = await viewsService.openView(ChatViewId) as ChatViewPane; + await view.loadSession(newResource); + } else { + // For the editor, widget.clear() already preserves the session type via clearChatEditor + await widget.clear(); + } + widget.attachmentModel.clear(true); widget.input.relatedFiles?.clear(); widget.focusInput(); @@ -259,3 +287,20 @@ export function registerNewChatActions() { } }); } + +/** + * Creates a new session resource URI with the specified session type. + * For remote sessions, creates a URI with the session type as the scheme. + * For local sessions, creates a LocalChatSessionUri. + */ +function getResourceForNewChatSession(sessionType: string): URI { + const isRemoteSession = sessionType !== localChatSessionType; + if (isRemoteSession) { + return URI.from({ + scheme: sessionType, + path: `/untitled-${generateUuid()}`, + }); + } + + return LocalChatSessionUri.forSession(generateUuid()); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 45142e50c1b..8accd14a179 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../../nls.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { registerSingleton, InstantiationType } from '../../../../../platform/instantiation/common/extensions.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -13,10 +15,17 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from './agentSessions.js'; import { IAgentSessionsService, AgentSessionsService } from './agentSessionsService.js'; import { LocalAgentsSessionsProvider } from './localAgentSessionsProvider.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; -import { ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { ISubmenuItem, MenuId, MenuRegistry, registerAction2, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, ToggleChatViewSessionsAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction } from './agentSessionsActions.js'; import { AgentSessionsQuickAccessProvider, AGENT_SESSIONS_QUICK_ACCESS_PREFIX } from './agentSessionsQuickAccess.js'; +import { IFocusViewService, FocusViewService } from './focusViewService.js'; +import { EnterFocusViewAction, ExitFocusViewAction, OpenInChatPanelAction, ToggleAgentsControl } from './focusViewActions.js'; +import { AgentsControlViewItem } from './agentsControl.js'; +import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../../common/constants.js'; //#region Actions and Menus @@ -44,6 +53,12 @@ registerAction2(ToggleChatViewSessionsAction); registerAction2(SetAgentSessionsOrientationStackedAction); registerAction2(SetAgentSessionsOrientationSideBySideAction); +// Focus View +registerAction2(EnterFocusViewAction); +registerAction2(ExitFocusViewAction); +registerAction2(OpenInChatPanelAction); +registerAction2(ToggleAgentsControl); + // --- Agent Sessions Toolbar MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { @@ -169,5 +184,65 @@ Registry.as(QuickAccessExtensions.Quickaccess).registerQui registerWorkbenchContribution2(LocalAgentsSessionsProvider.ID, LocalAgentsSessionsProvider, WorkbenchPhase.AfterRestored); registerSingleton(IAgentSessionsService, AgentSessionsService, InstantiationType.Delayed); +registerSingleton(IFocusViewService, FocusViewService, InstantiationType.Delayed); + +// Register Agents Control as a menu item in the command center (alongside the search box, not replacing it) +MenuRegistry.appendMenuItem(MenuId.CommandCenter, { + submenu: MenuId.AgentsControlMenu, + title: localize('agentsControl', "Agents"), + icon: Codicon.chatSparkle, + when: ContextKeyExpr.has(`config.${ChatConfiguration.AgentSessionProjectionEnabled}`), + order: 10002 // to the right of the chat button +}); + +// Register a placeholder action to the submenu so it appears (required for submenus) +MenuRegistry.appendMenuItem(MenuId.AgentsControlMenu, { + command: { + id: 'workbench.action.chat.toggle', + title: localize('openChat', "Open Chat"), + }, + when: ContextKeyExpr.has(`config.${ChatConfiguration.AgentSessionProjectionEnabled}`), +}); + +/** + * Provides custom rendering for the agents control in the command center. + * Uses IActionViewItemService to render a custom AgentsControlViewItem + * for the AgentsControlMenu submenu. + * Also adds a CSS class to the workbench when agents control is enabled. + */ +class AgentsControlRendering extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentsControl.rendering'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService + ) { + super(); + + this._register(actionViewItemService.register(MenuId.CommandCenter, MenuId.AgentsControlMenu, (action, options) => { + if (!(action instanceof SubmenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(AgentsControlViewItem, action, options); + }, undefined)); + + // Add/remove CSS class on workbench based on setting + const updateClass = () => { + const enabled = configurationService.getValue(ChatConfiguration.AgentSessionProjectionEnabled) === true; + mainWindow.document.body.classList.toggle('agents-control-enabled', enabled); + }; + updateClass(); + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.AgentSessionProjectionEnabled)) { + updateClass(); + } + })); + } +} + +// Register the workbench contribution that provides custom rendering for the agents control +registerWorkbenchContribution2(AgentsControlRendering.ID, AgentsControlRendering, WorkbenchPhase.AfterRestored); //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 54b47048b3c..0993aa2e8c8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -9,6 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; +import { getChatSessionType } from '../../common/model/chatUri.js'; export enum AgentSessionProviders { Local = localChatSessionType, @@ -16,6 +17,18 @@ export enum AgentSessionProviders { Cloud = 'copilot-cloud-agent', } +export function getAgentSessionProvider(sessionResource: URI | string): AgentSessionProviders | undefined { + const type = URI.isUri(sessionResource) ? getChatSessionType(sessionResource) : sessionResource; + switch (type) { + case AgentSessionProviders.Local: + case AgentSessionProviders.Background: + case AgentSessionProviders.Cloud: + return type; + default: + return undefined; + } +} + export function getAgentSessionProviderName(provider: AgentSessionProviders): string { switch (provider) { case AgentSessionProviders.Local: diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index c895c8f8eaf..75cd153a259 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -11,8 +11,39 @@ import { ACTIVE_GROUP, SIDE_GROUP } from '../../../../services/editor/common/edi import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { Schemas } from '../../../../../base/common/network.js'; +import { IFocusViewService } from './focusViewService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../../common/constants.js'; export async function openSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: { sideBySide?: boolean; editorOptions?: IEditorOptions }): Promise { + const configurationService = accessor.get(IConfigurationService); + const focusViewService = accessor.get(IFocusViewService); + + session.setRead(true); // mark as read when opened + + // Local chat sessions (chat history) should always open in the chat widget + if (isLocalAgentSessionItem(session)) { + await openSessionInChatWidget(accessor, session, openOptions); + return; + } + + // Check if Agent Session Projection is enabled for agent sessions + const agentSessionProjectionEnabled = configurationService.getValue(ChatConfiguration.AgentSessionProjectionEnabled) === true; + + if (agentSessionProjectionEnabled) { + // Enter Agent Session Projection mode for the session + await focusViewService.enterFocusView(session); + } else { + // Fall back to opening in chat widget when Agent Session Projection is disabled + await openSessionInChatWidget(accessor, session, openOptions); + } +} + +/** + * Opens a session in the traditional chat widget (side panel or editor). + * Use this when you explicitly want to open in the chat widget rather than agent session projection mode. + */ +export async function openSessionInChatWidget(accessor: ServicesAccessor, session: IAgentSession, openOptions?: { sideBySide?: boolean; editorOptions?: IEditorOptions }): Promise { const chatSessionsService = accessor.get(IChatSessionsService); const chatWidgetService = accessor.get(IChatWidgetService); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts new file mode 100644 index 00000000000..0a71dc15ccc --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts @@ -0,0 +1,290 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/focusView.css'; + +import { $, addDisposableListener, EventType, reset } from '../../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IFocusViewService } from './focusViewService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { ExitFocusViewAction } from './focusViewActions.js'; +import { IAgentSessionsService } from './agentSessionsService.js'; +import { isSessionInProgressStatus } from './agentSessionsModel.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../../base/common/actions.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; + +const TOGGLE_CHAT_ACTION_ID = 'workbench.action.chat.toggle'; +const OPEN_CHAT_ACTION_ID = 'workbench.action.chat.open'; // Has the keybinding +const QUICK_OPEN_ACTION_ID = 'workbench.action.quickOpenWithModes'; + +/** + * Agents Control View Item - renders agent status in the command center when agent session projection is enabled. + * + * Shows two different states: + * 1. Default state: Copilot icon pill (turns blue with in-progress count when agents are running) + * 2. Agent Session Projection state: Session title + close button (when viewing a session) + * + * The command center search box and navigation controls remain visible alongside this control. + */ +export class AgentsControlViewItem extends BaseActionViewItem { + + private _container: HTMLElement | undefined; + private readonly _dynamicDisposables = this._register(new DisposableStore()); + + constructor( + action: IAction, + options: IBaseActionViewItemOptions | undefined, + @IFocusViewService private readonly focusViewService: IFocusViewService, + @IHoverService private readonly hoverService: IHoverService, + @ICommandService private readonly commandService: ICommandService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ILabelService private readonly labelService: ILabelService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + ) { + super(undefined, action, options); + + // Re-render when session changes + this._register(this.focusViewService.onDidChangeActiveSession(() => { + this._render(); + })); + + this._register(this.focusViewService.onDidChangeFocusViewMode(() => { + this._render(); + })); + + // Re-render when sessions change to update statistics + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this._render(); + })); + } + + override render(container: HTMLElement): void { + super.render(container); + this._container = container; + container.classList.add('agents-control-container'); + + // Initial render + this._render(); + } + + private _render(): void { + if (!this._container) { + return; + } + + // Clear existing content + reset(this._container); + + // Clear previous disposables for dynamic content + this._dynamicDisposables.clear(); + + if (this.focusViewService.isActive && this.focusViewService.activeSession) { + // Agent Session Projection mode - show session title + close button + this._renderSessionMode(this._dynamicDisposables); + } else { + // Default mode - show copilot pill with optional in-progress indicator + this._renderChatInputMode(this._dynamicDisposables); + } + } + + private _renderChatInputMode(disposables: DisposableStore): void { + if (!this._container) { + return; + } + + // Get agent session statistics + const sessions = this.agentSessionsService.model.sessions; + const activeSessions = sessions.filter(s => isSessionInProgressStatus(s.status)); + const unreadSessions = sessions.filter(s => !s.isRead()); + const hasActiveSessions = activeSessions.length > 0; + const hasUnreadSessions = unreadSessions.length > 0; + + // Create pill - add 'has-active' class when sessions are in progress + const pill = $('div.agents-control-pill.chat-input-mode'); + if (hasActiveSessions) { + pill.classList.add('has-active'); + } else if (hasUnreadSessions) { + pill.classList.add('has-unread'); + } + pill.setAttribute('role', 'button'); + pill.setAttribute('aria-label', localize('openChat', "Open Chat")); + pill.tabIndex = 0; + this._container.appendChild(pill); + + // Copilot icon (always shown) + const icon = $('span.agents-control-icon'); + reset(icon, renderIcon(Codicon.chatSparkle)); + pill.appendChild(icon); + + // Show workspace name (centered) + const label = $('span.agents-control-label'); + const workspaceName = this.labelService.getWorkspaceLabel(this.workspaceContextService.getWorkspace()); + label.textContent = workspaceName; + pill.appendChild(label); + + // Right side indicator + const rightIndicator = $('span.agents-control-status'); + if (hasActiveSessions) { + // Running indicator when there are active sessions + const runningIcon = $('span.agents-control-status-icon'); + reset(runningIcon, renderIcon(Codicon.sessionInProgress)); + rightIndicator.appendChild(runningIcon); + const runningCount = $('span.agents-control-status-text'); + runningCount.textContent = String(activeSessions.length); + rightIndicator.appendChild(runningCount); + } else if (hasUnreadSessions) { + // Unread indicator when there are unread sessions + const unreadIcon = $('span.agents-control-status-icon'); + reset(unreadIcon, renderIcon(Codicon.circleFilled)); + rightIndicator.appendChild(unreadIcon); + const unreadCount = $('span.agents-control-status-text'); + unreadCount.textContent = String(unreadSessions.length); + rightIndicator.appendChild(unreadCount); + } else { + // Keyboard shortcut when idle (show open chat keybinding) + const kb = this.keybindingService.lookupKeybinding(OPEN_CHAT_ACTION_ID)?.getLabel(); + if (kb) { + const kbLabel = $('span.agents-control-keybinding'); + kbLabel.textContent = kb; + rightIndicator.appendChild(kbLabel); + } + } + pill.appendChild(rightIndicator); + + // Setup hover with keyboard shortcut + const hoverDelegate = getDefaultHoverDelegate('mouse'); + const kbForTooltip = this.keybindingService.lookupKeybinding(OPEN_CHAT_ACTION_ID)?.getLabel(); + const tooltip = kbForTooltip + ? localize('askTooltip', "Open Chat ({0})", kbForTooltip) + : localize('askTooltip2', "Open Chat"); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, tooltip)); + + // Click handler - open chat + disposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(TOGGLE_CHAT_ACTION_ID); + })); + + // Keyboard handler + disposables.add(addDisposableListener(pill, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(TOGGLE_CHAT_ACTION_ID); + } + })); + + // Search button (right of pill) + this._renderSearchButton(disposables); + } + + private _renderSessionMode(disposables: DisposableStore): void { + if (!this._container) { + return; + } + + const pill = $('div.agents-control-pill.session-mode'); + this._container.appendChild(pill); + + // Copilot icon + const iconContainer = $('span.agents-control-icon'); + reset(iconContainer, renderIcon(Codicon.chatSparkle)); + pill.appendChild(iconContainer); + + // Session title + const titleLabel = $('span.agents-control-title'); + const session = this.focusViewService.activeSession; + titleLabel.textContent = session?.label ?? localize('agentSessionProjection', "Agent Session Projection"); + pill.appendChild(titleLabel); + + // Close button + const closeButton = $('span.agents-control-close'); + closeButton.classList.add('codicon', 'codicon-close'); + closeButton.setAttribute('role', 'button'); + closeButton.setAttribute('aria-label', localize('exitAgentSessionProjection', "Exit Agent Session Projection")); + closeButton.tabIndex = 0; + pill.appendChild(closeButton); + + // Setup hovers + const hoverDelegate = getDefaultHoverDelegate('mouse'); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, closeButton, localize('exitAgentSessionProjectionTooltip', "Exit Agent Session Projection (Escape)"))); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, () => { + const activeSession = this.focusViewService.activeSession; + return activeSession ? localize('agentSessionProjectionTooltip', "Agent Session Projection: {0}", activeSession.label) : localize('agentSessionProjection', "Agent Session Projection"); + })); + + // Close button click handler + disposables.add(addDisposableListener(closeButton, EventType.MOUSE_DOWN, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(ExitFocusViewAction.ID); + })); + + disposables.add(addDisposableListener(closeButton, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(ExitFocusViewAction.ID); + })); + + // Close button keyboard handler + disposables.add(addDisposableListener(closeButton, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(ExitFocusViewAction.ID); + } + })); + + // Search button (right of pill) + this._renderSearchButton(disposables); + } + + private _renderSearchButton(disposables: DisposableStore): void { + if (!this._container) { + return; + } + + const searchButton = $('span.agents-control-search'); + reset(searchButton, renderIcon(Codicon.search)); + searchButton.setAttribute('role', 'button'); + searchButton.setAttribute('aria-label', localize('openQuickOpen', "Open Quick Open")); + searchButton.tabIndex = 0; + this._container.appendChild(searchButton); + + // Setup hover + const hoverDelegate = getDefaultHoverDelegate('mouse'); + const searchKb = this.keybindingService.lookupKeybinding(QUICK_OPEN_ACTION_ID)?.getLabel(); + const searchTooltip = searchKb + ? localize('openQuickOpenTooltip', "Go to File ({0})", searchKb) + : localize('openQuickOpenTooltip2', "Go to File"); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, searchButton, searchTooltip)); + + // Click handler + disposables.add(addDisposableListener(searchButton, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(QUICK_OPEN_ACTION_ID); + })); + + // Keyboard handler + disposables.add(addDisposableListener(searchButton, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(QUICK_OPEN_ACTION_ID); + } + })); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewActions.ts new file mode 100644 index 00000000000..d76b5c2c967 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewActions.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { IFocusViewService } from './focusViewService.js'; +import { IAgentSession, isMarshalledAgentSessionContext, IMarshalledAgentSessionContext } from './agentSessionsModel.js'; +import { IAgentSessionsService } from './agentSessionsService.js'; +import { CHAT_CATEGORY } from '../actions/chatActions.js'; +import { openSessionInChatWidget } from './agentSessionsOpener.js'; +import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; +import { IsCompactTitleBarContext } from '../../../../common/contextkeys.js'; + +//#region Enter Agent Session Projection + +export class EnterFocusViewAction extends Action2 { + static readonly ID = 'agentSession.enterAgentSessionProjection'; + + constructor() { + super({ + id: EnterFocusViewAction.ID, + title: localize2('enterAgentSessionProjection', "Enter Agent Session Projection"), + category: CHAT_CATEGORY, + f1: false, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.has(`config.${ChatConfiguration.AgentSessionProjectionEnabled}`), + ChatContextKeys.inFocusViewMode.negate() + ), + }); + } + + override async run(accessor: ServicesAccessor, context?: IAgentSession | IMarshalledAgentSessionContext): Promise { + const focusViewService = accessor.get(IFocusViewService); + const agentSessionsService = accessor.get(IAgentSessionsService); + + let session: IAgentSession | undefined; + if (context) { + if (isMarshalledAgentSessionContext(context)) { + session = agentSessionsService.getSession(context.session.resource); + } else { + session = context; + } + } + + if (session) { + await focusViewService.enterFocusView(session); + } + } +} + +//#endregion + +//#region Exit Agent Session Projection + +export class ExitFocusViewAction extends Action2 { + static readonly ID = 'agentSession.exitAgentSessionProjection'; + + constructor() { + super({ + id: ExitFocusViewAction.ID, + title: localize2('exitAgentSessionProjection', "Exit Agent Session Projection"), + category: CHAT_CATEGORY, + f1: true, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.inFocusViewMode + ), + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.Escape, + when: ChatContextKeys.inFocusViewMode, + }, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const focusViewService = accessor.get(IFocusViewService); + await focusViewService.exitFocusView(); + } +} + +//#endregion + +//#region Open in Chat Panel + +export class OpenInChatPanelAction extends Action2 { + static readonly ID = 'agentSession.openInChatPanel'; + + constructor() { + super({ + id: OpenInChatPanelAction.ID, + title: localize2('openInChatPanel', "Open in Chat Panel"), + category: CHAT_CATEGORY, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.AgentSessionsContext, + group: '1_open', + order: 1, + }] + }); + } + + override async run(accessor: ServicesAccessor, context?: IAgentSession | IMarshalledAgentSessionContext): Promise { + const agentSessionsService = accessor.get(IAgentSessionsService); + + let session: IAgentSession | undefined; + if (context) { + if (isMarshalledAgentSessionContext(context)) { + session = agentSessionsService.getSession(context.session.resource); + } else { + session = context; + } + } + + if (session) { + await openSessionInChatWidget(accessor, session); + } + } +} + +//#endregion + +//#region Toggle Agents Control + +export class ToggleAgentsControl extends ToggleTitleBarConfigAction { + constructor() { + super( + ChatConfiguration.AgentSessionProjectionEnabled, + localize('toggle.agentsControl', 'Agents Controls'), + localize('toggle.agentsControlDescription', "Toggle visibility of the Agents Controls in title bar"), 6, + ContextKeyExpr.and( + ChatContextKeys.enabled, + IsCompactTitleBarContext.negate(), + ChatContextKeys.supported + ) + ); + } +} + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts new file mode 100644 index 00000000000..cfcd09839dd --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/focusView.css'; + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IEditorGroupsService, IEditorWorkingSet } from '../../../../services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IAgentSession } from './agentSessionsModel.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../chat.js'; +import { AgentSessionProviders } from './agentSessions.js'; +import { IChatSessionsService } from '../../common/chatSessionsService.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; +import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; + +//#region Configuration + +/** + * Provider types that support agent session projection mode. + * Only sessions from these providers will trigger focus view. + * + * Configuration: + * - AgentSessionProviders.Local: Local chat sessions (disabled) + * - AgentSessionProviders.Background: Background CLI agents (enabled) + * - AgentSessionProviders.Cloud: Cloud agents (enabled) + */ +const AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS: Set = new Set([ + AgentSessionProviders.Background, + AgentSessionProviders.Cloud, +]); + +//#endregion + +//#region Focus View Service Interface + +export interface IFocusViewService { + readonly _serviceBrand: undefined; + + /** + * Whether focus view mode is active. + */ + readonly isActive: boolean; + + /** + * The currently active session in focus view, if any. + */ + readonly activeSession: IAgentSession | undefined; + + /** + * Event fired when focus view mode changes. + */ + readonly onDidChangeFocusViewMode: Event; + + /** + * Event fired when the active session changes (including when switching between sessions). + */ + readonly onDidChangeActiveSession: Event; + + /** + * Enter focus view mode for the given session. + */ + enterFocusView(session: IAgentSession): Promise; + + /** + * Exit focus view mode. + */ + exitFocusView(): Promise; +} + +export const IFocusViewService = createDecorator('focusViewService'); + +//#endregion + +//#region Focus View Service Implementation + +export class FocusViewService extends Disposable implements IFocusViewService { + + declare readonly _serviceBrand: undefined; + + private _isActive = false; + get isActive(): boolean { return this._isActive; } + + private _activeSession: IAgentSession | undefined; + get activeSession(): IAgentSession | undefined { return this._activeSession; } + + private readonly _onDidChangeFocusViewMode = this._register(new Emitter()); + readonly onDidChangeFocusViewMode = this._onDidChangeFocusViewMode.event; + + private readonly _onDidChangeActiveSession = this._register(new Emitter()); + readonly onDidChangeActiveSession = this._onDidChangeActiveSession.event; + + private readonly _inFocusViewModeContextKey: IContextKey; + + /** Working set saved when entering focus view (to restore on exit) */ + private _nonFocusViewWorkingSet: IEditorWorkingSet | undefined; + + /** Working sets per session, keyed by session resource URI string */ + private readonly _sessionWorkingSets = new Map(); + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IEditorService private readonly editorService: IEditorService, + @ILogService private readonly logService: ILogService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + + this._inFocusViewModeContextKey = ChatContextKeys.inFocusViewMode.bindTo(contextKeyService); + + // Listen for editor close events to exit focus view when all editors are closed + this._register(this.editorService.onDidCloseEditor(() => this._checkForEmptyEditors())); + } + + private _isEnabled(): boolean { + return this.configurationService.getValue(ChatConfiguration.AgentSessionProjectionEnabled) === true; + } + + private _checkForEmptyEditors(): void { + // Only check if we're in focus view mode + if (!this._isActive) { + return; + } + + // Check if there are any visible editors + const hasVisibleEditors = this.editorService.visibleEditors.length > 0; + + if (!hasVisibleEditors) { + this.logService.trace('[FocusView] All editors closed, exiting focus view mode'); + this.exitFocusView(); + } + } + + private async _openSessionFiles(session: IAgentSession): Promise { + // Clear editors first + await this.editorGroupsService.applyWorkingSet('empty', { preserveFocus: true }); + + this.logService.trace(`[FocusView] Opening files for session '${session.label}'`, { + hasChanges: !!session.changes, + isArray: Array.isArray(session.changes), + changeCount: Array.isArray(session.changes) ? session.changes.length : 0 + }); + + // Open changes from the session as a multi-diff editor (like edit session view) + if (session.changes && Array.isArray(session.changes) && session.changes.length > 0) { + // Filter to changes that have both original and modified URIs for diff view + const diffResources = session.changes + .filter(change => change.originalUri) + .map(change => ({ + originalUri: change.originalUri!, + modifiedUri: change.modifiedUri + })); + + this.logService.trace(`[FocusView] Found ${diffResources.length} files with diffs to display`); + + if (diffResources.length > 0) { + // Open multi-diff editor showing all changes + await this.commandService.executeCommand('_workbench.openMultiDiffEditor', { + multiDiffSourceUri: session.resource.with({ scheme: session.resource.scheme + '-agent-session-projection' }), + title: localize('agentSessionProjection.changes.title', '{0} - All Changes', session.label), + resources: diffResources, + }); + + this.logService.trace(`[FocusView] Multi-diff editor opened successfully`); + + // Save this as the session's working set + const sessionKey = session.resource.toString(); + const newWorkingSet = this.editorGroupsService.saveWorkingSet(`focus-view-session-${sessionKey}`); + this._sessionWorkingSets.set(sessionKey, newWorkingSet); + } else { + this.logService.trace(`[FocusView] No files with diffs to display (all changes missing originalUri)`); + } + } else { + this.logService.trace(`[FocusView] Session has no changes to display`); + } + } + + async enterFocusView(session: IAgentSession): Promise { + // Check if the feature is enabled + if (!this._isEnabled()) { + this.logService.trace('[FocusView] Agent Session Projection is disabled'); + return; + } + + // Check if this session's provider type supports agent session projection + if (!AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS.has(session.providerType)) { + this.logService.trace(`[FocusView] Provider type '${session.providerType}' does not support agent session projection`); + return; + } + + if (!this._isActive) { + // First time entering focus view - save the current working set as our "non-focus-view" backup + this._nonFocusViewWorkingSet = this.editorGroupsService.saveWorkingSet('focus-view-backup'); + } else if (this._activeSession) { + // Already in focus view, switching sessions - save the current session's working set + const previousSessionKey = this._activeSession.resource.toString(); + const previousWorkingSet = this.editorGroupsService.saveWorkingSet(`focus-view-session-${previousSessionKey}`); + this._sessionWorkingSets.set(previousSessionKey, previousWorkingSet); + } + + // Always open session files to ensure they're displayed + await this._openSessionFiles(session); + + // Set active state + const wasActive = this._isActive; + this._isActive = true; + this._activeSession = session; + this._inFocusViewModeContextKey.set(true); + this.layoutService.mainContainer.classList.add('focus-view-active'); + if (!wasActive) { + this._onDidChangeFocusViewMode.fire(true); + } + // Always fire session change event (for title updates when switching sessions) + this._onDidChangeActiveSession.fire(session); + + // Open the session in the chat panel + session.setRead(true); + await this.chatSessionsService.activateChatSessionItemProvider(session.providerType); + await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget, { + title: { preferred: session.label }, + revealIfOpened: true + }); + } + + async exitFocusView(): Promise { + if (!this._isActive) { + return; + } + + // Save the current session's working set before exiting + if (this._activeSession) { + const sessionKey = this._activeSession.resource.toString(); + const workingSet = this.editorGroupsService.saveWorkingSet(`focus-view-session-${sessionKey}`); + this._sessionWorkingSets.set(sessionKey, workingSet); + } + + // Restore the non-focus-view working set + if (this._nonFocusViewWorkingSet) { + const existingWorkingSets = this.editorGroupsService.getWorkingSets(); + const exists = existingWorkingSets.some(ws => ws.id === this._nonFocusViewWorkingSet!.id); + if (exists) { + await this.editorGroupsService.applyWorkingSet(this._nonFocusViewWorkingSet); + this.editorGroupsService.deleteWorkingSet(this._nonFocusViewWorkingSet); + } else { + await this.editorGroupsService.applyWorkingSet('empty', { preserveFocus: true }); + } + this._nonFocusViewWorkingSet = undefined; + } + + this._isActive = false; + this._activeSession = undefined; + this._inFocusViewModeContextKey.set(false); + this.layoutService.mainContainer.classList.remove('focus-view-active'); + this._onDidChangeFocusViewMode.fire(false); + this._onDidChangeActiveSession.fire(undefined); + + // Start a new chat to clear the sidebar + await this.commandService.executeCommand(ACTION_ID_NEW_CHAT); + } +} + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css new file mode 100644 index 00000000000..bea8ba912b9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ======================================== +Focus View Mode - Blue glow border around entire workbench +======================================== */ + +.monaco-workbench.focus-view-active::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 10000; + box-shadow: inset 0 0 0 3px rgba(0, 120, 212, 0.8), inset 0 0 30px rgba(0, 120, 212, 0.4); + transition: box-shadow 0.2s ease-in-out; +} + +.hc-black .monaco-workbench.focus-view-active::after, +.hc-light .monaco-workbench.focus-view-active::after { + box-shadow: inset 0 0 0 2px var(--vscode-contrastBorder); +} + +/* ======================================== +Agents Control - Titlebar control +======================================== */ + +/* Hide command center search box when agents control enabled */ +.agents-control-enabled .command-center .action-item.command-center-center { + display: none !important; +} + +/* Give agents control same width as search box */ +.agents-control-enabled .command-center .action-item.agents-control-container { + width: 38vw; + max-width: 600px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: center; +} + +.agents-control-container { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + gap: 4px; + -webkit-app-region: no-drag; +} + +/* Pill - shared styles */ +.agents-control-pill { + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + height: 22px; + border-radius: 6px; + position: relative; + flex: 1; + min-width: 0; + -webkit-app-region: no-drag; +} + +/* Chat input mode (default state) */ +.agents-control-pill.chat-input-mode { + background-color: var(--vscode-commandCenter-background, rgba(0, 0, 0, 0.05)); + border: 1px solid var(--vscode-commandCenter-border, transparent); + cursor: pointer; +} + +.agents-control-pill.chat-input-mode:hover { + background-color: var(--vscode-commandCenter-activeBackground, rgba(0, 0, 0, 0.1)); + border-color: var(--vscode-commandCenter-activeBorder, rgba(0, 0, 0, 0.2)); +} + +.agents-control-pill.chat-input-mode:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Active state - has running sessions */ +.agents-control-pill.chat-input-mode.has-active { + background-color: rgba(0, 120, 212, 0.15); + border: 1px solid rgba(0, 120, 212, 0.5); +} + +.agents-control-pill.chat-input-mode.has-active:hover { + background-color: rgba(0, 120, 212, 0.25); + border-color: rgba(0, 120, 212, 0.7); +} + +.agents-control-pill.chat-input-mode.has-active .agents-control-icon, +.agents-control-pill.chat-input-mode.has-active .agents-control-label { + color: var(--vscode-textLink-foreground); + opacity: 1; +} + +/* Unread state - has unread sessions (no background change, just indicator) */ +.agents-control-pill.chat-input-mode.has-unread .agents-control-status-icon { + font-size: 8px; +} + +/* Session mode (viewing a session) */ +.agents-control-pill.session-mode { + background-color: rgba(0, 120, 212, 0.15); + border: 1px solid rgba(0, 120, 212, 0.5); + padding: 0 12px; +} + +.agents-control-pill.session-mode:hover { + background-color: rgba(0, 120, 212, 0.25); + border-color: rgba(0, 120, 212, 0.7); +} + +/* Icon */ +.agents-control-icon { + display: flex; + align-items: center; + color: var(--vscode-foreground); + opacity: 0.7; +} + +.agents-control-pill.session-mode .agents-control-icon { + color: var(--vscode-textLink-foreground); + opacity: 1; +} + +/* Label (workspace name, centered) */ +.agents-control-label { + flex: 1; + text-align: center; + color: var(--vscode-foreground); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Right side status indicator */ +.agents-control-status { + position: absolute; + right: 8px; + display: flex; + align-items: center; + gap: 4px; + color: var(--vscode-descriptionForeground); +} + +.agents-control-pill.has-active .agents-control-status { + color: var(--vscode-textLink-foreground); +} + +.agents-control-status-icon { + display: flex; + align-items: center; +} + +.agents-control-status-text { + font-size: 11px; + font-weight: 500; +} + +.agents-control-keybinding { + font-size: 11px; + opacity: 0.7; +} + +/* Session title */ +.agents-control-title { + flex: 1; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Close button */ +.agents-control-close { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-foreground); + opacity: 0.8; + margin-left: auto; + -webkit-app-region: no-drag; +} + +.agents-control-close:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.1); +} + +.agents-control-close:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Search button (right of pill) */ +.agents-control-search { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-foreground); + opacity: 0.7; + -webkit-app-region: no-drag; +} + +.agents-control-search:hover { + opacity: 1; + background-color: var(--vscode-commandCenter-activeBackground, rgba(0, 0, 0, 0.1)); +} + +.agents-control-search:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} diff --git a/src/vs/workbench/contrib/chat/browser/attachments/media/simpleBrowserOverlay.css b/src/vs/workbench/contrib/chat/browser/attachments/media/simpleBrowserOverlay.css index 3a5e84b1fc9..9975b3a93b8 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/media/simpleBrowserOverlay.css +++ b/src/vs/workbench/contrib/chat/browser/attachments/media/simpleBrowserOverlay.css @@ -40,7 +40,6 @@ } .element-selection-main-content .monaco-button-dropdown > .monaco-button.monaco-text-button { - height: 24px; align-content: center; padding: 0px 5px; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index db6e9c2282b..6ef8420978a 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -113,6 +113,7 @@ import { ChatPasteProvidersFeature } from './widget/input/editor/chatPasteProvid import { QuickChatService } from './widgetHosts/chatQuick.js'; import { ChatResponseAccessibleView } from './accessibility/chatResponseAccessibleView.js'; import { ChatTerminalOutputAccessibleView } from './accessibility/chatTerminalOutputAccessibleView.js'; +import { ChatThinkingAccessibleView } from './accessibility/chatThinkingAccessibleView.js'; import { ChatSetupContribution, ChatTeardownContribution } from './chatSetup/chatSetupContributions.js'; import { ChatStatusBarEntry } from './chatStatus/chatStatusEntry.js'; import { ChatVariablesService } from './attachments/chatVariables.js'; @@ -134,6 +135,7 @@ import { ChatViewsWelcomeHandler } from './viewsWelcome/chatViewsWelcomeHandler. import { ChatWidgetService } from './widget/chatWidgetService.js'; import { ILanguageModelsConfigurationService } from '../common/languageModelsConfiguration.js'; import { ChatWindowNotifier } from './chatWindowNotifier.js'; +import { ChatRepoInfoContribution } from './chatRepoInfo.js'; const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; @@ -188,6 +190,12 @@ configurationRegistry.registerConfiguration({ markdownDescription: nls.localize('chat.commandCenter.enabled', "Controls whether the command center shows a menu for actions to control chat (requires {0}).", '`#window.commandCenter#`'), default: true }, + [ChatConfiguration.AgentSessionProjectionEnabled]: { + type: 'boolean', + markdownDescription: nls.localize('chat.agentSessionProjection.enabled', "Controls whether Agent Session Projection mode is enabled for reviewing agent sessions in a focused workspace."), + default: false, + tags: ['experimental'] + }, 'chat.implicitContext.enabled': { type: 'object', description: nls.localize('chat.implicitContext.enabled.1', "Enables automatically using the active editor as chat context for specified chat locations."), @@ -365,11 +373,6 @@ configurationRegistry.registerConfiguration({ enum: ['inline', 'hover', 'input', 'none'], default: 'inline', }, - [ChatConfiguration.ChatViewWelcomeEnabled]: { - type: 'boolean', - default: true, - description: nls.localize('chat.welcome.enabled', "Show welcome banner when chat is empty."), - }, [ChatConfiguration.ChatViewSessionsEnabled]: { type: 'boolean', default: true, @@ -1082,6 +1085,7 @@ class ToolReferenceNamesContribution extends Disposable implements IWorkbenchCon } AccessibleViewRegistry.register(new ChatTerminalOutputAccessibleView()); +AccessibleViewRegistry.register(new ChatThinkingAccessibleView()); AccessibleViewRegistry.register(new ChatResponseAccessibleView()); AccessibleViewRegistry.register(new PanelChatAccessibilityHelp()); AccessibleViewRegistry.register(new QuickChatAccessibilityHelp()); @@ -1202,6 +1206,7 @@ registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, registerWorkbenchContribution2(UserToolSetsContributions.ID, UserToolSetsContributions, WorkbenchPhase.Eventually); registerWorkbenchContribution2(PromptLanguageFeaturesProvider.ID, PromptLanguageFeaturesProvider, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatWindowNotifier.ID, ChatWindowNotifier, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatRepoInfoContribution.ID, ChatRepoInfoContribution, WorkbenchPhase.Eventually); registerChatActions(); registerChatAccessibilityActions(); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts index bda048be301..72074bcfcc6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCodeEditorIntegration.ts @@ -749,11 +749,18 @@ class DiffHunkWidget implements IOverlayWidget, IModifiedFileEntryChangeHunk { arg: this, }, actionViewItemProvider: (action, options) => { + const isPrimary = action.id === 'chatEditor.action.acceptHunk'; if (!action.class) { return new class extends ActionViewItem { constructor() { super(undefined, action, { ...options, keybindingNotRenderedWithLabel: true /* hide keybinding for actions without icon */, icon: false, label: true }); } + override render(container: HTMLElement): void { + super.render(container); + if (isPrimary) { + this.element?.classList.add('primary'); + } + } }; } return undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts index f7d6bef2bf1..ff4e50795c6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts @@ -239,6 +239,7 @@ class ChatEditorOverlayWidget extends Disposable { super.render(container); if (action.id === AcceptAction.ID) { + this.element?.classList.add('primary'); const listener = this._store.add(new MutableDisposable()); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css index 0177040611d..1033ada08b1 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css @@ -4,13 +4,15 @@ *--------------------------------------------------------------------------------------------*/ .chat-editor-overlay-widget { - padding: 2px; - color: var(--vscode-button-foreground); - background-color: var(--vscode-button-background); - border-radius: 2px; + padding: 2px 4px; + color: var(--vscode-foreground); + background-color: var(--vscode-editor-background); + border-radius: 6px; border: 1px solid var(--vscode-contrastBorder); display: flex; align-items: center; + justify-content: center; + gap: 4px; z-index: 10; box-shadow: 0 2px 8px var(--vscode-widget-shadow); overflow: hidden; @@ -54,25 +56,41 @@ } .chat-editor-overlay-widget .action-item > .action-label { - padding: 5px; - font-size: 12px; - border-radius: 2px; /* same as overlay widget */ + padding: 4px 6px; + font-size: 11px; + line-height: 14px; + border-radius: 4px; /* same as overlay widget */ } - -.chat-editor-overlay-widget .action-item:first-child > .action-label { - padding-left: 7px; +.chat-editor-overlay-widget .monaco-action-bar .actions-container { + gap: 4px; } -.chat-editor-overlay-widget .action-item:last-child > .action-label { - padding-right: 7px; -} - -.chat-editor-overlay-widget.busy .chat-editor-overlay-progress .codicon, -.chat-editor-overlay-widget .action-item > .action-label.codicon { +.chat-editor-overlay-widget .action-item.primary > .action-label { + background-color: var(--vscode-button-background); color: var(--vscode-button-foreground); } +.monaco-workbench .chat-editor-overlay-widget .monaco-action-bar .action-item.primary > .action-label:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.chat-editor-overlay-widget.busy .chat-editor-overlay-progress .codicon { + color: var(--vscode-foreground); +} + +.chat-editor-overlay-widget .action-item > .action-label.codicon:not(.separator) { + color: var(--vscode-foreground); + width: 22px; /* align with default icon button dimensions */ + height: 22px; + padding: 0; + font-size: 16px; + line-height: 22px; + display: flex; + align-items: center; + justify-content: center; +} + .chat-diff-change-content-widget .monaco-action-bar .action-item.disabled, .chat-editor-overlay-widget .monaco-action-bar .action-item.disabled { @@ -85,18 +103,13 @@ } } -.chat-diff-change-content-widget .action-item > .action-label { - border-radius: 2px; /* same as overlay widget */ -} - - .chat-editor-overlay-widget .action-item.label-item { font-variant-numeric: tabular-nums; } .chat-editor-overlay-widget .monaco-action-bar .action-item.label-item > .action-label, .chat-editor-overlay-widget .monaco-action-bar .action-item.label-item > .action-label:hover { - color: var(--vscode-button-foreground); + color: var(--vscode-foreground); opacity: 1; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css index 8be9fd6ba29..5e4b3de1ebc 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css @@ -8,6 +8,8 @@ transition: opacity 0.2s ease-in-out; display: flex; box-shadow: 0 2px 8px var(--vscode-widget-shadow); + border-radius: 6px; + overflow: hidden; } .chat-diff-change-content-widget.hover { @@ -15,27 +17,45 @@ } .chat-diff-change-content-widget .monaco-action-bar { - padding: 2px; - border-radius: 2px; - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); + padding: 4px 4px; + border-radius: 6px; + background-color: var(--vscode-editor-background); + color: var(--vscode-foreground); border: 1px solid var(--vscode-contrastBorder); + overflow: hidden; +} + +.chat-diff-change-content-widget .monaco-action-bar .actions-container { + gap: 4px; } .chat-diff-change-content-widget .monaco-action-bar .action-item .action-label { - border-radius: 2px; - color: var(--vscode-button-foreground); - padding: 2px 5px; + border-radius: 4px; + font-size: 11px; + line-height: 14px; + padding: 4px 6px; } -.chat-diff-change-content-widget .monaco-action-bar .action-item .action-label.codicon { - width: unset; - padding: 2px; - font-size: 16px; - line-height: 16px; +.chat-diff-change-content-widget .monaco-action-bar .action-item.primary .action-label { + background-color: var(--vscode-button-background); color: var(--vscode-button-foreground); } +.monaco-workbench .chat-diff-change-content-widget .monaco-action-bar .action-item.primary .action-label:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.chat-diff-change-content-widget .monaco-action-bar .action-item .action-label.codicon:not(.separator) { + width: 22px; /* align with default icon button dimensions */ + height: 22px; + padding: 0; + font-size: 16px; + line-height: 22px; + display: flex; + align-items: center; + justify-content: center; +} + .chat-diff-change-content-widget .monaco-action-bar .action-item .action-label.codicon[class*='codicon-'] { font-size: 16px; } diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts index a377d9a5b17..e2205448dbe 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts @@ -497,13 +497,17 @@ export class ChatModelsViewModel extends Disposable { } }); } - for (const model of group.models) { - if (vendor.vendor === 'copilot' && model.metadata.id === 'auto') { + for (const identifier of group.modelIdentifiers) { + const metadata = this.languageModelsService.lookupLanguageModel(identifier); + if (!metadata) { + continue; + } + if (vendor.vendor === 'copilot' && metadata.id === 'auto') { continue; } models.push({ - identifier: model.identifier, - metadata: model.metadata, + identifier, + metadata, provider, }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css b/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css index 2feaf2c2416..c70f5b6ba08 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css @@ -43,6 +43,7 @@ .models-widget .models-search-and-button-container .section-title-actions .models-add-model-button { white-space: nowrap; + padding: 4px 8px 4px 4px; } /** Table styling **/ diff --git a/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts new file mode 100644 index 00000000000..61636774433 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts @@ -0,0 +1,593 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { relativePath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { linesDiffComputers } from '../../../../editor/common/diff/linesDiffComputers.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; +import { ISCMService, ISCMResource } from '../../scm/common/scm.js'; +import { IChatService } from '../common/chatService/chatService.js'; +import { ChatConfiguration } from '../common/constants.js'; +import { IChatModel, IExportableRepoData, IExportableRepoDiff } from '../common/model/chatModel.js'; +import * as nls from '../../../../nls.js'; + +const MAX_CHANGES = 100; +const MAX_DIFFS_SIZE_BYTES = 900 * 1024; +const MAX_SESSIONS_WITH_FULL_DIFFS = 5; +/** + * Regex to match `url = ` lines in git config. + */ +const RemoteMatcher = /^\s*url\s*=\s*(.+\S)\s*$/mg; + +/** + * Extracts raw remote URLs from git config content. + */ +function getRawRemotes(text: string): string[] { + const remotes: string[] = []; + let match: RegExpExecArray | null; + while (match = RemoteMatcher.exec(text)) { + remotes.push(match[1]); + } + return remotes; +} + +/** + * Extracts a hostname from a git remote URL. + * + * Supports: + * - URL-like remotes: https://github.com/..., ssh://git@github.com/..., git://github.com/... + * - SCP-like remotes: git@github.com:owner/repo.git + */ +function getRemoteHost(remoteUrl: string): string | undefined { + try { + // Try standard URL parsing first (works for https://, ssh://, git://) + const url = new URL(remoteUrl); + return url.hostname.toLowerCase(); + } catch { + // Fallback for SCP-like syntax: [user@]host:path + const atIndex = remoteUrl.lastIndexOf('@'); + const hostAndPath = atIndex !== -1 ? remoteUrl.slice(atIndex + 1) : remoteUrl; + const colonIndex = hostAndPath.indexOf(':'); + if (colonIndex !== -1) { + const host = hostAndPath.slice(0, colonIndex); + return host ? host.toLowerCase() : undefined; + } + + // Fallback for hostname/path format without scheme (e.g., devdiv.visualstudio.com/...) + const slashIndex = hostAndPath.indexOf('/'); + if (slashIndex !== -1) { + const host = hostAndPath.slice(0, slashIndex); + return host ? host.toLowerCase() : undefined; + } + + return undefined; + } +} + +/** + * Determines the change type based on SCM resource properties. + */ +function determineChangeType(resource: ISCMResource, groupId: string): 'added' | 'modified' | 'deleted' | 'renamed' { + const contextValue = resource.contextValue?.toLowerCase() ?? ''; + const groupIdLower = groupId.toLowerCase(); + + if (contextValue.includes('untracked') || contextValue.includes('add')) { + return 'added'; + } + if (contextValue.includes('delete')) { + return 'deleted'; + } + if (contextValue.includes('rename')) { + return 'renamed'; + } + if (groupIdLower.includes('untracked')) { + return 'added'; + } + if (resource.decorations.strikeThrough) { + return 'deleted'; + } + if (!resource.multiDiffEditorOriginalUri) { + return 'added'; + } + return 'modified'; +} + +/** + * Generates a unified diff string compatible with `git apply`. + */ +async function generateUnifiedDiff( + fileService: IFileService, + relPath: string, + originalUri: URI | undefined, + modifiedUri: URI, + changeType: 'added' | 'modified' | 'deleted' | 'renamed' +): Promise { + try { + let originalContent = ''; + let modifiedContent = ''; + + if (originalUri && changeType !== 'added') { + try { + const originalFile = await fileService.readFile(originalUri); + originalContent = originalFile.value.toString(); + } catch { + if (changeType === 'modified') { + return undefined; + } + } + } + + if (changeType !== 'deleted') { + try { + const modifiedFile = await fileService.readFile(modifiedUri); + modifiedContent = modifiedFile.value.toString(); + } catch { + return undefined; + } + } + + const originalLines = originalContent.split('\n'); + const modifiedLines = modifiedContent.split('\n'); + const diffLines: string[] = []; + const aPath = changeType === 'added' ? '/dev/null' : `a/${relPath}`; + const bPath = changeType === 'deleted' ? '/dev/null' : `b/${relPath}`; + + diffLines.push(`--- ${aPath}`); + diffLines.push(`+++ ${bPath}`); + + if (changeType === 'added') { + if (modifiedLines.length > 0) { + diffLines.push(`@@ -0,0 +1,${modifiedLines.length} @@`); + for (const line of modifiedLines) { + diffLines.push(`+${line}`); + } + } + } else if (changeType === 'deleted') { + if (originalLines.length > 0) { + diffLines.push(`@@ -1,${originalLines.length} +0,0 @@`); + for (const line of originalLines) { + diffLines.push(`-${line}`); + } + } + } else { + const hunks = computeDiffHunks(originalLines, modifiedLines); + for (const hunk of hunks) { + diffLines.push(hunk); + } + } + + return diffLines.join('\n'); + } catch { + return undefined; + } +} + +/** + * Computes unified diff hunks using VS Code's diff algorithm. + * Merges adjacent/overlapping hunks to produce a valid patch. + */ +function computeDiffHunks(originalLines: string[], modifiedLines: string[]): string[] { + const contextSize = 3; + const result: string[] = []; + + const diffComputer = linesDiffComputers.getDefault(); + const diffResult = diffComputer.computeDiff(originalLines, modifiedLines, { + ignoreTrimWhitespace: false, + maxComputationTimeMs: 1000, + computeMoves: false + }); + + if (diffResult.changes.length === 0) { + return result; + } + + // Group changes that should be merged into the same hunk + // Changes are merged if their context regions would overlap + type Change = typeof diffResult.changes[number]; + const hunkGroups: Change[][] = []; + let currentGroup: Change[] = []; + + for (const change of diffResult.changes) { + if (currentGroup.length === 0) { + currentGroup.push(change); + } else { + const lastChange = currentGroup[currentGroup.length - 1]; + const lastContextEnd = lastChange.original.endLineNumberExclusive - 1 + contextSize; + const currentContextStart = change.original.startLineNumber - contextSize; + + // Merge if context regions overlap or are adjacent + if (currentContextStart <= lastContextEnd + 1) { + currentGroup.push(change); + } else { + hunkGroups.push(currentGroup); + currentGroup = [change]; + } + } + } + if (currentGroup.length > 0) { + hunkGroups.push(currentGroup); + } + + // Generate a single hunk for each group + for (const group of hunkGroups) { + const firstChange = group[0]; + const lastChange = group[group.length - 1]; + + const hunkOrigStart = Math.max(1, firstChange.original.startLineNumber - contextSize); + const hunkOrigEnd = Math.min(originalLines.length, lastChange.original.endLineNumberExclusive - 1 + contextSize); + const hunkModStart = Math.max(1, firstChange.modified.startLineNumber - contextSize); + + const hunkLines: string[] = []; + let origLineNum = hunkOrigStart; + let origCount = 0; + let modCount = 0; + + // Process each change in the group, emitting context lines between them + for (const change of group) { + const origStart = change.original.startLineNumber; + const origEnd = change.original.endLineNumberExclusive; + const modStart = change.modified.startLineNumber; + const modEnd = change.modified.endLineNumberExclusive; + + // Emit context lines before this change + while (origLineNum < origStart) { + hunkLines.push(` ${originalLines[origLineNum - 1]}`); + origLineNum++; + origCount++; + modCount++; + } + + // Emit deleted lines + for (let i = origStart; i < origEnd; i++) { + hunkLines.push(`-${originalLines[i - 1]}`); + origLineNum++; + origCount++; + } + + // Emit added lines + for (let i = modStart; i < modEnd; i++) { + hunkLines.push(`+${modifiedLines[i - 1]}`); + modCount++; + } + } + + // Emit trailing context lines + while (origLineNum <= hunkOrigEnd) { + hunkLines.push(` ${originalLines[origLineNum - 1]}`); + origLineNum++; + origCount++; + modCount++; + } + + result.push(`@@ -${hunkOrigStart},${origCount} +${hunkModStart},${modCount} @@`); + result.push(...hunkLines); + } + + return result; +} + +/** + * Captures repository state from the first available SCM repository. + */ +export async function captureRepoInfo(scmService: ISCMService, fileService: IFileService): Promise { + const repositories = [...scmService.repositories]; + if (repositories.length === 0) { + return undefined; + } + + const repository = repositories[0]; + const rootUri = repository.provider.rootUri; + if (!rootUri) { + return undefined; + } + + let hasGit = false; + try { + const gitDirUri = rootUri.with({ path: `${rootUri.path}/.git` }); + hasGit = await fileService.exists(gitDirUri); + } catch { + // ignore + } + + if (!hasGit) { + return { + workspaceType: 'plain-folder', + syncStatus: 'no-git', + diffs: undefined + }; + } + + let remoteUrl: string | undefined; + try { + // TODO: Handle git worktrees where .git is a file pointing to the actual git directory + const gitConfigUri = rootUri.with({ path: `${rootUri.path}/.git/config` }); + const exists = await fileService.exists(gitConfigUri); + if (exists) { + const content = await fileService.readFile(gitConfigUri); + const remotes = getRawRemotes(content.value.toString()); + remoteUrl = remotes[0]; + } + } catch { + // ignore + } + + let localBranch: string | undefined; + let localHeadCommit: string | undefined; + let remoteTrackingBranch: string | undefined; + let remoteHeadCommit: string | undefined; + let remoteBaseBranch: string | undefined; + + const historyProvider = repository.provider.historyProvider?.get(); + if (historyProvider) { + const historyItemRef = historyProvider.historyItemRef.get(); + localBranch = historyItemRef?.name; + localHeadCommit = historyItemRef?.revision; + + const historyItemRemoteRef = historyProvider.historyItemRemoteRef.get(); + if (historyItemRemoteRef) { + remoteTrackingBranch = historyItemRemoteRef.name; + remoteHeadCommit = historyItemRemoteRef.revision; + } + + const historyItemBaseRef = historyProvider.historyItemBaseRef.get(); + if (historyItemBaseRef) { + remoteBaseBranch = historyItemBaseRef.name; + } + } + + let workspaceType: IExportableRepoData['workspaceType']; + let syncStatus: IExportableRepoData['syncStatus']; + + if (!remoteUrl) { + workspaceType = 'local-git'; + syncStatus = 'local-only'; + } else { + workspaceType = 'remote-git'; + + if (!remoteTrackingBranch) { + syncStatus = 'unpublished'; + } else if (localHeadCommit === remoteHeadCommit) { + syncStatus = 'synced'; + } else { + syncStatus = 'unpushed'; + } + } + + let remoteVendor: IExportableRepoData['remoteVendor']; + if (remoteUrl) { + const host = getRemoteHost(remoteUrl); + if (host === 'github.com') { + remoteVendor = 'github'; + } else if (host === 'dev.azure.com' || (host && host.endsWith('.visualstudio.com'))) { + remoteVendor = 'ado'; + } else { + remoteVendor = 'other'; + } + } + + let totalChangeCount = 0; + for (const group of repository.provider.groups) { + totalChangeCount += group.resources.length; + } + + const baseRepoData: Omit = { + workspaceType, + syncStatus, + remoteUrl, + remoteVendor, + localBranch, + remoteTrackingBranch, + remoteBaseBranch, + localHeadCommit, + remoteHeadCommit, + }; + + if (totalChangeCount === 0) { + return { + ...baseRepoData, + diffs: undefined, + diffsStatus: 'noChanges', + changedFileCount: 0 + }; + } + + if (totalChangeCount > MAX_CHANGES) { + return { + ...baseRepoData, + diffs: undefined, + diffsStatus: 'tooManyChanges', + changedFileCount: totalChangeCount + }; + } + + const diffs: IExportableRepoDiff[] = []; + const diffPromises: Promise[] = []; + + for (const group of repository.provider.groups) { + for (const resource of group.resources) { + const relPath = relativePath(rootUri, resource.sourceUri) ?? resource.sourceUri.path; + const changeType = determineChangeType(resource, group.id); + + const diffPromise = (async (): Promise => { + const unifiedDiff = await generateUnifiedDiff( + fileService, + relPath, + resource.multiDiffEditorOriginalUri, + resource.sourceUri, + changeType + ); + + return { + relativePath: relPath, + changeType, + status: group.label || group.id, + unifiedDiff + }; + })(); + + diffPromises.push(diffPromise); + } + } + + const generatedDiffs = await Promise.all(diffPromises); + for (const diff of generatedDiffs) { + if (diff) { + diffs.push(diff); + } + } + + const diffsJson = JSON.stringify(diffs); + const diffsSizeBytes = new TextEncoder().encode(diffsJson).length; + + if (diffsSizeBytes > MAX_DIFFS_SIZE_BYTES) { + return { + ...baseRepoData, + diffs: undefined, + diffsStatus: 'tooLarge', + changedFileCount: totalChangeCount + }; + } + + return { + ...baseRepoData, + diffs, + diffsStatus: 'included', + changedFileCount: totalChangeCount + }; +} + +/** + * Captures repository information for chat sessions on creation and first message. + */ +export class ChatRepoInfoContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatRepoInfo'; + + private _configurationRegistered = false; + + constructor( + @IChatService private readonly chatService: IChatService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @ISCMService private readonly scmService: ISCMService, + @IFileService private readonly fileService: IFileService, + @ILogService private readonly logService: ILogService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(); + this.registerConfigurationIfInternal(); + this._register(this.chatEntitlementService.onDidChangeEntitlement(() => { + this.registerConfigurationIfInternal(); + })); + + this._register(this.chatService.onDidSubmitRequest(async ({ chatSessionResource }) => { + const model = this.chatService.getSession(chatSessionResource); + if (!model) { + return; + } + await this.captureAndSetRepoData(model); + })); + } + + private registerConfigurationIfInternal(): void { + if (this._configurationRegistered) { + return; + } + + if (!this.chatEntitlementService.isInternal) { + return; + } + + const registry = Registry.as(ConfigurationExtensions.Configuration); + registry.registerConfiguration({ + id: 'chatRepoInfo', + title: nls.localize('chatRepoInfoConfigurationTitle', "Chat Repository Info"), + type: 'object', + properties: { + [ChatConfiguration.RepoInfoEnabled]: { + type: 'boolean', + description: nls.localize('chat.repoInfo.enabled', "Controls whether repository information (branch, commit, working tree diffs) is captured at the start of chat sessions for internal diagnostics."), + default: true, + } + } + }); + + this._configurationRegistered = true; + this.logService.debug('[ChatRepoInfo] Configuration registered for internal user'); + } + + private async captureAndSetRepoData(model: IChatModel): Promise { + if (!this.chatEntitlementService.isInternal) { + return; + } + + // Check if repo info capture is enabled via configuration + if (!this.configurationService.getValue(ChatConfiguration.RepoInfoEnabled)) { + return; + } + + if (model.repoData) { + return; + } + + try { + const repoData = await captureRepoInfo(this.scmService, this.fileService); + if (repoData) { + model.setRepoData(repoData); + if (!repoData.localHeadCommit && repoData.workspaceType !== 'plain-folder') { + this.logService.warn('[ChatRepoInfo] Captured repo data without commit hash - git history may not be ready'); + } + + // Trim diffs from older sessions to manage storage + this.trimOldSessionDiffs(); + } else { + this.logService.debug('[ChatRepoInfo] No SCM repository available for chat session'); + } + } catch (error) { + this.logService.warn('[ChatRepoInfo] Failed to capture repo info:', error); + } + } + + /** + * Trims diffs from older sessions, keeping full diffs only for the most recent sessions. + */ + private trimOldSessionDiffs(): void { + try { + // Get all sessions with repoData that has diffs + const sessionsWithDiffs: { model: IChatModel; timestamp: number }[] = []; + + for (const model of this.chatService.chatModels.get()) { + if (model.repoData?.diffs && model.repoData.diffs.length > 0 && model.repoData.diffsStatus === 'included') { + sessionsWithDiffs.push({ model, timestamp: model.timestamp }); + } + } + + // Sort by timestamp descending (most recent first) + sessionsWithDiffs.sort((a, b) => b.timestamp - a.timestamp); + + // Trim diffs from sessions beyond the limit + for (let i = MAX_SESSIONS_WITH_FULL_DIFFS; i < sessionsWithDiffs.length; i++) { + const { model } = sessionsWithDiffs[i]; + if (model.repoData) { + const trimmedRepoData: IExportableRepoData = { + ...model.repoData, + diffs: undefined, + diffsStatus: 'trimmedForStorage' + }; + model.setRepoData(trimmedRepoData); + this.logService.trace(`[ChatRepoInfo] Trimmed diffs from older session: ${model.sessionResource.toString()}`); + } + } + } catch (error) { + this.logService.warn('[ChatRepoInfo] Failed to trim old session diffs:', error); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index f6861a21ab5..a9b3f78bc37 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -37,14 +37,18 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService, IChatToolInvocation } from '../../common/chatService/chatService.js'; -import { autorun, autorunIterableDelta, observableSignalFromEvent } from '../../../../../base/common/observable.js'; +import { autorun, autorunIterableDelta, observableFromEvent, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../chat.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; -import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; +import { AgentSessionProviders, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; +import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { assertNever } from '../../../../../base/common/assert.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', @@ -313,6 +317,21 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._evaluateAvailability(); })); + const builtinSessionProviders = [AgentSessionProviders.Local]; + const contributedSessionProviders = observableFromEvent( + this.onDidChangeAvailability, + () => Array.from(this._contributions.keys()).filter(isAgentSessionProviderType) as AgentSessionProviders[], + ).recomputeInitiallyAndOnChange(this._store); + + this._register(autorun(reader => { + const activatedProviders = [...builtinSessionProviders, ...contributedSessionProviders.read(reader)]; + for (const provider of Object.values(AgentSessionProviders)) { + if (activatedProviders.includes(provider)) { + reader.store.add(registerNewSessionInPlaceAction(provider, getAgentSessionProviderName(provider))); + } + } + })); + this._register(this.onDidChangeSessionItems(chatSessionType => { this.updateInProgressStatus(chatSessionType).catch(error => { this._logService.warn(`Failed to update progress status for '${chatSessionType}':`, error); @@ -510,6 +529,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } private _registerCommands(contribution: IChatSessionsExtensionPoint): IDisposable { + const isAvailableInSessionTypePicker = isAgentSessionProviderType(contribution.type); + return combinedDisposable( registerAction2(class OpenChatSessionAction extends Action2 { constructor() { @@ -549,30 +570,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { - const editorService = accessor.get(IEditorService); - const logService = accessor.get(ILogService); - const chatService = accessor.get(IChatService); - const { type } = contribution; - - try { - const options: IChatEditorOptions = { - override: ChatEditorInput.EditorID, - pinned: true, - title: { - fallback: localize('chatEditorContributionName', "{0}", contribution.displayName), - } - }; - const resource = URI.from({ - scheme: type, - path: `/untitled-${generateUuid()}`, - }); - await editorService.openEditor({ resource, options }); - if (chatOptions?.prompt) { - await chatService.sendRequest(resource, chatOptions.prompt, { agentIdSilent: type, attachedContext: chatOptions.attachedContext }); - } - } catch (e) { - logService.error(`Failed to open new '${type}' chat session editor`, e); - } + const { type, displayName } = contribution; + await openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Editor }, chatOptions); } }), // New chat in sidebar chat (+ button) @@ -585,34 +584,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ icon: Codicon.plus, f1: false, // Hide from Command Palette precondition: ChatContextKeys.enabled, - menu: { + menu: !isAvailableInSessionTypePicker ? { id: MenuId.ChatNewMenu, group: '3_new_special', - } + } : undefined, }); } async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { - const viewsService = accessor.get(IViewsService); - const logService = accessor.get(ILogService); - const chatService = accessor.get(IChatService); - const { type } = contribution; - - try { - const resource = URI.from({ - scheme: type, - path: `/untitled-${generateUuid()}`, - }); - - const view = await viewsService.openView(ChatViewId) as ChatViewPane; - await view.loadSession(resource); - if (chatOptions?.prompt) { - await chatService.sendRequest(resource, chatOptions.prompt, { agentIdSilent: type, attachedContext: chatOptions.attachedContext }); - } - view.focus(); - } catch (e) { - logService.error(`Failed to open new '${type}' chat session in sidebar`, e); - } + const { type, displayName } = contribution; + await openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Sidebar }, chatOptions); } }) ); @@ -935,7 +916,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const state = toolInvocation.state.get(); description = toolInvocation.generatedTitle || toolInvocation.pastTenseMessage || toolInvocation.invocationMessage; if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { - const confirmationTitle = toolInvocation.confirmationMessages?.title; + const confirmationTitle = state.confirmationMessages?.title; const titleMessage = confirmationTitle && (typeof confirmationTitle === 'string' ? confirmationTitle : confirmationTitle.value); @@ -951,7 +932,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } } - return renderAsPlaintext(description, { useLinkFormatter: true }); + return description ? renderAsPlaintext(description, { useLinkFormatter: true }) : ''; } public async getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise { @@ -1096,3 +1077,130 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } registerSingleton(IChatSessionsService, ChatSessionsService, InstantiationType.Delayed); + +function registerNewSessionInPlaceAction(type: string, displayName: string): IDisposable { + return registerAction2(class NewChatSessionInPlaceAction extends Action2 { + constructor() { + super({ + id: `workbench.action.chat.openNewChatSessionInPlace.${type}`, + title: localize2('interactiveSession.openNewChatSessionInPlace', "New {0}", displayName), + category: CHAT_CATEGORY, + f1: false, + precondition: ChatContextKeys.enabled, + }); + } + + // Expected args: [chatSessionPosition: 'sidebar' | 'editor'] + async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + if (args.length === 0) { + throw new BugIndicatingError('Expected chat session position argument'); + } + + const chatSessionPosition = args[0]; + if (chatSessionPosition !== ChatSessionPosition.Sidebar && chatSessionPosition !== ChatSessionPosition.Editor) { + throw new BugIndicatingError(`Invalid chat session position argument: ${chatSessionPosition}`); + } + + await openChatSession(accessor, { type: type, displayName: localize('chat', "Chat"), position: chatSessionPosition, replaceEditor: true }); + } + }); +} + +enum ChatSessionPosition { + Editor = 'editor', + Sidebar = 'sidebar' +} + +type NewChatSessionSendOptions = { + readonly prompt: string; + readonly attachedContext?: IChatRequestVariableEntry[]; +}; + +type NewChatSessionOpenOptions = { + readonly type: string; + readonly position: ChatSessionPosition; + readonly displayName: string; + readonly chatResource?: UriComponents; + readonly replaceEditor?: boolean; +}; + +async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatSessionOpenOptions, chatSendOptions?: NewChatSessionSendOptions): Promise { + const viewsService = accessor.get(IViewsService); + const chatService = accessor.get(IChatService); + const logService = accessor.get(ILogService); + const editorGroupService = accessor.get(IEditorGroupsService); + const editorService = accessor.get(IEditorService); + + // Determine resource to open + const resource = getResourceForNewChatSession(openOptions); + + // Open chat session + try { + switch (openOptions.position) { + case ChatSessionPosition.Sidebar: { + const view = await viewsService.openView(ChatViewId) as ChatViewPane; + await view.loadSession(resource); + view.focus(); + break; + } + case ChatSessionPosition.Editor: { + const options: IChatEditorOptions = { + override: ChatEditorInput.EditorID, + pinned: true, + title: { + fallback: localize('chatEditorContributionName', "{0}", openOptions.displayName), + } + }; + if (openOptions.replaceEditor) { + // TODO: Do not rely on active editor + const activeEditor = editorGroupService.activeGroup.activeEditor; + if (!activeEditor || !(activeEditor instanceof ChatEditorInput)) { + throw new Error('No active chat editor to replace'); + } + await editorService.replaceEditors([{ editor: activeEditor, replacement: { resource, options } }], editorGroupService.activeGroup); + } else { + await editorService.openEditor({ resource, options }); + } + break; + } + default: assertNever(openOptions.position, `Unknown chat session position: ${openOptions.position}`); + } + } catch (e) { + logService.error(`Failed to open '${openOptions.type}' chat session with openOptions: ${JSON.stringify(openOptions)}`, e); + return; + } + + // Send initial prompt if provided + if (chatSendOptions) { + try { + await chatService.sendRequest(resource, chatSendOptions.prompt, { agentIdSilent: openOptions.type, attachedContext: chatSendOptions.attachedContext }); + } catch (e) { + logService.error(`Failed to send initial request to '${openOptions.type}' chat session with contextOptions: ${JSON.stringify(chatSendOptions)}`, e); + } + } +} + +function getResourceForNewChatSession(options: NewChatSessionOpenOptions): URI { + if (options.chatResource) { + return URI.revive(options.chatResource); + } + + const isRemoteSession = options.type !== AgentSessionProviders.Local; + if (isRemoteSession) { + return URI.from({ + scheme: options.type, + path: `/untitled-${generateUuid()}`, + }); + } + + const isEditorPosition = options.position === ChatSessionPosition.Editor; + if (isEditorPosition) { + return ChatEditorInput.getNewEditorUri(); + } + + return LocalChatSessionUri.forSession(generateUuid()); +} + +function isAgentSessionProviderType(type: string): boolean { + return Object.values(AgentSessionProviders).includes(type as AgentSessionProviders); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts similarity index 82% rename from src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts rename to src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts index b6272b20716..8a7d7742419 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts @@ -5,7 +5,8 @@ import './media/chatSessionPickerActionItem.css'; import { IAction } from '../../../../../base/common/actions.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { Delayer } from '../../../../../base/common/async.js'; import * as dom from '../../../../../base/browser/dom.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction, IActionWidgetDropdownOptions } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; @@ -13,7 +14,7 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { ActionWidgetDropdownActionViewItem } from '../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../common/chatSessionsService.js'; -import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { renderLabelWithIcons, renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../../nls.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -170,26 +171,56 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction */ private async showSearchableQuickPick(optionGroup: IChatSessionProviderOptionGroup): Promise { if (optionGroup.onSearch) { + const disposables = new DisposableStore(); const quickPick = this.quickInputService.createQuickPick(); + disposables.add(quickPick); quickPick.placeholder = optionGroup.description ?? localize('selectOption.placeholder', "Select {0}", optionGroup.name); quickPick.matchOnDescription = true; quickPick.matchOnDetail = true; - quickPick.busy = !!optionGroup.onSearch; + quickPick.ignoreFocusOut = true; + quickPick.busy = true; quickPick.show(); - let items: IChatSessionProviderOptionItem[] = []; - try { - items = await optionGroup.onSearch(CancellationToken.None); - } catch (error) { - this.logService.error('Error fetching searchable option items:', error); - } finally { - quickPick.items = items.map(item => this.createQuickPickItem(item)); - quickPick.busy = false; - } + + // Debounced search state + let currentSearchCts: CancellationTokenSource | undefined; + const searchDelayer = disposables.add(new Delayer(300)); + + const performSearch = async (query: string) => { + // Cancel previous search + currentSearchCts?.cancel(); + currentSearchCts?.dispose(); + currentSearchCts = new CancellationTokenSource(); + const token = currentSearchCts.token; + + quickPick.busy = true; + try { + const items = await optionGroup.onSearch!(query, token); + if (!token.isCancellationRequested) { + quickPick.items = items.map(item => this.createQuickPickItem(item)); + } + } catch (error) { + if (!token.isCancellationRequested) { + this.logService.error('Error fetching searchable option items:', error); + } + } finally { + if (!token.isCancellationRequested) { + quickPick.busy = false; + } + } + }; + + // Initial search with empty query + await performSearch(''); + + // Listen for value changes and perform debounced search + disposables.add(quickPick.onDidChangeValue(value => { + searchDelayer.trigger(() => performSearch(value)); + })); // Handle selection return new Promise((resolve) => { - quickPick.onDidAccept(() => { + disposables.add(quickPick.onDidAccept(() => { const pick = quickPick.selectedItems[0]; if (isSearchableOptionQuickPickItem(pick)) { const selectedItem = pick.optionItem; @@ -198,12 +229,14 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction } } quickPick.hide(); - }); + })); - quickPick.onDidHide(() => { - quickPick.dispose(); + disposables.add(quickPick.onDidHide(() => { + currentSearchCts?.cancel(); + currentSearchCts?.dispose(); + disposables.dispose(); resolve(); - }); + })); }); } } diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index e02e787523a..3372317363c 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -41,7 +41,7 @@ import { ConfirmedReason, IChatService, IChatToolInvocation, ToolConfirmKind } f import { ChatConfiguration } from '../../common/constants.js'; import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; -import { CountTokensCallback, createToolSchemaUri, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, ToolSet, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; +import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, ToolSet, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; const jsonSchemaRegistry = Registry.as(JSONContributionRegistry.Extensions.JSONContribution); @@ -94,6 +94,9 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo private readonly _callsByRequestId = new Map(); + /** Pending tool calls in the streaming phase, keyed by toolCallId */ + private readonly _pendingToolCalls = new Map(); + private readonly _isAgentModeEnabled: IObservable; constructor( @@ -196,6 +199,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo super.dispose(); this._callsByRequestId.forEach(calls => calls.forEach(call => call.store.dispose())); + this._pendingToolCalls.clear(); this._ctxToolsCount.reset(); } @@ -337,8 +341,22 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } - // Shortcut to write to the model directly here, but could call all the way back to use the real stream. + // Check if there's an existing pending tool call from streaming phase + // Try both the callId and the chatStreamToolCallId (if provided) as lookup keys + let pendingToolCallKey: string | undefined; let toolInvocation: ChatToolInvocation | undefined; + if (this._pendingToolCalls.has(dto.callId)) { + pendingToolCallKey = dto.callId; + toolInvocation = this._pendingToolCalls.get(dto.callId); + } else if (dto.chatStreamToolCallId && this._pendingToolCalls.has(dto.chatStreamToolCallId)) { + pendingToolCallKey = dto.chatStreamToolCallId; + toolInvocation = this._pendingToolCalls.get(dto.chatStreamToolCallId); + } + const hadPendingInvocation = !!toolInvocation; + if (hadPendingInvocation && pendingToolCallKey) { + // Remove from pending since we're now invoking it + this._pendingToolCalls.delete(pendingToolCallKey); + } let requestId: string | undefined; let store: DisposableStore | undefined; @@ -383,15 +401,21 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo preparedInvocation = await this.prepareToolInvocation(tool, dto, token); prepareTimeWatch.stop(); - toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.fromSubAgent, dto.parameters); + if (hadPendingInvocation && toolInvocation) { + // Transition from streaming to executing/waiting state + toolInvocation.transitionFromStreaming(preparedInvocation, dto.parameters); + } else { + // Create a new tool invocation (no streaming phase) + toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.fromSubAgent, dto.parameters); + this._chatService.appendProgress(request, toolInvocation); + } + trackedCall.invocation = toolInvocation; const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters); if (autoConfirmed) { IChatToolInvocation.confirmWith(toolInvocation, autoConfirmed); } - this._chatService.appendProgress(request, toolInvocation); - dto.toolSpecificData = toolInvocation?.toolSpecificData; if (preparedInvocation?.confirmationMessages?.title) { if (!IChatToolInvocation.executionConfirmedOrDenied(toolInvocation) && !autoConfirmed) { @@ -569,6 +593,81 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return prepared; } + beginToolCall(options: IBeginToolCallOptions): IChatToolInvocation | undefined { + // First try to look up by tool ID (the package.json "name" field), + // then fall back to looking up by toolReferenceName + const toolEntry = this._tools.get(options.toolId); + if (!toolEntry) { + return undefined; + } + + // Create the invocation in streaming state + const invocation = ChatToolInvocation.createStreaming({ + toolCallId: options.toolCallId, + toolId: options.toolId, + toolData: toolEntry.data, + fromSubAgent: options.fromSubAgent, + chatRequestId: options.chatRequestId, + }); + + // Track the pending tool call + this._pendingToolCalls.set(options.toolCallId, invocation); + + // If we have a session, append the invocation to the chat as progress + if (options.sessionResource) { + const model = this._chatService.getSession(options.sessionResource); + if (model) { + // Find the request by chatRequestId if available, otherwise use the last request + const request = options.chatRequestId + ? model.getRequests().find(r => r.id === options.chatRequestId) + : model.getRequests().at(-1); + if (request) { + this._chatService.appendProgress(request, invocation); + } + } + } + + // Call handleToolStream to get initial streaming message + this._callHandleToolStream(toolEntry, invocation, options.toolCallId, undefined, CancellationToken.None); + + return invocation; + } + + private async _callHandleToolStream(toolEntry: IToolEntry, invocation: ChatToolInvocation, toolCallId: string, rawInput: unknown, token: CancellationToken): Promise { + if (!toolEntry.impl?.handleToolStream) { + return; + } + try { + const result = await toolEntry.impl.handleToolStream({ + toolCallId, + rawInput, + chatRequestId: invocation.chatRequestId, + }, token); + + if (result?.invocationMessage) { + invocation.updateStreamingMessage(result.invocationMessage); + } + } catch (error) { + this._logService.error(`[LanguageModelToolsService#_callHandleToolStream] Error calling handleToolStream for tool ${toolEntry.data.id}:`, error); + } + } + + async updateToolStream(toolCallId: string, partialInput: unknown, token: CancellationToken): Promise { + const invocation = this._pendingToolCalls.get(toolCallId); + if (!invocation) { + return; + } + + // Update the partial input on the invocation + invocation.updatePartialInput(partialInput); + + // Call handleToolStream if the tool implements it + const toolEntry = this._tools.get(invocation.toolId); + if (toolEntry) { + await this._callHandleToolStream(toolEntry, invocation, toolCallId, partialInput, token); + } + } + private playAccessibilitySignal(toolInvocations: ChatToolInvocation[]): void { const autoApproved = this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove); if (autoApproved) { @@ -760,6 +859,13 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo calls.forEach(call => call.store.dispose()); this._callsByRequestId.delete(requestId); } + + // Clean up any pending tool calls that belong to this request + for (const [toolCallId, invocation] of this._pendingToolCalls) { + if (invocation.chatRequestId === requestId) { + this._pendingToolCalls.delete(toolCallId); + } + } } private static readonly githubMCPServerAliases = ['github/github-mcp-server', 'io.github.github/github-mcp-server', 'github-mcp-server']; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts index 32997095374..d0fea511292 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts @@ -162,7 +162,7 @@ abstract class BaseSimpleChatConfirmationWidget extends Disposable { // Create buttons buttons.forEach(buttonData => { - const buttonOptions: IButtonOptions = { ...defaultButtonStyles, secondary: buttonData.isSecondary, title: buttonData.tooltip, disabled: buttonData.disabled }; + const buttonOptions: IButtonOptions = { ...defaultButtonStyles, small: true, secondary: buttonData.isSecondary, title: buttonData.tooltip, disabled: buttonData.disabled }; let button: IButton; if (buttonData.moreActions) { @@ -363,7 +363,7 @@ abstract class BaseChatConfirmationWidget extends Disposable { this._buttonsDomNode.children[0].remove(); } for (const buttonData of buttons) { - const buttonOptions: IButtonOptions = { ...defaultButtonStyles, secondary: buttonData.isSecondary, title: buttonData.tooltip, disabled: buttonData.disabled }; + const buttonOptions: IButtonOptions = { ...defaultButtonStyles, small: true, secondary: buttonData.isSecondary, title: buttonData.tooltip, disabled: buttonData.disabled }; let button: IButton; if (buttonData.moreActions) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts index 182e0a5ad6d..e9436e9ad65 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -32,6 +32,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP private readonly showSpinner: boolean; private readonly isHidden: boolean; private readonly renderedMessage = this._register(new MutableDisposable()); + private currentContent: IMarkdownString; constructor( progress: IChatProgressMessage | IChatTask | IChatTaskSerialized, @@ -46,6 +47,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP @IConfigurationService private readonly configurationService: IConfigurationService ) { super(); + this.currentContent = progress.content; const followingContent = context.content.slice(context.contentIndex + 1); this.showSpinner = forceShowSpinner ?? shouldShowSpinner(followingContent, context.element); @@ -101,6 +103,12 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP // Needs rerender when spinner state changes const showSpinner = shouldShowSpinner(followingContent, element); + + // Needs rerender when content changes + if (other.kind === 'progressMessage' && other.content.value !== this.currentContent.value) { + return false; + } + return other.kind === 'progressMessage' && this.showSpinner === showSpinner; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 826547da303..d26cbe3869f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { $, clearNode, hide } from '../../../../../../base/browser/dom.js'; +import { alert } from '../../../../../../base/browser/ui/aria/aria.js'; import { IChatMarkdownContent, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; import { IChatContentPartRenderContext, IChatContentPart } from './chatContentParts.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; @@ -129,6 +130,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } this.currentThinkingValue = initialText; + // Alert screen reader users that thinking has started + alert(localize('chat.thinking.started', 'Thinking')); + if (configuredMode === ThinkingDisplayMode.Collapsed) { this.setExpanded(false); } else { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css index 33ddd1bbb32..be0ea2424f3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css @@ -12,13 +12,6 @@ position: relative; } -.chat-confirmation-widget .monaco-text-button { - padding: 0 12px; - min-height: 2em; - box-sizing: border-box; - font-size: var(--vscode-chat-font-size-body-m); -} - .chat-confirmation-widget:not(:last-child) { margin-bottom: 16px; } @@ -279,22 +272,16 @@ .chat-confirmation-widget2 .chat-confirmation-widget-buttons { display: flex; padding: 5px 9px; - font-size: var(--vscode-chat-font-size-body-m); .chat-buttons { display: flex; - column-gap: 10px; + column-gap: 4px; align-items: center; .monaco-button { overflow-wrap: break-word; - padding: 2px 5px; width: inherit; } - - .monaco-text-button { - padding: 2px 10px; - } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css index b3a80031f70..397f482c9f4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css @@ -185,6 +185,9 @@ div.chat-terminal-content-part.progress-step > div.chat-terminal-output-containe .chat-terminal-output-terminal.chat-terminal-output-terminal-no-output { display: none; } +.chat-terminal-output-terminal.chat-terminal-output-terminal-clipped { + overflow: hidden; +} .chat-terminal-output { margin: 0; white-space: pre; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts index eace30b5aa1..ce673417560 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts @@ -55,7 +55,8 @@ export class ExtensionsInstallConfirmationWidgetSubPart extends BaseChatToolInvo this._register(chatExtensionsContentPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); dom.append(this.domNode, chatExtensionsContentPart.domNode); - if (toolInvocation.state.get().type === IChatToolInvocation.StateKind.WaitingForConfirmation) { + const state = toolInvocation.state.get(); + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { const allowLabel = localize('allow', "Allow"); const allowTooltip = keybindingService.appendKeybinding(allowLabel, AcceptToolConfirmationActionId); @@ -83,8 +84,8 @@ export class ExtensionsInstallConfirmationWidgetSubPart extends BaseChatToolInvo ChatConfirmationWidget, context, { - title: toolInvocation.confirmationMessages?.title ?? localize('installExtensions', "Install Extensions"), - message: toolInvocation.confirmationMessages?.message ?? localize('installExtensionsConfirmation', "Click the Install button on the extension and then press Allow when finished."), + title: state.confirmationMessages?.title ?? localize('installExtensions', "Install Extensions"), + message: state.confirmationMessages?.message ?? localize('installExtensionsConfirmation', "Click the Install button on the extension and then press Allow when finished."), buttons, } )); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index 1c72c6d32f4..7947d601b76 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -100,13 +100,14 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS context.container.classList.add('from-sub-agent'); } - if (!toolInvocation.confirmationMessages?.title) { + const state = toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation || !state.confirmationMessages?.title) { throw new Error('Confirmation messages are missing'); } terminalData = migrateLegacyTerminalToolSpecificData(terminalData); - const { title, message, disclaimer, terminalCustomActions } = toolInvocation.confirmationMessages; + const { title, message, disclaimer, terminalCustomActions } = state.confirmationMessages; const autoApproveEnabled = this.configurationService.getValue(TerminalContribSettingId.EnableAutoApprove) === true; const autoApproveWarningAccepted = this.storageService.getBoolean(TerminalToolConfirmationStorageKeys.TerminalAutoApproveWarningAccepted, StorageScope.APPLICATION, false); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 7cbc2010212..24f8727d48b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -756,6 +756,7 @@ class ChatTerminalToolOutputSection extends Disposable { private readonly _terminalContainer: HTMLElement; private readonly _emptyElement: HTMLElement; private _lastRenderedLineCount: number | undefined; + private _lastRenderedMaxColumnWidth: number | undefined; private readonly _onDidFocusEmitter = this._register(new Emitter()); public get onDidFocus() { return this._onDidFocusEmitter.event; } @@ -949,8 +950,8 @@ class ChatTerminalToolOutputSection extends Disposable { } const mirror = this._register(this._instantiationService.createInstance(DetachedTerminalCommandMirror, liveTerminalInstance.xterm, command)); this._mirror = mirror; - this._register(mirror.onDidUpdate(lineCount => { - this._layoutOutput(lineCount); + this._register(mirror.onDidUpdate(result => { + this._layoutOutput(result.lineCount, result.maxColumnWidth); if (this._isAtBottom) { this._scrollOutputToBottom(); } @@ -968,13 +969,13 @@ class ChatTerminalToolOutputSection extends Disposable { } else { this._hideEmptyMessage(); } - this._layoutOutput(result?.lineCount ?? 0); + this._layoutOutput(result?.lineCount ?? 0, result?.maxColumnWidth); return true; } private async _renderSnapshotOutput(snapshot: NonNullable): Promise { if (this._snapshotMirror) { - this._layoutOutput(snapshot.lineCount ?? 0); + this._layoutOutput(snapshot.lineCount ?? 0, this._lastRenderedMaxColumnWidth); return; } dom.clearNode(this._terminalContainer); @@ -989,7 +990,7 @@ class ChatTerminalToolOutputSection extends Disposable { this._showEmptyMessage(localize('chat.terminalOutputEmpty', 'No output was produced by the command.')); } const lineCount = result?.lineCount ?? snapshot.lineCount ?? 0; - this._layoutOutput(lineCount); + this._layoutOutput(lineCount, result?.maxColumnWidth); } private _renderUnavailableMessage(liveTerminalInstance: ITerminalInstance | undefined): void { @@ -1045,7 +1046,7 @@ class ChatTerminalToolOutputSection extends Disposable { } } - private _layoutOutput(lineCount?: number): void { + private _layoutOutput(lineCount?: number, maxColumnWidth?: number): void { if (!this._scrollableContainer) { return; } @@ -1056,11 +1057,22 @@ class ChatTerminalToolOutputSection extends Disposable { lineCount = this._lastRenderedLineCount; } + if (maxColumnWidth !== undefined) { + this._lastRenderedMaxColumnWidth = maxColumnWidth; + } else { + maxColumnWidth = this._lastRenderedMaxColumnWidth; + } + this._scrollableContainer.scanDomNode(); if (!this.isExpanded || lineCount === undefined) { return; } + const scrollableDomNode = this._scrollableContainer.getDomNode(); + + // Calculate and apply width based on content + this._applyContentWidth(maxColumnWidth); + const rowHeight = this._computeRowHeightPx(); const padding = this._getOutputPadding(); const minHeight = rowHeight * MIN_OUTPUT_ROWS + padding; @@ -1111,6 +1123,50 @@ class ChatTerminalToolOutputSection extends Disposable { return paddingTop + paddingBottom; } + private _applyContentWidth(maxColumnWidth?: number): void { + if (!this._scrollableContainer) { + return; + } + + const window = dom.getActiveWindow(); + const font = this._terminalConfigurationService.getFont(window); + const charWidth = font.charWidth; + + if (!charWidth || !maxColumnWidth || maxColumnWidth <= 0) { + // No content width info, leave existing width unchanged + return; + } + + // Calculate the pixel width needed for the content + // Add some padding for scrollbar and visual comfort + // Account for container padding + const horizontalPadding = 24; + const contentWidth = Math.ceil(maxColumnWidth * charWidth) + horizontalPadding; + + // Get the max available width (container's parent width) + const parentWidth = this.domNode.parentElement?.clientWidth ?? 0; + + const scrollableDomNode = this._scrollableContainer.getDomNode(); + + if (parentWidth > 0 && contentWidth < parentWidth) { + // Content is smaller than available space - shrink to fit + // Apply width to both the scrollable container and the content body + // The xterm element renders at full column width, so we need to clip it + scrollableDomNode.style.width = `${contentWidth}px`; + this._outputBody.style.width = `${contentWidth}px`; + this._terminalContainer.style.width = `${contentWidth}px`; + this._terminalContainer.classList.add('chat-terminal-output-terminal-clipped'); + } else { + // Content needs full width or more (scrollbar will show) + scrollableDomNode.style.width = ''; + this._outputBody.style.width = ''; + this._terminalContainer.style.width = ''; + this._terminalContainer.classList.remove('chat-terminal-output-terminal-clipped'); + } + + this._scrollableContainer.scanDomNode(); + } + private _computeRowHeightPx(): number { const window = dom.getActiveWindow(); const font = this._terminalConfigurationService.getFont(window); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index 859421939a0..dffa3138a9b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -23,7 +23,7 @@ import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browse import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../../../../platform/markers/common/markers.js'; import { IChatToolInvocation, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; import { CodeBlockModelCollection } from '../../../../common/widget/codeBlockModelCollection.js'; -import { createToolInputUri, createToolSchemaUri, ILanguageModelToolsService } from '../../../../common/tools/languageModelToolsService.js'; +import { createToolInputUri, createToolSchemaUri, ILanguageModelToolsService, IToolConfirmationMessages } from '../../../../common/tools/languageModelToolsService.js'; import { ILanguageModelToolsConfirmationService } from '../../../../common/tools/languageModelToolsConfirmationService.js'; import { AcceptToolConfirmationActionId, SkipToolConfirmationActionId } from '../../../actions/chatToolActions.js'; import { IChatCodeBlockInfo, IChatWidgetService } from '../../../chat.js'; @@ -63,7 +63,8 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, @ILanguageModelToolsConfirmationService private readonly confirmationService: ILanguageModelToolsConfirmationService, ) { - if (!toolInvocation.confirmationMessages?.title) { + const state = toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation || !state.confirmationMessages?.title) { throw new Error('Confirmation messages are missing'); } @@ -72,7 +73,7 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { this.render({ allowActionId: AcceptToolConfirmationActionId, skipActionId: SkipToolConfirmationActionId, - allowLabel: toolInvocation.confirmationMessages.confirmResults ? localize('allowReview', "Allow and Review") : localize('allow', "Allow"), + allowLabel: state.confirmationMessages.confirmResults ? localize('allowReview', "Allow and Review") : localize('allow', "Allow"), skipLabel: localize('skip.detail', 'Proceed without running this tool'), partType: 'chatToolConfirmation', subtitle: typeof toolInvocation.originMessage === 'string' ? toolInvocation.originMessage : toolInvocation.originMessage?.value, @@ -86,12 +87,18 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { protected override additionalPrimaryActions() { const actions = super.additionalPrimaryActions(); - if (this.toolInvocation.confirmationMessages?.allowAutoConfirm !== false) { + + const state = this.toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { + return actions; + } + + if (state.confirmationMessages?.allowAutoConfirm !== false) { // Get actions from confirmation service const confirmActions = this.confirmationService.getPreConfirmActions({ toolId: this.toolInvocation.toolId, source: this.toolInvocation.source, - parameters: this.toolInvocation.parameters + parameters: state.parameters }); for (const action of confirmActions) { @@ -110,12 +117,12 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { }); } } - if (this.toolInvocation.confirmationMessages?.confirmResults) { + if (state.confirmationMessages?.confirmResults) { actions.unshift( { label: localize('allowSkip', 'Allow and Skip Reviewing Result'), data: () => { - this.toolInvocation.confirmationMessages!.confirmResults = undefined; + (state.confirmationMessages as IToolConfirmationMessages).confirmResults = undefined; this.confirmWith(this.toolInvocation, { type: ToolConfirmKind.UserAction }); } }, @@ -127,7 +134,11 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { } protected createContentElement(): HTMLElement | string { - const { message, disclaimer } = this.toolInvocation.confirmationMessages!; + const state = this.toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { + return ''; + } + const { message, disclaimer } = state.confirmationMessages!; const toolInvocation = this.toolInvocation as IChatToolInvocation; if (typeof message === 'string' && !disclaimer) { @@ -305,8 +316,15 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { } protected getTitle(): string { - const { title } = this.toolInvocation.confirmationMessages!; - return typeof title === 'string' ? title : title!.value; + const state = this.toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { + return ''; + } + const title = state.confirmationMessages?.title; + if (!title) { + return ''; + } + return typeof title === 'string' ? title : title.value; } private _makeMarkdownPart(container: HTMLElement, message: string | IMarkdownString, codeBlockRenderOptions: ICodeBlockRenderOptions) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index 191c4e1b914..553a1532a30 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -28,6 +28,7 @@ import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; import { ChatToolOutputSubPart } from './chatToolOutputPart.js'; import { ChatToolPostExecuteConfirmationPart } from './chatToolPostExecuteConfirmationPart.js'; import { ChatToolProgressSubPart } from './chatToolProgressPart.js'; +import { ChatToolStreamingSubPart } from './chatToolStreamingSubPart.js'; export class ChatToolInvocationPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; @@ -147,6 +148,12 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa return this.instantiationService.createInstance(ExtensionsInstallConfirmationWidgetSubPart, this.toolInvocation, this.context); } const state = this.toolInvocation.state.get(); + + // Handle streaming state - show streaming progress + if (state.type === IChatToolInvocation.StateKind.Streaming) { + return this.instantiationService.createInstance(ChatToolStreamingSubPart, this.toolInvocation, this.context, this.renderer); + } + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { if (this.toolInvocation.toolSpecificData?.kind === 'terminal') { return this.instantiationService.createInstance(ChatTerminalToolConfirmationSubPart, this.toolInvocation, this.toolInvocation.toolSpecificData, this.context, this.renderer, this.editorPool, this.currentWidthDelegate, this.codeBlockModelCollection, this.codeBlockStartIndex); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts index 54ba1affb72..1c5af92390c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts @@ -72,11 +72,16 @@ export class ChatToolPostExecuteConfirmationPart extends AbstractToolConfirmatio protected override additionalPrimaryActions() { const actions = super.additionalPrimaryActions(); + const state = this.toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForPostApproval) { + return actions; + } + // Get actions from confirmation service const confirmActions = this.confirmationService.getPostConfirmActions({ toolId: this.toolInvocation.toolId, source: this.toolInvocation.source, - parameters: this.toolInvocation.parameters + parameters: state.parameters }); for (const action of confirmActions) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts index 94c0c5a3602..f16c95fde19 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts @@ -36,7 +36,9 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { } private createProgressPart(): HTMLElement { - if (IChatToolInvocation.isComplete(this.toolInvocation) && this.toolIsConfirmed && this.toolInvocation.pastTenseMessage) { + const isComplete = IChatToolInvocation.isComplete(this.toolInvocation); + + if (isComplete && this.toolIsConfirmed && this.toolInvocation.pastTenseMessage) { const key = this.getAnnouncementKey('complete'); const completionContent = this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage; const shouldAnnounce = this.toolInvocation.kind === 'toolInvocation' && this.hasMeaningfulContent(completionContent) ? this.computeShouldAnnounce(key) : false; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts new file mode 100644 index 00000000000..11d0a6af793 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../../base/browser/dom.js'; +import { IMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { autorun } from '../../../../../../../base/common/observable.js'; +import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { IChatProgressMessage, IChatToolInvocation } from '../../../../common/chatService/chatService.js'; +import { IChatCodeBlockInfo } from '../../../chat.js'; +import { IChatContentPartRenderContext } from '../chatContentParts.js'; +import { ChatProgressContentPart } from '../chatProgressContentPart.js'; +import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; + +/** + * Sub-part for rendering a tool invocation in the streaming state. + * This shows progress while the tool arguments are being streamed from the LM. + */ +export class ChatToolStreamingSubPart extends BaseChatToolInvocationSubPart { + public readonly domNode: HTMLElement; + + public override readonly codeblocks: IChatCodeBlockInfo[] = []; + + constructor( + toolInvocation: IChatToolInvocation, + private readonly context: IChatContentPartRenderContext, + private readonly renderer: IMarkdownRenderer, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(toolInvocation); + + this.domNode = this.createStreamingPart(); + } + + private createStreamingPart(): HTMLElement { + const container = document.createElement('div'); + + if (this.toolInvocation.kind !== 'toolInvocation') { + return container; + } + + const toolInvocation = this.toolInvocation; + const state = toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.Streaming) { + return container; + } + + // Observe streaming message changes + this._register(autorun(reader => { + const currentState = toolInvocation.state.read(reader); + if (currentState.type !== IChatToolInvocation.StateKind.Streaming) { + // State changed - clear the container DOM before triggering re-render + // This prevents the old streaming message from lingering + dom.clearNode(container); + this._onNeedsRerender.fire(); + return; + } + + // Read the streaming message + const streamingMessage = currentState.streamingMessage.read(reader); + const displayMessage = streamingMessage ?? toolInvocation.invocationMessage; + + const content: IMarkdownString = typeof displayMessage === 'string' + ? new MarkdownString().appendText(displayMessage) + : displayMessage; + + const progressMessage: IChatProgressMessage = { + kind: 'progressMessage', + content + }; + + const part = reader.store.add(this.instantiationService.createInstance( + ChatProgressContentPart, + progressMessage, + this.renderer, + this.context, + undefined, + true, + this.getIcon(), + toolInvocation + )); + + dom.reset(container, part.domNode); + + // Notify parent that content has changed + this._onDidChangeHeight.fire(); + })); + + return container; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index a8c47ca54b0..c59b8c329a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -777,6 +777,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(part))) { + return false; + } + // Show if no content, only "used references", ends with a complete tool call, or ends with complete text edits and there is no incomplete tool call (edits are still being applied some time after they are all generated) const lastPart = findLast(partsToRender, part => part.kind !== 'markdownContent' || part.content.value.trim().length > 0); @@ -787,7 +792,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) || - lastPart.kind === 'prepareToolInvocation' || lastPart.kind === 'mcpServersStarting' + lastPart.kind === 'mcpServersStarting' ) { return true; } @@ -1291,14 +1296,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined): ChatThinkingContentPart | undefined { 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 40a6ec71bb8..a7f1232c786 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -26,6 +26,7 @@ import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposab import { ResourceSet } from '../../../../../../base/common/map.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { Schemas } from '../../../../../../base/common/network.js'; +import { mixin } from '../../../../../../base/common/objects.js'; import { autorun, derived, derivedOpts, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; import { isEqual } from '../../../../../../base/common/resources.js'; @@ -78,44 +79,45 @@ import { AccessibilityCommandId } from '../../../../accessibility/common/accessi import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../../../codeEditor/browser/simpleEditorOptions.js'; import { InlineChatConfigKeys } from '../../../../inlineChat/common/inlineChat.js'; import { IChatViewTitleActionContext } from '../../../common/actions/chatActions.js'; -import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; -import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; -import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; +import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionProviderOptionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { getChatSessionType } from '../../../common/model/chatUri.js'; -import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; -import { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; -import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, validateChatMode } from '../../../common/constants.js'; +import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; +import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; +import { getChatSessionType } from '../../../common/model/chatUri.js'; +import { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; +import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; import { ActionLocation, ChatContinueInSessionActionItem, ContinueChatInSessionAction } from '../../actions/chatContinueInAction.js'; -import { ChatOpenModelPickerActionId, ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenModePickerAction } from '../../actions/chatExecuteActions.js'; +import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenModelPickerAction, OpenModePickerAction, OpenSessionTargetPickerAction } from '../../actions/chatExecuteActions.js'; +import { getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; -import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; -import { IChatWidget } from '../../chat.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; +import { ChatImplicitContext } from '../../attachments/chatImplicitContext.js'; +import { ChatRelatedFiles } from '../../attachments/chatInputRelatedFilesContrib.js'; +import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; +import { IChatWidget, isIChatResourceViewContext } from '../../chat.js'; +import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; +import { resizeImage } from '../../chatImageUtils.js'; +import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; +import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; +import { IChatContextService } from '../../contextContrib/chatContextService.js'; import { IDisposableReference } from '../chatContentParts/chatCollections.js'; import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; import { ChatTodoListWidget } from '../chatContentParts/chatTodoListWidget.js'; -import { IChatContextService } from '../../contextContrib/chatContextService.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; -import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { ChatFollowups } from './chatFollowups.js'; import { ChatInputPartWidgetController } from './chatInputPartWidgets.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; -import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; -import { ChatImplicitContext } from '../../attachments/chatImplicitContext.js'; -import { ChatRelatedFiles } from '../../attachments/chatInputRelatedFilesContrib.js'; -import { resizeImage } from '../../chatImageUtils.js'; import { IModelPickerDelegate, ModelPickerActionItem } from './modelPickerActionItem.js'; import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; -import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItemtest.js'; -import { mixin } from '../../../../../../base/common/objects.js'; +import { ISessionTypePickerDelegate, SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; const $ = dom.$; @@ -334,8 +336,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private modelVendorKey: IContextKey; private modelFamilyKey: IContextKey; private modelVersionKey: IContextKey; + private chatSessionOptionsValid: IContextKey; private modelWidget: ModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; + private sessionTargetWidget: SessionTypePickerActionItem | undefined; private chatSessionPickerWidgets: Map = new Map(); private chatSessionPickerContainer: HTMLElement | undefined; private _lastSessionPickerAction: MenuItemAction | undefined; @@ -526,6 +530,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.modelVendorKey = ChatContextKeys.Model.vendor.bindTo(contextKeyService); this.modelFamilyKey = ChatContextKeys.Model.family.bindTo(contextKeyService); this.modelVersionKey = ChatContextKeys.Model.version.bindTo(contextKeyService); + this.chatSessionOptionsValid = ChatContextKeys.chatSessionOptionsValid.bindTo(contextKeyService); const chatToolCount = ChatContextKeys.chatToolCount.bindTo(contextKeyService); @@ -715,6 +720,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.modeWidget?.show(); } + public openSessionTargetPicker(): void { + this.sessionTargetWidget?.show(); + } + public openChatSessionPicker(): void { // Open the first available picker widget const firstWidget = this.chatSessionPickerWidgets?.values()?.next().value; @@ -1376,6 +1385,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionResource = this._widget?.viewModel?.model.sessionResource; const hideAll = () => { this.chatSessionHasOptions.set(false); + this.chatSessionOptionsValid.set(true); // No options means nothing to validate this.hideAllSessionPickerWidgets(); }; @@ -1395,8 +1405,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return hideAll(); } - this.chatSessionHasOptions.set(true); - // First update all context keys with current values (before evaluating visibility) for (const optionGroup of optionGroups) { const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); @@ -1419,6 +1427,28 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + // Only show the picker if there are visible option groups + if (visibleGroupIds.size === 0) { + return hideAll(); + } + + // Validate that all selected options exist in their respective option group items + let allOptionsValid = true; + for (const optionGroup of optionGroups) { + const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); + if (currentOption) { + const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; + const isValidOption = optionGroup.items.some(item => item.id === currentOptionId); + if (!isValidOption) { + this.logService.trace(`[ChatInputPart] Selected option '${currentOptionId}' is not valid for group '${optionGroup.id}'`); + allOptionsValid = false; + } + } + } + this.chatSessionOptionsValid.set(allOptionsValid); + + this.chatSessionHasOptions.set(true); + const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); const needsRecreation = @@ -1747,7 +1777,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge hiddenItemStrategy: HiddenItemStrategy.NoHide, hoverDelegate, actionViewItemProvider: (action, options) => { - if (action.id === ChatOpenModelPickerActionId && action instanceof MenuItemAction) { + if (action.id === OpenModelPickerAction.ID && action instanceof MenuItemAction) { if (!this._currentLanguageModel) { this.setCurrentLanguageModelToDefault(); } @@ -1769,6 +1799,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge sessionResource: () => this._widget?.viewModel?.sessionResource, }; return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate); + } else if (action.id === OpenSessionTargetPickerAction.ID && action instanceof MenuItemAction) { + const delegate: ISessionTypePickerDelegate = { + getActiveSessionProvider: () => { + const sessionResource = this._widget?.viewModel?.sessionResource; + return sessionResource ? getAgentSessionProvider(sessionResource) : undefined; + }, + }; + const chatSessionPosition = isIChatResourceViewContext(widget.viewContext) ? 'editor' : 'sidebar'; + return this.sessionTargetWidget = this.instantiationService.createInstance(SessionTypePickerActionItem, action, chatSessionPosition, delegate); } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { // Create all pickers and return a container action view item const widgets = this.createChatSessionPickerWidgets(action); @@ -2168,6 +2207,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._chatInputTodoListWidget.value?.clear(sessionResource, force); } + setWorkingSetCollapsed(collapsed: boolean): void { + this._workingSetCollapsed.set(collapsed, undefined); + } + renderChatEditingSessionState(chatEditingSession: IChatEditingSession | null) { dom.setVisibility(Boolean(chatEditingSession), this.chatEditingSessionWidgetContainer); @@ -2346,6 +2389,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const isSessionMenu = topLevelIsSessionMenu.read(reader); reader.store.add(scopedInstantiationService.createInstance(MenuWorkbenchButtonBar, actionsContainer, isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar, { telemetrySource: this.options.menus.telemetrySource, + small: true, menuOptions: { arg: sessionResource && (isSessionMenu ? sessionResource : { $mid: MarshalledId.ChatViewContext, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index b4a6c1b98eb..2059f7c902b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -154,12 +154,12 @@ export class ModePickerActionItem extends ActionWidgetDropdownActionViewItem { protected override renderLabel(element: HTMLElement): IDisposable | null { this.setAriaLabelAttributes(element); const state = this.delegate.currentMode.get().label.get(); - dom.reset(element, dom.$('span.chat-model-label', undefined, state), ...renderLabelWithIcons(`$(chevron-down)`)); + dom.reset(element, dom.$('span.chat-input-picker-label', undefined, state), ...renderLabelWithIcons(`$(chevron-down)`)); return null; } override render(container: HTMLElement): void { super.render(container); - container.classList.add('chat-modelPicker-item'); + container.classList.add('chat-input-picker-item'); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index dfb4c2aa262..4cef0ffb4ec 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -192,7 +192,7 @@ export class ModelPickerActionItem extends ActionWidgetDropdownActionViewItem { domChildren.push(iconElement); } - domChildren.push(dom.$('span.chat-model-label', undefined, name ?? localize('chat.modelPicker.auto', "Auto"))); + domChildren.push(dom.$('span.chat-input-picker-label', undefined, name ?? localize('chat.modelPicker.auto', "Auto"))); domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); dom.reset(element, ...domChildren); @@ -202,6 +202,6 @@ export class ModelPickerActionItem extends ActionWidgetDropdownActionViewItem { override render(container: HTMLElement): void { super.render(container); - container.classList.add('chat-modelPicker-item'); + container.classList.add('chat-input-picker-item'); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts new file mode 100644 index 00000000000..cd5245be9db --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IAction } from '../../../../../../base/common/actions.js'; +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { ActionWidgetDropdownActionViewItem } from '../../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; +import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; +import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider, IActionWidgetDropdownOptions } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../agentSessions/agentSessions.js'; + +export interface ISessionTypePickerDelegate { + getActiveSessionProvider(): AgentSessionProviders | undefined; +} + +interface ISessionTypeItem { + type: AgentSessionProviders; + label: string; + description: string; + commandId: string; +} + +/** + * Action view item for selecting a session target in the chat interface. + * This picker allows switching between different chat session types contributed via extensions. + */ +export class SessionTypePickerActionItem extends ActionWidgetDropdownActionViewItem { + private _sessionTypeItems: ISessionTypeItem[] = []; + + constructor( + action: MenuItemAction, + private readonly chatSessionPosition: 'sidebar' | 'editor', + private readonly delegate: ISessionTypePickerDelegate, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IKeybindingService keybindingService: IKeybindingService, + @IContextKeyService contextKeyService: IContextKeyService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @ICommandService private readonly commandService: ICommandService, + @IOpenerService openerService: IOpenerService, + ) { + const actionProvider: IActionWidgetDropdownActionProvider = { + getActions: () => { + const currentType = this.delegate.getActiveSessionProvider(); + + const actions: IActionWidgetDropdownAction[] = []; + for (const sessionTypeItem of this._sessionTypeItems) { + actions.push({ + ...action, + id: sessionTypeItem.commandId, + label: sessionTypeItem.label, + tooltip: sessionTypeItem.description, + checked: currentType === sessionTypeItem.type, + icon: getAgentSessionProviderIcon(sessionTypeItem.type), + enabled: true, + run: async () => { + this.commandService.executeCommand(sessionTypeItem.commandId, this.chatSessionPosition); + if (this.element) { + this.renderLabel(this.element); + } + }, + }); + } + + return actions; + } + }; + + const actionBarActions: IAction[] = []; + + const learnMoreUrl = 'https://code.visualstudio.com/docs/copilot/agents/overview'; + actionBarActions.push({ + id: 'workbench.action.chat.agentOverview.learnMore', + label: localize('chat.learnMore', "Learn about agent types..."), + tooltip: learnMoreUrl, + class: undefined, + enabled: true, + run: async () => { + await openerService.open(URI.parse(learnMoreUrl)); + } + }); + + const sessionTargetPickerOptions: Omit = { + actionProvider, + actionBarActions, + actionBarActionProvider: undefined, + showItemKeybindings: true, + }; + + super(action, sessionTargetPickerOptions, actionWidgetService, keybindingService, contextKeyService); + + this._updateAgentSessionItems(); + this._register(this.chatSessionsService.onDidChangeAvailability(() => { + this._updateAgentSessionItems(); + })); + } + + private _updateAgentSessionItems(): void { + const localSessionItem = { + type: AgentSessionProviders.Local, + label: getAgentSessionProviderName(AgentSessionProviders.Local), + description: localize('chat.sessionTarget.local.description', "Local chat session"), + commandId: `workbench.action.chat.openNewChatSessionInPlace.${AgentSessionProviders.Local}`, + }; + + const agentSessionItems = [localSessionItem]; + + const contributions = this.chatSessionsService.getAllChatSessionContributions(); + for (const contribution of contributions) { + const agentSessionType = getAgentSessionProvider(contribution.type); + if (!agentSessionType) { + continue; + } + + agentSessionItems.push({ + type: agentSessionType, + label: getAgentSessionProviderName(agentSessionType), + description: contribution.description, + commandId: `workbench.action.chat.openNewChatSessionInPlace.${contribution.type}`, + }); + } + this._sessionTypeItems = agentSessionItems; + } + + protected override renderLabel(element: HTMLElement): IDisposable | null { + this.setAriaLabelAttributes(element); + const currentType = this.delegate.getActiveSessionProvider(); + + const label = getAgentSessionProviderName(currentType ?? AgentSessionProviders.Local); + const icon = getAgentSessionProviderIcon(currentType ?? AgentSessionProviders.Local); + + dom.reset(element, ...renderLabelWithIcons(`$(${icon.id})`), dom.$('span.chat-input-picker-label', undefined, label), ...renderLabelWithIcons(`$(chevron-down)`)); + return null; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('chat-input-picker-item'); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 9ae784f8b37..70b964b531b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -903,10 +903,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-editing-session .monaco-button { - height: 22px; width: fit-content; - padding: 2px 6px; - font-size: 12px; } .interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button:hover { @@ -1031,9 +1028,6 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-editing-session .chat-editing-session-actions .monaco-button.secondary.monaco-text-button.codicon { - background-color: transparent; - border-color: transparent; - color: var(--vscode-icon-foreground); cursor: pointer; padding: 0 3px; border-radius: 2px; @@ -1056,7 +1050,6 @@ have to be updated for changes to the rules above, or to support more deeply nes background-color: var(--vscode-toolbar-hoverBackground); } -.interactive-session .chat-editing-session .chat-editing-session-actions .monaco-button.secondary.monaco-text-button.codicon:not(.disabled):hover, .interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button:hover { background-color: var(--vscode-toolbar-hoverBackground); } @@ -1344,13 +1337,13 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: hidden; min-width: 0px; - .chat-modelPicker-item { + .chat-input-picker-item { min-width: 0px; .action-label { min-width: 0px; - .chat-model-label { + .chat-input-picker-label { overflow: hidden; text-overflow: ellipsis; } @@ -1359,9 +1352,19 @@ have to be updated for changes to the rules above, or to support more deeply nes color: var(--vscode-problemsWarningIcon-foreground); } - span + .chat-model-label { + span + .chat-input-picker-label { margin-left: 2px; } + + .codicon { + font-size: 12px; + } + } + + .action-label.disabled { + .codicon { + color: var(--vscode-disabledForeground); + } } .codicon { @@ -1374,7 +1377,7 @@ have to be updated for changes to the rules above, or to support more deeply nes box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset } -.interactive-session .chat-input-toolbar .chat-modelPicker-item .action-label, +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label, .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label { height: 16px; padding: 3px 0px 3px 6px; @@ -1383,7 +1386,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } -.interactive-session .chat-input-toolbar .chat-modelPicker-item .action-label .codicon-chevron-down, +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label .codicon-chevron-down, .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label .codicon-chevron-down { font-size: 12px; margin-left: 2px; @@ -2429,7 +2432,6 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-button { width: fit-content; - padding: 2px 11px; } .chat-quota-error-button, @@ -2751,7 +2753,6 @@ have to be updated for changes to the rules above, or to support more deeply nes .chat-buttons-container .monaco-button:not(.monaco-dropdown-button) { text-align: left; width: initial; - padding: 4px 8px; } .interactive-item-container .chat-edit-input-container { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css b/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css index 27bf0df2e09..678b4037a90 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css @@ -106,7 +106,6 @@ div.chat-welcome-view { .monaco-button { display: inline-block; width: initial; - padding: 4px 7px; } & > .chat-welcome-view-tips { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index de27b8f6f4d..e78c2ab4a19 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -6,9 +6,10 @@ import './media/chatViewPane.css'; import { $, addDisposableListener, append, EventHelper, EventType, getWindow, setVisibility } from '../../../../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; +import { Orientation, Sash } from '../../../../../../base/browser/ui/sash/sash.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; -import { MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { MutableDisposable, toDisposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { autorun, IReader } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -57,10 +58,13 @@ import { AgentSessionsFilter } from '../../agentSessions/agentSessionsFilter.js' import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidget.js'; import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; +import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; interface IChatViewPaneState extends Partial { sessionId?: string; + sessionsViewerLimited?: boolean; + sessionsSidebarWidth?: number; } type ChatViewPaneOpenedClassification = { @@ -105,6 +109,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @ILifecycleService lifecycleService: ILifecycleService, @IProgressService private readonly progressService: IProgressService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -119,6 +124,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this.viewState.sessionId = undefined; // clear persisted session on fresh start } this.sessionsViewerLimited = this.viewState.sessionsViewerLimited ?? true; + this.sessionsViewerSidebarWidth = Math.max(ChatViewPane.SESSIONS_SIDEBAR_MIN_WIDTH, this.viewState.sessionsSidebarWidth ?? ChatViewPane.SESSIONS_SIDEBAR_DEFAULT_WIDTH); // Contextkeys this.chatViewLocationContext = ChatContextKeys.panelLocation.bindTo(contextKeyService); @@ -170,7 +176,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } private updateViewPaneClasses(fromEvent: boolean): void { - const welcomeEnabled = this.configurationService.getValue(ChatConfiguration.ChatViewWelcomeEnabled) !== false; + const welcomeEnabled = !this.chatEntitlementService.sentiment.installed; // only show initially until Chat is setup this.viewPaneContainer?.classList.toggle('chat-view-welcome-enabled', welcomeEnabled); const activityBarLocationDefault = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) === 'default'; @@ -208,8 +214,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Settings changes this._register(Event.filter(this.configurationService.onDidChangeConfiguration, e => { - return e.affectsConfiguration(ChatConfiguration.ChatViewWelcomeEnabled) || e.affectsConfiguration(LayoutSettings.ACTIVITY_BAR_LOCATION); + return e.affectsConfiguration(LayoutSettings.ACTIVITY_BAR_LOCATION); })(() => this.updateViewPaneClasses(true))); + + // Entitlement changes + this._register(this.chatEntitlementService.onDidChangeSentiment(() => { + this.updateViewPaneClasses(true); + })); } private onDidChangeAgents(): void { @@ -286,9 +297,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { //#region Sessions Control - private static readonly SESSIONS_LIMIT = 3; - private static readonly SESSIONS_SIDEBAR_WIDTH = 300; - private static readonly SESSIONS_SIDEBAR_VIEW_MIN_WIDTH = 300 /* default chat width */ + this.SESSIONS_SIDEBAR_WIDTH; + private static readonly SESSIONS_LIMIT = 5; + private static readonly SESSIONS_SIDEBAR_MIN_WIDTH = 200; + private static readonly SESSIONS_SIDEBAR_DEFAULT_WIDTH = 300; + private static readonly CHAT_WIDGET_DEFAULT_WIDTH = 300; + private static readonly SESSIONS_SIDEBAR_VIEW_MIN_WIDTH = this.CHAT_WIDGET_DEFAULT_WIDTH + this.SESSIONS_SIDEBAR_DEFAULT_WIDTH; private sessionsContainer: HTMLElement | undefined; private sessionsTitleContainer: HTMLElement | undefined; @@ -298,13 +311,16 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private sessionsLinkContainer: HTMLElement | undefined; private sessionsLink: Link | undefined; private sessionsCount = 0; - private sessionsViewerLimited = true; + private sessionsViewerLimited: boolean; private sessionsViewerOrientation = AgentSessionsViewerOrientation.Stacked; private sessionsViewerOrientationConfiguration: 'stacked' | 'sideBySide' = 'sideBySide'; private sessionsViewerOrientationContext: IContextKey; private sessionsViewerLimitedContext: IContextKey; private sessionsViewerVisibilityContext: IContextKey; private sessionsViewerPositionContext: IContextKey; + private sessionsViewerSidebarWidth: number; + private sessionsViewerSash: Sash | undefined; + private readonly sessionsViewerSashDisposables = this._register(new MutableDisposable()); private createSessionsControl(parent: HTMLElement): AgentSessionsControl { const that = this; @@ -750,6 +766,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const newModelRef = await this.chatService.loadSessionForResource(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); clearWidget.dispose(); await queue; + return this.showModel(newModelRef); }); } @@ -862,6 +879,17 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Ensure visibility is in sync before we layout const { visible: sessionsContainerVisible } = this.updateSessionsControlVisibility(); + + // Handle Sash (only visible in side-by-side) + if (!sessionsContainerVisible || this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { + this.sessionsViewerSashDisposables.clear(); + this.sessionsViewerSash = undefined; + } else if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.SideBySide) { + if (!this.sessionsViewerSashDisposables.value && this.viewPaneContainer) { + this.createSessionsViewerSash(this.viewPaneContainer, height, width); + } + } + if (!sessionsContainerVisible) { return { heightReduction: 0, widthReduction: 0 }; } @@ -873,9 +901,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Show as sidebar if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.SideBySide) { + const sessionsViewerSidebarWidth = this.computeEffectiveSideBySideSessionsSidebarWidth(width); + this.sessionsControlContainer.style.height = `${availableSessionsHeight}px`; - this.sessionsControlContainer.style.width = `${ChatViewPane.SESSIONS_SIDEBAR_WIDTH}px`; - this.sessionsControl.layout(availableSessionsHeight, ChatViewPane.SESSIONS_SIDEBAR_WIDTH); + this.sessionsControlContainer.style.width = `${sessionsViewerSidebarWidth}px`; + this.sessionsControl.layout(availableSessionsHeight, sessionsViewerSidebarWidth); + this.sessionsViewerSash?.layout(); heightReduction = 0; // side by side to chat widget widthReduction = this.sessionsContainer.offsetWidth; @@ -887,7 +918,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { if (this.sessionsViewerLimited) { sessionsHeight = this.sessionsCount * AgentSessionsListDelegate.ITEM_HEIGHT; } else { - sessionsHeight = (ChatViewPane.SESSIONS_LIMIT * 2 /* expand a bit to indicate more items */) * AgentSessionsListDelegate.ITEM_HEIGHT; + sessionsHeight = availableSessionsHeight; } sessionsHeight = Math.min(availableSessionsHeight, sessionsHeight); @@ -903,10 +934,64 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { return { heightReduction, widthReduction }; } + private computeEffectiveSideBySideSessionsSidebarWidth(width: number, sessionsViewerSidebarWidth = this.sessionsViewerSidebarWidth): number { + return Math.max( + ChatViewPane.SESSIONS_SIDEBAR_MIN_WIDTH, // never smaller than min width for side by side sessions + Math.min( + sessionsViewerSidebarWidth, + width - ChatViewPane.CHAT_WIDGET_DEFAULT_WIDTH // never so wide that chat widget is smaller than default width + ) + ); + } + getLastDimensions(orientation: AgentSessionsViewerOrientation): { height: number; width: number } | undefined { return this.lastDimensionsPerOrientation.get(orientation); } + private createSessionsViewerSash(container: HTMLElement, height: number, width: number): void { + const disposables = this.sessionsViewerSashDisposables.value = new DisposableStore(); + + const sash = this.sessionsViewerSash = disposables.add(new Sash(container, { + getVerticalSashLeft: () => { + const sessionsViewerSidebarWidth = this.computeEffectiveSideBySideSessionsSidebarWidth(this.lastDimensions?.width ?? width); + const { position } = this.getViewPositionAndLocation(); + if (position === Position.RIGHT) { + return (this.lastDimensions?.width ?? width) - sessionsViewerSidebarWidth; + } + + return sessionsViewerSidebarWidth; + } + }, { orientation: Orientation.VERTICAL })); + + let sashStartWidth: number | undefined; + disposables.add(sash.onDidStart(() => sashStartWidth = this.sessionsViewerSidebarWidth)); + disposables.add(sash.onDidEnd(() => sashStartWidth = undefined)); + + disposables.add(sash.onDidChange(e => { + if (sashStartWidth === undefined || !this.lastDimensions) { + return; + } + + const { position } = this.getViewPositionAndLocation(); + const delta = e.currentX - e.startX; + const newWidth = position === Position.RIGHT ? sashStartWidth - delta : sashStartWidth + delta; + + this.sessionsViewerSidebarWidth = this.computeEffectiveSideBySideSessionsSidebarWidth(this.lastDimensions.width, newWidth); + this.viewState.sessionsSidebarWidth = this.sessionsViewerSidebarWidth; + + this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); + })); + + disposables.add(sash.onDidReset(() => { + this.sessionsViewerSidebarWidth = ChatViewPane.SESSIONS_SIDEBAR_DEFAULT_WIDTH; + this.viewState.sessionsSidebarWidth = this.sessionsViewerSidebarWidth; + + if (this.lastDimensions) { + this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); + } + })); + } + //#endregion override saveState(): void { diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index a6501cc4108..97fa6b8b92b 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -55,6 +55,7 @@ export namespace ChatContextKeys { export const chatEditingCanRedo = new RawContextKey('chatEditingCanRedo', false, { type: 'boolean', description: localize('chatEditingCanRedo', "True when it is possible to redo an interaction in the editing panel.") }); export const languageModelsAreUserSelectable = new RawContextKey('chatModelsAreUserSelectable', false, { type: 'boolean', description: localize('chatModelsAreUserSelectable', "True when the chat model can be selected manually by the user.") }); export const chatSessionHasModels = new RawContextKey('chatSessionHasModels', false, { type: 'boolean', description: localize('chatSessionHasModels', "True when the chat is in a contributed chat session that has available 'models' to display.") }); + export const chatSessionOptionsValid = new RawContextKey('chatSessionOptionsValid', true, { type: 'boolean', description: localize('chatSessionOptionsValid', "True when all selected session options exist in their respective option group items.") }); export const extensionInvalid = new RawContextKey('chatExtensionInvalid', false, { type: 'boolean', description: localize('chatExtensionInvalid', "True when the installed chat extension is invalid and needs to be updated.") }); export const inputCursorAtTop = new RawContextKey('chatCursorAtTop', false); export const inputHasAgent = new RawContextKey('chatInputHasAgent', false); @@ -111,6 +112,9 @@ export namespace ChatContextKeys { export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); + + // Focus View mode + export const inFocusViewMode = new RawContextKey('chatInFocusViewMode', false, { type: 'boolean', description: localize('chatInFocusViewMode', "True when the workbench is in focus view mode for an agent session.") }); } export namespace ChatContextKeyExprs { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 7a757d8eb1e..99b140e6912 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -450,14 +450,12 @@ export type ConfirmedReason = export interface IChatToolInvocation { readonly presentation: IPreparedToolInvocation['presentation']; readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent; - readonly confirmationMessages?: IToolConfirmationMessages; readonly originMessage: string | IMarkdownString | undefined; readonly invocationMessage: string | IMarkdownString; readonly pastTenseMessage: string | IMarkdownString | undefined; readonly source: ToolDataSource; readonly toolId: string; readonly toolCallId: string; - readonly parameters: unknown; readonly fromSubAgent?: boolean; readonly state: IObservable; generatedTitle?: string; @@ -469,6 +467,8 @@ export interface IChatToolInvocation { export namespace IChatToolInvocation { export const enum StateKind { + /** Tool call is streaming partial input from the LM */ + Streaming, WaitingForConfirmation, Executing, WaitingForPostApproval, @@ -480,12 +480,26 @@ export namespace IChatToolInvocation { type: StateKind; } - interface IChatToolInvocationWaitingForConfirmationState extends IChatToolInvocationStateBase { + export interface IChatToolInvocationStreamingState extends IChatToolInvocationStateBase { + type: StateKind.Streaming; + /** Observable partial input from the LM stream */ + readonly partialInput: IObservable; + /** Custom invocation message from handleToolStream */ + readonly streamingMessage: IObservable; + } + + /** Properties available after streaming is complete */ + interface IChatToolInvocationPostStreamState { + readonly parameters: unknown; + readonly confirmationMessages?: IToolConfirmationMessages; + } + + interface IChatToolInvocationWaitingForConfirmationState extends IChatToolInvocationStateBase, IChatToolInvocationPostStreamState { type: StateKind.WaitingForConfirmation; confirm(reason: ConfirmedReason): void; } - interface IChatToolInvocationPostConfirmState { + interface IChatToolInvocationPostConfirmState extends IChatToolInvocationPostStreamState { confirmed: ConfirmedReason; } @@ -510,12 +524,13 @@ export namespace IChatToolInvocation { contentForModel: IToolResult['content']; } - interface IChatToolInvocationCancelledState extends IChatToolInvocationStateBase { + interface IChatToolInvocationCancelledState extends IChatToolInvocationStateBase, IChatToolInvocationPostStreamState { type: StateKind.Cancelled; reason: ToolConfirmKind.Denied | ToolConfirmKind.Skipped; } export type State = + | IChatToolInvocationStreamingState | IChatToolInvocationWaitingForConfirmationState | IChatToolInvocationExecutingState | IChatToolWaitingForPostApprovalState @@ -531,7 +546,7 @@ export namespace IChatToolInvocation { } const state = invocation.state.read(reader); - if (state.type === StateKind.WaitingForConfirmation) { + if (state.type === StateKind.Streaming || state.type === StateKind.WaitingForConfirmation) { return undefined; // don't know yet } if (state.type === StateKind.Cancelled) { @@ -635,6 +650,47 @@ export namespace IChatToolInvocation { const state = invocation.state.read(reader); return state.type === StateKind.Completed || state.type === StateKind.Cancelled; } + + export function isStreaming(invocation: IChatToolInvocation | IChatToolInvocationSerialized, reader?: IReader): boolean { + if (invocation.kind === 'toolInvocationSerialized') { + return false; + } + + const state = invocation.state.read(reader); + return state.type === StateKind.Streaming; + } + + /** + * Get parameters from invocation. Returns undefined during streaming state. + */ + export function getParameters(invocation: IChatToolInvocation | IChatToolInvocationSerialized, reader?: IReader): unknown | undefined { + if (invocation.kind === 'toolInvocationSerialized') { + return undefined; // serialized invocations don't store parameters + } + + const state = invocation.state.read(reader); + if (state.type === StateKind.Streaming) { + return undefined; + } + + return state.parameters; + } + + /** + * Get confirmation messages from invocation. Returns undefined during streaming state. + */ + export function getConfirmationMessages(invocation: IChatToolInvocation | IChatToolInvocationSerialized, reader?: IReader): IToolConfirmationMessages | undefined { + if (invocation.kind === 'toolInvocationSerialized') { + return undefined; // serialized invocations don't store confirmation messages + } + + const state = invocation.state.read(reader); + if (state.type === StateKind.Streaming) { + return undefined; + } + + return state.confirmationMessages; + } } @@ -734,11 +790,6 @@ export class ChatMcpServersStarting implements IChatMcpServersStarting { } } -export interface IChatPrepareToolInvocationPart { - readonly kind: 'prepareToolInvocation'; - readonly toolName: string; -} - export type IChatProgress = | IChatMarkdownContent | IChatAgentMarkdownContentWithVulnerability @@ -765,7 +816,6 @@ export type IChatProgress = | IChatExtensionsContent | IChatPullRequestContent | IChatUndoStop - | IChatPrepareToolInvocationPart | IChatThinkingPart | IChatTaskSerialized | IChatElicitationRequest @@ -1035,6 +1085,8 @@ export interface IChatService { readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI }>; + readonly onDidCreateModel: Event; + /** * An observable containing all live chat models. */ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index d517d0ce503..e515c29b76d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -87,6 +87,8 @@ export class ChatService extends Disposable implements IChatService { private readonly _onDidSubmitRequest = this._register(new Emitter<{ readonly chatSessionResource: URI }>()); public readonly onDidSubmitRequest = this._onDidSubmitRequest.event; + public get onDidCreateModel() { return this._sessionModels.onDidCreateModel; } + private readonly _onDidPerformUserAction = this._register(new Emitter()); public readonly onDidPerformUserAction: Event = this._onDidPerformUserAction.event; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 8e9a983a33f..76a9b348698 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -45,7 +45,7 @@ export interface IChatSessionProviderOptionGroup { description?: string; items: IChatSessionProviderOptionItem[]; searchable?: boolean; - onSearch?: (token: CancellationToken) => Thenable; + onSearch?: (query: string, token: CancellationToken) => Thenable; /** * A context key expression that controls visibility of this option group picker. * When specified, the picker is only visible when the expression evaluates to true. diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 2ece134ffd7..5c212aba616 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -10,8 +10,10 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AgentEnabled = 'chat.agent.enabled', + AgentSessionProjectionEnabled = 'chat.agentSessionProjection.enabled', Edits2Enabled = 'chat.edits2.enabled', ExtensionToolsEnabled = 'chat.extensionTools.enabled', + RepoInfoEnabled = 'chat.repoInfo.enabled', EditRequests = 'chat.editRequests', GlobalAutoApprove = 'chat.tools.global.autoApprove', AutoApproveEdits = 'chat.tools.edits.autoApprove', @@ -26,7 +28,6 @@ export enum ChatConfiguration { ChatViewSessionsEnabled = 'chat.viewSessions.enabled', ChatViewSessionsOrientation = 'chat.viewSessions.orientation', ChatViewTitleEnabled = 'chat.viewTitle.enabled', - ChatViewWelcomeEnabled = 'chat.viewWelcome.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', RestoreLastPanelSession = 'chat.restoreLastPanelSession', diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 3c92e9646f1..92567694b67 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -276,7 +276,7 @@ export interface ILanguageModelChatInfoOptions { export interface ILanguageModelsGroup { readonly group?: ILanguageModelsProviderGroup; - readonly models: ILanguageModelChatMetadataAndIdentifier[]; + readonly modelIdentifiers: string[]; readonly status?: { readonly message: string; readonly severity: Severity; @@ -546,11 +546,11 @@ export class LanguageModelsService implements ILanguageModelsService { const models = await this._resolveLanguageModels(provider, { silent }); if (models.length) { allModels.push(...models); - languageModelsGroups.push({ models }); + languageModelsGroups.push({ modelIdentifiers: models.map(m => m.identifier) }); } } catch (error) { languageModelsGroups.push({ - models: [], + modelIdentifiers: [], status: { message: getErrorMessage(error), severity: Severity.Error @@ -570,12 +570,12 @@ export class LanguageModelsService implements ILanguageModelsService { const models = await this._resolveLanguageModels(provider, { group: group.name, silent, configuration }); if (models.length) { allModels.push(...models); - languageModelsGroups.push({ group, models }); + languageModelsGroups.push({ group, modelIdentifiers: models.map(m => m.identifier) }); } } catch (error) { languageModelsGroups.push({ group, - models: [], + modelIdentifiers: [], status: { message: getErrorMessage(error), severity: Severity.Error diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 7627b5f85fb..8fecdb4ebf8 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -29,7 +29,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatPrepareToolInvocationPart, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsedContext, IChatWarningMessage, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsedContext, IChatWarningMessage, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../languageModels.js'; @@ -155,7 +155,6 @@ export type IChatProgressResponseContent = | IChatToolInvocationSerialized | IChatMultiDiffData | IChatUndoStop - | IChatPrepareToolInvocationPart | IChatElicitationRequest | IChatElicitationRequestSerialized | IChatClearToPreviousToolInvocation @@ -170,7 +169,7 @@ export type IChatProgressResponseContentSerialized = Exclude; -const nonHistoryKinds = new Set(['toolInvocation', 'toolInvocationSerialized', 'undoStop', 'prepareToolInvocation']); +const nonHistoryKinds = new Set(['toolInvocation', 'toolInvocationSerialized', 'undoStop']); function isChatProgressHistoryResponseContent(content: IChatProgressResponseContent): content is IChatProgressHistoryResponseContent { return !nonHistoryKinds.has(content.kind); } @@ -439,7 +438,6 @@ class AbstractResponse implements IResponse { case 'extensions': case 'pullRequest': case 'undoStop': - case 'prepareToolInvocation': case 'elicitation2': case 'elicitationSerialized': case 'thinking': @@ -1011,9 +1009,12 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel signal.read(r); for (const part of this._response.value) { - if (part.kind === 'toolInvocation' && part.state.read(r).type === IChatToolInvocation.StateKind.WaitingForConfirmation) { - const title = part.confirmationMessages?.title; - return title ? (isMarkdownString(title) ? title.value : title) : undefined; + if (part.kind === 'toolInvocation') { + const state = part.state.read(r); + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { + const title = state.confirmationMessages?.title; + return title ? (isMarkdownString(title) ? title.value : title) : undefined; + } } if (part.kind === 'confirmation' && !part.isUsed) { return part.title; @@ -1253,6 +1254,9 @@ export interface IChatModel extends IDisposable { toExport(): IExportableChatData; toJSON(): ISerializableChatData; readonly contributedChatSession: IChatSessionContext | undefined; + + readonly repoData: IExportableRepoData | undefined; + setRepoData(data: IExportableRepoData | undefined): void; } export interface ISerializableChatsData { @@ -1304,6 +1308,102 @@ export interface ISerializableMarkdownInfo { readonly suggestionId: EditSuggestionId; } +/** + * Repository state captured for chat session export. + * Enables reproducing the workspace state by cloning, checking out the commit, and applying diffs. + */ +export interface IExportableRepoData { + /** + * Classification of the workspace's version control state. + * - `remote-git`: Git repo with a configured remote URL + * - `local-git`: Git repo without any remote (local only) + * - `plain-folder`: Not a git repository + */ + workspaceType: 'remote-git' | 'local-git' | 'plain-folder'; + + /** + * Sync status between local and remote. + * - `synced`: Local HEAD matches remote tracking branch (fully pushed) + * - `unpushed`: Local has commits not pushed to the remote tracking branch + * - `unpublished`: Local branch has no remote tracking branch configured + * - `local-only`: No remote configured (local git repo only) + * - `no-git`: Not a git repository + */ + syncStatus: 'synced' | 'unpushed' | 'unpublished' | 'local-only' | 'no-git'; + + /** + * Remote URL of the repository (e.g., https://github.com/org/repo.git). + * Undefined if no remote is configured. + */ + remoteUrl?: string; + + /** + * Vendor/host of the remote repository. + * Undefined if no remote is configured. + */ + remoteVendor?: 'github' | 'ado' | 'other'; + + /** + * Remote tracking branch for the current branch (e.g., "origin/feature/my-work"). + * Undefined if branch is unpublished or no remote. + */ + remoteTrackingBranch?: string; + + /** + * Default remote branch used as base for unpublished branches (e.g., "origin/main"). + * Helpful for computing merge-base when branch has no tracking. + */ + remoteBaseBranch?: string; + + /** + * Commit hash of the remote tracking branch HEAD. + * Undefined if branch has no remote tracking branch. + */ + remoteHeadCommit?: string; + + /** + * Name of the current local branch (e.g., "feature/my-work"). + */ + localBranch?: string; + + /** + * Commit hash of the local HEAD when captured. + */ + localHeadCommit?: string; + + /** + * Working tree diffs (uncommitted changes). + */ + diffs?: IExportableRepoDiff[]; + + /** + * Status of the diffs collection. + * - `included`: Diffs were successfully captured and included + * - `tooManyChanges`: Diffs skipped because >100 files changed (degenerate case like mass renames) + * - `tooLarge`: Diffs skipped because total size exceeded 900KB + * - `trimmedForStorage`: Diffs were trimmed to save storage (older session) + * - `noChanges`: No working tree changes detected + * - `notCaptured`: Diffs not captured (default/undefined case) + */ + diffsStatus?: 'included' | 'tooManyChanges' | 'tooLarge' | 'trimmedForStorage' | 'noChanges' | 'notCaptured'; + + /** + * Number of changed files detected, even if diffs were not included. + */ + changedFileCount?: number; +} + +/** + * A file change exported as a unified diff patch compatible with `git apply`. + */ +export interface IExportableRepoDiff { + relativePath: string; + changeType: 'added' | 'modified' | 'deleted' | 'renamed'; + oldRelativePath?: string; + unifiedDiff?: string; + status: string; +} + export interface IExportableChatData { initialLocation: ChatAgentLocation | undefined; requests: ISerializableChatRequestData[]; @@ -1327,8 +1427,14 @@ export interface ISerializableChatData2 extends ISerializableChatData1 { export interface ISerializableChatData3 extends Omit { version: 3; customTitle: string | undefined; + /** + * Whether the session had pending edits when it was stored. + * todo@connor4312 This will be cleaned up with the globalization of edits. + */ + hasPendingEdits?: boolean; /** Current draft input state (added later, fully backwards compatible) */ inputState?: ISerializableChatModelInputState; + repoData?: IExportableRepoData; } /** @@ -1652,6 +1758,15 @@ export class ChatModel extends Disposable implements IChatModel { public setContributedChatSession(session: IChatSessionContext | undefined) { this._contributedChatSession = session; } + + private _repoData: IExportableRepoData | undefined; + public get repoData(): IExportableRepoData | undefined { + return this._repoData; + } + public setRepoData(data: IExportableRepoData | undefined): void { + this._repoData = data; + } + readonly lastRequestObs: IObservable; // TODO to be clear, this is not the same as the id from the session object, which belongs to the provider. @@ -1791,6 +1906,9 @@ export class ChatModel extends Disposable implements IChatModel { this.dataSerializer = dataRef?.serializer; this._initialResponderUsername = initialData?.responderUsername; + + this._repoData = isValidFullData && initialData.repoData ? initialData.repoData : undefined; + this._initialLocation = initialData?.initialLocation ?? initialModelProps.initialLocation; this._canUseTools = initialModelProps.canUseTools; @@ -2237,6 +2355,7 @@ export class ChatModel extends Disposable implements IChatModel { creationDate: this._timestamp, customTitle: this._customTitle, inputState: this.inputModel.toJSON(), + repoData: this._repoData, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts b/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts index 25b37e97ae8..42305065ed5 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts @@ -38,6 +38,9 @@ export class ChatModelStore extends ReferenceCollection implements ID private readonly _onDidDisposeModel = this._store.add(new Emitter()); public readonly onDidDisposeModel = this._onDidDisposeModel.event; + private readonly _onDidCreateModel = this._store.add(new Emitter()); + public readonly onDidCreateModel = this._onDidCreateModel.event; + constructor( private readonly delegate: ChatModelStoreDelegate, @ILogService private readonly logService: ILogService, @@ -93,6 +96,7 @@ export class ChatModelStore extends ReferenceCollection implements ID throw new Error(`Chat session key mismatch for ${key}`); } this._models.set(key, model); + this._onDidCreateModel.fire(model); return model; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts index 69acdebd98b..b5515039ffe 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts @@ -10,31 +10,59 @@ import { localize } from '../../../../../../nls.js'; import { ConfirmedReason, IChatExtensionsContent, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; import { IPreparedToolInvocation, isToolResultOutputDetails, IToolConfirmationMessages, IToolData, IToolProgressStep, IToolResult, ToolDataSource } from '../../tools/languageModelToolsService.js'; +export interface IStreamingToolCallOptions { + toolCallId: string; + toolId: string; + toolData: IToolData; + fromSubAgent?: boolean; + chatRequestId?: string; +} + export class ChatToolInvocation implements IChatToolInvocation { public readonly kind: 'toolInvocation' = 'toolInvocation'; - public readonly invocationMessage: string | IMarkdownString; + public invocationMessage: string | IMarkdownString; public readonly originMessage: string | IMarkdownString | undefined; public pastTenseMessage: string | IMarkdownString | undefined; public confirmationMessages: IToolConfirmationMessages | undefined; - public readonly presentation: IPreparedToolInvocation['presentation']; + public presentation: IPreparedToolInvocation['presentation']; public readonly toolId: string; - public readonly source: ToolDataSource; + public source: ToolDataSource; public readonly fromSubAgent: boolean | undefined; - public readonly parameters: unknown; + public parameters: unknown; public generatedTitle?: string; + public readonly chatRequestId?: string; - public readonly toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; + public toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent; private readonly _progress = observableValue<{ message?: string | IMarkdownString; progress: number | undefined }>(this, { progress: 0 }); private readonly _state: ISettableObservable; + // Streaming-related observables + private readonly _partialInput = observableValue(this, undefined); + private readonly _streamingMessage = observableValue(this, undefined); + public get state(): IObservable { return this._state; } + /** + * Create a tool invocation in streaming state. + * Use this when the tool call is beginning to stream partial input from the LM. + */ + public static createStreaming(options: IStreamingToolCallOptions): ChatToolInvocation { + return new ChatToolInvocation(undefined, options.toolData, options.toolCallId, options.fromSubAgent, undefined, true, options.chatRequestId); + } - constructor(preparedInvocation: IPreparedToolInvocation | undefined, toolData: IToolData, public readonly toolCallId: string, fromSubAgent: boolean | undefined, parameters: unknown) { + constructor( + preparedInvocation: IPreparedToolInvocation | undefined, + toolData: IToolData, + public readonly toolCallId: string, + fromSubAgent: boolean | undefined, + parameters: unknown, + isStreaming: boolean = false, + chatRequestId?: string + ) { const defaultMessage = localize('toolInvocationMessage', "Using {0}", `"${toolData.displayName}"`); const invocationMessage = preparedInvocation?.invocationMessage ?? defaultMessage; this.invocationMessage = invocationMessage; @@ -47,26 +75,143 @@ export class ChatToolInvocation implements IChatToolInvocation { this.source = toolData.source; this.fromSubAgent = fromSubAgent; this.parameters = parameters; + this.chatRequestId = chatRequestId; - if (!this.confirmationMessages?.title) { - this._state = observableValue(this, { type: IChatToolInvocation.StateKind.Executing, confirmed: { type: ToolConfirmKind.ConfirmationNotNeeded, reason: this.confirmationMessages?.confirmationNotNeededReason }, progress: this._progress }); + if (isStreaming) { + // Start in streaming state + this._state = observableValue(this, { + type: IChatToolInvocation.StateKind.Streaming, + partialInput: this._partialInput, + streamingMessage: this._streamingMessage, + }); + } else if (!this.confirmationMessages?.title) { + this._state = observableValue(this, { + type: IChatToolInvocation.StateKind.Executing, + confirmed: { type: ToolConfirmKind.ConfirmationNotNeeded, reason: this.confirmationMessages?.confirmationNotNeededReason }, + progress: this._progress, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, + }); } else { this._state = observableValue(this, { type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, confirm: reason => { if (reason.type === ToolConfirmKind.Denied || reason.type === ToolConfirmKind.Skipped) { - this._state.set({ type: IChatToolInvocation.StateKind.Cancelled, reason: reason.type }, undefined); + this._state.set({ + type: IChatToolInvocation.StateKind.Cancelled, + reason: reason.type, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, + }, undefined); } else { - this._state.set({ type: IChatToolInvocation.StateKind.Executing, confirmed: reason, progress: this._progress }, undefined); + this._state.set({ + type: IChatToolInvocation.StateKind.Executing, + confirmed: reason, + progress: this._progress, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, + }, undefined); } } }); } } + /** + * Update the partial input observable during streaming. + */ + public updatePartialInput(input: unknown): void { + if (this._state.get().type !== IChatToolInvocation.StateKind.Streaming) { + return; // Only update in streaming state + } + this._partialInput.set(input, undefined); + } + + /** + * Update the streaming message (from handleToolStream). + */ + public updateStreamingMessage(message: string | IMarkdownString): void { + const state = this._state.get(); + if (state.type !== IChatToolInvocation.StateKind.Streaming) { + return; // Only update in streaming state + } + this._streamingMessage.set(message, undefined); + } + + /** + * Transition from streaming state to prepared/executing state. + * Called when the full tool call is ready. + */ + public transitionFromStreaming(preparedInvocation: IPreparedToolInvocation | undefined, parameters: unknown): void { + const currentState = this._state.get(); + if (currentState.type !== IChatToolInvocation.StateKind.Streaming) { + return; // Only transition from streaming state + } + + // Preserve the last streaming message if no new invocation message is provided + const lastStreamingMessage = this._streamingMessage.get(); + if (lastStreamingMessage && !preparedInvocation?.invocationMessage) { + this.invocationMessage = lastStreamingMessage; + } + + // Update fields from prepared invocation + this.parameters = parameters; + if (preparedInvocation) { + if (preparedInvocation.invocationMessage) { + this.invocationMessage = preparedInvocation.invocationMessage; + } + this.pastTenseMessage = preparedInvocation.pastTenseMessage; + this.confirmationMessages = preparedInvocation.confirmationMessages; + this.presentation = preparedInvocation.presentation; + this.toolSpecificData = preparedInvocation.toolSpecificData; + } + + // Transition to the appropriate state + if (!this.confirmationMessages?.title) { + this._state.set({ + type: IChatToolInvocation.StateKind.Executing, + confirmed: { type: ToolConfirmKind.ConfirmationNotNeeded, reason: this.confirmationMessages?.confirmationNotNeededReason }, + progress: this._progress, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, + }, undefined); + } else { + this._state.set({ + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, + confirm: reason => { + if (reason.type === ToolConfirmKind.Denied || reason.type === ToolConfirmKind.Skipped) { + this._state.set({ + type: IChatToolInvocation.StateKind.Cancelled, + reason: reason.type, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, + }, undefined); + } else { + this._state.set({ + type: IChatToolInvocation.StateKind.Executing, + confirmed: reason, + progress: this._progress, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, + }, undefined); + } + } + }, undefined); + } + } + private _setCompleted(result: IToolResult | undefined, postConfirmed?: ConfirmedReason | undefined) { if (postConfirmed && (postConfirmed.type === ToolConfirmKind.Denied || postConfirmed.type === ToolConfirmKind.Skipped)) { - this._state.set({ type: IChatToolInvocation.StateKind.Cancelled, reason: postConfirmed.type }, undefined); + this._state.set({ + type: IChatToolInvocation.StateKind.Cancelled, + reason: postConfirmed.type, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, + }, undefined); return; } @@ -76,6 +221,8 @@ export class ChatToolInvocation implements IChatToolInvocation { resultDetails: result?.toolResultDetails, postConfirmed, contentForModel: result?.content || [], + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, }, undefined); } @@ -93,6 +240,8 @@ export class ChatToolInvocation implements IChatToolInvocation { resultDetails: result?.toolResultDetails, contentForModel: result?.content || [], confirm: reason => this._setCompleted(result, reason), + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, }, undefined); } else { this._setCompleted(result); diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index 97dda654be0..4fdb96b6dbd 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -8,6 +8,7 @@ import { isMarkdownString } from '../../../../../base/common/htmlContent.js'; import { equals as objectsEqual } from '../../../../../base/common/objects.js'; import { isEqual as urisEqual } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; +import { ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { IChatMarkdownContent, ResponseModelState } from '../chatService/chatService.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { IChatAgentEditedFileEvent, IChatDataSerializerLog, IChatModel, IChatProgressResponseContent, IChatRequestModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState, ISerializableChatRequestData, SerializedChatResponsePart } from './chatModel.js'; @@ -70,7 +71,6 @@ const responsePartSchema = Adapt.v({ responderUsername: Adapt.v(m => m.responderUsername), sessionId: Adapt.v(m => m.sessionId), requests: Adapt.t(m => m.getRequests(), Adapt.array(requestSchema)), + hasPendingEdits: Adapt.v(m => m.editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified)), + repoData: Adapt.v(m => m.repoData, objectsEqual), }); export class ChatSessionOperationLog extends Adapt.ObjectMutationLog implements IChatDataSerializerLog { diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index 4fd1a06dea9..c31571559ba 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -8,7 +8,6 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { hash } from '../../../../../base/common/hash.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, dispose } from '../../../../../base/common/lifecycle.js'; -import * as marked from '../../../../../base/common/marked/marked.js'; import { IObservable } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -17,7 +16,6 @@ import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatMcpServersStarting, IChatProgressMessage, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from '../chatService/chatService.js'; import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; -import { annotateVulnerabilitiesInText } from '../widget/annotations.js'; import { CodeBlockModelCollection } from '../widget/codeBlockModelCollection.js'; import { IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js'; import { ChatStreamStatsTracker, IChatStreamStats } from './chatStreamStats.js'; @@ -270,7 +268,6 @@ export class ChatViewModel extends Disposable implements IChatViewModel { _model.getRequests().forEach((request, i) => { const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, request); this._items.push(requestModel); - this.updateCodeBlockTextModels(requestModel); if (request.response) { this.onAddResponse(request.response); @@ -282,7 +279,6 @@ export class ChatViewModel extends Disposable implements IChatViewModel { if (e.kind === 'addRequest') { const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request); this._items.push(requestModel); - this.updateCodeBlockTextModels(requestModel); if (e.request.response) { this.onAddResponse(e.request.response); @@ -317,13 +313,9 @@ export class ChatViewModel extends Disposable implements IChatViewModel { private onAddResponse(responseModel: IChatResponseModel) { const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel, this); this._register(response.onDidChange(() => { - if (response.isComplete) { - this.updateCodeBlockTextModels(response); - } return this._onDidChange.fire(null); })); this._items.push(response); - this.updateCodeBlockTextModels(response); } getItems(): (IChatRequestViewModel | IChatResponseViewModel)[] { @@ -348,24 +340,6 @@ export class ChatViewModel extends Disposable implements IChatViewModel { super.dispose(); dispose(this._items.filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel)); } - - updateCodeBlockTextModels(model: IChatRequestViewModel | IChatResponseViewModel) { - let content: string; - if (isRequestVM(model)) { - content = model.messageText; - } else { - content = annotateVulnerabilitiesInText(model.response.value).map(x => x.content.value).join(''); - } - - let codeBlockIndex = 0; - marked.walkTokens(marked.lexer(content), token => { - if (token.type === 'code') { - const lang = token.lang || ''; - const text = token.text; - this.codeBlockModelCollection.update(this._model.sessionResource, model, codeBlockIndex++, { text, languageId: lang, isComplete: true }); - } - }); - } } const variablesHash = new WeakMap(); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index b5de8005f78..98bccdd257a 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -184,22 +184,22 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } } - // Track whether we should collect markdown (after the last prepare tool invocation) + // Track whether we should collect markdown (after the last tool invocation) const markdownParts: string[] = []; let inEdit = false; const progressCallback = (parts: IChatProgress[]) => { for (const part of parts) { // Write certain parts immediately to the model - if (part.kind === 'prepareToolInvocation' || part.kind === 'textEdit' || part.kind === 'notebookEdit' || part.kind === 'codeblockUri') { + if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized' || part.kind === 'textEdit' || part.kind === 'notebookEdit' || part.kind === 'codeblockUri') { if (part.kind === 'codeblockUri' && !inEdit) { inEdit = true; model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('```\n'), fromSubagent: true }); } model.acceptResponseProgress(request, part); - // When we see a prepare tool invocation, reset markdown collection - if (part.kind === 'prepareToolInvocation') { + // When we see a tool invocation starting, reset markdown collection + if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') { markdownParts.length = 0; // Clear previously collected markdown } } else if (part.kind === 'markdownContent') { diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 12c3413ee33..a94d97745c1 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -24,7 +24,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; import { IVariableReference } from '../chatModes.js'; -import { IChatExtensionsContent, IChatTodoListContent, IChatToolInputInvocationData, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; +import { IChatExtensionsContent, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; import { ChatRequestToolReferenceEntry } from '../attachments/chatVariableEntries.js'; import { LanguageModelPartAudience } from '../languageModels.js'; import { PromptElementJSON, stringifyPromptElementJSON } from './promptTsxTypes.js'; @@ -148,6 +148,10 @@ export interface IToolInvocation { context: IToolInvocationContext | undefined; chatRequestId?: string; chatInteractionId?: string; + /** + * Optional tool call ID from the chat stream, used to correlate with pending streaming tool calls. + */ + chatStreamToolCallId?: string; /** * Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups */ @@ -302,6 +306,18 @@ export enum ToolInvocationPresentation { HiddenAfterComplete = 'hiddenAfterComplete' } +export interface IToolInvocationStreamContext { + toolCallId: string; + rawInput: unknown; + chatRequestId?: string; + chatSessionId?: string; + chatInteractionId?: string; +} + +export interface IStreamedToolInvocation { + invocationMessage?: string | IMarkdownString; +} + export interface IPreparedToolInvocation { invocationMessage?: string | IMarkdownString; pastTenseMessage?: string | IMarkdownString; @@ -314,6 +330,7 @@ export interface IPreparedToolInvocation { export interface IToolImpl { invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, progress: ToolProgress, token: CancellationToken): Promise; prepareToolInvocation?(context: IToolInvocationPreparationContext, token: CancellationToken): Promise; + handleToolStream?(context: IToolInvocationStreamContext, token: CancellationToken): Promise; } export type IToolAndToolSetEnablementMap = ReadonlyMap; @@ -370,6 +387,14 @@ export class ToolSet { } +export interface IBeginToolCallOptions { + toolCallId: string; + toolId: string; + chatRequestId?: string; + sessionResource?: URI; + fromSubAgent?: boolean; +} + export const ILanguageModelToolsService = createDecorator('ILanguageModelToolsService'); export type CountTokensCallback = (input: string, token: CancellationToken) => Promise; @@ -421,6 +446,19 @@ export interface ILanguageModelToolsService { */ getToolByName(name: string): IToolData | undefined; + /** + * Begin a tool call in the streaming phase. + * Creates a ChatToolInvocation in the Streaming state and appends it to the chat. + * Returns the invocation so it can be looked up later when invokeTool is called. + */ + beginToolCall(options: IBeginToolCallOptions): IChatToolInvocation | undefined; + + /** + * Update the streaming state of a pending tool call. + * Calls the tool's handleToolStream method to get a custom invocation message. + */ + updateToolStream(toolCallId: string, partialInput: unknown, token: CancellationToken): Promise; + invokeTool(invocation: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise; cancelToolCallsForRequest(requestId: string): void; /** Flush any pending tool updates to the extension hosts. */ diff --git a/src/vs/workbench/contrib/chat/common/widget/annotations.ts b/src/vs/workbench/contrib/chat/common/widget/annotations.ts index 600decb9f36..a1dbed9eb89 100644 --- a/src/vs/workbench/contrib/chat/common/widget/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/widget/annotations.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { IRange } from '../../../../../editor/common/core/range.js'; import { isLocation } from '../../../../../editor/common/languages.js'; import { IChatProgressRenderableResponseContent, IChatProgressResponseContent, appendMarkdownString, canMergeMarkdownStrings } from '../model/chatModel.js'; -import { IChatAgentVulnerabilityDetails, IChatMarkdownContent } from '../chatService/chatService.js'; +import { IChatAgentVulnerabilityDetails } from '../chatService/chatService.js'; export const contentRefUrl = 'http://_vscodecontentref_'; // must be lowercase for URI @@ -79,31 +79,6 @@ export interface IMarkdownVulnerability { readonly description: string; readonly range: IRange; } - -export function annotateVulnerabilitiesInText(response: ReadonlyArray): readonly IChatMarkdownContent[] { - const result: IChatMarkdownContent[] = []; - for (const item of response) { - const previousItem = result[result.length - 1]; - if (item.kind === 'markdownContent') { - if (previousItem?.kind === 'markdownContent') { - result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + item.content.value, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; - } else { - result.push(item); - } - } else if (item.kind === 'markdownVuln') { - const vulnText = encodeURIComponent(JSON.stringify(item.vulnerabilities)); - const markdownText = `${item.content.value}`; - if (previousItem?.kind === 'markdownContent') { - result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + markdownText, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; - } else { - result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); - } - } - } - - return result; -} - export function extractCodeblockUrisFromText(text: string): { uri: URI; isEdit?: boolean; textWithoutResult: string } | undefined { const match = /(.*?)<\/vscode_codeblock_uri>/ms.exec(text); if (match) { diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts new file mode 100644 index 00000000000..e16e8af4d8b --- /dev/null +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { joinPath } from '../../../../../base/common/resources.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { INativeHostService } from '../../../../../platform/native/common/native.js'; +import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; +import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js'; +import { IChatWidgetService } from '../../browser/chat.js'; +import { captureRepoInfo } from '../../browser/chatRepoInfo.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { ISCMService } from '../../../scm/common/scm.js'; + +export function registerChatExportZipAction() { + registerAction2(class ExportChatAsZipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.exportAsZip', + category: CHAT_CATEGORY, + title: localize2('chat.exportAsZip.label', "Export Chat as Zip..."), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatEntitlementContextKeys.Entitlement.internal), + f1: true, + }); + } + + async run(accessor: ServicesAccessor) { + const widgetService = accessor.get(IChatWidgetService); + const fileDialogService = accessor.get(IFileDialogService); + const chatService = accessor.get(IChatService); + const nativeHostService = accessor.get(INativeHostService); + const notificationService = accessor.get(INotificationService); + const scmService = accessor.get(ISCMService); + const fileService = accessor.get(IFileService); + const configurationService = accessor.get(IConfigurationService); + + const repoInfoEnabled = configurationService.getValue(ChatConfiguration.RepoInfoEnabled) ?? true; + + const widget = widgetService.lastFocusedWidget; + if (!widget || !widget.viewModel) { + return; + } + + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), 'chat.zip'); + const result = await fileDialogService.showSaveDialog({ + defaultUri, + filters: [{ name: 'Zip Archive', extensions: ['zip'] }] + }); + + if (!result) { + return; + } + + const model = chatService.getSession(widget.viewModel.sessionResource); + if (!model) { + return; + } + + const files: { path: string; contents: string }[] = [ + { + path: 'chat.json', + contents: JSON.stringify(model.toExport(), undefined, 2) + } + ]; + + const hasMessages = model.getRequests().length > 0; + + if (hasMessages) { + if (model.repoData) { + files.push({ + path: 'chat.repo.begin.json', + contents: JSON.stringify(model.repoData, undefined, 2) + }); + } + + if (repoInfoEnabled) { + const currentRepoData = await captureRepoInfo(scmService, fileService); + if (currentRepoData) { + files.push({ + path: 'chat.repo.end.json', + contents: JSON.stringify(currentRepoData, undefined, 2) + }); + } + + if (!model.repoData && !currentRepoData) { + notificationService.notify({ + severity: Severity.Warning, + message: localize('chatExportZip.noRepoData', "Exported chat without repository context. No Git repository was detected.") + }); + } + } + } else { + if (repoInfoEnabled) { + const currentRepoData = await captureRepoInfo(scmService, fileService); + if (currentRepoData) { + files.push({ + path: 'chat.repo.begin.json', + contents: JSON.stringify(currentRepoData, undefined, 2) + }); + } else { + notificationService.notify({ + severity: Severity.Warning, + message: localize('chatExportZip.noRepoData', "Exported chat without repository context. No Git repository was detected.") + }); + } + } + } + + try { + await nativeHostService.createZipFile(result, files); + } catch (error) { + notificationService.notify({ + severity: Severity.Error, + message: localize('chatExportZip.error', "Failed to export chat as zip: {0}", error instanceof Error ? error.message : String(error)) + }); + } + } + }); +} diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 4ab40ef6a76..e4c7c9cfbab 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -30,6 +30,7 @@ import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { ChatConfiguration, ChatModeKind } from '../common/constants.js'; import { IChatService } from '../common/chatService/chatService.js'; import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js'; +import { registerChatExportZipAction } from './actions/chatExportZip.js'; import { HoldToVoiceChatInChatViewAction, InlineVoiceChatAction, KeywordActivationContribution, QuickVoiceChatAction, ReadChatResponseAloud, StartVoiceChatAction, StopListeningAction, StopListeningAndSubmitAction, StopReadAloud, StopReadChatItemAloud, VoiceChatInChatViewAction } from './actions/voiceChatActions.js'; import { NativeBuiltinToolsContribution } from './builtInTools/tools.js'; @@ -200,6 +201,7 @@ registerAction2(StopReadChatItemAloud); registerAction2(StopReadAloud); registerChatDeveloperActions(); +registerChatExportZipAction(); registerWorkbenchContribution2(KeywordActivationContribution.ID, KeywordActivationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(NativeBuiltinToolsContribution.ID, NativeBuiltinToolsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts index 04e96b80adc..7be0701efe2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts @@ -32,6 +32,7 @@ class MockChatService implements IChatService { editingSessions = []; transferredSessionResource = undefined; readonly onDidSubmitRequest = Event.None; + readonly onDidCreateModel = Event.None; private sessions = new Map(); private liveSessionItems: IChatDetail[] = []; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 87a4fc80080..bcf006ffa27 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -48,10 +48,10 @@ class MockLanguageModelsService implements ILanguageModelsService { vendor: vendorId, name: this.vendors.find(v => v.vendor === vendorId)?.displayName || 'Default' }, - models: [] + modelIdentifiers: [] }); } - groups[0].models.push({ identifier, metadata }); + groups[0].modelIdentifiers.push(identifier); this.modelGroups.set(vendorId, groups); } diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index a7a5cce8e4b..42610e5a4c7 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -21,6 +21,7 @@ export class MockChatService implements IChatService { editingSessions = []; transferredSessionResource: URI | undefined; readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI }> = Event.None; + readonly onDidCreateModel: Event = Event.None; private sessions = new ResourceMap(); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index f0029e5096f..23db280a7e9 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -642,11 +642,10 @@ suite('ChatResponseModel', () => { assert.strictEqual(response.confirmationAdjustedTimestamp.get(), start); // Add pending confirmation via tool invocation - const toolState = observableValue('state', { type: 0 /* IChatToolInvocation.StateKind.WaitingForConfirmation */ }); + const toolState = observableValue('state', { type: 1 /* IChatToolInvocation.StateKind.WaitingForConfirmation */, confirmationMessages: { title: 'Please confirm' } }); const toolInvocation = { kind: 'toolInvocation', invocationMessage: 'calling tool', - confirmationMessages: { title: 'Please confirm' }, state: toolState } as Partial as IChatToolInvocation; @@ -658,7 +657,7 @@ suite('ChatResponseModel', () => { assert.strictEqual(response.confirmationAdjustedTimestamp.get(), start); // Resolve confirmation - toolState.set({ type: 3 /* IChatToolInvocation.StateKind.Completed */ }, undefined); + toolState.set({ type: 4 /* IChatToolInvocation.StateKind.Completed */ }, undefined); // Now adjusted timestamp should reflect the wait time // The wait time was 2000ms. diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index 34f407b43a9..4cced4a16c4 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; -import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; +import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; export class MockChatModel extends Disposable implements IChatModel { @@ -38,6 +38,7 @@ export class MockChatModel extends Disposable implements IChatModel { toJSON: () => undefined }; readonly contributedChatSession = undefined; + repoData: IExportableRepoData | undefined = undefined; isDisposed = false; lastRequestObs: IObservable; @@ -58,6 +59,7 @@ export class MockChatModel extends Disposable implements IChatModel { startEditingSession(isGlobalEditingSession?: boolean, transferFromSession?: IChatEditingSession): void { } getRequests(): IChatRequestModel[] { return []; } setCheckpoint(requestId: string | undefined): void { } + setRepoData(data: IExportableRepoData | undefined): void { this.repoData = data; } toExport(): IExportableChatData { return { initialLocation: this.initialLocation, @@ -74,6 +76,7 @@ export class MockChatModel extends Disposable implements IChatModel { initialLocation: this.initialLocation, requests: [], responderUsername: '', + repoData: this.repoData }; } } diff --git a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts index 9076a8defee..2d4d41001bf 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts @@ -12,8 +12,9 @@ import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IProgressStep } from '../../../../../../platform/progress/common/progress.js'; import { IVariableReference } from '../../../common/chatModes.js'; +import { IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { ChatRequestToolReferenceEntry } from '../../../common/attachments/chatVariableEntries.js'; -import { CountTokensCallback, ILanguageModelToolsService, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; +import { CountTokensCallback, IBeginToolCallOptions, ILanguageModelToolsService, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; export class MockLanguageModelToolsService implements ILanguageModelToolsService { _serviceBrand: undefined; @@ -97,6 +98,15 @@ export class MockLanguageModelToolsService implements ILanguageModelToolsService }; } + beginToolCall(_options: IBeginToolCallOptions): IChatToolInvocation | undefined { + // Mock implementation - return undefined + return undefined; + } + + async updateToolStream(_toolCallId: string, _partialInput: unknown, _token: CancellationToken): Promise { + // Mock implementation - do nothing + } + toolSets: IObservable = constObservable([]); getToolSetByName(name: string): ToolSet | undefined { @@ -111,7 +121,7 @@ export class MockLanguageModelToolsService implements ILanguageModelToolsService throw new Error('Method not implemented.'); } - toToolAndToolSetEnablementMap(toolOrToolSetNames: readonly string[]): IToolAndToolSetEnablementMap { + toToolAndToolSetEnablementMap(toolOrToolSetNames: readonly string[], target: string | undefined): IToolAndToolSetEnablementMap { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.css b/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.css index 0c378f88922..7b5530e7fa7 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.css +++ b/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.css @@ -5,7 +5,7 @@ .suggest-input-container { padding: 2px 6px; - border-radius: 2px; + border-radius: 4px; } .suggest-input-container .monaco-editor-background, diff --git a/src/vs/workbench/contrib/comments/browser/commentFormActions.ts b/src/vs/workbench/contrib/comments/browser/commentFormActions.ts index f83a1b60a53..7acbf42409f 100644 --- a/src/vs/workbench/contrib/comments/browser/commentFormActions.ts +++ b/src/vs/workbench/contrib/comments/browser/commentFormActions.ts @@ -60,8 +60,9 @@ export class CommentFormActions implements IDisposable { secondary: !isPrimary, title, addPrimaryActionToDropdown: false, + small: true, ...defaultButtonStyles - }) : new Button(this.container, { secondary: !isPrimary, title, ...defaultButtonStyles }); + }) : new Button(this.container, { secondary: !isPrimary, title, small: true, ...defaultButtonStyles }); isPrimary = false; this._buttonElements.push(button.element); diff --git a/src/vs/workbench/contrib/comments/browser/media/review.css b/src/vs/workbench/contrib/comments/browser/media/review.css index 42a3076cffd..1d42ac39101 100644 --- a/src/vs/workbench/contrib/comments/browser/media/review.css +++ b/src/vs/workbench/contrib/comments/browser/media/review.css @@ -319,10 +319,6 @@ margin: 0 10px 0 0; } -.review-widget .body .comment-additional-actions .button-bar .monaco-text-button { - padding: 4px 10px; -} - .review-widget .body .comment-additional-actions .codicon-drop-down-button { align-items: center; } @@ -425,7 +421,6 @@ .review-widget .body .comment-form-container .form-actions .monaco-text-button, .review-widget .body .edit-container .monaco-text-button { width: auto; - padding: 4px 10px; margin-left: 5px; } diff --git a/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css b/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css index 98e13ca7a83..eb7649d45cb 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css @@ -246,7 +246,6 @@ .extension-editor > .header > .details > .actions-status-container > .monaco-action-bar > .actions-container > .action-item > .extension-action.label, .extension-editor > .header > .details > .actions-status-container > .monaco-action-bar > .actions-container > .action-item.action-dropdown-item .extension-action.label { - font-weight: 600; max-width: 300px; } @@ -269,17 +268,17 @@ /* single install */ .extension-editor > .header > .details > .actions-status-container > .monaco-action-bar > .actions-container > .action-item > .extension-action.label, .extension-editor > .header > .details > .actions-status-container > .monaco-action-bar > .actions-container > .action-item.action-dropdown-item.empty > .extension-action.label { - border-radius: 2px; + border-radius: 4px; } /* split install */ .extension-editor > .header > .details > .actions-status-container > .monaco-action-bar > .actions-container > .action-item.action-dropdown-item:not(.empty) > .extension-action.label { - border-radius: 2px 0 0 2px; + border-radius: 4px 0 0 4px; } .extension-editor > .header > .details > .actions-status-container > .monaco-action-bar > .actions-container > .action-item.action-dropdown-item:not(.empty) > .monaco-dropdown .extension-action.label { border-left-width: 0; - border-radius: 0 2px 2px 0; + border-radius: 0 4px 4px 0; padding: 0 2px; } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 05f5ee69e13..33bac6a0273 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -204,10 +204,6 @@ export class InlineChatController implements IEditorContribution { this._store.add(result); - this._store.add(result.widget.chatWidget.input.onDidChangeCurrentLanguageModel(newModel => { - InlineChatController._selectVendorDefaultLanguageModel = Boolean(newModel.metadata.isDefaultForLocation[location.location]); - })); - result.domNode.classList.add('inline-chat-2'); return result; @@ -434,7 +430,7 @@ export class InlineChatController implements IEditorContribution { this._isActiveController.set(true, undefined); const session = this._inlineChatSessionService.createSession(this._editor); - + const store = new DisposableStore(); // fallback to the default model of the selected vendor unless an explicit selection was made for the session // or unless the user has chosen to persist their model choice @@ -451,6 +447,10 @@ export class InlineChatController implements IEditorContribution { } } + store.add(this._zone.value.widget.chatWidget.input.onDidChangeCurrentLanguageModel(newModel => { + InlineChatController._selectVendorDefaultLanguageModel = Boolean(newModel.metadata.isDefaultForLocation[session.chatModel.initialLocation]); + })); + // ADD diagnostics const entries: IChatRequestVariableEntry[] = []; for (const [range, marker] of this._markerDecorationsService.getLiveMarkers(uri)) { @@ -502,20 +502,25 @@ export class InlineChatController implements IEditorContribution { } } - if (!arg?.resolveOnResponse) { - // DEFAULT: wait for the session to be accepted or rejected - await Event.toPromise(session.editingSession.onDidDispose); - const rejected = session.editingSession.getEntry(uri)?.state.get() === ModifiedFileEntryState.Rejected; - return !rejected; + try { + if (!arg?.resolveOnResponse) { + // DEFAULT: wait for the session to be accepted or rejected + await Event.toPromise(session.editingSession.onDidDispose); + const rejected = session.editingSession.getEntry(uri)?.state.get() === ModifiedFileEntryState.Rejected; + return !rejected; - } else { - // resolveOnResponse: ONLY wait for the file to be modified - const modifiedObs = derived(r => { - const entry = session.editingSession.readEntry(uri, r); - return entry?.state.read(r) === ModifiedFileEntryState.Modified && !entry?.isCurrentlyBeingModifiedBy.read(r); - }); - await waitForState(modifiedObs, state => state === true); - return true; + } else { + // resolveOnResponse: ONLY wait for the file to be modified + const modifiedObs = derived(r => { + const entry = session.editingSession.readEntry(uri, r); + return entry?.state.read(r) === ModifiedFileEntryState.Modified && !entry?.isCurrentlyBeingModifiedBy.read(r); + }); + await waitForState(modifiedObs, state => state === true); + return true; + } + + } finally { + store.dispose(); } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css index fcdb9a19960..e53c6ec761b 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css @@ -172,7 +172,7 @@ max-width: 66%; } -.monaco-workbench .inline-chat .chat-widget .interactive-session .chat-input-toolbars > .chat-execute-toolbar .chat-modelPicker-item { +.monaco-workbench .inline-chat .chat-widget .interactive-session .chat-input-toolbars > .chat-execute-toolbar .chat-input-picker-item { min-width: 40px; max-width: 132px; } diff --git a/src/vs/workbench/contrib/issue/browser/media/issueReporter.css b/src/vs/workbench/contrib/issue/browser/media/issueReporter.css index bc997b189eb..d24fe259aa8 100644 --- a/src/vs/workbench/contrib/issue/browser/media/issueReporter.css +++ b/src/vs/workbench/contrib/issue/browser/media/issueReporter.css @@ -80,9 +80,7 @@ .issue-reporter-body .monaco-text-button { display: block; width: auto; - padding: 4px 10px; align-self: flex-end; - font-size: 13px; } .issue-reporter-body .monaco-button-dropdown { @@ -603,10 +601,6 @@ body.issue-reporter-body { line-height: 15px; /* approximate button height for vertical centering */ } -.issue-reporter-body .internal-elements .monaco-text-button { - font-size: 10px; - padding: 2px 8px; -} .issue-reporter-body .internal-elements #show-private-repo-name { align-self: flex-end; diff --git a/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css b/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css index 04d78eea54a..ad55ce23048 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css +++ b/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css @@ -181,7 +181,6 @@ .settings-editor > .settings-body .settings-tree-container .setting-item.setting-item-list .monaco-text-button { width: initial; white-space: nowrap; - padding: 4px 14px; } .settings-editor > .settings-body .settings-tree-container .setting-item.setting-item-list .setting-item-control.setting-list-hide-add-button .setting-list-new-row { diff --git a/src/vs/workbench/contrib/scm/browser/media/scm.css b/src/vs/workbench/contrib/scm/browser/media/scm.css index fc5382a2164..20c78c396f1 100644 --- a/src/vs/workbench/contrib/scm/browser/media/scm.css +++ b/src/vs/workbench/contrib/scm/browser/media/scm.css @@ -378,7 +378,11 @@ } .scm-view .scm-editor-container .monaco-editor { - border-radius: 2px; + border-radius: 4px; +} + +.scm-view .scm-editor-container .monaco-editor .overflow-guard { + border-radius: 4px; } .scm-view .scm-editor { @@ -389,7 +393,7 @@ box-sizing: border-box; border: 1px solid var(--vscode-input-border, transparent); background-color: var(--vscode-input-background); - border-radius: 2px; + border-radius: 4px; } .scm-view .button-container { diff --git a/src/vs/workbench/contrib/scm/common/scmService.ts b/src/vs/workbench/contrib/scm/common/scmService.ts index a0f2ebc47da..8b6e1a13559 100644 --- a/src/vs/workbench/contrib/scm/common/scmService.ts +++ b/src/vs/workbench/contrib/scm/common/scmService.ts @@ -437,6 +437,10 @@ export class SCMService implements ISCMService { let bestMatchLength = Number.POSITIVE_INFINITY; for (const repository of this.repositories) { + if (repository.provider.isHidden === true) { + continue; + } + const root = repository.provider.rootUri; if (!root) { diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 4af69123f2a..3ea882a7f34 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -38,16 +38,65 @@ function getChatTerminalBackgroundColor(theme: IColorTheme, contextKeyService: I return theme.getColor(isInEditor ? editorBackground : PANEL_BACKGROUND); } +/** + * Computes the maximum column width of content in a terminal buffer. + * Iterates through each line and finds the rightmost non-empty cell. + * + * @param buffer The buffer to measure + * @param cols The terminal column count (used to clamp line length) + * @returns The maximum column width (number of columns used), or 0 if all lines are empty + */ +export function computeMaxBufferColumnWidth(buffer: { readonly length: number; getLine(y: number): { readonly length: number; getCell(x: number): { getChars(): string } | undefined } | undefined }, cols: number): number { + let maxWidth = 0; + + for (let y = 0; y < buffer.length; y++) { + const line = buffer.getLine(y); + if (!line) { + continue; + } + + // Find the last non-empty cell by iterating backwards + const lineLength = Math.min(line.length, cols); + for (let x = lineLength - 1; x >= 0; x--) { + if (line.getCell(x)?.getChars()) { + maxWidth = Math.max(maxWidth, x + 1); + break; + } + } + } + + return maxWidth; +} + +export interface IDetachedTerminalCommandMirrorRenderResult { + lineCount?: number; + maxColumnWidth?: number; +} + interface IDetachedTerminalCommandMirror { attach(container: HTMLElement): Promise; - renderCommand(): Promise<{ lineCount?: number } | undefined>; - onDidUpdate: Event; + renderCommand(): Promise; + onDidUpdate: Event; onDidInput: Event; } const enum ChatTerminalMirrorMetrics { MirrorRowCount = 10, - MirrorColCountFallback = 80 + MirrorColCountFallback = 80, + /** + * Maximum number of lines for which we compute the max column width. + * Computing max column width iterates the entire buffer, so we skip it + * for large outputs to avoid performance issues. + */ + MaxLinesForColumnWidthComputation = 100 +} + +/** + * Computes the line count for terminal output between start and end lines. + * The end line is exclusive (points to the line after output ends). + */ +function computeOutputLineCount(startLine: number, endLine: number): number { + return Math.max(endLine - startLine, 0); } export async function getCommandOutputSnapshot( @@ -94,13 +143,13 @@ export async function getCommandOutputSnapshot( return { text: '', lineCount: 0 }; } const endLine = endMarker.line; - const lineCount = Math.max(endLine - startLine + 1, 0); + const lineCount = computeOutputLineCount(startLine, endLine); return { text, lineCount }; } const startLine = executedMarker.line; const endLine = endMarker.line; - const lineCount = Math.max(endLine - startLine + 1, 0); + const lineCount = computeOutputLineCount(startLine, endLine); let text: string | undefined; try { @@ -151,13 +200,14 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach private _detachedTerminalPromise: Promise | undefined; private _attachedContainer: HTMLElement | undefined; private readonly _streamingDisposables = this._register(new DisposableStore()); - private readonly _onDidUpdateEmitter = this._register(new Emitter()); - public readonly onDidUpdate: Event = this._onDidUpdateEmitter.event; + private readonly _onDidUpdateEmitter = this._register(new Emitter()); + public readonly onDidUpdate: Event = this._onDidUpdateEmitter.event; private readonly _onDidInputEmitter = this._register(new Emitter()); public readonly onDidInput: Event = this._onDidInputEmitter.event; private _lastVT = ''; private _lineCount = 0; + private _maxColumnWidth = 0; private _lastUpToDateCursorY: number | undefined; private _lowestDirtyCursorY: number | undefined; private _flushPromise: Promise | undefined; @@ -200,7 +250,7 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach } } - async renderCommand(): Promise<{ lineCount?: number } | undefined> { + async renderCommand(): Promise { if (this._store.isDisposed) { return undefined; } @@ -258,8 +308,13 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach } this._lineCount = this._getRenderedLineCount(); + // Only compute max column width after the command finishes and for small outputs + const commandFinished = this._command.endMarker && !this._command.endMarker.isDisposed; + if (commandFinished && this._lineCount <= ChatTerminalMirrorMetrics.MaxLinesForColumnWidthComputation) { + this._maxColumnWidth = this._computeMaxColumnWidth(); + } - return { lineCount: this._lineCount }; + return { lineCount: this._lineCount, maxColumnWidth: this._maxColumnWidth }; } private async _getCommandOutputAsVT(source: XtermTerminal): Promise<{ text: string } | undefined> { @@ -289,7 +344,7 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach if (this._command.executedMarker && endMarker && !endMarker.isDisposed) { const startLine = this._command.executedMarker.line; const endLine = endMarker.line; - return Math.max(endLine - startLine, 0); + return computeOutputLineCount(startLine, endLine); } // During streaming (no end marker), calculate from the source terminal buffer @@ -297,12 +352,20 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach if (executedMarker && this._sourceRaw) { const buffer = this._sourceRaw.buffer.active; const currentLine = buffer.baseY + buffer.cursorY; - return Math.max(currentLine - executedMarker.line, 0); + return computeOutputLineCount(executedMarker.line, currentLine); } return this._lineCount; } + private _computeMaxColumnWidth(): number { + const detached = this._detachedTerminal; + if (!detached) { + return 0; + } + return computeMaxBufferColumnWidth(detached.xterm.buffer.active, detached.xterm.cols); + } + private async _getOrCreateTerminal(): Promise { if (this._detachedTerminal) { return this._detachedTerminal; @@ -460,11 +523,17 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach this._lastVT = vt.text; this._lineCount = this._getRenderedLineCount(); this._lastUpToDateCursorY = currentCursor; - this._onDidUpdateEmitter.fire(this._lineCount); - if (this._command.endMarker && !this._command.endMarker.isDisposed) { + const commandFinished = this._command.endMarker && !this._command.endMarker.isDisposed; + if (commandFinished) { + // Only compute max column width after the command finishes and for small outputs + if (this._lineCount <= ChatTerminalMirrorMetrics.MaxLinesForColumnWidthComputation) { + this._maxColumnWidth = this._computeMaxColumnWidth(); + } this._stopStreaming(); } + + this._onDidUpdateEmitter.fire({ lineCount: this._lineCount, maxColumnWidth: this._maxColumnWidth }); } private _getAbsoluteCursorY(raw: RawXtermTerminal): number { @@ -484,6 +553,7 @@ export class DetachedTerminalSnapshotMirror extends Disposable { private _container: HTMLElement | undefined; private _dirty = true; private _lastRenderedLineCount: number | undefined; + private _lastRenderedMaxColumnWidth: number | undefined; constructor( output: IChatTerminalToolInvocationData['terminalCommandOutput'] | undefined, @@ -534,13 +604,13 @@ export class DetachedTerminalSnapshotMirror extends Disposable { this._applyTheme(container); } - public async render(): Promise<{ lineCount?: number } | undefined> { + public async render(): Promise<{ lineCount?: number; maxColumnWidth?: number } | undefined> { const output = this._output; if (!output) { return undefined; } if (!this._dirty) { - return { lineCount: this._lastRenderedLineCount ?? output.lineCount }; + return { lineCount: this._lastRenderedLineCount ?? output.lineCount, maxColumnWidth: this._lastRenderedMaxColumnWidth }; } const terminal = await this._getTerminal(); if (this._container) { @@ -551,12 +621,21 @@ export class DetachedTerminalSnapshotMirror extends Disposable { if (!text) { this._dirty = false; this._lastRenderedLineCount = lineCount; - return { lineCount: 0 }; + this._lastRenderedMaxColumnWidth = 0; + return { lineCount: 0, maxColumnWidth: 0 }; } await new Promise(resolve => terminal.xterm.write(text, resolve)); this._dirty = false; this._lastRenderedLineCount = lineCount; - return { lineCount }; + // Only compute max column width for small outputs to avoid performance issues + if (this._shouldComputeMaxColumnWidth(lineCount)) { + this._lastRenderedMaxColumnWidth = this._computeMaxColumnWidth(terminal); + } + return { lineCount, maxColumnWidth: this._lastRenderedMaxColumnWidth }; + } + + private _computeMaxColumnWidth(terminal: IDetachedTerminalInstance): number { + return computeMaxBufferColumnWidth(terminal.xterm.buffer.active, terminal.xterm.cols); } private _estimateLineCount(text: string): number { @@ -569,6 +648,10 @@ export class DetachedTerminalSnapshotMirror extends Disposable { return Math.max(count, 1); } + private _shouldComputeMaxColumnWidth(lineCount: number): boolean { + return lineCount <= ChatTerminalMirrorMetrics.MaxLinesForColumnWidthComputation; + } + private _applyTheme(container: HTMLElement): void { const theme = this._getTheme(); if (!theme) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 4444764f1a7..d5ace8862c6 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1493,6 +1493,11 @@ export interface IDetachedXtermTerminal extends IXtermTerminal { * Access to the terminal buffer for reading cursor position and content. */ readonly buffer: IBufferSet; + + /** + * The number of columns in the terminal. + */ + readonly cols: number; } export interface IInternalXtermTerminal { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts index 7c0bc25ca41..7e6e39b2f31 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts @@ -48,8 +48,8 @@ export class TerminalTabsChatEntry extends Disposable { this._deleteButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.trashcan)); this._deleteButton.tabIndex = 0; this._deleteButton.setAttribute('role', 'button'); - this._deleteButton.setAttribute('aria-label', localize('terminal.tabs.chatEntryDeleteAriaLabel', "Delete all hidden chat terminals")); - this._deleteButton.setAttribute('title', localize('terminal.tabs.chatEntryDeleteTooltip', "Delete all hidden chat terminals")); + this._deleteButton.setAttribute('aria-label', localize('terminal.tabs.chatEntryDeleteAriaLabel', "Kill all hidden chat terminals")); + this._deleteButton.setAttribute('title', localize('terminal.tabs.chatEntryDeleteTooltip', "Kill all hidden chat terminals")); const runChatTerminalsCommand = () => { void this._commandService.executeCommand('workbench.action.terminal.chat.viewHiddenChatTerminals'); diff --git a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts index 534c69a1966..176f0378394 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts @@ -32,12 +32,18 @@ export interface IXtermCore { }; } +export interface IBufferLine { + readonly length: number; + getCell(x: number): { getChars(): string } | undefined; + translateToString(trimRight?: boolean): string; +} + export interface IBufferSet { readonly active: { readonly baseY: number; readonly cursorY: number; readonly cursorX: number; readonly length: number; - getLine(y: number): { translateToString(trimRight?: boolean): string } | undefined; + getLine(y: number): IBufferLine | undefined; }; } diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 282a8e3f303..78849f9d992 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -108,6 +108,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach private _progressState: IProgressState = { state: 0, value: 0 }; get progressState(): IProgressState { return this._progressState; } get buffer() { return this.raw.buffer; } + get cols() { return this.raw.cols; } // Always on addons private _markNavigationAddon: MarkNavigationAddon; diff --git a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts index d66eb7fa7af..e5fd2c6a08b 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts @@ -14,6 +14,7 @@ import { TerminalCapabilityStore } from '../../../../../platform/terminal/common import { XtermTerminal } from '../../browser/xterm/xtermTerminal.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { TestXtermAddonImporter } from './xterm/xtermTestUtils.js'; +import { computeMaxBufferColumnWidth } from '../../browser/chatTerminalCommandMirror.js'; const defaultTerminalConfig = { fontFamily: 'monospace', @@ -231,4 +232,147 @@ suite('Workbench - ChatTerminalCommandMirror', () => { strictEqual(getBufferText(mirror), getBufferText(freshMirror)); }); }); + + suite('computeMaxBufferColumnWidth', () => { + + /** + * Creates a mock buffer with the given lines. + * Each string represents a line; characters are cells, spaces are empty cells. + */ + function createMockBuffer(lines: string[], cols: number = 80): { readonly length: number; getLine(y: number): { readonly length: number; getCell(x: number): { getChars(): string } | undefined } | undefined } { + return { + length: lines.length, + getLine(y: number) { + if (y < 0 || y >= lines.length) { + return undefined; + } + const lineContent = lines[y]; + return { + length: Math.max(lineContent.length, cols), + getCell(x: number) { + if (x < 0 || x >= lineContent.length) { + return { getChars: () => '' }; + } + const char = lineContent[x]; + return { getChars: () => char === ' ' ? '' : char }; + } + }; + } + }; + } + + test('returns 0 for empty buffer', () => { + const buffer = createMockBuffer([]); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 0); + }); + + test('returns 0 for buffer with only empty lines', () => { + const buffer = createMockBuffer(['', '', '']); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 0); + }); + + test('returns correct width for single character', () => { + const buffer = createMockBuffer(['X']); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 1); + }); + + test('returns correct width for single line', () => { + const buffer = createMockBuffer(['hello']); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 5); + }); + + test('returns max width across multiple lines', () => { + const buffer = createMockBuffer([ + 'short', + 'much longer line', + 'mid' + ]); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 16); + }); + + test('ignores trailing spaces (empty cells)', () => { + // Spaces are treated as empty cells in our mock + const buffer = createMockBuffer(['hello ']); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 5); + }); + + test('respects cols parameter to clamp line length', () => { + const buffer = createMockBuffer(['abcdefghijklmnop']); // 16 chars, no spaces + strictEqual(computeMaxBufferColumnWidth(buffer, 10), 10); + }); + + test('handles lines with content at different positions', () => { + const buffer = createMockBuffer([ + 'a', // width 1 + ' b', // content at col 2, but width is 3 + ' c', // content at col 4, but width is 5 + ' d' // content at col 6, width is 7 + ]); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 7); + }); + + test('handles buffer with undefined lines gracefully', () => { + const buffer = { + length: 3, + getLine(y: number) { + if (y === 1) { + return undefined; + } + return { + length: 5, + getCell(x: number) { + return x < 3 ? { getChars: () => 'X' } : { getChars: () => '' }; + } + }; + } + }; + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 3); + }); + + test('handles line with all empty cells', () => { + const buffer = createMockBuffer([' ']); // all spaces = empty cells + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 0); + }); + + test('handles mixed empty and non-empty lines', () => { + const buffer = createMockBuffer([ + '', + 'content', + '', + 'more', + '' + ]); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 7); + }); + + test('returns correct width for line exactly at 80 cols', () => { + const line80 = 'a'.repeat(80); + const buffer = createMockBuffer([line80]); + strictEqual(computeMaxBufferColumnWidth(buffer, 80), 80); + }); + + test('returns correct width for line exceeding 80 cols with higher cols value', () => { + const line100 = 'a'.repeat(100); + const buffer = createMockBuffer([line100], 120); + strictEqual(computeMaxBufferColumnWidth(buffer, 120), 100); + }); + + test('handles wide terminal with long content', () => { + const buffer = createMockBuffer([ + 'short', + 'a'.repeat(150), + 'medium content here' + ], 200); + strictEqual(computeMaxBufferColumnWidth(buffer, 200), 150); + }); + + test('max of multiple lines where longest exceeds default cols', () => { + const buffer = createMockBuffer([ + 'a'.repeat(50), + 'b'.repeat(120), + 'c'.repeat(90) + ], 150); + strictEqual(computeMaxBufferColumnWidth(buffer, 150), 120); + }); + }); }); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/recordings/rich/macos_zsh_omz_echo_3_times.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/recordings/rich/macos_zsh_omz_echo_3_times.ts index 984e205d9a5..fb03eb65f07 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/recordings/rich/macos_zsh_omz_echo_3_times.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/recordings/rich/macos_zsh_omz_echo_3_times.ts @@ -137,10 +137,6 @@ export const events = [ "type": "output", "data": "" }, - { - "type": "promptInputChange", - "data": "echo a" - }, { "type": "output", "data": "\u001b]633;P;Cwd=/Users/tyriar/playground/test1\u0007\u001b]633;EnvSingleStart;0;448d50d0-70fe-4ab5-842e-132f3b1c159a;\u0007\u001b]633;EnvSingleEnd;448d50d0-70fe-4ab5-842e-132f3b1c159a;\u0007\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\u001b]633;A\u0007tyriar@Mac test1 % \u001b]633;B\u0007\u001b[K\u001b[?2004h" @@ -245,10 +241,6 @@ export const events = [ "type": "output", "data": "" }, - { - "type": "promptInputChange", - "data": "echo b" - }, { "type": "output", "data": "\u001b]633;P;Cwd=/Users/tyriar/playground/test1\u0007\u001b]633;EnvSingleStart;0;448d50d0-70fe-4ab5-842e-132f3b1c159a;\u0007\u001b]633;EnvSingleEnd;448d50d0-70fe-4ab5-842e-132f3b1c159a;\u0007\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\u001b]633;A\u0007tyriar@Mac test1 % \u001b]633;B\u0007\u001b[K\u001b[?2004h" @@ -357,10 +349,6 @@ export const events = [ "type": "output", "data": "" }, - { - "type": "promptInputChange", - "data": "echo c" - }, { "type": "output", "data": "\u001b]633;P;Cwd=/Users/tyriar/playground/test1\u0007\u001b]633;EnvSingleStart;0;448d50d0-70fe-4ab5-842e-132f3b1c159a;\u0007\u001b]633;EnvSingleEnd;448d50d0-70fe-4ab5-842e-132f3b1c159a;\u0007\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\u001b]633;A\u0007tyriar@Mac test1 % \u001b]633;B\u0007\u001b[K\u001b[?2004h" diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts index 3b847a23f87..e9f18f15afa 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts @@ -49,6 +49,22 @@ class ShellIntegrationTimeoutMigrationContribution extends Disposable implements } registerWorkbenchContribution2(ShellIntegrationTimeoutMigrationContribution.ID, ShellIntegrationTimeoutMigrationContribution, WorkbenchPhase.Eventually); +class OutputLocationMigrationContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'terminal.outputLocationMigration'; + + constructor( + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + // Migrate legacy 'none' value to 'chat' + const currentValue = configurationService.getValue(TerminalChatAgentToolsSettingId.OutputLocation); + if (currentValue === 'none') { + configurationService.updateValue(TerminalChatAgentToolsSettingId.OutputLocation, 'chat'); + } + } +} +registerWorkbenchContribution2(OutputLocationMigrationContribution.ID, OutputLocationMigrationContribution, WorkbenchPhase.Eventually); + class ChatAgentToolsContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'terminal.chatAgentTools'; 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 eb1d8c9a3b8..6de2cb80cc3 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 @@ -26,9 +26,9 @@ import { IConfirmationPrompt, IExecution, IPollingResult, OutputMonitorState, Po import { getTextResponseFromStream } from './utils.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js'; -import { ILogService } from '../../../../../../../platform/log/common/log.js'; import { ITerminalService } from '../../../../../terminal/browser/terminal.js'; import { LocalChatSessionUri } from '../../../../../chat/common/model/chatUri.js'; +import { ITerminalLogService } from '../../../../../../../platform/terminal/common/terminal.js'; export interface IOutputMonitor extends Disposable { readonly pollingResult: IPollingResult & { pollDurationMs: number } | undefined; @@ -61,6 +61,13 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private _pollingResult: IPollingResult & { pollDurationMs: number } | undefined; get pollingResult(): IPollingResult & { pollDurationMs: number } | undefined { return this._pollingResult; } + /** + * Flag to track if user has inputted since idle was detected. + * This is used to skip showing prompts if the user already provided input. + */ + private _userInputtedSinceIdleDetected = false; + private _userInputListener: IDisposable | undefined; + private readonly _outputMonitorTelemetryCounters: IOutputMonitorTelemetryCounters = { inputToolManualAcceptCount: 0, inputToolManualRejectCount: 0, @@ -87,7 +94,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { @IChatService private readonly _chatService: IChatService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @IConfigurationService private readonly _configurationService: IConfigurationService, - @ILogService private readonly _logService: ILogService, + @ITerminalLogService private readonly _logService: ITerminalLogService, @ITerminalService private readonly _terminalService: ITerminalService, ) { super(); @@ -121,6 +128,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const shouldContinuePolling = await this._handleTimeoutState(command, invocationContext, extended, token); if (shouldContinuePolling) { extended = true; + this._state = OutputMonitorState.PollingForIdle; continue; } else { this._promptPart?.hide(); @@ -159,6 +167,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { pollDurationMs: Date.now() - pollStartTime, resources }; + // Clean up idle input listener if still active + this._userInputListener?.dispose(); + this._userInputListener = undefined; const promptPart = this._promptPart; this._promptPart = undefined; if (promptPart) { @@ -180,9 +191,28 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return { shouldContinuePollling: false, output }; } + // Check if user already inputted since idle was detected (before we even got here) + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + const confirmationPrompt = await this._determineUserInputOptions(this._execution, token); + // Check again after the async LLM call - user may have inputted while we were analyzing + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + if (confirmationPrompt?.detectedRequestForFreeFormInput) { + // Check again right before showing prompt + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + // Clean up the input listener now - the prompt will set up its own + this._cleanupIdleInputListener(); this._outputMonitorTelemetryCounters.inputToolFreeFormInputShownCount++; const receivedTerminalInput = await this._requestFreeFormTerminalInput(token, this._execution, confirmationPrompt); if (receivedTerminalInput) { @@ -200,8 +230,16 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const suggestedOptionResult = await this._selectAndHandleOption(confirmationPrompt, token); if (suggestedOptionResult?.sentToTerminal) { // Continue polling as we sent the input + this._cleanupIdleInputListener(); return { shouldContinuePollling: true }; } + // Check again after LLM call - user may have inputted while we were selecting option + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + // Clean up the input listener now - the prompt will set up its own + this._cleanupIdleInputListener(); const confirmed = await this._confirmRunInTerminal(token, suggestedOptionResult?.suggestedOption ?? confirmationPrompt.options[0], this._execution, confirmationPrompt); if (confirmed) { // Continue polling as we sent the input @@ -213,6 +251,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } } + // Clean up input listener before custom poll/error assessment + this._cleanupIdleInputListener(); + // Let custom poller override if provided const custom = await this._pollFn?.(this._execution, token, this._taskService); const resources = custom?.resources; @@ -220,63 +261,15 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return { resources, modelOutputEvalResponse, shouldContinuePollling: false, output: custom?.output ?? output }; } - private async _handleTimeoutState(command: string, invocationContext: IToolInvocationContext | undefined, extended: boolean, token: CancellationToken): Promise { - let continuePollingPart: ChatElicitationRequestPart | undefined; - if (extended) { + private async _handleTimeoutState(_command: string, _invocationContext: IToolInvocationContext | undefined, _extended: boolean, _token: CancellationToken): Promise { + // Stop after extended polling (2 minutes) without notifying user + if (_extended) { + this._logService.info('OutputMonitor: Extended polling timeout reached after 2 minutes'); this._state = OutputMonitorState.Cancelled; return false; } - extended = true; - - const { promise: p, part } = await this._promptForMorePolling(command, token, invocationContext); - let continuePollingDecisionP: Promise | undefined = p; - continuePollingPart = part; - - // Start another polling pass and race it against the user's decision - const nextPollP = this._waitForIdle(this._execution, extended, token) - .catch((): IPollingResult => ({ - state: OutputMonitorState.Cancelled, - output: this._execution.getOutput(), - modelOutputEvalResponse: 'Cancelled' - })); - - const race = await Promise.race([ - continuePollingDecisionP.then(v => ({ kind: 'decision' as const, v })), - nextPollP.then(r => ({ kind: 'poll' as const, r })) - ]); - - if (race.kind === 'decision') { - try { continuePollingPart?.hide(); } catch { /* noop */ } - continuePollingPart = undefined; - - // User explicitly declined to keep waiting, so finish with the timed-out result - if (race.v === false) { - this._state = OutputMonitorState.Cancelled; - return false; - } - - // User accepted; keep polling (the loop iterates again). - // Clear the decision so we don't race on a resolved promise. - continuePollingDecisionP = undefined; - return true; - } else { - // A background poll completed while waiting for a decision - const r = race.r; - // r can be either an OutputMonitorState or an IPollingResult object (from catch) - const state = (typeof r === 'object' && r !== null) ? r.state : r; - - if (state === OutputMonitorState.Idle || state === OutputMonitorState.Cancelled || state === OutputMonitorState.Timeout) { - try { continuePollingPart?.hide(); } catch { /* noop */ } - continuePollingPart = undefined; - continuePollingDecisionP = undefined; - this._promptPart = undefined; - - return false; - } - - // Still timing out; loop and race again with the same prompt. - return true; - } + // Continue polling with exponential backoff + return true; } /** @@ -310,12 +303,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (detectsNonInteractiveHelpPattern(currentOutput)) { this._state = OutputMonitorState.Idle; + this._setupIdleInputListener(); return this._state; } const promptResult = detectsInputRequiredPattern(currentOutput); if (promptResult) { this._state = OutputMonitorState.Idle; + this._setupIdleInputListener(); return this._state; } @@ -331,6 +326,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._logService.trace(`OutputMonitor: waitForIdle check: waited=${waited}ms, recentlyIdle=${recentlyIdle}, isActive=${isActive}`); if (recentlyIdle && isActive !== true) { this._state = OutputMonitorState.Idle; + this._setupIdleInputListener(); return this._state; } } @@ -345,26 +341,31 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return OutputMonitorState.Timeout; } - private async _promptForMorePolling(command: string, token: CancellationToken, context: IToolInvocationContext | undefined): Promise<{ promise: Promise; part?: ChatElicitationRequestPart }> { - if (token.isCancellationRequested || this._state === OutputMonitorState.Cancelled) { - return { promise: Promise.resolve(false) }; - } - const result = this._createElicitationPart( - token, - context?.sessionId, - new MarkdownString(localize('poll.terminal.waiting', "Continue waiting for `{0}`?", command)), - new MarkdownString(localize('poll.terminal.polling', "This will continue to poll for output to determine when the terminal becomes idle for up to 2 minutes.")), - '', - localize('poll.terminal.accept', 'Yes'), - localize('poll.terminal.reject', 'No'), - async () => true, - async () => { this._state = OutputMonitorState.Cancelled; return false; } - ); + /** + * Sets up a listener for user input that triggers immediately when idle is detected. + * This ensures we catch any input that happens between idle detection and prompt creation. + */ + private _setupIdleInputListener(): void { + // Clean up any existing listener + this._userInputListener?.dispose(); + this._userInputtedSinceIdleDetected = false; - return { promise: result.promise.then(p => p ?? false), part: result.part }; + // Set up new listener + this._userInputListener = this._execution.instance.onDidInputData((data) => { + if (data === '\r' || data === '\n' || data === '\r\n') { + this._userInputtedSinceIdleDetected = true; + } + }); } - + /** + * Cleans up the idle input listener and resets the flag. + */ + private _cleanupIdleInputListener(): void { + this._userInputtedSinceIdleDetected = false; + this._userInputListener?.dispose(); + this._userInputListener = undefined; + } private async _assessOutputForErrors(buffer: string, token: CancellationToken): Promise { const model = await this._getLanguageModel(); @@ -404,7 +405,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } const promptText = - `Analyze the following terminal output. If it contains a prompt requesting user input (such as a confirmation, selection, or yes/no question) and that prompt has NOT already been answered, extract the prompt text. The prompt may ask to choose from a set. If so, extract the possible options as a JSON object with keys 'prompt', 'options' (an array of strings or an object with option to description mappings), and 'freeFormInput': false. If no options are provided, and free form input is requested, for example: Password:, return the word freeFormInput. For example, if the options are "[Y] Yes [A] Yes to All [N] No [L] No to All [C] Cancel", the option to description mappings would be {"Y": "Yes", "A": "Yes to All", "N": "No", "L": "No to All", "C": "Cancel"}. If there is no such prompt, return null. If the option is ambiguous, return null. + `Analyze the following terminal output. If it contains a prompt requesting user input (such as a confirmation, selection, or yes/no question) that appears at the VERY END of the output and has NOT already been answered (i.e., there is no user response or subsequent output after the prompt), extract the prompt text. IMPORTANT: Only detect prompts that are at the end of the output with no content following them - if there is any output after the prompt, the prompt has already been answered and you should return null. The prompt may ask to choose from a set. If so, extract the possible options as a JSON object with keys 'prompt', 'options' (an array of strings or an object with option to description mappings), and 'freeFormInput': false. If no options are provided, and free form input is requested, for example: Password:, return the word freeFormInput. For example, if the options are "[Y] Yes [A] Yes to All [N] No [L] No to All [C] Cancel", the option to description mappings would be {"Y": "Yes", "A": "Yes to All", "N": "No", "L": "No to All", "C": "Cancel"}. If there is no such prompt, return null. If the option is ambiguous, return null. Examples: 1. Output: "Do you want to overwrite? (y/n)" Response: {"prompt": "Do you want to overwrite?", "options": ["y", "n"], "freeFormInput": false} @@ -434,6 +435,10 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { Response: {"prompt": "Password:", "freeFormInput": true, "options": []} 10. Output: "press ctrl-c to detach, ctrl-d to kill" Response: null + 11. Output: "Continue (y/n)? y" + Response: null (the prompt was already answered with 'y') + 12. Output: "Do you want to proceed? (yes/no)\nyes\nProceeding with operation..." + Response: null (the prompt was already answered and there is subsequent output) Alternatively, the prompt may request free form input, for example: 1. Output: "Enter your username:" diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/types.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/types.ts index d4a92506c1a..27dde5dba9c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/types.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/types.ts @@ -53,6 +53,6 @@ export const enum PollingConsts { MinPollingDuration = 500, FirstPollingMaxDuration = 20000, // 20 seconds ExtendedPollingMaxDuration = 120000, // 2 minutes - MaxPollingIntervalDuration = 2000, // 2 seconds + MaxPollingIntervalDuration = 10000, // 10 seconds - grows via exponential backoff MaxRecursionCount = 5 } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/createAndRunTaskTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/createAndRunTaskTool.ts index b5ec1c14644..4918ac03d10 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/createAndRunTaskTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/createAndRunTaskTool.ts @@ -157,8 +157,8 @@ export class CreateAndRunTaskTool implements IToolImpl { const allTasks = await this._tasksService.tasks(); if (allTasks?.find(t => t._label === task.label)) { return { - invocationMessage: new MarkdownString(localize('taskExists', 'Task `{0}` already exists.', task.label)), - pastTenseMessage: new MarkdownString(localize('taskExistsPast', 'Task `{0}` already exists.', task.label)), + invocationMessage: new MarkdownString(localize('taskExists', 'Task \`{0}\` already exists.', task.label)), + pastTenseMessage: new MarkdownString(localize('taskExistsPast', 'Task \`{0}\` already exists.', task.label)), confirmationMessages: undefined }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/getTaskOutputTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/getTaskOutputTool.ts index 5d0a0589c25..dc3f4eaa916 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/getTaskOutputTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/getTaskOutputTool.ts @@ -65,17 +65,17 @@ export class GetTaskOutputTool extends Disposable implements IToolImpl { const taskDefinition = getTaskDefinition(args.id); const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService, true); if (!task) { - return { invocationMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id)) }; + return { invocationMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: \`{0}\`', args.id)) }; } const taskLabel = task._label; const activeTasks = await this._tasksService.getActiveTasks(); if (activeTasks.includes(task)) { - return { invocationMessage: new MarkdownString(localize('copilotChat.taskAlreadyRunning', 'The task `{0}` is already running.', taskLabel)) }; + return { invocationMessage: new MarkdownString(localize('copilotChat.taskAlreadyRunning', 'The task \`{0}\` is already running.', taskLabel)) }; } return { - invocationMessage: new MarkdownString(localize('copilotChat.checkingTerminalOutput', 'Checking output for task `{0}`', taskLabel)), - pastTenseMessage: new MarkdownString(localize('copilotChat.checkedTerminalOutput', 'Checked output for task `{0}`', taskLabel)), + invocationMessage: new MarkdownString(localize('copilotChat.checkingTerminalOutput', 'Checking output for task \`{0}\`', taskLabel)), + pastTenseMessage: new MarkdownString(localize('copilotChat.checkedTerminalOutput', 'Checked output for task \`{0}\`', taskLabel)), }; } @@ -84,7 +84,7 @@ export class GetTaskOutputTool extends Disposable implements IToolImpl { const taskDefinition = getTaskDefinition(args.id); const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService, true); if (!task) { - return { content: [{ kind: 'text', value: `Task not found: ${args.id}` }], toolResultMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id)) }; + return { content: [{ kind: 'text', value: `Task not found: ${args.id}` }], toolResultMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: \`{0}\`', args.id)) }; } const dependencyTasks = await resolveDependencyTasks(task, args.workspaceFolder, this._configurationService, this._tasksService); @@ -92,7 +92,7 @@ export class GetTaskOutputTool extends Disposable implements IToolImpl { const taskLabel = task._label; const terminals = resources?.map(resource => this._terminalService.instances.find(t => t.resource.path === resource?.path && t.resource.scheme === resource.scheme)).filter(t => !!t); if (!terminals || terminals.length === 0) { - return { content: [{ kind: 'text', value: `Terminal not found for task ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('copilotChat.terminalNotFound', 'Terminal not found for task `{0}`', taskLabel)) }; + return { content: [{ kind: 'text', value: `Terminal not found for task ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('copilotChat.terminalNotFound', 'Terminal not found for task \`{0}\`', taskLabel)) }; } const store = new DisposableStore(); const terminalResults = await collectTerminalResults( diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/runTaskTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/runTaskTool.ts index f92fe12ab6f..d27210e3022 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/runTaskTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/task/runTaskTool.ts @@ -45,12 +45,12 @@ export class RunTaskTool implements IToolImpl { const taskDefinition = getTaskDefinition(args.id); const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService, true); if (!task) { - return { content: [{ kind: 'text', value: `Task not found: ${args.id}` }], toolResultMessage: new MarkdownString(localize('chat.taskNotFound', 'Task not found: `{0}`', args.id)) }; + return { content: [{ kind: 'text', value: `Task not found: ${args.id}` }], toolResultMessage: new MarkdownString(localize('chat.taskNotFound', 'Task not found: \`{0}\`', args.id)) }; } const taskLabel = task._label; const activeTasks = await this._tasksService.getActiveTasks(); if (activeTasks.includes(task)) { - return { content: [{ kind: 'text', value: `The task ${taskLabel} is already running.` }], toolResultMessage: new MarkdownString(localize('chat.taskAlreadyRunning', 'The task `{0}` is already running.', taskLabel)) }; + return { content: [{ kind: 'text', value: `The task ${taskLabel} is already running.` }], toolResultMessage: new MarkdownString(localize('chat.taskAlreadyRunning', 'The task \`{0}\` is already running.', taskLabel)) }; } const raceResult = await Promise.race([this._tasksService.run(task, undefined, TaskRunSource.ChatAgent), timeout(3000)]); @@ -59,11 +59,11 @@ export class RunTaskTool implements IToolImpl { const dependencyTasks = await resolveDependencyTasks(task, args.workspaceFolder, this._configurationService, this._tasksService); const resources = this._tasksService.getTerminalsForTasks(dependencyTasks ?? task); if (!resources || resources.length === 0) { - return { content: [{ kind: 'text', value: `Task started but no terminal was found for: ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('chat.noTerminal', 'Task started but no terminal was found for: `{0}`', taskLabel)) }; + return { content: [{ kind: 'text', value: `Task started but no terminal was found for: ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('chat.noTerminal', 'Task started but no terminal was found for: \`{0}\`', taskLabel)) }; } const terminals = this._terminalService.instances.filter(t => resources.some(r => r.path === t.resource.path && r.scheme === t.resource.scheme)); if (terminals.length === 0) { - return { content: [{ kind: 'text', value: `Task started but no terminal was found for: ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('chat.noTerminal', 'Task started but no terminal was found for: `{0}`', taskLabel)) }; + return { content: [{ kind: 'text', value: `Task started but no terminal was found for: ${taskLabel}` }], toolResultMessage: new MarkdownString(localize('chat.noTerminal', 'Task started but no terminal was found for: \`{0}\`', taskLabel)) }; } const store = new DisposableStore(); @@ -117,7 +117,7 @@ export class RunTaskTool implements IToolImpl { const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService, true); if (!task) { - return { invocationMessage: new MarkdownString(localize('chat.taskNotFound', 'Task not found: `{0}`', args.id)) }; + return { invocationMessage: new MarkdownString(localize('chat.taskNotFound', 'Task not found: \`{0}\`', args.id)) }; } const taskLabel = task._label; const activeTasks = await this._tasksService.getActiveTasks(); @@ -127,19 +127,19 @@ export class RunTaskTool implements IToolImpl { if (await this._isTaskActive(task)) { return { - invocationMessage: new MarkdownString(localize('chat.taskIsAlreadyRunning', '`{0}` is already running.', taskLabel)), - pastTenseMessage: new MarkdownString(localize('chat.taskWasAlreadyRunning', '`{0}` was already running.', taskLabel)), + invocationMessage: new MarkdownString(localize('chat.taskIsAlreadyRunning', '\`{0}\` is already running.', taskLabel)), + pastTenseMessage: new MarkdownString(localize('chat.taskWasAlreadyRunning', '\`{0}\` was already running.', taskLabel)), confirmationMessages: undefined }; } return { - invocationMessage: new MarkdownString(localize('chat.runningTask', 'Running `{0}`', taskLabel)), + invocationMessage: new MarkdownString(localize('chat.runningTask', 'Running \`{0}\`', taskLabel)), pastTenseMessage: new MarkdownString(task?.configurationProperties.isBackground - ? localize('chat.startedTask', 'Started `{0}`', taskLabel) - : localize('chat.ranTask', 'Ran `{0}`', taskLabel)), + ? localize('chat.startedTask', 'Started \`{0}\`', taskLabel) + : localize('chat.ranTask', 'Ran \`{0}\`', taskLabel)), confirmationMessages: task - ? { title: localize('chat.allowTaskRunTitle', 'Allow task run?'), message: localize('chat.allowTaskRunMsg', 'Allow to run the task `{0}`?', taskLabel) } + ? { title: localize('chat.allowTaskRunTitle', 'Allow task run?'), message: localize('chat.allowTaskRunMsg', 'Allow to run the task \`{0}\`?', taskLabel) } : undefined }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index e9e1adddbef..57351d2b065 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -8,7 +8,6 @@ import type { IJSONSchema } from '../../../../../base/common/jsonSchema.js'; import { localize } from '../../../../../nls.js'; import { type IConfigurationPropertySchema } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { TerminalSettingId } from '../../../../../platform/terminal/common/terminal.js'; -import product from '../../../../../platform/product/common/product.js'; import { terminalProfileBaseProperties } from '../../../../../platform/terminal/common/terminalPlatformConfiguration.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; @@ -176,6 +175,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { } as any) } ); - instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ITerminalLogService, new NullLogService()); cts = new CancellationTokenSource(); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index c97f35f44f6..25a032e8e24 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -251,7 +251,56 @@ suite('RunInTerminalTool', () => { 'sed "s/foo/bar/g"', 'sed -n "1,10p" file.txt', 'sort file.txt', - 'tree directory' + 'tree directory', + + // od + 'od somefile', + 'od -A x somefile', + + // xxd + 'xxd somefile', + 'xxd -l100 somefile', + 'xxd -r somefile', + 'xxd -rp somefile', + + // docker readonly sub-commands + 'docker ps', + 'docker ps -a', + 'docker images', + 'docker info', + 'docker version', + 'docker inspect mycontainer', + 'docker logs mycontainer', + 'docker top mycontainer', + 'docker stats', + 'docker port mycontainer', + 'docker diff mycontainer', + 'docker search nginx', + 'docker events', + 'docker container ls', + 'docker container ps', + 'docker container inspect mycontainer', + 'docker image ls', + 'docker image history myimage', + 'docker image inspect myimage', + 'docker network ls', + 'docker network inspect mynetwork', + 'docker volume ls', + 'docker volume inspect myvolume', + 'docker context ls', + 'docker context inspect mycontext', + 'docker context show', + 'docker system df', + 'docker system info', + 'docker compose ps', + 'docker compose ls', + 'docker compose top', + 'docker compose logs', + 'docker compose images', + 'docker compose config', + 'docker compose version', + 'docker compose port', + 'docker compose events', ]; const confirmationRequiredTestCases = [ // Dangerous file operations @@ -325,6 +374,21 @@ suite('RunInTerminalTool', () => { 'HTTP_PROXY=proxy:8080 wget https://example.com', 'VAR1=value1 VAR2=value2 echo test', 'A=1 B=2 C=3 ./script.sh', + + // xxd with outfile or ambiguous args + 'xxd infile outfile', + 'xxd -l 100 somefile', + + // docker write/execute sub-commands + 'docker run nginx', + 'docker exec mycontainer bash', + 'docker rm mycontainer', + 'docker rmi myimage', + 'docker build .', + 'docker push myimage', + 'docker pull nginx', + 'docker compose up', + 'docker compose down', ]; suite.skip('auto approved', () => { diff --git a/src/vs/workbench/contrib/userDataProfile/browser/media/userDataProfilesEditor.css b/src/vs/workbench/contrib/userDataProfile/browser/media/userDataProfilesEditor.css index 42debd6aca7..181db2d28a9 100644 --- a/src/vs/workbench/contrib/userDataProfile/browser/media/userDataProfilesEditor.css +++ b/src/vs/workbench/contrib/userDataProfile/browser/media/userDataProfilesEditor.css @@ -365,7 +365,6 @@ .profiles-editor .contents-container .profile-body .profile-row-container .profile-workspaces-button-container .monaco-button { width: inherit; - padding: 2px 14px; } /* Profile Editor Tree Theming */ diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css index 9b4448a62be..fefdf4c9dfe 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css @@ -946,11 +946,7 @@ } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideDetails .getting-started-step .step-description-container .monaco-button { - height: 24px; width: fit-content; - display: flex; - padding: 0 11px; - align-items: center; min-width: max-content; } diff --git a/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css b/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css index ce487f6cf7b..3bd850fb25b 100644 --- a/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css +++ b/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css @@ -175,7 +175,6 @@ .workspace-trust-editor .workspace-trust-settings .trusted-uris-button-bar .monaco-button { width: fit-content; - padding: 5px 10px; overflow: hidden; text-overflow: ellipsis; outline-offset: 2px !important; @@ -188,7 +187,7 @@ } .workspace-trust-editor .workspace-trust-features .workspace-trust-buttons-row .workspace-trust-buttons .monaco-button-dropdown .monaco-dropdown-button { - padding: 5px; + padding: 0 4px; } .workspace-trust-limitations { diff --git a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts index d410a975a99..6b5bc990d72 100644 --- a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts +++ b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts @@ -198,8 +198,8 @@ export class TextMateWorkerTokenizer extends MirrorTextModel { range: new OffsetRange(offsetAtLineStart + fontInfo.startIndex, offsetAtLineStart + fontInfo.endIndex), annotation: { fontFamily: fontInfo.fontFamily ?? undefined, - fontSize: fontInfo.fontSize ?? undefined, - lineHeight: fontInfo.lineHeight ?? undefined + fontSizeMultiplier: fontInfo.fontSizeMultiplier ?? undefined, + lineHeightMultiplier: fontInfo.lineHeightMultiplier ?? undefined } }); } diff --git a/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts b/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts index 0e9f18f3d10..75053189914 100644 --- a/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts +++ b/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts @@ -471,7 +471,7 @@ function equalsTokenRules(a: ITextMateThemingRule[] | null, b: ITextMateThemingR const s1 = r1.settings; const s2 = r2.settings; if (s1 && s2) { - if (s1.fontStyle !== s2.fontStyle || s1.foreground !== s2.foreground || s1.background !== s2.background) { + if (s1.fontStyle !== s2.fontStyle || s1.foreground !== s2.foreground || s1.background !== s2.background || s1.lineHeight !== s2.lineHeight || s1.fontSize !== s2.fontSize || s1.fontFamily !== s2.fontFamily) { return false; } } else if (!s1 || !s2) { diff --git a/src/vs/workbench/services/themes/common/colorThemeData.ts b/src/vs/workbench/services/themes/common/colorThemeData.ts index 386d668f89c..314dac44116 100644 --- a/src/vs/workbench/services/themes/common/colorThemeData.ts +++ b/src/vs/workbench/services/themes/common/colorThemeData.ts @@ -408,6 +408,7 @@ export class ColorThemeData implements IWorkbenchColorTheme { } this.tokenColorIndex = undefined; + this.tokenFontIndex = undefined; this.textMateThemingRules = undefined; this.customTokenScopeMatchers = undefined; } @@ -437,6 +438,7 @@ export class ColorThemeData implements IWorkbenchColorTheme { } this.tokenColorIndex = undefined; + this.tokenFontIndex = undefined; this.textMateThemingRules = undefined; this.customTokenScopeMatchers = undefined; } @@ -462,6 +464,7 @@ export class ColorThemeData implements IWorkbenchColorTheme { } this.tokenColorIndex = undefined; + this.tokenFontIndex = undefined; this.textMateThemingRules = undefined; } @@ -585,6 +588,7 @@ export class ColorThemeData implements IWorkbenchColorTheme { public clearCaches() { this.tokenColorIndex = undefined; + this.tokenFontIndex = undefined; this.textMateThemingRules = undefined; this.themeTokenScopeMatchers = undefined; this.customTokenScopeMatchers = undefined; @@ -1014,8 +1018,8 @@ class TokenFontIndex { this._font2id = new Map(); } - public add(fontFamily: string | undefined, fontSize: string | undefined, lineHeight: number | undefined): number { - const font: IFontTokenOptions = { fontFamily, fontSize, lineHeight }; + public add(fontFamily: string | undefined, fontSizeMultiplier: number | undefined, lineHeightMultiplier: number | undefined): number { + const font: IFontTokenOptions = { fontFamily, fontSizeMultiplier, lineHeightMultiplier }; let value = this._font2id.get(font); if (value) { return value; diff --git a/src/vs/workbench/services/themes/common/colorThemeSchema.ts b/src/vs/workbench/services/themes/common/colorThemeSchema.ts index ddcc9f57c09..99ed142d4b7 100644 --- a/src/vs/workbench/services/themes/common/colorThemeSchema.ts +++ b/src/vs/workbench/services/themes/common/colorThemeSchema.ts @@ -175,12 +175,12 @@ const textmateColorSchema: IJSONSchema = { description: nls.localize('schema.token.fontFamily', 'Font family for the token (e.g., "Fira Code", "JetBrains Mono").') }, fontSize: { - type: 'string', - description: nls.localize('schema.token.fontSize', 'Font size string for the token (e.g., "14px", "1.2em").') + type: 'number', + description: nls.localize('schema.token.fontSize', 'Font size multiplier for the token (e.g., 1.2 will use 1.2 times the default font size).') }, lineHeight: { type: 'number', - description: nls.localize('schema.token.lineHeight', 'Line height number for the token (e.g., "20").') + description: nls.localize('schema.token.lineHeight', 'Line height multiplier for the token (e.g., 1.2 will use 1.2 times the default height).') } }, additionalProperties: false, diff --git a/src/vs/workbench/services/themes/common/workbenchThemeService.ts b/src/vs/workbench/services/themes/common/workbenchThemeService.ts index 679f93e9385..a214818b29c 100644 --- a/src/vs/workbench/services/themes/common/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/common/workbenchThemeService.ts @@ -478,7 +478,7 @@ export interface ITokenColorizationSetting { background?: string; fontStyle?: string; /* [italic|bold|underline|strikethrough] */ fontFamily?: string; - fontSize?: string; + fontSize?: number; lineHeight?: number; } diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index 11ab6065d95..025bc77477e 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -172,6 +172,7 @@ export class TestNativeHostService implements INativeHostService { async readClipboardBuffer(format: string): Promise { return VSBuffer.wrap(Uint8Array.from([])); } async hasClipboard(format: string, type?: 'selection' | 'clipboard' | undefined): Promise { return false; } async windowsGetStringRegKey(hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise { return undefined; } + async createZipFile(zipPath: URI, files: { path: string; contents: string }[]): Promise { } async profileRenderer(): Promise { throw new Error(); } async getScreenshot(rect?: IRectangle): Promise { return undefined; } } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index a6e645fbd1b..08cf87eaa85 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -80,9 +80,12 @@ declare module 'vscode' { constructor(value: Uri, license: string, snippet: string); } - export class ChatPrepareToolInvocationPart { - toolName: string; - constructor(toolName: string); + export interface ChatToolInvocationStreamData { + /** + * Partial or not-yet-validated arguments that have streamed from the language model. + * Tools may use this to render interim UI while the full invocation input is collected. + */ + readonly partialInput?: unknown; } export interface ChatTerminalToolInvocationData { @@ -176,7 +179,7 @@ declare module 'vscode' { constructor(uris: Uri[], callback: () => Thenable); } - export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatPrepareToolInvocationPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart; + export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart; export class ChatResponseWarningPart { value: MarkdownString; constructor(value: string | MarkdownString); @@ -349,7 +352,21 @@ declare module 'vscode' { codeCitation(value: Uri, license: string, snippet: string): void; - prepareToolInvocation(toolName: string): void; + /** + * Begin a tool invocation in streaming mode. This creates a tool invocation that will + * display streaming progress UI until the tool is actually invoked. + * @param toolCallId Unique identifier for this tool call, used to correlate streaming updates and final invocation. + * @param toolName The name of the tool being invoked. + * @param streamData Optional initial streaming data with partial arguments. + */ + beginToolInvocation(toolCallId: string, toolName: string, streamData?: ChatToolInvocationStreamData): void; + + /** + * Update the streaming data for a tool invocation that was started with `beginToolInvocation`. + * @param toolCallId The tool call ID that was passed to `beginToolInvocation`. + * @param streamData New streaming data with updated partial arguments. + */ + updateToolInvocation(toolCallId: string, streamData: ChatToolInvocationStreamData): void; push(part: ExtendedChatResponsePart): void; @@ -668,6 +685,37 @@ declare module 'vscode' { export interface LanguageModelToolInvocationOptions { model?: LanguageModelChat; + chatStreamToolCallId?: string; + } + + export interface LanguageModelToolInvocationStreamOptions { + /** + * Raw argument payload, such as the streamed JSON fragment from the language model. + */ + readonly rawInput?: unknown; + + readonly chatRequestId?: string; + readonly chatSessionId?: string; + readonly chatInteractionId?: string; + } + + export interface LanguageModelToolStreamResult { + /** + * A customized progress message to show while the tool runs. + */ + invocationMessage?: string | MarkdownString; + } + + export interface LanguageModelTool { + /** + * Called zero or more times before {@link LanguageModelTool.prepareInvocation} while the + * language model streams argument data for the invocation. Use this to update progress + * or UI with the partial arguments that have been generated so far. + * + * Implementations must be free of side-effects and should be resilient to receiving + * malformed or incomplete input. + */ + handleToolStream?(options: LanguageModelToolInvocationStreamOptions, token: CancellationToken): ProviderResult; } export interface ChatRequest { diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 5a7f9bd503b..ac6ade0f413 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -392,12 +392,13 @@ declare module 'vscode' { /** * Handler for dynamic search when `searchable` is true. - * Called when the user clicks "See more..." to load additional items. + * Called when the user types in the searchable QuickPick or clicks "See more..." to load additional items. * + * @param query The search query entered by the user. Empty string for initial load. * @param token A cancellation token. * @returns Additional items to display in the searchable QuickPick. */ - readonly onSearch?: (token: CancellationToken) => Thenable; + readonly onSearch?: (query: string, token: CancellationToken) => Thenable; } export interface ChatSessionProviderOptions { diff --git a/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts b/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts index ed9f5022b2f..deef5f2a74f 100644 --- a/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts @@ -135,7 +135,12 @@ declare module 'vscode' { // eslint-disable-next-line local/vscode-dts-provider-naming handleListEndOfLifetime?(list: InlineCompletionList, reason: InlineCompletionsDisposeReason): void; - readonly onDidChange?: Event; + /** + * Fired when the provider wants to trigger a new completion request. + * Can optionally pass a {@link InlineCompletionChangeHint} which will be + * included in the {@link InlineCompletionContext.changeHint} of the subsequent request. + */ + readonly onDidChange?: Event; readonly modelInfo?: InlineCompletionModelInfo; readonly onDidChangeModelInfo?: Event; @@ -199,6 +204,18 @@ declare module 'vscode' { export type InlineCompletionsDisposeReason = { kind: InlineCompletionsDisposeReasonKind }; + /** + * Arbitrary data that the provider can pass when firing {@link InlineCompletionItemProvider.onDidChange}. + * This data is passed back to the provider in {@link InlineCompletionContext.changeHint}. + */ + export interface InlineCompletionChangeHint { + /** + * Arbitrary data that the provider can use to identify what triggered the change. + * This data must be JSON serializable. + */ + readonly data?: unknown; + } + export interface InlineCompletionContext { readonly userPrompt?: string; @@ -207,6 +224,12 @@ declare module 'vscode' { readonly requestIssuedDateTime: number; readonly earliestShownDateTime: number; + + /** + * The change hint that was passed to {@link InlineCompletionItemProvider.onDidChange}. + * Only set if this request was triggered by such an event. + */ + readonly changeHint?: InlineCompletionChangeHint; } export interface PartialAcceptInfo { diff --git a/test/automation/src/application.ts b/test/automation/src/application.ts index 81acded3853..2a68759388f 100644 --- a/test/automation/src/application.ts +++ b/test/automation/src/application.ts @@ -49,8 +49,11 @@ export class Application { return !!this.options.web; } - private _workspacePathOrFolder: string; + private _workspacePathOrFolder: string | undefined; get workspacePathOrFolder(): string { + if (!this._workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } return this._workspacePathOrFolder; } @@ -78,7 +81,7 @@ export class Application { })(), 'Application#restart()', this.logger); } - private async _start(workspaceOrFolder = this.workspacePathOrFolder, extraArgs: string[] = []): Promise { + private async _start(workspaceOrFolder = this._workspacePathOrFolder, extraArgs: string[] = []): Promise { this._workspacePathOrFolder = workspaceOrFolder; // Launch Code... @@ -109,6 +112,7 @@ export class Application { private async startApplication(extraArgs: string[] = []): Promise { const code = this._code = await launch({ ...this.options, + workspacePath: this._workspacePathOrFolder, extraArgs: [...(this.options.extraArgs || []), ...extraArgs], }); diff --git a/test/automation/src/code.ts b/test/automation/src/code.ts index fe498419122..c61b23da7db 100644 --- a/test/automation/src/code.ts +++ b/test/automation/src/code.ts @@ -18,7 +18,7 @@ export interface LaunchOptions { // Allows you to override the Playwright instance playwright?: typeof playwright; codePath?: string; - readonly workspacePath: string; + readonly workspacePath?: string; userDataDir?: string; readonly extensionsPath?: string; readonly logger: Logger; diff --git a/test/automation/src/electron.ts b/test/automation/src/electron.ts index a34e802ed5a..473ebf01ae8 100644 --- a/test/automation/src/electron.ts +++ b/test/automation/src/electron.ts @@ -22,8 +22,7 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom const { codePath, workspacePath, extensionsPath, userDataDir, remote, logger, logsPath, crashesPath, extraArgs } = options; const env = { ...process.env }; - const args = [ - workspacePath, + const args: string[] = [ '--skip-release-notes', '--skip-welcome', '--disable-telemetry', @@ -35,6 +34,12 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom '--disable-workspace-trust', `--logsPath=${logsPath}` ]; + + // Only add workspace path if provided + if (workspacePath) { + args.unshift(workspacePath); + } + if (options.useInMemorySecretStorage) { args.push('--use-inmemory-secretstorage'); } @@ -49,6 +54,9 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom } if (remote) { + if (!workspacePath) { + throw new Error('Workspace path is required when running remote'); + } // Replace workspace path with URI args[0] = `--${workspacePath.endsWith('.code-workspace') ? 'file' : 'folder'}-uri=vscode-remote://test+test/${URI.file(workspacePath).path}`; diff --git a/test/automation/src/playwrightBrowser.ts b/test/automation/src/playwrightBrowser.ts index a459826b571..3ca9894a95a 100644 --- a/test/automation/src/playwrightBrowser.ts +++ b/test/automation/src/playwrightBrowser.ts @@ -157,7 +157,15 @@ async function launchBrowser(options: LaunchOptions, endpoint: string) { `["logLevel","${options.verbose ? 'trace' : 'info'}"]` ].join(',')}]`; - const gotoPromise = measureAndLog(() => page.goto(`${endpoint}&${workspacePath.endsWith('.code-workspace') ? 'workspace' : 'folder'}=${URI.file(workspacePath!).path}&payload=${payloadParam}`), 'page.goto()', logger); + // Build URL with optional workspace path + let url = `${endpoint}&`; + if (workspacePath) { + const workspaceParam = workspacePath.endsWith('.code-workspace') ? 'workspace' : 'folder'; + url += `${workspaceParam}=${URI.file(workspacePath).path}&`; + } + url += `payload=${payloadParam}`; + + const gotoPromise = measureAndLog(() => page.goto(url), 'page.goto()', logger); const pageLoadedPromise = page.waitForLoadState('load'); await gotoPromise; diff --git a/test/mcp/src/application.ts b/test/mcp/src/application.ts index a60c7b9764d..fa8c2ff9dec 100644 --- a/test/mcp/src/application.ts +++ b/test/mcp/src/application.ts @@ -232,7 +232,7 @@ async function setup(): Promise { logger.log('Smoketest setup done!\n'); } -export async function getApplication({ recordVideo }: { recordVideo?: boolean } = {}) { +export async function getApplication({ recordVideo, workspacePath }: { recordVideo?: boolean; workspacePath?: string } = {}) { const testCodePath = getDevElectronPath(); const electronPath = testCodePath; if (!fs.existsSync(electronPath || '')) { @@ -252,7 +252,8 @@ export async function getApplication({ recordVideo }: { recordVideo?: boolean } quality, version: parseVersion(version ?? '0.0.0'), codePath: opts.build, - workspacePath: rootPath, + // Use provided workspace path, or fall back to rootPath on CI (GitHub Actions) + workspacePath: workspacePath ?? (process.env.GITHUB_ACTIONS ? rootPath : undefined), logger, logsPath: logsRootPath, crashesPath: crashesRootPath, @@ -292,12 +293,12 @@ export class ApplicationService { return this._application; } - async getOrCreateApplication({ recordVideo }: { recordVideo?: boolean } = {}): Promise { + async getOrCreateApplication({ recordVideo, workspacePath }: { recordVideo?: boolean; workspacePath?: string } = {}): Promise { if (this._closing) { await this._closing; } if (!this._application) { - this._application = await getApplication({ recordVideo }); + this._application = await getApplication({ recordVideo, workspacePath }); this._application.code.driver.currentPage.on('close', () => { this._closing = (async () => { if (this._application) { diff --git a/test/mcp/src/automation.ts b/test/mcp/src/automation.ts index 9163af43e89..3263081ecfc 100644 --- a/test/mcp/src/automation.ts +++ b/test/mcp/src/automation.ts @@ -18,17 +18,18 @@ export async function getServer(appService: ApplicationService): Promise server.tool( 'vscode_automation_start', - 'Start VS Code Build', + 'Start VS Code Build. If workspacePath is not provided, VS Code will open with the last used workspace or an empty window.', { - recordVideo: z.boolean().optional() + recordVideo: z.boolean().optional().describe('Whether to record a video of the session'), + workspacePath: z.string().optional().describe('Optional path to a workspace or folder to open. If not provided, opens the last used workspace.') }, - async ({ recordVideo }) => { - const app = await appService.getOrCreateApplication({ recordVideo }); + async ({ recordVideo, workspacePath }) => { + const app = await appService.getOrCreateApplication({ recordVideo, workspacePath }); await app.startTracing(); return { content: [{ type: 'text' as const, - text: app ? `VS Code started successfully` : `Failed to start VS Code` + text: app ? `VS Code started successfully${workspacePath ? ` with workspace: ${workspacePath}` : ''}` : `Failed to start VS Code` }] }; } diff --git a/test/mcp/src/automationTools/core.ts b/test/mcp/src/automationTools/core.ts index 591d7437896..d18adf35ef0 100644 --- a/test/mcp/src/automationTools/core.ts +++ b/test/mcp/src/automationTools/core.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; import { ApplicationService } from '../application'; /** @@ -12,25 +13,26 @@ import { ApplicationService } from '../application'; export function applyCoreTools(server: McpServer, appService: ApplicationService): RegisteredTool[] { const tools: RegisteredTool[] = []; - // Playwright keeps using this as a start... maybe it needs some massaging - // server.tool( - // 'vscode_automation_restart', - // 'Restart VS Code with optional workspace or folder and extra arguments', - // { - // workspaceOrFolder: z.string().optional().describe('Optional path to workspace or folder to open'), - // extraArgs: z.array(z.string()).optional().describe('Optional extra command line arguments') - // }, - // async (args) => { - // const { workspaceOrFolder, extraArgs } = args; - // await app.restart({ workspaceOrFolder, extraArgs }); - // return { - // content: [{ - // type: 'text' as const, - // text: `VS Code restarted successfully${workspaceOrFolder ? ` with workspace: ${workspaceOrFolder}` : ''}` - // }] - // }; - // } - // ); + tools.push(server.tool( + 'vscode_automation_restart', + 'Restart VS Code with optional workspace or folder and extra command-line arguments', + { + workspaceOrFolder: z.string().optional().describe('Path to a workspace or folder to open on restart'), + extraArgs: z.array(z.string()).optional().describe('Extra CLI arguments to pass on restart') + }, + async ({ workspaceOrFolder, extraArgs }) => { + const app = await appService.getOrCreateApplication(); + await app.restart({ workspaceOrFolder, extraArgs }); + const workspaceText = workspaceOrFolder ? ` with workspace: ${workspaceOrFolder}` : ''; + const argsText = extraArgs?.length ? ` (args: ${extraArgs.join(' ')})` : ''; + return { + content: [{ + type: 'text' as const, + text: `VS Code restarted successfully${workspaceText}${argsText}` + }] + }; + } + )); tools.push(server.tool( 'vscode_automation_stop', diff --git a/test/sanity/package-lock.json b/test/sanity/package-lock.json index 1c246d774be..ee85c10be1a 100644 --- a/test/sanity/package-lock.json +++ b/test/sanity/package-lock.json @@ -10,11 +10,16 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "minimist": "^1.2.8", + "mocha": "^11.7.5", "mocha-junit-reporter": "^2.2.1", "node-fetch": "^3.3.2", - "playwright": "^1.57.0" + "playwright": "^1.57.0", + "typescript": "^6.0.0-dev.20251110" }, "devDependencies": { + "@types/minimist": "^1.2.5", + "@types/mocha": "^10.0.10", "@types/node": "22.x" } }, @@ -23,7 +28,6 @@ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "license": "ISC", - "peer": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -42,11 +46,24 @@ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=14" } }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", @@ -62,7 +79,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -75,7 +91,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -90,22 +105,19 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0", - "peer": true + "license": "Python-2.0" }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -114,15 +126,13 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -135,7 +145,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -152,7 +161,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -174,7 +182,6 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -190,7 +197,6 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "license": "ISC", - "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -205,7 +211,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -214,15 +219,13 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", - "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -237,7 +240,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -250,7 +252,6 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -268,7 +269,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -280,15 +280,13 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -338,7 +336,6 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -351,7 +348,6 @@ "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.3.1" } @@ -360,22 +356,19 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -385,7 +378,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -421,7 +413,6 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "license": "MIT", - "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -438,7 +429,6 @@ "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "license": "BSD-3-Clause", - "peer": true, "bin": { "flat": "cli.js" } @@ -448,7 +438,6 @@ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", - "peer": true, "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" @@ -491,7 +480,6 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "license": "ISC", - "peer": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -501,7 +489,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", - "peer": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -522,7 +509,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -532,7 +518,6 @@ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "license": "MIT", - "peer": true, "bin": { "he": "bin/he" } @@ -548,7 +533,6 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -558,7 +542,6 @@ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -568,7 +551,6 @@ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -578,7 +560,6 @@ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -590,15 +571,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -614,7 +593,6 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -627,7 +605,6 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "license": "MIT", - "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -643,7 +620,6 @@ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "license": "MIT", - "peer": true, "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -659,8 +635,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/md5": { "version": "2.3.0", @@ -678,7 +653,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -689,12 +663,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "license": "ISC", - "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -719,7 +701,6 @@ "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", "license": "MIT", - "peer": true, "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", @@ -837,7 +818,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "license": "MIT", - "peer": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -853,7 +833,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "license": "MIT", - "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -868,15 +847,13 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0", - "peer": true + "license": "BlueOak-1.0.0" }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -886,7 +863,6 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -896,7 +872,6 @@ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -912,8 +887,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/playwright": { "version": "1.57.0", @@ -950,7 +924,6 @@ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -960,7 +933,6 @@ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -974,7 +946,6 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -997,15 +968,13 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -1015,7 +984,6 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -1028,7 +996,6 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -1038,7 +1005,6 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", - "peer": true, "engines": { "node": ">=14" }, @@ -1051,7 +1017,6 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", - "peer": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -1070,7 +1035,6 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", - "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -1085,7 +1049,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -1094,15 +1057,13 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -1115,7 +1076,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -1132,7 +1092,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -1145,7 +1104,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -1155,7 +1113,6 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" }, @@ -1168,7 +1125,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -1179,6 +1135,19 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/typescript": { + "version": "6.0.0-dev.20260113", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.0-dev.20260113.tgz", + "integrity": "sha512-frXm5LJtstQlM511cGZLCalQjX5YUdUhvNSQAEcI4EuHoflAaqvCa2KIzPKNbyH3KmFPjA3EOs9FphTSKNc4CQ==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -1200,7 +1169,6 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -1215,15 +1183,13 @@ "version": "9.3.4", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -1242,7 +1208,6 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -1260,7 +1225,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -1269,15 +1233,13 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", - "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -1292,7 +1254,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -1305,7 +1266,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1324,7 +1284,6 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "license": "ISC", - "peer": true, "engines": { "node": ">=10" } @@ -1334,7 +1293,6 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", - "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -1353,7 +1311,6 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -1363,7 +1320,6 @@ "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "license": "MIT", - "peer": true, "dependencies": { "camelcase": "^6.0.0", "decamelize": "^4.0.0", @@ -1379,7 +1335,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -1388,15 +1343,13 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", - "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -1411,7 +1364,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -1424,7 +1376,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, diff --git a/test/sanity/package.json b/test/sanity/package.json index a1974fc7906..2080734447b 100644 --- a/test/sanity/package.json +++ b/test/sanity/package.json @@ -5,15 +5,20 @@ "main": "./out/index.js", "scripts": { "postinstall": "playwright install --with-deps chromium webkit", - "compile": "node ../../node_modules/typescript/bin/tsc", + "compile": "tsc", "start": "node ./out/index.js" }, "dependencies": { + "minimist": "^1.2.8", + "mocha": "^11.7.5", "mocha-junit-reporter": "^2.2.1", "node-fetch": "^3.3.2", - "playwright": "^1.57.0" + "playwright": "^1.57.0", + "typescript": "^6.0.0-dev.20251110" }, "devDependencies": { + "@types/minimist": "^1.2.5", + "@types/mocha": "^10.0.10", "@types/node": "22.x" } } diff --git a/test/sanity/src/cli.test.ts b/test/sanity/src/cli.test.ts index 90b01257131..13694169b3a 100644 --- a/test/sanity/src/cli.test.ts +++ b/test/sanity/src/cli.test.ts @@ -5,128 +5,131 @@ import assert from 'assert'; import { spawn } from 'child_process'; +import { test } from 'mocha'; import { TestContext } from './context'; export function setup(context: TestContext) { - describe('CLI', () => { - if (context.platform === 'linux-arm64') { - it('cli-alpine-arm64', async () => { - const dir = await context.downloadAndUnpack('cli-alpine-arm64'); - const entryPoint = context.getEntryPoint('cli', dir); - await testCliApp(entryPoint); - }); + if (context.skipRuntimeCheck || context.platform === 'linux-arm64') { + test('cli-alpine-arm64', async () => { + const dir = await context.downloadAndUnpack('cli-alpine-arm64'); + const entryPoint = context.getEntryPoint('cli', dir); + await testCliApp(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-x64') { + test('cli-alpine-x64', async () => { + const dir = await context.downloadAndUnpack('cli-alpine-x64'); + const entryPoint = context.getEntryPoint('cli', dir); + await testCliApp(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'darwin-arm64') { + test('cli-darwin-arm64', async () => { + const dir = await context.downloadAndUnpack('cli-darwin-arm64'); + context.validateAllCodesignSignatures(dir); + const entryPoint = context.getEntryPoint('cli', dir); + await testCliApp(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'darwin-x64') { + test('cli-darwin-x64', async () => { + const dir = await context.downloadAndUnpack('cli-darwin-x64'); + context.validateAllCodesignSignatures(dir); + const entryPoint = context.getEntryPoint('cli', dir); + await testCliApp(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-arm64') { + test('cli-linux-arm64', async () => { + const dir = await context.downloadAndUnpack('cli-linux-arm64'); + const entryPoint = context.getEntryPoint('cli', dir); + await testCliApp(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-arm') { + test('cli-linux-armhf', async () => { + const dir = await context.downloadAndUnpack('cli-linux-armhf'); + const entryPoint = context.getEntryPoint('cli', dir); + await testCliApp(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-x64') { + test('cli-linux-x64', async () => { + const dir = await context.downloadAndUnpack('cli-linux-x64'); + const entryPoint = context.getEntryPoint('cli', dir); + await testCliApp(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'win32-arm64') { + test('cli-win32-arm64', async () => { + const dir = await context.downloadAndUnpack('cli-win32-arm64'); + context.validateAllAuthenticodeSignatures(dir); + const entryPoint = context.getEntryPoint('cli', dir); + await testCliApp(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'win32-x64') { + test('cli-win32-x64', async () => { + const dir = await context.downloadAndUnpack('cli-win32-x64'); + context.validateAllAuthenticodeSignatures(dir); + const entryPoint = context.getEntryPoint('cli', dir); + await testCliApp(entryPoint); + }); + } + + async function testCliApp(entryPoint: string) { + if (context.skipRuntimeCheck) { + return; } - if (context.platform === 'linux-x64') { - it('cli-alpine-x64', async () => { - const dir = await context.downloadAndUnpack('cli-alpine-x64'); - const entryPoint = context.getEntryPoint('cli', dir); - await testCliApp(entryPoint); - }); - } + const result = context.runNoErrors(entryPoint, '--version'); + const version = result.stdout.trim(); + assert.ok(version.includes(`(commit ${context.commit})`)); - if (context.platform === 'darwin-arm64') { - it('cli-darwin-arm64', async () => { - const dir = await context.downloadAndUnpack('cli-darwin-arm64'); - context.validateAllCodesignSignatures(dir); - const entryPoint = context.getEntryPoint('cli', dir); - await testCliApp(entryPoint); - }); - } + const workspaceDir = context.createTempDir(); + process.chdir(workspaceDir); + context.log(`Changed current directory to: ${workspaceDir}`); - if (context.platform === 'darwin-x64') { - it('cli-darwin-x64', async () => { - const dir = await context.downloadAndUnpack('cli-darwin-x64'); - context.validateAllCodesignSignatures(dir); - const entryPoint = context.getEntryPoint('cli', dir); - await testCliApp(entryPoint); - }); - } + const args = [ + '--cli-data-dir', context.createTempDir(), + '--user-data-dir', context.createTempDir(), + 'tunnel', + '--accept-server-license-terms', + '--server-data-dir', context.createTempDir(), + '--extensions-dir', context.createTempDir(), + ]; - if (context.platform === 'linux-arm64') { - it('cli-linux-arm64', async () => { - const dir = await context.downloadAndUnpack('cli-linux-arm64'); - const entryPoint = context.getEntryPoint('cli', dir); - await testCliApp(entryPoint); - }); - } + context.log(`Running CLI ${entryPoint} with args ${args.join(' ')}`); + const cli = spawn(entryPoint, args, { detached: true }); - if (context.platform === 'linux-arm') { - it('cli-linux-armhf', async () => { - const dir = await context.downloadAndUnpack('cli-linux-armhf'); - const entryPoint = context.getEntryPoint('cli', dir); - await testCliApp(entryPoint); - }); - } + cli.stderr.on('data', (data) => { + context.error(`[CLI Error] ${data.toString().trim()}`); + }); - if (context.platform === 'linux-x64') { - it('cli-linux-x64', async () => { - const dir = await context.downloadAndUnpack('cli-linux-x64'); - const entryPoint = context.getEntryPoint('cli', dir); - await testCliApp(entryPoint); - }); - } - - if (context.platform === 'win32-arm64') { - it('cli-win32-arm64', async () => { - const dir = await context.downloadAndUnpack('cli-win32-arm64'); - context.validateAllAuthenticodeSignatures(dir); - const entryPoint = context.getEntryPoint('cli', dir); - await testCliApp(entryPoint); - }); - } - - if (context.platform === 'win32-x64') { - it('cli-win32-x64', async () => { - const dir = await context.downloadAndUnpack('cli-win32-x64'); - context.validateAllAuthenticodeSignatures(dir); - const entryPoint = context.getEntryPoint('cli', dir); - await testCliApp(entryPoint); - }); - } - - async function testCliApp(entryPoint: string) { - const result = context.runNoErrors(entryPoint, '--version'); - const version = result.stdout.trim(); - assert.ok(version.includes(`(commit ${context.commit})`)); - - const workspaceDir = context.createTempDir(); - process.chdir(workspaceDir); - context.log(`Changed current directory to: ${workspaceDir}`); - - const args = [ - '--cli-data-dir', context.createTempDir(), - '--user-data-dir', context.createTempDir(), - 'tunnel', - '--accept-server-license-terms', - '--server-data-dir', context.createTempDir(), - '--extensions-dir', context.createTempDir(), - ]; - - context.log(`Running CLI ${entryPoint} with args ${args.join(' ')}`); - const cli = spawn(entryPoint, args, { detached: true }); - - cli.stderr.on('data', (data) => { - context.error(`[CLI Error] ${data.toString().trim()}`); + cli.stdout.on('data', (data) => { + const text = data.toString().trim(); + text.split('\n').forEach((line: string) => { + context.log(`[CLI Output] ${line}`); }); - cli.stdout.on('data', (data) => { - const text = data.toString().trim(); - text.split('\n').forEach((line: string) => { - context.log(`[CLI Output] ${line}`); - }); + const match = /Using GitHub for authentication/.exec(text); + if (match !== null) { + context.log(`CLI started successfully and is waiting for authentication`); + context.killProcessTree(cli.pid!); + } + }); - const match = /Using GitHub for authentication/.exec(text); - if (match !== null) { - context.log(`CLI started successfully and is waiting for authentication`); - context.killProcessTree(cli.pid!); - } - }); - - await new Promise((resolve, reject) => { - cli.on('error', reject); - cli.on('exit', resolve); - }); - } - }); + await new Promise((resolve, reject) => { + cli.on('error', reject); + cli.on('exit', resolve); + }); + } } diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 39f3e85438a..82023c01c1a 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -33,19 +33,17 @@ export class TestContext { private static readonly codesignExclude = /node_modules\/(@parcel\/watcher\/build\/Release\/watcher\.node|@vscode\/deviceid\/build\/Release\/windows\.node|@vscode\/ripgrep\/bin\/rg|@vscode\/spdlog\/build\/Release\/spdlog.node|kerberos\/build\/Release\/kerberos.node|native-watchdog\/build\/Release\/watchdog\.node|node-pty\/build\/Release\/(pty\.node|spawn-helper)|vsda\/build\/Release\/vsda\.node)$/; private readonly tempDirs = new Set(); - private readonly logFile: string; private _currentTest?: Mocha.Test & { consoleOutputs?: string[] }; + private _osTempDir?: string; public constructor( public readonly quality: 'stable' | 'insider' | 'exploration', public readonly commit: string, public readonly verbose: boolean, public readonly skipSigningCheck: boolean, + public readonly headless: boolean, + public readonly skipRuntimeCheck: boolean, ) { - const osTempDir = fs.realpathSync(os.tmpdir()); - const logDir = fs.mkdtempSync(path.join(osTempDir, 'vscode-sanity-log')); - this.logFile = path.join(logDir, 'sanity.log'); - console.log(`Log file: ${this.logFile}`); } /** @@ -63,12 +61,31 @@ export class TestContext { return `${os.platform()}-${os.arch()}`; } + /** + * Returns the OS temp directory with expanded long names on Windows. + */ + public get osTempDir(): string { + if (this._osTempDir === undefined) { + let tempDir = fs.realpathSync(os.tmpdir()); + + // On Windows, expand short 8.3 file names to long names + if (os.platform() === 'win32') { + const result = spawnSync('powershell', ['-Command', `(Get-Item "${tempDir}").FullName`], { encoding: 'utf-8' }); + if (result.status === 0 && result.stdout) { + tempDir = result.stdout.trim(); + } + } + + this._osTempDir = tempDir; + } + return this._osTempDir; + } + /** * Logs a message with a timestamp. */ public log(message: string) { const line = `[${new Date().toISOString()}] ${message}`; - fs.appendFileSync(this.logFile, line + '\n'); this._currentTest?.consoleOutputs?.push(line); if (this.verbose) { console.log(line); @@ -80,7 +97,6 @@ export class TestContext { */ public error(message: string): never { const line = `[${new Date().toISOString()}] ERROR: ${message}`; - fs.appendFileSync(this.logFile, line + '\n'); this._currentTest?.consoleOutputs?.push(line); console.error(line); throw new Error(message); @@ -90,8 +106,7 @@ export class TestContext { * Creates a new temporary directory and returns its path. */ public createTempDir(): string { - const osTempDir = fs.realpathSync(os.tmpdir()); - const tempDir = fs.mkdtempSync(path.join(osTempDir, 'vscode-sanity')); + const tempDir = fs.mkdtempSync(path.join(this.osTempDir, 'vscode-sanity')); this.log(`Created temp directory: ${tempDir}`); this.tempDirs.add(tempDir); return tempDir; @@ -233,7 +248,7 @@ export class TestContext { * @param filePath The path to the file to validate. */ public validateAuthenticodeSignature(filePath: string) { - if (this.skipSigningCheck) { + if (this.skipSigningCheck || os.platform() !== 'win32') { this.log(`Skipping Authenticode signature validation for ${filePath} (signing checks disabled)`); return; } @@ -256,7 +271,7 @@ export class TestContext { * @param dir The directory to scan for executable files. */ public validateAllAuthenticodeSignatures(dir: string) { - if (this.skipSigningCheck) { + if (this.skipSigningCheck || os.platform() !== 'win32') { this.log(`Skipping Authenticode signature validation for ${dir} (signing checks disabled)`); return; } @@ -277,7 +292,7 @@ export class TestContext { * @param filePath The path to the file or app bundle to validate. */ public validateCodesignSignature(filePath: string) { - if (this.skipSigningCheck) { + if (this.skipSigningCheck || os.platform() !== 'darwin') { this.log(`Skipping codesign signature validation for ${filePath} (signing checks disabled)`); return; } @@ -299,7 +314,7 @@ export class TestContext { * @param dir The directory to scan for Mach-O binaries. */ public validateAllCodesignSignatures(dir: string) { - if (this.skipSigningCheck) { + if (this.skipSigningCheck || os.platform() !== 'darwin') { this.log(`Skipping codesign signature validation for ${dir} (signing checks disabled)`); return; } @@ -496,11 +511,11 @@ export class TestContext { } /** - * Prepares a macOS .app bundle for execution by removing the quarantine attribute. + * Returns the path to the VS Code Electron executable within a macOS .app bundle. * @param bundleDir The directory containing the .app bundle. * @returns The path to the VS Code Electron executable. */ - public installMacApp(bundleDir: string): string { + public getMacAppEntryPoint(bundleDir: string): string { let appName: string; switch (this.quality) { case 'stable': @@ -666,11 +681,11 @@ export class TestContext { this.log(`Launching web browser`); switch (os.platform()) { case 'darwin': - return await webkit.launch({ headless: false }); + return await webkit.launch({ headless: this.headless }); case 'win32': - return await chromium.launch({ channel: 'msedge', headless: false }); + return await chromium.launch({ channel: 'msedge', headless: this.headless }); default: - return await chromium.launch({ channel: 'chrome', headless: false }); + return await chromium.launch({ channel: 'chrome', headless: this.headless }); } } diff --git a/test/sanity/src/desktop.test.ts b/test/sanity/src/desktop.test.ts index 9e955856225..832e3b0b0fc 100644 --- a/test/sanity/src/desktop.test.ts +++ b/test/sanity/src/desktop.test.ts @@ -3,201 +3,226 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { test } from 'mocha'; import path from 'path'; import { _electron } from 'playwright'; import { TestContext } from './context'; import { UITest } from './uiTest'; export function setup(context: TestContext) { - describe('Desktop', () => { - if (context.platform === 'darwin-x64') { - it('desktop-darwin-x64', async () => { - const dir = await context.downloadAndUnpack('darwin'); - context.validateAllCodesignSignatures(dir); - const entryPoint = context.installMacApp(dir); - await testDesktopApp(entryPoint); - }); - } + if (context.skipRuntimeCheck || context.platform === 'darwin-x64') { + test('desktop-darwin-x64', async () => { + const dir = await context.downloadAndUnpack('darwin'); + context.validateAllCodesignSignatures(dir); + const entryPoint = context.getMacAppEntryPoint(dir); + await testDesktopApp(entryPoint); + }); + } - if (context.platform === 'darwin-arm64') { - it('desktop-darwin-arm64', async () => { - const dir = await context.downloadAndUnpack('darwin-arm64'); - context.validateAllCodesignSignatures(dir); - const entryPoint = context.installMacApp(dir); - await testDesktopApp(entryPoint); - }); - } + if (context.skipRuntimeCheck || context.platform === 'darwin-arm64') { + test('desktop-darwin-arm64', async () => { + const dir = await context.downloadAndUnpack('darwin-arm64'); + context.validateAllCodesignSignatures(dir); + const entryPoint = context.getMacAppEntryPoint(dir); + await testDesktopApp(entryPoint); + }); + } - if (context.platform === 'darwin-arm64' || context.platform === 'darwin-x64') { - it('desktop-darwin-universal', async () => { - const dir = await context.downloadAndUnpack('darwin-universal'); - context.validateAllCodesignSignatures(dir); - const entryPoint = context.installMacApp(dir); - await testDesktopApp(entryPoint); - }); - } + if (context.skipRuntimeCheck || context.platform === 'darwin-arm64' || context.platform === 'darwin-x64') { + test('desktop-darwin-universal', async () => { + const dir = await context.downloadAndUnpack('darwin-universal'); + context.validateAllCodesignSignatures(dir); + const entryPoint = context.getMacAppEntryPoint(dir); + await testDesktopApp(entryPoint); + }); + } - if (context.platform === 'linux-arm64') { - it('desktop-linux-arm64', async () => { - const dir = await context.downloadAndUnpack('linux-arm64'); - const entryPoint = context.getEntryPoint('desktop', dir); - const dataDir = context.createPortableDataDir(dir); - await testDesktopApp(entryPoint, dataDir); - }); - } + if (context.skipRuntimeCheck || context.platform === 'linux-arm64') { + test('desktop-linux-arm64', async () => { + const dir = await context.downloadAndUnpack('linux-arm64'); + const entryPoint = context.getEntryPoint('desktop', dir); + const dataDir = context.createPortableDataDir(dir); + await testDesktopApp(entryPoint, dataDir); + }); + } - if (context.platform === 'linux-arm') { - it('desktop-linux-armhf', async () => { - const dir = await context.downloadAndUnpack('linux-armhf'); - const entryPoint = context.getEntryPoint('desktop', dir); - const dataDir = context.createPortableDataDir(dir); - await testDesktopApp(entryPoint, dataDir); - }); - } + if (context.skipRuntimeCheck || context.platform === 'linux-arm') { + test('desktop-linux-armhf', async () => { + const dir = await context.downloadAndUnpack('linux-armhf'); + const entryPoint = context.getEntryPoint('desktop', dir); + const dataDir = context.createPortableDataDir(dir); + await testDesktopApp(entryPoint, dataDir); + }); + } - if (context.platform === 'linux-arm64') { - it('desktop-linux-deb-arm64', async () => { - const packagePath = await context.downloadTarget('linux-deb-arm64'); + if (context.skipRuntimeCheck || context.platform === 'linux-arm64') { + test('desktop-linux-deb-arm64', async () => { + const packagePath = await context.downloadTarget('linux-deb-arm64'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installDeb(packagePath); await testDesktopApp(entryPoint); - }); - } + } + }); + } - if (context.platform === 'linux-arm') { - it('desktop-linux-deb-armhf', async () => { - const packagePath = await context.downloadTarget('linux-deb-armhf'); + if (context.skipRuntimeCheck || context.platform === 'linux-arm') { + test('desktop-linux-deb-armhf', async () => { + const packagePath = await context.downloadTarget('linux-deb-armhf'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installDeb(packagePath); await testDesktopApp(entryPoint); - }); - } + } + }); + } - if (context.platform === 'linux-x64') { - it('desktop-linux-deb-x64', async () => { - const packagePath = await context.downloadTarget('linux-deb-x64'); + if (context.skipRuntimeCheck || context.platform === 'linux-x64') { + test('desktop-linux-deb-x64', async () => { + const packagePath = await context.downloadTarget('linux-deb-x64'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installDeb(packagePath); await testDesktopApp(entryPoint); - }); - } + } + }); + } - if (context.platform === 'linux-arm64') { - it('desktop-linux-rpm-arm64', async () => { - const packagePath = await context.downloadTarget('linux-rpm-arm64'); + if (context.skipRuntimeCheck || context.platform === 'linux-arm64') { + test('desktop-linux-rpm-arm64', async () => { + const packagePath = await context.downloadTarget('linux-rpm-arm64'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installRpm(packagePath); await testDesktopApp(entryPoint); - }); - } + } + }); + } - if (context.platform === 'linux-arm') { - it('desktop-linux-rpm-armhf', async () => { - const packagePath = await context.downloadTarget('linux-rpm-armhf'); + if (context.skipRuntimeCheck || context.platform === 'linux-arm') { + test('desktop-linux-rpm-armhf', async () => { + const packagePath = await context.downloadTarget('linux-rpm-armhf'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installRpm(packagePath); await testDesktopApp(entryPoint); - }); - } + } + }); + } - if (context.platform === 'linux-x64') { - it('desktop-linux-rpm-x64', async () => { - const packagePath = await context.downloadTarget('linux-rpm-x64'); + if (context.skipRuntimeCheck || context.platform === 'linux-x64') { + test('desktop-linux-rpm-x64', async () => { + const packagePath = await context.downloadTarget('linux-rpm-x64'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installRpm(packagePath); await testDesktopApp(entryPoint); - }); - } + } + }); + } - if (context.platform === 'linux-x64') { - it('desktop-linux-snap-x64', async () => { - const packagePath = await context.downloadTarget('linux-snap-x64'); + if (context.skipRuntimeCheck || context.platform === 'linux-x64') { + test('desktop-linux-snap-x64', async () => { + const packagePath = await context.downloadTarget('linux-snap-x64'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installSnap(packagePath); await testDesktopApp(entryPoint); - }); - } + } + }); + } - if (context.platform === 'linux-x64') { - it('desktop-linux-x64', async () => { - const dir = await context.downloadAndUnpack('linux-x64'); - const entryPoint = context.getEntryPoint('desktop', dir); - const dataDir = context.createPortableDataDir(dir); - await testDesktopApp(entryPoint, dataDir); - }); - } + if (context.skipRuntimeCheck || context.platform === 'linux-x64') { + test('desktop-linux-x64', async () => { + const dir = await context.downloadAndUnpack('linux-x64'); + const entryPoint = context.getEntryPoint('desktop', dir); + const dataDir = context.createPortableDataDir(dir); + await testDesktopApp(entryPoint, dataDir); + }); + } - if (context.platform === 'win32-arm64') { - it('desktop-win32-arm64', async () => { - const packagePath = await context.downloadTarget('win32-arm64'); + if (context.skipRuntimeCheck || context.platform === 'win32-arm64') { + test('desktop-win32-arm64', async () => { + const packagePath = await context.downloadTarget('win32-arm64'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installWindowsApp('system', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('system'); - }); - } + } + }); + } - if (context.platform === 'win32-arm64') { - it('desktop-win32-arm64-archive', async () => { - const dir = await context.downloadAndUnpack('win32-arm64-archive'); - context.validateAllAuthenticodeSignatures(dir); - const entryPoint = context.getEntryPoint('desktop', dir); - const dataDir = context.createPortableDataDir(dir); - await testDesktopApp(entryPoint, dataDir); - }); - } + if (context.skipRuntimeCheck || context.platform === 'win32-arm64') { + test('desktop-win32-arm64-archive', async () => { + const dir = await context.downloadAndUnpack('win32-arm64-archive'); + context.validateAllAuthenticodeSignatures(dir); + const entryPoint = context.getEntryPoint('desktop', dir); + const dataDir = context.createPortableDataDir(dir); + await testDesktopApp(entryPoint, dataDir); + }); + } - if (context.platform === 'win32-arm64') { - it('desktop-win32-arm64-user', async () => { - const packagePath = await context.downloadTarget('win32-arm64-user'); + if (context.skipRuntimeCheck || context.platform === 'win32-arm64') { + test('desktop-win32-arm64-user', async () => { + const packagePath = await context.downloadTarget('win32-arm64-user'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installWindowsApp('user', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('user'); - }); - } + } + }); + } - if (context.platform === 'win32-x64') { - it('desktop-win32-x64', async () => { - const packagePath = await context.downloadTarget('win32-x64'); + if (context.skipRuntimeCheck || context.platform === 'win32-x64') { + test('desktop-win32-x64', async () => { + const packagePath = await context.downloadTarget('win32-x64'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installWindowsApp('system', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('system'); - }); - } + } + }); + } - if (context.platform === 'win32-x64') { - it('desktop-win32-x64-archive', async () => { - const dir = await context.downloadAndUnpack('win32-x64-archive'); - context.validateAllAuthenticodeSignatures(dir); - const entryPoint = context.getEntryPoint('desktop', dir); - const dataDir = context.createPortableDataDir(dir); - await testDesktopApp(entryPoint, dataDir); - }); - } + if (context.skipRuntimeCheck || context.platform === 'win32-x64') { + test('desktop-win32-x64-archive', async () => { + const dir = await context.downloadAndUnpack('win32-x64-archive'); + context.validateAllAuthenticodeSignatures(dir); + const entryPoint = context.getEntryPoint('desktop', dir); + const dataDir = context.createPortableDataDir(dir); + await testDesktopApp(entryPoint, dataDir); + }); + } - if (context.platform === 'win32-x64') { - it('desktop-win32-x64-user', async () => { - const packagePath = await context.downloadTarget('win32-x64-user'); + if (context.skipRuntimeCheck || context.platform === 'win32-x64') { + test('desktop-win32-x64-user', async () => { + const packagePath = await context.downloadTarget('win32-x64-user'); + if (!context.skipRuntimeCheck) { const entryPoint = context.installWindowsApp('user', packagePath); context.validateAllAuthenticodeSignatures(path.dirname(entryPoint)); await testDesktopApp(entryPoint); await context.uninstallWindowsApp('user'); - }); + } + }); + } + + async function testDesktopApp(entryPoint: string, dataDir?: string) { + if (context.skipRuntimeCheck) { + return; } - async function testDesktopApp(entryPoint: string, dataDir?: string) { - const test = new UITest(context, dataDir); - const args = dataDir ? [] : [ - '--extensions-dir', test.extensionsDir, - '--user-data-dir', test.userDataDir, - ]; - args.push(test.workspaceDir); + const test = new UITest(context, dataDir); + const args = dataDir ? [] : [ + '--extensions-dir', test.extensionsDir, + '--user-data-dir', test.userDataDir, + ]; + args.push(test.workspaceDir); - context.log(`Starting VS Code ${entryPoint} with args ${args.join(' ')}`); - const app = await _electron.launch({ executablePath: entryPoint, args }); - const window = await app.firstWindow(); + context.log(`Starting VS Code ${entryPoint} with args ${args.join(' ')}`); + const app = await _electron.launch({ executablePath: entryPoint, args }); + const window = await app.firstWindow(); - await test.run(window); + await test.run(window); - context.log('Closing the application'); - await app.close(); + context.log('Closing the application'); + await app.close(); - test.validate(); - } - }); + test.validate(); + } } diff --git a/test/sanity/src/index.ts b/test/sanity/src/index.ts index 8ce74cae96c..f88b0679953 100644 --- a/test/sanity/src/index.ts +++ b/test/sanity/src/index.ts @@ -3,8 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import fs from 'fs'; import minimist from 'minimist'; import Mocha, { MochaOptions } from 'mocha'; +import path from 'path'; const options = minimist(process.argv.slice(2), { string: ['fgrep', 'grep', 'test-results'], @@ -19,6 +21,8 @@ if (options.help) { console.info(` --quality, -q The quality to test (required, "stable", "insider" or "exploration")`); console.info(' --no-cleanup Do not cleanup downloaded files after each test'); console.info(' --no-signing-check Skip Authenticode and codesign signature checks'); + console.info(' --no-headless Run tests with a visible UI (desktop tests only)'); + console.info(' --no-runtime-check Enable all tests regardless of platform and skip executable runs'); console.info(' --grep, -g Only run tests matching the given '); console.info(' --fgrep, -f Only run tests containing the given '); console.info(' --test-results, -t Output test results in JUnit format to the specified path'); @@ -38,6 +42,10 @@ const mochaOptions: MochaOptions = { reporterOptions: testResults ? { mochaFile: testResults, outputs: true } : undefined, }; +if (testResults) { + fs.mkdirSync(path.dirname(testResults), { recursive: true }); +} + const mocha = new Mocha(mochaOptions); mocha.addFile(require.resolve('./main.js')); mocha.run(failures => { diff --git a/test/sanity/src/main.ts b/test/sanity/src/main.ts index e522356bc85..6ca2773d66c 100644 --- a/test/sanity/src/main.ts +++ b/test/sanity/src/main.ts @@ -12,9 +12,9 @@ import { setup as setupServerWebTests } from './serverWeb.test'; const options = minimist(process.argv.slice(2), { string: ['commit', 'quality'], - boolean: ['cleanup', 'verbose', 'signing-check'], + boolean: ['cleanup', 'verbose', 'signing-check', 'headless', 'runtime-check'], alias: { commit: 'c', quality: 'q', verbose: 'v' }, - default: { cleanup: true, verbose: false, 'signing-check': true }, + default: { cleanup: true, verbose: false, 'signing-check': true, headless: true, 'runtime-check': true }, }); if (!options.commit) { @@ -25,24 +25,28 @@ if (!options.quality) { throw new Error('--quality is required'); } -const context = new TestContext(options.quality, options.commit, options.verbose, !options['signing-check']); +const context = new TestContext( + options.quality, + options.commit, + options.verbose, + !options['signing-check'], + options.headless, + !options['runtime-check']); -describe('VS Code Sanity Tests', () => { - beforeEach(function () { - context.currentTest = this.currentTest!; - const cwd = context.createTempDir(); - process.chdir(cwd); - context.log(`Changed working directory to: ${cwd}`); - }); - - if (options.cleanup) { - afterEach(() => { - context.cleanup(); - }); - } - - setupCliTests(context); - setupDesktopTests(context); - setupServerTests(context); - setupServerWebTests(context); +beforeEach(function () { + context.currentTest = this.currentTest!; + const cwd = context.createTempDir(); + process.chdir(cwd); + context.log(`Changed working directory to: ${cwd}`); }); + +if (options.cleanup) { + afterEach(() => { + context.cleanup(); + }); +} + +setupCliTests(context); +setupDesktopTests(context); +setupServerTests(context); +setupServerWebTests(context); diff --git a/test/sanity/src/server.test.ts b/test/sanity/src/server.test.ts index 70738ab6ddf..e9c662be2bc 100644 --- a/test/sanity/src/server.test.ts +++ b/test/sanity/src/server.test.ts @@ -5,138 +5,141 @@ import assert from 'assert'; import { spawn } from 'child_process'; +import { test } from 'mocha'; import os from 'os'; import { TestContext } from './context'; export function setup(context: TestContext) { - describe('Server', () => { - if (context.platform === 'linux-arm64') { - it('server-alpine-arm64', async () => { - const dir = await context.downloadAndUnpack('server-alpine-arm64'); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); + if (context.skipRuntimeCheck || context.platform === 'linux-arm64') { + test('server-alpine-arm64', async () => { + const dir = await context.downloadAndUnpack('server-alpine-arm64'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-x64') { + test('server-alpine-x64', async () => { + const dir = await context.downloadAndUnpack('server-linux-alpine'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'darwin-arm64') { + test('server-darwin-arm64', async () => { + const dir = await context.downloadAndUnpack('server-darwin-arm64'); + context.validateAllCodesignSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'darwin-x64') { + test('server-darwin-x64', async () => { + const dir = await context.downloadAndUnpack('server-darwin'); + context.validateAllCodesignSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-arm64') { + test('server-linux-arm64', async () => { + const dir = await context.downloadAndUnpack('server-linux-arm64'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-arm') { + test('server-linux-armhf', async () => { + const dir = await context.downloadAndUnpack('server-linux-armhf'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-x64') { + test('server-linux-x64', async () => { + const dir = await context.downloadAndUnpack('server-linux-x64'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'win32-arm64') { + test('server-win32-arm64', async () => { + const dir = await context.downloadAndUnpack('server-win32-arm64'); + context.validateAllAuthenticodeSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'win32-x64') { + test('server-win32-x64', async () => { + const dir = await context.downloadAndUnpack('server-win32-x64'); + context.validateAllAuthenticodeSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + async function testServer(entryPoint: string) { + if (context.skipRuntimeCheck) { + return; } - if (context.platform === 'linux-x64') { - it('server-alpine-x64', async () => { - const dir = await context.downloadAndUnpack('server-linux-alpine'); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } + const args = [ + '--accept-server-license-terms', + '--connection-token', context.getRandomToken(), + '--port', context.getRandomPort(), + '--server-data-dir', context.createTempDir(), + '--extensions-dir', context.createTempDir(), + ]; - if (context.platform === 'darwin-arm64') { - it('server-darwin-arm64', async () => { - const dir = await context.downloadAndUnpack('server-darwin-arm64'); - context.validateAllCodesignSignatures(dir); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } + context.log(`Starting server ${entryPoint} with args ${args.join(' ')}`); + const server = spawn(entryPoint, args, { shell: true, detached: os.platform() !== 'win32' }); - if (context.platform === 'darwin-x64') { - it('server-darwin-x64', async () => { - const dir = await context.downloadAndUnpack('server-darwin'); - context.validateAllCodesignSignatures(dir); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } + let testError: Error | undefined; - if (context.platform === 'linux-arm64') { - it('server-linux-arm64', async () => { - const dir = await context.downloadAndUnpack('server-linux-arm64'); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } + server.stderr.on('data', (data) => { + context.error(`[Server Error] ${data.toString().trim()}`); + }); - if (context.platform === 'linux-arm') { - it('server-linux-armhf', async () => { - const dir = await context.downloadAndUnpack('server-linux-armhf'); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } - - if (context.platform === 'linux-x64') { - it('server-linux-x64', async () => { - const dir = await context.downloadAndUnpack('server-linux-x64'); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } - - if (context.platform === 'win32-arm64') { - it('server-win32-arm64', async () => { - const dir = await context.downloadAndUnpack('server-win32-arm64'); - context.validateAllAuthenticodeSignatures(dir); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } - - if (context.platform === 'win32-x64') { - it('server-win32-x64', async () => { - const dir = await context.downloadAndUnpack('server-win32-x64'); - context.validateAllAuthenticodeSignatures(dir); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } - - async function testServer(entryPoint: string) { - const args = [ - '--accept-server-license-terms', - '--connection-token', context.getRandomToken(), - '--port', context.getRandomPort(), - '--server-data-dir', context.createTempDir(), - '--extensions-dir', context.createTempDir(), - ]; - - context.log(`Starting server ${entryPoint} with args ${args.join(' ')}`); - const server = spawn(entryPoint, args, { shell: true, detached: os.platform() !== 'win32' }); - - let testError: Error | undefined; - - server.stderr.on('data', (data) => { - context.error(`[Server Error] ${data.toString().trim()}`); + server.stdout.on('data', (data) => { + const text = data.toString().trim(); + text.split('\n').forEach((line: string) => { + context.log(`[Server Output] ${line}`); }); - server.stdout.on('data', (data) => { - const text = data.toString().trim(); - text.split('\n').forEach((line: string) => { - context.log(`[Server Output] ${line}`); - }); - - const port = /Extension host agent listening on (\d+)/.exec(text)?.[1]; - if (port) { - const url = context.getWebServerUrl(port); - url.pathname = '/version'; - runWebTest(url.toString()) - .catch((error) => { testError = error; }) - .finally(() => context.killProcessTree(server.pid!)); - } - }); - - await new Promise((resolve, reject) => { - server.on('error', reject); - server.on('exit', resolve); - }); - - if (testError) { - throw testError; + const port = /Extension host agent listening on (\d+)/.exec(text)?.[1]; + if (port) { + const url = context.getWebServerUrl(port); + url.pathname = '/version'; + runWebTest(url.toString()) + .catch((error) => { testError = error; }) + .finally(() => context.killProcessTree(server.pid!)); } - } + }); - async function runWebTest(url: string) { - context.log(`Fetching ${url}`); - const response = await fetch(url); - assert.strictEqual(response.status, 200, `Expected status 200 but got ${response.status}`); + await new Promise((resolve, reject) => { + server.on('error', reject); + server.on('exit', resolve); + }); - const text = await response.text(); - assert.strictEqual(text, context.commit, `Expected commit ${context.commit} but got ${text}`); + if (testError) { + throw testError; } - }); + } + + async function runWebTest(url: string) { + context.log(`Fetching ${url}`); + const response = await fetch(url); + assert.strictEqual(response.status, 200, `Expected status 200 but got ${response.status}`); + + const text = await response.text(); + assert.strictEqual(text, context.commit, `Expected commit ${context.commit} but got ${text}`); + } } diff --git a/test/sanity/src/serverWeb.test.ts b/test/sanity/src/serverWeb.test.ts index 5a769b8805d..d35d0d4d0d5 100644 --- a/test/sanity/src/serverWeb.test.ts +++ b/test/sanity/src/serverWeb.test.ts @@ -4,150 +4,153 @@ *--------------------------------------------------------------------------------------------*/ import { spawn } from 'child_process'; +import { test } from 'mocha'; import os from 'os'; import { TestContext } from './context'; import { UITest } from './uiTest'; export function setup(context: TestContext) { - describe('Server Web', () => { - if (context.platform === 'linux-arm64') { - it('server-web-alpine-arm64', async () => { - const dir = await context.downloadAndUnpack('server-alpine-arm64-web'); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); + if (context.skipRuntimeCheck || context.platform === 'linux-arm64') { + test('server-web-alpine-arm64', async () => { + const dir = await context.downloadAndUnpack('server-alpine-arm64-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-x64') { + test('server-web-alpine-x64', async () => { + const dir = await context.downloadAndUnpack('server-linux-alpine-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'darwin-arm64') { + test('server-web-darwin-arm64', async () => { + const dir = await context.downloadAndUnpack('server-darwin-arm64-web'); + context.validateAllCodesignSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'darwin-x64') { + test('server-web-darwin-x64', async () => { + const dir = await context.downloadAndUnpack('server-darwin-web'); + context.validateAllCodesignSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-arm64') { + test('server-web-linux-arm64', async () => { + const dir = await context.downloadAndUnpack('server-linux-arm64-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-arm') { + test('server-web-linux-armhf', async () => { + const dir = await context.downloadAndUnpack('server-linux-armhf-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'linux-x64') { + test('server-web-linux-x64', async () => { + const dir = await context.downloadAndUnpack('server-linux-x64-web'); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'win32-arm64') { + test('server-web-win32-arm64', async () => { + const dir = await context.downloadAndUnpack('server-win32-arm64-web'); + context.validateAllAuthenticodeSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + if (context.skipRuntimeCheck || context.platform === 'win32-x64') { + test('server-web-win32-x64', async () => { + const dir = await context.downloadAndUnpack('server-win32-x64-web'); + context.validateAllAuthenticodeSignatures(dir); + const entryPoint = context.getServerEntryPoint(dir); + await testServer(entryPoint); + }); + } + + async function testServer(entryPoint: string) { + if (context.skipRuntimeCheck) { + return; } - if (context.platform === 'linux-x64') { - it('server-web-alpine-x64', async () => { - const dir = await context.downloadAndUnpack('server-linux-alpine-web'); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } + const token = context.getRandomToken(); + const test = new UITest(context); + const args = [ + '--accept-server-license-terms', + '--port', context.getRandomPort(), + '--connection-token', token, + '--server-data-dir', context.createTempDir(), + '--extensions-dir', test.extensionsDir, + '--user-data-dir', test.userDataDir + ]; - if (context.platform === 'darwin-arm64') { - it('server-web-darwin-arm64', async () => { - const dir = await context.downloadAndUnpack('server-darwin-arm64-web'); - context.validateAllCodesignSignatures(dir); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } + context.log(`Starting server ${entryPoint} with args ${args.join(' ')}`); + const server = spawn(entryPoint, args, { shell: true, detached: os.platform() !== 'win32' }); - if (context.platform === 'darwin-x64') { - it('server-web-darwin-x64', async () => { - const dir = await context.downloadAndUnpack('server-darwin-web'); - context.validateAllCodesignSignatures(dir); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } + let testError: Error | undefined; - if (context.platform === 'linux-arm64') { - it('server-web-linux-arm64', async () => { - const dir = await context.downloadAndUnpack('server-linux-arm64-web'); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } + server.stderr.on('data', (data) => { + context.error(`[Server Error] ${data.toString().trim()}`); + }); - if (context.platform === 'linux-arm') { - it('server-web-linux-armhf', async () => { - const dir = await context.downloadAndUnpack('server-linux-armhf-web'); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } - - if (context.platform === 'linux-x64') { - it('server-web-linux-x64', async () => { - const dir = await context.downloadAndUnpack('server-linux-x64-web'); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } - - if (context.platform === 'win32-arm64') { - it('server-web-win32-arm64', async () => { - const dir = await context.downloadAndUnpack('server-win32-arm64-web'); - context.validateAllAuthenticodeSignatures(dir); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } - - if (context.platform === 'win32-x64') { - it('server-web-win32-x64', async () => { - const dir = await context.downloadAndUnpack('server-win32-x64-web'); - context.validateAllAuthenticodeSignatures(dir); - const entryPoint = context.getServerEntryPoint(dir); - await testServer(entryPoint); - }); - } - - async function testServer(entryPoint: string) { - const token = context.getRandomToken(); - const test = new UITest(context); - const args = [ - '--accept-server-license-terms', - '--port', context.getRandomPort(), - '--connection-token', token, - '--server-data-dir', context.createTempDir(), - '--extensions-dir', test.extensionsDir, - '--user-data-dir', test.userDataDir - ]; - - context.log(`Starting server ${entryPoint} with args ${args.join(' ')}`); - const server = spawn(entryPoint, args, { shell: true, detached: os.platform() !== 'win32' }); - - let testError: Error | undefined; - - server.stderr.on('data', (data) => { - context.error(`[Server Error] ${data.toString().trim()}`); + server.stdout.on('data', (data) => { + const text = data.toString().trim(); + text.split('\n').forEach((line: string) => { + context.log(`[Server Output] ${line}`); }); - server.stdout.on('data', (data) => { - const text = data.toString().trim(); - text.split('\n').forEach((line: string) => { - context.log(`[Server Output] ${line}`); - }); - - const port = /Extension host agent listening on (\d+)/.exec(text)?.[1]; - if (port) { - const url = context.getWebServerUrl(port, token, test.workspaceDir).toString(); - runUITest(url, test) - .catch((error) => { testError = error; }) - .finally(() => context.killProcessTree(server.pid!)); - } - }); - - await new Promise((resolve, reject) => { - server.on('error', reject); - server.on('exit', resolve); - }); - - if (testError) { - throw testError; + const port = /Extension host agent listening on (\d+)/.exec(text)?.[1]; + if (port) { + const url = context.getWebServerUrl(port, token, test.workspaceDir).toString(); + runUITest(url, test) + .catch((error) => { testError = error; }) + .finally(() => context.killProcessTree(server.pid!)); } + }); + + await new Promise((resolve, reject) => { + server.on('error', reject); + server.on('exit', resolve); + }); + + if (testError) { + throw testError; } + } - async function runUITest(url: string, test: UITest) { - const browser = await context.launchBrowser(); - const page = await browser.newPage(); + async function runUITest(url: string, test: UITest) { + const browser = await context.launchBrowser(); + const page = await browser.newPage(); - context.log(`Navigating to ${url}`); - await page.goto(url, { waitUntil: 'networkidle' }); + context.log(`Navigating to ${url}`); + await page.goto(url, { waitUntil: 'networkidle' }); - context.log('Waiting for the workbench to load'); - await page.waitForSelector('.monaco-workbench'); + context.log('Waiting for the workbench to load'); + await page.waitForSelector('.monaco-workbench'); - await test.run(page); + await test.run(page); - context.log('Closing browser'); - await browser.close(); + context.log('Closing browser'); + await browser.close(); - test.validate(); - } - }); + test.validate(); + } } diff --git a/test/smoke/src/areas/languages/languages.test.ts b/test/smoke/src/areas/languages/languages.test.ts index 9ec05b0c9e2..3db5c7c9894 100644 --- a/test/smoke/src/areas/languages/languages.test.ts +++ b/test/smoke/src/areas/languages/languages.test.ts @@ -15,6 +15,7 @@ export function setup(logger: Logger) { it('verifies quick outline (js)', async function () { const app = this.app as Application; + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.openQuickOutline(); @@ -24,6 +25,7 @@ export function setup(logger: Logger) { it('verifies quick outline (css)', async function () { const app = this.app as Application; + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.quickaccess.openQuickOutline(); @@ -33,6 +35,7 @@ export function setup(logger: Logger) { it('verifies problems view (css)', async function () { const app = this.app as Application; + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.editor.waitForTypeInEditor('style.css', '.foo{}'); @@ -45,6 +48,7 @@ export function setup(logger: Logger) { it('verifies settings (css)', async function () { const app = this.app as Application; + await app.workbench.settingsEditor.addUserSetting('css.lint.emptyRules', '"error"'); await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); diff --git a/test/smoke/src/areas/multiroot/multiroot.test.ts b/test/smoke/src/areas/multiroot/multiroot.test.ts index cedbac51e7a..f48f1cad1b7 100644 --- a/test/smoke/src/areas/multiroot/multiroot.test.ts +++ b/test/smoke/src/areas/multiroot/multiroot.test.ts @@ -46,6 +46,9 @@ export function setup(logger: Logger) { // Shared before/after handling installAllHandlers(logger, opts => { + if (!opts.workspacePath) { + throw new Error('Multiroot tests require a workspace to be open'); + } const workspacePath = createWorkspaceFile(opts.workspacePath); return { ...opts, workspacePath }; }); diff --git a/test/smoke/src/areas/notebook/notebook.test.ts b/test/smoke/src/areas/notebook/notebook.test.ts index a0b81837266..39fc1e339f5 100644 --- a/test/smoke/src/areas/notebook/notebook.test.ts +++ b/test/smoke/src/areas/notebook/notebook.test.ts @@ -21,6 +21,7 @@ export function setup(logger: Logger) { after(async function () { const app = this.app as Application; + cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }); cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }); }); diff --git a/test/smoke/src/areas/search/search.test.ts b/test/smoke/src/areas/search/search.test.ts index f635ad827df..40db1cb07c0 100644 --- a/test/smoke/src/areas/search/search.test.ts +++ b/test/smoke/src/areas/search/search.test.ts @@ -15,6 +15,7 @@ export function setup(logger: Logger) { after(function () { const app = this.app as Application; + retry(async () => cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }), 0, 5); retry(async () => cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }), 0, 5); }); diff --git a/test/smoke/src/areas/statusbar/statusbar.test.ts b/test/smoke/src/areas/statusbar/statusbar.test.ts index ccfbeb5772f..edf594ad7e9 100644 --- a/test/smoke/src/areas/statusbar/statusbar.test.ts +++ b/test/smoke/src/areas/statusbar/statusbar.test.ts @@ -15,6 +15,7 @@ export function setup(logger: Logger) { it('verifies presence of all default status bar elements', async function () { const app = this.app as Application; + await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.BRANCH_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.SYNC_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.PROBLEMS_STATUS); @@ -29,6 +30,7 @@ export function setup(logger: Logger) { it(`verifies that 'quick input' opens when clicking on status bar elements`, async function () { const app = this.app as Application; + await app.workbench.statusbar.clickOn(StatusBarElement.BRANCH_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); @@ -56,6 +58,7 @@ export function setup(logger: Logger) { it(`verifies if changing EOL is reflected in the status bar`, async function () { const app = this.app as Application; + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.EOL_STATUS); diff --git a/test/smoke/src/areas/workbench/data-loss.test.ts b/test/smoke/src/areas/workbench/data-loss.test.ts index 3e27f1acba9..3fb16b61c74 100644 --- a/test/smoke/src/areas/workbench/data-loss.test.ts +++ b/test/smoke/src/areas/workbench/data-loss.test.ts @@ -27,6 +27,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); + // Open 3 editors await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.runCommand('View: Keep Editor');