Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion extensions/copilot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,38 @@
]
}
},
{
"name": "copilot_githubTextSearch",
"legacyToolReferenceFullNames": [
"githubTextSearch"
],
"toolReferenceName": "githubTextSearch",
"displayName": "%github.copilot.tools.githubTextSearch.name%",
"modelDescription": "Lexically searches a GitHub repository or organization for files containing specific keywords or code patterns. Use this when looking for exact strings, function names, or identifiers in a GitHub repo or org. Unlike the semantic search tool, this uses keyword matching rather than meaning-based search.",
"userDescription": "%github.copilot.tools.githubTextSearch.userDescription%",
"icon": "$(search)",
"inputSchema": {
"type": "object",
"properties": {
"scope": {
"type": "string",
"description": "The GitHub scope to search. Use 'owner/repo' to search a single repository, or an org name (no slash) to search across an entire organization."
},
"query": {
"type": "string",
"description": "The keyword search query. Supports GitHub code search syntax such as 'language:typescript', 'extension:ts', 'path:src/', etc."
},
"maxResults": {
"type": "number",
"description": "Optional. The maximum number of search results to return. Defaults to 100."
}
},
"required": [
"scope",
"query"
]
}
},
{
"name": "copilot_switchAgent",
"toolReferenceName": "switchAgent",
Expand Down Expand Up @@ -1271,7 +1303,8 @@
"icon": "$(globe)",
"tools": [
"fetch",
"githubRepo"
"githubRepo",
"githubTextSearch"
]
}
],
Expand Down
6 changes: 4 additions & 2 deletions extensions/copilot/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,10 @@
"github.copilot.tools.searchResults.name": "Search View Results",
"github.copilot.tools.searchResults.description": "Get the results of the search view",
"github.copilot.config.getSearchViewResultsSkill.enabled": "Enable the Search View Results skill and disable the corresponding tool.",
"github.copilot.tools.githubRepo.name": "Search GitHub Repository",
"github.copilot.tools.githubRepo.userDescription": "Search a GitHub repository for relevant source code snippets. You can specify a repository using `owner/repo`",
"github.copilot.tools.githubRepo.name": "Semantic Search GitHub Repository",
"github.copilot.tools.githubRepo.userDescription": "Semantic Search a GitHub repository for relevant source code snippets. You can specify a repository using `owner/repo`",
Comment on lines +277 to +278
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User-facing text: “Semantic Search a GitHub repository …” is grammatically incorrect. Consider changing it to “Semantically search a GitHub repository …” (same applies to the userDescription).

Suggested change
"github.copilot.tools.githubRepo.name": "Semantic Search GitHub Repository",
"github.copilot.tools.githubRepo.userDescription": "Semantic Search a GitHub repository for relevant source code snippets. You can specify a repository using `owner/repo`",
"github.copilot.tools.githubRepo.name": "Semantically Search GitHub Repository",
"github.copilot.tools.githubRepo.userDescription": "Semantically search a GitHub repository for relevant source code snippets. You can specify a repository using `owner/repo`",

