diff --git a/extensions/terminal-suggest/src/terminalSuggestMain.ts b/extensions/terminal-suggest/src/terminalSuggestMain.ts index 95654ffe4182e..599f4b93849ea 100644 --- a/extensions/terminal-suggest/src/terminalSuggestMain.ts +++ b/extensions/terminal-suggest/src/terminalSuggestMain.ts @@ -413,6 +413,13 @@ export async function resolveCwdFromCurrentCommandString(currentCommandString: s } const relativeFolder = lastSlashIndex === -1 ? '' : prefix.slice(0, lastSlashIndex); + // Don't pre-resolve paths with .. segments - let the completion service handle those + // to avoid double-navigation (e.g., typing ../ would resolve cwd to parent here, + // then completion service would navigate up again from the already-parent cwd) + if (relativeFolder.includes('..')) { + return undefined; + } + // Use vscode.Uri.joinPath for path resolution const resolvedUri = vscode.Uri.joinPath(currentCwd, relativeFolder); diff --git a/extensions/terminal-suggest/src/test/completions/cd.test.ts b/extensions/terminal-suggest/src/test/completions/cd.test.ts index a40d5ee210319..cee2f62e55a65 100644 --- a/extensions/terminal-suggest/src/test/completions/cd.test.ts +++ b/extensions/terminal-suggest/src/test/completions/cd.test.ts @@ -37,7 +37,8 @@ export const cdTestSuiteSpec: ISuiteSpec = { // Relative directories (changes cwd due to /) { input: 'cd child/|', expectedCompletions, expectedResourceRequests: { type: 'folders', cwd: testPaths.cwdChild } }, - { input: 'cd ../|', expectedCompletions, expectedResourceRequests: { type: 'folders', cwd: testPaths.cwdParent } }, - { input: 'cd ../sibling|', expectedCompletions, expectedResourceRequests: { type: 'folders', cwd: testPaths.cwdParent } }, + // Paths with .. are handled by the completion service to avoid double-navigation (no cwd resolution) + { input: 'cd ../|', expectedCompletions, expectedResourceRequests: { type: 'folders' } }, + { input: 'cd ../sibling|', expectedCompletions, expectedResourceRequests: { type: 'folders' } }, ] }; diff --git a/extensions/terminal-suggest/src/test/completions/upstream/ls.test.ts b/extensions/terminal-suggest/src/test/completions/upstream/ls.test.ts index e08b755e60a1b..1b06db30546a7 100644 --- a/extensions/terminal-suggest/src/test/completions/upstream/ls.test.ts +++ b/extensions/terminal-suggest/src/test/completions/upstream/ls.test.ts @@ -84,8 +84,9 @@ export const lsTestSuiteSpec: ISuiteSpec = { // Relative directories (changes cwd due to /) { input: 'ls child/|', expectedCompletions: allOptions, expectedResourceRequests: { type: 'both', cwd: testPaths.cwdChild } }, - { input: 'ls ../|', expectedCompletions: allOptions, expectedResourceRequests: { type: 'both', cwd: testPaths.cwdParent } }, - { input: 'ls ../sibling|', expectedCompletions: allOptions, expectedResourceRequests: { type: 'both', cwd: testPaths.cwdParent } }, + // Paths with .. are handled by the completion service to avoid double-navigation (no cwd resolution) + { input: 'ls ../|', expectedCompletions: allOptions, expectedResourceRequests: { type: 'both' } }, + { input: 'ls ../sibling|', expectedCompletions: allOptions, expectedResourceRequests: { type: 'both' } }, ] }; diff --git a/extensions/terminal-suggest/src/test/helpers.ts b/extensions/terminal-suggest/src/test/helpers.ts index a4101a49194e4..b5080535fcf6b 100644 --- a/extensions/terminal-suggest/src/test/helpers.ts +++ b/extensions/terminal-suggest/src/test/helpers.ts @@ -21,7 +21,7 @@ export interface ITestSpec { input: string; expectedResourceRequests?: { type: 'files' | 'folders' | 'both'; - cwd: Uri; + cwd?: Uri; }; expectedCompletions?: (string | ICompletionResource)[]; } diff --git a/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts b/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts index 793cc9a634bbb..57749d2df6869 100644 --- a/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts +++ b/extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts @@ -85,7 +85,7 @@ suite('Terminal Suggest', () => { let expectedString = testSpec.expectedCompletions ? `[${testSpec.expectedCompletions.map(e => `'${e}'`).join(', ')}]` : '[]'; if (testSpec.expectedResourceRequests) { expectedString += ` + ${testSpec.expectedResourceRequests.type}`; - if (testSpec.expectedResourceRequests.cwd.fsPath !== testPaths.cwd.fsPath) { + if (testSpec.expectedResourceRequests.cwd && testSpec.expectedResourceRequests.cwd.fsPath !== testPaths.cwd.fsPath) { expectedString += ` @ ${basename(testSpec.expectedResourceRequests.cwd.fsPath)}/`; } } diff --git a/package-lock.json b/package-lock.json index d7d561c1ee5a5..c15b9ca7f093d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3570,9 +3570,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": { diff --git a/package.json b/package.json index f33f6d0d96916..fc7bc3b48ad83 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.109.0", - "distro": "66d6007ba3b8eff60c3026cb216e699981aca7ec", + "distro": "276abacfc6a1d1a9d17ab0d7d7cb4775998082b2", "author": { "name": "Microsoft Corporation" }, diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index d52779616d04a..de0fce1d4fdd0 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -345,6 +345,89 @@ export namespace Event { }, delay, undefined, flushOnListenerRemove ?? true, undefined, disposable); } + /** + * Throttles an event, ensuring the event is fired at most once during the specified delay period. + * Unlike debounce, throttle will fire immediately on the leading edge and/or after the delay on the trailing edge. + * + * *NOTE* that this function returns an `Event` and it MUST be called with a `DisposableStore` whenever the returned + * event is accessible to "third parties", e.g the event is a public property. Otherwise a leaked listener on the + * returned event causes this utility to leak a listener on the original event. + * + * @param event The event source for the new event. + * @param merge An accumulator function that merges events if multiple occur during the throttle period. + * @param delay The number of milliseconds to throttle. + * @param leading Whether to fire on the leading edge (immediately on first event). + * @param trailing Whether to fire on the trailing edge (after delay with the last value). + * @param leakWarningThreshold See {@link EmitterOptions.leakWarningThreshold}. + * @param disposable A disposable store to register the throttle emitter to. + */ + export function throttle(event: Event, merge: (last: T | undefined, event: T) => T, delay?: number | typeof MicrotaskDelay, leading?: boolean, trailing?: boolean, leakWarningThreshold?: number, disposable?: DisposableStore): Event; + export function throttle(event: Event, merge: (last: O | undefined, event: I) => O, delay?: number | typeof MicrotaskDelay, leading?: boolean, trailing?: boolean, leakWarningThreshold?: number, disposable?: DisposableStore): Event; + export function throttle(event: Event, merge: (last: O | undefined, event: I) => O, delay: number | typeof MicrotaskDelay = 100, leading = true, trailing = true, leakWarningThreshold?: number, disposable?: DisposableStore): Event { + let subscription: IDisposable; + let output: O | undefined = undefined; + let handle: Timeout | undefined = undefined; + let numThrottledCalls = 0; + + const options: EmitterOptions | undefined = { + leakWarningThreshold, + onWillAddFirstListener() { + subscription = event(cur => { + numThrottledCalls++; + output = merge(output, cur); + + // If not currently throttling, fire immediately if leading is enabled + if (handle === undefined) { + if (leading) { + emitter.fire(output); + output = undefined; + numThrottledCalls = 0; + } + + // Set up the throttle period + if (typeof delay === 'number') { + handle = setTimeout(() => { + // Fire on trailing edge if there were calls during throttle period + if (trailing && numThrottledCalls > 0) { + emitter.fire(output!); + } + output = undefined; + handle = undefined; + numThrottledCalls = 0; + }, delay); + } else { + // Use a special marker to indicate microtask is pending + handle = 0 as unknown as Timeout; + queueMicrotask(() => { + // Fire on trailing edge if there were calls during throttle period + if (trailing && numThrottledCalls > 0) { + emitter.fire(output!); + } + output = undefined; + handle = undefined; + numThrottledCalls = 0; + }); + } + } + // If already throttling, just accumulate the value for trailing edge + }); + }, + onDidRemoveLastListener() { + subscription.dispose(); + } + }; + + if (!disposable) { + _addLeakageTraceLogic(options); + } + + const emitter = new Emitter(options); + + disposable?.add(emitter); + + return emitter.event; + } + /** * Filters an event such that some condition is _not_ met more than once in a row, effectively ensuring duplicate * event objects from different sources do not fire the same event object. diff --git a/src/vs/base/test/common/event.test.ts b/src/vs/base/test/common/event.test.ts index f79fa81cdff54..4f0369b28b979 100644 --- a/src/vs/base/test/common/event.test.ts +++ b/src/vs/base/test/common/event.test.ts @@ -1598,6 +1598,273 @@ suite('Event utils', () => { }); }); + suite('throttle', () => { + test('leading only', async function () { + return runWithFakedTimers({}, async function () { + const emitter = ds.add(new Emitter()); + const throttled = Event.throttle(emitter.event, (l, e) => l ? l + 1 : 1, 10, /*leading=*/true, /*trailing=*/false); + + const calls: number[] = []; + ds.add(throttled((e) => calls.push(e))); + + // First event fires immediately + emitter.fire(1); + assert.deepStrictEqual(calls, [1]); + + // Subsequent events during throttle period are ignored + emitter.fire(2); + emitter.fire(3); + assert.deepStrictEqual(calls, [1]); + + // Wait for throttle period to end + await timeout(15); + assert.deepStrictEqual(calls, [1], 'no trailing edge fire with trailing=false'); + + // After throttle period, next event fires immediately + emitter.fire(4); + assert.deepStrictEqual(calls, [1, 1]); + }); + }); + + test('trailing only', async function () { + return runWithFakedTimers({}, async function () { + const emitter = ds.add(new Emitter()); + const throttled = Event.throttle(emitter.event, (l, e) => l ? l + 1 : 1, 10, /*leading=*/false, /*trailing=*/true); + + const calls: number[] = []; + ds.add(throttled((e) => calls.push(e))); + + // First event does not fire immediately + emitter.fire(1); + assert.deepStrictEqual(calls, []); + + // Multiple events during throttle period + emitter.fire(2); + emitter.fire(3); + assert.deepStrictEqual(calls, []); + + // Wait for throttle period - should fire with accumulated value + await timeout(15); + assert.deepStrictEqual(calls, [3]); + + // New events start a new throttle period + emitter.fire(4); + emitter.fire(5); + assert.deepStrictEqual(calls, [3]); + + await timeout(15); + assert.deepStrictEqual(calls, [3, 2]); + }); + }); + + test('both leading and trailing', async function () { + return runWithFakedTimers({}, async function () { + const emitter = ds.add(new Emitter()); + const throttled = Event.throttle(emitter.event, (l, e) => l ? l + 1 : 1, 10, /*leading=*/true, /*trailing=*/true); + + const calls: number[] = []; + ds.add(throttled((e) => calls.push(e))); + + // First event fires immediately (leading) + emitter.fire(1); + assert.deepStrictEqual(calls, [1]); + + // Events during throttle period are accumulated + emitter.fire(2); + emitter.fire(3); + assert.deepStrictEqual(calls, [1]); + + // Wait for throttle period - should fire trailing edge with accumulated value + await timeout(15); + assert.deepStrictEqual(calls, [1, 2]); + }); + }); + + test('only leading edge if no subsequent events', async function () { + return runWithFakedTimers({}, async function () { + const emitter = ds.add(new Emitter()); + const throttled = Event.throttle(emitter.event, (l, e) => l ? l + 1 : 1, 10, /*leading=*/true, /*trailing=*/true); + + const calls: number[] = []; + ds.add(throttled((e) => calls.push(e))); + + // Single event fires immediately (leading) + emitter.fire(1); + assert.deepStrictEqual(calls, [1]); + + // No more events during throttle period + await timeout(15); + // Should not fire trailing edge since there were no more events + assert.deepStrictEqual(calls, [1]); + }); + }); + + test('microtask delay', function (done: () => void) { + const emitter = ds.add(new Emitter()); + const throttled = Event.throttle(emitter.event, (l, e) => l ? l + 1 : 1, MicrotaskDelay); + + const calls: number[] = []; + ds.add(throttled((e) => calls.push(e))); + + // First event fires immediately (leading by default) + emitter.fire(1); + assert.deepStrictEqual(calls, [1]); + + // Events during microtask + emitter.fire(2); + emitter.fire(3); + assert.deepStrictEqual(calls, [1]); + + // Check after microtask + queueMicrotask(() => { + // Should have fired trailing edge + assert.deepStrictEqual(calls, [1, 2]); + done(); + }); + }); + + test('merge function accumulates values', async function () { + return runWithFakedTimers({}, async function () { + const emitter = ds.add(new Emitter()); + const throttled = Event.throttle( + emitter.event, + (last, cur) => (last || 0) + cur, + 10, + /*leading=*/true, + /*trailing=*/true + ); + + const calls: number[] = []; + ds.add(throttled((e) => calls.push(e))); + + // First event fires immediately with value 1 + emitter.fire(1); + assert.deepStrictEqual(calls, [1]); + + // Accumulate more values: 2 + 3 = 5 + emitter.fire(2); + emitter.fire(3); + assert.deepStrictEqual(calls, [1]); + + await timeout(15); + // Trailing edge fires with accumulated sum + assert.deepStrictEqual(calls, [1, 5]); + }); + }); + + test('rapid consecutive throttle periods', async function () { + return runWithFakedTimers({}, async function () { + const emitter = ds.add(new Emitter()); + const throttled = Event.throttle(emitter.event, (l, e) => e, 10, /*leading=*/true, /*trailing=*/true); + + const calls: number[] = []; + ds.add(throttled((e) => calls.push(e))); + + // Period 1 + emitter.fire(1); + emitter.fire(2); + assert.deepStrictEqual(calls, [1]); + + await timeout(15); + assert.deepStrictEqual(calls, [1, 2]); + + // Period 2 + emitter.fire(3); + emitter.fire(4); + assert.deepStrictEqual(calls, [1, 2, 3]); + + await timeout(15); + assert.deepStrictEqual(calls, [1, 2, 3, 4]); + + // Period 3 + emitter.fire(5); + assert.deepStrictEqual(calls, [1, 2, 3, 4, 5]); + + await timeout(15); + // No trailing fire since only one event + assert.deepStrictEqual(calls, [1, 2, 3, 4, 5]); + }); + }); + + test('default parameters', async function () { + return runWithFakedTimers({}, async function () { + const emitter = ds.add(new Emitter()); + // Default: delay=100, leading=true, trailing=true + const throttled = Event.throttle(emitter.event, (l, e) => e); + + const calls: number[] = []; + ds.add(throttled((e) => calls.push(e))); + + emitter.fire(1); + assert.deepStrictEqual(calls, [1], 'should fire leading edge by default'); + + emitter.fire(2); + await timeout(110); + assert.deepStrictEqual(calls, [1, 2], 'should fire trailing edge by default'); + }); + }); + + test('disposal cleans up', async function () { + return runWithFakedTimers({}, async function () { + const emitter = ds.add(new Emitter()); + const throttled = Event.throttle(emitter.event, (l, e) => e, 10); + + const calls: number[] = []; + const listener = throttled((e) => calls.push(e)); + + emitter.fire(1); + emitter.fire(2); + assert.deepStrictEqual(calls, [1]); + + listener.dispose(); + + // Events after disposal should not fire + await timeout(15); + emitter.fire(3); + assert.deepStrictEqual(calls, [1]); + }); + }); + + test('no events during throttle with trailing=false', async function () { + return runWithFakedTimers({}, async function () { + const emitter = ds.add(new Emitter()); + const throttled = Event.throttle(emitter.event, (l, e) => l ? l + 1 : 1, 10, /*leading=*/true, /*trailing=*/false); + + const calls: number[] = []; + ds.add(throttled((e) => calls.push(e))); + + emitter.fire(1); + assert.deepStrictEqual(calls, [1]); + + // No more events + await timeout(15); + assert.deepStrictEqual(calls, [1]); + + // Next event after throttle period + emitter.fire(2); + assert.deepStrictEqual(calls, [1, 1]); + }); + }); + + test('neither leading nor trailing', async function () { + return runWithFakedTimers({}, async function () { + const emitter = ds.add(new Emitter()); + const throttled = Event.throttle(emitter.event, (l, e) => e, 10, /*leading=*/false, /*trailing=*/false); + + const calls: number[] = []; + ds.add(throttled((e) => calls.push(e))); + + emitter.fire(1); + emitter.fire(2); + emitter.fire(3); + assert.deepStrictEqual(calls, []); + + await timeout(15); + assert.deepStrictEqual(calls, [], 'no events should fire with both leading and trailing false'); + }); + }); + }); + test('issue #230401', () => { let count = 0; const emitter = ds.add(new Emitter()); diff --git a/src/vs/editor/common/core/ranges/offsetRange.ts b/src/vs/editor/common/core/ranges/offsetRange.ts index 3e9bbeba6eb1e..21fe3fd5503c2 100644 --- a/src/vs/editor/common/core/ranges/offsetRange.ts +++ b/src/vs/editor/common/core/ranges/offsetRange.ts @@ -18,6 +18,10 @@ export class OffsetRange implements IOffsetRange { return new OffsetRange(start, endExclusive); } + public static equals(r1: IOffsetRange, r2: IOffsetRange): boolean { + return r1.start === r2.start && r1.endExclusive === r2.endExclusive; + } + public static addRange(range: OffsetRange, sortedRanges: OffsetRange[]): void { let i = 0; while (i < sortedRanges.length && sortedRanges[i].endExclusive < range.start) { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 791a22f677f79..7173ddc8d7064 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -650,7 +650,13 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat private _refreshProviderOptions(handle: number, chatSessionScheme: string): void { this._proxy.$provideChatSessionProviderOptions(handle, CancellationToken.None).then(options => { if (options?.optionGroups && options.optionGroups.length) { - this._chatSessionsService.setOptionGroupsForSessionType(chatSessionScheme, handle, options.optionGroups); + const groupsWithCallbacks = options.optionGroups.map(group => ({ + ...group, + onSearch: group.searchable ? async (token: CancellationToken) => { + return await this._proxy.$invokeOptionGroupSearch(handle, group.id, token); + } : undefined, + })); + this._chatSessionsService.setOptionGroupsForSessionType(chatSessionScheme, handle, groupsWithCallbacks); } }).catch(err => this._logService.error('Error fetching chat session options', err)); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 94592df5f3782..ed0f88a69b5e5 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3339,6 +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; $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 260627104c1a1..b0ad15d2d4991 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -82,7 +82,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio * Map of uri -> chat sessions infos */ private readonly _extHostChatSessions = new ResourceMap<{ readonly sessionObj: ExtHostChatSession; readonly disposeCts: CancellationTokenSource }>(); - + /** + * Store option groups with onSearch callbacks per provider handle + */ + private readonly _providerOptionGroups = new Map(); constructor( private readonly commands: ExtHostCommands, @@ -324,6 +327,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio if (!optionGroups) { return; } + this._providerOptionGroups.set(handle, optionGroups); return { optionGroups, }; @@ -460,4 +464,26 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio participant: turn.participant }; } + + async $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, token: CancellationToken): Promise { + const optionGroups = this._providerOptionGroups.get(providerHandle); + if (!optionGroups) { + this._logService.warn(`No option groups found for provider handle ${providerHandle}`); + return []; + } + + const group = optionGroups.find((g: vscode.ChatSessionProviderOptionGroup) => g.id === optionGroupId); + if (!group || !group.onSearch) { + this._logService.warn(`No onSearch callback found for option group ${optionGroupId}`); + return []; + } + + try { + const results = await group.onSearch(token); + return results ?? []; + } catch (error) { + this._logService.error(`Error calling onSearch for option group ${optionGroupId}:`, error); + return []; + } + } } diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index e3e315bcb7ec7..9e818d0a34adf 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -53,6 +53,7 @@ suite('ObservableChatSession', function () { $provideChatSessionContent: sinon.stub(), $provideChatSessionProviderOptions: sinon.stub<[providerHandle: number, token: CancellationToken], Promise>().resolves(undefined), $provideHandleOptionsChange: sinon.stub(), + $invokeOptionGroupSearch: sinon.stub().resolves([]), $interruptChatSessionActiveResponse: sinon.stub(), $invokeChatSessionRequestHandler: sinon.stub(), $disposeChatSessionContent: sinon.stub(), @@ -348,6 +349,7 @@ suite('MainThreadChatSessions', function () { $provideChatSessionContent: sinon.stub(), $provideChatSessionProviderOptions: sinon.stub<[providerHandle: number, token: CancellationToken], Promise>().resolves(undefined), $provideHandleOptionsChange: sinon.stub(), + $invokeOptionGroupSearch: sinon.stub().resolves([]), $interruptChatSessionActiveResponse: sinon.stub(), $invokeChatSessionRequestHandler: sinon.stub(), $disposeChatSessionContent: sinon.stub(), diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts index c9bc3e6f82b25..76f0b41e3caf6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts @@ -25,7 +25,7 @@ export interface IChatSessionPickerDelegate { readonly onDidChangeOption: Event; getCurrentOption(): IChatSessionProviderOptionItem | undefined; setOption(option: IChatSessionProviderOptionItem): void; - getAllOptions(): IChatSessionProviderOptionItem[]; + getOptionGroup(): IChatSessionProviderOptionGroup | undefined; } /** @@ -71,7 +71,11 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI run: () => { } } satisfies IActionWidgetDropdownAction]; } else { - return this.delegate.getAllOptions().map(optionItem => { + const group = this.delegate.getOptionGroup(); + if (!group) { + return []; + } + return group.items.map(optionItem => { const isCurrent = optionItem.id === this.delegate.getCurrentOption()?.id; return { id: optionItem.id, diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts new file mode 100644 index 0000000000000..b6272b207162e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatSessionPickerActionItem.css'; +import { IAction } from '../../../../../base/common/actions.js'; +import { CancellationToken } 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'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +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 { renderLabelWithIcons, renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { localize } from '../../../../../nls.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { IChatSessionPickerDelegate } from './chatSessionPickerActionItem.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; + +interface ISearchableOptionQuickPickItem extends IQuickPickItem { + readonly optionItem: IChatSessionProviderOptionItem; +} + +function isSearchableOptionQuickPickItem(item: IQuickPickItem | undefined): item is ISearchableOptionQuickPickItem { + return !!item && typeof (item as ISearchableOptionQuickPickItem).optionItem === 'object'; +} + +/** + * Action view item for searchable option groups with QuickPick. + * Used when an option group has `searchable: true` (e.g., repository selection). + * Shows an inline dropdown with items + "See more..." option that opens a searchable QuickPick. + */ +export class SearchableOptionPickerActionItem extends ActionWidgetDropdownActionViewItem { + private currentOption: IChatSessionProviderOptionItem | undefined; + private static readonly SEE_MORE_ID = '__see_more__'; + + constructor( + action: IAction, + initialState: { group: IChatSessionProviderOptionGroup; item: IChatSessionProviderOptionItem | undefined }, + private readonly delegate: IChatSessionPickerDelegate, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IContextKeyService contextKeyService: IContextKeyService, + @IKeybindingService keybindingService: IKeybindingService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @ILogService private readonly logService: ILogService, + ) { + const { group, item } = initialState; + const actionWithLabel: IAction = { + ...action, + label: item?.name || group.name, + tooltip: item?.description ?? group.description ?? group.name, + run: () => { } + }; + + const searchablePickerOptions: Omit = { + actionProvider: { + getActions: () => { + // If locked, show the current option only + const currentOption = this.delegate.getCurrentOption(); + if (currentOption?.locked) { + return [{ + id: currentOption.id, + enabled: false, + icon: currentOption.icon, + checked: true, + class: undefined, + description: undefined, + tooltip: currentOption.description ?? currentOption.name, + label: currentOption.name, + run: () => { } + } satisfies IActionWidgetDropdownAction]; + } + + const actions: IActionWidgetDropdownAction[] = []; + const optionGroup = this.delegate.getOptionGroup(); + if (!optionGroup) { + return []; + } + + // Build actions from items + optionGroup.items.map(optionItem => { + const isCurrent = optionItem.id === currentOption?.id; + actions.push({ + id: optionItem.id, + enabled: !optionItem.locked, + icon: optionItem.icon, + checked: isCurrent, + class: undefined, + description: undefined, + tooltip: optionItem.description ?? optionItem.name, + label: optionItem.name, + run: () => { + this.delegate.setOption(optionItem); + } + }); + }); + + // Add "See more..." action if onSearch is available + if (optionGroup.onSearch) { + actions.push({ + id: SearchableOptionPickerActionItem.SEE_MORE_ID, + enabled: true, + checked: false, + class: 'searchable-picker-see-more', + description: undefined, + tooltip: localize('seeMore.tooltip', "Search for more options"), + label: localize('seeMore', "See more..."), + run: () => { + this.showSearchableQuickPick(optionGroup); + } + } satisfies IActionWidgetDropdownAction); + } + + return actions; + } + }, + actionBarActionProvider: undefined, + }; + + super(actionWithLabel, searchablePickerOptions, actionWidgetService, keybindingService, contextKeyService); + this.currentOption = item; + + this._register(this.delegate.onDidChangeOption(newOption => { + this.currentOption = newOption; + if (this.element) { + this.renderLabel(this.element); + } + })); + } + + protected override renderLabel(element: HTMLElement): IDisposable | null { + const domChildren = []; + const optionGroup = this.delegate.getOptionGroup(); + + element.classList.add('chat-session-option-picker'); + if (optionGroup?.icon) { + domChildren.push(renderIcon(optionGroup.icon)); + } + + // Label + const label = this.currentOption?.name ?? optionGroup?.name ?? localize('selectOption', "Select..."); + domChildren.push(dom.$('span.chat-session-option-label', undefined, label)); + + // Chevron + domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); + + // Locked indicator + if (this.currentOption?.locked) { + domChildren.push(renderIcon(Codicon.lock)); + } + + dom.reset(element, ...domChildren); + this.setAriaLabelAttributes(element); + return null; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('chat-searchable-option-picker-item'); + } + + /** + * Shows the full searchable QuickPick with all items (initial + search results) + * Called when user clicks "See more..." from the dropdown + */ + private async showSearchableQuickPick(optionGroup: IChatSessionProviderOptionGroup): Promise { + if (optionGroup.onSearch) { + const quickPick = this.quickInputService.createQuickPick(); + quickPick.placeholder = optionGroup.description ?? localize('selectOption.placeholder', "Select {0}", optionGroup.name); + quickPick.matchOnDescription = true; + quickPick.matchOnDetail = true; + quickPick.busy = !!optionGroup.onSearch; + 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; + } + + + // Handle selection + return new Promise((resolve) => { + quickPick.onDidAccept(() => { + const pick = quickPick.selectedItems[0]; + if (isSearchableOptionQuickPickItem(pick)) { + const selectedItem = pick.optionItem; + if (!selectedItem.locked) { + this.delegate.setOption(selectedItem); + } + } + quickPick.hide(); + }); + + quickPick.onDidHide(() => { + quickPick.dispose(); + resolve(); + }); + }); + } + } + + private createQuickPickItem( + item: IChatSessionProviderOptionItem, + ): ISearchableOptionQuickPickItem { + const iconClass = item.icon ? ThemeIcon.asClassName(item.icon) : undefined; + + return { + label: item.name, + description: item.description, + iconClass, + disabled: item.locked, + optionItem: item, + }; + } + + /** + * Opens the picker programmatically. + */ + override show(): void { + const optionGroup = this.delegate.getOptionGroup(); + if (optionGroup) { + this.showSearchableQuickPick(optionGroup); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index d5e8655adf08f..9c7599f24b1e2 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -206,7 +206,9 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo this._tools.set(toolData.id, { data: toolData }); this._ctxToolsCount.set(this._tools.size); - this._onDidChangeToolsScheduler.schedule(); + if (!this._onDidChangeToolsScheduler.isScheduled()) { + this._onDidChangeToolsScheduler.schedule(); + } toolData.when?.keys().forEach(key => this._toolContextKeys.add(key)); @@ -223,7 +225,9 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo this._tools.delete(toolData.id); this._ctxToolsCount.set(this._tools.size); this._refreshAllToolContextKeys(); - this._onDidChangeToolsScheduler.schedule(); + if (!this._onDidChangeToolsScheduler.isScheduled()) { + this._onDidChangeToolsScheduler.schedule(); + } }); } 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 d8176438e45c1..7cbc201021256 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 @@ -20,7 +20,7 @@ import '../media/chatTerminalToolProgressPart.css'; import type { ICodeBlockRenderOptions } from '../codeBlockPart.js'; import { Action, IAction } from '../../../../../../../base/common/actions.js'; import { IChatTerminalToolProgressPart, ITerminalChatService, ITerminalConfigurationService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../../../terminal/browser/terminal.js'; -import { Disposable, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { Emitter } from '../../../../../../../base/common/event.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { DecorationSelector, getTerminalCommandDecorationState, getTerminalCommandDecorationTooltip } from '../../../../../terminal/browser/xterm/decorationStyles.js'; @@ -213,6 +213,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart private readonly _isSerializedInvocation: boolean; private _terminalInstance: ITerminalInstance | undefined; private readonly _decoration: TerminalCommandDecoration; + private _autoExpandTimeout: ReturnType | undefined; + private _userToggledOutput: boolean = false; private markdownPart: ChatMarkdownContentPart | undefined; public get codeblocks(): IChatCodeBlockInfo[] { @@ -506,16 +508,51 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart return; } - commandDetectionListener.value = commandDetection.onCommandFinished(() => { + const store = new DisposableStore(); + store.add(commandDetection.onCommandExecuted(() => { + this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); + // Auto-expand if there's output, checking periodically for up to 1 second + if (!this._outputView.isExpanded && !this._userToggledOutput && !this._autoExpandTimeout) { + let attempts = 0; + const maxAttempts = 5; + const checkForOutput = () => { + this._autoExpandTimeout = undefined; + if (this._store.isDisposed || this._outputView.isExpanded || this._userToggledOutput) { + return; + } + if (this._hasOutput(terminalInstance)) { + this._toggleOutput(true); + return; + } + attempts++; + if (attempts < maxAttempts) { + this._autoExpandTimeout = setTimeout(checkForOutput, 200); + } + }; + this._autoExpandTimeout = setTimeout(checkForOutput, 200); + } + })); + store.add(commandDetection.onCommandFinished(() => { this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); const resolvedCommand = this._getResolvedCommand(terminalInstance); + + // Auto-collapse on success + if (resolvedCommand?.exitCode === 0 && this._outputView.isExpanded && !this._userToggledOutput) { + this._toggleOutput(false); + } if (resolvedCommand?.endMarker) { commandDetectionListener.clear(); } - }); + })); + commandDetectionListener.value = store; + const resolvedImmediately = await tryResolveCommand(); if (resolvedImmediately?.endMarker) { commandDetectionListener.clear(); + // Auto-collapse on success + if (resolvedImmediately.exitCode === 0 && this._outputView.isExpanded && !this._userToggledOutput) { + this._toggleOutput(false); + } return; } }; @@ -595,6 +632,10 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } private _handleDispose(): void { + if (this._autoExpandTimeout) { + clearTimeout(this._autoExpandTimeout); + this._autoExpandTimeout = undefined; + } this._terminalOutputContextKey.reset(); this._terminalChatService.clearFocusedProgressPart(this); } @@ -623,6 +664,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } public async toggleOutputFromKeyboard(): Promise { + this._userToggledOutput = true; if (!this._outputView.isExpanded) { await this._toggleOutput(true); this.focusOutput(); @@ -632,6 +674,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } private async _toggleOutputFromAction(): Promise { + this._userToggledOutput = true; if (!this._outputView.isExpanded) { await this._toggleOutput(true); return; @@ -646,17 +689,52 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._focusChatInput(); } + private _hasOutput(terminalInstance: ITerminalInstance): boolean { + // Check for snapshot + if (this._terminalData.terminalCommandOutput?.text?.trim()) { + return true; + } + // Check for live output (cursor moved past executed marker) + const command = this._getResolvedCommand(terminalInstance); + if (!command?.executedMarker || terminalInstance.isDisposed) { + return false; + } + const buffer = terminalInstance.xterm?.raw.buffer.active; + if (!buffer) { + return false; + } + const cursorLine = buffer.baseY + buffer.cursorY; + return cursorLine > command.executedMarker.line; + } + private _resolveCommand(instance: ITerminalInstance): ITerminalCommand | undefined { if (instance.isDisposed) { return undefined; } const commandDetection = instance.capabilities.get(TerminalCapability.CommandDetection); - const commands = commandDetection?.commands; - if (!commands || commands.length === 0) { + if (!commandDetection) { + return undefined; + } + + const targetId = this._terminalData.terminalCommandId; + if (!targetId) { return undefined; } - return commands.find(c => c.id === this._terminalData.terminalCommandId); + const commands = commandDetection.commands; + if (commands && commands.length > 0) { + const fromHistory = commands.find(c => c.id === targetId); + if (fromHistory) { + return fromHistory; + } + } + + const executing = commandDetection.executingCommandObject; + if (executing && executing.id === targetId) { + return executing; + } + + return undefined; } } @@ -670,6 +748,8 @@ class ChatTerminalToolOutputSection extends Disposable { private readonly _outputBody: HTMLElement; private _scrollableContainer: DomScrollableElement | undefined; private _renderedOutputHeight: number | undefined; + private _isAtBottom: boolean = true; + private _isProgrammaticScroll: boolean = false; private _mirror: DetachedTerminalCommandMirror | undefined; private _snapshotMirror: DetachedTerminalSnapshotMirror | undefined; private readonly _contentContainer: HTMLElement; @@ -738,6 +818,7 @@ class ChatTerminalToolOutputSection extends Disposable { if (!expanded) { this._renderedOutputHeight = undefined; + this._isAtBottom = true; this._onDidChangeHeight(); return true; } @@ -825,6 +906,14 @@ class ChatTerminalToolOutputSection extends Disposable { scrollableDomNode.tabIndex = 0; this.domNode.appendChild(scrollableDomNode); this.updateAriaLabel(); + + // Track scroll state to enable scroll lock behavior (only for user scrolls) + this._register(this._scrollableContainer.onScroll(() => { + if (this._isProgrammaticScroll) { + return; + } + this._isAtBottom = this._computeIsAtBottom(); + })); } private async _updateTerminalContent(): Promise { @@ -858,9 +947,22 @@ class ChatTerminalToolOutputSection extends Disposable { this._disposeLiveMirror(); return false; } - this._mirror = this._register(this._instantiationService.createInstance(DetachedTerminalCommandMirror, liveTerminalInstance.xterm!, command)); - await this._mirror.attach(this._terminalContainer); - const result = await this._mirror.renderCommand(); + const mirror = this._register(this._instantiationService.createInstance(DetachedTerminalCommandMirror, liveTerminalInstance.xterm, command)); + this._mirror = mirror; + this._register(mirror.onDidUpdate(lineCount => { + this._layoutOutput(lineCount); + if (this._isAtBottom) { + this._scrollOutputToBottom(); + } + })); + // Forward input from the mirror terminal to the live terminal instance + this._register(mirror.onDidInput(data => { + if (!liveTerminalInstance.isDisposed) { + liveTerminalInstance.sendText(data, false); + } + })); + await mirror.attach(this._terminalContainer); + const result = await mirror.renderCommand(); if (!result || result.lineCount === 0) { this._showEmptyMessage(localize('chat.terminalOutputEmpty', 'No output was produced by the command.')); } else { @@ -975,12 +1077,25 @@ class ChatTerminalToolOutputSection extends Disposable { } } + private _computeIsAtBottom(): boolean { + if (!this._scrollableContainer) { + return true; + } + const dimensions = this._scrollableContainer.getScrollDimensions(); + const scrollPosition = this._scrollableContainer.getScrollPosition(); + // Consider "at bottom" if within a small threshold to account for rounding + const threshold = 5; + return scrollPosition.scrollTop >= dimensions.scrollHeight - dimensions.height - threshold; + } + private _scrollOutputToBottom(): void { if (!this._scrollableContainer) { return; } + this._isProgrammaticScroll = true; const dimensions = this._scrollableContainer.getScrollDimensions(); this._scrollableContainer.setScrollPosition({ scrollTop: dimensions.scrollHeight }); + this._isProgrammaticScroll = false; } private _getOutputContentHeight(lineCount: number, rowHeight: number, padding: number): number { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index da168a4805137..540ef09bb584e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1660,10 +1660,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer; private modelWidget: ModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; - private chatSessionPickerWidgets: Map = new Map(); + private chatSessionPickerWidgets: Map = new Map(); private chatSessionPickerContainer: HTMLElement | undefined; private _lastSessionPickerAction: MenuItemAction | undefined; private readonly _waitForPersistedLanguageModel: MutableDisposable = this._register(new MutableDisposable()); @@ -702,7 +703,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge /** * Create picker widgets for all option groups available for the current session type. */ - private createChatSessionPickerWidgets(action: MenuItemAction): ChatSessionPickerActionItem[] { + private createChatSessionPickerWidgets(action: MenuItemAction): (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] { this._lastSessionPickerAction = action; // Helper to resolve chat session context @@ -736,12 +737,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - const widgets: ChatSessionPickerActionItem[] = []; + const widgets: (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] = []; for (const optionGroup of optionGroups) { if (!ctx) { continue; } - if (!this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id)) { + + const hasSessionValue = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); + const hasItems = optionGroup.items.length > 0; + if (!hasSessionValue && !hasItems) { // This session does not have a value to contribute for this option group continue; } @@ -774,18 +778,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Refresh pickers to re-evaluate visibility of other option groups this.refreshChatSessionPickers(); }, - getAllOptions: () => { + getOptionGroup: () => { const ctx = resolveChatSessionContext(); if (!ctx) { - return []; + return undefined; } const groups = this.chatSessionsService.getOptionGroupsForSessionType(ctx.chatSessionType); - const group = groups?.find(g => g.id === optionGroup.id); - return group?.items ?? []; + return groups?.find(g => g.id === optionGroup.id); } }; - const widget = this.instantiationService.createInstance(ChatSessionPickerActionItem, action, initialState, itemDelegate); + const widget = this.instantiationService.createInstance(optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, action, initialState, itemDelegate); this.chatSessionPickerWidgets.set(optionGroup.id, widget); widgets.push(widget); } @@ -1464,7 +1467,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const currentOptionValue = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroupId); if (!currentOptionValue) { - return; + const defaultItem = optionGroup.items.find(item => item.default); + return defaultItem; } if (typeof currentOptionValue === 'string') { @@ -2604,10 +2608,12 @@ function getLastPosition(model: ITextModel): IPosition { const chatInputEditorContainerSelector = '.interactive-input-editor'; setupSimpleEditorSelectionStyling(chatInputEditorContainerSelector); +type ChatSessionPickerWidget = ChatSessionPickerActionItem | SearchableOptionPickerActionItem; + class ChatSessionPickersContainerActionItem extends ActionViewItem { constructor( action: IAction, - private readonly widgets: ChatSessionPickerActionItem[], + private readonly widgets: ChatSessionPickerWidget[], options?: IActionViewItemOptions ) { super(null, action, options ?? {}); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 9cd086c83314f..8e9a983a33f11 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -35,6 +35,7 @@ export interface IChatSessionProviderOptionItem { description?: string; locked?: boolean; icon?: ThemeIcon; + default?: boolean; // [key: string]: any; } @@ -43,6 +44,8 @@ export interface IChatSessionProviderOptionGroup { name: string; description?: string; items: IChatSessionProviderOptionItem[]; + searchable?: boolean; + onSearch?: (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. @@ -50,6 +53,7 @@ export interface IChatSessionProviderOptionGroup { * Example: `"chatSessionOption.models == 'gpt-4'"` */ when?: string; + icon?: ThemeIcon; } export interface IChatSessionsExtensionPoint { diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatElicitationRequestPart.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatElicitationRequestPart.ts index aefae323291cc..69913fbf76ed3 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatElicitationRequestPart.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatElicitationRequestPart.ts @@ -5,12 +5,11 @@ import { IAction } from '../../../../../../base/common/actions.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ElicitationState, IChatElicitationRequest, IChatElicitationRequestSerialized } from '../../chatService/chatService.js'; import { ToolDataSource } from '../../tools/languageModelToolsService.js'; -export class ChatElicitationRequestPart extends Disposable implements IChatElicitationRequest { +export class ChatElicitationRequestPart implements IChatElicitationRequest { public readonly kind = 'elicitation2'; public state = observableValue('state', ElicitationState.Pending); public acceptedResult?: Record; @@ -32,8 +31,6 @@ export class ChatElicitationRequestPart extends Disposable implements IChatElici public readonly moreActions?: IAction[], public readonly onHide?: () => void, ) { - super(); - if (reject) { this.reject = async () => { const state = await reject!(); @@ -54,7 +51,9 @@ export class ChatElicitationRequestPart extends Disposable implements IChatElici } this._isHiddenValue.set(true, undefined, undefined); this.onHide?.(); - this.dispose(); + if (this.state.get() === ElicitationState.Pending) { + this.state.set(ElicitationState.Rejected, undefined); + } } public toJSON() { diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatParserTypes.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatParserTypes.ts index 47ecffdb0a503..8040aeeba5985 100644 --- a/src/vs/workbench/contrib/chat/common/requestParser/chatParserTypes.ts +++ b/src/vs/workbench/contrib/chat/common/requestParser/chatParserTypes.ts @@ -6,13 +6,14 @@ import { revive } from '../../../../../base/common/marshalling.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { IOffsetRange, OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; -import { IRange } from '../../../../../editor/common/core/range.js'; +import { IRange, Range } from '../../../../../editor/common/core/range.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentService, reviveSerializedAgent } from '../participants/chatAgents.js'; import { IChatSlashData } from '../participants/chatSlashCommands.js'; import { IChatRequestProblemsVariable, IChatRequestVariableValue } from '../attachments/chatVariables.js'; import { ChatAgentLocation } from '../constants.js'; import { IToolData } from '../tools/languageModelToolsService.js'; import { IChatRequestToolEntry, IChatRequestToolSetEntry, IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData } from '../attachments/chatVariableEntries.js'; +import { arrayEquals } from '../../../../../base/common/equals.js'; // These are in a separate file to avoid circular dependencies with the dependencies of the parser @@ -21,6 +22,17 @@ export interface IParsedChatRequest { readonly text: string; } +export namespace IParsedChatRequest { + export function equals(a: IParsedChatRequest, b: IParsedChatRequest): boolean { + return a.text === b.text && arrayEquals(a.parts, b.parts, (p1, p2) => + p1.kind === p2.kind && + OffsetRange.equals(p1.range, p2.range) && + Range.equalsRange(p1.editorRange, p2.editorRange) && + p1.text === p2.text + ); + } +} + export interface IParsedChatRequestPart { readonly kind: string; // for serialization readonly range: IOffsetRange; diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index 99ad550daad9e..25e9080dd4c2d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -29,6 +29,7 @@ import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocatio import { IMcpRegistry } from './mcpRegistryTypes.js'; import { IMcpServer, IMcpService, IMcpTool, IMcpToolResourceLinkContents, McpResourceURI, McpToolResourceLinkMimeType, McpToolVisibility } from './mcpTypes.js'; import { mcpServerToSourceData } from './mcpTypesUtils.js'; +import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; interface ISyncedToolData { toolData: IToolData; @@ -44,6 +45,7 @@ export class McpLanguageModelToolContribution extends Disposable implements IWor @IMcpService mcpService: IMcpService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + @ILifecycleService private readonly lifecycleService: ILifecycleService, ) { super(); @@ -111,7 +113,18 @@ export class McpLanguageModelToolContribution extends Disposable implements IWor store.add(collectionData.value.toolSet.addTool(toolData)); }; + // Don't bother cleaning up tools internally during shutdown. This just costs time for no benefit. + if (this.lifecycleService.willShutdown) { + return; + } + const collection = collectionObservable.read(reader); + if (!collection) { + tools.forEach(t => t.store.dispose()); + tools.clear(); + return; + } + for (const tool of server.tools.read(reader)) { // Skip app-only tools - they should not be registered with the language model tools service if (!(tool.visibility & McpToolVisibility.Model)) { diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 797d0473b436f..4af69123f2a2b 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -3,8 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; -import type { IMarker as IXtermMarker } from '@xterm/xterm'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import type { IMarker as IXtermMarker, Terminal as RawXtermTerminal } from '@xterm/xterm'; import type { ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalService, type IDetachedTerminalInstance } from './terminal.js'; import { DetachedProcessInfo } from './detachedTerminal.js'; @@ -17,6 +19,7 @@ import { editorBackground } from '../../../../platform/theme/common/colorRegistr import { Color } from '../../../../base/common/color.js'; import type { IChatTerminalToolInvocationData } from '../../chat/common/chatService/chatService.js'; import type { IColorTheme } from '../../../../platform/theme/common/themeService.js'; +import { ICurrentPartialCommand } from '../../../../platform/terminal/common/capabilities/commandDetection/terminalCommand.js'; function getChatTerminalBackgroundColor(theme: IColorTheme, contextKeyService: IContextKeyService, storedBackground?: string): Color | undefined { if (storedBackground) { @@ -35,35 +38,16 @@ function getChatTerminalBackgroundColor(theme: IColorTheme, contextKeyService: I return theme.getColor(isInEditor ? editorBackground : PANEL_BACKGROUND); } -/** - * Base class for detached terminal mirrors. - * Handles attaching to containers and managing the detached terminal instance. - */ -abstract class DetachedTerminalMirror extends Disposable { - private _detachedTerminal: Promise | undefined; - private _attachedContainer: HTMLElement | undefined; - - protected _setDetachedTerminal(detachedTerminal: Promise): void { - this._detachedTerminal = detachedTerminal.then(terminal => this._register(terminal)); - } - - protected async _getTerminal(): Promise { - if (!this._detachedTerminal) { - throw new Error('Detached terminal not initialized'); - } - return this._detachedTerminal; - } +interface IDetachedTerminalCommandMirror { + attach(container: HTMLElement): Promise; + renderCommand(): Promise<{ lineCount?: number } | undefined>; + onDidUpdate: Event; + onDidInput: Event; +} - protected async _attachToContainer(container: HTMLElement): Promise { - const terminal = await this._getTerminal(); - container.classList.add('chat-terminal-output-terminal'); - const needsAttach = this._attachedContainer !== container || container.firstChild === null; - if (needsAttach) { - terminal.attachToElement(container, { enableGpu: false }); - this._attachedContainer = container; - } - return terminal; - } +const enum ChatTerminalMirrorMetrics { + MirrorRowCount = 10, + MirrorColCountFallback = 80 } export async function getCommandOutputSnapshot( @@ -109,13 +93,13 @@ export async function getCommandOutputSnapshot( if (!text) { return { text: '', lineCount: 0 }; } - const endLine = endMarker.line - 1; + const endLine = endMarker.line; const lineCount = Math.max(endLine - startLine + 1, 0); return { text, lineCount }; } const startLine = executedMarker.line; - const endLine = endMarker.line - 1; + const endLine = endMarker.line; const lineCount = Math.max(endLine - startLine + 1, 0); let text: string | undefined; @@ -132,53 +116,359 @@ export async function getCommandOutputSnapshot( return { text, lineCount }; } -interface IDetachedTerminalCommandMirror { - attach(container: HTMLElement): Promise; - renderCommand(): Promise<{ lineCount?: number } | undefined>; -} - /** * Mirrors a terminal command's output into a detached terminal instance. - * Used in the chat terminal tool progress part to show command output for example. + * Used in the chat terminal tool progress part to show command output. */ -export class DetachedTerminalCommandMirror extends DetachedTerminalMirror implements IDetachedTerminalCommandMirror { +export class DetachedTerminalCommandMirror extends Disposable implements IDetachedTerminalCommandMirror { + // Streaming approach + // ------------------ + // The mirror maintains a VT snapshot of the command's output and incrementally updates a + // detached xterm instance instead of re-rendering the whole range on every change. + // + // - A *dirty range* is the set of buffer rows that may have diverged between the source + // terminal and the detached mirror. It is tracked by: + // - `_lastUpToDateCursorY`: the last cursor row in the source buffer for which the + // mirror is known to be fully up to date. + // - `_lowestDirtyCursorY`: the smallest (top-most) cursor row that has been affected + // by new data or cursor movement since the last flush. + // + // - When new data arrives or the cursor moves, xterm events and `onData` callbacks are + // used to update `_lowestDirtyCursorY`. This effectively marks everything from that row + // downwards as potentially stale. + // + // - If the dirty range starts exactly at the previous end of the mirrored output (that is, + // `_lowestDirtyCursorY` is at or after `_lastUpToDateCursorY` and no earlier rows have + // changed), the mirror can *append* VT that corresponds only to the new rows. + // + // - If the cursor moves or data is written above the previously mirrored end (for example, + // when the command rewrites lines, uses carriage returns, or modifies earlier rows), + // `_lowestDirtyCursorY` will be before `_lastUpToDateCursorY`. In that case the mirror + // cannot safely append and instead falls back to taking a fresh VT snapshot of the + // entire command range and *rewrites* the detached terminal content. + + private _detachedTerminal: IDetachedTerminalInstance | undefined; + 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 _onDidInputEmitter = this._register(new Emitter()); + public readonly onDidInput: Event = this._onDidInputEmitter.event; + + private _lastVT = ''; + private _lineCount = 0; + private _lastUpToDateCursorY: number | undefined; + private _lowestDirtyCursorY: number | undefined; + private _flushPromise: Promise | undefined; + private _dirtyScheduled = false; + private _isStreaming = false; + private _sourceRaw: RawXtermTerminal | undefined; + constructor( private readonly _xtermTerminal: XtermTerminal, private readonly _command: ITerminalCommand, @ITerminalService private readonly _terminalService: ITerminalService, - @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService ) { super(); - const processInfo = this._register(new DetachedProcessInfo({ initialCwd: '' })); - this._setDetachedTerminal(this._terminalService.createDetachedTerminal({ - cols: this._xtermTerminal.raw!.cols, - rows: 10, - readonly: true, - processInfo, - disableOverviewRuler: true, - colorProvider: { - getBackgroundColor: theme => getChatTerminalBackgroundColor(theme, this._contextKeyService), - }, + this._register(toDisposable(() => { + this._stopStreaming(); })); } async attach(container: HTMLElement): Promise { - await this._attachToContainer(container); + if (this._store.isDisposed) { + return; + } + let terminal: IDetachedTerminalInstance; + try { + terminal = await this._getOrCreateTerminal(); + } catch (error) { + if (error instanceof CancellationError) { + return; + } + throw error; + } + if (this._store.isDisposed) { + return; + } + if (this._attachedContainer !== container) { + container.classList.add('chat-terminal-output-terminal'); + terminal.attachToElement(container, { enableGpu: false }); + this._attachedContainer = container; + } } async renderCommand(): Promise<{ lineCount?: number } | undefined> { - const vt = await getCommandOutputSnapshot(this._xtermTerminal, this._command); + if (this._store.isDisposed) { + return undefined; + } + let detached: IDetachedTerminalInstance; + try { + detached = await this._getOrCreateTerminal(); + } catch (error) { + if (error instanceof CancellationError) { + return undefined; + } + throw error; + } + if (this._store.isDisposed) { + return undefined; + } + let vt; + try { + vt = await this._getCommandOutputAsVT(this._xtermTerminal); + } catch { + // ignore and treat as no output + } if (!vt) { return undefined; } - if (!vt.text) { - return { lineCount: 0 }; + if (this._store.isDisposed) { + return undefined; } - const detached = await this._getTerminal(); + await new Promise(resolve => { - detached.xterm.write(vt.text, () => resolve()); + if (!this._lastVT) { + if (vt.text) { + detached.xterm.write(vt.text, resolve); + } else { + resolve(); + } + } else { + const appended = vt.text.slice(this._lastVT.length); + if (appended) { + detached.xterm.write(appended, resolve); + } else { + resolve(); + } + } }); - return { lineCount: vt.lineCount }; + + this._lastVT = vt.text; + + const sourceRaw = this._xtermTerminal.raw; + if (sourceRaw) { + this._sourceRaw = sourceRaw; + this._lastUpToDateCursorY = this._getAbsoluteCursorY(sourceRaw); + if (!this._isStreaming && (!this._command.endMarker || this._command.endMarker.isDisposed)) { + this._startStreaming(sourceRaw); + } + } + + this._lineCount = this._getRenderedLineCount(); + + return { lineCount: this._lineCount }; + } + + private async _getCommandOutputAsVT(source: XtermTerminal): Promise<{ text: string } | undefined> { + if (this._store.isDisposed) { + return undefined; + } + const executedMarker = this._command.executedMarker ?? (this._command as unknown as ICurrentPartialCommand).commandExecutedMarker; + if (!executedMarker) { + return undefined; + } + + const endMarker = this._command.endMarker; + const text = await source.getRangeAsVT(executedMarker, endMarker, endMarker?.line !== executedMarker.line); + if (this._store.isDisposed) { + return undefined; + } + if (!text) { + return { text: '' }; + } + + return { text }; + } + + private _getRenderedLineCount(): number { + // Calculate line count from the command's markers when available + const endMarker = this._command.endMarker; + if (this._command.executedMarker && endMarker && !endMarker.isDisposed) { + const startLine = this._command.executedMarker.line; + const endLine = endMarker.line; + return Math.max(endLine - startLine, 0); + } + + // During streaming (no end marker), calculate from the source terminal buffer + const executedMarker = this._command.executedMarker ?? (this._command as unknown as ICurrentPartialCommand).commandExecutedMarker; + if (executedMarker && this._sourceRaw) { + const buffer = this._sourceRaw.buffer.active; + const currentLine = buffer.baseY + buffer.cursorY; + return Math.max(currentLine - executedMarker.line, 0); + } + + return this._lineCount; + } + + private async _getOrCreateTerminal(): Promise { + if (this._detachedTerminal) { + return this._detachedTerminal; + } + if (this._detachedTerminalPromise) { + return this._detachedTerminalPromise; + } + if (this._store.isDisposed) { + throw new CancellationError(); + } + const createPromise = (async () => { + const colorProvider = { + getBackgroundColor: (theme: IColorTheme) => getChatTerminalBackgroundColor(theme, this._contextKeyService) + }; + const detached = await this._terminalService.createDetachedTerminal({ + cols: this._xtermTerminal.raw.cols ?? ChatTerminalMirrorMetrics.MirrorColCountFallback, + rows: ChatTerminalMirrorMetrics.MirrorRowCount, + readonly: false, + processInfo: new DetachedProcessInfo({ initialCwd: '' }), + disableOverviewRuler: true, + colorProvider + }); + if (this._store.isDisposed) { + detached.dispose(); + throw new CancellationError(); + } + this._detachedTerminal = detached; + this._register(detached); + + // Forward input from the mirror terminal to the source terminal + this._register(detached.onData(data => this._onDidInputEmitter.fire(data))); + return detached; + })(); + this._detachedTerminalPromise = createPromise; + return createPromise; + } + + private _startStreaming(raw: RawXtermTerminal): void { + if (this._store.isDisposed || this._isStreaming) { + return; + } + this._isStreaming = true; + this._streamingDisposables.add(Event.any(raw.onCursorMove, raw.onLineFeed, raw.onWriteParsed)(() => this._handleCursorEvent())); + this._streamingDisposables.add(raw.onData(() => this._handleCursorEvent())); + } + + private _stopStreaming(): void { + if (!this._isStreaming) { + return; + } + this._streamingDisposables.clear(); + this._isStreaming = false; + this._lowestDirtyCursorY = undefined; + this._sourceRaw = undefined; + } + + private _handleCursorEvent(): void { + if (this._store.isDisposed || !this._sourceRaw) { + return; + } + const cursorY = this._getAbsoluteCursorY(this._sourceRaw); + this._lowestDirtyCursorY = this._lowestDirtyCursorY === undefined ? cursorY : Math.min(this._lowestDirtyCursorY, cursorY); + this._scheduleFlush(); + } + + private _scheduleFlush(): void { + if (this._dirtyScheduled || this._store.isDisposed) { + return; + } + this._dirtyScheduled = true; + queueMicrotask(() => { + this._dirtyScheduled = false; + if (this._store.isDisposed) { + return; + } + this._flushDirtyRange(); + }); + } + + private _flushDirtyRange(): void { + if (this._store.isDisposed || this._flushPromise) { + return; + } + this._flushPromise = this._doFlushDirtyRange().finally(() => { + this._flushPromise = undefined; + }); + } + + private async _doFlushDirtyRange(): Promise { + if (this._store.isDisposed) { + return; + } + const sourceRaw = this._xtermTerminal.raw; + let detached = this._detachedTerminal; + if (!detached) { + try { + detached = await this._getOrCreateTerminal(); + } catch (error) { + if (error instanceof CancellationError) { + return; + } + throw error; + } + } + if (this._store.isDisposed) { + return; + } + const detachedRaw = detached?.xterm; + if (!sourceRaw || !detachedRaw) { + return; + } + + this._sourceRaw = sourceRaw; + const currentCursor = this._getAbsoluteCursorY(sourceRaw); + const previousCursor = this._lastUpToDateCursorY ?? currentCursor; + const startCandidate = this._lowestDirtyCursorY ?? currentCursor; + this._lowestDirtyCursorY = undefined; + + const startLine = Math.min(previousCursor, startCandidate); + // Ensure we resolve any pending flush even when no actual new output is available. + const vt = await this._getCommandOutputAsVT(this._xtermTerminal); + if (!vt) { + return; + } + if (this._store.isDisposed) { + return; + } + + if (vt.text === this._lastVT) { + this._lastUpToDateCursorY = currentCursor; + if (this._command.endMarker && !this._command.endMarker.isDisposed) { + this._stopStreaming(); + } + return; + } + + const canAppend = !!this._lastVT && startLine >= previousCursor; + await new Promise(resolve => { + if (!this._lastVT || !canAppend) { + if (vt.text) { + detachedRaw.write(vt.text, resolve); + } else { + resolve(); + } + } else { + const appended = vt.text.slice(this._lastVT.length); + if (appended) { + detachedRaw.write(appended, resolve); + } else { + resolve(); + } + } + }); + + this._lastVT = vt.text; + this._lineCount = this._getRenderedLineCount(); + this._lastUpToDateCursorY = currentCursor; + this._onDidUpdateEmitter.fire(this._lineCount); + + if (this._command.endMarker && !this._command.endMarker.isDisposed) { + this._stopStreaming(); + } + } + + private _getAbsoluteCursorY(raw: RawXtermTerminal): number { + return raw.buffer.active.baseY + raw.buffer.active.cursorY; } } @@ -186,7 +476,10 @@ export class DetachedTerminalCommandMirror extends DetachedTerminalMirror implem * Mirrors a terminal output snapshot into a detached terminal instance. * Used when the terminal has been disposed of but we still want to show the output. */ -export class DetachedTerminalSnapshotMirror extends DetachedTerminalMirror { +export class DetachedTerminalSnapshotMirror extends Disposable { + private _detachedTerminal: Promise | undefined; + private _attachedContainer: HTMLElement | undefined; + private _output: IChatTerminalToolInvocationData['terminalCommandOutput'] | undefined; private _container: HTMLElement | undefined; private _dirty = true; @@ -201,9 +494,9 @@ export class DetachedTerminalSnapshotMirror extends DetachedTerminalMirror { super(); this._output = output; const processInfo = this._register(new DetachedProcessInfo({ initialCwd: '' })); - this._setDetachedTerminal(this._terminalService.createDetachedTerminal({ - cols: 80, - rows: 10, + this._detachedTerminal = this._terminalService.createDetachedTerminal({ + cols: ChatTerminalMirrorMetrics.MirrorColCountFallback, + rows: ChatTerminalMirrorMetrics.MirrorRowCount, readonly: true, processInfo, disableOverviewRuler: true, @@ -213,7 +506,14 @@ export class DetachedTerminalSnapshotMirror extends DetachedTerminalMirror { return getChatTerminalBackgroundColor(theme, this._contextKeyService, storedBackground); } } - })); + }).then(terminal => this._register(terminal)); + } + + private async _getTerminal(): Promise { + if (!this._detachedTerminal) { + throw new Error('Detached terminal not initialized'); + } + return this._detachedTerminal; } public setOutput(output: IChatTerminalToolInvocationData['terminalCommandOutput'] | undefined): void { @@ -222,7 +522,14 @@ export class DetachedTerminalSnapshotMirror extends DetachedTerminalMirror { } public async attach(container: HTMLElement): Promise { - await this._attachToContainer(container); + const terminal = await this._getTerminal(); + container.classList.add('chat-terminal-output-terminal'); + const needsAttach = this._attachedContainer !== container || container.firstChild === null; + if (needsAttach) { + terminal.attachToElement(container, { enableGpu: false }); + this._attachedContainer = container; + } + this._container = container; this._applyTheme(container); } diff --git a/src/vs/workbench/contrib/terminal/browser/detachedTerminal.ts b/src/vs/workbench/contrib/terminal/browser/detachedTerminal.ts index 606240a14d805..ff94e3496e809 100644 --- a/src/vs/workbench/contrib/terminal/browser/detachedTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/detachedTerminal.ts @@ -20,6 +20,7 @@ import { TerminalWidgetManager } from './widgets/widgetManager.js'; import { XtermTerminal } from './xterm/xtermTerminal.js'; import { IEnvironmentVariableInfo } from '../common/environmentVariable.js'; import { ITerminalProcessInfo, ProcessState } from '../common/terminal.js'; +import { Event } from '../../../../base/common/event.js'; export class DetachedTerminal extends Disposable implements IDetachedTerminalInstance { private readonly _widgets = this._register(new TerminalWidgetManager()); @@ -32,6 +33,7 @@ export class DetachedTerminal extends Disposable implements IDetachedTerminalIns public get xterm(): IDetachedXtermTerminal { return this._xterm; } + public readonly onData: Event; constructor( private readonly _xterm: XtermTerminal, @@ -39,6 +41,7 @@ export class DetachedTerminal extends Disposable implements IDetachedTerminalIns @IInstantiationService instantiationService: IInstantiationService, ) { super(); + this.onData = this._xterm.raw.onData; const capabilities = options.capabilities ?? new TerminalCapabilityStore(); this._register(capabilities); this.capabilities = capabilities; diff --git a/src/vs/workbench/contrib/terminal/browser/media/terminal.css b/src/vs/workbench/contrib/terminal/browser/media/terminal.css index fccb2d8eea6a3..c005756ed461f 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/terminal.css +++ b/src/vs/workbench/contrib/terminal/browser/media/terminal.css @@ -351,8 +351,8 @@ .monaco-workbench .pane-body.integrated-terminal .terminal-tabs-chat-entry .terminal-tabs-entry { display: flex; align-items: center; - justify-content: center; - gap: 4px; + justify-content: flex-start; + gap: 6px; width: 100%; height: 100%; padding: 0; @@ -376,7 +376,7 @@ } .monaco-workbench .pane-body.integrated-terminal .tabs-container.has-text .terminal-tabs-chat-entry .terminal-tabs-entry { - justify-content: center; + justify-content: flex-start; padding: 0 10px; } @@ -384,6 +384,36 @@ display: none; } +.monaco-workbench .pane-body.integrated-terminal .terminal-tabs-chat-entry .terminal-tabs-chat-entry-delete { + display: none; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 2px; + margin-left: auto; + cursor: pointer; + opacity: 0.8; + flex-shrink: 0; +} + +.monaco-workbench .pane-body.integrated-terminal .terminal-tabs-chat-entry:hover .terminal-tabs-chat-entry-delete, +.monaco-workbench .pane-body.integrated-terminal .terminal-tabs-chat-entry:focus-within .terminal-tabs-chat-entry-delete { + display: flex; +} + +.monaco-workbench .pane-body.integrated-terminal .terminal-tabs-chat-entry .terminal-tabs-chat-entry-delete:hover { + opacity: 1; + background-color: var(--vscode-toolbar-hoverBackground); + border-radius: 3px; +} + +.monaco-workbench .pane-body.integrated-terminal .tabs-container:not(.has-text) .terminal-tabs-chat-entry .terminal-tabs-chat-entry-delete, +.monaco-workbench .pane-body.integrated-terminal .tabs-container:not(.has-text) .terminal-tabs-chat-entry:hover .terminal-tabs-chat-entry-delete, +.monaco-workbench .pane-body.integrated-terminal .tabs-container:not(.has-text) .terminal-tabs-chat-entry:focus-within .terminal-tabs-chat-entry-delete { + display: none; +} + .monaco-workbench .pane-body.integrated-terminal .tabs-list .terminal-tabs-entry { text-align: center; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 70f66a2fbfb80..4444764f1a7e3 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -28,7 +28,7 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { GroupIdentifier } from '../../../common/editor.js'; import { ACTIVE_GROUP_TYPE, AUX_WINDOW_GROUP_TYPE, SIDE_GROUP_TYPE } from '../../../services/editor/common/editorService.js'; import type { ICurrentPartialCommand } from '../../../../platform/terminal/common/capabilities/commandDetection/terminalCommand.js'; -import type { IXtermCore } from './xterm-private.js'; +import type { IXtermCore, IBufferSet } from './xterm-private.js'; import type { IMenu } from '../../../../platform/actions/common/actions.js'; import type { IProgressState } from '@xterm/addon-progress'; import type { IEditorOptions } from '../../../../platform/editor/common/editor.js'; @@ -418,6 +418,11 @@ export interface IBaseTerminalInstance { export interface IDetachedTerminalInstance extends IDisposable, IBaseTerminalInstance { readonly xterm: IDetachedXtermTerminal; + /** + * Event fired when data is received from the terminal. + */ + onData: Event; + /** * Attached the terminal to the given element. This should be preferred over * calling {@link IXtermTerminal.attachToElement} so that extra DOM elements @@ -1378,11 +1383,11 @@ export interface IXtermTerminal extends IDisposable { /** * Gets the content between two markers as VT sequences. - * @param startMarker The marker to start from. - * @param endMarker The marker to end at. + * @param startMarker The marker to start from. When not provided, will start from 0. + * @param endMarker The marker to end at. When not provided, will end at the last line. * @param skipLastLine Whether the last line should be skipped (e.g. when it's the prompt line) */ - getRangeAsVT(startMarker: IXtermMarker, endMarker?: IXtermMarker, skipLastLine?: boolean): Promise; + getRangeAsVT(startMarker?: IXtermMarker, endMarker?: IXtermMarker, skipLastLine?: boolean): Promise; /** * Gets whether there's any terminal selection. @@ -1483,6 +1488,11 @@ export interface IDetachedXtermTerminal extends IXtermTerminal { * Resizes the terminal. */ resize(columns: number, rows: number): void; + + /** + * Access to the terminal buffer for reading cursor position and content. + */ + readonly buffer: IBufferSet; } export interface IInternalXtermTerminal { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts index 85824414f1115..7c0bc25ca410b 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts @@ -9,17 +9,19 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { $ } from '../../../../base/browser/dom.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { ITerminalChatService } from './terminal.js'; +import { ITerminalChatService, ITerminalService } from './terminal.js'; import * as dom from '../../../../base/browser/dom.js'; export class TerminalTabsChatEntry extends Disposable { private readonly _entry: HTMLElement; private readonly _label: HTMLElement; + private readonly _deleteButton: HTMLElement; override dispose(): void { this._entry.remove(); this._label.remove(); + this._deleteButton.remove(); super.dispose(); } @@ -28,6 +30,7 @@ export class TerminalTabsChatEntry extends Disposable { private readonly _tabContainer: HTMLElement, @ICommandService private readonly _commandService: ICommandService, @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, + @ITerminalService private readonly _terminalService: ITerminalService, ) { super(); @@ -40,10 +43,22 @@ export class TerminalTabsChatEntry extends Disposable { icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.commentDiscussionSparkle)); this._label = dom.append(entry, $('.terminal-tabs-chat-entry-label')); + // Add delete button (right-aligned via CSS margin-left: auto) + this._deleteButton = dom.append(entry, $('.terminal-tabs-chat-entry-delete')); + 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")); + const runChatTerminalsCommand = () => { void this._commandService.executeCommand('workbench.action.terminal.chat.viewHiddenChatTerminals'); }; this._register(dom.addDisposableListener(this._entry, dom.EventType.CLICK, e => { + // Don't trigger if clicking on the delete button + if (e.target === this._deleteButton || this._deleteButton.contains(e.target as Node)) { + return; + } e.preventDefault(); runChatTerminalsCommand(); })); @@ -53,9 +68,31 @@ export class TerminalTabsChatEntry extends Disposable { runChatTerminalsCommand(); } })); + + // Delete button click handler + this._register(dom.addDisposableListener(this._deleteButton, dom.EventType.CLICK, async (e) => { + e.preventDefault(); + e.stopPropagation(); + await this._deleteAllHiddenTerminals(); + })); + + // Delete button keyboard handler + this._register(dom.addDisposableListener(this._deleteButton, dom.EventType.KEY_DOWN, async (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + await this._deleteAllHiddenTerminals(); + } + })); + this.update(); } + private async _deleteAllHiddenTerminals(): Promise { + const hiddenTerminals = this._terminalChatService.getToolSessionTerminalInstances(true); + await Promise.all(hiddenTerminals.map(terminal => this._terminalService.safeDisposeTerminal(terminal))); + } + get element(): HTMLElement { return this._entry; } 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 23729e0c8d1d6..534c69a196658 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts @@ -31,3 +31,13 @@ export interface IXtermCore { }; }; } + +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; + }; +} diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 17db39e35daff..282a8e3f30365 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -46,7 +46,7 @@ import { equals } from '../../../../../base/common/objects.js'; import type { IProgressState } from '@xterm/addon-progress'; import type { CommandDetectionCapability } from '../../../../../platform/terminal/common/capabilities/commandDetectionCapability.js'; import { URI } from '../../../../../base/common/uri.js'; -import { assert } from '../../../../../base/common/assert.js'; +import { isNumber } from '../../../../../base/common/types.js'; const enum RenderConstants { SmoothScrollDuration = 125 @@ -107,6 +107,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach get lastInputEvent(): string | undefined { return this._lastInputEvent; } private _progressState: IProgressState = { state: 0, value: 0 }; get progressState(): IProgressState { return this._progressState; } + get buffer() { return this.raw.buffer; } // Always on addons private _markNavigationAddon: MarkNavigationAddon; @@ -920,22 +921,24 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this._onDidRequestRefreshDimensions.fire(); } - async getRangeAsVT(startMarker: IXtermMarker, endMarker?: IXtermMarker, skipLastLine?: boolean): Promise { + async getRangeAsVT(startMarker?: IXtermMarker, endMarker?: IXtermMarker, skipLastLine?: boolean): Promise { if (!this._serializeAddon) { const Addon = await this._xtermAddonLoader.importAddon('serialize'); this._serializeAddon = new Addon(); this.raw.loadAddon(this._serializeAddon); } - assert(startMarker.line !== -1); - let end = endMarker?.line ?? this.raw.buffer.active.length - 1; - if (skipLastLine) { + const hasValidEndMarker = isNumber(endMarker?.line); + const start = isNumber(startMarker?.line) && startMarker?.line > -1 ? startMarker.line : 0; + let end = hasValidEndMarker ? endMarker.line : this.raw.buffer.active.length - 1; + if (skipLastLine && hasValidEndMarker) { end = end - 1; } + end = Math.max(end, start); return this._serializeAddon.serialize({ range: { - start: startMarker.line, - end: end + start: startMarker?.line ?? 0, + end } }); } diff --git a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts new file mode 100644 index 0000000000000..d66eb7fa7af7a --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Terminal } from '@xterm/xterm'; +import { strictEqual } from 'assert'; +import { importAMDNodeModule } from '../../../../../amdX.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { IEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { TerminalCapabilityStore } from '../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; +import { XtermTerminal } from '../../browser/xterm/xtermTerminal.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { TestXtermAddonImporter } from './xterm/xtermTestUtils.js'; + +const defaultTerminalConfig = { + fontFamily: 'monospace', + fontWeight: 'normal', + fontWeightBold: 'normal', + gpuAcceleration: 'off', + scrollback: 10, + fastScrollSensitivity: 2, + mouseWheelScrollSensitivity: 1, + unicodeVersion: '6' +}; + +suite('Workbench - ChatTerminalCommandMirror', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + suite('VT mirroring with XtermTerminal', () => { + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let XTermBaseCtor: typeof Terminal; + + async function createXterm(cols = 80, rows = 10, scrollback = 10): Promise { + const capabilities = store.add(new TerminalCapabilityStore()); + return store.add(instantiationService.createInstance(XtermTerminal, undefined, XTermBaseCtor, { + cols, + rows, + xtermColorProvider: { getBackgroundColor: () => undefined }, + capabilities, + disableShellIntegrationReporting: true, + xtermAddonImporter: new TestXtermAddonImporter(), + }, undefined)); + } + + function write(xterm: XtermTerminal, data: string): Promise { + return new Promise(resolve => xterm.write(data, resolve)); + } + + function getBufferText(xterm: XtermTerminal): string { + const buffer = xterm.raw.buffer.active; + const lines: string[] = []; + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + lines.push(line?.translateToString(true) ?? ''); + } + // Trim trailing empty lines + while (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + return lines.join('\n'); + } + + async function mirrorViaVT(source: XtermTerminal, startLine = 0): Promise { + const startMarker = source.raw.registerMarker(startLine - source.raw.buffer.active.baseY - source.raw.buffer.active.cursorY); + const vt = await source.getRangeAsVT(startMarker ?? undefined, undefined, true); + startMarker?.dispose(); + + const mirror = await createXterm(source.raw.cols, source.raw.rows); + if (vt) { + await write(mirror, vt); + } + return mirror; + } + + setup(async () => { + configurationService = new TestConfigurationService({ + editor: { + fastScrollSensitivity: 2, + mouseWheelScrollSensitivity: 1 + } as Partial, + files: {}, + terminal: { + integrated: defaultTerminalConfig + }, + }); + + instantiationService = workbenchInstantiationService({ + configurationService: () => configurationService + }, store); + + XTermBaseCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; + }); + + test('single character', async () => { + const source = await createXterm(); + await write(source, 'X'); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('single line', async () => { + const source = await createXterm(); + await write(source, 'hello world'); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('multiple lines', async () => { + const source = await createXterm(); + await write(source, 'line 1\r\nline 2\r\nline 3'); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('wrapped line', async () => { + const source = await createXterm(20, 10); // narrow terminal + const longLine = 'a'.repeat(50); // exceeds 20 cols + await write(source, longLine); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('content with special characters', async () => { + const source = await createXterm(); + await write(source, 'hello\ttab\r\nspaces here\r\n$pecial!@#%^&*'); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('content with ANSI colors', async () => { + const source = await createXterm(); + await write(source, '\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m \x1b[34mblue\x1b[0m'); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('content filling visible area', async () => { + const source = await createXterm(80, 5); + for (let i = 1; i <= 5; i++) { + await write(source, `line ${i}\r\n`); + } + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('content with scrollback (partial buffer)', async () => { + const source = await createXterm(80, 5, 5); // 5 rows visible, 5 scrollback = 10 total + // Write enough to push into scrollback + for (let i = 1; i <= 12; i++) { + await write(source, `line ${i}\r\n`); + } + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('empty content', async () => { + const source = await createXterm(); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('content from marker to cursor', async () => { + const source = await createXterm(); + await write(source, 'before\r\n'); + const startMarker = source.raw.registerMarker(0)!; + await write(source, 'output line 1\r\noutput line 2'); + + const vt = await source.getRangeAsVT(startMarker, undefined, true); + const mirror = await createXterm(); + if (vt) { + await write(mirror, vt); + } + startMarker.dispose(); + + // Mirror should contain just the content from marker onwards + const mirrorText = getBufferText(mirror); + strictEqual(mirrorText.includes('output line 1'), true); + strictEqual(mirrorText.includes('output line 2'), true); + strictEqual(mirrorText.includes('before'), false); + }); + + test('incremental mirroring appends correctly', async () => { + const source = await createXterm(); + const marker = source.raw.registerMarker(0)!; + await write(source, 'initial\r\n'); + + // First mirror with initial content + const vt1 = await source.getRangeAsVT(marker, undefined, true) ?? ''; + const mirror = await createXterm(); + await write(mirror, vt1); + + // Add more content to source + await write(source, 'added\r\n'); + const vt2 = await source.getRangeAsVT(marker, undefined, true) ?? ''; + + // Append only the new part to mirror + const appended = vt2.slice(vt1.length); + if (appended) { + await write(mirror, appended); + } + + // Create a fresh mirror with full VT to compare against + const freshMirror = await createXterm(); + await write(freshMirror, vt2); + + marker.dispose(); + + // Incremental mirror should match fresh mirror + strictEqual(getBufferText(mirror), getBufferText(freshMirror)); + }); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts index 83287031fbf41..8db8fe5cffc78 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts @@ -3,8 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { WebglAddon } from '@xterm/addon-webgl'; -import type { IEvent, Terminal } from '@xterm/xterm'; +import type { Terminal } from '@xterm/xterm'; import { deepStrictEqual, strictEqual } from 'assert'; import { importAMDNodeModule } from '../../../../../../amdX.js'; import { Color, RGBA } from '../../../../../../base/common/color.js'; @@ -22,40 +21,10 @@ import { XtermTerminal } from '../../../browser/xterm/xtermTerminal.js'; import { ITerminalConfiguration, TERMINAL_VIEW_ID } from '../../../common/terminal.js'; import { registerColors, TERMINAL_BACKGROUND_COLOR, TERMINAL_CURSOR_BACKGROUND_COLOR, TERMINAL_CURSOR_FOREGROUND_COLOR, TERMINAL_FOREGROUND_COLOR, TERMINAL_INACTIVE_SELECTION_BACKGROUND_COLOR, TERMINAL_SELECTION_BACKGROUND_COLOR, TERMINAL_SELECTION_FOREGROUND_COLOR } from '../../../common/terminalColorRegistry.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { IXtermAddonNameToCtor, XtermAddonImporter } from '../../../browser/xterm/xtermAddonImporter.js'; +import { TestWebglAddon, TestXtermAddonImporter } from './xtermTestUtils.js'; registerColors(); -class TestWebglAddon implements WebglAddon { - static shouldThrow = false; - static isEnabled = false; - readonly onChangeTextureAtlas = new Emitter().event as IEvent; - readonly onAddTextureAtlasCanvas = new Emitter().event as IEvent; - readonly onRemoveTextureAtlasCanvas = new Emitter().event as IEvent; - readonly onContextLoss = new Emitter().event as IEvent; - constructor(preserveDrawingBuffer?: boolean) { - } - activate() { - TestWebglAddon.isEnabled = !TestWebglAddon.shouldThrow; - if (TestWebglAddon.shouldThrow) { - throw new Error('Test webgl set to throw'); - } - } - dispose() { - TestWebglAddon.isEnabled = false; - } - clearTextureAtlas() { } -} - -class TestXtermAddonImporter extends XtermAddonImporter { - override async importAddon(name: T): Promise { - if (name === 'webgl') { - return TestWebglAddon as unknown as IXtermAddonNameToCtor[T]; - } - return super.importAddon(name); - } -} - export class TestViewDescriptorService implements Partial { private _location = ViewContainerLocation.Panel; private _onDidChangeLocation = new Emitter<{ views: IViewDescriptor[]; from: ViewContainerLocation; to: ViewContainerLocation }>(); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTestUtils.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTestUtils.ts new file mode 100644 index 0000000000000..bff945f742abb --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTestUtils.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { WebglAddon } from '@xterm/addon-webgl'; +import type { IEvent } from '@xterm/xterm'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { XtermAddonImporter, type IXtermAddonNameToCtor } from '../../../browser/xterm/xtermAddonImporter.js'; + +export class TestWebglAddon implements WebglAddon { + static shouldThrow = false; + static isEnabled = false; + private readonly _onChangeTextureAtlas = new Emitter(); + private readonly _onAddTextureAtlasCanvas = new Emitter(); + private readonly _onRemoveTextureAtlasCanvas = new Emitter(); + private readonly _onContextLoss = new Emitter(); + readonly onChangeTextureAtlas = this._onChangeTextureAtlas.event as IEvent; + readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event as IEvent; + readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event as IEvent; + readonly onContextLoss = this._onContextLoss.event as IEvent; + constructor(preserveDrawingBuffer?: boolean) { + } + activate(): void { + TestWebglAddon.isEnabled = !TestWebglAddon.shouldThrow; + if (TestWebglAddon.shouldThrow) { + throw new Error('Test webgl set to throw'); + } + } + dispose(): void { + TestWebglAddon.isEnabled = false; + this._onChangeTextureAtlas.dispose(); + this._onAddTextureAtlasCanvas.dispose(); + this._onRemoveTextureAtlasCanvas.dispose(); + this._onContextLoss.dispose(); + } + clearTextureAtlas(): void { } +} + +export class TestXtermAddonImporter extends XtermAddonImporter { + override async importAddon(name: T): Promise { + if (name === 'webgl') { + return TestWebglAddon as unknown as IXtermAddonNameToCtor[T]; + } + return super.importAddon(name); + } +} + 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 db346101787e2..eb1d8c9a3b848 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 @@ -711,7 +711,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } let part!: ChatElicitationRequestPart; const promise = new Promise(resolve => { - const thePart = part = this._register(new ChatElicitationRequestPart( + const thePart = part = new ChatElicitationRequestPart( title, detail, subtitle, @@ -744,7 +744,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { undefined, // source moreActions, () => this._outputMonitorTelemetryCounters.inputToolManualShownCount++ - )); + ); chatModel.acceptResponseProgress(request, thePart); this._promptPart = thePart; diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts index eb8761b215225..6e0f54ece23fb 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts @@ -600,7 +600,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo if (lastWordFolder.length > 0) { label = addPathRelativePrefix(lastWordFolder + label, resourceOptions, lastWordFolderHasDotPrefix); } - const parentDir = URI.joinPath(cwd, '..' + resourceOptions.pathSeparator); + const parentDir = URI.joinPath(lastWordFolderResource, '..' + resourceOptions.pathSeparator); resourceCompletions.push({ label, provider, diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts b/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts index 23b8845644ce1..aca7ea2fac054 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts @@ -198,6 +198,32 @@ suite('TerminalCompletionService', () => { ], { replacementRange: [1, 3] }); }); + test('../| should return parent folder completions', async () => { + // Scenario: cwd is /parent/folder1, sibling is /parent/folder2 + // When typing ../, should see contents of /parent/ (folder1 and folder2) + validResources = [ + URI.parse('file:///parent/folder1'), + URI.parse('file:///parent'), + ]; + childResources = [ + { resource: URI.parse('file:///parent/folder1/'), isDirectory: true }, + { resource: URI.parse('file:///parent/folder2/'), isDirectory: true }, + ]; + const resourceOptions: TerminalCompletionResourceOptions = { + cwd: URI.parse('file:///parent/folder1'), + showDirectories: true, + pathSeparator + }; + const result = await terminalCompletionService.resolveResources(resourceOptions, '../', 3, provider, capabilities); + + assertCompletions(result, [ + { label: '../', detail: '/parent/' }, + { label: '../folder1/', detail: '/parent/folder1/' }, + { label: '../folder2/', detail: '/parent/folder2/' }, + { label: '../../', detail: '/' }, + ], { replacementRange: [0, 3] }); + }); + test('cd ./| should return folder completions', async () => { const resourceOptions: TerminalCompletionResourceOptions = { cwd: URI.parse('file:///test'), @@ -564,7 +590,8 @@ suite('TerminalCompletionService', () => { assertCompletions(result, [ { label: './test/', detail: '/test/test/' }, { label: './test/inner/', detail: '/test/test/inner/' }, - { label: './test/../', detail: '/' } + // ../` from the viewed folder (/test/test/) goes to /test/, not / + { label: './test/../', detail: '/test/' } ], { replacementRange: [0, 5] }); }); test('test/| should normalize current and parent folders', async () => { diff --git a/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts b/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts index df1a6e3e83bcd..cf7c1be09b43f 100644 --- a/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts +++ b/src/vs/workbench/services/extensionManagement/common/extensionManagementService.ts @@ -1212,10 +1212,10 @@ class WorkspaceExtensionsManagementService extends Disposable { ) { super(); - this._register(Event.debounce(this.fileService.onDidFilesChange, (last, e) => { + this._register(Event.throttle(this.fileService.onDidFilesChange, (last, e) => { (last = last ?? []).push(e); return last; - }, 1000)(events => { + }, 1000, false)(events => { const changedInvalidExtensions = this.extensions.filter(extension => !extension.isValid && events.some(e => e.affects(extension.location))); if (changedInvalidExtensions.length) { this.checkExtensionsValidity(changedInvalidExtensions); diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 9dc1fe4f557ac..5a7f9bd503b28 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -252,7 +252,7 @@ declare module 'vscode' { * Called as soon as you register (call me once) * @param token */ - provideChatSessionProviderOptions?(token: CancellationToken): Thenable | ChatSessionProviderOptions; + provideChatSessionProviderOptions?(token: CancellationToken): Thenable; } export interface ChatSessionOptionUpdate { @@ -337,6 +337,12 @@ declare module 'vscode' { * An icon for the option item shown in UI. */ readonly icon?: ThemeIcon; + + /** + * Indicates if this option should be selected by default. + * Only one item per option group should be marked as default. + */ + readonly default?: boolean; } /** @@ -372,6 +378,26 @@ declare module 'vscode' { * the 'models' option group has 'gpt-4' selected. */ readonly when?: string; + + /** + * When true, displays a searchable QuickPick with a "See more..." option. + * Recommended for option groups with additional async items (e.g., repositories). + */ + readonly searchable?: boolean; + + /** + * An icon for the option group shown in UI. + */ + readonly icon?: ThemeIcon; + + /** + * Handler for dynamic search when `searchable` is true. + * Called when the user clicks "See more..." to load additional items. + * + * @param token A cancellation token. + * @returns Additional items to display in the searchable QuickPick. + */ + readonly onSearch?: (token: CancellationToken) => Thenable; } export interface ChatSessionProviderOptions {