diff --git a/clients/tabby-chat-panel/src/index.ts b/clients/tabby-chat-panel/src/index.ts index e2cc1ec63c85..0cfcfc43a0dc 100644 --- a/clients/tabby-chat-panel/src/index.ts +++ b/clients/tabby-chat-panel/src/index.ts @@ -53,7 +53,14 @@ export interface ServerApi { updateTheme: (style: string, themeClass: string) => void updateActiveSelection: (context: Context | null) => void } - +export interface SymbolInfo { + sourceFile: string + sourceLine: number + sourceCol: number + targetFile: string + targetLine: number + targetCol: number +} export interface ClientApiMethods { navigate: (context: Context, opts?: NavigateOpts) => void refresh: () => Promise @@ -77,6 +84,8 @@ export interface ClientApiMethods { onKeyboardEvent: (type: 'keydown' | 'keyup' | 'keypress', event: KeyboardEventInit) => void + // find symbol definition location by hint filepaths and keyword + onLookupSymbol?: (hintFilepaths: string[], keyword: string) => Promise } export interface ClientApi extends ClientApiMethods { @@ -119,6 +128,7 @@ export function createClient(target: HTMLIFrameElement, api: ClientApiMethods): onLoaded: api.onLoaded, onCopy: api.onCopy, onKeyboardEvent: api.onKeyboardEvent, + onLookupSymbol: api.onLookupSymbol, }, }) } diff --git a/clients/vscode/src/chat/WebviewHelper.ts b/clients/vscode/src/chat/WebviewHelper.ts index a4f22d40bb19..fdf8e2544852 100644 --- a/clients/vscode/src/chat/WebviewHelper.ts +++ b/clients/vscode/src/chat/WebviewHelper.ts @@ -9,8 +9,11 @@ import { Webview, ColorThemeKind, ProgressLocation, + commands, + LocationLink, + workspace, } from "vscode"; -import type { ServerApi, ChatMessage, Context, NavigateOpts, OnLoadedParams } from "tabby-chat-panel"; +import type { ServerApi, ChatMessage, Context, NavigateOpts, OnLoadedParams, SymbolInfo } from "tabby-chat-panel"; import { TABBY_CHAT_PANEL_API_VERSION } from "tabby-chat-panel"; import hashObject from "object-hash"; import * as semver from "semver"; @@ -21,6 +24,7 @@ import { createClient } from "./chatPanel"; import { Client as LspClient } from "../lsp/Client"; import { isBrowser } from "../env"; import { getFileContextFromSelection, showFileContext } from "./fileContext"; +import path from "path"; export class WebviewHelper { webview?: Webview; @@ -384,6 +388,9 @@ export class WebviewHelper { } public createChatClient(webview: Webview) { + /* + utility functions for createClient + */ const getIndentInfo = (document: TextDocument, selection: Selection) => { // Determine the indentation for the content // The calculation is based solely on the indentation of the first line @@ -547,6 +554,61 @@ export class WebviewHelper { this.logger.debug(`Dispatching keyboard event: ${type} ${JSON.stringify(event)}`); this.webview?.postMessage({ action: "dispatchKeyboardEvent", type, event }); }, + onLookupSymbol: async (hintFilepaths: string[], keyword: string): Promise => { + const findSymbolInfo = async (filepaths: string[], keyword: string): Promise => { + if (!keyword || !filepaths.length) { + this.logger.info("No keyword or filepaths provided"); + return undefined; + } + try { + const workspaceRoot = workspace.workspaceFolders?.[0]; + if (!workspaceRoot) { + this.logger.error("No workspace folder found"); + return undefined; + } + const rootPath = workspaceRoot.uri; + for (const filepath of filepaths) { + const normalizedPath = filepath.startsWith("/") ? filepath.slice(1) : filepath; + const fullPath = path.join(rootPath.path, normalizedPath); + const fileUri = Uri.file(fullPath); + const document = await workspace.openTextDocument(fileUri); + const content = document.getText(); + let pos = 0; + while ((pos = content.indexOf(keyword, pos)) !== -1) { + const position = document.positionAt(pos); + const locations = await commands.executeCommand( + "vscode.executeDefinitionProvider", + fileUri, + position, + ); + if (locations && locations.length > 0) { + const location = locations[0]; + if (location) { + const targetPath = location.targetUri.fsPath; + const relativePath = path.relative(rootPath.path, targetPath); + const normalizedTargetPath = relativePath.startsWith("/") ? relativePath.slice(1) : relativePath; + + return { + sourceFile: filepath, + sourceLine: position.line + 1, + sourceCol: position.character, + targetFile: normalizedTargetPath, + targetLine: location.targetRange.start.line + 1, + targetCol: location.targetRange.start.character, + }; + } + } + pos += keyword.length; + } + } + } catch (error) { + this.logger.error("Error in findSymbolInfo:", error); + } + return undefined; + }; + + return await findSymbolInfo(hintFilepaths, keyword); + }, }); } } diff --git a/clients/vscode/src/chat/chatPanel.ts b/clients/vscode/src/chat/chatPanel.ts index 553880f532c4..3ef44a34a236 100644 --- a/clients/vscode/src/chat/chatPanel.ts +++ b/clients/vscode/src/chat/chatPanel.ts @@ -33,6 +33,7 @@ export function createClient(webview: Webview, api: ClientApiMethods): ServerApi onLoaded: api.onLoaded, onCopy: api.onCopy, onKeyboardEvent: api.onKeyboardEvent, + onLookupSymbol: api.onLookupSymbol, }, }); } diff --git a/ee/tabby-ui/app/chat/page.tsx b/ee/tabby-ui/app/chat/page.tsx index 6f14266a0909..2c5da6d76dbd 100644 --- a/ee/tabby-ui/app/chat/page.tsx +++ b/ee/tabby-ui/app/chat/page.tsx @@ -74,6 +74,7 @@ export default function ChatPage() { // server feature support check const [supportsOnApplyInEditorV2, setSupportsOnApplyInEditorV2] = useState(false) + const [supportsOnLookupSymbol, setSupportsOnLookupSymbol] = useState(false) const sendMessage = (message: ChatMessage) => { if (chatRef.current) { @@ -236,6 +237,7 @@ export default function ChatPage() { server ?.hasCapability('onApplyInEditorV2') .then(setSupportsOnApplyInEditorV2) + server?.hasCapability('onLookupSymbol').then(setSupportsOnLookupSymbol) } checkCapabilities() @@ -388,6 +390,10 @@ export default function ChatPage() { : server?.onApplyInEditor) } supportsOnApplyInEditorV2={supportsOnApplyInEditorV2} + onLookupSymbol={ + isInEditor && + (supportsOnLookupSymbol ? server?.onLookupSymbol : undefined) + } /> ) diff --git a/ee/tabby-ui/app/files/components/chat-side-bar.tsx b/ee/tabby-ui/app/files/components/chat-side-bar.tsx index de87e95fc496..824140f4c94c 100644 --- a/ee/tabby-ui/app/files/components/chat-side-bar.tsx +++ b/ee/tabby-ui/app/files/components/chat-side-bar.tsx @@ -79,7 +79,10 @@ export const ChatSideBar: React.FC = ({ onApplyInEditor(_content) {}, onLoaded() {}, onCopy(_content) {}, - onKeyboardEvent() {} + onKeyboardEvent() {}, + async onLookupSymbol(_filepath, _keywords) { + return undefined + } }) const getPrompt = ({ action }: QuickActionEventPayload) => { diff --git a/ee/tabby-ui/app/search/components/assistant-message-section.tsx b/ee/tabby-ui/app/search/components/assistant-message-section.tsx index 7ec479e603c7..fd21467870f3 100644 --- a/ee/tabby-ui/app/search/components/assistant-message-section.tsx +++ b/ee/tabby-ui/app/search/components/assistant-message-section.tsx @@ -101,7 +101,8 @@ export function AssistantMessageSection({ onUpdateMessage } = useContext(SearchContext) - const { supportsOnApplyInEditorV2 } = useContext(ChatContext) + const { supportsOnApplyInEditorV2, onNavigateToContext } = + useContext(ChatContext) const [isEditing, setIsEditing] = useState(false) const [showMoreSource, setShowMoreSource] = useState(false) @@ -374,6 +375,7 @@ export function AssistantMessageSection({ fetchingContextInfo={fetchingContextInfo} canWrapLongLines={!isLoading} supportsOnApplyInEditorV2={supportsOnApplyInEditorV2} + onNavigateToContext={onNavigateToContext} /> {/* if isEditing, do not display error message block */} {message.error && } diff --git a/ee/tabby-ui/components/chat/chat.tsx b/ee/tabby-ui/components/chat/chat.tsx index c86caee905a1..03212c6c3295 100644 --- a/ee/tabby-ui/components/chat/chat.tsx +++ b/ee/tabby-ui/components/chat/chat.tsx @@ -1,6 +1,11 @@ import React, { RefObject } from 'react' import { compact, findIndex, isEqual, some, uniqWith } from 'lodash-es' -import type { Context, FileContext, NavigateOpts } from 'tabby-chat-panel' +import type { + Context, + FileContext, + NavigateOpts, + SymbolInfo +} from 'tabby-chat-panel' import { ERROR_CODE_NOT_FOUND } from '@/lib/constants' import { @@ -46,6 +51,10 @@ type ChatContextValue = { onApplyInEditor?: | ((content: string) => void) | ((content: string, opts?: { languageId: string; smart: boolean }) => void) + onLookupSymbol?: ( + filepaths: string[], + keyword: string + ) => Promise relevantContext: Context[] activeSelection: Context | null removeRelevantContext: (index: number) => void @@ -84,6 +93,10 @@ interface ChatProps extends React.ComponentProps<'div'> { onApplyInEditor?: | ((content: string) => void) | ((content: string, opts?: { languageId: string; smart: boolean }) => void) + onLookupSymbol?: ( + filepaths: string[], + keyword: string + ) => Promise chatInputRef: RefObject supportsOnApplyInEditorV2: boolean } @@ -105,6 +118,7 @@ function ChatRenderer( onCopyContent, onSubmitMessage, onApplyInEditor, + onLookupSymbol, chatInputRef, supportsOnApplyInEditorV2 }: ChatProps, @@ -531,6 +545,7 @@ function ChatRenderer( container, onCopyContent, onApplyInEditor, + onLookupSymbol, relevantContext, removeRelevantContext, chatInputRef, diff --git a/ee/tabby-ui/components/chat/question-answer.tsx b/ee/tabby-ui/components/chat/question-answer.tsx index 3f767fb07e2b..5c1de52133a9 100644 --- a/ee/tabby-ui/components/chat/question-answer.tsx +++ b/ee/tabby-ui/components/chat/question-answer.tsx @@ -261,6 +261,7 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { onNavigateToContext, onApplyInEditor, onCopyContent, + onLookupSymbol, supportsOnApplyInEditorV2 } = React.useContext(ChatContext) const [relevantCodeHighlightIndex, setRelevantCodeHighlightIndex] = @@ -404,7 +405,10 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { onCodeCitationMouseEnter={onCodeCitationMouseEnter} onCodeCitationMouseLeave={onCodeCitationMouseLeave} canWrapLongLines={!isLoading} + onLookupSymbol={onLookupSymbol} supportsOnApplyInEditorV2={supportsOnApplyInEditorV2} + activeSelection={userMessage.activeContext} + onNavigateToContext={onNavigateToContext} /> {!!message.error && } diff --git a/ee/tabby-ui/components/message-markdown/code.tsx b/ee/tabby-ui/components/message-markdown/code.tsx new file mode 100644 index 000000000000..26f42ab12b8c --- /dev/null +++ b/ee/tabby-ui/components/message-markdown/code.tsx @@ -0,0 +1,113 @@ +import { ReactNode, useContext, useEffect } from 'react' +import { Element } from 'react-markdown/lib/ast-to-react' + +import { cn } from '@/lib/utils' + +import { CodeBlock } from '../ui/codeblock' +import { IconSquareChevronRight } from '../ui/icons' +import { MessageMarkdownContext } from './markdown-context' + +export interface CodeElementProps { + node: Element + inline?: boolean + className?: string + children: ReactNode & ReactNode[] +} + +/** + * Code element in Markdown AST. + */ +export function CodeElement({ + inline, + className, + children, + ...props +}: CodeElementProps) { + const { + lookupSymbol, + canWrapLongLines, + onApplyInEditor, + onCopyContent, + supportsOnApplyInEditorV2, + onNavigateToContext, + symbolPositionMap + } = useContext(MessageMarkdownContext) + + const keyword = children[0]?.toString() + const symbolLocation = keyword ? symbolPositionMap.get(keyword) : undefined + + useEffect(() => { + if (!inline || !lookupSymbol || !keyword) return + lookupSymbol(keyword) + }, [inline, keyword, lookupSymbol]) + + if (children.length) { + if (children[0] === '▍') { + return + } + children[0] = (children[0] as string).replace('`▍`', '▍') + } + + if (inline) { + const isSymbolNavigable = Boolean(symbolLocation) + + const handleClick = () => { + if (!isSymbolNavigable || !symbolLocation || !onNavigateToContext) return + + onNavigateToContext( + { + filepath: symbolLocation.targetFile, + range: { + start: symbolLocation.targetLine, + end: symbolLocation.targetLine + }, + git_url: '', + content: '', + kind: 'file' + }, + { + openInEditor: true + } + ) + } + + return ( + + {isSymbolNavigable && ( + + )} + + {children} + + + ) + } + + const match = /language-(\w+)/.exec(className || '') + return ( + + ) +} diff --git a/ee/tabby-ui/components/message-markdown/index.tsx b/ee/tabby-ui/components/message-markdown/index.tsx index a0955880af8e..248241d2cde3 100644 --- a/ee/tabby-ui/components/message-markdown/index.tsx +++ b/ee/tabby-ui/components/message-markdown/index.tsx @@ -1,4 +1,4 @@ -import { createContext, ReactNode, useContext, useMemo, useState } from 'react' +import { ReactNode, useContext, useMemo, useState } from 'react' import Image from 'next/image' import defaultFavicon from '@/assets/default-favicon.png' import DOMPurify from 'dompurify' @@ -15,7 +15,6 @@ import { } from '@/lib/gql/generates/graphql' import { AttachmentCodeItem, AttachmentDocItem } from '@/lib/types' import { cn, getContent } from '@/lib/utils' -import { CodeBlock, CodeBlockProps } from '@/components/ui/codeblock' import { HoverCard, HoverCardContent, @@ -25,6 +24,13 @@ import { MemoizedReactMarkdown } from '@/components/markdown' import './style.css' +import { + Context, + FileContext, + NavigateOpts, + SymbolInfo +} from 'tabby-chat-panel/index' + import { MARKDOWN_CITATION_REGEX, MARKDOWN_SOURCE_REGEX @@ -39,6 +45,8 @@ import { IconGitPullRequest } from '../ui/icons' import { Skeleton } from '../ui/skeleton' +import { CodeElement } from './code' +import { MessageMarkdownContext } from './markdown-context' type RelevantDocItem = { type: 'doc' @@ -75,6 +83,11 @@ export interface MessageMarkdownProps { content: string, opts?: { languageId: string; smart: boolean } ) => void + onLookupSymbol?: ( + filepaths: string[], + keyword: string + ) => Promise + onNavigateToContext?: (context: Context, opts?: NavigateOpts) => void onCodeCitationClick?: (code: AttachmentCodeItem) => void onCodeCitationMouseEnter?: (index: number) => void onCodeCitationMouseLeave?: (index: number) => void @@ -84,27 +97,9 @@ export interface MessageMarkdownProps { // wrapLongLines for code block canWrapLongLines?: boolean supportsOnApplyInEditorV2: boolean + activeSelection?: FileContext } -type MessageMarkdownContextValue = { - onCopyContent?: ((value: string) => void) | undefined - onApplyInEditor?: ( - content: string, - opts?: { languageId: string; smart: boolean } - ) => void - onCodeCitationClick?: (code: AttachmentCodeItem) => void - onCodeCitationMouseEnter?: (index: number) => void - onCodeCitationMouseLeave?: (index: number) => void - contextInfo: ContextInfo | undefined - fetchingContextInfo: boolean - canWrapLongLines: boolean - supportsOnApplyInEditorV2: boolean -} - -const MessageMarkdownContext = createContext( - {} as MessageMarkdownContextValue -) - export function MessageMarkdown({ message, headline = false, @@ -117,9 +112,15 @@ export function MessageMarkdown({ fetchingContextInfo, className, canWrapLongLines, + onLookupSymbol, supportsOnApplyInEditorV2, + activeSelection, + onNavigateToContext, ...rest }: MessageMarkdownProps) { + const [symbolPositionMap, setSymbolLocationMap] = useState< + Map + >(new Map()) const messageAttachments: MessageAttachments = useMemo(() => { const docs: MessageAttachments = attachmentDocs?.map(item => ({ @@ -189,6 +190,18 @@ export function MessageMarkdown({ return elements } + const lookupSymbol = async (keyword: string) => { + if (!onLookupSymbol) return + if (symbolPositionMap.has(keyword)) return + + setSymbolLocationMap(map => new Map(map.set(keyword, undefined))) + const symbolInfo = await onLookupSymbol( + activeSelection?.filepath ? [activeSelection?.filepath] : [], + keyword + ) + setSymbolLocationMap(map => new Map(map.set(keyword, symbolInfo))) + } + return ( {childrenItem} })} @@ -240,37 +259,15 @@ export function MessageMarkdown({ return
  • {children}
  • }, code({ node, inline, className, children, ...props }) { - if (children.length) { - if (children[0] == '▍') { - return ( - - ) - } - - children[0] = (children[0] as string).replace('`▍`', '▍') - } - - const match = /language-(\w+)/.exec(className || '') - - if (inline) { - return ( - - {children} - - ) - } - return ( - + > + {children} + ) } }} @@ -317,20 +314,6 @@ export function ErrorMessageBlock({ ) } -function CodeBlockWrapper(props: CodeBlockProps) { - const { canWrapLongLines, supportsOnApplyInEditorV2 } = useContext( - MessageMarkdownContext - ) - - return ( - - ) -} - function CitationTag({ citationIndex, showcitation, diff --git a/ee/tabby-ui/components/message-markdown/markdown-context.tsx b/ee/tabby-ui/components/message-markdown/markdown-context.tsx new file mode 100644 index 000000000000..8e00f225fddc --- /dev/null +++ b/ee/tabby-ui/components/message-markdown/markdown-context.tsx @@ -0,0 +1,32 @@ +import { createContext } from 'react' +import { + Context, + FileContext, + NavigateOpts, + SymbolInfo +} from 'tabby-chat-panel/index' + +import { ContextInfo } from '@/lib/gql/generates/graphql' +import { AttachmentCodeItem } from '@/lib/types' + +export type MessageMarkdownContextValue = { + onCopyContent?: ((value: string) => void) | undefined + onApplyInEditor?: ( + content: string, + opts?: { languageId: string; smart: boolean } + ) => void + onCodeCitationClick?: (code: AttachmentCodeItem) => void + onCodeCitationMouseEnter?: (index: number) => void + onCodeCitationMouseLeave?: (index: number) => void + contextInfo: ContextInfo | undefined + fetchingContextInfo: boolean + canWrapLongLines: boolean + onNavigateToContext?: (context: Context, opts?: NavigateOpts) => void + supportsOnApplyInEditorV2: boolean + activeSelection?: FileContext + symbolPositionMap: Map + lookupSymbol?: (keyword: string) => void +} + +export const MessageMarkdownContext = + createContext({} as MessageMarkdownContextValue) diff --git a/ee/tabby-ui/components/message-markdown/style.css b/ee/tabby-ui/components/message-markdown/style.css index b703da22d482..d93782c4cefb 100644 --- a/ee/tabby-ui/components/message-markdown/style.css +++ b/ee/tabby-ui/components/message-markdown/style.css @@ -1,3 +1,12 @@ .message-markdown li > * { vertical-align: top; +} + +.message-markdown code.symbol { + @apply rounded-md font-normal mx-1 px-1; +} + +.message-markdown code.symbol::before, +.message-markdown code.symbol::after { + content: none; } \ No newline at end of file diff --git a/ee/tabby-ui/components/ui/icons.tsx b/ee/tabby-ui/components/ui/icons.tsx index 40e806a33717..223cd38b8888 100644 --- a/ee/tabby-ui/components/ui/icons.tsx +++ b/ee/tabby-ui/components/ui/icons.tsx @@ -39,6 +39,7 @@ import { Share2, Sparkles, SquareActivity, + SquareChevronRight, Star, Tag, WrapText, @@ -1730,6 +1731,13 @@ function IconGitMerge({ return } +function IconSquareChevronRight({ + className, + ...props +}: React.ComponentProps) { + return +} + function IconFileSearch2({ className, ...props @@ -1848,5 +1856,6 @@ export { IconCircleDot, IconGitPullRequest, IconGitMerge, + IconSquareChevronRight, IconFileSearch2 }