From 3f197a65831ce81ce2526f90e845caef0bb57f9e Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:18:02 -0800 Subject: [PATCH 01/67] Initial sketch of a controller based chat session item API For #276243 Explores moving the chat session item api to use a controller instead of a provider --- eslint.config.js | 1 + .../vscode.proposed.chatSessionsProvider.d.ts | 106 +++++++++++++----- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 52eb95c5ff0..0cf09d0b24f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -899,6 +899,7 @@ export default tseslint.config( ], 'verbs': [ 'accept', + 'archive', 'change', 'close', 'collapse', diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 2ec68c1731e..12c664326d6 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -26,30 +26,96 @@ declare module 'vscode' { InProgress = 2 } + export namespace chat { + /** + * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. + */ + export function createChatSessionItemController(id: string): ChatSessionItemController; + } + /** * Provides a list of information about chat sessions. */ - export interface ChatSessionItemProvider { - /** - * Event that the provider can fire to signal that chat sessions have changed. - */ - readonly onDidChangeChatSessionItems: Event; + export class ChatSessionItemController { + readonly id: string; /** - * Provides a list of chat sessions. + * Unregisters the controller, disposing of its associated chat session items. */ - // TODO: Do we need a flag to try auth if needed? - provideChatSessionItems(token: CancellationToken): ProviderResult; - - // #region Unstable parts of API + dispose(): void; /** - * Event that the provider can fire to signal that the current (original) chat session should be replaced with a new (modified) chat session. - * The UI can use this information to gracefully migrate the user to the new session. + * Managed collection of chat session items */ - readonly onDidCommitChatSessionItem: Event<{ original: ChatSessionItem /** untitled */; modified: ChatSessionItem /** newly created */ }>; + readonly items: ChatSessionItemCollection; - // #endregion + /** + * Creates a new managed chat session item that be added to the collection. + */ + createChatSessionItem(resource: Uri, label: string): ChatSessionItem; + + /** + * Handler called to refresh the collection of chat session items. + * + * This is also called on first load to get the initial set of items. + */ + refreshHandler: () => Thenable; + + /** + * Fired when an item is archived by the editor + * + * TODO: expose archive state on the item too? Or should this + */ + readonly onDidArchiveChatSessionItem: Event; + + /** + * Fired when an item is disposed by the editor + */ + readonly onDidDisposeChatSessionItem: Event; + } + + /** + * A collection of chat session items. It provides operations for managing and iterating over the items. + */ + export interface ChatSessionItemCollection extends Iterable { + /** + * Gets the number of items in the collection. + */ + readonly size: number; + + /** + * Replaces the items stored by the collection. + * @param items Items to store. + */ + replace(items: readonly ChatSessionItem[]): void; + + /** + * Iterate over each entry in this collection. + * + * @param callback Function to execute for each entry. + * @param thisArg The `this` context used when invoking the handler function. + */ + forEach(callback: (item: ChatSessionItem, collection: ChatSessionItemCollection) => unknown, thisArg?: any): void; + + /** + * Adds the chat session item to the collection. If an item with the same resource URI already + * exists, it'll be replaced. + * @param item Item to add. + */ + add(item: ChatSessionItem): void; + + /** + * Removes a single chat session item from the collection. + * @param resource Item resource to delete. + */ + delete(resource: Uri): void; + + /** + * Efficiently gets a chat session item by resource, if it exists, in the collection. + * @param resource Item resource to get. + * @returns The found item or undefined if it does not exist. + */ + get(resource: Uri): ChatSessionItem | undefined; } export interface ChatSessionItem { @@ -268,18 +334,6 @@ declare module 'vscode' { } export namespace chat { - /** - * Registers a new {@link ChatSessionItemProvider chat session item provider}. - * - * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. - * - * @param chatSessionType The type of chat session the provider is for. - * @param provider The provider to register. - * - * @returns A disposable that unregisters the provider when disposed. - */ - export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; - /** * Registers a new {@link ChatSessionContentProvider chat session content provider}. * From e45ac30b90b0de04d8cafda7ab6ad96922eaa5bd Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:46:29 -0800 Subject: [PATCH 02/67] Hookup basic proxy impl --- .../workbench/api/common/extHost.api.impl.ts | 5 +- .../api/common/extHostChatSessions.ts | 224 +++++++++++++++--- src/vs/workbench/api/common/extHostTypes.ts | 11 + .../vscode.proposed.chatSessionsProvider.d.ts | 6 +- 4 files changed, 211 insertions(+), 35 deletions(-) diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ccfd8731f6e..99e61873974 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1525,9 +1525,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); }, - registerChatSessionItemProvider: (chatSessionType: string, provider: vscode.ChatSessionItemProvider) => { + createChatSessionItemController: (id: string, refreshHandler: () => Thenable) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); - return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); + return extHostChatSessions.createChatSessionItemController(extension, id, refreshHandler); }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); @@ -1865,6 +1865,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I InteractiveSessionVoteDirection: extHostTypes.InteractiveSessionVoteDirection, ChatCopyKind: extHostTypes.ChatCopyKind, ChatSessionChangedFile: extHostTypes.ChatSessionChangedFile, + ChatSessionItemController: extHostTypes.ChatSessionItemController, ChatEditingSessionActionOutcome: extHostTypes.ChatEditingSessionActionOutcome, InteractiveEditorResponseFeedbackKind: extHostTypes.InteractiveEditorResponseFeedbackKind, DebugStackFrame: extHostTypes.DebugStackFrame, diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 260627104c1..2c926f086e9 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -2,12 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-native-private */ import type * as vscode from 'vscode'; import { coalesce } from '../../../base/common/arrays.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; @@ -29,6 +31,148 @@ import { basename } from '../../../base/common/resources.js'; import { Diagnostic } from './extHostTypeConverters.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; +// #region Chat Session Item Controller + +class ChatSessionItemImpl implements vscode.ChatSessionItem { + readonly resource: vscode.Uri; + #label: string; + #iconPath?: vscode.IconPath; + #description?: string | vscode.MarkdownString; + #badge?: string | vscode.MarkdownString; + #status?: vscode.ChatSessionStatus; + #tooltip?: string | vscode.MarkdownString; + #timing?: { startTime: number; endTime?: number }; + #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; + #onChanged: () => void; + + constructor(resource: vscode.Uri, label: string, onChanged: () => void) { + this.resource = resource; + this.#label = label; + this.#onChanged = onChanged; + } + + get label(): string { + return this.#label; + } + + set label(value: string) { + this.#label = value; + this.#onChanged(); + } + + get iconPath(): vscode.IconPath | undefined { + return this.#iconPath; + } + + set iconPath(value: vscode.IconPath | undefined) { + this.#iconPath = value; + this.#onChanged(); + } + + get description(): string | vscode.MarkdownString | undefined { + return this.#description; + } + + set description(value: string | vscode.MarkdownString | undefined) { + this.#description = value; + this.#onChanged(); + } + + get badge(): string | vscode.MarkdownString | undefined { + return this.#badge; + } + + set badge(value: string | vscode.MarkdownString | undefined) { + this.#badge = value; + this.#onChanged(); + } + + get status(): vscode.ChatSessionStatus | undefined { + return this.#status; + } + + set status(value: vscode.ChatSessionStatus | undefined) { + this.#status = value; + this.#onChanged(); + } + + get tooltip(): string | vscode.MarkdownString | undefined { + return this.#tooltip; + } + + set tooltip(value: string | vscode.MarkdownString | undefined) { + this.#tooltip = value; + this.#onChanged(); + } + + get timing(): { startTime: number; endTime?: number } | undefined { + return this.#timing; + } + + set timing(value: { startTime: number; endTime?: number } | undefined) { + this.#timing = value; + this.#onChanged(); + } + + get changes(): readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined { + return this.#changes; + } + + set changes(value: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined) { + this.#changes = value; + this.#onChanged(); + } +} + +class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection { + readonly #items = new ResourceMap(); + #onItemsChanged: () => void; + + constructor(onItemsChanged: () => void) { + this.#onItemsChanged = onItemsChanged; + } + + get size(): number { + return this.#items.size; + } + + replace(items: readonly vscode.ChatSessionItem[]): void { + this.#items.clear(); + for (const item of items) { + this.#items.set(item.resource, item); + } + this.#onItemsChanged(); + } + + forEach(callback: (item: vscode.ChatSessionItem, collection: vscode.ChatSessionItemCollection) => unknown, thisArg?: any): void { + for (const [_, item] of this.#items) { + callback.call(thisArg, item, this); + } + } + + add(item: vscode.ChatSessionItem): void { + this.#items.set(item.resource, item); + this.#onItemsChanged(); + } + + delete(resource: vscode.Uri): void { + this.#items.delete(resource); + this.#onItemsChanged(); + } + + get(resource: vscode.Uri): vscode.ChatSessionItem | undefined { + return this.#items.get(resource); + } + + *[Symbol.iterator](): Generator { + for (const [uri, item] of this.#items) { + yield [uri, item] as const; + } + } +} + +// #endregion + class ExtHostChatSession { private _stream: ChatAgentResponseStream; @@ -56,9 +200,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio private static _sessionHandlePool = 0; private readonly _proxy: Proxied; - private readonly _chatSessionItemProviders = new Map(); @@ -68,7 +212,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio readonly capabilities?: vscode.ChatSessionCapabilities; readonly disposable: DisposableStore; }>(); - private _nextChatSessionItemProviderHandle = 0; + private _nextChatSessionItemControllerHandle = 0; private _nextChatSessionContentProviderHandle = 0; /** @@ -111,30 +255,50 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); } - registerChatSessionItemProvider(extension: IExtensionDescription, chatSessionType: string, provider: vscode.ChatSessionItemProvider): vscode.Disposable { - const handle = this._nextChatSessionItemProviderHandle++; + createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { + const handle = this._nextChatSessionItemControllerHandle++; const disposables = new DisposableStore(); - this._chatSessionItemProviders.set(handle, { provider, extension, disposable: disposables, sessionType: chatSessionType }); - this._proxy.$registerChatSessionItemProvider(handle, chatSessionType); - if (provider.onDidChangeChatSessionItems) { - disposables.add(provider.onDidChangeChatSessionItems(() => { - this._proxy.$onDidChangeChatSessionItems(handle); - })); - } - if (provider.onDidCommitChatSessionItem) { - disposables.add(provider.onDidCommitChatSessionItem((e) => { - const { original, modified } = e; - this._proxy.$onDidCommitChatSessionItem(handle, original.resource, modified.resource); - })); - } - return { + // TODO: Currently not hooked up + const onDidArchiveChatSessionItem = disposables.add(new Emitter()); + const onDidDisposeChatSessionItem = disposables.add(new Emitter()); + + const collection = new ChatSessionItemCollectionImpl(() => { + this._proxy.$onDidChangeChatSessionItems(handle); + }); + + let isDisposed = false; + + const controller: vscode.ChatSessionItemController = { + id, + refreshHandler, + items: collection, + onDidArchiveChatSessionItem: onDidArchiveChatSessionItem.event, + onDidDisposeChatSessionItem: onDidDisposeChatSessionItem.event, + createChatSessionItem: (resource: vscode.Uri, label: string) => { + if (isDisposed) { + throw new Error('ChatSessionItemController has been disposed'); + } + + return new ChatSessionItemImpl(resource, label, () => { + this._proxy.$onDidChangeChatSessionItems(handle); + }); + }, dispose: () => { - this._chatSessionItemProviders.delete(handle); + isDisposed = true; disposables.dispose(); - this._proxy.$unregisterChatSessionItemProvider(handle); - } + }, }; + + this._chatSessionItemControllers.set(handle, { controller, extension, disposable: disposables, sessionType: id }); + this._proxy.$registerChatSessionItemProvider(handle, id); + + disposables.add(toDisposable(() => { + this._chatSessionItemControllers.delete(handle); + this._proxy.$unregisterChatSessionItemProvider(handle); + })); + + return controller; } registerChatSessionContentProvider(extension: IExtensionDescription, chatSessionScheme: string, chatParticipant: vscode.ChatParticipant, provider: vscode.ChatSessionContentProvider, capabilities?: vscode.ChatSessionCapabilities): vscode.Disposable { @@ -204,19 +368,19 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const entry = this._chatSessionItemProviders.get(handle); + const entry = this._chatSessionItemControllers.get(handle); if (!entry) { - this._logService.error(`No provider registered for handle ${handle}`); + this._logService.error(`No controller registered for handle ${handle}`); return []; } - const sessions = await entry.provider.provideChatSessionItems(token); - if (!sessions) { - return []; - } + // Call the refresh handler to populate items + await entry.controller.refreshHandler(); + + const items = [...entry.controller.items]; const response: IChatSessionItem[] = []; - for (const sessionContent of sessions) { + for (const [_, sessionContent] of items) { this._sessionItems.set(sessionContent.resource, sessionContent); response.push(this.convertChatSessionItem(entry.sessionType, sessionContent)); } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 41cbfdd1738..0a718a8c547 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3431,6 +3431,17 @@ export class ChatSessionChangedFile { constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } } +// Stub class - actual implementation is in extHostChatSessions.ts +export class ChatSessionItemController { + readonly id!: string; + readonly items!: vscode.ChatSessionItemCollection; + refreshHandler!: () => Thenable; + readonly onDidArchiveChatSessionItem!: vscode.Event; + readonly onDidDisposeChatSessionItem!: vscode.Event; + createChatSessionItem(_resource: vscode.Uri, _label: string): vscode.ChatSessionItem { throw new Error('Stub'); } + dispose(): void { throw new Error('Stub'); } +} + export enum ChatResponseReferencePartStatusKind { Complete = 1, Partial = 2, diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 12c664326d6..c44f0991d36 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -30,7 +30,7 @@ declare module 'vscode' { /** * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. */ - export function createChatSessionItemController(id: string): ChatSessionItemController; + export function createChatSessionItemController(id: string, refreshHandler: () => Thenable): ChatSessionItemController; } /** @@ -64,7 +64,7 @@ declare module 'vscode' { /** * Fired when an item is archived by the editor * - * TODO: expose archive state on the item too? Or should this + * TODO: expose archive state on the item too? */ readonly onDidArchiveChatSessionItem: Event; @@ -77,7 +77,7 @@ declare module 'vscode' { /** * A collection of chat session items. It provides operations for managing and iterating over the items. */ - export interface ChatSessionItemCollection extends Iterable { + export interface ChatSessionItemCollection extends Iterable { /** * Gets the number of items in the collection. */ From 25bdc74f90a7494e5438d7d332da263331c57020 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:44:43 -0800 Subject: [PATCH 03/67] Cleanup --- .../workbench/api/common/extHost.api.impl.ts | 5 +- .../api/common/extHostChatSessions.ts | 122 +++++++++++++----- src/vs/workbench/api/common/extHostTypes.ts | 11 -- .../vscode.proposed.chatSessionsProvider.d.ts | 45 ++++++- 4 files changed, 135 insertions(+), 48 deletions(-) diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 99e61873974..ad4c3bf659f 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1525,6 +1525,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); }, + registerChatSessionItemProvider: (id: string, provider: vscode.ChatSessionItemProvider) => { + checkProposedApiEnabled(extension, 'chatSessionsProvider'); + return extHostChatSessions.registerChatSessionItemProvider(extension, id, provider); + }, createChatSessionItemController: (id: string, refreshHandler: () => Thenable) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.createChatSessionItemController(extension, id, refreshHandler); @@ -1865,7 +1869,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I InteractiveSessionVoteDirection: extHostTypes.InteractiveSessionVoteDirection, ChatCopyKind: extHostTypes.ChatCopyKind, ChatSessionChangedFile: extHostTypes.ChatSessionChangedFile, - ChatSessionItemController: extHostTypes.ChatSessionItemController, ChatEditingSessionActionOutcome: extHostTypes.ChatEditingSessionActionOutcome, InteractiveEditorResponseFeedbackKind: extHostTypes.InteractiveEditorResponseFeedbackKind, DebugStackFrame: extHostTypes.DebugStackFrame, diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 2c926f086e9..6ecdfc4d375 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -56,8 +56,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set label(value: string) { - this.#label = value; - this.#onChanged(); + if (this.#label !== value) { + this.#label = value; + this.#onChanged(); + } } get iconPath(): vscode.IconPath | undefined { @@ -65,8 +67,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set iconPath(value: vscode.IconPath | undefined) { - this.#iconPath = value; - this.#onChanged(); + if (this.#iconPath !== value) { + this.#iconPath = value; + this.#onChanged(); + } } get description(): string | vscode.MarkdownString | undefined { @@ -74,8 +78,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set description(value: string | vscode.MarkdownString | undefined) { - this.#description = value; - this.#onChanged(); + if (this.#description !== value) { + this.#description = value; + this.#onChanged(); + } } get badge(): string | vscode.MarkdownString | undefined { @@ -83,8 +89,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set badge(value: string | vscode.MarkdownString | undefined) { - this.#badge = value; - this.#onChanged(); + if (this.#badge !== value) { + this.#badge = value; + this.#onChanged(); + } } get status(): vscode.ChatSessionStatus | undefined { @@ -92,8 +100,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set status(value: vscode.ChatSessionStatus | undefined) { - this.#status = value; - this.#onChanged(); + if (this.#status !== value) { + this.#status = value; + this.#onChanged(); + } } get tooltip(): string | vscode.MarkdownString | undefined { @@ -101,8 +111,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set tooltip(value: string | vscode.MarkdownString | undefined) { - this.#tooltip = value; - this.#onChanged(); + if (this.#tooltip !== value) { + this.#tooltip = value; + this.#onChanged(); + } } get timing(): { startTime: number; endTime?: number } | undefined { @@ -110,8 +122,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set timing(value: { startTime: number; endTime?: number } | undefined) { - this.#timing = value; - this.#onChanged(); + if (this.#timing !== value) { + this.#timing = value; + this.#onChanged(); + } } get changes(): readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined { @@ -119,8 +133,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set changes(value: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined) { - this.#changes = value; - this.#onChanged(); + if (this.#changes !== value) { + this.#changes = value; + this.#onChanged(); + } } } @@ -200,12 +216,19 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio private static _sessionHandlePool = 0; private readonly _proxy: Proxied; + private readonly _chatSessionItemProviders = new Map(); private readonly _chatSessionItemControllers = new Map(); + private _nextChatSessionItemProviderHandle = 0; private readonly _chatSessionContentProviders = new Map { + this._proxy.$onDidChangeChatSessionItems(handle); + })); + } + if (provider.onDidCommitChatSessionItem) { + disposables.add(provider.onDidCommitChatSessionItem((e) => { + const { original, modified } = e; + this._proxy.$onDidCommitChatSessionItem(handle, original.resource, modified.resource); + })); + } + return { + dispose: () => { + this._chatSessionItemProviders.delete(handle); + disposables.dispose(); + this._proxy.$unregisterChatSessionItemProvider(handle); + } + }; + } + + createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { const handle = this._nextChatSessionItemControllerHandle++; const disposables = new DisposableStore(); // TODO: Currently not hooked up const onDidArchiveChatSessionItem = disposables.add(new Emitter()); - const onDidDisposeChatSessionItem = disposables.add(new Emitter()); const collection = new ChatSessionItemCollectionImpl(() => { this._proxy.$onDidChangeChatSessionItems(handle); @@ -274,7 +323,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio refreshHandler, items: collection, onDidArchiveChatSessionItem: onDidArchiveChatSessionItem.event, - onDidDisposeChatSessionItem: onDidDisposeChatSessionItem.event, createChatSessionItem: (resource: vscode.Uri, label: string) => { if (isDisposed) { throw new Error('ChatSessionItemController has been disposed'); @@ -345,7 +393,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } } - private convertChatSessionItem(sessionType: string, sessionContent: vscode.ChatSessionItem): IChatSessionItem { + private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { return { resource: sessionContent.resource, label: sessionContent.label, @@ -368,21 +416,35 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const entry = this._chatSessionItemControllers.get(handle); - if (!entry) { - this._logService.error(`No controller registered for handle ${handle}`); - return []; + let items: vscode.ChatSessionItem[]; + + const controller = this._chatSessionItemControllers.get(handle); + if (controller) { + // Call the refresh handler to populate items + await controller.controller.refreshHandler(); + if (token.isCancellationRequested) { + return []; + } + + items = Array.from(controller.controller.items, x => x[1]); + } else { + + const itemProvider = this._chatSessionItemProviders.get(handle); + if (!itemProvider) { + this._logService.error(`No provider registered for handle ${handle}`); + return []; + } + + items = await itemProvider.provider.provideChatSessionItems(token) ?? []; + if (token.isCancellationRequested) { + return []; + } } - // Call the refresh handler to populate items - await entry.controller.refreshHandler(); - - const items = [...entry.controller.items]; - const response: IChatSessionItem[] = []; - for (const [_, sessionContent] of items) { + for (const sessionContent of items) { this._sessionItems.set(sessionContent.resource, sessionContent); - response.push(this.convertChatSessionItem(entry.sessionType, sessionContent)); + response.push(this.convertChatSessionItem(sessionContent)); } return response; } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 0a718a8c547..41cbfdd1738 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3431,17 +3431,6 @@ export class ChatSessionChangedFile { constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } } -// Stub class - actual implementation is in extHostChatSessions.ts -export class ChatSessionItemController { - readonly id!: string; - readonly items!: vscode.ChatSessionItemCollection; - refreshHandler!: () => Thenable; - readonly onDidArchiveChatSessionItem!: vscode.Event; - readonly onDidDisposeChatSessionItem!: vscode.Event; - createChatSessionItem(_resource: vscode.Uri, _label: string): vscode.ChatSessionItem { throw new Error('Stub'); } - dispose(): void { throw new Error('Stub'); } -} - export enum ChatResponseReferencePartStatusKind { Complete = 1, Partial = 2, diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index c44f0991d36..b7e53e5d49b 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -27,6 +27,18 @@ declare module 'vscode' { } export namespace chat { + /** + * Registers a new {@link ChatSessionItemProvider chat session item provider}. + * + * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. + * + * @param chatSessionType The type of chat session the provider is for. + * @param provider The provider to register. + * + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; + /** * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. */ @@ -36,7 +48,33 @@ declare module 'vscode' { /** * Provides a list of information about chat sessions. */ - export class ChatSessionItemController { + export interface ChatSessionItemProvider { + /** + * Event that the provider can fire to signal that chat sessions have changed. + */ + readonly onDidChangeChatSessionItems: Event; + + /** + * Provides a list of chat sessions. + */ + // TODO: Do we need a flag to try auth if needed? + provideChatSessionItems(token: CancellationToken): ProviderResult; + + // #region Unstable parts of API + + /** + * Event that the provider can fire to signal that the current (original) chat session should be replaced with a new (modified) chat session. + * The UI can use this information to gracefully migrate the user to the new session. + */ + readonly onDidCommitChatSessionItem: Event<{ original: ChatSessionItem /** untitled */; modified: ChatSessionItem /** newly created */ }>; + + // #endregion + } + + /** + * Provides a list of information about chat sessions. + */ + export interface ChatSessionItemController { readonly id: string; /** @@ -67,11 +105,6 @@ declare module 'vscode' { * TODO: expose archive state on the item too? */ readonly onDidArchiveChatSessionItem: Event; - - /** - * Fired when an item is disposed by the editor - */ - readonly onDidDisposeChatSessionItem: Event; } /** From 242baf74bc4c1b77a24f1d7149539965227c1a87 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:34:17 -0800 Subject: [PATCH 04/67] Add archived property --- .../api/browser/mainThreadChatSessions.ts | 3 +- .../api/common/extHostChatSessions.ts | 28 ++++++++++++++----- .../chat/common/chatSessionsService.ts | 1 + .../vscode.proposed.chatSessionsProvider.d.ts | 5 ++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 791a22f677f..fa5e7997e62 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -382,7 +382,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat )); } - $onDidChangeChatSessionItems(handle: number): void { this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire(); } @@ -490,7 +489,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat changes: revive(session.changes), resource: uri, iconPath: session.iconPath, - tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, + tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, archived: session.archived, } satisfies IChatSessionItem; })); } catch (error) { diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 6ecdfc4d375..9c751cd07a6 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -40,6 +40,7 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { #description?: string | vscode.MarkdownString; #badge?: string | vscode.MarkdownString; #status?: vscode.ChatSessionStatus; + #archived?: boolean; #tooltip?: string | vscode.MarkdownString; #timing?: { startTime: number; endTime?: number }; #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; @@ -106,6 +107,17 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } } + get archived(): boolean | undefined { + return this.#archived; + } + + set archived(value: boolean | undefined) { + if (this.#archived !== value) { + this.#archived = value; + this.#onChanged(); + } + } + get tooltip(): string | vscode.MarkdownString | undefined { return this.#tooltip; } @@ -306,14 +318,14 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { - const handle = this._nextChatSessionItemControllerHandle++; + const controllerHandle = this._nextChatSessionItemControllerHandle++; const disposables = new DisposableStore(); // TODO: Currently not hooked up const onDidArchiveChatSessionItem = disposables.add(new Emitter()); const collection = new ChatSessionItemCollectionImpl(() => { - this._proxy.$onDidChangeChatSessionItems(handle); + this._proxy.$onDidChangeChatSessionItems(controllerHandle); }); let isDisposed = false; @@ -329,7 +341,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } return new ChatSessionItemImpl(resource, label, () => { - this._proxy.$onDidChangeChatSessionItems(handle); + // TODO: Optimize to only update the specific item + this._proxy.$onDidChangeChatSessionItems(controllerHandle); }); }, dispose: () => { @@ -338,12 +351,12 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, }; - this._chatSessionItemControllers.set(handle, { controller, extension, disposable: disposables, sessionType: id }); - this._proxy.$registerChatSessionItemProvider(handle, id); + this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id }); + this._proxy.$registerChatSessionItemProvider(controllerHandle, id); disposables.add(toDisposable(() => { - this._chatSessionItemControllers.delete(handle); - this._proxy.$unregisterChatSessionItemProvider(handle); + this._chatSessionItemControllers.delete(controllerHandle); + this._proxy.$unregisterChatSessionItemProvider(controllerHandle); })); return controller; @@ -400,6 +413,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio description: sessionContent.description ? typeConvert.MarkdownString.from(sessionContent.description) : undefined, badge: sessionContent.badge ? typeConvert.MarkdownString.from(sessionContent.badge) : undefined, status: this.convertChatSessionStatus(sessionContent.status), + archived: sessionContent.archived, tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), timing: { startTime: sessionContent.timing?.startTime ?? 0, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 6e50c8e5a7b..95352a55f2c 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -62,6 +62,7 @@ export interface IChatSessionsExtensionPoint { readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; } + export interface IChatSessionItem { resource: URI; label: string; diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index b7e53e5d49b..bdef678b5aa 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -189,6 +189,11 @@ declare module 'vscode' { */ tooltip?: string | MarkdownString; + /** + * Whether the chat session has been archived. + */ + archived?: boolean; + /** * The times at which session started and ended */ From 93ff336d5185459a117d520b3f735c75bb4a1887 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:39:35 -0800 Subject: [PATCH 05/67] Cleanup --- src/vs/workbench/api/browser/mainThreadChatSessions.ts | 3 ++- src/vs/workbench/api/common/extHost.api.impl.ts | 8 ++++---- src/vs/workbench/api/common/extHostChatSessions.ts | 9 ++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index fa5e7997e62..7f8142003d5 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -489,7 +489,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat changes: revive(session.changes), resource: uri, iconPath: session.iconPath, - tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, archived: session.archived, + tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, + archived: session.archived, } satisfies IChatSessionItem; })); } catch (error) { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ad4c3bf659f..4d893851be1 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1525,13 +1525,13 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); }, - registerChatSessionItemProvider: (id: string, provider: vscode.ChatSessionItemProvider) => { + registerChatSessionItemProvider: (chatSessionType: string, provider: vscode.ChatSessionItemProvider) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); - return extHostChatSessions.registerChatSessionItemProvider(extension, id, provider); + return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); }, - createChatSessionItemController: (id: string, refreshHandler: () => Thenable) => { + createChatSessionItemController: (chatSessionType: string, refreshHandler: () => Thenable) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); - return extHostChatSessions.createChatSessionItemController(extension, id, refreshHandler); + return extHostChatSessions.createChatSessionItemController(extension, chatSessionType, refreshHandler); }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 9c751cd07a6..65253dbe9ce 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -34,7 +34,6 @@ import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; // #region Chat Session Item Controller class ChatSessionItemImpl implements vscode.ChatSessionItem { - readonly resource: vscode.Uri; #label: string; #iconPath?: vscode.IconPath; #description?: string | vscode.MarkdownString; @@ -46,6 +45,8 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; #onChanged: () => void; + readonly resource: vscode.Uri; + constructor(resource: vscode.Uri, label: string, onChanged: () => void) { this.resource = resource; this.#label = label; @@ -192,10 +193,8 @@ class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection return this.#items.get(resource); } - *[Symbol.iterator](): Generator { - for (const [uri, item] of this.#items) { - yield [uri, item] as const; - } + [Symbol.iterator](): Iterator { + return this.#items.entries(); } } From bfd6eed65bfa65d77440864372e75679e0df1c17 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:41:16 -0800 Subject: [PATCH 06/67] Bump api version --- src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index bdef678b5aa..213feb92c00 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 3 +// version: 4 declare module 'vscode' { /** From 49ee0d00694f3a90c57f79b9f5c51229fdfefaac Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:09:18 -0800 Subject: [PATCH 07/67] Also update proposal file --- src/vs/platform/extensions/common/extensionsApiProposals.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 65bcacd26e6..ddefdd8934b 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -65,7 +65,7 @@ const _allApiProposals = { }, chatSessionsProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts', - version: 3 + version: 4 }, chatStatusItem: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts', From b6e388529484d88654632a061b36734d7049af51 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 13 Jan 2026 10:53:18 -0800 Subject: [PATCH 08/67] Making chat session repo quick pick searchable --- .../api/browser/mainThreadChatSessions.ts | 4 +- .../workbench/api/common/extHost.protocol.ts | 2 +- .../api/common/extHostChatSessions.ts | 4 +- .../searchableOptionPickerActionItemtest.ts | 75 ++++++++++++++----- .../chat/common/chatSessionsService.ts | 2 +- .../vscode.proposed.chatSessionsProvider.d.ts | 5 +- 6 files changed, 67 insertions(+), 25 deletions(-) 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/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ed0f88a69b5..5c4dd634a4b 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3339,7 +3339,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/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/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts index b6272b20716..9622ad945d7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts @@ -5,7 +5,7 @@ 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 * 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 +13,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 +170,62 @@ 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 searchTimeout: ReturnType | undefined; + let currentSearchCts: CancellationTokenSource | undefined; + const SEARCH_DEBOUNCE_MS = 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 => { + if (searchTimeout) { + clearTimeout(searchTimeout); + } + searchTimeout = setTimeout(() => { + performSearch(value); + }, SEARCH_DEBOUNCE_MS); + })); // 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 +234,17 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction } } quickPick.hide(); - }); + })); - quickPick.onDidHide(() => { - quickPick.dispose(); + disposables.add(quickPick.onDidHide(() => { + if (searchTimeout) { + clearTimeout(searchTimeout); + } + currentSearchCts?.cancel(); + currentSearchCts?.dispose(); + disposables.dispose(); resolve(); - }); + })); }); } } 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/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 { From 452be6bf19b50fc55a314a86a3c39561ec70c5c1 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 13 Jan 2026 12:53:41 -0600 Subject: [PATCH 09/67] Delete -> Kill (#287600) fixes #287540 --- .../contrib/terminal/browser/terminalTabsChatEntry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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'); From 9b619448882ff6f7f0e9095f67ac56ecbae711f7 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 13 Jan 2026 11:31:28 -0800 Subject: [PATCH 10/67] Using delayer --- .../searchableOptionPickerActionItemtest.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts index 9622ad945d7..8a7d7742419 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts @@ -6,6 +6,7 @@ import './media/chatSessionPickerActionItem.css'; import { IAction } from '../../../../../base/common/actions.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'; @@ -181,9 +182,8 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction quickPick.show(); // Debounced search state - let searchTimeout: ReturnType | undefined; let currentSearchCts: CancellationTokenSource | undefined; - const SEARCH_DEBOUNCE_MS = 300; + const searchDelayer = disposables.add(new Delayer(300)); const performSearch = async (query: string) => { // Cancel previous search @@ -214,12 +214,7 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction // Listen for value changes and perform debounced search disposables.add(quickPick.onDidChangeValue(value => { - if (searchTimeout) { - clearTimeout(searchTimeout); - } - searchTimeout = setTimeout(() => { - performSearch(value); - }, SEARCH_DEBOUNCE_MS); + searchDelayer.trigger(() => performSearch(value)); })); @@ -237,9 +232,6 @@ export class SearchableOptionPickerActionItem extends ActionWidgetDropdownAction })); disposables.add(quickPick.onDidHide(() => { - if (searchTimeout) { - clearTimeout(searchTimeout); - } currentSearchCts?.cancel(); currentSearchCts?.dispose(); disposables.dispose(); From d11af7878b6b7c15af733ba7d8f5f5b055297833 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:43:39 -0800 Subject: [PATCH 11/67] chore: bump distro (#287602) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6f63917b150..993fdc02d9d 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" }, @@ -240,4 +240,4 @@ "optionalDependencies": { "windows-foreground-love": "0.5.0" } -} \ No newline at end of file +} From c3a86729da2fa045fde0480c7bf3dcd4c8fc190c Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 13 Jan 2026 13:54:29 -0600 Subject: [PATCH 12/67] escape backticks so they're rendered correctly (#287606) fixes #287593 --- .../tools/task/createAndRunTaskTool.ts | 4 ++-- .../browser/tools/task/getTaskOutputTool.ts | 12 +++++----- .../browser/tools/task/runTaskTool.ts | 22 +++++++++---------- 3 files changed, 19 insertions(+), 19 deletions(-) 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 }; } From 9333fa4471e2b5ef2a53f1e70e228d8390ec7634 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 13 Jan 2026 13:54:59 -0600 Subject: [PATCH 13/67] fix off by one issue (#287611) fixes #287607 --- .../browser/chatTerminalCommandMirror.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 4af69123f2a..3e5c31abcdc 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -50,6 +50,14 @@ const enum ChatTerminalMirrorMetrics { MirrorColCountFallback = 80 } +/** + * 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( xtermTerminal: XtermTerminal, command: ITerminalCommand, @@ -94,13 +102,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 { @@ -289,7 +297,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,7 +305,7 @@ 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; From fe6f509675c01ca1fb9b343abd19a0d819ec0504 Mon Sep 17 00:00:00 2001 From: Chase Knowlden Date: Tue, 13 Jan 2026 15:06:30 -0500 Subject: [PATCH 14/67] Hover on keyboard modifier should trigger instantly (#276582) --- .../hover/browser/contentHoverController.ts | 13 +- .../hover/browser/glyphHoverController.ts | 10 +- .../contrib/hover/browser/hoverUtils.ts | 20 ++- .../hover/test/browser/hoverUtils.test.ts | 138 +++++++++++++++++- 4 files changed, 171 insertions(+), 10 deletions(-) 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); + }); + }); }); From 84efd923012eeefafd5194fa06fe88494ebafe90 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 13 Jan 2026 14:14:04 -0600 Subject: [PATCH 15/67] set terminal chat output width based on content (#287586) --- .../media/chatTerminalToolProgressPart.css | 3 + .../chatTerminalToolProgressPart.ts | 68 ++++++++- .../browser/chatTerminalCommandMirror.ts | 101 ++++++++++-- .../contrib/terminal/browser/terminal.ts | 5 + .../terminal/browser/xterm-private.d.ts | 8 +- .../terminal/browser/xterm/xtermTerminal.ts | 1 + .../browser/chatTerminalCommandMirror.test.ts | 144 ++++++++++++++++++ 7 files changed, 310 insertions(+), 20 deletions(-) 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/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/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 3e5c31abcdc..3ea882a7f34 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -38,16 +38,57 @@ 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 } /** @@ -159,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; @@ -208,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; } @@ -266,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> { @@ -311,6 +358,14 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach 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; @@ -468,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 { @@ -492,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, @@ -542,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) { @@ -559,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 { @@ -577,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/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); + }); + }); }); From 88eb80e805b60bd6ac84aeb8cafc0d55cacc4e4b Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:42:10 -0800 Subject: [PATCH 16/67] chore: bump native node modules (#287597) * chore: bump native node modules * chore: update Debian deps list --- build/linux/debian/dep-lists.ts | 1 - package-lock.json | 38 ++++++++++++++++----------------- package.json | 2 +- remote/package-lock.json | 18 ++++++++-------- 4 files changed, 29 insertions(+), 30 deletions(-) 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/package-lock.json b/package-lock.json index c5874a4622b..6f165a5feb3 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", @@ -3258,9 +3258,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 +3320,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 +3332,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 +3559,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 +3580,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 +12818,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" }, diff --git a/package.json b/package.json index 993fdc02d9d..8e94496dee9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/remote/package-lock.json b/remote/package-lock.json index 44adad2a826..6ad59487fb5 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -451,9 +451,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 +501,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 +511,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" }, From 4641b2abb857e6a8df370b4c9cff8982b1d11eff Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:27:47 -0800 Subject: [PATCH 17/67] chore: update deviceid (#287631) --- package-lock.json | 7 ++++--- remote/package-lock.json | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6f165a5feb3..cb95a5a4cbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/remote/package-lock.json b/remote/package-lock.json index 6ad59487fb5..80bae871859 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -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" From 4c7b7c7edfc049dd56776f15d40448f53203742a Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:37:54 -0800 Subject: [PATCH 18/67] Bump api notebook milestone --- .vscode/notebooks/api.github-issues | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 067cb03d18229c4cf3f142448f852dac4b7ebf33 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 14 Jan 2026 00:31:19 +0100 Subject: [PATCH 19/67] [json] add trustedDomains settings (#287639) * use trusted schemas * [json] add trustedDomains settings --- .../client/src/jsonClient.ts | 335 +++++++++++++----- .../client/src/languageStatus.ts | 91 ++++- .../client/src/utils/urlMatch.ts | 107 ++++++ .../json-language-features/package.json | 16 + .../json-language-features/package.nls.json | 4 +- .../server/package-lock.json | 8 +- .../server/package.json | 2 +- .../server/src/jsonServer.ts | 18 +- 8 files changed, 469 insertions(+), 112 deletions(-) create mode 100644 extensions/json-language-features/client/src/utils/urlMatch.ts 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(); } From 96a75ab878d7698eaf09822e088b757942c9b936 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 13 Jan 2026 17:40:00 -0600 Subject: [PATCH 20/67] fix races in prompt for input (#287651) fixes #287642 --- .../browser/tools/monitoring/outputMonitor.ts | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) 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..2ac03d97b94 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 @@ -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, @@ -159,6 +166,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 +190,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 +229,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 +250,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; @@ -310,12 +350,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 +373,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,6 +388,32 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return OutputMonitorState.Timeout; } + /** + * 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; + + // 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 _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) }; @@ -404,7 +473,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 +503,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:" From d7291115c0ddf2ecc8dfe35ca40789a4ab577b2a Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:40:50 -0800 Subject: [PATCH 21/67] fix edge case showing "Open Picker" with chatSession optionGroups (#287650) --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 9669d88db00..8997ec10510 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1381,8 +1381,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); @@ -1405,6 +1403,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + // Only show the picker if there are visible option groups + if (visibleGroupIds.size === 0) { + return hideAll(); + } + + this.chatSessionHasOptions.set(true); + const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); const needsRecreation = From cf6f3c94f7320a299e76b1d1b93ee6f2845d62a0 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:07:04 -0800 Subject: [PATCH 22/67] Remove timeout warning, keep running Fixes #285434 --- .../browser/tools/monitoring/outputMonitor.ts | 81 ++----------------- .../browser/tools/monitoring/types.ts | 2 +- 2 files changed, 7 insertions(+), 76 deletions(-) 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 2ac03d97b94..068c257762c 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 @@ -260,63 +260,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; } /** @@ -414,27 +366,6 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._userInputListener = undefined; } - 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; } - ); - - return { promise: result.promise.then(p => p ?? false), part: result.part }; - } - - - private async _assessOutputForErrors(buffer: string, token: CancellationToken): Promise { const model = await this._getLanguageModel(); if (!model) { 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 } From 7b7243f1d0695c2313684c6e6448705e95d50ba8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:18:32 -0800 Subject: [PATCH 23/67] Add better timing info to chat sessions Fixes #278567 Resubmission of #278858 --- .../api/common/extHostChatSessions.ts | 19 +++++-- .../agentSessions/agentSessionsModel.ts | 53 ++++++++++++------ .../agentSessions/agentSessionsPicker.ts | 2 +- .../agentSessions/agentSessionsViewer.ts | 13 +++-- .../chat/common/chatService/chatService.ts | 20 ++++++- .../common/chatService/chatServiceImpl.ts | 12 +++- .../chat/common/chatSessionsService.ts | 7 +-- .../contrib/chat/common/model/chatModel.ts | 10 +++- .../chat/common/model/chatSessionStore.ts | 7 ++- .../agentSessionViewModel.test.ts | 52 +++++++++++------- .../agentSessionsDataSource.test.ts | 9 +-- .../localAgentSessionsProvider.test.ts | 55 ++++++++++++------- .../vscode.proposed.chatSessionsProvider.d.ts | 28 +++++++++- 13 files changed, 197 insertions(+), 90 deletions(-) diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index f98838cc2f3..2f4697224ab 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -31,6 +31,8 @@ import { basename } from '../../../base/common/resources.js'; import { Diagnostic } from './extHostTypeConverters.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; +type ChatSessionTiming = vscode.ChatSessionItem['timing']; + // #region Chat Session Item Controller class ChatSessionItemImpl implements vscode.ChatSessionItem { @@ -41,7 +43,7 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { #status?: vscode.ChatSessionStatus; #archived?: boolean; #tooltip?: string | vscode.MarkdownString; - #timing?: { startTime: number; endTime?: number }; + #timing?: { created: number; lastRequestStarted?: number; lastRequestEnded?: number; startTime?: number; endTime?: number }; #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; #onChanged: () => void; @@ -130,11 +132,11 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } } - get timing(): { startTime: number; endTime?: number } | undefined { + get timing(): ChatSessionTiming | undefined { return this.#timing; } - set timing(value: { startTime: number; endTime?: number } | undefined) { + set timing(value: ChatSessionTiming | undefined) { if (this.#timing !== value) { this.#timing = value; this.#onChanged(); @@ -409,6 +411,12 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { + // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties + const timing = sessionContent.timing; + const created = timing?.created ?? timing?.startTime ?? 0; + const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; + const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; + return { resource: sessionContent.resource, label: sessionContent.label, @@ -418,8 +426,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio archived: sessionContent.archived, tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), timing: { - startTime: sessionContent.timing?.startTime ?? 0, - endTime: sessionContent.timing?.endTime + created, + lastRequestStarted, + lastRequestEnded, }, changes: sessionContent.changes instanceof Array ? sessionContent.changes : diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index b579321fec1..4612c9f2dff 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -359,19 +359,24 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode ? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions } : changes; - // Times: it is important to always provide a start and end time to track + // Times: it is important to always provide timing information to track // unread/read state for example. // If somehow the provider does not provide any, fallback to last known - let startTime = session.timing.startTime; - let endTime = session.timing.endTime; - if (!startTime || !endTime) { + let created = session.timing.created; + let lastRequestStarted = session.timing.lastRequestStarted; + let lastRequestEnded = session.timing.lastRequestEnded; + if (!created || lastRequestEnded === undefined) { const existing = this._sessions.get(session.resource); - if (!startTime && existing?.timing.startTime) { - startTime = existing.timing.startTime; + if (!created && existing?.timing.created) { + created = existing.timing.created; } - if (!endTime && existing?.timing.endTime) { - endTime = existing.timing.endTime; + if (lastRequestEnded === undefined && existing?.timing.lastRequestEnded) { + lastRequestEnded = existing.timing.lastRequestEnded; + } + + if (lastRequestStarted === undefined && existing?.timing.lastRequestStarted) { + lastRequestStarted = existing.timing.lastRequestStarted; } } @@ -386,7 +391,13 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode tooltip: session.tooltip, status, archived: session.archived, - timing: { startTime, endTime, inProgressTime, finishedOrFailedTime }, + timing: { + created, + lastRequestStarted, + lastRequestEnded, + inProgressTime, + finishedOrFailedTime + }, changes: normalizedChanges, })); } @@ -454,7 +465,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private isRead(session: IInternalAgentSessionData): boolean { const readDate = this.sessionStates.get(session.resource)?.read; - return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.endTime ?? session.timing.startTime); + return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); } private setRead(session: IInternalAgentSessionData, read: boolean): void { @@ -473,7 +484,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode //#region Sessions Cache -interface ISerializedAgentSession extends Omit { +interface ISerializedAgentSession { readonly providerType: string; readonly providerLabel: string; @@ -492,7 +503,11 @@ interface ISerializedAgentSession extends Omit ({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index cd91ba6fbdb..ba5bfac455d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -44,7 +44,7 @@ export const deleteButton: IQuickInputButton = { export function getSessionDescription(session: IAgentSession): string { const descriptionText = typeof session.description === 'string' ? session.description : session.description ? renderAsPlaintext(session.description) : undefined; - const timeAgo = fromNow(session.timing.endTime || session.timing.startTime); + const timeAgo = fromNow(session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); const descriptionParts = [descriptionText, session.providerLabel, timeAgo].filter(part => !!part); return descriptionParts.join(' • '); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index f3d3e6e29cd..17c8d9f3a5a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -323,7 +323,7 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -826,7 +827,9 @@ export class AgentSessionsSorter implements ITreeSorter { } //Sort by end or start time (most recent first) - return (sessionB.timing.endTime || sessionB.timing.startTime) - (sessionA.timing.endTime || sessionA.timing.startTime); + const timeA = sessionA.timing.lastRequestEnded ?? sessionA.timing.lastRequestStarted ?? sessionA.timing.created; + const timeB = sessionB.timing.lastRequestEnded ?? sessionB.timing.lastRequestStarted ?? sessionB.timing.created; + return timeB - timeA; } } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 7a757d8eb1e..6986780910b 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -941,8 +941,24 @@ export interface IChatSessionStats { } export interface IChatSessionTiming { - startTime: number; - endTime?: number; + /** + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted: number | undefined; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded: number | undefined; } export const enum ResponseModelState { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index d517d0ce503..e5d90f3d715 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -375,7 +375,11 @@ export class ChatService extends Disposable implements IChatService { ...entry, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: entry.timing ?? { startTime: entry.lastMessageDate }, + timing: entry.timing ?? { + created: entry.lastMessageDate, + lastRequestStarted: undefined, + lastRequestEnded: entry.lastMessageDate, + }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: entry.lastResponseState ?? ResponseModelState.Complete, @@ -391,7 +395,11 @@ export class ChatService extends Disposable implements IChatService { ...metadata, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: metadata.timing ?? { startTime: metadata.lastMessageDate }, + timing: metadata.timing ?? { + created: metadata.lastMessageDate, + lastRequestStarted: undefined, + lastRequestEnded: metadata.lastMessageDate, + }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: metadata.lastResponseState ?? ResponseModelState.Complete, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 3a2144b3596..94126a5ffcf 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -14,7 +14,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participants/chatAgents.js'; import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; -import { IChatProgress, IChatService } from './chatService/chatService.js'; +import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; export const enum ChatSessionStatus { Failed = 0, @@ -82,10 +82,7 @@ export interface IChatSessionItem { description?: string | IMarkdownString; status?: ChatSessionStatus; tooltip?: string | IMarkdownString; - timing: { - startTime: number; - endTime?: number; - }; + timing: IChatSessionTiming; changes?: { files: number; insertions: number; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 7627b5f85fb..2a5cc4df78e 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1687,10 +1687,14 @@ export class ChatModel extends Disposable implements IChatModel { } get timing(): IChatSessionTiming { - const lastResponse = this._requests.at(-1)?.response; + const lastRequest = this._requests.at(-1); + const lastResponse = lastRequest?.response; + const lastRequestStarted = lastRequest?.timestamp; + const lastRequestEnded = lastResponse?.completedAt ?? lastResponse?.timestamp; return { - startTime: this._timestamp, - endTime: lastResponse?.completedAt ?? lastResponse?.timestamp + created: this._timestamp, + lastRequestStarted, + lastRequestEnded, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 63ac4c99c21..1465a8d5c54 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -665,12 +665,13 @@ async function getSessionMetadata(session: ChatModel | ISerializableChatData): P session.lastMessageDate : session.requests.at(-1)?.timestamp ?? session.creationDate; - const timing = session instanceof ChatModel ? + const timing: IChatSessionTiming = session instanceof ChatModel ? session.timing : // session is only ISerializableChatData in the old pre-fs storage data migration scenario { - startTime: session.creationDate, - endTime: lastMessageDate + created: session.creationDate, + lastRequestStarted: session.requests.at(-1)?.timestamp, + lastRequestEnded: lastMessageDate, }; return { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index 114f666d135..bacf032abd9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -176,8 +176,8 @@ suite('Agent Sessions', () => { test('should handle session with all properties', async () => { return runWithFakedTimers({}, async () => { - const startTime = Date.now(); - const endTime = startTime + 1000; + const created = Date.now(); + const lastRequestEnded = created + 1000; const provider: IChatSessionItemProvider = { chatSessionType: 'test-type', @@ -190,8 +190,8 @@ suite('Agent Sessions', () => { status: ChatSessionStatus.Completed, tooltip: 'Session tooltip', iconPath: ThemeIcon.fromId('check'), - timing: { startTime, endTime }, - changes: { files: 1, insertions: 10, deletions: 5, details: [] } + timing: { created, lastRequestStarted: created, lastRequestEnded }, + changes: { files: 1, insertions: 10, deletions: 5 } } ] }; @@ -210,8 +210,8 @@ suite('Agent Sessions', () => { assert.strictEqual(session.description.value, '**Bold** description'); } assert.strictEqual(session.status, ChatSessionStatus.Completed); - assert.strictEqual(session.timing.startTime, startTime); - assert.strictEqual(session.timing.endTime, endTime); + assert.strictEqual(session.timing.created, created); + assert.strictEqual(session.timing.lastRequestEnded, lastRequestEnded); assert.deepStrictEqual(session.changes, { files: 1, insertions: 10, deletions: 5 }); }); }); @@ -1521,9 +1521,10 @@ suite('Agent Sessions', () => { test('should consider sessions before initial date as read by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing before the READ_STATE_INITIAL_DATE (December 8, 2025) - const oldSessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), - endTime: Date.UTC(2025, 10 /* November */, 2), + const oldSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: Date.UTC(2025, 10 /* November */, 2), }; const provider: IChatSessionItemProvider = { @@ -1552,9 +1553,10 @@ suite('Agent Sessions', () => { test('should consider sessions after initial date as unread by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing after the READ_STATE_INITIAL_DATE (December 8, 2025) - const newSessionTiming = { - startTime: Date.UTC(2025, 11 /* December */, 10), - endTime: Date.UTC(2025, 11 /* December */, 11), + const newSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 11 /* December */, 10), + lastRequestStarted: Date.UTC(2025, 11 /* December */, 10), + lastRequestEnded: Date.UTC(2025, 11 /* December */, 11), }; const provider: IChatSessionItemProvider = { @@ -1583,9 +1585,10 @@ suite('Agent Sessions', () => { test('should use endTime for read state comparison when available', async () => { return runWithFakedTimers({}, async () => { // Session with startTime before initial date but endTime after - const sessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), - endTime: Date.UTC(2025, 11 /* December */, 10), + const sessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: Date.UTC(2025, 11 /* December */, 10), }; const provider: IChatSessionItemProvider = { @@ -1606,7 +1609,7 @@ suite('Agent Sessions', () => { await viewModel.resolve(undefined); const session = viewModel.sessions[0]; - // Should use endTime (December 10) which is after the initial date + // Should use lastRequestEnded (December 10) which is after the initial date assert.strictEqual(session.isRead(), false); }); }); @@ -1614,8 +1617,10 @@ suite('Agent Sessions', () => { test('should use startTime for read state comparison when endTime is not available', async () => { return runWithFakedTimers({}, async () => { // Session with only startTime before initial date - const sessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), + const sessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: undefined, }; const provider: IChatSessionItemProvider = { @@ -2054,8 +2059,15 @@ function makeSimpleSessionItem(id: string, overrides?: Partial }; } -function makeNewSessionTiming(): IChatSessionItem['timing'] { +function makeNewSessionTiming(options?: { + created?: number; + lastRequestStarted?: number | undefined; + lastRequestEnded?: number | undefined; +}): IChatSessionItem['timing'] { + const now = Date.now(); return { - startTime: Date.now(), + created: options?.created ?? now, + lastRequestStarted: options?.lastRequestStarted, + lastRequestEnded: options?.lastRequestEnded, }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index f29f8f83327..d551277757b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -36,8 +36,9 @@ suite('AgentSessionsDataSource', () => { label: `Session ${overrides.id ?? 'default'}`, icon: Codicon.terminal, timing: { - startTime: overrides.startTime ?? now, - endTime: overrides.endTime ?? now, + created: overrides.startTime ?? now, + lastRequestEnded: undefined, + lastRequestStarted: undefined, }, isArchived: () => overrides.isArchived ?? false, setArchived: () => { }, @@ -73,8 +74,8 @@ suite('AgentSessionsDataSource', () => { return { compare: (a, b) => { // Sort by end time, most recent first - const aTime = a.timing.endTime || a.timing.startTime; - const bTime = b.timing.endTime || b.timing.startTime; + const aTime = a.timing.lastRequestEnded ?? a.timing.lastRequestStarted ?? a.timing.created; + const bTime = b.timing.lastRequestEnded ?? b.timing.lastRequestStarted ?? b.timing.created; return bTime - aTime; } }; 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..ac5db0d4980 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 @@ -18,11 +18,24 @@ import { LocalAgentsSessionsProvider } from '../../../browser/agentSessions/loca import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; +function createTestTiming(options?: { + created?: number; + lastRequestStarted?: number | undefined; + lastRequestEnded?: number | undefined; +}): IChatSessionItem['timing'] { + const now = Date.now(); + return { + created: options?.created ?? now, + lastRequestStarted: options?.lastRequestStarted, + lastRequestEnded: options?.lastRequestEnded, + }; +} + class MockChatService implements IChatService { private readonly _chatModels: ISettableObservable> = observableValue('chatModels', []); readonly chatModels = this._chatModels; @@ -318,7 +331,7 @@ suite('LocalAgentsSessionsProvider', () => { title: 'Test Session', lastMessageDate: Date.now(), isActive: true, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), lastResponseState: ResponseModelState.Complete }]); @@ -342,7 +355,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -368,7 +381,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); mockChatService.setHistorySessionItems([{ sessionResource, @@ -376,7 +389,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -404,7 +417,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -434,7 +447,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -463,7 +476,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -492,7 +505,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -536,7 +549,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), stats: { added: 30, removed: 8, @@ -581,7 +594,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -592,7 +605,7 @@ suite('LocalAgentsSessionsProvider', () => { }); suite('Session Timing', () => { - test('should use model timestamp for startTime when model exists', async () => { + test('should use model timestamp for created when model exists', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -611,16 +624,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: modelTimestamp } + timing: createTestTiming({ created: modelTimestamp }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.startTime, modelTimestamp); + assert.strictEqual(sessions[0].timing.created, modelTimestamp); }); }); - test('should use lastMessageDate for startTime when model does not exist', async () => { + test('should use lastMessageDate for created when model does not exist', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -634,16 +647,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: lastMessageDate } + timing: createTestTiming({ created: lastMessageDate }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.startTime, lastMessageDate); + assert.strictEqual(sessions[0].timing.created, lastMessageDate); }); }); - test('should set endTime from last response completedAt', async () => { + test('should set lastRequestEnded from last response completedAt', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -663,12 +676,12 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: completedAt } + timing: createTestTiming({ lastRequestEnded: completedAt }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.endTime, completedAt); + assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); }); }); }); @@ -691,7 +704,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 6094894b761..c1cbdf9c715 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -195,15 +195,37 @@ declare module 'vscode' { archived?: boolean; /** - * The times at which session started and ended + * Timing information for the chat session */ timing?: { /** - * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. */ - startTime: number; + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted?: number; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded?: number; + + /** + * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `created` and `lastRequestStarted` instead. + */ + startTime?: number; + /** * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `lastRequestEnded` instead. */ endTime?: number; }; From 38f6584b07fedf9096e74c7df812eb395f865926 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:23:18 -0800 Subject: [PATCH 24/67] Cleanup --- src/vs/workbench/api/common/extHostChatSessions.ts | 2 +- .../chat/browser/agentSessions/agentSessionsModel.ts | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 2f4697224ab..c4d34921e45 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -43,7 +43,7 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { #status?: vscode.ChatSessionStatus; #archived?: boolean; #tooltip?: string | vscode.MarkdownString; - #timing?: { created: number; lastRequestStarted?: number; lastRequestEnded?: number; startTime?: number; endTime?: number }; + #timing?: ChatSessionTiming; #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; #onChanged: () => void; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 4612c9f2dff..73776e50163 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -365,17 +365,17 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode let created = session.timing.created; let lastRequestStarted = session.timing.lastRequestStarted; let lastRequestEnded = session.timing.lastRequestEnded; - if (!created || lastRequestEnded === undefined) { + if (!created || !lastRequestEnded) { const existing = this._sessions.get(session.resource); if (!created && existing?.timing.created) { created = existing.timing.created; } - if (lastRequestEnded === undefined && existing?.timing.lastRequestEnded) { + if (!lastRequestEnded && existing?.timing.lastRequestEnded) { lastRequestEnded = existing.timing.lastRequestEnded; } - if (lastRequestStarted === undefined && existing?.timing.lastRequestStarted) { + if (!lastRequestStarted && existing?.timing.lastRequestStarted) { lastRequestStarted = existing.timing.lastRequestStarted; } } @@ -569,7 +569,7 @@ class AgentSessionsCache { try { const cached = JSON.parse(sessionsCache) as ISerializedAgentSession[]; - return cached.map(session => ({ + return cached.map((session): IInternalAgentSessionData => ({ providerType: session.providerType, providerLabel: session.providerLabel, @@ -589,9 +589,6 @@ class AgentSessionsCache { created: session.timing.created ?? session.timing.startTime ?? 0, lastRequestStarted: session.timing.lastRequestStarted ?? session.timing.startTime, lastRequestEnded: session.timing.lastRequestEnded ?? session.timing.endTime, - // Deprecated fields for backward compatibility - startTime: session.timing.created ?? session.timing.startTime, - endTime: session.timing.lastRequestEnded ?? session.timing.endTime, }, changes: Array.isArray(session.changes) ? session.changes.map((change: IChatSessionFileChange) => ({ From 4212deb21060d87a95f009cc55515e5c8f378861 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:30:24 -0800 Subject: [PATCH 25/67] Only keep around text models for live code blocks Seeing if we can improve perf by only keeping text models for live code blocks. This was potentially helpful in ask mode but not as useful in agent mode --- .../chat/common/model/chatViewModel.ts | 26 ------------------ .../contrib/chat/common/widget/annotations.ts | 27 +------------------ 2 files changed, 1 insertion(+), 52 deletions(-) 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/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) { From e64d58389d6a8978ced2449d799cad41f497af6f Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Tue, 13 Jan 2026 16:38:31 -0800 Subject: [PATCH 26/67] vscode mcp: support custom workspace path; enable restart tool (#286297) * vscode mcp: support specifying workspace path; enable restart tool * fix: include workspace path in launch options for browser * fix: ensure workspace path is set for tests requiring a workspace * Address PR review comments: support optional workspace path * fix: standardize workspace path variable naming in tests * fix: fallback to rootPath for workspacePath in CI environments --- test/automation/src/application.ts | 5 ++- test/automation/src/code.ts | 2 +- test/automation/src/electron.ts | 12 +++++- test/automation/src/playwrightBrowser.ts | 10 ++++- test/mcp/src/application.ts | 9 +++-- test/mcp/src/automation.ts | 11 ++--- test/mcp/src/automationTools/core.ts | 40 ++++++++++--------- .../src/areas/languages/languages.test.ts | 28 +++++++++++-- .../src/areas/multiroot/multiroot.test.ts | 3 ++ .../smoke/src/areas/notebook/notebook.test.ts | 9 ++++- test/smoke/src/areas/search/search.test.ts | 9 ++++- .../src/areas/statusbar/statusbar.test.ts | 21 ++++++++-- .../src/areas/workbench/data-loss.test.ts | 39 ++++++++++++++---- 13 files changed, 146 insertions(+), 52 deletions(-) diff --git a/test/automation/src/application.ts b/test/automation/src/application.ts index 81acded3853..848640a4983 100644 --- a/test/automation/src/application.ts +++ b/test/automation/src/application.ts @@ -49,8 +49,8 @@ export class Application { return !!this.options.web; } - private _workspacePathOrFolder: string; - get workspacePathOrFolder(): string { + private _workspacePathOrFolder: string | undefined; + get workspacePathOrFolder(): string | undefined { return this._workspacePathOrFolder; } @@ -109,6 +109,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/smoke/src/areas/languages/languages.test.ts b/test/smoke/src/areas/languages/languages.test.ts index 9ec05b0c9e2..508a35d9d4d 100644 --- a/test/smoke/src/areas/languages/languages.test.ts +++ b/test/smoke/src/areas/languages/languages.test.ts @@ -15,7 +15,12 @@ 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')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.openQuickOutline(); await app.workbench.quickinput.waitForQuickInputElements(names => names.length >= 6); @@ -24,7 +29,12 @@ 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')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.quickaccess.openQuickOutline(); await app.workbench.quickinput.waitForQuickInputElements(names => names.length === 2); @@ -33,7 +43,12 @@ 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')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.editor.waitForTypeInEditor('style.css', '.foo{}'); await app.code.waitForElement(Problems.getSelectorInEditor(ProblemSeverity.WARNING)); @@ -45,8 +60,13 @@ export function setup(logger: Logger) { it('verifies settings (css)', async function () { const app = this.app as Application; + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + await app.workbench.settingsEditor.addUserSetting('css.lint.emptyRules', '"error"'); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.code.waitForElement(Problems.getSelectorInEditor(ProblemSeverity.ERROR)); 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..b104ce26f76 100644 --- a/test/smoke/src/areas/notebook/notebook.test.ts +++ b/test/smoke/src/areas/notebook/notebook.test.ts @@ -21,8 +21,13 @@ 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 }); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + cp.execSync('git checkout . --quiet', { cwd: workspacePathOrFolder }); + cp.execSync('git reset --hard HEAD --quiet', { cwd: workspacePathOrFolder }); }); // the heap snapshot fails to parse diff --git a/test/smoke/src/areas/search/search.test.ts b/test/smoke/src/areas/search/search.test.ts index f635ad827df..8ac0bba570f 100644 --- a/test/smoke/src/areas/search/search.test.ts +++ b/test/smoke/src/areas/search/search.test.ts @@ -15,8 +15,13 @@ 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); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + retry(async () => cp.execSync('git checkout . --quiet', { cwd: workspacePathOrFolder }), 0, 5); + retry(async () => cp.execSync('git reset --hard HEAD --quiet', { cwd: workspacePathOrFolder }), 0, 5); }); it('verifies the sidebar moves to the right', async function () { diff --git a/test/smoke/src/areas/statusbar/statusbar.test.ts b/test/smoke/src/areas/statusbar/statusbar.test.ts index ccfbeb5772f..f681758562e 100644 --- a/test/smoke/src/areas/statusbar/statusbar.test.ts +++ b/test/smoke/src/areas/statusbar/statusbar.test.ts @@ -15,11 +15,16 @@ export function setup(logger: Logger) { it('verifies presence of all default status bar elements', async function () { const app = this.app as Application; + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.BRANCH_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.SYNC_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.PROBLEMS_STATUS); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.ENCODING_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.EOL_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.INDENTATION_STATUS); @@ -29,11 +34,16 @@ 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; + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + await app.workbench.statusbar.clickOn(StatusBarElement.BRANCH_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.INDENTATION_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); @@ -56,7 +66,12 @@ 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')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.EOL_STATUS); await app.workbench.quickinput.selectQuickInputElement(1); diff --git a/test/smoke/src/areas/workbench/data-loss.test.ts b/test/smoke/src/areas/workbench/data-loss.test.ts index 3e27f1acba9..f876f8596bd 100644 --- a/test/smoke/src/areas/workbench/data-loss.test.ts +++ b/test/smoke/src/areas/workbench/data-loss.test.ts @@ -27,10 +27,15 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + // Open 3 editors - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.runCommand('View: Keep Editor'); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'app.js')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); await app.workbench.quickaccess.runCommand('View: Keep Editor'); await app.workbench.editors.newUntitledFile(); @@ -53,10 +58,15 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + const textToType = 'Hello, Code'; // open editor and type - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'app.js')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); await app.workbench.editor.waitForTypeInEditor('app.js', textToType); await app.workbench.editors.waitForTab('app.js', true); @@ -94,6 +104,11 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + if (autoSave) { await app.workbench.settingsEditor.addUserSetting('files.autoSave', '"afterDelay"'); } @@ -105,7 +120,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin await app.workbench.editors.waitForTab('Untitled-1', true); const textToType = 'Hello, Code'; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.editor.waitForTypeInEditor('readme.md', textToType); await app.workbench.editors.waitForTab('readme.md', !autoSave); @@ -175,10 +190,15 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin stableApp = new Application(stableOptions); await stableApp.start(); + const workspacePathOrFolder = stableApp.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + // Open 3 editors - await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'bin', 'www')); + await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); await stableApp.workbench.quickaccess.runCommand('View: Keep Editor'); - await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'app.js')); + await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); await stableApp.workbench.quickaccess.runCommand('View: Keep Editor'); await stableApp.workbench.editors.newUntitledFile(); @@ -231,6 +251,11 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin stableApp = new Application(stableOptions); await stableApp.start(); + const workspacePathOrFolder = stableApp.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + const textToTypeInUntitled = 'Hello from Untitled'; await stableApp.workbench.editors.newUntitledFile(); @@ -238,7 +263,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin await stableApp.workbench.editors.waitForTab('Untitled-1', true); const textToType = 'Hello, Code'; - await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'readme.md')); + await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await stableApp.workbench.editor.waitForTypeInEditor('readme.md', textToType); await stableApp.workbench.editors.waitForTab('readme.md', true); From 90a7324651e4e3db8be5c76648516f781be2a673 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:56:07 -0800 Subject: [PATCH 27/67] Update mock --- .../workbench/contrib/chat/test/common/model/mockChatModel.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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..d9f5d6113d3 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -10,13 +10,14 @@ 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 { ChatAgentLocation } from '../../../common/constants.js'; +import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; export class MockChatModel extends Disposable implements IChatModel { readonly onDidDispose = this._register(new Emitter()).event; readonly onDidChange = this._register(new Emitter()).event; sessionId = ''; readonly timestamp = 0; - readonly timing = { startTime: 0 }; + readonly timing: IChatSessionTiming = { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; readonly initialLocation = ChatAgentLocation.Chat; readonly title = ''; readonly hasCustomTitle = false; From 0169cdc342dda11d7938c7a5a13a643e6a1d1682 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 13 Jan 2026 18:49:54 -0800 Subject: [PATCH 28/67] Context key for vaild option groups --- .../chat/browser/actions/chatExecuteActions.ts | 4 +++- .../chat/browser/widget/input/chatInputPart.ts | 18 ++++++++++++++++++ .../chat/common/actions/chatContextKeys.ts | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index dc52a099eb2..810a13614cf 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({ @@ -494,7 +495,8 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { const menuCondition = ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask); const precondition = ContextKeyExpr.and( ChatContextKeys.inputHasText, - whenNotInProgress + whenNotInProgress, + ChatContextKeys.chatSessionOptionsValid ); super({ 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 8997ec10510..b76274ea665 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -330,6 +330,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private withinEditSessionKey: IContextKey; private filePartOfEditSessionKey: IContextKey; private chatSessionHasOptions: IContextKey; + private chatSessionOptionsValid: IContextKey; private modelWidget: ModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; private chatSessionPickerWidgets: Map = new Map(); @@ -518,6 +519,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.withinEditSessionKey = ChatContextKeys.withinEditSessionDiff.bindTo(contextKeyService); this.filePartOfEditSessionKey = ChatContextKeys.filePartOfEditSession.bindTo(contextKeyService); this.chatSessionHasOptions = ChatContextKeys.chatSessionHasModels.bindTo(contextKeyService); + this.chatSessionOptionsValid = ChatContextKeys.chatSessionOptionsValid.bindTo(contextKeyService); const chatToolCount = ChatContextKeys.chatToolCount.bindTo(contextKeyService); @@ -1362,6 +1364,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(); }; @@ -1408,6 +1411,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge 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()); diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 62efc919657..5f7e826e76b 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); From 38682c2c9fde15271d79f85fa31174ac3de096bf Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:38:33 -0800 Subject: [PATCH 29/67] Focused projection of artifacts when selecting an agent (#287700) * checkpoint: glow * checkpoint 2 * checkpoint title bar * stash a bit of tidy * checkpoint status bar UI * x button * polish * polish * tweaks * gate on chatenabled --- src/vs/platform/actions/common/actions.ts | 1 + .../titlebar/commandCenterControlRegistry.ts | 71 +++++ .../browser/parts/titlebar/titlebarPart.ts | 30 +- .../chat/browser/actions/chatActions.ts | 3 +- .../chat/browser/actions/chatNewActions.ts | 8 + .../agentSessions.contribution.ts | 79 ++++- .../agentSessions/agentSessionsOpener.ts | 31 ++ .../browser/agentSessions/agentsControl.ts | 290 ++++++++++++++++++ .../browser/agentSessions/focusViewActions.ts | 149 +++++++++ .../browser/agentSessions/focusViewService.ts | 277 +++++++++++++++++ .../browser/agentSessions/media/focusView.css | 230 ++++++++++++++ .../contrib/chat/browser/chat.contribution.ts | 6 + .../chat/common/actions/chatContextKeys.ts | 3 + .../contrib/chat/common/constants.ts | 1 + 14 files changed, 1173 insertions(+), 6 deletions(-) create mode 100644 src/vs/workbench/browser/parts/titlebar/commandCenterControlRegistry.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/focusViewActions.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css 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/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/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/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 22ca202b3fe..3da3a988b9b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -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 }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index 1476c1660a0..792b1b13df8 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -24,6 +24,7 @@ import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.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 { @@ -114,6 +115,13 @@ 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 executeCommandContext = args[0] as INewEditSessionActionContext | undefined; 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/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/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 4269ca6a4a8..a70f3a314d9 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -188,6 +188,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."), diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 5f7e826e76b..d9033dd9f6f 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -105,6 +105,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/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 2ece134ffd7..cda127db62a 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -10,6 +10,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AgentEnabled = 'chat.agent.enabled', + AgentSessionProjectionEnabled = 'chat.agentSessionProjection.enabled', Edits2Enabled = 'chat.edits2.enabled', ExtensionToolsEnabled = 'chat.extensionTools.enabled', EditRequests = 'chat.editRequests', From 6cd9f4b7a05c6f6895f4dde55f9960784b835c60 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 14 Jan 2026 07:47:45 +0100 Subject: [PATCH 30/67] debt - naming of searchable option picker action item file (fix #287701) (#287703) --- ...kerActionItemtest.ts => searchableOptionPickerActionItem.ts} | 0 .../contrib/chat/browser/widget/input/chatInputPart.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/vs/workbench/contrib/chat/browser/chatSessions/{searchableOptionPickerActionItemtest.ts => searchableOptionPickerActionItem.ts} (100%) diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts similarity index 100% rename from src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts rename to src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts 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 b76274ea665..bdd510cef5c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -114,7 +114,7 @@ import { ChatRelatedFiles } from '../../attachments/chatInputRelatedFilesContrib import { resizeImage } from '../../chatImageUtils.js'; import { IModelPickerDelegate, ModelPickerActionItem } from './modelPickerActionItem.js'; import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; -import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItemtest.js'; +import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; import { mixin } from '../../../../../../base/common/objects.js'; const $ = dom.$; From dd1105d395c9b39cb33502194dd2f6b3b8ffbf93 Mon Sep 17 00:00:00 2001 From: Zhichao Li <57812115+zhichli@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:48:36 -0800 Subject: [PATCH 31/67] Merge pull request #286812 from microsoft/zhichli/chatrepoinfo feat-internal: Export Chat as Zip with Repository Context --- src/vs/platform/native/common/native.ts | 9 + .../electron-main/nativeHostMainService.ts | 9 + .../contrib/chat/browser/chat.contribution.ts | 2 + .../contrib/chat/browser/chatRepoInfo.ts | 593 ++++++++++++++++++ .../chat/common/chatService/chatService.ts | 2 + .../common/chatService/chatServiceImpl.ts | 2 + .../contrib/chat/common/constants.ts | 1 + .../contrib/chat/common/model/chatModel.ts | 118 ++++ .../chat/common/model/chatModelStore.ts | 4 + .../common/model/chatSessionOperationLog.ts | 3 + .../electron-browser/actions/chatExportZip.ts | 129 ++++ .../electron-browser/chat.contribution.ts | 2 + .../localAgentSessionsProvider.test.ts | 1 + .../common/chatService/mockChatService.ts | 1 + .../chat/test/common/model/mockChatModel.ts | 5 +- .../electron-browser/workbenchTestServices.ts | 1 + 16 files changed, 881 insertions(+), 1 deletion(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts create mode 100644 src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts 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/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index a70f3a314d9..5647d285ea3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -134,6 +134,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[] = []; @@ -1208,6 +1209,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/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/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 6986780910b..6bd8e4f9060 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1051,6 +1051,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 e5d90f3d715..97c2637ef07 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/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index cda127db62a..e9b27d99b4c 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -13,6 +13,7 @@ export enum ChatConfiguration { 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', diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 2a5cc4df78e..eb25096e3c9 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1253,6 +1253,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 +1307,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 +1426,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 +1757,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. @@ -1795,6 +1909,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; @@ -2241,6 +2358,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/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index 97dda654be0..df3b644bb74 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'; @@ -161,6 +162,8 @@ export const storageSchema = Adapt.object({ 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/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 ac5db0d4980..8b88eae7f82 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 @@ -45,6 +45,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/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/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index d9f5d6113d3..026c88b2fa5 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'; import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; @@ -39,6 +39,7 @@ export class MockChatModel extends Disposable implements IChatModel { toJSON: () => undefined }; readonly contributedChatSession = undefined; + repoData: IExportableRepoData | undefined = undefined; isDisposed = false; lastRequestObs: IObservable; @@ -59,6 +60,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, @@ -75,6 +77,7 @@ export class MockChatModel extends Disposable implements IChatModel { initialLocation: this.initialLocation, requests: [], responderUsername: '', + repoData: this.repoData }; } } 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; } } From e2144d22df2641c89f6c8af0e6a59b6e0fcfe807 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 14 Jan 2026 07:53:34 +0100 Subject: [PATCH 32/67] refactor - improve workspace path handling in smoke tests (#287706) --- test/automation/src/application.ts | 7 +++- .../src/areas/languages/languages.test.ts | 24 ++---------- .../smoke/src/areas/notebook/notebook.test.ts | 8 +--- test/smoke/src/areas/search/search.test.ts | 8 +--- .../src/areas/statusbar/statusbar.test.ts | 18 ++------- .../src/areas/workbench/data-loss.test.ts | 38 ++++--------------- 6 files changed, 23 insertions(+), 80 deletions(-) diff --git a/test/automation/src/application.ts b/test/automation/src/application.ts index 848640a4983..2a68759388f 100644 --- a/test/automation/src/application.ts +++ b/test/automation/src/application.ts @@ -50,7 +50,10 @@ export class Application { } private _workspacePathOrFolder: string | undefined; - get 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... diff --git a/test/smoke/src/areas/languages/languages.test.ts b/test/smoke/src/areas/languages/languages.test.ts index 508a35d9d4d..3db5c7c9894 100644 --- a/test/smoke/src/areas/languages/languages.test.ts +++ b/test/smoke/src/areas/languages/languages.test.ts @@ -15,12 +15,8 @@ export function setup(logger: Logger) { it('verifies quick outline (js)', async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.openQuickOutline(); await app.workbench.quickinput.waitForQuickInputElements(names => names.length >= 6); @@ -29,12 +25,8 @@ export function setup(logger: Logger) { it('verifies quick outline (css)', async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.quickaccess.openQuickOutline(); await app.workbench.quickinput.waitForQuickInputElements(names => names.length === 2); @@ -43,12 +35,8 @@ export function setup(logger: Logger) { it('verifies problems view (css)', async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.editor.waitForTypeInEditor('style.css', '.foo{}'); await app.code.waitForElement(Problems.getSelectorInEditor(ProblemSeverity.WARNING)); @@ -60,13 +48,9 @@ export function setup(logger: Logger) { it('verifies settings (css)', async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } await app.workbench.settingsEditor.addUserSetting('css.lint.emptyRules', '"error"'); - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.code.waitForElement(Problems.getSelectorInEditor(ProblemSeverity.ERROR)); diff --git a/test/smoke/src/areas/notebook/notebook.test.ts b/test/smoke/src/areas/notebook/notebook.test.ts index b104ce26f76..39fc1e339f5 100644 --- a/test/smoke/src/areas/notebook/notebook.test.ts +++ b/test/smoke/src/areas/notebook/notebook.test.ts @@ -21,13 +21,9 @@ export function setup(logger: Logger) { after(async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - cp.execSync('git checkout . --quiet', { cwd: workspacePathOrFolder }); - cp.execSync('git reset --hard HEAD --quiet', { cwd: workspacePathOrFolder }); + cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }); + cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }); }); // the heap snapshot fails to parse diff --git a/test/smoke/src/areas/search/search.test.ts b/test/smoke/src/areas/search/search.test.ts index 8ac0bba570f..40db1cb07c0 100644 --- a/test/smoke/src/areas/search/search.test.ts +++ b/test/smoke/src/areas/search/search.test.ts @@ -15,13 +15,9 @@ export function setup(logger: Logger) { after(function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - retry(async () => cp.execSync('git checkout . --quiet', { cwd: workspacePathOrFolder }), 0, 5); - retry(async () => cp.execSync('git reset --hard HEAD --quiet', { cwd: workspacePathOrFolder }), 0, 5); + 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); }); it('verifies the sidebar moves to the right', async function () { diff --git a/test/smoke/src/areas/statusbar/statusbar.test.ts b/test/smoke/src/areas/statusbar/statusbar.test.ts index f681758562e..edf594ad7e9 100644 --- a/test/smoke/src/areas/statusbar/statusbar.test.ts +++ b/test/smoke/src/areas/statusbar/statusbar.test.ts @@ -15,16 +15,12 @@ export function setup(logger: Logger) { it('verifies presence of all default status bar elements', async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.BRANCH_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.SYNC_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.PROBLEMS_STATUS); - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.ENCODING_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.EOL_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.INDENTATION_STATUS); @@ -34,16 +30,12 @@ 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; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } await app.workbench.statusbar.clickOn(StatusBarElement.BRANCH_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.INDENTATION_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); @@ -66,12 +58,8 @@ export function setup(logger: Logger) { it(`verifies if changing EOL is reflected in the status bar`, async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.EOL_STATUS); await app.workbench.quickinput.selectQuickInputElement(1); diff --git a/test/smoke/src/areas/workbench/data-loss.test.ts b/test/smoke/src/areas/workbench/data-loss.test.ts index f876f8596bd..3fb16b61c74 100644 --- a/test/smoke/src/areas/workbench/data-loss.test.ts +++ b/test/smoke/src/areas/workbench/data-loss.test.ts @@ -27,15 +27,11 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } // Open 3 editors - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.runCommand('View: Keep Editor'); - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'app.js')); await app.workbench.quickaccess.runCommand('View: Keep Editor'); await app.workbench.editors.newUntitledFile(); @@ -58,15 +54,10 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - const textToType = 'Hello, Code'; // open editor and type - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'app.js')); await app.workbench.editor.waitForTypeInEditor('app.js', textToType); await app.workbench.editors.waitForTab('app.js', true); @@ -104,11 +95,6 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - if (autoSave) { await app.workbench.settingsEditor.addUserSetting('files.autoSave', '"afterDelay"'); } @@ -120,7 +106,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin await app.workbench.editors.waitForTab('Untitled-1', true); const textToType = 'Hello, Code'; - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); await app.workbench.editor.waitForTypeInEditor('readme.md', textToType); await app.workbench.editors.waitForTab('readme.md', !autoSave); @@ -190,15 +176,10 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin stableApp = new Application(stableOptions); await stableApp.start(); - const workspacePathOrFolder = stableApp.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - // Open 3 editors - await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); + await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'bin', 'www')); await stableApp.workbench.quickaccess.runCommand('View: Keep Editor'); - await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); + await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'app.js')); await stableApp.workbench.quickaccess.runCommand('View: Keep Editor'); await stableApp.workbench.editors.newUntitledFile(); @@ -251,11 +232,6 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin stableApp = new Application(stableOptions); await stableApp.start(); - const workspacePathOrFolder = stableApp.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - const textToTypeInUntitled = 'Hello from Untitled'; await stableApp.workbench.editors.newUntitledFile(); @@ -263,7 +239,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin await stableApp.workbench.editors.waitForTab('Untitled-1', true); const textToType = 'Hello, Code'; - await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); + await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'readme.md')); await stableApp.workbench.editor.waitForTypeInEditor('readme.md', textToType); await stableApp.workbench.editors.waitForTab('readme.md', true); From e161b81a60d2b0c64e53e660892773ae571058b8 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:41:56 +0100 Subject: [PATCH 33/67] Chat - expand working set when opening a session (#286344) --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 4 ++++ .../chat/browser/widgetHosts/viewPane/chatViewPane.ts | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) 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 bdd510cef5c..afc7eef7363 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2177,6 +2177,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); 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..cd53193efa5 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -750,7 +750,13 @@ 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); + + const chatModel = await this.showModel(newModelRef); + if (chatModel) { + this._widget.input.setWorkingSetCollapsed(false); + } + + return chatModel; }); } From 49cb3560c16293b018885f7c799a705b7c02fc40 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:00:44 +0100 Subject: [PATCH 34/67] Add Session target picker actions (#287143) * first round * icons * nits * Agent type picker and improved new chat actions --------- Co-authored-by: Benjamin Pasero --- .../chat/browser/actions/chatActions.ts | 2 +- .../browser/actions/chatExecuteActions.ts | 41 +++- .../chat/browser/actions/chatNewActions.ts | 43 +++- .../browser/agentSessions/agentSessions.ts | 13 ++ .../chatSessions/chatSessions.contribution.ts | 204 +++++++++++++----- .../browser/widget/input/chatInputPart.ts | 22 +- .../widget/input/modePickerActionItem.ts | 4 +- .../widget/input/modelPickerActionItem.ts | 4 +- .../input/sessionTargetPickerActionItem.ts | 150 +++++++++++++ .../chat/browser/widget/media/chat.css | 20 +- .../inlineChat/browser/media/inlineChat.css | 2 +- 11 files changed, 437 insertions(+), 68 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 3da3a988b9b..df91b07dbd8 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 }], }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 810a13614cf..884cc180f4c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -347,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({ @@ -431,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() { @@ -758,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 792b1b13df8..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,13 +16,17 @@ 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'; @@ -96,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 } ], @@ -122,6 +128,7 @@ export function registerNewChatActions() { await focusViewService.exitFocusView(); return; } + const viewsService = accessor.get(IViewsService); const executeCommandContext = args[0] as INewEditSessionActionContext | undefined; @@ -140,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(); @@ -267,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.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/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index f6861a21ab5..8b41b6f025e 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); } }) ); @@ -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/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index afc7eef7363..ab2fd90106a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -93,10 +93,10 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind, validateChatMode } import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.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 { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; -import { IChatWidget } from '../../chat.js'; +import { IChatWidget, isIChatResourceViewContext } 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 { IDisposableReference } from '../chatContentParts/chatCollections.js'; @@ -114,6 +114,8 @@ import { ChatRelatedFiles } from '../../attachments/chatInputRelatedFilesContrib import { resizeImage } from '../../chatImageUtils.js'; import { IModelPickerDelegate, ModelPickerActionItem } from './modelPickerActionItem.js'; import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; +import { ISessionTypePickerDelegate, SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; +import { getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; import { mixin } from '../../../../../../base/common/objects.js'; @@ -333,6 +335,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge 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; @@ -709,6 +712,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; @@ -1756,7 +1763,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(); } @@ -1778,6 +1785,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); 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..841d3692235 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1344,13 +1344,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 +1359,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 +1384,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 +1393,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; 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; } From 27782b41f27761c10b2fd950bd7135b249aeb923 Mon Sep 17 00:00:00 2001 From: Aiday Marlen Kyzy Date: Wed, 14 Jan 2026 10:02:47 +0100 Subject: [PATCH 35/67] using relative values instead of absolute values for the font size and the line height (#286006) * using relative values instead of absolute values for the font size and the line height * renaming to multiplier * setting back to font size and line height * Revert "renaming to multiplier" This reverts commit 558885565989c3aa702cc58f9a83c2c95a9e6067. * doing some polishing work * changing the api * updating to higher version of vscode-textmate * also changing the vscode textmate package version for the remote extension * increasing the vscode textmate version in remote/web * updating package lock json * using css variables instead of fetching font size from config service * removing the second multiplier event * adding ? after dom element style * Ensure dots from floating fontSize are stripped from class names --------- Co-authored-by: Alexandru Dima --- .../lib/stylelint/vscode-known-variables.json | 1 + package-lock.json | 8 +++--- package.json | 2 +- remote/package-lock.json | 8 +++--- remote/package.json | 2 +- remote/web/package-lock.json | 8 +++--- remote/web/package.json | 2 +- .../widget/codeEditor/codeEditorWidget.ts | 4 +++ src/vs/editor/common/languages.ts | 4 +-- .../common/languages/supports/tokenization.ts | 25 ++++++++++++++----- .../tokenizationFontDecorationsProvider.ts | 8 +++--- src/vs/editor/common/textModelEvents.ts | 18 ++++++------- .../editor/common/viewModel/viewModelImpl.ts | 6 ++--- src/vs/platform/theme/common/themeService.ts | 4 +-- .../worker/textMateWorkerTokenizer.ts | 4 +-- .../services/themes/common/colorThemeData.ts | 4 +-- .../themes/common/colorThemeSchema.ts | 6 ++--- .../themes/common/workbenchThemeService.ts | 2 +- 18 files changed, 67 insertions(+), 49 deletions(-) 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/package-lock.json b/package-lock.json index cb95a5a4cbe..fc9b6b0f7a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "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" }, @@ -17696,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 8e94496dee9..e5ad7191f87 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "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" }, diff --git a/remote/package-lock.json b/remote/package-lock.json index 80bae871859..30c5541fd60 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -42,7 +42,7 @@ "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" } @@ -1400,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..d2eab8bf24a 100644 --- a/remote/package.json +++ b/remote/package.json @@ -37,7 +37,7 @@ "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/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..95af648f724 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; } /** 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/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/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/themes/common/colorThemeData.ts b/src/vs/workbench/services/themes/common/colorThemeData.ts index 386d668f89c..75c05cdec53 100644 --- a/src/vs/workbench/services/themes/common/colorThemeData.ts +++ b/src/vs/workbench/services/themes/common/colorThemeData.ts @@ -1014,8 +1014,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; } From 17523c000eef5c2197a369b1dda37574b9c63217 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:18:42 +0100 Subject: [PATCH 36/67] Revert recent merges affecting chat session functionality (#287734) * Revert "Merge pull request #287668 from mjbvz/dev/mjbvz/eventual-sparrow" This reverts commit 81f7af4b9f20f2746d58e2ca28e4f686856df81b, reversing changes made to 85a14f966c3ad1b7dbe5c1275a235a57fac1b5d7. * Revert "Merge pull request #286642 from microsoft/dev/mjbvz/chat-session-item-controller" This reverts commit b39ecc3960ec91b2ce85518d44e3e8a323b7f4a6, reversing changes made to 45aced59351aa73fd1bc002f64ab4a0c13ba68a9. --- eslint.config.js | 1 - .../common/extensionsApiProposals.ts | 2 +- .../api/browser/mainThreadChatSessions.ts | 2 +- .../workbench/api/common/extHost.api.impl.ts | 4 - .../api/common/extHostChatSessions.ts | 278 +----------------- .../agentSessions/agentSessionsModel.ts | 52 ++-- .../agentSessions/agentSessionsPicker.ts | 2 +- .../agentSessions/agentSessionsViewer.ts | 13 +- .../chat/common/chatService/chatService.ts | 20 +- .../common/chatService/chatServiceImpl.ts | 12 +- .../chat/common/chatSessionsService.ts | 8 +- .../contrib/chat/common/model/chatModel.ts | 10 +- .../chat/common/model/chatSessionStore.ts | 7 +- .../agentSessionViewModel.test.ts | 52 ++-- .../agentSessionsDataSource.test.ts | 9 +- .../localAgentSessionsProvider.test.ts | 55 ++-- .../chat/test/common/model/mockChatModel.ts | 3 +- .../vscode.proposed.chatSessionsProvider.d.ts | 144 +-------- 18 files changed, 116 insertions(+), 558 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index b245f9466ac..37fb7fe63bf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -899,7 +899,6 @@ export default tseslint.config( ], 'verbs': [ 'accept', - 'archive', 'change', 'close', 'collapse', diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 069ec076c42..2a0fdff9f24 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -69,7 +69,7 @@ const _allApiProposals = { }, chatSessionsProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts', - version: 4 + version: 3 }, chatStatusItem: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts', diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 38de78caf4a..6a18a39b05f 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -382,6 +382,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat )); } + $onDidChangeChatSessionItems(handle: number): void { this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire(); } @@ -490,7 +491,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat resource: uri, iconPath: session.iconPath, tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, - archived: session.archived, } satisfies IChatSessionItem; })); } catch (error) { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 02eeb78c937..a77a0079ee0 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1530,10 +1530,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); }, - createChatSessionItemController: (chatSessionType: string, refreshHandler: () => Thenable) => { - checkProposedApiEnabled(extension, 'chatSessionsProvider'); - return extHostChatSessions.createChatSessionItemController(extension, chatSessionType, refreshHandler); - }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionContentProvider(extension, scheme, chatParticipant, provider, capabilities); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index c4d34921e45..bc7366256c1 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -2,14 +2,12 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ import type * as vscode from 'vscode'; import { coalesce } from '../../../base/common/arrays.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; @@ -31,177 +29,6 @@ import { basename } from '../../../base/common/resources.js'; import { Diagnostic } from './extHostTypeConverters.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; -type ChatSessionTiming = vscode.ChatSessionItem['timing']; - -// #region Chat Session Item Controller - -class ChatSessionItemImpl implements vscode.ChatSessionItem { - #label: string; - #iconPath?: vscode.IconPath; - #description?: string | vscode.MarkdownString; - #badge?: string | vscode.MarkdownString; - #status?: vscode.ChatSessionStatus; - #archived?: boolean; - #tooltip?: string | vscode.MarkdownString; - #timing?: ChatSessionTiming; - #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; - #onChanged: () => void; - - readonly resource: vscode.Uri; - - constructor(resource: vscode.Uri, label: string, onChanged: () => void) { - this.resource = resource; - this.#label = label; - this.#onChanged = onChanged; - } - - get label(): string { - return this.#label; - } - - set label(value: string) { - if (this.#label !== value) { - this.#label = value; - this.#onChanged(); - } - } - - get iconPath(): vscode.IconPath | undefined { - return this.#iconPath; - } - - set iconPath(value: vscode.IconPath | undefined) { - if (this.#iconPath !== value) { - this.#iconPath = value; - this.#onChanged(); - } - } - - get description(): string | vscode.MarkdownString | undefined { - return this.#description; - } - - set description(value: string | vscode.MarkdownString | undefined) { - if (this.#description !== value) { - this.#description = value; - this.#onChanged(); - } - } - - get badge(): string | vscode.MarkdownString | undefined { - return this.#badge; - } - - set badge(value: string | vscode.MarkdownString | undefined) { - if (this.#badge !== value) { - this.#badge = value; - this.#onChanged(); - } - } - - get status(): vscode.ChatSessionStatus | undefined { - return this.#status; - } - - set status(value: vscode.ChatSessionStatus | undefined) { - if (this.#status !== value) { - this.#status = value; - this.#onChanged(); - } - } - - get archived(): boolean | undefined { - return this.#archived; - } - - set archived(value: boolean | undefined) { - if (this.#archived !== value) { - this.#archived = value; - this.#onChanged(); - } - } - - get tooltip(): string | vscode.MarkdownString | undefined { - return this.#tooltip; - } - - set tooltip(value: string | vscode.MarkdownString | undefined) { - if (this.#tooltip !== value) { - this.#tooltip = value; - this.#onChanged(); - } - } - - get timing(): ChatSessionTiming | undefined { - return this.#timing; - } - - set timing(value: ChatSessionTiming | undefined) { - if (this.#timing !== value) { - this.#timing = value; - this.#onChanged(); - } - } - - get changes(): readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined { - return this.#changes; - } - - set changes(value: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined) { - if (this.#changes !== value) { - this.#changes = value; - this.#onChanged(); - } - } -} - -class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection { - readonly #items = new ResourceMap(); - #onItemsChanged: () => void; - - constructor(onItemsChanged: () => void) { - this.#onItemsChanged = onItemsChanged; - } - - get size(): number { - return this.#items.size; - } - - replace(items: readonly vscode.ChatSessionItem[]): void { - this.#items.clear(); - for (const item of items) { - this.#items.set(item.resource, item); - } - this.#onItemsChanged(); - } - - forEach(callback: (item: vscode.ChatSessionItem, collection: vscode.ChatSessionItemCollection) => unknown, thisArg?: any): void { - for (const [_, item] of this.#items) { - callback.call(thisArg, item, this); - } - } - - add(item: vscode.ChatSessionItem): void { - this.#items.set(item.resource, item); - this.#onItemsChanged(); - } - - delete(resource: vscode.Uri): void { - this.#items.delete(resource); - this.#onItemsChanged(); - } - - get(resource: vscode.Uri): vscode.ChatSessionItem | undefined { - return this.#items.get(resource); - } - - [Symbol.iterator](): Iterator { - return this.#items.entries(); - } -} - -// #endregion - class ExtHostChatSession { private _stream: ChatAgentResponseStream; @@ -235,20 +62,13 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio readonly extension: IExtensionDescription; readonly disposable: DisposableStore; }>(); - private readonly _chatSessionItemControllers = new Map(); - private _nextChatSessionItemProviderHandle = 0; private readonly _chatSessionContentProviders = new Map(); - private _nextChatSessionItemControllerHandle = 0; + private _nextChatSessionItemProviderHandle = 0; private _nextChatSessionContentProviderHandle = 0; /** @@ -320,52 +140,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } - - createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { - const controllerHandle = this._nextChatSessionItemControllerHandle++; - const disposables = new DisposableStore(); - - // TODO: Currently not hooked up - const onDidArchiveChatSessionItem = disposables.add(new Emitter()); - - const collection = new ChatSessionItemCollectionImpl(() => { - this._proxy.$onDidChangeChatSessionItems(controllerHandle); - }); - - let isDisposed = false; - - const controller: vscode.ChatSessionItemController = { - id, - refreshHandler, - items: collection, - onDidArchiveChatSessionItem: onDidArchiveChatSessionItem.event, - createChatSessionItem: (resource: vscode.Uri, label: string) => { - if (isDisposed) { - throw new Error('ChatSessionItemController has been disposed'); - } - - return new ChatSessionItemImpl(resource, label, () => { - // TODO: Optimize to only update the specific item - this._proxy.$onDidChangeChatSessionItems(controllerHandle); - }); - }, - dispose: () => { - isDisposed = true; - disposables.dispose(); - }, - }; - - this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id }); - this._proxy.$registerChatSessionItemProvider(controllerHandle, id); - - disposables.add(toDisposable(() => { - this._chatSessionItemControllers.delete(controllerHandle); - this._proxy.$unregisterChatSessionItemProvider(controllerHandle); - })); - - return controller; - } - registerChatSessionContentProvider(extension: IExtensionDescription, chatSessionScheme: string, chatParticipant: vscode.ChatParticipant, provider: vscode.ChatSessionContentProvider, capabilities?: vscode.ChatSessionCapabilities): vscode.Disposable { const handle = this._nextChatSessionContentProviderHandle++; const disposables = new DisposableStore(); @@ -410,25 +184,17 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } } - private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { - // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties - const timing = sessionContent.timing; - const created = timing?.created ?? timing?.startTime ?? 0; - const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; - const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; - + private convertChatSessionItem(sessionType: string, sessionContent: vscode.ChatSessionItem): IChatSessionItem { return { resource: sessionContent.resource, label: sessionContent.label, description: sessionContent.description ? typeConvert.MarkdownString.from(sessionContent.description) : undefined, badge: sessionContent.badge ? typeConvert.MarkdownString.from(sessionContent.badge) : undefined, status: this.convertChatSessionStatus(sessionContent.status), - archived: sessionContent.archived, tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), timing: { - created, - lastRequestStarted, - lastRequestEnded, + startTime: sessionContent.timing?.startTime ?? 0, + endTime: sessionContent.timing?.endTime }, changes: sessionContent.changes instanceof Array ? sessionContent.changes : @@ -441,35 +207,21 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - let items: vscode.ChatSessionItem[]; + const entry = this._chatSessionItemProviders.get(handle); + if (!entry) { + this._logService.error(`No provider registered for handle ${handle}`); + return []; + } - const controller = this._chatSessionItemControllers.get(handle); - if (controller) { - // Call the refresh handler to populate items - await controller.controller.refreshHandler(); - if (token.isCancellationRequested) { - return []; - } - - items = Array.from(controller.controller.items, x => x[1]); - } else { - - const itemProvider = this._chatSessionItemProviders.get(handle); - if (!itemProvider) { - this._logService.error(`No provider registered for handle ${handle}`); - return []; - } - - items = await itemProvider.provider.provideChatSessionItems(token) ?? []; - if (token.isCancellationRequested) { - return []; - } + const sessions = await entry.provider.provideChatSessionItems(token); + if (!sessions) { + return []; } const response: IChatSessionItem[] = []; - for (const sessionContent of items) { + for (const sessionContent of sessions) { this._sessionItems.set(sessionContent.resource, sessionContent); - response.push(this.convertChatSessionItem(sessionContent)); + response.push(this.convertChatSessionItem(entry.sessionType, sessionContent)); } return response; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 73776e50163..b579321fec1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -359,24 +359,19 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode ? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions } : changes; - // Times: it is important to always provide timing information to track + // Times: it is important to always provide a start and end time to track // unread/read state for example. // If somehow the provider does not provide any, fallback to last known - let created = session.timing.created; - let lastRequestStarted = session.timing.lastRequestStarted; - let lastRequestEnded = session.timing.lastRequestEnded; - if (!created || !lastRequestEnded) { + let startTime = session.timing.startTime; + let endTime = session.timing.endTime; + if (!startTime || !endTime) { const existing = this._sessions.get(session.resource); - if (!created && existing?.timing.created) { - created = existing.timing.created; + if (!startTime && existing?.timing.startTime) { + startTime = existing.timing.startTime; } - if (!lastRequestEnded && existing?.timing.lastRequestEnded) { - lastRequestEnded = existing.timing.lastRequestEnded; - } - - if (!lastRequestStarted && existing?.timing.lastRequestStarted) { - lastRequestStarted = existing.timing.lastRequestStarted; + if (!endTime && existing?.timing.endTime) { + endTime = existing.timing.endTime; } } @@ -391,13 +386,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode tooltip: session.tooltip, status, archived: session.archived, - timing: { - created, - lastRequestStarted, - lastRequestEnded, - inProgressTime, - finishedOrFailedTime - }, + timing: { startTime, endTime, inProgressTime, finishedOrFailedTime }, changes: normalizedChanges, })); } @@ -465,7 +454,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private isRead(session: IInternalAgentSessionData): boolean { const readDate = this.sessionStates.get(session.resource)?.read; - return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); + return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.endTime ?? session.timing.startTime); } private setRead(session: IInternalAgentSessionData, read: boolean): void { @@ -484,7 +473,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode //#region Sessions Cache -interface ISerializedAgentSession { +interface ISerializedAgentSession extends Omit { readonly providerType: string; readonly providerLabel: string; @@ -503,11 +492,7 @@ interface ISerializedAgentSession { readonly archived: boolean | undefined; readonly timing: { - readonly created: number; - readonly lastRequestStarted?: number; - readonly lastRequestEnded?: number; - // Old format for backward compatibility when reading - readonly startTime?: number; + readonly startTime: number; readonly endTime?: number; }; @@ -550,9 +535,8 @@ class AgentSessionsCache { archived: session.archived, timing: { - created: session.timing.created, - lastRequestStarted: session.timing.lastRequestStarted, - lastRequestEnded: session.timing.lastRequestEnded, + startTime: session.timing.startTime, + endTime: session.timing.endTime, }, changes: session.changes, @@ -569,7 +553,7 @@ class AgentSessionsCache { try { const cached = JSON.parse(sessionsCache) as ISerializedAgentSession[]; - return cached.map((session): IInternalAgentSessionData => ({ + return cached.map(session => ({ providerType: session.providerType, providerLabel: session.providerLabel, @@ -585,10 +569,8 @@ class AgentSessionsCache { archived: session.archived, timing: { - // Support loading both new and old cache formats - created: session.timing.created ?? session.timing.startTime ?? 0, - lastRequestStarted: session.timing.lastRequestStarted ?? session.timing.startTime, - lastRequestEnded: session.timing.lastRequestEnded ?? session.timing.endTime, + startTime: session.timing.startTime, + endTime: session.timing.endTime, }, changes: Array.isArray(session.changes) ? session.changes.map((change: IChatSessionFileChange) => ({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index ba5bfac455d..cd91ba6fbdb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -44,7 +44,7 @@ export const deleteButton: IQuickInputButton = { export function getSessionDescription(session: IAgentSession): string { const descriptionText = typeof session.description === 'string' ? session.description : session.description ? renderAsPlaintext(session.description) : undefined; - const timeAgo = fromNow(session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); + const timeAgo = fromNow(session.timing.endTime || session.timing.startTime); const descriptionParts = [descriptionText, session.providerLabel, timeAgo].filter(part => !!part); return descriptionParts.join(' • '); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 17c8d9f3a5a..f3d3e6e29cd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -323,7 +323,7 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -827,9 +826,7 @@ export class AgentSessionsSorter implements ITreeSorter { } //Sort by end or start time (most recent first) - const timeA = sessionA.timing.lastRequestEnded ?? sessionA.timing.lastRequestStarted ?? sessionA.timing.created; - const timeB = sessionB.timing.lastRequestEnded ?? sessionB.timing.lastRequestStarted ?? sessionB.timing.created; - return timeB - timeA; + return (sessionB.timing.endTime || sessionB.timing.startTime) - (sessionA.timing.endTime || sessionA.timing.startTime); } } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 6bd8e4f9060..b4f75cc832f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -941,24 +941,8 @@ export interface IChatSessionStats { } export interface IChatSessionTiming { - /** - * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - */ - created: number; - - /** - * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * - * Should be undefined if no requests have been made yet. - */ - lastRequestStarted: number | undefined; - - /** - * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * - * Should be undefined if the most recent request is still in progress or if no requests have been made yet. - */ - lastRequestEnded: number | undefined; + startTime: number; + endTime?: number; } export const enum ResponseModelState { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 97c2637ef07..e515c29b76d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -377,11 +377,7 @@ export class ChatService extends Disposable implements IChatService { ...entry, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: entry.timing ?? { - created: entry.lastMessageDate, - lastRequestStarted: undefined, - lastRequestEnded: entry.lastMessageDate, - }, + timing: entry.timing ?? { startTime: entry.lastMessageDate }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: entry.lastResponseState ?? ResponseModelState.Complete, @@ -397,11 +393,7 @@ export class ChatService extends Disposable implements IChatService { ...metadata, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: metadata.timing ?? { - created: metadata.lastMessageDate, - lastRequestStarted: undefined, - lastRequestEnded: metadata.lastMessageDate, - }, + timing: metadata.timing ?? { startTime: metadata.lastMessageDate }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: metadata.lastResponseState ?? ResponseModelState.Complete, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 94126a5ffcf..76a9b348698 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -14,7 +14,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participants/chatAgents.js'; import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; -import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; +import { IChatProgress, IChatService } from './chatService/chatService.js'; export const enum ChatSessionStatus { Failed = 0, @@ -73,7 +73,6 @@ export interface IChatSessionsExtensionPoint { readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; } - export interface IChatSessionItem { resource: URI; label: string; @@ -82,7 +81,10 @@ export interface IChatSessionItem { description?: string | IMarkdownString; status?: ChatSessionStatus; tooltip?: string | IMarkdownString; - timing: IChatSessionTiming; + timing: { + startTime: number; + endTime?: number; + }; changes?: { files: number; insertions: number; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index eb25096e3c9..6115e54dbbe 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1801,14 +1801,10 @@ export class ChatModel extends Disposable implements IChatModel { } get timing(): IChatSessionTiming { - const lastRequest = this._requests.at(-1); - const lastResponse = lastRequest?.response; - const lastRequestStarted = lastRequest?.timestamp; - const lastRequestEnded = lastResponse?.completedAt ?? lastResponse?.timestamp; + const lastResponse = this._requests.at(-1)?.response; return { - created: this._timestamp, - lastRequestStarted, - lastRequestEnded, + startTime: this._timestamp, + endTime: lastResponse?.completedAt ?? lastResponse?.timestamp }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 1465a8d5c54..63ac4c99c21 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -665,13 +665,12 @@ async function getSessionMetadata(session: ChatModel | ISerializableChatData): P session.lastMessageDate : session.requests.at(-1)?.timestamp ?? session.creationDate; - const timing: IChatSessionTiming = session instanceof ChatModel ? + const timing = session instanceof ChatModel ? session.timing : // session is only ISerializableChatData in the old pre-fs storage data migration scenario { - created: session.creationDate, - lastRequestStarted: session.requests.at(-1)?.timestamp, - lastRequestEnded: lastMessageDate, + startTime: session.creationDate, + endTime: lastMessageDate }; return { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index bacf032abd9..114f666d135 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -176,8 +176,8 @@ suite('Agent Sessions', () => { test('should handle session with all properties', async () => { return runWithFakedTimers({}, async () => { - const created = Date.now(); - const lastRequestEnded = created + 1000; + const startTime = Date.now(); + const endTime = startTime + 1000; const provider: IChatSessionItemProvider = { chatSessionType: 'test-type', @@ -190,8 +190,8 @@ suite('Agent Sessions', () => { status: ChatSessionStatus.Completed, tooltip: 'Session tooltip', iconPath: ThemeIcon.fromId('check'), - timing: { created, lastRequestStarted: created, lastRequestEnded }, - changes: { files: 1, insertions: 10, deletions: 5 } + timing: { startTime, endTime }, + changes: { files: 1, insertions: 10, deletions: 5, details: [] } } ] }; @@ -210,8 +210,8 @@ suite('Agent Sessions', () => { assert.strictEqual(session.description.value, '**Bold** description'); } assert.strictEqual(session.status, ChatSessionStatus.Completed); - assert.strictEqual(session.timing.created, created); - assert.strictEqual(session.timing.lastRequestEnded, lastRequestEnded); + assert.strictEqual(session.timing.startTime, startTime); + assert.strictEqual(session.timing.endTime, endTime); assert.deepStrictEqual(session.changes, { files: 1, insertions: 10, deletions: 5 }); }); }); @@ -1521,10 +1521,9 @@ suite('Agent Sessions', () => { test('should consider sessions before initial date as read by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing before the READ_STATE_INITIAL_DATE (December 8, 2025) - const oldSessionTiming: IChatSessionItem['timing'] = { - created: Date.UTC(2025, 10 /* November */, 1), - lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), - lastRequestEnded: Date.UTC(2025, 10 /* November */, 2), + const oldSessionTiming = { + startTime: Date.UTC(2025, 10 /* November */, 1), + endTime: Date.UTC(2025, 10 /* November */, 2), }; const provider: IChatSessionItemProvider = { @@ -1553,10 +1552,9 @@ suite('Agent Sessions', () => { test('should consider sessions after initial date as unread by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing after the READ_STATE_INITIAL_DATE (December 8, 2025) - const newSessionTiming: IChatSessionItem['timing'] = { - created: Date.UTC(2025, 11 /* December */, 10), - lastRequestStarted: Date.UTC(2025, 11 /* December */, 10), - lastRequestEnded: Date.UTC(2025, 11 /* December */, 11), + const newSessionTiming = { + startTime: Date.UTC(2025, 11 /* December */, 10), + endTime: Date.UTC(2025, 11 /* December */, 11), }; const provider: IChatSessionItemProvider = { @@ -1585,10 +1583,9 @@ suite('Agent Sessions', () => { test('should use endTime for read state comparison when available', async () => { return runWithFakedTimers({}, async () => { // Session with startTime before initial date but endTime after - const sessionTiming: IChatSessionItem['timing'] = { - created: Date.UTC(2025, 10 /* November */, 1), - lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), - lastRequestEnded: Date.UTC(2025, 11 /* December */, 10), + const sessionTiming = { + startTime: Date.UTC(2025, 10 /* November */, 1), + endTime: Date.UTC(2025, 11 /* December */, 10), }; const provider: IChatSessionItemProvider = { @@ -1609,7 +1606,7 @@ suite('Agent Sessions', () => { await viewModel.resolve(undefined); const session = viewModel.sessions[0]; - // Should use lastRequestEnded (December 10) which is after the initial date + // Should use endTime (December 10) which is after the initial date assert.strictEqual(session.isRead(), false); }); }); @@ -1617,10 +1614,8 @@ suite('Agent Sessions', () => { test('should use startTime for read state comparison when endTime is not available', async () => { return runWithFakedTimers({}, async () => { // Session with only startTime before initial date - const sessionTiming: IChatSessionItem['timing'] = { - created: Date.UTC(2025, 10 /* November */, 1), - lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), - lastRequestEnded: undefined, + const sessionTiming = { + startTime: Date.UTC(2025, 10 /* November */, 1), }; const provider: IChatSessionItemProvider = { @@ -2059,15 +2054,8 @@ function makeSimpleSessionItem(id: string, overrides?: Partial }; } -function makeNewSessionTiming(options?: { - created?: number; - lastRequestStarted?: number | undefined; - lastRequestEnded?: number | undefined; -}): IChatSessionItem['timing'] { - const now = Date.now(); +function makeNewSessionTiming(): IChatSessionItem['timing'] { return { - created: options?.created ?? now, - lastRequestStarted: options?.lastRequestStarted, - lastRequestEnded: options?.lastRequestEnded, + startTime: Date.now(), }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index d551277757b..f29f8f83327 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -36,9 +36,8 @@ suite('AgentSessionsDataSource', () => { label: `Session ${overrides.id ?? 'default'}`, icon: Codicon.terminal, timing: { - created: overrides.startTime ?? now, - lastRequestEnded: undefined, - lastRequestStarted: undefined, + startTime: overrides.startTime ?? now, + endTime: overrides.endTime ?? now, }, isArchived: () => overrides.isArchived ?? false, setArchived: () => { }, @@ -74,8 +73,8 @@ suite('AgentSessionsDataSource', () => { return { compare: (a, b) => { // Sort by end time, most recent first - const aTime = a.timing.lastRequestEnded ?? a.timing.lastRequestStarted ?? a.timing.created; - const bTime = b.timing.lastRequestEnded ?? b.timing.lastRequestStarted ?? b.timing.created; + const aTime = a.timing.endTime || a.timing.startTime; + const bTime = b.timing.endTime || b.timing.startTime; return bTime - aTime; } }; 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 8b88eae7f82..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 @@ -18,24 +18,11 @@ import { LocalAgentsSessionsProvider } from '../../../browser/agentSessions/loca import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; -function createTestTiming(options?: { - created?: number; - lastRequestStarted?: number | undefined; - lastRequestEnded?: number | undefined; -}): IChatSessionItem['timing'] { - const now = Date.now(); - return { - created: options?.created ?? now, - lastRequestStarted: options?.lastRequestStarted, - lastRequestEnded: options?.lastRequestEnded, - }; -} - class MockChatService implements IChatService { private readonly _chatModels: ISettableObservable> = observableValue('chatModels', []); readonly chatModels = this._chatModels; @@ -332,7 +319,7 @@ suite('LocalAgentsSessionsProvider', () => { title: 'Test Session', lastMessageDate: Date.now(), isActive: true, - timing: createTestTiming(), + timing: { startTime: 0, endTime: 1 }, lastResponseState: ResponseModelState.Complete }]); @@ -356,7 +343,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -382,7 +369,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); mockChatService.setHistorySessionItems([{ sessionResource, @@ -390,7 +377,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -418,7 +405,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -448,7 +435,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming(), + timing: { startTime: 0, endTime: 1 }, }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -477,7 +464,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming(), + timing: { startTime: 0, endTime: 1 }, }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -506,7 +493,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming(), + timing: { startTime: 0, endTime: 1 }, }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -550,7 +537,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming(), + timing: { startTime: 0, endTime: 1 }, stats: { added: 30, removed: 8, @@ -595,7 +582,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -606,7 +593,7 @@ suite('LocalAgentsSessionsProvider', () => { }); suite('Session Timing', () => { - test('should use model timestamp for created when model exists', async () => { + test('should use model timestamp for startTime when model exists', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -625,16 +612,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming({ created: modelTimestamp }) + timing: { startTime: modelTimestamp } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.created, modelTimestamp); + assert.strictEqual(sessions[0].timing.startTime, modelTimestamp); }); }); - test('should use lastMessageDate for created when model does not exist', async () => { + test('should use lastMessageDate for startTime when model does not exist', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -648,16 +635,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming({ created: lastMessageDate }) + timing: { startTime: lastMessageDate } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.created, lastMessageDate); + assert.strictEqual(sessions[0].timing.startTime, lastMessageDate); }); }); - test('should set lastRequestEnded from last response completedAt', async () => { + test('should set endTime from last response completedAt', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -677,12 +664,12 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming({ lastRequestEnded: completedAt }) + timing: { startTime: 0, endTime: completedAt } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); + assert.strictEqual(sessions[0].timing.endTime, completedAt); }); }); }); @@ -705,7 +692,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); 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 026c88b2fa5..4cced4a16c4 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -10,14 +10,13 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; -import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; export class MockChatModel extends Disposable implements IChatModel { readonly onDidDispose = this._register(new Emitter()).event; readonly onDidChange = this._register(new Emitter()).event; sessionId = ''; readonly timestamp = 0; - readonly timing: IChatSessionTiming = { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; + readonly timing = { startTime: 0 }; readonly initialLocation = ChatAgentLocation.Chat; readonly title = ''; readonly hasCustomTitle = false; diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index c1cbdf9c715..ac6ade0f413 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 4 +// version: 3 declare module 'vscode' { /** @@ -26,25 +26,6 @@ declare module 'vscode' { InProgress = 2 } - export namespace chat { - /** - * Registers a new {@link ChatSessionItemProvider chat session item provider}. - * - * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. - * - * @param chatSessionType The type of chat session the provider is for. - * @param provider The provider to register. - * - * @returns A disposable that unregisters the provider when disposed. - */ - export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; - - /** - * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. - */ - export function createChatSessionItemController(id: string, refreshHandler: () => Thenable): ChatSessionItemController; - } - /** * Provides a list of information about chat sessions. */ @@ -71,86 +52,6 @@ declare module 'vscode' { // #endregion } - /** - * Provides a list of information about chat sessions. - */ - export interface ChatSessionItemController { - readonly id: string; - - /** - * Unregisters the controller, disposing of its associated chat session items. - */ - dispose(): void; - - /** - * Managed collection of chat session items - */ - readonly items: ChatSessionItemCollection; - - /** - * Creates a new managed chat session item that be added to the collection. - */ - createChatSessionItem(resource: Uri, label: string): ChatSessionItem; - - /** - * Handler called to refresh the collection of chat session items. - * - * This is also called on first load to get the initial set of items. - */ - refreshHandler: () => Thenable; - - /** - * Fired when an item is archived by the editor - * - * TODO: expose archive state on the item too? - */ - readonly onDidArchiveChatSessionItem: Event; - } - - /** - * A collection of chat session items. It provides operations for managing and iterating over the items. - */ - export interface ChatSessionItemCollection extends Iterable { - /** - * Gets the number of items in the collection. - */ - readonly size: number; - - /** - * Replaces the items stored by the collection. - * @param items Items to store. - */ - replace(items: readonly ChatSessionItem[]): void; - - /** - * Iterate over each entry in this collection. - * - * @param callback Function to execute for each entry. - * @param thisArg The `this` context used when invoking the handler function. - */ - forEach(callback: (item: ChatSessionItem, collection: ChatSessionItemCollection) => unknown, thisArg?: any): void; - - /** - * Adds the chat session item to the collection. If an item with the same resource URI already - * exists, it'll be replaced. - * @param item Item to add. - */ - add(item: ChatSessionItem): void; - - /** - * Removes a single chat session item from the collection. - * @param resource Item resource to delete. - */ - delete(resource: Uri): void; - - /** - * Efficiently gets a chat session item by resource, if it exists, in the collection. - * @param resource Item resource to get. - * @returns The found item or undefined if it does not exist. - */ - get(resource: Uri): ChatSessionItem | undefined; - } - export interface ChatSessionItem { /** * The resource associated with the chat session. @@ -190,42 +91,15 @@ declare module 'vscode' { tooltip?: string | MarkdownString; /** - * Whether the chat session has been archived. - */ - archived?: boolean; - - /** - * Timing information for the chat session + * The times at which session started and ended */ timing?: { - /** - * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - */ - created: number; - - /** - * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * - * Should be undefined if no requests have been made yet. - */ - lastRequestStarted?: number; - - /** - * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * - * Should be undefined if the most recent request is still in progress or if no requests have been made yet. - */ - lastRequestEnded?: number; - /** * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * @deprecated Use `created` and `lastRequestStarted` instead. */ - startTime?: number; - + startTime: number; /** * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * @deprecated Use `lastRequestEnded` instead. */ endTime?: number; }; @@ -394,6 +268,18 @@ declare module 'vscode' { } export namespace chat { + /** + * Registers a new {@link ChatSessionItemProvider chat session item provider}. + * + * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. + * + * @param chatSessionType The type of chat session the provider is for. + * @param provider The provider to register. + * + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; + /** * Registers a new {@link ChatSessionContentProvider chat session content provider}. * From c0cb2b9412404eeff036865dfc70fa7aa1b50064 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 14 Jan 2026 11:28:18 +0100 Subject: [PATCH 37/67] add debug statements to ThemeMainService (#287735) --- .../electron-main/themeMainServiceImpl.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts index 8a31973e9d1..57f6c794e17 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 @@ -82,9 +82,23 @@ export class ThemeMainService extends Disposable implements IThemeMainService { })); } 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: string) => `${setting}=${this.configurationService.getValue(setting)}`; + this.logService.debug(`[theme main service] ${logSetting(ThemeSettings.DETECT_COLOR_SCHEME)}, ${logSetting(ThemeSettings.DETECT_HC)}, ${logSetting(ThemeSettings.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 { From 650bc4f9ae9f15677f1a1f271fe872ab39e45da0 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:51:47 +0100 Subject: [PATCH 38/67] Git - expose repository kind through the git extension API (#287737) --- extensions/git/src/api/api1.ts | 4 +++- extensions/git/src/api/git.d.ts | 2 ++ extensions/git/src/repository.ts | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) 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; } From 5f566854312d26e238a13b2357e1fcd96544cda9 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:13:32 +0100 Subject: [PATCH 39/67] Chat - icon button rendering in the working set title bar (#287743) --- src/vs/workbench/contrib/chat/browser/widget/media/chat.css | 4 ---- 1 file changed, 4 deletions(-) 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 841d3692235..eb83d36d846 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1031,9 +1031,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 +1053,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); } From ff6cd330c8bfa25852d501d0c01081b1c437a42a Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Wed, 14 Jan 2026 11:54:23 +0100 Subject: [PATCH 40/67] Add telemetry on the number of additional certificates --- src/vs/workbench/api/node/proxyResolver.ts | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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'; From f5d4eb22604808113fa4520c37ea8ab8ac9de94b Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 14 Jan 2026 12:18:40 +0100 Subject: [PATCH 41/67] Sanity tests pipeline and bug fixes (#287721) Contributes towards #279402 Make test/sanity NPM-install independent for faster initialization in the pipeline. Fixed pipeline and tests ro fully pass on Windows x64 and MacOS x64. Updated suite/test names to report nicely in ADO. Ensure temp dir name is expanded on Windows to avoid ~ expansion from CLI. Removed custom log file now that XML report is supported. Added option to turn headless browsing on/off. --- build/azure-pipelines/common/sanity-tests.yml | 51 +-- .../azure-pipelines/product-sanity-tests.yml | 50 ++- test/sanity/package-lock.json | 165 ++++------ test/sanity/package.json | 9 +- test/sanity/src/cli.test.ts | 225 ++++++------- test/sanity/src/context.ts | 51 +-- test/sanity/src/desktop.test.ts | 299 ++++++++++-------- test/sanity/src/index.ts | 8 + test/sanity/src/main.ts | 46 +-- test/sanity/src/server.test.ts | 239 +++++++------- test/sanity/src/serverWeb.test.ts | 253 +++++++-------- 11 files changed, 723 insertions(+), 673 deletions(-) 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/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(); + } } From 92e1dfc80f574829b2251f7aa5bd598e9c91bb9d Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Wed, 14 Jan 2026 12:38:21 +0100 Subject: [PATCH 42/67] updates learnings (#287749) --- .github/copilot-instructions.md | 3 +++ .github/instructions/learnings.instructions.md | 10 +++------- 2 files changed, 6 insertions(+), 7 deletions(-) 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. From 973b8abab62d7d0ac9b0305665ac915910006046 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 14 Jan 2026 04:35:11 -0800 Subject: [PATCH 43/67] Add several new auto approved commands Fixes #285434 --- .../terminalChatAgentToolsConfiguration.ts | 13 +++ .../browser/commandLineAutoApprover.test.ts | 80 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index e9e1adddbef..d7f5cec0a46 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -176,6 +176,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { ok(!await isAutoApproved('cat file.txt'), 'Default rule should be ignored'); }); }); + + suite('od, xxd, docker defaults', () => { + test('od should be auto-approved', async () => { + ok(await isAutoApproved('od somefile')); + ok(await isAutoApproved('od -A x somefile')); + }); + + test('xxd should be auto-approved for simple usage', async () => { + ok(await isAutoApproved('xxd somefile')); + ok(await isAutoApproved('xxd -l 100 somefile')); + }); + + test('xxd should NOT be auto-approved with -r (revert/patch mode)', async () => { + ok(!await isAutoApproved('xxd -r somefile')); + ok(!await isAutoApproved('xxd -r -p somefile')); + }); + + test('xxd should NOT be auto-approved with outfile argument', async () => { + ok(!await isAutoApproved('xxd infile outfile')); + ok(!await isAutoApproved('xxd -l 100 infile outfile')); + }); + + test('docker readonly sub-commands should be auto-approved', async () => { + ok(await isAutoApproved('docker ps')); + ok(await isAutoApproved('docker ps -a')); + ok(await isAutoApproved('docker images')); + ok(await isAutoApproved('docker info')); + ok(await isAutoApproved('docker version')); + ok(await isAutoApproved('docker inspect mycontainer')); + ok(await isAutoApproved('docker logs mycontainer')); + ok(await isAutoApproved('docker top mycontainer')); + ok(await isAutoApproved('docker stats')); + ok(await isAutoApproved('docker port mycontainer')); + ok(await isAutoApproved('docker diff mycontainer')); + ok(await isAutoApproved('docker search nginx')); + ok(await isAutoApproved('docker events')); + }); + + test('docker management command readonly sub-commands should be auto-approved', async () => { + ok(await isAutoApproved('docker container ls')); + ok(await isAutoApproved('docker container ps')); + ok(await isAutoApproved('docker container inspect mycontainer')); + ok(await isAutoApproved('docker image ls')); + ok(await isAutoApproved('docker image history myimage')); + ok(await isAutoApproved('docker image inspect myimage')); + ok(await isAutoApproved('docker network ls')); + ok(await isAutoApproved('docker network inspect mynetwork')); + ok(await isAutoApproved('docker volume ls')); + ok(await isAutoApproved('docker volume inspect myvolume')); + ok(await isAutoApproved('docker context ls')); + ok(await isAutoApproved('docker context inspect mycontext')); + ok(await isAutoApproved('docker context show')); + ok(await isAutoApproved('docker system df')); + ok(await isAutoApproved('docker system info')); + }); + + test('docker compose readonly sub-commands should be auto-approved', async () => { + ok(await isAutoApproved('docker compose ps')); + ok(await isAutoApproved('docker compose ls')); + ok(await isAutoApproved('docker compose top')); + ok(await isAutoApproved('docker compose logs')); + ok(await isAutoApproved('docker compose images')); + ok(await isAutoApproved('docker compose config')); + ok(await isAutoApproved('docker compose version')); + ok(await isAutoApproved('docker compose port')); + ok(await isAutoApproved('docker compose events')); + }); + + test('docker write/execute sub-commands should NOT be auto-approved', async () => { + ok(!await isAutoApproved('docker run nginx')); + ok(!await isAutoApproved('docker exec mycontainer bash')); + ok(!await isAutoApproved('docker rm mycontainer')); + ok(!await isAutoApproved('docker rmi myimage')); + ok(!await isAutoApproved('docker build .')); + ok(!await isAutoApproved('docker push myimage')); + ok(!await isAutoApproved('docker pull nginx')); + ok(!await isAutoApproved('docker compose up')); + ok(!await isAutoApproved('docker compose down')); + }); + }); }); From 105a5e57dbca3f28e2d6950205dfe48ef50dbdf7 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Wed, 14 Jan 2026 14:20:14 +0100 Subject: [PATCH 44/67] Implements inline completion provider change hint (#287748) * Implements inline completion provider change hint * Fixes tests * Allows command to set data --- src/vs/editor/common/languages.ts | 25 +++++- .../browser/controller/commands.ts | 4 +- .../browser/model/inlineCompletionsModel.ts | 17 +++-- .../test/browser/inlineCompletions.test.ts | 76 +++++++++++++++++++ .../inlineCompletions/test/browser/utils.ts | 17 ++++- .../editor/test/browser/editorTestServices.ts | 3 +- src/vs/monaco.d.ts | 24 +++++- .../api/browser/mainThreadLanguageFeatures.ts | 14 ++-- .../workbench/api/common/extHost.protocol.ts | 6 +- .../api/common/extHostLanguageFeatures.ts | 2 +- ...e.proposed.inlineCompletionsAdditions.d.ts | 25 +++++- 11 files changed, 189 insertions(+), 24 deletions(-) diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 95af648f724..25724438958 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -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/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/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/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/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 5c4dd634a4b..000764668ba 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -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; 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/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 { From 8269aa77a06039de9d0329ef7bd95d8f983952d9 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 14 Jan 2026 05:22:01 -0800 Subject: [PATCH 45/67] Make xxd safer --- .../terminalChatAgentToolsConfiguration.ts | 8 +-- .../browser/commandLineAutoApprover.test.ts | 57 +++++++++++++++++-- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index d7f5cec0a46..34ba1c2f912 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -340,11 +340,9 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { }); suite('od, xxd, docker defaults', () => { + function setAutoApproveWithDefaults(userConfig: { [key: string]: boolean }, defaultConfig: { [key: string]: boolean }) { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AutoApprove, userConfig); + const originalInspect = configurationService.inspect; + const originalGetValue = configurationService.getValue; + configurationService.inspect = (key: string): any => { + if (key === TerminalChatAgentToolsSettingId.AutoApprove) { + return { + default: { value: defaultConfig }, + user: { value: userConfig }, + workspace: undefined, + workspaceFolder: undefined, + application: undefined, + policy: undefined, + memory: undefined, + value: { ...defaultConfig, ...userConfig } + }; + } + return originalInspect.call(configurationService, key); + }; + configurationService.getValue = (key: string): any => { + if (key === TerminalChatAgentToolsSettingId.AutoApprove) { + return { ...defaultConfig, ...userConfig }; + } + return originalGetValue.call(configurationService, key); + }; + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: () => true, + affectedKeys: new Set([TerminalChatAgentToolsSettingId.AutoApprove]), + source: ConfigurationTarget.USER, + change: null!, + }); + } + + const defaultRules: { [key: string]: boolean } = { + od: true, + '/^xxd\\b(\\s+-\\S+)*\\s+[^-\\s]\\S*$/': true, + '/^docker\\s+(ps|images|info|version|inspect|logs|top|stats|port|diff|search|events)\\b/': true, + '/^docker\\s+(container|image|network|volume|context|system)\\s+(ls|ps|inspect|history|show|df|info)\\b/': true, + '/^docker\\s+compose\\s+(ps|ls|top|logs|images|config|version|port|events)\\b/': true, + }; + + setup(() => { + setAutoApproveWithDefaults({}, defaultRules); + }); + test('od should be auto-approved', async () => { ok(await isAutoApproved('od somefile')); ok(await isAutoApproved('od -A x somefile')); @@ -1252,17 +1297,17 @@ suite('CommandLineAutoApprover', () => { test('xxd should be auto-approved for simple usage', async () => { ok(await isAutoApproved('xxd somefile')); - ok(await isAutoApproved('xxd -l 100 somefile')); + ok(await isAutoApproved('xxd -l100 somefile')); }); - test('xxd should NOT be auto-approved with -r (revert/patch mode)', async () => { - ok(!await isAutoApproved('xxd -r somefile')); - ok(!await isAutoApproved('xxd -r -p somefile')); + test('xxd should be auto-approved with -r (outputs to stdout)', async () => { + ok(await isAutoApproved('xxd -r somefile')); + ok(await isAutoApproved('xxd -rp somefile')); }); - test('xxd should NOT be auto-approved with outfile argument', async () => { + test('xxd should NOT be auto-approved with outfile or ambiguous args', async () => { ok(!await isAutoApproved('xxd infile outfile')); - ok(!await isAutoApproved('xxd -l 100 infile outfile')); + ok(!await isAutoApproved('xxd -l 100 somefile')); // ambiguous - could be flag+value or two positional }); test('docker readonly sub-commands should be auto-approved', async () => { From c8f9f772e6c2d874277b9085f4e8c68acaf44288 Mon Sep 17 00:00:00 2001 From: Aiday Marlen Kyzy Date: Wed, 14 Jan 2026 14:36:39 +0100 Subject: [PATCH 46/67] Retokenize on custom font token setting change (#287577) tokenization --- .../textMate/browser/textMateTokenizationFeatureImpl.ts | 2 +- src/vs/workbench/services/themes/common/colorThemeData.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 75c05cdec53..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; From ac2e7be87d49dbf5268d9f03ec0c9898c0c8f0d7 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:47:21 +0100 Subject: [PATCH 47/67] Chat - revert the change to expand the working set (#287768) --- .../chat/browser/widgetHosts/viewPane/chatViewPane.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 cd53193efa5..3652a5fd238 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -751,12 +751,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { clearWidget.dispose(); await queue; - const chatModel = await this.showModel(newModelRef); - if (chatModel) { - this._widget.input.setWorkingSetCollapsed(false); - } - - return chatModel; + return this.showModel(newModelRef); }); } From d756bddc8d77c4bc2da3b70d3bec62de71c0caa4 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:06:02 -0800 Subject: [PATCH 48/67] Use terminal log service --- .../chatAgentTools/browser/tools/monitoring/outputMonitor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 068c257762c..73d50e77464 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; @@ -94,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(); From aa5e7969cdad95f90400756373a7215d74bd3d81 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:09:27 -0800 Subject: [PATCH 49/67] Add polling for idle transition --- .../chatAgentTools/browser/tools/monitoring/outputMonitor.ts | 1 + 1 file changed, 1 insertion(+) 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 73d50e77464..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 @@ -128,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(); From e568df9d1ab82beb6db8c046d4c119525e16e63b Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:14:26 -0800 Subject: [PATCH 50/67] Fix tests --- .../chatAgentTools/test/browser/outputMonitor.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 75a535b8295..61a0bb1a345 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -14,7 +14,8 @@ import { ILanguageModelsService } from '../../../../chat/common/languageModels.j import { IChatService } from '../../../../chat/common/chatService/chatService.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ChatModel } from '../../../../chat/common/model/chatModel.js'; -import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { IToolInvocationContext } from '../../../../chat/common/tools/languageModelToolsService.js'; import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; @@ -72,7 +73,7 @@ suite('OutputMonitor', () => { } as any) } ); - instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ITerminalLogService, new NullLogService()); cts = new CancellationTokenSource(); }); From a3dcfddb6ea1aee3033761d2528fe39cb29875db Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Wed, 14 Jan 2026 15:19:14 +0100 Subject: [PATCH 51/67] fix: memory leak in createStyleSheet2 (#287754) * fix: memory leak in createStyleSheet2 * polish --------- Co-authored-by: Benjamin Pasero --- src/vs/base/browser/domStylesheets.ts | 32 +++++++++------------------ 1 file changed, 11 insertions(+), 21 deletions(-) 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)); })); From 6655b3070e35442f30b47f8927a41a4c17eed153 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:24:11 -0800 Subject: [PATCH 52/67] Polish tests --- .../browser/commandLineAutoApprover.test.ts | 125 ------------------ .../runInTerminalTool.test.ts | 66 ++++++++- 2 files changed, 65 insertions(+), 126 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/commandLineAutoApprover.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/commandLineAutoApprover.test.ts index 3abcf74fe80..c36e07aa673 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/commandLineAutoApprover.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/commandLineAutoApprover.test.ts @@ -1243,129 +1243,4 @@ suite('CommandLineAutoApprover', () => { ok(!await isAutoApproved('cat file.txt'), 'Default rule should be ignored'); }); }); - - suite('od, xxd, docker defaults', () => { - function setAutoApproveWithDefaults(userConfig: { [key: string]: boolean }, defaultConfig: { [key: string]: boolean }) { - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AutoApprove, userConfig); - const originalInspect = configurationService.inspect; - const originalGetValue = configurationService.getValue; - configurationService.inspect = (key: string): any => { - if (key === TerminalChatAgentToolsSettingId.AutoApprove) { - return { - default: { value: defaultConfig }, - user: { value: userConfig }, - workspace: undefined, - workspaceFolder: undefined, - application: undefined, - policy: undefined, - memory: undefined, - value: { ...defaultConfig, ...userConfig } - }; - } - return originalInspect.call(configurationService, key); - }; - configurationService.getValue = (key: string): any => { - if (key === TerminalChatAgentToolsSettingId.AutoApprove) { - return { ...defaultConfig, ...userConfig }; - } - return originalGetValue.call(configurationService, key); - }; - configurationService.onDidChangeConfigurationEmitter.fire({ - affectsConfiguration: () => true, - affectedKeys: new Set([TerminalChatAgentToolsSettingId.AutoApprove]), - source: ConfigurationTarget.USER, - change: null!, - }); - } - - const defaultRules: { [key: string]: boolean } = { - od: true, - '/^xxd\\b(\\s+-\\S+)*\\s+[^-\\s]\\S*$/': true, - '/^docker\\s+(ps|images|info|version|inspect|logs|top|stats|port|diff|search|events)\\b/': true, - '/^docker\\s+(container|image|network|volume|context|system)\\s+(ls|ps|inspect|history|show|df|info)\\b/': true, - '/^docker\\s+compose\\s+(ps|ls|top|logs|images|config|version|port|events)\\b/': true, - }; - - setup(() => { - setAutoApproveWithDefaults({}, defaultRules); - }); - - test('od should be auto-approved', async () => { - ok(await isAutoApproved('od somefile')); - ok(await isAutoApproved('od -A x somefile')); - }); - - test('xxd should be auto-approved for simple usage', async () => { - ok(await isAutoApproved('xxd somefile')); - ok(await isAutoApproved('xxd -l100 somefile')); - }); - - test('xxd should be auto-approved with -r (outputs to stdout)', async () => { - ok(await isAutoApproved('xxd -r somefile')); - ok(await isAutoApproved('xxd -rp somefile')); - }); - - test('xxd should NOT be auto-approved with outfile or ambiguous args', async () => { - ok(!await isAutoApproved('xxd infile outfile')); - ok(!await isAutoApproved('xxd -l 100 somefile')); // ambiguous - could be flag+value or two positional - }); - - test('docker readonly sub-commands should be auto-approved', async () => { - ok(await isAutoApproved('docker ps')); - ok(await isAutoApproved('docker ps -a')); - ok(await isAutoApproved('docker images')); - ok(await isAutoApproved('docker info')); - ok(await isAutoApproved('docker version')); - ok(await isAutoApproved('docker inspect mycontainer')); - ok(await isAutoApproved('docker logs mycontainer')); - ok(await isAutoApproved('docker top mycontainer')); - ok(await isAutoApproved('docker stats')); - ok(await isAutoApproved('docker port mycontainer')); - ok(await isAutoApproved('docker diff mycontainer')); - ok(await isAutoApproved('docker search nginx')); - ok(await isAutoApproved('docker events')); - }); - - test('docker management command readonly sub-commands should be auto-approved', async () => { - ok(await isAutoApproved('docker container ls')); - ok(await isAutoApproved('docker container ps')); - ok(await isAutoApproved('docker container inspect mycontainer')); - ok(await isAutoApproved('docker image ls')); - ok(await isAutoApproved('docker image history myimage')); - ok(await isAutoApproved('docker image inspect myimage')); - ok(await isAutoApproved('docker network ls')); - ok(await isAutoApproved('docker network inspect mynetwork')); - ok(await isAutoApproved('docker volume ls')); - ok(await isAutoApproved('docker volume inspect myvolume')); - ok(await isAutoApproved('docker context ls')); - ok(await isAutoApproved('docker context inspect mycontext')); - ok(await isAutoApproved('docker context show')); - ok(await isAutoApproved('docker system df')); - ok(await isAutoApproved('docker system info')); - }); - - test('docker compose readonly sub-commands should be auto-approved', async () => { - ok(await isAutoApproved('docker compose ps')); - ok(await isAutoApproved('docker compose ls')); - ok(await isAutoApproved('docker compose top')); - ok(await isAutoApproved('docker compose logs')); - ok(await isAutoApproved('docker compose images')); - ok(await isAutoApproved('docker compose config')); - ok(await isAutoApproved('docker compose version')); - ok(await isAutoApproved('docker compose port')); - ok(await isAutoApproved('docker compose events')); - }); - - test('docker write/execute sub-commands should NOT be auto-approved', async () => { - ok(!await isAutoApproved('docker run nginx')); - ok(!await isAutoApproved('docker exec mycontainer bash')); - ok(!await isAutoApproved('docker rm mycontainer')); - ok(!await isAutoApproved('docker rmi myimage')); - ok(!await isAutoApproved('docker build .')); - ok(!await isAutoApproved('docker push myimage')); - ok(!await isAutoApproved('docker pull nginx')); - ok(!await isAutoApproved('docker compose up')); - ok(!await isAutoApproved('docker compose down')); - }); - }); }); 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', () => { From 4d2b6774accc6f2054698a9cf73dbf0fce70f316 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 14 Jan 2026 15:26:28 +0100 Subject: [PATCH 53/67] fix reading theme setting in ThemeMainService (#287770) fix reading theme defaults in ThemeMainService --- .../electron-main/themeMainServiceImpl.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts index 57f6c794e17..1ebcf272816 100644 --- a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts +++ b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts @@ -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,8 +84,9 @@ 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(); } })); } @@ -90,10 +99,11 @@ export class ThemeMainService extends Disposable implements IThemeMainService { this._onDidChangeColorScheme.fire(this.getColorScheme()); })); } + private logThemeSettings(): void { if (this.logService.getLevel() >= LogLevel.Debug) { - const logSetting = (setting: string) => `${setting}=${this.configurationService.getValue(setting)}`; - this.logService.debug(`[theme main service] ${logSetting(ThemeSettings.DETECT_COLOR_SCHEME)}, ${logSetting(ThemeSettings.DETECT_HC)}, ${logSetting(ThemeSettings.SYSTEM_COLOR_THEME)}`); + 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')} `); @@ -102,10 +112,10 @@ export class ThemeMainService extends Disposable implements IThemeMainService { } 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; @@ -159,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; } @@ -348,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; From ce9855da009adca6702a4c089de2c7006e12598f Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:53:56 +0100 Subject: [PATCH 54/67] SCM - hidden repositories should not be exposed (#287786) --- src/vs/workbench/contrib/scm/common/scmService.ts | 4 ++++ 1 file changed, 4 insertions(+) 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) { From 472a1bb13043fb1b2c5b13ccd0ce76787b4ede6a Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 14 Jan 2026 17:13:31 +0100 Subject: [PATCH 55/67] fix how `_selectVendorDefaultLanguageModel` is set (#287804) --- .../browser/inlineChatController.ts | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) 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(); } } From ef0bb3bbc9e1c844e66af90eb0de89f1d9539fc5 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Wed, 14 Jan 2026 08:42:19 -0800 Subject: [PATCH 56/67] chore: bump node-pty (#287627) --- package-lock.json | 8 ++++---- package.json | 2 +- remote/package-lock.json | 8 ++++---- remote/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc9b6b0f7a6..b14a6d39f8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "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", @@ -12950,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": { diff --git a/package.json b/package.json index e5ad7191f87..56fe0197794 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "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", diff --git a/remote/package-lock.json b/remote/package-lock.json index 30c5541fd60..fd2b8a14bee 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -38,7 +38,7 @@ "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", @@ -1052,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": { diff --git a/remote/package.json b/remote/package.json index d2eab8bf24a..f506788e938 100644 --- a/remote/package.json +++ b/remote/package.json @@ -33,7 +33,7 @@ "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", From 5fca6aea19862f0bbe0f6c42017e05fd634a419d Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 14 Jan 2026 10:58:09 -0600 Subject: [PATCH 57/67] `outputLocation:none` -> `outputLocation:terminal` (#287596) fixes #275584 --- .../terminal.chatAgentTools.contribution.ts | 16 ++++++++++++++++ .../terminalChatAgentToolsConfiguration.ts | 10 +++++----- 2 files changed, 21 insertions(+), 5 deletions(-) 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/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 34ba1c2f912..5ed1a54ed12 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -491,14 +491,14 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary Date: Wed, 14 Jan 2026 09:00:10 -0800 Subject: [PATCH 58/67] Polish buttons and inputs (#280457) * Update default styles and add small variant * Strip other instances where default styles should apply * Strip overrides from review/comment UI * Update some buttons in chat * Polish floating keep/undo action bars * One off the add model button for now * Update secondary button styles + common button border for dark modern * Use small variant for "keep | undo" chat button bar * Strip overrides from chat confirmations buttons * Missed one borde radius override * Fix small variant * Align keep/undo editor widgets with small button variant and icon button sizes * Use same border radius for inputs * Update src/vs/workbench/contrib/chat/browser/media/chatEditorController.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/base/browser/ui/dialog/dialog.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensionEditor.css to adjust font weight and border radius for action items * Update quickInput.css and titlebarpart.css to adjust border radius and padding for improved UI consistency --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: mrleemurray Co-authored-by: mrleemurray Co-authored-by: Benjamin Pasero Co-authored-by: Lee Murray --- .../theme-defaults/themes/dark_modern.json | 6 +- src/vs/base/browser/ui/button/button.css | 20 ++++--- src/vs/base/browser/ui/button/button.ts | 2 + src/vs/base/browser/ui/dialog/dialog.css | 10 +--- src/vs/base/browser/ui/inputbox/inputBox.css | 2 +- .../base/browser/ui/selectBox/selectBox.css | 4 +- .../browser/ui/selectBox/selectBoxCustom.css | 2 +- .../contrib/rename/browser/renameWidget.css | 2 +- src/vs/platform/actions/browser/buttonbar.ts | 6 +- .../quickinput/browser/media/quickInput.css | 14 +---- .../parts/editor/media/editorplaceholder.css | 2 - .../notifications/media/notificationsList.css | 2 - .../parts/titlebar/media/titlebarpart.css | 5 +- .../bulkEdit/browser/preview/bulkEdit.css | 1 - .../media/simpleBrowserOverlay.css | 1 - .../chatEditingCodeEditorIntegration.ts | 7 +++ .../chatEditing/chatEditingEditorOverlay.ts | 1 + .../media/chatEditingEditorOverlay.css | 57 ++++++++++++------- .../media/chatEditorController.css | 44 ++++++++++---- .../chatManagement/media/chatModelsWidget.css | 1 + .../chatConfirmationWidget.ts | 4 +- .../media/chatConfirmationWidget.css | 15 +---- .../browser/widget/input/chatInputPart.ts | 1 + .../chat/browser/widget/media/chat.css | 5 -- .../browser/widget/media/chatViewWelcome.css | 1 - .../suggestEnabledInput.css | 2 +- .../comments/browser/commentFormActions.ts | 3 +- .../contrib/comments/browser/media/review.css | 5 -- .../browser/media/extensionEditor.css | 7 +-- .../issue/browser/media/issueReporter.css | 6 -- .../browser/media/settingsWidgets.css | 1 - .../contrib/scm/browser/media/scm.css | 8 ++- .../browser/media/userDataProfilesEditor.css | 1 - .../browser/media/gettingStarted.css | 4 -- .../browser/media/workspaceTrustEditor.css | 3 +- 35 files changed, 126 insertions(+), 129 deletions(-) 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/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/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/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/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/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/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/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/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/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/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/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/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/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index ab2fd90106a..a98c158ad8c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2375,6 +2375,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/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index eb83d36d846..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 { @@ -2435,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, @@ -2757,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/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/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/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 { From a880611b4821a492147f5ce5258d11cd5a6a17e6 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 14 Jan 2026 18:25:51 +0100 Subject: [PATCH 59/67] Agent sessions: allow to resize the sessions sidebar like terminal tabs (fix #281258) (#287817) * . * sash it * . --- .../widgetHosts/viewPane/chatViewPane.ts | 89 +++++++++++++++++-- 1 file changed, 83 insertions(+), 6 deletions(-) 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 3652a5fd238..8db9d769606 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'; @@ -60,7 +61,9 @@ import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; interface IChatViewPaneState extends Partial { sessionId?: string; + sessionsViewerLimited?: boolean; + sessionsSidebarWidth?: number; } type ChatViewPaneOpenedClassification = { @@ -119,6 +122,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); @@ -287,8 +291,10 @@ 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_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 +304,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; @@ -863,6 +872,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 }; } @@ -874,9 +894,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; @@ -904,10 +927,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 { From 228337d31980f73e2612055f8aabcfabb693aedc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:28:08 -0800 Subject: [PATCH 60/67] Truncate long page titles in integrated browser (#287702) * Initial plan * Add title truncation to integrated browser using existing truncate utility Co-authored-by: jruales <1588988+jruales@users.noreply.github.com> * Add unit tests for browser title truncation Co-authored-by: jruales <1588988+jruales@users.noreply.github.com> * Fix test assertion for truncated title verification Co-authored-by: jruales <1588988+jruales@users.noreply.github.com> * Remove test file as requested Co-authored-by: jruales <1588988+jruales@users.noreply.github.com> * Change MAX_TITLE_LENGTH from 100 to 30 characters Co-authored-by: jruales <1588988+jruales@users.noreply.github.com> * Don't truncate Title --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jruales <1588988+jruales@users.noreply.github.com> --- .../browserView/electron-browser/browserEditorInput.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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) { From 0e5d5949d13f404584700736cf6b5b7d18ced30a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 14 Jan 2026 18:37:02 +0100 Subject: [PATCH 61/67] complete the fix for #287509 (#287824) --- .../browser/chatManagement/chatModelsViewModel.ts | 12 ++++++++---- .../workbench/contrib/chat/common/languageModels.ts | 10 +++++----- .../chatManagement/chatModelsViewModel.test.ts | 4 ++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts index a377d9a5b17..e65dccbf74e 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.modelIndetifiers) { + 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/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 3c92e9646f1..e2e645d0c09 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 modelIndetifiers: 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({ modelIndetifiers: models.map(m => m.identifier) }); } } catch (error) { languageModelsGroups.push({ - models: [], + modelIndetifiers: [], 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, modelIndetifiers: models.map(m => m.identifier) }); } } catch (error) { languageModelsGroups.push({ group, - models: [], + modelIndetifiers: [], status: { message: getErrorMessage(error), severity: Severity.Error 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..9cec1023f7f 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: [] + modelIndetifiers: [] }); } - groups[0].models.push({ identifier, metadata }); + groups[0].modelIndetifiers.push(identifier); this.modelGroups.set(vendorId, groups); } From 8fae6fe4f3ac6062facec2e856201e8a462a133c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 14 Jan 2026 19:05:41 +0100 Subject: [PATCH 62/67] fix spelling (#287828) --- .../chat/browser/chatManagement/chatModelsViewModel.ts | 2 +- src/vs/workbench/contrib/chat/common/languageModels.ts | 10 +++++----- .../browser/chatManagement/chatModelsViewModel.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts index e65dccbf74e..e2205448dbe 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts @@ -497,7 +497,7 @@ export class ChatModelsViewModel extends Disposable { } }); } - for (const identifier of group.modelIndetifiers) { + for (const identifier of group.modelIdentifiers) { const metadata = this.languageModelsService.lookupLanguageModel(identifier); if (!metadata) { continue; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index e2e645d0c09..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 modelIndetifiers: string[]; + 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({ modelIndetifiers: models.map(m => m.identifier) }); + languageModelsGroups.push({ modelIdentifiers: models.map(m => m.identifier) }); } } catch (error) { languageModelsGroups.push({ - modelIndetifiers: [], + 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, modelIndetifiers: models.map(m => m.identifier) }); + languageModelsGroups.push({ group, modelIdentifiers: models.map(m => m.identifier) }); } } catch (error) { languageModelsGroups.push({ group, - modelIndetifiers: [], + modelIdentifiers: [], status: { message: getErrorMessage(error), severity: Severity.Error 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 9cec1023f7f..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' }, - modelIndetifiers: [] + modelIdentifiers: [] }); } - groups[0].modelIndetifiers.push(identifier); + groups[0].modelIdentifiers.push(identifier); this.modelGroups.set(vendorId, groups); } From 6b31b753c7f4604c18c09cc5aa1d7ff856579e72 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Wed, 14 Jan 2026 13:27:49 -0500 Subject: [PATCH 63/67] Support streaming of partial tool data (#278640) * Start work on handling tool stream * Start working on rendering tool progress * Handle complete better * Inovcation message * Revert "Inovcation message" This reverts commit f502d22d9a1da901f053f87a75a0c5a720e57466. * Reapply "Inovcation message" This reverts commit 855668653f65e1729bfa4a716e0f8b34d08b8522. * Revert "Reapply "Inovcation message"" This reverts commit 4c4db3ac36a2f799e306ce9bda3b78b477a531ac. * Handle updating progress * Better messages * Have progress re-render if content changes * Fix import * Move prepare to view layer * Clean up diff * Pass tool call id through * Pin it * Modify the progress tool flow to use the same part + observables * Some more debug logs * Address feedback * Plumb tool call id through to invoke * Address connor's typing feedback * Fix import * Fix session operation log * Update src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove debug logging * fix wrong enums --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/browser/mainThreadChatAgents2.ts | 19 ++ .../browser/mainThreadLanguageModelTools.ts | 1 + .../workbench/api/common/extHost.api.impl.ts | 1 - .../workbench/api/common/extHost.protocol.ts | 24 ++- .../api/common/extHostChatAgents2.ts | 35 +++- .../api/common/extHostLanguageModelTools.ts | 37 +++- .../api/common/extHostTypeConverters.ts | 17 +- src/vs/workbench/api/common/extHostTypes.ts | 11 -- .../chatAccessibilityProvider.ts | 4 +- .../chatResponseAccessibleView.ts | 6 +- .../chatSessions/chatSessions.contribution.ts | 4 +- .../tools/languageModelToolsService.ts | 116 +++++++++++- .../chatProgressContentPart.ts | 8 + .../chatExtensionsInstallToolSubPart.ts | 7 +- .../chatTerminalToolConfirmationSubPart.ts | 5 +- .../chatToolConfirmationSubPart.ts | 38 +++- .../chatToolInvocationPart.ts | 7 + .../chatToolPostExecuteConfirmationPart.ts | 7 +- .../chatToolProgressPart.ts | 4 +- .../chatToolStreamingSubPart.ts | 93 ++++++++++ .../chat/browser/widget/chatListRenderer.ts | 13 +- .../chat/common/chatService/chatService.ts | 74 ++++++-- .../contrib/chat/common/model/chatModel.ts | 15 +- .../chatProgressTypes/chatToolInvocation.ts | 171 ++++++++++++++++-- .../common/model/chatSessionOperationLog.ts | 1 - .../tools/builtinTools/runSubagentTool.ts | 8 +- .../common/tools/languageModelToolsService.ts | 41 ++++- .../chat/test/common/model/chatModel.test.ts | 5 +- .../tools/mockLanguageModelToolsService.ts | 14 +- ...ode.proposed.chatParticipantAdditions.d.ts | 58 +++++- 30 files changed, 724 insertions(+), 120 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts 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/mainThreadLanguageModelTools.ts b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts index 00336ecfc71..a0686773ff4 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts @@ -93,6 +93,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 a77a0079ee0..ee8bf713db5 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1906,7 +1906,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 000764668ba..c6c821007c4 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'; @@ -1509,6 +1509,7 @@ export interface ExtHostLanguageModelToolsShape { $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; } @@ -1535,7 +1536,9 @@ export type IChatProgressDto = | IChatTaskDto | IChatNotebookEditDto | IChatExternalEditsDto - | IChatResponseClearToPreviousToolInvocationDto; + | IChatResponseClearToPreviousToolInvocationDto + | IChatBeginToolInvocationDto + | IChatUpdateToolInvocationDto; export interface ExtHostUrlsShape { $handleExternalUri(handle: number, uri: UriComponents): Promise; @@ -2320,6 +2323,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 diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 493723aa848..1f47b51ee41 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/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index ab4eb8822ef..f629148a389 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'; @@ -126,6 +126,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; @@ -191,6 +192,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 = { @@ -242,6 +246,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 592ca855aa6..d12a5b9c375 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/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/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 8b41b6f025e..a9b3f78bc37 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -916,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); @@ -932,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 { diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 9c7599f24b1..b5c42764ddf 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 { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { ChatConfiguration } from '../../common/constants.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(); } @@ -321,8 +325,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; @@ -367,15 +385,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) { @@ -553,6 +577,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) { @@ -744,6 +843,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/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/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/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/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index b4f75cc832f..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 diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 6115e54dbbe..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; 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 df3b644bb74..4fdb96b6dbd 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -71,7 +71,6 @@ const responsePartSchema = Adapt.v { 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 25f2d885de2..b2c10b4d433 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'; @@ -132,6 +132,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 */ @@ -286,6 +290,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; @@ -298,6 +314,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; @@ -354,6 +371,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; @@ -372,6 +397,20 @@ export interface ILanguageModelToolsService { readonly toolsObservable: IObservable; getTool(id: string): IToolData | undefined; getToolByName(name: string, includeDisabled?: boolean): 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/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/tools/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts index d51bb8aa884..2bc3f49c5d2 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts @@ -11,8 +11,9 @@ import { constObservable, IObservable } from '../../../../../../base/common/obse import { ThemeIcon } from '../../../../../../base/common/themables.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; @@ -90,6 +91,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 { @@ -104,7 +114,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/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index aa7001a3d2f..01dcc338f80 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 { From da543af76142ff71d515f73e57dd20e31dd4bc36 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 14 Jan 2026 12:55:42 -0600 Subject: [PATCH 64/67] enable `outputLocation: chat` in stable (#287836) part of https://github.com/microsoft/vscode-internalbacklog/issues/6162 --- .../common/terminalChatAgentToolsConfiguration.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 5ed1a54ed12..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'; @@ -498,7 +497,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary Date: Wed, 14 Jan 2026 20:13:30 +0100 Subject: [PATCH 65/67] chat - only show welcome until setup has ran and show more sessions (#287841) --- .../chat/browser/actions/chatActions.ts | 25 ------------------- .../contrib/chat/browser/chat.contribution.ts | 5 ---- .../widgetHosts/viewPane/chatViewPane.ts | 15 ++++++++--- .../contrib/chat/common/constants.ts | 1 - 4 files changed, 11 insertions(+), 35 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index df91b07dbd8..a34abcfa1ff 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1194,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/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 5647d285ea3..ef6ce77012c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -372,11 +372,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, 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 8db9d769606..e78c2ab4a19 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -58,6 +58,7 @@ 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; @@ -108,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); @@ -174,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'; @@ -212,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 { @@ -290,7 +297,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { //#region Sessions Control - private static readonly SESSIONS_LIMIT = 3; + 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; @@ -911,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); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index e9b27d99b4c..5c212aba616 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -28,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', From b41e8848682f001467dff9f2a4ca71516c82c7e1 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 14 Jan 2026 13:26:17 -0600 Subject: [PATCH 66/67] add accessibility help for thinking (#287640) --- .../accessibility/browser/accessibleView.ts | 1 + .../chatThinkingAccessibleView.ts | 72 +++++++++++++++++++ .../actions/chatAccessibilityActions.ts | 39 +++++++++- .../browser/actions/chatAccessibilityHelp.ts | 2 + .../contrib/chat/browser/chat.contribution.ts | 2 + .../chatThinkingContentPart.ts | 4 ++ 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/vs/workbench/contrib/chat/browser/accessibility/chatThinkingAccessibleView.ts 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/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/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index ef6ce77012c..529e2d3890d 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'; @@ -1084,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()); 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 { From ace789bc8daaf92a9549c09b1638e3ae2a76fb61 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:29:35 -0800 Subject: [PATCH 67/67] Clear promptInput's value onCommandFinished (#287139) * Clear promptInputModel value onDidCommandFinish * Add test * Remove stale state that doesnt exist anymore * Fire Empty _onDidChangeInput on promptInputModel _handlecommandFinish --- .../commandDetection/promptInputModel.ts | 9 ++++++++ .../commandDetectionCapability.ts | 2 +- .../commandDetection/promptInputModel.test.ts | 23 ++++++++++++++++++- .../rich/macos_zsh_omz_echo_3_times.ts | 12 ---------- 4 files changed, 32 insertions(+), 14 deletions(-) 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/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"