diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 56308a40..e0032dae 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -34,6 +34,8 @@ import { IEditCodeService } from './editCodeServiceInterface.js'; import { VoidFileSnapshot } from '../common/editCodeServiceTypes.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IModelService } from '../../../../editor/common/services/model.js'; +import { IDirectoryStrService } from './directoryStrService.js'; +import { truncate } from '../../../../base/common/strings.js'; /* @@ -245,7 +247,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { @IEditCodeService private readonly _editCodeService: IEditCodeService, @INotificationService private readonly _notificationService: INotificationService, @IModelService private readonly _modelService: IModelService, - + @IDirectoryStrService private readonly _directoryStrService: IDirectoryStrService, ) { super() this.state = { allThreads: {}, currentThreadId: null as unknown as string } // default state @@ -582,8 +584,15 @@ class ChatThreadService extends Disposable implements IChatThreadService { const openedURIs = this._modelService.getModels().filter(m => m.isAttachedToEditor()).map(m => m.uri.fsPath) || []; const activeURI = this._editorService.activeEditor?.resource?.fsPath; + const { wasCutOff, str: directoryStr_ } = await this._directoryStrService.getAllDirectoriesStr() + + const directoryStr = wasCutOff ? ( + chatMode === 'agent' || chatMode === 'gather' ? `${directoryStr_}\nString cut off, use tools to read more.` + : `${directoryStr_}\nString cut off, ask user for more if necessary.` + ) : directoryStr_ + const runningTerminalIds = this._terminalToolService.listTerminalIds() - const systemMessage = chat_systemMessage({ workspaceFolders, openedURIs, activeURI, runningTerminalIds, chatMode }) + const systemMessage = chat_systemMessage({ workspaceFolders, openedURIs, directoryStr, activeURI, runningTerminalIds, chatMode }) // all messages so far in the chat history (including tools) const messages: LLMChatMessage[] = [ @@ -1052,22 +1061,23 @@ We only need to do it for files that were edited since `from`, ie files between private _wrapRunAgentToNotify(p: Promise, threadId: string) { - const notify = (error: string | null) => { + const notify = ({ error }: { error: string | null }) => { const thread = this.state.allThreads[threadId] if (!thread) return const userMsg = findLast(thread.messages, m => m.role === 'user') if (!userMsg) return if (userMsg.role !== 'user') return - const messageContent = userMsg.displayContent.substring(0, 50) + const messageContent = truncate(userMsg.displayContent, 50, '...') this._notificationService.notify({ severity: error ? Severity.Warning : Severity.Info, - message: error ? `Error: ${error} ` : `Task Complete!\n${messageContent}...`, + message: error ? `Error: ${error} ` : `A new Chat result is ready.`, + source: messageContent, actions: { - secondary: [{ + primary: [{ id: 'void.goToChat', enabled: true, - label: `View`, + label: `Jump to Chat`, tooltip: '', class: undefined, run: () => { @@ -1080,10 +1090,9 @@ We only need to do it for files that were edited since `from`, ie files between } p.then(() => { - notify(null) - + if (threadId !== this.state.currentThreadId) notify({ error: null }) }).catch((e) => { - notify(getErrorMessage(e)) + if (threadId !== this.state.currentThreadId) notify({ error: getErrorMessage(e) }) throw e }) } diff --git a/src/vs/workbench/contrib/void/browser/directoryStrService.ts b/src/vs/workbench/contrib/void/browser/directoryStrService.ts new file mode 100644 index 00000000..d1423b04 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/directoryStrService.ts @@ -0,0 +1,318 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { ShallowDirectoryItem, ToolCallParams, ToolResultType } from '../common/toolsServiceTypes.js'; +import { MAX_CHILDREN_URIs_PAGE } from './toolsService.js'; +import { IExplorerService } from '../../files/browser/files.js'; +import { SortOrder } from '../../files/common/files.js'; +import { ExplorerItem } from '../../files/common/explorerModel.js'; +import { VoidDirectoryItem } from '../common/directoryStrTypes.js'; + + +const MAX_CHARS_TOTAL_BEGINNING = 20_000 +const MAX_CHARS_TOTAL_TOOL = 20_000 +// const MAX_FILES_TOTAL = 200 + + +export interface IDirectoryStrService { + readonly _serviceBrand: undefined; + + getDirectoryStrTool(uri: URI): Promise<{ wasCutOff: boolean, str: string }> + getAllDirectoriesStr(): Promise<{ wasCutOff: boolean, str: string }> + +} +export const IDirectoryStrService = createDecorator('voidDirectoryStrService'); + + + + +// Check if it's a known filtered type like .git +const shouldExcludeDirectory = (item: ExplorerItem) => { + if (item.name === '.git' || + item.name === 'node_modules' || + item.name.startsWith('.') || + item.name === 'dist' || + item.name === 'build' || + item.name === 'out' || + item.name === 'bin' || + item.name === 'coverage' || + item.name === '__pycache__' || + item.name === 'env' || + item.name === 'venv' || + item.name === 'tmp' || + item.name === 'temp' || + item.name === 'artifacts' || + item.name === 'target' || + item.name === 'obj' || + item.name === 'vendor' || + item.name === 'logs' || + item.name === 'cache' + + ) { + return true; + } + return false; +} + +// ---------- ONE LAYER DEEP ---------- + +export const computeDirectoryTree1Deep = async ( + fileService: IFileService, + rootURI: URI, + pageNumber: number = 1, +): Promise => { + const stat = await fileService.resolve(rootURI, { resolveMetadata: false }); + if (!stat.isDirectory) { + return { children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 }; + } + + const nChildren = 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: ShallowDirectoryItem[] = listChildren?.map(child => ({ + name: child.name, + uri: child.resource, + isDirectory: child.isDirectory, + isSymbolicLink: child.isSymbolicLink + })) ?? []; + + const hasNextPage = (nChildren - 1) > toChildIdx; + const hasPrevPage = pageNumber > 1; + const itemsRemaining = Math.max(0, nChildren - (toChildIdx + 1)); + + return { + children, + hasNextPage, + hasPrevPage, + itemsRemaining + }; +}; + +export const stringifyDirectoryTree1Deep = (params: ToolCallParams['list_dir'], result: ToolResultType['list_dir']): string => { + if (!result.children) { + return `Error: ${params.rootURI} is not a directory`; + } + + let output = ''; + const entries = result.children; + + if (!result.hasPrevPage) { // is first page + output += `${params.rootURI.fsPath}\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; +}; + + +// ---------- IN GENERAL ---------- + + +// if the filter exists use it to filter out files and folders when creating the tree +const computeDirectoryTree = async ( + eItem: ExplorerItem, + explorerService: IExplorerService +): Promise => { + // Fetch children with default sort order + const eChildren = await eItem.fetchChildren(SortOrder.FilesFirst); + + const isGitIgnoredDirectory = eItem.isDirectory && shouldExcludeDirectory(eItem) + + // Process children recursively + const children = !isGitIgnoredDirectory ? await Promise.all( + eChildren.map(async c => await computeDirectoryTree(c, explorerService)) + ) : null + + // Create our directory item + const item: VoidDirectoryItem = { + uri: eItem.resource, + name: eItem.name, + isDirectory: eItem.isDirectory, + isSymbolicLink: eItem.isSymbolicLink, + children, + isGitIgnoredDirectory: isGitIgnoredDirectory && { numChildren: eItem.children.size }, + }; + + return item; +}; + + +const stringifyDirectoryTree = ( + node: VoidDirectoryItem, + MAX_CHARS: number, +): { content: string, wasCutOff: boolean } => { + let content = ''; + let wasCutOff = false; + + // If we're already exceeding the max characters, return immediately + if (MAX_CHARS <= 0) { + return { content, wasCutOff: true }; + } + + // Add the root node first (without tree characters) + const nodeLine = `${node.name}${node.isDirectory ? '/' : ''}${node.isSymbolicLink ? ' (symbolic link)' : ''}\n`; + + if (nodeLine.length > MAX_CHARS) { + return { content: '', wasCutOff: true }; + } + + content += nodeLine; + let remainingChars = MAX_CHARS - nodeLine.length; + + // Then recursively add all children with proper tree formatting + if (node.children && node.children.length > 0) { + const { childrenContent, childrenCutOff } = renderChildren( + node.children, + remainingChars, + '' + ); + content += childrenContent; + wasCutOff = childrenCutOff; + } + return { content, wasCutOff }; +}; + +// Helper function to render children with proper tree formatting +const renderChildren = ( + children: VoidDirectoryItem[], + maxChars: number, + parentPrefix: string +): { childrenContent: string, childrenCutOff: boolean } => { + let childrenContent = ''; + let childrenCutOff = false; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const isLast = i === children.length - 1; + + // Create the tree branch symbols + const branchSymbol = isLast ? '└── ' : '├── '; + const childLine = `${parentPrefix}${branchSymbol}${child.name}${child.isDirectory ? '/' : ''}${child.isSymbolicLink ? ' (symbolic link)' : ''}\n`; + + // Check if adding this line would exceed the limit + if (childrenContent.length + childLine.length > maxChars) { + childrenCutOff = true; + break; + } + childrenContent += childLine; + + const nextLevelPrefix = parentPrefix + (isLast ? ' ' : '│ '); + + + // if gitignored, just say the number of children + if (child.isDirectory && child.isGitIgnoredDirectory && child.isGitIgnoredDirectory.numChildren > 0) { + childrenContent += `${nextLevelPrefix}└── ... (${child.isGitIgnoredDirectory.numChildren} children) ...\n` + } + + // Create the prefix for the next level (continuation line or space) + else if (child.children && child.children.length > 0) { + + const { + childrenContent: grandChildrenContent, + childrenCutOff: grandChildrenCutOff + } = renderChildren( + child.children, + maxChars, + nextLevelPrefix + ); + + // If adding grandchildren content would exceed the limit + if (childrenContent.length + grandChildrenContent.length > maxChars) { + childrenCutOff = true; + break; + } + + childrenContent += grandChildrenContent; + + if (grandChildrenCutOff) { + childrenCutOff = true; + break; + } + } + } + + return { childrenContent, childrenCutOff }; +}; + + +// --------------------------------------------------- + + +class DirectoryStrService extends Disposable implements IDirectoryStrService { + _serviceBrand: undefined; + + constructor( + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IExplorerService private readonly explorerService: IExplorerService, + ) { + super(); + } + + async getDirectoryStrTool(uri: URI) { + const eRoot = this.explorerService.findClosest(uri) + if (!eRoot) throw new Error(`There was a problem reading the URI: ${uri.fsPath}.`) + + const dirTree = await computeDirectoryTree(eRoot, this.explorerService); + console.log('dirtree', dirTree) + const { content, wasCutOff } = stringifyDirectoryTree(dirTree, MAX_CHARS_TOTAL_TOOL); + + return { + str: `Directory of ${uri.fsPath}:\n${content}`, + wasCutOff, + } + } + + async getAllDirectoriesStr() { + let str: string = ''; + let cutOff = false; + const folders = this.workspaceContextService.getWorkspace().folders; + + for (let i = 0; i < folders.length; i += 1) { + if (i > 0) str += '\n'; + + // this prioritizes filling 1st workspace before any other, etc + const f = folders[i]; + str += `Directory of ${f.uri.fsPath}:\n`; + const rootURI = f.uri; + + const eRoot = this.explorerService.findClosestRoot(rootURI); + if (!eRoot) continue; + + // Use our new approach with direct explorer service + const dirTree = await computeDirectoryTree(eRoot, this.explorerService); + console.log('dirtree', dirTree) + const { content, wasCutOff } = stringifyDirectoryTree(dirTree, MAX_CHARS_TOTAL_BEGINNING - str.length); + str += content; + if (wasCutOff) { + cutOff = true; + break; + } + } + + return { wasCutOff: cutOff, str }; + } +} + +registerSingleton(IDirectoryStrService, DirectoryStrService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/void/browser/directoryTreeService.ts b/src/vs/workbench/contrib/void/browser/directoryTreeService.ts deleted file mode 100644 index d6e6fd06..00000000 --- a/src/vs/workbench/contrib/void/browser/directoryTreeService.ts +++ /dev/null @@ -1,222 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -import * as path from '../../../../base/common/path.js'; -import { URI } from '../../../../base/common/uri.js'; -import { FilesFilter } from '../../files/browser/views/explorerViewer.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IExplorerService } from '../../files/browser/files.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; - -export interface IDirectoryTreeService { - readonly _serviceBrand: undefined; - getDirectoryTreeWithVSCodeIgnores(directoryPath: string): Promise<{ content: string, cutOff: boolean }>; -} - -export const IDirectoryTreeService = createDecorator('voidDirectoryTreeService'); - -class DirectoryTreeService extends Disposable implements IDirectoryTreeService { - _serviceBrand: undefined; - - constructor( - @IFileService private readonly _fileService: IFileService, - @IConfigurationService private readonly _configService: IConfigurationService, - @IEditorService private readonly editorService: IEditorService, - @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, - @IExplorerService private readonly explorerService: IExplorerService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService - ) { - super(); - } - - /** - * Prints a directory structure in a tree-like format, respecting gitignore patterns - * @param directoryPath The path to the directory to print - * @returns Object containing the formatted tree as a string and whether it was cut off - */ - public async getDirectoryTreeWithVSCodeIgnores(directoryPath: string): Promise<{ content: string, cutOff: boolean }> { - // Create a files filter instance - const filesFilter = new FilesFilter( - this.workspaceContextService, - this._configService, - this.explorerService, - this.editorService, - this.uriIdentityService, - this._fileService - ); - - const isPathIgnored = this.createVSCodeIgnoreCheck( - directoryPath, - filesFilter, - ); - - const MAX_CHARS = 20_000; - const result = await this.printDirectoryTree(this._fileService, directoryPath, '', isPathIgnored, MAX_CHARS); - - return { - content: result.content, - cutOff: result.cutOff - }; - } - - /** - * Prints a directory structure in a tree-like format, respecting gitignore patterns - * @param fileService The file service to use - * @param directoryPath The path to the directory to print - * @param indent Optional indentation for nested calls - * @param isPathIgnored Optional function to check if a path is ignored - * @param maxChars Maximum number of characters before cutting off - * @returns Object containing the formatted tree and cut-off status - */ - private async printDirectoryTree( - fileService: IFileService, - directoryPath: string, - indent: string = '', - isPathIgnored?: (path: string, isDirectory: boolean) => boolean, - maxChars: number = Infinity - ): Promise<{ content: string, cutOff: boolean }> { - let resolve: (result: { content: string, cutOff: boolean }) => void = () => undefined - const p = new Promise<{ content: string, cutOff: boolean }>((res) => { resolve = res }); - - try { - const directoryUri = URI.file(directoryPath); - const stat = await fileService.resolve(directoryUri); - if (!stat.isDirectory) { - resolve({ content: '', cutOff: false }); - return p; - } - - // For root level only - let result = ''; - let cutOff = false; - - if (indent === '') { - const baseName = path.basename(directoryPath); - result += baseName + '\n'; - - if (result.length >= maxChars) { - resolve({ content: result.substring(0, maxChars), cutOff: true }); - return p; - } - } - - // Separate directories and files - const directories: string[] = []; - const files: string[] = []; - - for (const entry of stat.children || []) { - const itemPath = entry.resource.fsPath; - const isDirectory = entry.isDirectory; - - // Skip ignored files/folders if isPathIgnored is provided - if (isPathIgnored && isPathIgnored(itemPath, isDirectory)) { - continue; - } - - if (isDirectory) { - directories.push(entry.name); - } else { - files.push(entry.name); - } - } - - // Process directories first, then files - const sortedItems = [...directories.sort(), ...files.sort()]; - - // Process each visible item - for (let i = 0; i < sortedItems.length; i++) { - // Check if we've reached the character limit - if (result.length >= maxChars) { - cutOff = true; - break; - } - - const item = sortedItems[i]; - const isLast = i === sortedItems.length - 1; - const itemPath = path.join(directoryPath, item); - const isDirectory = directories.includes(item); - - // Add the current item to the result - const itemLine = `${indent}|--${item}\n`; - - // Check if adding this line would exceed the limit - if (result.length + itemLine.length > maxChars) { - result += itemLine.substring(0, maxChars - result.length); - cutOff = true; - break; - } - - result += itemLine; - - // Recursively process directories - if (isDirectory) { - // Next level indentation - const childIndent = `${indent}${isLast ? ' ' : '| '}`; - const childResult = await this.printDirectoryTree( - fileService, - itemPath, - childIndent, - isPathIgnored, - maxChars - result.length - ); - - result += childResult.content; - - if (childResult.cutOff) { - cutOff = true; - break; - } - } - } - - resolve({ content: result, cutOff }); - } catch (error) { - const errorMessage = `Error: ${error.message}\n`; - const cutOff = errorMessage.length > maxChars; - resolve({ - content: cutOff ? errorMessage.substring(0, maxChars) : errorMessage, - cutOff - }); - } - return p; - } - - /** - * Creates a function that checks if a path should be ignored based on VS Code's FilesFilter - * @param directoryPath Root directory path - * @param filesFilter VS Code's FilesFilter instance - * @param fileService VS Code's FileService instance - * @param configService VS Code's ConfigurationService instance - * @param filesConfigService VS Code's FilesConfigurationService instance - * @returns A function that checks if a path is ignored - */ - private createVSCodeIgnoreCheck( - directoryPath: string, - filesFilter: FilesFilter, - ): (path: string, isDirectory: boolean) => boolean { - // Create a workspace folder URI (root explorer item) - const workspaceUri = URI.file(directoryPath); - - return (itemPath: string, isDirectory: boolean): boolean => { - try { - const itemUri = URI.file(itemPath); - - // Use FilesFilter.isIgnored to check if the item should be hidden based on VS Code's excludes - return filesFilter.isIgnored(itemUri, workspaceUri, isDirectory); - } catch (error) { - console.error(`Error checking if path is ignored: ${itemPath}`, error); - return false; - } - }; - } -} - -registerSingleton(IDirectoryTreeService, DirectoryTreeService, InstantiationType.Delayed); 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 d4a15157..5083a79c 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 @@ -1180,6 +1180,7 @@ const folderFileStr = (isFolder: boolean) => isFolder ? 'folder' : 'file' const titleOfToolName = { 'read_file': { done: 'Read file', proposed: 'Read file', running: loadingTitleWrapper('Reading file') }, 'list_dir': { done: 'Inspected folder', proposed: 'Inspect folder', running: loadingTitleWrapper('Inspecting folder') }, + 'list_dir_recursive': { done: 'Inspected folder', proposed: 'Inspect folder', running: loadingTitleWrapper('Inspecting folder') }, 'pathname_search': { done: 'Searched by file name', proposed: 'Search by file name', running: loadingTitleWrapper('Searching by file name') }, 'grep_search': { done: 'Searched', proposed: 'Search', running: loadingTitleWrapper('Searching') }, 'create_uri': { @@ -1392,7 +1393,7 @@ const toolNameToComponent: { [T in ToolName]: ToolComponent } = { componentParams.onClick = () => { commandService.executeCommand('vscode.open', params.uri, { preview: true }) } if (value.hasNextPage && params.pageNumber === 1) // first page componentParams.desc2 = '(more content available)' - else if (params.pageNumber >= 1) // subsequent pages + else if (params.pageNumber > 1) // subsequent pages componentParams.desc2 = `(part ${params.pageNumber})` } else { @@ -1408,6 +1409,47 @@ const toolNameToComponent: { [T in ToolName]: ToolComponent } = { return }, }, + 'list_dir_recursive': { + requestWrapper: null, + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + + const title = toolMessage.result.type === 'success' ? titleOfToolName[toolMessage.name].done : titleOfToolName[toolMessage.name].proposed + const desc1 = toolNameToDesc(toolMessage.name, toolMessage.result.params) + const icon = null + + if (toolMessage.result.type === 'rejected') return null // will never happen, not rejectable + + const isError = toolMessage.result.type === 'error' + const componentParams: ToolHeaderParams = { title, desc1, isError, icon } + + if (toolMessage.result.type === 'success') { + const { value, params } = toolMessage.result + componentParams.children = + + + + + } + else { + const { value, params } = toolMessage.result + componentParams.children = + + {value} + + + } + + return + + } + }, 'list_dir': { requestWrapper: null, resultWrapper: ({ toolMessage }) => { diff --git a/src/vs/workbench/contrib/void/browser/toolsService.ts b/src/vs/workbench/contrib/void/browser/toolsService.ts index 636a62b8..dd30ed71 100644 --- a/src/vs/workbench/contrib/void/browser/toolsService.ts +++ b/src/vs/workbench/contrib/void/browser/toolsService.ts @@ -8,11 +8,12 @@ import { QueryBuilder } from '../../../services/search/common/queryBuilder.js' import { ISearchService } from '../../../services/search/common/search.js' import { IEditCodeService } from './editCodeServiceInterface.js' import { ITerminalToolService } from './terminalToolService.js' -import { ToolCallParams, ToolDirectoryItem, ToolName, ToolResultType } from '../common/toolsServiceTypes.js' +import { ToolCallParams, ToolName, ToolResultType } from '../common/toolsServiceTypes.js' import { IVoidModelService } from '../common/voidModelService.js' import { EndOfLinePreference } from '../../../../editor/common/model.js' import { basename } from '../../../../base/common/path.js' import { IVoidCommandBarService } from './voidCommandBarService.js' +import { computeDirectoryTree1Deep, IDirectoryStrService, stringifyDirectoryTree1Deep } from './directoryStrService.js' // tool use for AI @@ -28,77 +29,14 @@ type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: Tool // pagination info -const MAX_FILE_CHARS_PAGE = 50_000 -const MAX_CHILDREN_URIs_PAGE = 500 +export const MAX_FILE_CHARS_PAGE = 50_000 +export const MAX_CHILDREN_URIs_PAGE = 500 export const MAX_TERMINAL_CHARS_PAGE = 20_000 export const TERMINAL_TIMEOUT_TIME = 15 export const TERMINAL_BG_WAIT_TIME = 1 -const computeDirectoryResult = async ( - fileService: IFileService, - rootURI: URI, - pageNumber: number = 1 -): Promise => { - const stat = await fileService.resolve(rootURI, { resolveMetadata: false }); - if (!stat.isDirectory) { - return { children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 }; - } - - 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: ToolDirectoryItem[] = listChildren.map(child => ({ - name: child.name, - uri: child.resource, - isDirectory: child.isDirectory, - isSymbolicLink: child.isSymbolicLink - })); - - const hasNextPage = (originalChildrenLength - 1) > toChildIdx; - const hasPrevPage = pageNumber > 1; - const itemsRemaining = Math.max(0, originalChildrenLength - (toChildIdx + 1)); - - return { - children, - hasNextPage, - hasPrevPage, - itemsRemaining - }; -}; - -const directoryResultToString = (params: ToolCallParams['list_dir'], result: ToolResultType['list_dir']): string => { - if (!result.children) { - return `Error: ${params.rootURI} is not a directory`; - } - - let output = ''; - const entries = result.children; - - if (!result.hasPrevPage) { // is first page - output += `${params.rootURI.fsPath}\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; -}; - - - const validateJSON = (s: string): { [s: string]: unknown } => { @@ -195,6 +133,7 @@ export class ToolsService implements IToolsService { @IEditCodeService editCodeService: IEditCodeService, @ITerminalToolService private readonly terminalToolService: ITerminalToolService, @IVoidCommandBarService private readonly commandBarService: IVoidCommandBarService, + @IDirectoryStrService private readonly directoryStrService: IDirectoryStrService, ) { const queryBuilder = instantiationService.createInstance(QueryBuilder); @@ -217,6 +156,12 @@ export class ToolsService implements IToolsService { const pageNumber = validatePageNum(pageNumberUnknown) return { rootURI: uri, pageNumber } }, + list_dir_recursive: async (params: string) => { + const o = validateJSON(params) + const { uri: uriStr, } = o + const uri = validateURI(uriStr) + return { rootURI: uri } + }, pathname_search: async (params: string) => { const o = validateJSON(params) const { query: queryUnknown, pageNumber: pageNumberUnknown } = o @@ -294,10 +239,17 @@ export class ToolsService implements IToolsService { }, list_dir: async ({ rootURI, pageNumber }) => { - const dirResult = await computeDirectoryResult(fileService, rootURI, pageNumber) + const dirResult = await computeDirectoryTree1Deep(fileService, rootURI, pageNumber) return { result: dirResult } }, + list_dir_recursive: async ({ rootURI }) => { + const result = await this.directoryStrService.getDirectoryStrTool(rootURI) + let str = result.str + if (result.wasCutOff) str += '\n(Result was truncated)' + return { result: { str } } + }, + pathname_search: async ({ queryStr, pageNumber }) => { const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), { filePattern: queryStr, @@ -385,9 +337,12 @@ export class ToolsService implements IToolsService { return result.fileContents + nextPageStr(result.hasNextPage) }, list_dir: (params, result) => { - const dirTreeStr = directoryResultToString(params, result) + const dirTreeStr = stringifyDirectoryTree1Deep(params, result) return dirTreeStr // + nextPageStr(result.hasNextPage) // already handles num results remaining }, + list_dir_recursive: (params, result) => { + return result.str + }, pathname_search: (params, result) => { return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage) }, diff --git a/src/vs/workbench/contrib/void/common/directoryStrTypes.ts b/src/vs/workbench/contrib/void/common/directoryStrTypes.ts new file mode 100644 index 00000000..c17c22b6 --- /dev/null +++ b/src/vs/workbench/contrib/void/common/directoryStrTypes.ts @@ -0,0 +1,10 @@ +import { URI } from '../../../../base/common/uri.js'; + +export type VoidDirectoryItem = { + uri: URI; + name: string; + isSymbolicLink: boolean; + children: VoidDirectoryItem[] | null; + isDirectory: boolean; + isGitIgnoredDirectory: false | { numChildren: number }; // if directory is gitignored, we ignore children +} diff --git a/src/vs/workbench/contrib/void/common/prompt/prompts.ts b/src/vs/workbench/contrib/void/common/prompt/prompts.ts index a7166763..10613934 100644 --- a/src/vs/workbench/contrib/void/common/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/common/prompt/prompts.ts @@ -69,6 +69,14 @@ export const voidTools = { }, }, + list_dir_recursive: { + name: 'list_dir_recursive', + description: `Returns a tree diagram of all the files and folders in the URI. If results are large, the given string will be truncated (this will be indicated). If truncated, you should use this tool on a more specific folder, or just use list_dir which supports pagination but is not recursive.`, + params: { + ...uriParam('folder') + } + }, + pathname_search: { name: 'pathname_search', description: `Returns all pathnames that match a given \`find\`-style query (searches ONLY file names). You should use this when looking for a file with a specific name or path. ${paginationHelper.desc}`, @@ -146,7 +154,7 @@ Here's an example of a good description:\n${editToolDescription}.` -export const chat_systemMessage = ({ workspaceFolders, openedURIs, activeURI, runningTerminalIds, chatMode: mode }: { workspaceFolders: string[], openedURIs: string[], activeURI: string | undefined, runningTerminalIds: string[], chatMode: ChatMode }) => `\ +export const chat_systemMessage = ({ workspaceFolders, openedURIs, activeURI, runningTerminalIds, directoryStr, chatMode: mode }: { workspaceFolders: string[], directoryStr: string, openedURIs: string[], activeURI: string | undefined, runningTerminalIds: string[], chatMode: ChatMode }) => `\ You are an expert coding ${mode === 'agent' ? 'agent' : 'assistant'} that runs in the Void code editor. Your job is \ ${mode === 'agent' ? `to help the user develop, run, deploy, and make changes to their codebase. You should ALWAYS bring user's task to completion to the fullest extent possible, calling tools to make all necessary changes.` : mode === 'gather' ? `to search and understand the user's codebase. You MUST use tools to read files and help the user understand the codebase, even if you were initially given files.` @@ -193,14 +201,21 @@ If you think it's appropriate to suggest an edit to a file, then you must descri - The remaining contents should be a brief code description of the change you want to make, with comments like "// ... existing code ..." to condense your writing. - NEVER re-write the whole file, and ALWAYS use comments like "// ... existing code ...". Bias towards writing as little as possible. - Your description will be handed to a dumber, faster model that will quickly apply the change, so it should be clear and concise. -Here's an example of a good code block:\n${fileNameEdit}.\ +Here's an example of a good code block:\n${fileNameEdit}. + +If you write a code block that's related to a specific file, please use the same format as above: +- The first line of the code block must be the FULL PATH of the related file if known. +- The remaining contents of the file should proceed as usual. +\ `} ${/* misc */''} Misc: - Do not make things up. - Do not be lazy. - NEVER re-write the entire file. -- Always wrap any code you produce in triple backticks, and specify a language if possible. For example, ${tripleTick[0]}typescript\n...\n${tripleTick[1]}.\ +- Always wrap any code you produce in triple backticks, and specify a language if possible. For example, ${tripleTick[0]}typescript\n...\n${tripleTick[1]}. +The user's codebase is structured as follows:\n${directoryStr} +\ ` // agent mode doesn't know about 1st line paths yet // - If you wrote triple ticks and ___, then include the file's full path in the first line of the triple ticks. This is only for display purposes to the user, and it's preferred but optional. Never do this in a tool parameter, or if there's ambiguity about the full path. diff --git a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts index 90d34311..bb746eed 100644 --- a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts @@ -2,18 +2,18 @@ import { URI } from '../../../../base/common/uri.js' import { voidTools } from './prompt/prompts.js'; -export type ToolDirectoryItem = { + + +export type TerminalResolveReason = { type: 'toofull' | 'timeout' | 'bgtask' } | { type: 'done', exitCode: number } + +// Partial of IFileStat +export type ShallowDirectoryItem = { uri: URI; name: string; isDirectory: boolean; isSymbolicLink: boolean; } - -export type TerminalResolveReason = { type: 'toofull' | 'timeout' | 'bgtask' } | { type: 'done', exitCode: number } - - - // we do this using Anthropic's style and convert to OpenAI style later export type InternalToolInfo = { name: string, @@ -43,6 +43,7 @@ export const toolNamesThatRequireApproval = new Set(toolNamesWithAppro export type ToolCallParams = { 'read_file': { uri: URI, pageNumber: number }, 'list_dir': { rootURI: URI, pageNumber: number }, + 'list_dir_recursive': { rootURI: URI }, 'pathname_search': { queryStr: string, pageNumber: number }, 'grep_search': { queryStr: string, pageNumber: number }, // --- @@ -55,7 +56,8 @@ export type ToolCallParams = { export type ToolResultType = { 'read_file': { fileContents: string, hasNextPage: boolean }, - 'list_dir': { children: ToolDirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number }, + 'list_dir': { children: ShallowDirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number }, + 'list_dir_recursive': { str: string, }, 'pathname_search': { uris: URI[], hasNextPage: boolean }, 'grep_search': { uris: URI[], hasNextPage: boolean }, // ---