Copilot uses AI. Check for mistakes.
"github.copilot.tools.githubTextSearch.name": "GitHub Text Search",
"github.copilot.tools.githubTextSearch.userDescription": "Text search a GitHub repository or organization for files containing specific keywords or code patterns.",
"github.copilot.config.autoFix": "Automatically fix diagnostics for edited files.",
"github.copilot.config.rateLimitAutoSwitchToAuto": "Automatically switch to the Auto model and retry when you hit a per-model rate limit.",
"github.copilot.tools.createNewWorkspace.userDescription": "Scaffold a new workspace in VS Code",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ get_project_setup_info
get_search_view_results
get_vscode_api
github_repo
github_text_search
install_extension
read_notebook_cell_output
read_project_structure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ get_project_setup_info
get_search_view_results
get_vscode_api
github_repo
github_text_search
install_extension
read_notebook_cell_output
read_project_structure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ get_project_setup_info
get_search_view_results
get_vscode_api
github_repo
github_text_search
install_extension
read_notebook_cell_output
read_project_structure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ get_project_setup_info
get_search_view_results
get_vscode_api
github_repo
github_text_search
install_extension
read_notebook_cell_output
read_project_structure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ get_project_setup_info
get_search_view_results
get_vscode_api
github_repo
github_text_search
install_extension
read_notebook_cell_output
read_project_structure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ get_project_setup_info
get_search_view_results
get_vscode_api
github_repo
github_text_search
install_extension
read_notebook_cell_output
read_project_structure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ get_project_setup_info
get_search_view_results
get_vscode_api
github_repo
github_text_search
install_extension
read_notebook_cell_output
read_project_structure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ get_project_setup_info
get_search_view_results
get_vscode_api
github_repo
github_text_search
install_extension
read_notebook_cell_output
read_project_structure
Expand Down
9 changes: 6 additions & 3 deletions extensions/copilot/src/extension/tools/common/toolNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export enum ToolName {
FindTestFiles = 'test_search',
GetProjectSetupInfo = 'get_project_setup_info',
SearchViewResults = 'get_search_view_results',
GithubRepo = 'github_repo',
GithubSemanticRepoSearch = 'github_repo',
GithubTextSearch = 'github_text_search',
CreateDirectory = 'create_directory',
RunVscodeCmd = 'run_vscode_command',
CoreManageTodoList = 'manage_todo_list',
Expand Down Expand Up @@ -132,7 +133,8 @@ export enum ContributedToolName {
FindTestFiles = 'copilot_findTestFiles',
GetProjectSetupInfo = 'copilot_getProjectSetupInfo',
SearchViewResults = 'copilot_getSearchResults',
GithubRepo = 'copilot_githubRepo',
GithubSemanticRepoSearch = 'copilot_githubRepo',
GithubTextSearch = 'copilot_githubTextSearch',
CreateAndRunTask = 'copilot_createAndRunTask',
CreateDirectory = 'copilot_createDirectory',
RunVscodeCmd = 'copilot_runVscodeCommand',
Expand Down Expand Up @@ -223,7 +225,8 @@ export const toolCategories: Record<ToolName, ToolCategory> = {

// Web Interaction
[ToolName.FetchWebPage]: ToolCategory.WebInteraction,
[ToolName.GithubRepo]: ToolCategory.WebInteraction,
[ToolName.GithubSemanticRepoSearch]: ToolCategory.WebInteraction,
[ToolName.GithubTextSearch]: ToolCategory.WebInteraction,
[ToolName.CoreOpenBrowserPage]: ToolCategory.WebInteraction,
[ToolName.CoreClickElement]: ToolCategory.WebInteraction,
[ToolName.CoreScreenshotPage]: ToolCategory.WebInteraction,
Expand Down
3 changes: 2 additions & 1 deletion extensions/copilot/src/extension/tools/node/allTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import './findTextInFilesTool';
import './getErrorsTool';
import './getNotebookCellOutputTool';
import './getSearchViewResultsTool';
import './githubRepoTool';
import './githubRepoSemanticSearchTool.tsx';
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import './githubRepoSemanticSearchTool.tsx' will fail to compile under the extension's tsconfig because allowImportingTsExtensions is not enabled (TypeScript disallows importing .ts/.tsx extensions by default). Drop the explicit .tsx extension and import the module the same way as the other tool imports in this file.

Suggested change
import './githubRepoSemanticSearchTool.tsx';
import './githubRepoSemanticSearchTool';

Copilot uses AI. Check for mistakes.
import './githubTextSearchTool';
import './insertEditTool';
import './installExtensionTool';
import './listDirTool';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ interface PrepareError {
readonly details?: string;
}

export class GithubRepoTool implements ICopilotTool<GithubRepoToolParams> {
public static readonly toolName = ToolName.GithubRepo;

export class GithubRepoSemanticSearchTool implements ICopilotTool<GithubRepoToolParams> {
public static readonly toolName = ToolName.GithubSemanticRepoSearch;

constructor(
@IRunCommandExecutionService _commandService: IRunCommandExecutionService,
Expand All @@ -61,14 +60,15 @@ export class GithubRepoTool implements ICopilotTool<GithubRepoToolParams> {
throw new Error('No embedding models available');
}

const searchResults = await this._githubCodeSearch.searchRepo({ silent: true }, embeddingType, { githubRepoId, localRepoRoot: undefined, indexedCommit: undefined }, options.input.query, 64, {}, new TelemetryCorrelationId('github-repo-tool'), token);
const searchResults = await this._githubCodeSearch.semanticSearch({ silent: true }, embeddingType, { kind: 'repo', githubRepoId, localRepoRoot: undefined, indexedCommit: undefined }, options.input.query, 64, {}, new TelemetryCorrelationId('github-repo-tool'), token);

// Map the chunks to URIs
// TODO: Won't work for proxima or branches not called main
// Map the chunks to URIs using the remote URL and ref from the search response
const repoBaseUrl = searchResults.remoteUrl ?? `https://github.com/${toGithubNwo(githubRepoId)}`;
const ref = searchResults.refName ?? 'main';
const chunks = searchResults.chunks.map((entry): FileChunkAndScore => ({
chunk: {
...entry.chunk,
file: URI.joinPath(URI.parse('https://github.com'), toGithubNwo(githubRepoId), 'tree', 'main', entry.chunk.file.path).with({
file: URI.joinPath(URI.parse(repoBaseUrl), 'tree', ref, entry.chunk.file.path).with({
fragment: `L${entry.chunk.range.startLineNumber}-L${entry.chunk.range.endLineNumber}`,
}),
},
Expand Down Expand Up @@ -229,4 +229,4 @@ class GithubChunkSearchResults extends PromptElement<GithubChunkSearchResultsPro
}


ToolRegistry.registerTool(GithubRepoTool);
ToolRegistry.registerTool(GithubRepoSemanticSearchTool);
183 changes: 183 additions & 0 deletions extensions/copilot/src/extension/tools/node/githubTextSearchTool.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as l10n from '@vscode/l10n';
import { BasePromptElementProps, PromptElement, PromptPiece, PromptReference, PromptSizing, TextChunk } from '@vscode/prompt-tsx';
import type * as vscode from 'vscode';
import { FileChunkAndScore } from '../../../platform/chunking/common/chunk';
import { GithubRepoId } from '../../../platform/git/common/gitService';
import { GithubCodeSearchScope, IGithubCodeSearchService } from '../../../platform/remoteCodeSearch/common/githubCodeSearchService';
import { createFencedCodeBlock, getLanguageId } from '../../../util/common/markdown';
import { TelemetryCorrelationId } from '../../../util/common/telemetryCorrelationId';
import { isLocation, isUri } from '../../../util/common/types';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
import { URI } from '../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { ExtendedLanguageModelToolResult, LanguageModelPromptTsxPart, MarkdownString } from '../../../vscodeTypes';
import { getUniqueReferences } from '../../prompt/common/conversation';
import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer';
import { ToolName } from '../common/toolNames';
import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry';

export interface GithubTextSearchToolParams {
readonly query: string;
/** Either 'owner/repo' for a single repo, or an org name (no slash) */
readonly scope: string;
readonly maxResults?: number;
}

export class GithubTextSearchTool implements ICopilotTool<GithubTextSearchToolParams> {
public static readonly toolName = ToolName.GithubTextSearch;

constructor(
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IGithubCodeSearchService private readonly _githubCodeSearch: IGithubCodeSearchService,
) { }

async invoke(options: vscode.LanguageModelToolInvocationOptions<GithubTextSearchToolParams>, token: CancellationToken): Promise<vscode.LanguageModelToolResult> {
const scope = parseScope(options.input.scope);
if (!scope) {
throw new Error(l10n.t`Invalid input. Could not parse 'scope' argument`);
}

const maxResults = options.input.maxResults ?? 100;

const searchResults = await this._githubCodeSearch.lexicalSearch(
{ silent: true },
scope,
options.input.query,
maxResults,
{},
new TelemetryCorrelationId('github-text-search-tool'),
token,
);

const chunks = searchResults.chunks.map((entry): FileChunkAndScore => {
let file = entry.file;
if (file.scheme === 'githubRepoResult') {
// Path format: /owner/repo/relative/file/path
const parts = file.path.split('/').filter(Boolean);
if (parts.length >= 3) {
const nwo = `${parts[0]}/${parts[1]}`;
const relativePath = parts.slice(2).join('/');
file = URI.joinPath(URI.parse('https://github.com'), nwo, 'tree', 'main', '/' + relativePath).with({
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URI.joinPath uses path.posix.join, so passing a segment that starts with / resets the path. Using '/' + relativePath here will drop the owner/repo/tree/main segments and produce an incorrect GitHub URL. Pass relativePath without a leading slash (or split it into path segments) when calling URI.joinPath.

Suggested change
file = URI.joinPath(URI.parse('https://github.com'), nwo, 'tree', 'main', '/' + relativePath).with({
file = URI.joinPath(URI.parse('https://github.com'), nwo, 'tree', 'main', relativePath).with({

Copilot uses AI. Check for mistakes.
fragment: entry.range.startLineNumber > 0
? `L${entry.range.startLineNumber}-L${entry.range.endLineNumber}`
: undefined,
});
Comment on lines +64 to +69
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tool hard-codes the branch name to main when building GitHub URLs. That will generate broken links for repos whose default branch is not main (common for master, release branches, or protected default branches). Consider using the html_url returned by GitHub's search API (or fetching the repo default branch once) instead of hardcoding main.

Copilot uses AI. Check for mistakes.
}
}
return { chunk: { ...entry, file }, distance: undefined };
});

let references: PromptReference[] = [];
const json = await renderPromptElementJSON(this._instantiationService, GithubTextSearchResults, {
chunks,
referencesOut: references,
});
const result = new ExtendedLanguageModelToolResult([
new LanguageModelPromptTsxPart(json),
]);

references = getUniqueReferences(references);
const scopeLabel = options.input.scope;
result.toolResultMessage = references.length === 0 ?
new MarkdownString(l10n.t`Searched ${scopeLabel} for "${options.input.query}", no results`) :
references.length === 1 ?
new MarkdownString(l10n.t`Searched ${scopeLabel} for "${options.input.query}", 1 result`) :
new MarkdownString(l10n.t`Searched ${scopeLabel} for "${options.input.query}", ${references.length} results`);
result.toolResultDetails = references
.map(r => r.anchor)
.filter(r => isUri(r) || isLocation(r));
return result;
}

async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions<GithubTextSearchToolParams>, _token: vscode.CancellationToken): Promise<vscode.PreparedToolInvocation> {
if (!options.input.scope) {
throw new Error(l10n.t`Invalid input. No 'scope' argument provided`);
}
if (!parseScope(options.input.scope)) {
throw new Error(l10n.t`Invalid input. Could not parse 'scope' argument`);
}
return {
invocationMessage: l10n.t("Searching '{0}' for '{1}'", options.input.scope, options.input.query),
};
}
}

function parseScope(scope: string): GithubCodeSearchScope | undefined {
if (!scope) {
return undefined;
}
if (scope.includes('/')) {
const repoId = GithubRepoId.parse(scope);
if (!repoId) {
return undefined;
}
return { kind: 'repo', githubRepoId: repoId, localRepoRoot: undefined, indexedCommit: undefined };
}

return { kind: 'org', org: scope };
}

interface GithubTextSearchResultsProps extends BasePromptElementProps {
readonly chunks: FileChunkAndScore[];
readonly referencesOut: PromptReference[];
}

class GithubTextSearchResults extends PromptElement<GithubTextSearchResultsProps> {
override render(_state: void, _sizing: PromptSizing): PromptPiece | undefined {
const references: PromptReference[] = [];
const seenFiles = new Set<string>();

const renderedChunks = this.props.chunks
.filter(x => x.chunk.text)
.map(chunk => {
const fileKey = chunk.chunk.file.toString();
if (!seenFiles.has(fileKey)) {
seenFiles.add(fileKey);
references.push(new PromptReference(chunk.chunk.file));
}

const githubInfo = parseGithubFileUrl(chunk.chunk.file);
const displayPath = githubInfo?.path ?? chunk.chunk.file.toString();
const nwoLabel = githubInfo?.nwo;

const lineInfo = ` starting at line ${chunk.chunk.range.startLineNumber}`;

const headerText = nwoLabel
? `Text match excerpt from \`${nwoLabel}\` in \`${displayPath}\`${lineInfo}:`
: `Text match excerpt in \`${displayPath}\`${lineInfo}:`;

return <TextChunk>
{headerText}<br />
{createFencedCodeBlock(getLanguageId(chunk.chunk.file), chunk.chunk.text)}<br /><br />
Comment on lines +147 to +156
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lineInfo is always rendered as starting at line ${chunk.chunk.range.startLineNumber}. For lexical search results the range is currently new Range(0, 0, 0, 0), which will display “starting at line 0” (and also prevents fragments from being added). Consider omitting the line info (and fragment) when the range is unknown/0 so the output is not misleading.

Suggested change
const lineInfo = ` starting at line ${chunk.chunk.range.startLineNumber}`;
const headerText = nwoLabel
? `Text match excerpt from \`${nwoLabel}\` in \`${displayPath}\`${lineInfo}:`
: `Text match excerpt in \`${displayPath}\`${lineInfo}:`;
return <TextChunk>
{headerText}<br />
{createFencedCodeBlock(getLanguageId(chunk.chunk.file), chunk.chunk.text)}<br /><br />
const hasKnownRange = chunk.chunk.range.startLineNumber > 0;
const lineInfo = hasKnownRange ? ` starting at line ${chunk.chunk.range.startLineNumber}` : '';
const headerText = nwoLabel
? hasKnownRange
? `Text match excerpt from \`${nwoLabel}\` in \`${displayPath}\`${lineInfo}:`
: `Text match from \`${nwoLabel}\` in \`${displayPath}\`:`
: hasKnownRange
? `Text match excerpt in \`${displayPath}\`${lineInfo}:`
: `Text match in \`${displayPath}\`:`;
return <TextChunk>
{headerText}<br />
{hasKnownRange ? <>{createFencedCodeBlock(getLanguageId(chunk.chunk.file), chunk.chunk.text)}<br /></> : undefined}
<br />

Copilot uses AI. Check for mistakes.
</TextChunk>;
});

Comment on lines +135 to +159
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

references (and therefore toolResultMessage) is derived only from chunks with non-empty chunk.text because of .filter(x => x.chunk.text). But parseLexicalSearchResponse can produce isFullFile: true entries with text: '' when the API omits text_matches, which would make the tool report “no results” even though files matched. Consider rendering (or at least referencing) those file-only matches so the tool result accurately reflects hits.

Suggested change
const renderedChunks = this.props.chunks
.filter(x => x.chunk.text)
.map(chunk => {
const fileKey = chunk.chunk.file.toString();
if (!seenFiles.has(fileKey)) {
seenFiles.add(fileKey);
references.push(new PromptReference(chunk.chunk.file));
}
const githubInfo = parseGithubFileUrl(chunk.chunk.file);
const displayPath = githubInfo?.path ?? chunk.chunk.file.toString();
const nwoLabel = githubInfo?.nwo;
const lineInfo = ` starting at line ${chunk.chunk.range.startLineNumber}`;
const headerText = nwoLabel
? `Text match excerpt from \`${nwoLabel}\` in \`${displayPath}\`${lineInfo}:`
: `Text match excerpt in \`${displayPath}\`${lineInfo}:`;
return <TextChunk>
{headerText}<br />
{createFencedCodeBlock(getLanguageId(chunk.chunk.file), chunk.chunk.text)}<br /><br />
</TextChunk>;
});
const renderedChunks = this.props.chunks.map(chunk => {
const fileKey = chunk.chunk.file.toString();
if (!seenFiles.has(fileKey)) {
seenFiles.add(fileKey);
references.push(new PromptReference(chunk.chunk.file));
}
const githubInfo = parseGithubFileUrl(chunk.chunk.file);
const displayPath = githubInfo?.path ?? chunk.chunk.file.toString();
const nwoLabel = githubInfo?.nwo;
if (!chunk.chunk.text) {
if (!chunk.chunk.isFullFile) {
return undefined;
}
const headerText = nwoLabel
? l10n.t("Match found in `{0}` in `{1}`. GitHub did not provide a text excerpt.", nwoLabel, displayPath)
: l10n.t("Match found in `{0}`. GitHub did not provide a text excerpt.", displayPath);
return <TextChunk>
{headerText}<br /><br />
</TextChunk>;
}
const lineInfo = ` starting at line ${chunk.chunk.range.startLineNumber}`;
const headerText = nwoLabel
? `Text match excerpt from \`${nwoLabel}\` in \`${displayPath}\`${lineInfo}:`
: `Text match excerpt in \`${displayPath}\`${lineInfo}:`;
return <TextChunk>
{headerText}<br />
{createFencedCodeBlock(getLanguageId(chunk.chunk.file), chunk.chunk.text)}<br /><br />
</TextChunk>;
});

Copilot uses AI. Check for mistakes.
this.props.referencesOut.push(...references);

return <>
<references value={references} />
{renderedChunks}
</>;
}
}

function parseGithubFileUrl(uri: URI): { nwo: string; path: string } | undefined {
if (uri.scheme === 'https' && uri.authority === 'github.com') {
const parts = uri.path.split('/').filter(Boolean);
// Pattern: /owner/repo/tree/branch/...path
if (parts.length >= 4 && parts[2] === 'tree') {
return {
nwo: `${parts[0]}/${parts[1]}`,
path: parts.slice(4).join('/'),
};
}
}
return undefined;
}

ToolRegistry.registerTool(GithubTextSearchTool);
Loading
Loading