diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index e6a52c54..d92fb772 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -48,13 +48,13 @@ export type FileSelection = { export type StagingSelectionItem = CodeSelection | FileSelection -type ToolMessage = { +export type ToolMessage = { role: 'tool'; name: T; // internal use params: string; // internal use id: string; // apis require this tool use id content: string; // result - result: ToolCallReturnType; // text message of result + result: ToolCallReturnType[T]; // text message of result } @@ -430,10 +430,10 @@ class ChatThreadService extends Disposable implements IChatThreadService { // 1. let toolResult: Awaited> - let toolResultVal: ToolCallReturnType + let toolResultVal: ToolCallReturnType[ToolName] try { toolResult = await this._toolsService.toolFns[toolName](tool.params) - toolResultVal = toolResult[0] + toolResultVal = toolResult } catch (error) { this._setStreamState(threadId, { error }) shouldSendAnotherMessage = false 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 3c95f434..7a0563c3 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 @@ -7,7 +7,7 @@ import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, K import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useUriState, useSettingsState } from '../util/services.js'; -import { ChatMessage, StagingSelectionItem } from '../../../chatThreadService.js'; +import { ChatMessage, StagingSelectionItem, ToolMessage } from '../../../chatThreadService.js'; import { BlockCode } from '../markdown/BlockCode.js'; import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'; @@ -21,10 +21,11 @@ import { useScrollbarStyles } from '../util/useScrollbarStyles.js'; import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js'; import { filenameToVscodeLanguage } from '../../../helpers/detectLanguage.js'; import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js'; -import { Pencil, X } from 'lucide-react'; +import { ChevronRight, Pencil, X } from 'lucide-react'; import { FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'; import { WarningBox } from '../void-settings-tsx/WarningBox.js'; import { ChatMessageLocation } from '../../../aiRegexService.js'; +import { ToolCallReturnType, ToolName } from '../../../../common/toolsService.js'; @@ -542,6 +543,146 @@ export const SelectedFiles = ( } +type ToolReusltToComponent = { [T in ToolName]: (props: { message: ToolMessage }) => React.ReactNode } +interface ToolResultProps { + title: string; + desc: string; + desc2?: number; + children?: React.ReactNode; +} + +const ToolResult = ({ + title, + desc, + desc2, + children, +}: ToolResultProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + const isDropdown = !!children + + return ( +
+
+
children && setIsExpanded(!isExpanded)} + > + {isDropdown && ( + + )} +
+ {title} + {`"`}{desc}{`"`} + {desc2 !== undefined && ( + + {`(`}{desc2}{` result`}{desc2 !== 1 ? 's' : ''}{`)`} + + )} +
+
+
+ {children} +
+
+
+ ); +}; + + + +const toolResultToComponent: ToolReusltToComponent = { + 'read_file': ({ message }) => ( + + ), + 'list_dir': ({ message }) => ( + +
+ {message.result.children?.map((item, i) => ( +
+ • {item.name} + {item.isDirectory && '/'} +
+ ))} + {message.result.hasNextPage && ( +
+ {message.result.itemsRemaining} more items... +
+ )} +
+
+ ), + 'pathname_search': ({ message }) => ( + +
+ {Array.isArray(message.result.uris) ? + message.result.uris.map((uri, i) => ( + + )) : +
{message.result.uris}
+ } + {message.result.hasNextPage && ( +
+ More results available... +
+ )} +
+
+ ), + 'search': ({ message }) => ( + +
+ {typeof message.result.uris === 'string' ? + message.result.uris : + message.result.uris.map((uri, i) => ( + + )) + } + {message.result.hasNextPage && ( +
+ More results available... +
+ )} +
+
+ ) +}; + + + type ChatBubbleMode = 'display' | 'edit' const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatMessage, messageIdx?: number, isLoading?: boolean, }) => { @@ -695,7 +836,13 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM chatbubbleContents = } else if (role === 'tool') { - chatbubbleContents = chatMessage.name + + const ToolComponent = toolResultToComponent[chatMessage.name] as ({ message }: { message: any }) => React.ReactNode // ts isnt smart enough to deal with the types here... + + chatbubbleContents = + + console.log('tool result:', chatMessage.name, chatMessage.params, chatMessage.result) + } return
= keyof typeof voidTools[T]['params'] export type ToolParamsObj = { [paramName in ToolParamNames]: unknown } +export type ToolCallReturnType = { + 'read_file': { uri: URI, fileContents: string, hasNextPage: boolean }, + 'list_dir': { rootURI: URI, children: DirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number }, + 'pathname_search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean }, + 'search': { queryStr: string, uris: URI[] | string, hasNextPage: boolean } + 'create_file': {} +} -export type ToolCallReturnType - = T extends 'read_file' ? string - : T extends 'list_dir' ? string - : T extends 'pathname_search' ? string | URI[] - : T extends 'search' ? string | URI[] - : T extends 'create_file' ? string - : never +type DirectoryItem = { + name: string; + isDirectory: boolean; + isSymbolicLink: boolean; +} -export type ToolFns = { [T in ToolName]: (p: string) => Promise<[ToolCallReturnType, boolean]> } -export type ToolResultToString = { [T in ToolName]: (result: [ToolCallReturnType, boolean]) => string } +export type ToolFns = { [T in ToolName]: (p: string) => Promise } +export type ToolResultToString = { [T in ToolName]: (result: ToolCallReturnType[T]) => string } // pagination info const MAX_FILE_CHARS_PAGE = 50_000 const MAX_CHILDREN_URIs_PAGE = 500 -const MAX_DEPTH = 1 -async function generateDirectoryTreeMd(fileService: IFileService, rootURI: URI, pageNumber: number): Promise<[string, boolean]> { - let output = ''; - const indentation = (depth: number, isLast: boolean): string => { - if (depth === 0) return ''; - return `${'| '.repeat(depth - 1)}${isLast ? '└── ' : '├── '}`; - }; - - let hasNextPage = false - - async function traverseChildren(uri: URI, depth: number, isLast: boolean) { - const stat = await fileService.resolve(uri, { resolveMetadata: false }); - - // we might want to say where symlink links to - if (depth === 0 && pageNumber !== 1) - output += '' - else - output += `${indentation(depth, isLast)}${stat.name}${stat.isDirectory ? '/' : ''}${stat.isSymbolicLink ? ` (symbolic link)` : ''}\n`; - - // list children - const originalChildrenLength = stat.children?.length ?? 0 - const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1) - const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1 // INCLUSIVE - const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? []; - - if (!stat.isDirectory) return; - - if (listChildren.length === 0) return - if (depth === MAX_DEPTH) return // right now MAX_DEPTH=1 to make pagination work nicely - - for (let i = 0; i < Math.min(listChildren.length, MAX_CHILDREN_URIs_PAGE); i++) { - await traverseChildren(listChildren[i].resource, depth + 1, i === listChildren.length - 1); - } - const nCutoffResults = (originalChildrenLength - 1) - toChildIdx - if (nCutoffResults >= 1) { - output += `${indentation(depth + 1, true)}(${nCutoffResults} results remaining...)\n` - hasNextPage = true - } +const computeDirectoryResult = async ( + fileService: IFileService, + rootURI: URI, + pageNumber: number = 1 +): Promise => { + const stat = await fileService.resolve(rootURI, { resolveMetadata: false }); + if (!stat.isDirectory) { + return { rootURI, children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 }; } - await traverseChildren(rootURI, 0, false); + const originalChildrenLength = stat.children?.length ?? 0; + const fromChildIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1); + const toChildIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1; // INCLUSIVE + const listChildren = stat.children?.slice(fromChildIdx, toChildIdx + 1) ?? []; + + const children: DirectoryItem[] = listChildren.map(child => ({ + name: child.name, + isDirectory: child.isDirectory, + isSymbolicLink: child.isSymbolicLink || false + })); + + const hasNextPage = (originalChildrenLength - 1) > toChildIdx; + const hasPrevPage = pageNumber > 1; + const itemsRemaining = Math.max(0, originalChildrenLength - (toChildIdx + 1)); + + return { + rootURI, + children, + hasNextPage, + hasPrevPage, + itemsRemaining + }; +}; + +const directoryResultToString = (result: ToolCallReturnType['list_dir']): string => { + if (!result.children) { + return `Error: ${result.rootURI} is not a directory`; + } + + let output = ''; + const entries = result.children; + + if (!result.hasPrevPage) { + output += `${result.rootURI}\n`; + } + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const isLast = i === entries.length - 1 && !result.hasNextPage; + const prefix = isLast ? '└── ' : '├── '; + + output += `${prefix}${entry.name}${entry.isDirectory ? '/' : ''}${entry.isSymbolicLink ? ' (symbolic link)' : ''}\n`; + } + + if (result.hasNextPage) { + output += `└── (${result.itemsRemaining} results remaining...)\n`; + } + + return output; +}; + + - return [output, hasNextPage] -} const validateJSON = (s: string): { [s: string]: unknown } => { @@ -217,6 +241,8 @@ export class ToolsService implements IToolsService { this.toolFns = { read_file: async (s: string) => { + console.log('read_file') + const o = validateJSON(s) const { uri: uriStr, pageNumber: pageNumberUnknown } = o @@ -227,22 +253,30 @@ export class ToolsService implements IToolsService { const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1) const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1 - let fileContents = readFileContents.slice(fromIdx, toIdx + 1) // paginate + const fileContents = readFileContents.slice(fromIdx, toIdx + 1) || '(empty)' // paginate const hasNextPage = (readFileContents.length - 1) - toIdx >= 1 - return [fileContents || '(empty)', hasNextPage] + + console.log('read_file result:', fileContents) + + + return { uri, fileContents, hasNextPage } }, list_dir: async (s: string) => { + console.log('list_dir') const o = validateJSON(s) const { uri: uriStr, pageNumber: pageNumberUnknown } = o const uri = validateURI(uriStr) const pageNumber = validatePageNum(pageNumberUnknown) - const [treeStr, hasNextPage] = await generateDirectoryTreeMd(fileService, uri, pageNumber) - return [treeStr, hasNextPage] + const dirResult = await computeDirectoryResult(fileService, uri, pageNumber) + console.log('list_dir result:', dirResult) + + return dirResult }, pathname_search: async (s: string) => { + console.log('pathname_search') const o = validateJSON(s) const { query: queryUnknown, pageNumber: pageNumberUnknown } = o @@ -254,15 +288,18 @@ export class ToolsService implements IToolsService { const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1) const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1 - const URIs = data.results + const uris = data.results .slice(fromIdx, toIdx + 1) // paginate .map(({ resource, results }) => resource) const hasNextPage = (data.results.length - 1) - toIdx >= 1 + console.log('pathname_search result:', uris) - return [URIs, hasNextPage] + return { queryStr, uris, hasNextPage } }, search: async (s: string) => { + console.log('search') + const o = validateJSON(s) const { query: queryUnknown, pageNumber: pageNumberUnknown } = o @@ -274,35 +311,37 @@ export class ToolsService implements IToolsService { const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1) const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1 - const URIs = data.results + const uris = data.results .slice(fromIdx, toIdx + 1) // paginate .map(({ resource, results }) => resource) const hasNextPage = (data.results.length - 1) - toIdx >= 1 - return [URIs, hasNextPage] + console.log('search result:', uris) + + return { queryStr, uris, hasNextPage } }, } - const nextPageStr = (hasNextPage: boolean) => hasNextPage ? '\n\n(more on next page...)' : '' this.toolResultToString = { - read_file: ([fileContents, hasNextPage]) => { - return fileContents + nextPageStr(hasNextPage) + read_file: (result) => { + return nextPageStr(result.hasNextPage) }, - list_dir: ([dirTreeStr, hasNextPage]) => { - return dirTreeStr + nextPageStr(hasNextPage) + list_dir: (result) => { + const dirTreeStr = directoryResultToString(result) + return dirTreeStr + nextPageStr(result.hasNextPage) }, - pathname_search: ([URIs, hasNextPage]) => { - if (typeof URIs === 'string') return URIs - return URIs.map(uri => uri.fsPath).join('\n') + nextPageStr(hasNextPage) + pathname_search: (result) => { + if (typeof result.uris === 'string') return result.uris + return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage) }, - search: ([URIs, hasNextPage]) => { - if (typeof URIs === 'string') return URIs - return URIs.map(uri => uri.fsPath).join('\n') + nextPageStr(hasNextPage) + search: (result) => { + if (typeof result.uris === 'string') return result.uris + return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage) }, } @@ -314,4 +353,3 @@ export class ToolsService implements IToolsService { } registerSingleton(IToolsService, ToolsService, InstantiationType.Eager); -