diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index f02f9e2c..205568d4 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -21,7 +21,11 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; import { ChatMode, FeatureName } from '../common/voidSettingsTypes.js'; import { IVoidSettingsService } from '../common/voidSettingsService.js'; - +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { LocationLink, SymbolKind } from '../../../../editor/common/languages.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Position } from '../../../../editor/common/core/position.js'; const findLastIndex = (arr: T[], condition: (t: T) => boolean): number => { for (let i = arr.length - 1; i >= 0; i--) { @@ -78,6 +82,15 @@ export type FileSelection = { export type StagingSelectionItem = CodeSelection | FileSelection +export type CodespanLocationLink = { + uri: URI, // we handle serialization for this + selection?: { // store as JSON so dont have to worry about serialization + startLineNumber: number + startColumn: number, + endLineNumber: number + endColumn: number, + } | undefined +} | null export type ToolMessage = { role: 'tool'; @@ -133,6 +146,13 @@ export type ChatThreads = { state: { stagingSelections: StagingSelectionItem[]; focusedMessageIdx: number | undefined; // index of the message that is being edited (undefined if none) + + linksOfMessageIdx: { // eg. link = linksOfMessageIdx[4]['RangeFunction'] + [messageIdx: number]: { + [codespanName: string]: CodespanLocationLink + } + } + isCheckedOfSelectionId: { [selectionId: string]: boolean }; // TODO } }; @@ -143,7 +163,8 @@ type ThreadType = ChatThreads[string] const defaultThreadState: ThreadType['state'] = { stagingSelections: [], focusedMessageIdx: undefined, - isCheckedOfSelectionId: {} + isCheckedOfSelectionId: {}, + linksOfMessageIdx: {}, } export type ThreadsState = { @@ -199,12 +220,19 @@ export interface IChatThreadService { isFocusingMessage(): boolean; setFocusedMessageIdx(messageIdx: number | undefined): void; + + + getCodespanLink({ codespanStr, messageIdx, threadId }: { codespanStr: string, messageIdx: number, threadId: string }): CodespanLocationLink | undefined; + addCodespanLink({ newLinkText, newLinkLocation, messageIdx, threadId }: { newLinkText: string, newLinkLocation: CodespanLocationLink, messageIdx: number, threadId: string }): void; + generateCodespanLink(codespanStr: string): Promise + // exposed getters/setters getCurrentMessageState: (messageIdx: number) => UserMessageState setCurrentMessageState: (messageIdx: number, newState: Partial) => void getCurrentThreadState: () => ThreadType['state'] setCurrentThreadState: (newState: Partial) => void + closeStagingSelectionsInCurrentThread(): void; closeStagingSelectionsInMessage(messageIdx: number): void; @@ -243,6 +271,8 @@ class ChatThreadService extends Disposable implements IChatThreadService { @IToolsService private readonly _toolsService: IToolsService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IVoidSettingsService private readonly _settingsService: IVoidSettingsService, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ITextModelService private readonly _textModelService: ITextModelService, ) { super() this.state = { allThreads: {}, currentThreadId: null as unknown as string } // default state @@ -260,6 +290,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { } // !!! this is important for properly restoring URIs from storage + // should probably re-use code from void/src/vs/base/common/marshalling.ts instead. but this is simple enough private _convertThreadDataFromStorage(threadsStr: string): ChatThreads { return JSON.parse(threadsStr, (key, value) => { if (value && typeof value === 'object' && value.$mid === 1) { //$mid is the MarshalledId. $mid === 1 means it is a URI @@ -551,6 +582,220 @@ class ChatThreadService extends Disposable implements IChatThreadService { // ---------- the rest ---------- + // gets the location of codespan link so the user can click on it + async generateCodespanLink(_codespanStr: string): Promise { + + // process codespan to understand what we are searching for + // TODO account for more complicated patterns eg `ITextEditorService.openEditor()` + const filePattern = /^[^\s.]+\.[^\s.]+$/; + const functionPattern = /^[^\s(]+\([^)]*\)$/; + + let target = _codespanStr // the string to search for + let codespanType: 'file' | 'function-or-class' | 'unsearchable' = 'unsearchable'; + if (filePattern.test(target)) { + + codespanType = 'file' + target = _codespanStr + + } else if (functionPattern.test(target)) { + const match = target.match(functionPattern) + if (match && match[0]) { + + codespanType = 'function-or-class' + target = match[0] + + } + } + + if (codespanType === 'unsearchable') { + return null + } + + // get history of all AI and user added files in conversation + store in reverse order (MRU) + const prevUris = this._getAllSelections() + .map(s => s.fileURI) + .filter((uri, index, array) => array.findIndex(u => u.toString() === uri.toString()) === index) // O(n^2) but this is small + .reverse() + + + + if (codespanType === 'file') { + + + const doesUriMatchTarget = (uri: URI) => uri.path.includes(target) + + // check if any prevFiles are the `codespanSearch` + for (const uri of prevUris) { + if (doesUriMatchTarget(uri)) return { uri } + } + + // else search codebase for file + const { uris } = await this._toolsService.callTool['pathname_search']({ queryStr: target, pageNumber: 0 }) + + for (const uri of uris) { + if (doesUriMatchTarget(uri)) return { uri } + } + + } + + + if (codespanType === 'function-or-class') { + + + // check all prevUris for the target + for (const uri of prevUris) { + + const modelRef = await this._textModelService.createModelReference(uri); + const model = modelRef.object.textEditorModel; + + try { + const matches = model.findMatches( + target.split('(')[0].trim(), // remove parameters + false, // searchOnlyEditableRange + false, // isRegex + true, // matchCase + null, // wordSeparators + true // captureMatches + ); + + const firstThree = matches.slice(0, 3); + + // take first 3 occurences, attempt to goto definition on them + + for (const match of firstThree) { + const position = new Position(match.range.startLineNumber, match.range.startColumn); + const definitionProviders = this._languageFeaturesService.definitionProvider.ordered(model); + + for (const provider of definitionProviders) { + const definitions = await provider.provideDefinition(model, position, CancellationToken.None); + if (!definitions) continue; + + const locationLinks: LocationLink[] = []; + + if (Array.isArray(definitions)) { + // Handle Location[] or LocationLink[] + for (const def of definitions) { + if ('uri' in def) { + locationLinks.push({ + uri: def.uri, + range: def.range, + targetSelectionRange: def.range, + originSelectionRange: undefined + }); + } else { + locationLinks.push(def); + } + } + } else { + // Handle single Location + locationLinks.push({ + uri: definitions.uri, + range: definitions.range, + targetSelectionRange: definitions.range, + originSelectionRange: undefined + }); + } + + const definition = locationLinks[0]; + if (!definition) continue; + + // Load definition file model + const defModelRef = await this._textModelService.createModelReference(definition.uri); + const defModel = defModelRef.object.textEditorModel; + + try { + const symbolProviders = this._languageFeaturesService.documentSymbolProvider.ordered(defModel); + + for (const symbolProvider of symbolProviders) { + const symbols = await symbolProvider.provideDocumentSymbols( + defModel, + CancellationToken.None + ); + + if (symbols) { + const symbol = symbols.find(s => { + const symbolRange = s.range; + return symbolRange.startLineNumber <= definition.range.startLineNumber && + symbolRange.endLineNumber >= definition.range.endLineNumber && + (symbolRange.startLineNumber !== definition.range.startLineNumber || symbolRange.startColumn <= definition.range.startColumn) && + (symbolRange.endLineNumber !== definition.range.endLineNumber || symbolRange.endColumn >= definition.range.endColumn); + }); + + + console.log('@@@ symbol', symbol?.name, symbol?.kind) + + // if we got to a class/function get the full range and return + if (symbol?.kind === SymbolKind.Function || symbol?.kind === SymbolKind.Class) { + return { + uri: definition.uri, + selection: { + startLineNumber: definition.range.startLineNumber, + startColumn: definition.range.startColumn, + endLineNumber: definition.range.endLineNumber, + endColumn: definition.range.endColumn, + } + }; + } + } + } + } finally { + defModelRef.dispose(); + } + } + } + } finally { + modelRef.dispose(); + } + } + + // unlike above do not search codebase (doesnt make sense) + + } + + return null + + } + + getCodespanLink({ codespanStr, messageIdx, threadId }: { codespanStr: string, messageIdx: number, threadId: string }): CodespanLocationLink | undefined { + const thread = this.state.allThreads[threadId] + if (!thread) return undefined; + + const links = thread.state.linksOfMessageIdx?.[messageIdx] + if (!links) return undefined; + + const location = links[codespanStr] + if (!location) return undefined; + + return location + } + + async addCodespanLink({ newLinkText, newLinkLocation, messageIdx, threadId }: { newLinkText: string, newLinkLocation: CodespanLocationLink, messageIdx: number, threadId: string }) { + const thread = this.state.allThreads[threadId] + if (!thread) return + + this._setState({ + + allThreads: { + ...this.state.allThreads, + [threadId]: { + ...thread, + state: { + ...thread.state, + linksOfMessageIdx: { + ...thread.state.linksOfMessageIdx, + [messageIdx]: { + ...thread.state.linksOfMessageIdx?.[messageIdx], + [newLinkText]: newLinkLocation + } + } + } + + } + } + }, true) + } + + getCurrentThread(): ChatThreads[string] { const state = this.state const thread = state.allThreads[state.currentThreadId] @@ -721,7 +966,9 @@ class ChatThreadService extends Disposable implements IChatThreadService { getCurrentThreadState = () => { + const currentThread = this.getCurrentThread() + return currentThread.state } diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx index 50b8378e..d2b7cc56 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx @@ -3,11 +3,17 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { JSX } from 'react' +import React, { JSX, useState } from 'react' import { marked, MarkedToken, Token } from 'marked' import { BlockCode } from './BlockCode.js' import { nameToVscodeLanguage } from '../../../../common/helpers/detectLanguage.js' import { ApplyBlockHoverButtons } from './ApplyBlockHoverButtons.js' +import { useAccessor, useChatThreadsState } from '../util/services.js' +import { Range } from '../../../../../../services/search/common/searchExtTypes.js' +import { CodespanLocationLink } from '../../../chatThreadService.js' +import { IRange } from '../../../../../../../base/common/range.js' +import { ScrollType } from '../../../../../../../editor/common/editorCommon.js' + export type ChatMessageLocation = { threadId: string; @@ -20,7 +26,87 @@ const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocation) => return `${threadId}-${messageIdx}-${tokenIdx}` } -const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { token: Token | string, nested?: boolean, chatMessageLocationForApply?: ChatMessageLocation, tokenIdx: string }): JSX.Element => { + +const Codespan = ({ text, className, onClick }: { text: string, className?: string, onClick?: () => void }) => { + + return + {text} + + +} + +const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string, rawText: string, chatMessageLocation: ChatMessageLocation }) => { + + const accessor = useAccessor() + + const chatThreadService = accessor.get('IChatThreadService') + const commandSerivce = accessor.get('ICommandService') + const editorService = accessor.get('ICodeEditorService') + + const { messageIdx, threadId } = chatMessageLocation + + const [didComputeCodespanLink, setDidComputeCodespanLink] = useState(false) + + console.log('rerender', didComputeCodespanLink ? 1 : 0) + + let link = undefined + + if (rawText.endsWith("`")) { // if codespan was completed + + // get link from cache + link = chatThreadService.getCodespanLink({ codespanStr: text, messageIdx, threadId }) + + if (link === undefined) { + + // generate link and add to cache + chatThreadService.generateCodespanLink(text) + .then(link => { + + chatThreadService.addCodespanLink({ newLinkText: text, newLinkLocation: link, messageIdx, threadId }) + + setDidComputeCodespanLink(true) + + }) + } + + } + + + const onClick = () => { + + if (!link) return; + const selection = link.selection + + // open the file + commandSerivce.executeCommand('vscode.open', link.uri).then(() => { + + console.log('click:', selection, link.uri) + + // select the text + if (!selection) return; + + const editor = editorService.getActiveCodeEditor() + if (!editor) return; + + editor.setSelection(selection) + editor.revealRange(selection, ScrollType.Immediate) + + }) + + } + + + return +} + +const RenderToken = ({ token, nested, chatMessageLocation, tokenIdx }: { token: Token | string, nested?: boolean, chatMessageLocation?: ChatMessageLocation, tokenIdx: string }): JSX.Element => { // deal with built-in tokens first (assume marked token) const t = token as MarkedToken @@ -35,12 +121,14 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { if (t.type === "code") { - const applyBoxId = chatMessageLocationForApply ? getApplyBoxId({ - threadId: chatMessageLocationForApply.threadId, - messageIdx: chatMessageLocationForApply.messageIdx, + const applyBoxId = chatMessageLocation ? getApplyBoxId({ + threadId: chatMessageLocation.threadId, + messageIdx: chatMessageLocation.messageIdx, tokenIdx: tokenIdx, }) : null + // TODO user should only be able to apply this when the code has been closed (t.raw ends with "```") + return
- + } @@ -148,7 +236,7 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { )} - + ))} @@ -162,6 +250,7 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { ))} @@ -221,11 +310,19 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { // inline code if (t.type === "codespan") { - return ( - - {t.text} - - ) + + + console.log('chatmessagelocation', chatMessageLocation) + + if (chatMessageLocation) { + return + } + + return } if (t.type === "br") { @@ -244,12 +341,12 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: { ) } -export const ChatMarkdownRender = ({ string, nested = false, chatMessageLocationForApply }: { string: string, nested?: boolean, chatMessageLocationForApply?: ChatMessageLocation }) => { +export const ChatMarkdownRender = ({ string, nested = false, chatMessageLocation }: { string: string, nested?: boolean, chatMessageLocation: ChatMessageLocation | undefined }) => { const tokens = marked.lexer(string); // https://marked.js.org/using_pro#renderer return ( <> {tokens.map((token, index) => ( - + ))} ) diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index cc9d779e..6bcf5ae8 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -936,19 +936,6 @@ const AssistantMessageComponent = ({ chatMessage, isLoading, messageIdx }: ChatB if (isEmpty) return null return <> - - {/* reasoning token */} - {hasReasoning && } - > - - } -
+ {/* reasoning token */} + {hasReasoning && } + > + + } + {/* assistant message */} - {isLoading && } - + {/* loading indicator */} + {isLoading && }
@@ -1122,25 +1121,28 @@ const toolNameToComponent: { [T in ToolName]: { numResults={value.uris.length} icon={} > - {value.uris.map((uri, i) => ( -
{ - commandService.executeCommand('vscode.open', uri, { preview: true }) - }} - > -
- {uri.fsPath.split('/').pop()} -
- )) + { + value.uris.map((uri, i) => ( +
{ + commandService.executeCommand('vscode.open', uri, { preview: true }) + }} + > +
+ {uri.fsPath.split('/').pop()} +
+ )) } - {value.hasNextPage && ( -
- More results available... -
- )} - + { + value.hasNextPage && ( +
+ More results available... +
+ ) + } + ) } }, @@ -1232,8 +1234,8 @@ const toolNameToComponent: { [T in ToolName]: { return } onClick={() => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }} > - - + + }, resultWrapper: ({ toolMessage }) => { const accessor = useAccessor() @@ -1278,7 +1280,7 @@ const toolNameToComponent: { [T in ToolName]: { // TODO!!! open terminal >
- +
) diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx index 10068af2..346d9143 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx @@ -291,7 +291,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider isPasswordField={isPasswordField} /> {subTextMd === undefined ? null :
- +
} @@ -421,11 +421,11 @@ export const FeaturesTab = () => { {/*

{`Void can access any model that you host locally. We automatically detect your local models by default.`}

*/}

{`Void can access any model that you host locally. We automatically detect your local models by default.`}

- - - - - + + + + + {/* TODO we should create UI for downloading models without user going into terminal */}