links draft 1

This commit is contained in:
Mathew Pareles 2025-03-07 19:18:47 -08:00
parent 009dfbef37
commit 4a9c0c864f
4 changed files with 405 additions and 59 deletions

View file

@ -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 = <T>(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<T extends ToolName> = {
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<CodespanLocationLink>
// exposed getters/setters
getCurrentMessageState: (messageIdx: number) => UserMessageState
setCurrentMessageState: (messageIdx: number, newState: Partial<UserMessageState>) => void
getCurrentThreadState: () => ThreadType['state']
setCurrentThreadState: (newState: Partial<ThreadType['state']>) => 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<CodespanLocationLink> {
// 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
}

View file

@ -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 <code
className={`font-mono font-medium rounded-sm bg-void-bg-1 px-1 ${className}`}
onClick={onClick}
>
{text}
</code>
}
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<boolean>(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 <Codespan
text={text}
onClick={onClick}
className={link ? 'underline hover:brightness-90 transition-all duration-200 cursor-pointer' : ''}
/>
}
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 <div>
<BlockCode
initValue={t.text}
@ -132,7 +220,7 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: {
return <li>
<input type="checkbox" checked={t.checked} readOnly />
<span>
<ChatMarkdownRender chatMessageLocationForApply={chatMessageLocationForApply} string={t.text} nested={true} />
<ChatMarkdownRender chatMessageLocation={chatMessageLocation} string={t.text} nested={true} />
</span>
</li>
}
@ -148,7 +236,7 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: {
<input type="checkbox" checked={item.checked} readOnly />
)}
<span>
<ChatMarkdownRender chatMessageLocationForApply={chatMessageLocationForApply} string={item.text} nested={true} />
<ChatMarkdownRender chatMessageLocation={chatMessageLocation} string={item.text} nested={true} />
</span>
</li>
))}
@ -162,6 +250,7 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: {
<RenderToken key={index}
token={token}
tokenIdx={`${tokenIdx ? `${tokenIdx}-` : ''}${index}`} // assign a unique tokenId to nested components
chatMessageLocation={chatMessageLocation}
/>
))}
</>
@ -221,11 +310,19 @@ const RenderToken = ({ token, nested, chatMessageLocationForApply, tokenIdx }: {
// inline code
if (t.type === "codespan") {
return (
<code className="font-mono font-medium rounded-sm bg-void-bg-1 px-1">
{t.text}
</code>
)
console.log('chatmessagelocation', chatMessageLocation)
if (chatMessageLocation) {
return <CodespanWithLink
text={t.text}
rawText={t.raw}
chatMessageLocation={chatMessageLocation}
/>
}
return <Codespan text={t.text} />
}
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) => (
<RenderToken key={index} token={token} nested={nested} chatMessageLocationForApply={chatMessageLocationForApply} tokenIdx={index + ''} />
<RenderToken key={index} token={token} nested={nested} chatMessageLocation={chatMessageLocation} tokenIdx={index + ''} />
))}
</>
)

View file

