diff --git a/src/vs/workbench/api/browser/mainThreadSearch.ts b/src/vs/workbench/api/browser/mainThreadSearch.ts index 9f7772a2ffd..e8a72e6c59a 100644 --- a/src/vs/workbench/api/browser/mainThreadSearch.ts +++ b/src/vs/workbench/api/browser/mainThreadSearch.ts @@ -14,6 +14,7 @@ import { ExtHostContext, ExtHostSearchShape, MainContext, MainThreadSearchShape import { revive } from '../../../base/common/marshalling.js'; import * as Constants from '../../contrib/search/common/constants.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { AISearchKeyword } from '../../services/search/common/searchExtTypes.js'; @extHostNamedCustomer(MainContext.MainThreadSearch) export class MainThreadSearch implements MainThreadSearchShape { @@ -72,6 +73,16 @@ export class MainThreadSearch implements MainThreadSearchShape { provider.handleFindMatch(session, data); } + + $handleKeywordResult(handle: number, session: number, data: AISearchKeyword): void { + const provider = this._searchProvider.get(handle); + if (!provider) { + throw new Error('Got result for unknown provider'); + } + + provider.handleKeywordResult(session, data); + } + $handleTelemetry(eventName: string, data: any): void { this._telemetryService.publicLog(eventName, data); } @@ -84,7 +95,8 @@ class SearchOperation { constructor( readonly progress?: (match: IFileMatch) => any, readonly id: number = ++SearchOperation._idPool, - readonly matches = new Map() + readonly matches = new Map(), + readonly keywords: AISearchKeyword[] = [] ) { // } @@ -104,6 +116,10 @@ class SearchOperation { this.progress?.(match); } + + addKeyword(result: AISearchKeyword): void { + this.keywords.push(result); + } } class RemoteSearchProvider implements ISearchResultProvider, IDisposable { @@ -153,7 +169,7 @@ class RemoteSearchProvider implements ISearchResultProvider, IDisposable { return Promise.resolve(searchP).then((result: ISearchCompleteStats) => { this._searches.delete(search.id); - return { results: Array.from(search.matches.values()), stats: result.stats, limitHit: result.limitHit, messages: result.messages }; + return { results: Array.from(search.matches.values()), aiKeywords: Array.from(search.keywords), stats: result.stats, limitHit: result.limitHit, messages: result.messages }; }, err => { this._searches.delete(search.id); return Promise.reject(err); @@ -183,7 +199,17 @@ class RemoteSearchProvider implements ISearchResultProvider, IDisposable { }); } - private _provideSearchResults(query: ISearchQuery, session: number, token: CancellationToken): Promise { + handleKeywordResult(session: number, data: AISearchKeyword): void { + const searchOp = this._searches.get(session); + + if (!searchOp) { + // ignore... + return; + } + searchOp.addKeyword(data); + } + + private _provideSearchResults(query: ISearchQuery, session: number, token: CancellationToken, onKeywordResult?: (keyword: AISearchKeyword) => void): Promise { switch (query.type) { case QueryType.File: return this._proxy.$provideFileSearchResults(this._handle, session, query, token); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 4bb1895d0b4..f0141e6eaca 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -27,7 +27,7 @@ import { ExtensionDescriptionRegistry } from '../../services/extensions/common/e import { UIKind } from '../../services/extensions/common/extensionHostProtocol.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; import { ProxyIdentifier } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExcludeSettingOptions, TextSearchCompleteMessageType, TextSearchContext2, TextSearchMatch2 } from '../../services/search/common/searchExtTypes.js'; +import { ExcludeSettingOptions, TextSearchCompleteMessageType, TextSearchContext2, TextSearchMatch2, AISearchKeyword } from '../../services/search/common/searchExtTypes.js'; import { CandidatePortSource, ExtHostContext, ExtHostLogLevelServiceShape, MainContext } from './extHost.protocol.js'; import { ExtHostRelatedInformation } from './extHostAiRelatedInformation.js'; import { ExtHostApiCommands } from './extHostApiCommands.js'; @@ -1826,6 +1826,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ExcludeSettingOptions: ExcludeSettingOptions, TextSearchContext2: TextSearchContext2, TextSearchMatch2: TextSearchMatch2, + AISearchKeyword: AISearchKeyword, TextSearchCompleteMessageTypeNew: TextSearchCompleteMessageType, ChatErrorLevel: extHostTypes.ChatErrorLevel, McpSSEServerDefinition: extHostTypes.McpSSEServerDefinition, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index b0bd01e9f68..d5233cb7444 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -85,7 +85,7 @@ import { OutputChannelUpdateMode } from '../../services/output/common/output.js' import { CandidatePort } from '../../services/remote/common/tunnelModel.js'; import { IFileQueryBuilderOptions, ITextQueryBuilderOptions } from '../../services/search/common/queryBuilder.js'; import * as search from '../../services/search/common/search.js'; -import { TextSearchCompleteMessage } from '../../services/search/common/searchExtTypes.js'; +import { AISearchKeyword, TextSearchCompleteMessage } from '../../services/search/common/searchExtTypes.js'; import { ISaveProfileResult } from '../../services/userDataProfile/common/userDataProfile.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; @@ -1524,6 +1524,7 @@ export interface MainThreadSearchShape extends IDisposable { $unregisterProvider(handle: number): void; $handleFileMatch(handle: number, session: number, data: UriComponents[]): void; $handleTextMatch(handle: number, session: number, data: search.IRawFileMatch2[]): void; + $handleKeywordResult(handle: number, session: number, data: AISearchKeyword): void; $handleTelemetry(eventName: string, data: any): void; } diff --git a/src/vs/workbench/api/common/extHostSearch.ts b/src/vs/workbench/api/common/extHostSearch.ts index fb92adb1fc9..92ba9241bf8 100644 --- a/src/vs/workbench/api/common/extHostSearch.ts +++ b/src/vs/workbench/api/common/extHostSearch.ts @@ -176,7 +176,7 @@ export class ExtHostSearch implements IExtHostSearch { const query = reviveQuery(rawQuery); const engine = this.createAITextSearchManager(query, provider); - return engine.search(progress => this._proxy.$handleTextMatch(handle, session, progress), token); + return engine.search(progress => this._proxy.$handleTextMatch(handle, session, progress), token, result => this._proxy.$handleKeywordResult(handle, session, result)); } $enableExtensionHostSearch(): void { } diff --git a/src/vs/workbench/api/test/node/extHostSearch.test.ts b/src/vs/workbench/api/test/node/extHostSearch.test.ts index 94380c10ec4..4abd86979a5 100644 --- a/src/vs/workbench/api/test/node/extHostSearch.test.ts +++ b/src/vs/workbench/api/test/node/extHostSearch.test.ts @@ -26,6 +26,7 @@ import { IFileMatch, IFileQuery, IPatternInfo, IRawFileMatch2, ISearchCompleteSt import { TextSearchManager } from '../../../services/search/common/textSearchManager.js'; import { NativeTextSearchManager } from '../../../services/search/node/textSearchManager.js'; import type * as vscode from 'vscode'; +import { AISearchKeyword } from '../../../services/search/common/searchExtTypes.js'; let rpcProtocol: TestRPCProtocol; let extHostSearch: NativeExtHostSearch; @@ -36,6 +37,8 @@ class MockMainThreadSearch implements MainThreadSearchShape { results: Array = []; + keywords: Array = []; + $registerFileSearchProvider(handle: number, scheme: string): void { this.lastHandle = handle; } @@ -59,6 +62,10 @@ class MockMainThreadSearch implements MainThreadSearchShape { this.results.push(...data); } + $handleKeywordResult(handle: number, session: number, data: AISearchKeyword): void { + this.keywords.push(data); + } + $handleTelemetry(eventName: string, data: any): void { } diff --git a/src/vs/workbench/contrib/search/browser/media/searchview.css b/src/vs/workbench/contrib/search/browser/media/searchview.css index ddab0a2e10b..deccc655fb0 100644 --- a/src/vs/workbench/contrib/search/browser/media/searchview.css +++ b/src/vs/workbench/contrib/search/browser/media/searchview.css @@ -186,6 +186,18 @@ color: var(--vscode-textLink-activeForeground); } +.search-view .message .keyword-refresh { + vertical-align: sub; + margin-right: 4px; + cursor: pointer; +} + +.search-view .message .keyword-refresh:hover, +.search-view .message .keyword-refresh:active { + color: var(--vscode-textLink-activeForeground); +} + + .search-view .foldermatch, .search-view .filematch { display: flex; diff --git a/src/vs/workbench/contrib/search/browser/searchView.ts b/src/vs/workbench/contrib/search/browser/searchView.ts index 2a7f4d3c326..1a2c086bf1b 100644 --- a/src/vs/workbench/contrib/search/browser/searchView.ts +++ b/src/vs/workbench/contrib/search/browser/searchView.ts @@ -72,7 +72,7 @@ import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/edit import { IPreferencesService, ISettingsEditorOptions } from '../../../services/preferences/common/preferences.js'; import { ITextQueryBuilderOptions, QueryBuilder } from '../../../services/search/common/queryBuilder.js'; import { IPatternInfo, ISearchComplete, ISearchConfiguration, ISearchConfigurationProperties, ISearchService, ITextQuery, SearchCompletionExitCode, SearchSortOrder, TextSearchCompleteMessageType, ViewMode } from '../../../services/search/common/search.js'; -import { TextSearchCompleteMessage } from '../../../services/search/common/searchExtTypes.js'; +import { AISearchKeyword, TextSearchCompleteMessage } from '../../../services/search/common/searchExtTypes.js'; import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; import { INotebookService } from '../../notebook/common/notebookService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -1820,7 +1820,11 @@ export class SearchView extends ViewPane { const result = this.viewModel.addAIResults(); return result.then((complete) => { clearTimeout(slowTimer); - this.updateSearchResultCount(this.viewModel.searchResult.query?.userDisabledExcludesAndIgnoreFiles, this.viewModel.searchResult.query?.onlyOpenEditors, false); + if (complete.aiKeywords && complete.aiKeywords.length > 0) { + this.updateKeywordSuggestion(complete.aiKeywords); + } else { + this.updateSearchResultCount(this.viewModel.searchResult.query?.userDisabledExcludesAndIgnoreFiles, this.viewModel.searchResult.query?.onlyOpenEditors, false); + } return this.onSearchComplete(progressComplete, excludePatternText, includePatternText, complete, false); }, (e) => { clearTimeout(slowTimer); @@ -1948,6 +1952,49 @@ export class SearchView extends ViewPane { } } + private handleKeywordClick(keyword: string) { + this.searchWidget.searchInput?.setValue(keyword); + this.triggerQueryChange({ preserveFocus: false, triggeredOnType: false, shouldKeepAIResults: false }); + } + + private updateKeywordSuggestion(keywords: AISearchKeyword[]) { + let currentKeyword = keywords.shift()?.keyword || ''; + const messageEl = this.clearMessage(); + + // Refresh icon + const icon = dom.append(messageEl, dom.$('')); + icon.ariaLabel = nls.localize('search.refresh', "Get new suggestion"); + icon.role = 'button'; + icon.tabIndex = 0; + icon.classList.add('codicon', 'codicon-refresh', 'keyword-refresh'); + icon.onclick = () => { + // change the keyword to the next one + const nextKeyword = keywords.shift(); + if (nextKeyword) { + currentKeyword = nextKeyword.keyword; + textButton.element.textContent = currentKeyword; + } + if (keywords.length === 1) { + icon.remove(); + } + }; + + // Unclickable message + const resultMsg = nls.localize('keywordSuggestion.message', "Search instead for: "); + this.tree.ariaLabel = resultMsg + nls.localize('aiSearchForTerm', " - Search: {0}", currentKeyword); + dom.append(messageEl, resultMsg); + + const textButton = this.messageDisposables.add(new SearchLinkButton( + currentKeyword, + () => this.handleKeywordClick(currentKeyword), + this.hoverService, + )); + + dom.append(messageEl, textButton.element); + + + } + private addMessage(message: TextSearchCompleteMessage) { const messageBox = this.messagesElement.firstChild as HTMLDivElement; if (!messageBox) { return; } diff --git a/src/vs/workbench/services/search/common/search.ts b/src/vs/workbench/services/search/common/search.ts index 88619390030..923021734e0 100644 --- a/src/vs/workbench/services/search/common/search.ts +++ b/src/vs/workbench/services/search/common/search.ts @@ -17,7 +17,7 @@ import { ITelemetryData } from '../../../../platform/telemetry/common/telemetry. import { Event } from '../../../../base/common/event.js'; import * as paths from '../../../../base/common/path.js'; import { isCancellationError } from '../../../../base/common/errors.js'; -import { GlobPattern, TextSearchCompleteMessageType } from './searchExtTypes.js'; +import { AISearchKeyword, GlobPattern, TextSearchCompleteMessageType } from './searchExtTypes.js'; import { isThenable } from '../../../../base/common/async.js'; import { ResourceSet } from '../../../../base/common/map.js'; @@ -263,6 +263,7 @@ export interface ISearchCompleteStats { export interface ISearchComplete extends ISearchCompleteStats { results: IFileMatch[]; exit?: SearchCompletionExitCode; + aiKeywords?: AISearchKeyword[]; } export const enum SearchCompletionExitCode { diff --git a/src/vs/workbench/services/search/common/searchExtTypes.ts b/src/vs/workbench/services/search/common/searchExtTypes.ts index decb405a400..595b0014095 100644 --- a/src/vs/workbench/services/search/common/searchExtTypes.ts +++ b/src/vs/workbench/services/search/common/searchExtTypes.ts @@ -315,11 +315,28 @@ export class TextSearchContext2 { public lineNumber: number) { } } +/** +/** + * Keyword suggestion for AI search. + */ +export class AISearchKeyword { + /** + * @param keyword The keyword associated with the search. + */ + constructor(public keyword: string) { } +} + /** * A result payload for a text search, pertaining to matches within a single file. */ export type TextSearchResult2 = TextSearchMatch2 | TextSearchContext2; +/** + * A result payload for an AI search. + * This can be a {@link TextSearchMatch2 match} or a {@link AISearchKeyword keyword}. + * The result can be a match or a keyword. +*/ +export type AISearchResult = TextSearchResult2 | AISearchKeyword; /** * A FileSearchProvider provides search results for files in the given folder that match a query string. It can be invoked by quickaccess or other extensions. diff --git a/src/vs/workbench/services/search/common/searchService.ts b/src/vs/workbench/services/search/common/searchService.ts index b0981b9891c..f52bcd99ed2 100644 --- a/src/vs/workbench/services/search/common/searchService.ts +++ b/src/vs/workbench/services/search/common/searchService.ts @@ -213,7 +213,8 @@ export class SearchService extends Disposable implements ISearchService { limitHit: completes[0] && completes[0].limitHit, stats: completes[0].stats, messages: arrays.coalesce(completes.flatMap(i => i.messages)).filter(arrays.uniqueFilter(message => message.type + message.text + message.trusted)), - results: completes.flatMap((c: ISearchComplete) => c.results) + results: completes.flatMap((c: ISearchComplete) => c.results), + aiKeywords: completes.flatMap((c: ISearchComplete) => c.aiKeywords).filter(keyword => keyword !== undefined), }; })(); diff --git a/src/vs/workbench/services/search/common/textSearchManager.ts b/src/vs/workbench/services/search/common/textSearchManager.ts index 59a10ed9024..92571aef883 100644 --- a/src/vs/workbench/services/search/common/textSearchManager.ts +++ b/src/vs/workbench/services/search/common/textSearchManager.ts @@ -12,7 +12,7 @@ import * as resources from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { FolderQuerySearchTree } from './folderQuerySearchTree.js'; import { DEFAULT_MAX_SEARCH_RESULTS, hasSiblingPromiseFn, IAITextQuery, IExtendedExtensionSearchOptions, IFileMatch, IFolderQuery, excludeToGlobPattern, IPatternInfo, ISearchCompleteStats, ITextQuery, ITextSearchContext, ITextSearchMatch, ITextSearchResult, ITextSearchStats, QueryGlobTester, QueryType, resolvePatternsForProvider, ISearchRange, DEFAULT_TEXT_SEARCH_PREVIEW_OPTIONS } from './search.js'; -import { TextSearchComplete2, TextSearchMatch2, TextSearchProviderFolderOptions, TextSearchProvider2, TextSearchProviderOptions, TextSearchQuery2, TextSearchResult2, AITextSearchProvider } from './searchExtTypes.js'; +import { TextSearchComplete2, TextSearchMatch2, TextSearchProviderFolderOptions, TextSearchProvider2, TextSearchProviderOptions, TextSearchQuery2, TextSearchResult2, AITextSearchProvider, AISearchResult, AISearchKeyword } from './searchExtTypes.js'; export interface IFileUtils { readdir: (resource: URI) => Promise; @@ -46,7 +46,7 @@ export class TextSearchManager { return this.queryProviderPair.query; } - search(onProgress: (matches: IFileMatch[]) => void, token: CancellationToken): Promise { + search(onProgress: (matches: IFileMatch[]) => void, token: CancellationToken, onKeywordResult?: (keyword: AISearchKeyword) => void): Promise { const folderQueries = this.query.folderQueries || []; const tokenSource = new CancellationTokenSource(token); @@ -55,6 +55,10 @@ export class TextSearchManager { let isCanceled = false; const onResult = (result: TextSearchResult2, folderIdx: number) => { + if (result instanceof AISearchKeyword) { + // Already processed by the callback. + return; + } if (isCanceled) { return; } @@ -80,7 +84,7 @@ export class TextSearchManager { }; // For each root folder - this.doSearch(folderQueries, onResult, tokenSource.token).then(result => { + this.doSearch(folderQueries, onResult, tokenSource.token, onKeywordResult).then(result => { tokenSource.dispose(); this.collector!.flush(); @@ -121,7 +125,7 @@ export class TextSearchManager { return new TextSearchMatch2(result.uri, result.ranges.slice(0, size), result.previewText); } - private async doSearch(folderQueries: IFolderQuery[], onResult: (result: TextSearchResult2, folderIdx: number) => void, token: CancellationToken): Promise { + private async doSearch(folderQueries: IFolderQuery[], onResult: (result: TextSearchResult2, folderIdx: number) => void, token: CancellationToken, onKeywordResult?: (keyword: AISearchKeyword) => void): Promise { const folderMappings: FolderQuerySearchTree = new FolderQuerySearchTree( folderQueries, (fq, i) => { @@ -133,31 +137,34 @@ export class TextSearchManager { const testingPs: Promise[] = []; const progress = { - report: (result: TextSearchResult2) => { + report: (result: TextSearchResult2 | AISearchResult) => { + if (result instanceof AISearchKeyword) { + onKeywordResult?.(result); + } else { + if (result.uri === undefined) { + throw Error('Text search result URI is undefined. Please check provider implementation.'); + } + const folderQuery = folderMappings.findQueryFragmentAwareSubstr(result.uri)!; + const hasSibling = folderQuery.folder.scheme === Schemas.file ? + hasSiblingPromiseFn(() => { + return this.fileUtils.readdir(resources.dirname(result.uri)); + }) : + undefined; - if (result.uri === undefined) { - throw Error('Text search result URI is undefined. Please check provider implementation.'); - } - const folderQuery = folderMappings.findQueryFragmentAwareSubstr(result.uri)!; - const hasSibling = folderQuery.folder.scheme === Schemas.file ? - hasSiblingPromiseFn(() => { - return this.fileUtils.readdir(resources.dirname(result.uri)); - }) : - undefined; - - const relativePath = resources.relativePath(folderQuery.folder, result.uri); - if (relativePath) { - // This method is only async when the exclude contains sibling clauses - const included = folderQuery.queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling); - if (isThenable(included)) { - testingPs.push( - included.then(isIncluded => { - if (isIncluded) { - onResult(result, folderQuery.folderIdx); - } - })); - } else if (included) { - onResult(result, folderQuery.folderIdx); + const relativePath = resources.relativePath(folderQuery.folder, result.uri); + if (relativePath) { + // This method is only async when the exclude contains sibling clauses + const included = folderQuery.queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling); + if (isThenable(included)) { + testingPs.push( + included.then(isIncluded => { + if (isIncluded) { + onResult(result, folderQuery.folderIdx); + } + })); + } else if (included) { + onResult(result, folderQuery.folderIdx); + } } } } diff --git a/src/vscode-dts/vscode.proposed.textSearchProvider2.d.ts b/src/vscode-dts/vscode.proposed.textSearchProvider2.d.ts index 146b76b5fa5..2bcb81299b8 100644 --- a/src/vscode-dts/vscode.proposed.textSearchProvider2.d.ts +++ b/src/vscode-dts/vscode.proposed.textSearchProvider2.d.ts @@ -237,12 +237,34 @@ declare module 'vscode' { lineNumber: number; } + /** + * Keyword suggestion for AI search. + */ + export class AISearchKeyword { + /** + * @param keyword The keyword associated with the search. + */ + constructor(keyword: string); + + /** + * The keyword associated with the search. + */ + keyword: string; + } + /** * A result payload for a text search, pertaining to {@link TextSearchMatch2 matches} * and its associated {@link TextSearchContext2 context} within a single file. */ export type TextSearchResult2 = TextSearchMatch2 | TextSearchContext2; + /** + * A result payload for an AI search. + * This can be a {@link TextSearchMatch2 match} or a {@link AISearchKeyword keyword}. + * The result can be a match or a keyword. + */ + export type AISearchResult = TextSearchResult2 | AISearchKeyword; + /** * A TextSearchProvider provides search results for text results inside files in the workspace. */ @@ -255,7 +277,7 @@ declare module 'vscode' { * These results can be direct matches, or context that surrounds matches. * @param token A cancellation token. */ - provideTextSearchResults(query: TextSearchQuery2, options: TextSearchProviderOptions, progress: Progress, token: CancellationToken): ProviderResult; + provideTextSearchResults(query: TextSearchQuery2, options: TextSearchProviderOptions, progress: Progress, token: CancellationToken): ProviderResult; } export namespace workspace {