Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(vscode/chat-panel): mention feature with symbol in chat panel&vscode #3777

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
20 changes: 20 additions & 0 deletions clients/tabby-chat-panel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,26 @@ export interface ListFilesInWorkspaceParams {
limit?: number
}

export interface ListSymbolsParams {

query: string

limit?: number
}

export interface ListFileItem {
/**
* The filepath of the file.
*/
filepath: Filepath
}

export interface ListSymbolItem {
filepath: Filepath
range: LineRange
label: string
}

export interface ServerApi {
init: (request: InitRequest) => void

Expand Down Expand Up @@ -344,6 +357,13 @@ export interface ClientApiMethods {
*/
listFileInWorkspace?: (params: ListFilesInWorkspaceParams) => Promise<ListFileItem[]>

/**
* Returns active editor symbols when no query is provided. Otherwise, returns workspace symbols that match the query.
* @param params An {@link ListSymbolsParams} object that includes a search query and a limit for the results.
* @returns An array of {@link ListSymbolItem} objects that could be empty.
*/
listSymbols?: (params: ListSymbolsParams) => Promise<ListSymbolItem[]>

/**
* Returns the content of a file within the specified range.
* If `range` is not provided, the entire file content is returned.
Expand Down
1 change: 1 addition & 0 deletions clients/vscode/src/chat/createClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function createClient(webview: Webview, api: ClientApiMethods): ServerApi
storeSessionState: api.storeSessionState,
listFileInWorkspace: api.listFileInWorkspace,
readFileContent: api.readFileContent,
listSymbols: api.listSymbols,
},
});
}
7 changes: 7 additions & 0 deletions clients/vscode/src/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ export function chatPanelLineRangeToVSCodeRange(lineRange: LineRange): VSCodeRan
return new VSCodeRange(Math.max(0, lineRange.start - 1), 0, lineRange.end, 0);
}

export function vscodeRangeToChatPanelLineRange(range: VSCodeRange): LineRange {
return {
start: range.start.line + 1,
end: range.end.line + 1,
};
}

export function chatPanelLocationToVSCodeRange(location: Location | undefined): VSCodeRange | null {
if (!location) {
return null;
Expand Down
179 changes: 179 additions & 0 deletions clients/vscode/src/chat/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
Location,
LocationLink,
TabInputText,
SymbolInformation,
DocumentSymbol,
} from "vscode";
import { TABBY_CHAT_PANEL_API_VERSION } from "tabby-chat-panel";
import type {
Expand All @@ -30,6 +32,9 @@ import type {
ListFilesInWorkspaceParams,
ListFileItem,
FileRange,
Filepath,
ListSymbolsParams,
ListSymbolItem,
} from "tabby-chat-panel";
import * as semver from "semver";
import debounce from "debounce";
Expand All @@ -50,6 +55,7 @@ import {
isValidForSyncActiveEditorSelection,
localUriToListFileItem,
escapeGlobPattern,
vscodeRangeToChatPanelLineRange,
} from "./utils";
import { findFiles } from "../findFiles";
import { wrapCancelableFunction } from "../cancelableFunction";
Expand Down Expand Up @@ -550,6 +556,179 @@ export class ChatWebview {
const document = await workspace.openTextDocument(uri);
return document.getText(chatPanelLocationToVSCodeRange(info.range) ?? undefined);
},
listSymbols: async (params: ListSymbolsParams): Promise<ListSymbolItem[]> => {
const { query } = params;
let { limit } = params;
const editor = window.activeTextEditor;

if (!editor) {
this.logger.warn("listActiveSymbols: No active editor found.");
return [];
}
if (!limit || limit < 0) {
limit = 20;
}

const getDocumentSymbols = async (editor: TextEditor): Promise<SymbolInformation[]> => {
this.logger.debug(`getDocumentSymbols: Fetching document symbols for ${editor.document.uri.toString()}`);
const symbols =
(await commands.executeCommand<DocumentSymbol[] | SymbolInformation[]>(
"vscode.executeDocumentSymbolProvider",
editor.document.uri,
)) || [];

const result: SymbolInformation[] = [];
const queue: (DocumentSymbol | SymbolInformation)[] = [...symbols];

// BFS to get all symbols up to the limit
while (queue.length > 0 && result.length < limit) {
const current = queue.shift();
if (!current) {
continue;
}

if (current instanceof DocumentSymbol) {
const converted = new SymbolInformation(
current.name,
current.kind,
current.detail,
new Location(editor.document.uri, current.range),
);

result.push(converted);

if (result.length >= limit) {
break;
}

queue.push(...current.children);
} else {
result.push(current);

if (result.length >= limit) {
break;
}
}
}

this.logger.debug(`getDocumentSymbols: Found ${result.length} symbols.`);
return result;
};

const getWorkspaceSymbols = async (query: string): Promise<ListSymbolItem[]> => {
this.logger.debug(`getWorkspaceSymbols: Fetching workspace symbols for query "${query}"`);
try {
const symbols =
(await commands.executeCommand<SymbolInformation[]>("vscode.executeWorkspaceSymbolProvider", query)) ||
[];

const items = symbols.map((symbol) => ({
filepath: localUriToChatPanelFilepath(symbol.location.uri, this.gitProvider),
range: vscodeRangeToChatPanelLineRange(symbol.location.range),
label: symbol.name,
}));
this.logger.debug(`getWorkspaceSymbols: Found ${items.length} symbols.`);
return items;
} catch (error) {
this.logger.error(`Workspace symbols failed: ${error}`);
return [];
}
};

const filterSymbols = (symbols: SymbolInformation[], query: string): SymbolInformation[] => {
const lowerQuery = query.toLowerCase();
const filtered = symbols.filter(
(s) => s.name.toLowerCase().includes(lowerQuery) || s.containerName?.toLowerCase().includes(lowerQuery),
);
this.logger.debug(`filterSymbols: Filtered down to ${filtered.length} symbols with query "${query}"`);
return filtered;
};

const mergeResults = (
local: ListSymbolItem[],
workspace: ListSymbolItem[],
query: string,
limit = 20,
): ListSymbolItem[] => {
this.logger.debug(
`mergeResults: Merging ${local.length} local symbols and ${workspace.length} workspace symbols with query "${query}" and limit ${limit}`,
);

const seen = new Set<string>();
const allItems = [...local, ...workspace];
const uniqueItems: ListSymbolItem[] = [];

for (const item of allItems) {
const key = `${item.filepath}-${item.label}-${item.range.start}-${item.range.end}`;
if (!seen.has(key)) {
seen.add(key);
uniqueItems.push(item);
}
}

// Sort all items by the match score
const getMatchScore = (label: string): number => {
const lowerLabel = label.toLowerCase();
const lowerQuery = query.toLowerCase();

if (lowerLabel === lowerQuery) return 3;
if (lowerLabel.startsWith(lowerQuery)) return 2;
if (lowerLabel.includes(lowerQuery)) return 1;
return 0;
};

uniqueItems.sort((a, b) => {
const scoreA = getMatchScore(a.label);
const scoreB = getMatchScore(b.label);

if (scoreB !== scoreA) return scoreB - scoreA;
return a.label.length - b.label.length;
});

this.logger.debug(`mergeResults: Returning ${Math.min(uniqueItems.length, limit)} sorted symbols.`);
return uniqueItems.slice(0, limit);
};

const symbolToItem = (symbol: SymbolInformation, filepath: Filepath): ListSymbolItem => {
return {
filepath,
range: vscodeRangeToChatPanelLineRange(symbol.location.range),
label: symbol.name,
};
};

try {
this.logger.info("listActiveSymbols: Starting to fetch symbols.");
const defaultSymbols = await getDocumentSymbols(editor);
const filepath = localUriToChatPanelFilepath(editor.document.uri, this.gitProvider);

if (!query) {
const items = defaultSymbols.slice(0, limit).map((symbol) => symbolToItem(symbol, filepath));
this.logger.debug(`listActiveSymbols: Returning ${items.length} symbols.`);
return items;
}

const [filteredDefault, workspaceSymbols] = await Promise.all([
Promise.resolve(filterSymbols(defaultSymbols, query)),
getWorkspaceSymbols(query),
]);
this.logger.info(
`listActiveSymbols: Found ${filteredDefault.length} filtered local symbols and ${workspaceSymbols.length} workspace symbols.`,
);

const mergedItems = mergeResults(
filteredDefault.map((s) => symbolToItem(s, filepath)),
workspaceSymbols,
query,
limit,
);
this.logger.info(`listActiveSymbols: Returning ${mergedItems.length} merged symbols.`);
return mergedItems;
} catch (error) {
this.logger.error(`listActiveSymbols: Failed - ${error}`);
return [];
}
},
});
}

Expand Down
16 changes: 12 additions & 4 deletions ee/tabby-ui/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { FooterText } from '@/components/footer'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { ChatContext } from './chat'
import { PromptFormRef } from './form-editor/types'
import { isSameEntireFileContextFromMention } from './form-editor/utils'
import { isSameFileContext } from './form-editor/utils'
import { RepoSelect } from './repo-select'

export interface ChatPanelProps extends Pick<UseChatHelpers, 'stop' | 'input'> {
Expand Down Expand Up @@ -156,13 +156,21 @@ function ChatPanelRenderer(

const currentContext: FileContext = relevantContext[idx]
state.doc.descendants((node, pos) => {
if (node.type.name === 'mention' && node.attrs.category === 'file') {
// TODO: use a easy way to dealling with mention node
if (
node.type.name === 'mention' &&
(node.attrs.category === 'file' || node.attrs.category === 'symbol')
) {
const fileContext = convertEditorContext({
filepath: node.attrs.fileItem.filepath,
content: '',
kind: 'file'
kind: 'file',
range:
node.attrs.category === 'symbol'
? node.attrs.fileItem.range
: undefined
})
if (isSameEntireFileContextFromMention(fileContext, currentContext)) {
if (isSameFileContext(fileContext, currentContext)) {
positionsToDelete.push({ from: pos, to: pos + node.nodeSize })
}
}
Expand Down
10 changes: 8 additions & 2 deletions ee/tabby-ui/components/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import type {
GitRepository,
ListFileItem,
ListFilesInWorkspaceParams,
ListSymbolItem,
ListSymbolsParams,
LookupSymbolHint,
SymbolInfo
} from 'tabby-chat-panel'
Expand Down Expand Up @@ -96,6 +98,7 @@ type ChatContextValue = {
listFileInWorkspace?: (
params: ListFilesInWorkspaceParams
) => Promise<ListFileItem[]>
listSymbols?: (param: ListSymbolsParams) => Promise<ListSymbolItem[]>
readFileContent?: (info: FileRange) => Promise<string | null>
}

Expand Down Expand Up @@ -143,6 +146,7 @@ interface ChatProps extends React.ComponentProps<'div'> {
listFileInWorkspace?: (
params: ListFilesInWorkspaceParams
) => Promise<ListFileItem[]>
listSymbols?: (param: ListSymbolsParams) => Promise<ListSymbolItem[]>
readFileContent?: (info: FileRange) => Promise<string | null>
}

Expand Down Expand Up @@ -183,7 +187,8 @@ function ChatRenderer(
fetchSessionState,
storeSessionState,
listFileInWorkspace,
readFileContent
readFileContent,
listSymbols
}: ChatProps,
ref: React.ForwardedRef<ChatRef>
) {
Expand Down Expand Up @@ -748,7 +753,8 @@ function ChatRenderer(
fetchingRepos,
initialized,
listFileInWorkspace,
readFileContent
readFileContent,
listSymbols
}}
>
<div className="flex justify-center overflow-x-hidden">
Expand Down
Loading
Loading