@ -936,19 +936,6 @@ const AssistantMessageComponent = ({ chatMessage, isLoading, messageIdx }: ChatB
if (isEmpty) return null
return <>
{/* reasoning token */}
{hasReasoning && <DropdownComponent
title="Reasoning"
desc1=""
icon={<Dot className='stroke-blue-500' />}
>
<ChatMarkdownRender
string={reasoningStr}
chatMessageLocationForApply={chatMessageLocation}
/>
</DropdownComponent>}
<div
className='
text-void-fg-2
@ -977,14 +964,26 @@ const AssistantMessageComponent = ({ chatMessage, isLoading, messageIdx }: ChatB
'
>
{/* reasoning token */}
{hasReasoning && <DropdownComponent
title="Reasoning"
desc1=""
icon={<Dot className='stroke-blue-500' />}
>
<ChatMarkdownRender
string={reasoningStr}
chatMessageLocation={chatMessageLocation}
/>
</DropdownComponent>}
{/* assistant message */}
<ChatMarkdownRender
string={chatMessage.content || ''}
chatMessageLocationForApply={chatMessageLocation}
chatMessageLocation={chatMessageLocation}
/>
{isLoading && <IconLoading className='opacity-50 text-sm mx-4' />}
{/* loading indicator */}
{isLoading && <IconLoading className='opacity-50 text-sm' />}
</div>
</>
@ -1122,25 +1121,28 @@ const toolNameToComponent: { [T in ToolName]: {
numResults={value.uris.length}
icon={<Dot className={`stroke-orange-500`} />}
>
{value.uris.map((uri, i) => (
<div
key={i}
className="hover:brightness-125 hover:cursor-pointer transition-all duration-200 flex items-center flex-nowrap"
onClick={() => {
commandService.executeCommand('vscode.open', uri, { preview: true })
}}
>
<div className="flex-shrink-0"><svg className="w-1 h-1 opacity-60 mr-1.5 fill-current" viewBox="0 0 100 40"><rect x="0" y="15" width="100" height="10" /></svg></div>
{uri.fsPath.split('/').pop()}
</div>
))
{
value.uris.map((uri, i) => (
<div
key={i}
className="hover:brightness-125 hover:cursor-pointer transition-all duration-200 flex items-center flex-nowrap"
onClick={() => {
commandService.executeCommand('vscode.open', uri, { preview: true })
}}
>
<div className="flex-shrink-0"><svg className="w-1 h-1 opacity-60 mr-1.5 fill-current" viewBox="0 0 100 40"><rect x="0" y="15" width="100" height="10" /></svg></div>
{uri.fsPath.split('/').pop()}
</div>
))
}
{value.hasNextPage && (
<div className="italic">
More results available...
</div>
)}
</DropdownComponent>
{
value.hasNextPage && (
<div className="italic">
More results available...
</div>
)
}
</DropdownComponent >
)
}
},
@ -1232,8 +1234,8 @@ const toolNameToComponent: { [T in ToolName]: {
return <DropdownComponent title={title} desc1={getBasename(params.uri.fsPath)} icon={<Dot className={`stroke-orange-500`} />}
onClick={() => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) }}
>
<ChatMarkdownRender string={params.changeDescription} />
</DropdownComponent>
<ChatMarkdownRender string={params.changeDescription} chatMessageLocation={undefined} />
</DropdownComponent >
},
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor()
@ -1278,7 +1280,7 @@ const toolNameToComponent: { [T in ToolName]: {
// TODO!!! open terminal
>
<div className="flex-shrink-0"><svg className="w-1 h-1 opacity-60 mr-1.5 fill-current" viewBox="0 0 100 40"><rect x="0" y="15" width="100" height="10" /></svg></div>
<ChatMarkdownRender string={''} />
<ChatMarkdownRender string={''} chatMessageLocation={undefined} />
</div>
</DropdownComponent>
)

View file

@ -291,7 +291,7 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider
isPasswordField={isPasswordField}
/>
{subTextMd === undefined ? null : <div className='py-1 px-3 opacity-50 text-sm'>
<ChatMarkdownRender string={subTextMd} />
<ChatMarkdownRender string={subTextMd} chatMessageLocation={undefined} />
</div>}
</div>
@ -421,11 +421,11 @@ export const FeaturesTab = () => {
{/* <h3 className={`mb-2`}>{`Void can access any model that you host locally. We automatically detect your local models by default.`}</h3> */}
<h3 className={`text-void-fg-3 mb-2`}>{`Void can access any model that you host locally. We automatically detect your local models by default.`}</h3>
<div className='pl-4 prose-ol:list-decimal opacity-80'>
<span className={`text-sm mb-2`}><ChatMarkdownRender string={`1. Download [Ollama](https://ollama.com/download).`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender string={`2. Open your terminal.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender string={`3. Run \`ollama run llama3.1:8b\`. This installs Meta's llama3.1 model which is best for chat and inline edits. Requires 5GB of memory.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender string={`4. Run \`ollama run qwen2.5-coder:1.5b\`. This installs a faster autocomplete model. Requires 1GB of memory.`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender string={`Void automatically detects locally running models and enables them.`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender string={`1. Download [Ollama](https://ollama.com/download).`} chatMessageLocation={undefined} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender string={`2. Open your terminal.`} chatMessageLocation={undefined} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender string={`3. Run \`ollama run llama3.1:8b\`. This installs Meta's llama3.1 model which is best for chat and inline edits. Requires 5GB of memory.`} chatMessageLocation={undefined} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender string={`4. Run \`ollama run qwen2.5-coder:1.5b\`. This installs a faster autocomplete model. Requires 1GB of memory.`} chatMessageLocation={undefined} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender string={`Void automatically detects locally running models and enables them.`} chatMessageLocation={undefined} /></span>
{/* TODO we should create UI for downloading models without user going into terminal */}
</div>